diff --git a/backend/internal/modules/adminfinance/handler.go b/backend/internal/modules/adminfinance/handler.go index 92ca89d..4f6682f 100644 --- a/backend/internal/modules/adminfinance/handler.go +++ b/backend/internal/modules/adminfinance/handler.go @@ -2,6 +2,7 @@ package adminfinance import ( "errors" + "fmt" "net/http" "strconv" "strings" @@ -292,6 +293,45 @@ func parseOperatingExpenseQuery(c *gin.Context) (OperatingExpenseQuery, bool) { return query, true } +func parseFlexibleDateTime(raw string, loc *time.Location, isEnd bool) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, errors.New("empty time string") + } + formats := []string{ + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006-01-02T15:04:05", + "2006-01-02T15:04", + time.RFC3339, + "2006-01-02", + } + for _, fmtStr := range formats { + if t, err := time.ParseInLocation(fmtStr, raw, loc); err == nil { + switch fmtStr { + case "2006-01-02": + if isEnd { + return t.AddDate(0, 0, 1).Add(-time.Nanosecond), nil + } + return t, nil + case "2006-01-02 15:04", "2006-01-02T15:04": + if isEnd { + return t.Add(time.Minute).Add(-time.Nanosecond), nil + } + return t, nil + case "2006-01-02 15:04:05", "2006-01-02T15:04:05": + if isEnd { + return t.Add(time.Second - time.Nanosecond), nil + } + return t, nil + default: + return t, nil + } + } + } + return time.Time{}, fmt.Errorf("invalid time format: %s", raw) +} + func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) { loc := timeutil.ShanghaiLocation() today := timeutil.ShanghaiNow() @@ -300,24 +340,49 @@ func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Ti start := defaultStart end := defaultEnd - if raw := c.Query("start_date"); raw != "" { - parsed, err := time.ParseInLocation("2006-01-02", raw, loc) + + rawStart := strings.TrimSpace(c.Query("start_time")) + usingStartTime := rawStart != "" + if rawStart == "" { + rawStart = strings.TrimSpace(c.Query("start_date")) + } + if rawStart != "" { + parsed, err := parseFlexibleDateTime(rawStart, loc, false) if err != nil { - response.BadRequest(c, "开始日期不正确") + if usingStartTime { + response.BadRequest(c, "开始时间不正确") + } else { + response.BadRequest(c, "开始日期不正确") + } return start, end, false } start = parsed } - if raw := c.Query("end_date"); raw != "" { - parsed, err := time.ParseInLocation("2006-01-02", raw, loc) + + rawEnd := strings.TrimSpace(c.Query("end_time")) + usingEndTime := rawEnd != "" + if rawEnd == "" { + rawEnd = strings.TrimSpace(c.Query("end_date")) + } + if rawEnd != "" { + parsed, err := parseFlexibleDateTime(rawEnd, loc, true) if err != nil { - response.BadRequest(c, "结束日期不正确") + if usingEndTime { + response.BadRequest(c, "结束时间不正确") + } else { + response.BadRequest(c, "结束日期不正确") + } return start, end, false } - end = parsed.AddDate(0, 0, 1).Add(-time.Nanosecond) + end = parsed } + if start.After(end) { - response.BadRequest(c, "开始日期不能晚于结束日期") + if usingStartTime || usingEndTime { + response.BadRequest(c, "开始时间不能晚于结束时间") + } else { + response.BadRequest(c, "开始日期不能晚于结束日期") + } return start, end, false } return start, end, true diff --git a/backend/internal/modules/adminfinance/handler_test.go b/backend/internal/modules/adminfinance/handler_test.go new file mode 100644 index 0000000..d19b229 --- /dev/null +++ b/backend/internal/modules/adminfinance/handler_test.go @@ -0,0 +1,142 @@ +package adminfinance + +import ( + "net/http" + "net/http/httptest" + "testing" + + "hfb_sys/backend/internal/timeutil" + + "github.com/gin-gonic/gin" +) + +func TestParseFlexibleDateTime(t *testing.T) { + loc := timeutil.ShanghaiLocation() + + // 1. Test YYYY-MM-DD + start, err := parseFlexibleDateTime("2026-09-01", loc, false) + if err != nil { + t.Fatalf("unexpected error for date start: %v", err) + } + if start.Year() != 2026 || start.Month() != 9 || start.Day() != 1 || start.Hour() != 0 || start.Minute() != 0 || start.Second() != 0 { + t.Fatalf("unexpected start date: %v", start) + } + + end, err := parseFlexibleDateTime("2026-09-01", loc, true) + if err != nil { + t.Fatalf("unexpected error for date end: %v", err) + } + if end.Year() != 2026 || end.Month() != 9 || end.Day() != 1 || end.Hour() != 23 || end.Minute() != 59 || end.Second() != 59 { + t.Fatalf("unexpected end date: %v", end) + } + + // 2. Test YYYY-MM-DD HH:mm + startMinute, err := parseFlexibleDateTime("2026-09-01 14:30", loc, false) + if err != nil { + t.Fatalf("unexpected error for minute start: %v", err) + } + if startMinute.Hour() != 14 || startMinute.Minute() != 30 || startMinute.Second() != 0 { + t.Fatalf("unexpected start minute: %v", startMinute) + } + + endMinute, err := parseFlexibleDateTime("2026-09-01 14:30", loc, true) + if err != nil { + t.Fatalf("unexpected error for minute end: %v", err) + } + if endMinute.Hour() != 14 || endMinute.Minute() != 30 || endMinute.Second() != 59 { + t.Fatalf("unexpected end minute: %v", endMinute) + } + + // 3. Test YYYY-MM-DD HH:mm:ss + startSec, err := parseFlexibleDateTime("2026-09-01 14:30:15", loc, false) + if err != nil { + t.Fatalf("unexpected error for second start: %v", err) + } + if startSec.Hour() != 14 || startSec.Minute() != 30 || startSec.Second() != 15 { + t.Fatalf("unexpected start sec: %v", startSec) + } + + endSec, err := parseFlexibleDateTime("2026-09-01 14:30:15", loc, true) + if err != nil { + t.Fatalf("unexpected error for second end: %v", err) + } + if endSec.Hour() != 14 || endSec.Minute() != 30 || endSec.Second() != 15 { + t.Fatalf("unexpected end sec: %v", endSec) + } + + // 4. Test invalid + if _, err := parseFlexibleDateTime("invalid-date", loc, false); err == nil { + t.Fatalf("expected error for invalid date") + } +} + +func TestParseDateRangeGin(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + query string + wantStatus int + wantOk bool + }{ + { + name: "default range", + query: "", + wantStatus: http.StatusOK, + wantOk: true, + }, + { + name: "date range", + query: "?start_date=2026-09-01&end_date=2026-09-07", + wantStatus: http.StatusOK, + wantOk: true, + }, + { + name: "datetime range with minute", + query: "?start_date=2026-09-01%2009:30&end_date=2026-09-07%2018:45", + wantStatus: http.StatusOK, + wantOk: true, + }, + { + name: "datetime range with second", + query: "?start_date=2026-09-01%2009:30:00&end_date=2026-09-07%2018:45:59", + wantStatus: http.StatusOK, + wantOk: true, + }, + { + name: "start_time and end_time params", + query: "?start_time=2026-09-01%2009:30:00&end_time=2026-09-07%2018:45:59", + wantStatus: http.StatusOK, + wantOk: true, + }, + { + name: "start after end", + query: "?start_date=2026-09-07&end_date=2026-09-01", + wantStatus: http.StatusBadRequest, + wantOk: false, + }, + { + name: "invalid date", + query: "?start_date=invalid", + wantStatus: http.StatusBadRequest, + wantOk: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req, _ := http.NewRequest(http.MethodGet, "/test"+tc.query, nil) + c.Request = req + + _, _, ok := parseDateRange(c, 29) + if ok != tc.wantOk { + t.Fatalf("parseDateRange() ok = %v, wantOk = %v", ok, tc.wantOk) + } + if !ok && w.Code != tc.wantStatus { + t.Fatalf("HTTP status = %d, wantStatus = %d", w.Code, tc.wantStatus) + } + }) + } +} diff --git a/frontend/src/features/admin/api/adminFinance.ts b/frontend/src/features/admin/api/adminFinance.ts index 356a3e6..7f17020 100644 --- a/frontend/src/features/admin/api/adminFinance.ts +++ b/frontend/src/features/admin/api/adminFinance.ts @@ -312,6 +312,8 @@ export interface FinanceDetail { export interface FinanceDateQuery { start_date?: string end_date?: string + start_time?: string + end_time?: string } export interface FinanceDetailQuery extends FinanceDateQuery { diff --git a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.css b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.css index a3962ec..fb56dd1 100644 --- a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.css +++ b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.css @@ -239,7 +239,7 @@ .filter-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px 16px; } diff --git a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue index 3251100..faf5ee1 100644 --- a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue @@ -32,7 +32,7 @@ import { useAdminSessionStore } from '@/stores/adminSession' import { adminPath } from '@/shared/utils/adminPath' import { readError } from '@/shared/utils/error' import { formatCentWithSymbol, yuanToCent } from '@/shared/utils/money' -import { formatDateTime, formatInputDate } from '@/shared/utils/time' +import { formatDateTime } from '@/shared/utils/time' import AdminTablePagination from '../components/AdminTablePagination.vue' import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue' @@ -73,10 +73,84 @@ const createForm = reactive({ voucher_url: '', }) +const defaultStartTime = new Date(2000, 0, 1, 0, 0, 0) +const defaultEndTime = new Date(2000, 0, 1, 23, 59, 59) + +const startTimeShortcuts = [ + { + text: '今天 00:00', + value: () => { + const d = new Date() + d.setHours(0, 0, 0, 0) + return d + }, + }, + { + text: '昨天 00:00', + value: () => { + const d = new Date() + d.setDate(d.getDate() - 1) + d.setHours(0, 0, 0, 0) + return d + }, + }, + { + text: '近7天', + value: () => { + const d = new Date() + d.setDate(d.getDate() - 6) + d.setHours(0, 0, 0, 0) + return d + }, + }, + { + text: '近30天', + value: () => { + const d = new Date() + d.setDate(d.getDate() - 29) + d.setHours(0, 0, 0, 0) + return d + }, + }, + { + text: '本月初', + value: () => { + const d = new Date() + d.setDate(1) + d.setHours(0, 0, 0, 0) + return d + }, + }, +] + +const endTimeShortcuts = [ + { + text: '此刻', + value: () => new Date(), + }, + { + text: '今天 23:59', + value: () => { + const d = new Date() + d.setHours(23, 59, 59, 0) + return d + }, + }, + { + text: '昨天 23:59', + value: () => { + const d = new Date() + d.setDate(d.getDate() - 1) + d.setHours(23, 59, 59, 0) + return d + }, + }, +] + const filters = reactive({ date_type: 'created', - start_date: defaultStartDate(), - end_date: formatInputDate(new Date()), + start_date: defaultStartDateTime(), + end_date: defaultEndDateTime(), source_type: '', status: '', business_no: '', @@ -115,8 +189,8 @@ function search() { function resetFilters() { filters.date_type = 'created' - filters.start_date = defaultStartDate() - filters.end_date = formatInputDate(new Date()) + filters.start_date = defaultStartDateTime() + filters.end_date = defaultEndDateTime() filters.source_type = '' filters.status = '' filters.business_no = '' @@ -263,10 +337,17 @@ function emptySummary(): FinanceDisbursementListSummary { } } -function defaultStartDate() { +function defaultStartDateTime() { const date = new Date() date.setDate(date.getDate() - 29) - return formatInputDate(date) + date.setHours(0, 0, 0, 0) + return formatDateTime(date) +} + +function defaultEndDateTime() { + const date = new Date() + date.setHours(23, 59, 59, 0) + return formatDateTime(date) } function money(cents: number | undefined | null) { @@ -446,29 +527,37 @@ function accountTypeLabel(type: string) {
- + - - + + - + - +