From 8646b002c1310a1758b6aa7d29195796dda2d72e Mon Sep 17 00:00:00 2001 From: mlogclub Date: Wed, 29 Apr 2026 09:23:54 +0800 Subject: [PATCH 01/19] feat: add calendar scheduling feature for agent teams - Implemented FindCalendarSchedules method in agentTeamScheduleService to retrieve schedules within a specified time range. - Added new API endpoint fetchAgentTeamScheduleCalendar to fetch schedules for the calendar view. - Created ScheduleCalendar component to display agent team schedules in a calendar format with drag-and-drop functionality for moving and resizing schedules. - Updated EditDialog component to support deleting schedules and handling default values for new entries. - Enhanced DashboardAgentTeamSchedulesPage to toggle between calendar and list views, and to load calendar data based on selected week. - Added tests for FindCalendarSchedules to ensure correct schedule retrieval and validation of time ranges. --- docs | 2 +- .../agent_team_schedule_controller.go | 22 + internal/pkg/dto/request/agent_request.go | 6 + .../agent_team_schedule_repository.go | 16 +- .../services/agent_team_schedule_service.go | 15 + .../agent_team_schedule_service_test.go | 134 ++++++ .../_components/calendar.tsx | 374 +++++++++++++++ .../agent-team-schedules/_components/edit.tsx | 27 +- .../dashboard/agent-team-schedules/page.tsx | 430 ++++++++++++------ web/lib/api/admin.ts | 8 + 10 files changed, 895 insertions(+), 139 deletions(-) create mode 100644 internal/services/agent_team_schedule_service_test.go create mode 100644 web/app/dashboard/agent-team-schedules/_components/calendar.tsx diff --git a/docs b/docs index a4aee85..83ab315 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit a4aee8582df89dad53044d293577e88e1bfd7b49 +Subproject commit 83ab315da694bf5ddf4cd8f2043a796c2893224d diff --git a/internal/controllers/dashboard/agent_team_schedule_controller.go b/internal/controllers/dashboard/agent_team_schedule_controller.go index 58b66bb..1dd741a 100644 --- a/internal/controllers/dashboard/agent_team_schedule_controller.go +++ b/internal/controllers/dashboard/agent_team_schedule_controller.go @@ -31,6 +31,28 @@ func (c *AgentTeamScheduleController) AnyList() *web.JsonResult { return web.JsonData(&web.PageResult{Results: results, Page: paging}) } +func (c *AgentTeamScheduleController) AnyCalendar() *web.JsonResult { + if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionAgentTeamScheduleView); err != nil { + return web.JsonError(err) + } + startAt, _ := params.Get(c.Ctx, "startAt") + endAt, _ := params.Get(c.Ctx, "endAt") + teamID, _ := params.GetInt64(c.Ctx, "teamId") + list, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{ + StartAt: startAt, + EndAt: endAt, + TeamID: teamID, + }) + if err != nil { + return web.JsonError(err) + } + results := make([]response.AgentTeamScheduleResponse, 0, len(list)) + for _, item := range list { + results = append(results, buildAgentTeamScheduleResponse(&item)) + } + return web.JsonData(results) +} + func (c *AgentTeamScheduleController) GetBy(id int64) *web.JsonResult { if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionAgentTeamScheduleView); err != nil { return web.JsonError(err) diff --git a/internal/pkg/dto/request/agent_request.go b/internal/pkg/dto/request/agent_request.go index 7dc118a..95bf539 100644 --- a/internal/pkg/dto/request/agent_request.go +++ b/internal/pkg/dto/request/agent_request.go @@ -62,3 +62,9 @@ type UpdateAgentTeamScheduleRequest struct { type DeleteAgentTeamScheduleRequest struct { ID int64 `json:"id"` } + +type AgentTeamScheduleCalendarRequest struct { + StartAt string `json:"startAt"` + EndAt string `json:"endAt"` + TeamID int64 `json:"teamId"` +} diff --git a/internal/repositories/agent_team_schedule_repository.go b/internal/repositories/agent_team_schedule_repository.go index 52685d9..f201d20 100644 --- a/internal/repositories/agent_team_schedule_repository.go +++ b/internal/repositories/agent_team_schedule_repository.go @@ -2,6 +2,7 @@ package repositories import ( "cs-agent/internal/models" + "time" "github.com/mlogclub/simple/sqls" "github.com/mlogclub/simple/web/params" @@ -38,6 +39,16 @@ func (r *agentTeamScheduleRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []m return } +func (r *agentTeamScheduleRepository) FindByTimeRange(db *gorm.DB, startAt, endAt time.Time, teamID int64) (list []models.AgentTeamSchedule) { + query := db.Model(&models.AgentTeamSchedule{}). + Where("start_at < ? AND end_at > ?", endAt, startAt) + if teamID > 0 { + query = query.Where("team_id = ?", teamID) + } + query.Order("team_id ASC").Order("start_at ASC").Order("id ASC").Find(&list) + return +} + func (r *agentTeamScheduleRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.AgentTeamSchedule { ret := &models.AgentTeamSchedule{} if err := cnd.FindOne(db, &ret); err != nil { @@ -62,12 +73,12 @@ func (r *agentTeamScheduleRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) return } -func (r *agentTeamScheduleRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr... interface{}) (list []models.AgentTeamSchedule) { +func (r *agentTeamScheduleRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (list []models.AgentTeamSchedule) { db.Raw(sqlStr, paramArr...).Scan(&list) return } -func (r *agentTeamScheduleRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr... interface{}) (count int64) { +func (r *agentTeamScheduleRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (count int64) { db.Raw(sqlStr, paramArr...).Count(&count) return } @@ -99,4 +110,3 @@ func (r *agentTeamScheduleRepository) UpdateColumn(db *gorm.DB, id int64, name s func (r *agentTeamScheduleRepository) Delete(db *gorm.DB, id int64) { db.Delete(&models.AgentTeamSchedule{}, "id = ?", id) } - diff --git a/internal/services/agent_team_schedule_service.go b/internal/services/agent_team_schedule_service.go index 416067f..b57f869 100644 --- a/internal/services/agent_team_schedule_service.go +++ b/internal/services/agent_team_schedule_service.go @@ -53,6 +53,21 @@ func (s *agentTeamScheduleService) Count(cnd *sqls.Cnd) int64 { return repositories.AgentTeamScheduleRepository.Count(sqls.DB(), cnd) } +func (s *agentTeamScheduleService) FindCalendarSchedules(req request.AgentTeamScheduleCalendarRequest) ([]models.AgentTeamSchedule, error) { + startAtValue, err := parseRequiredDateTime(req.StartAt, "开始时间格式错误") + if err != nil { + return nil, err + } + endAtValue, err := parseRequiredDateTime(req.EndAt, "结束时间格式错误") + if err != nil { + return nil, err + } + if !endAtValue.After(startAtValue) { + return nil, errorsx.InvalidParam("结束时间必须晚于开始时间") + } + return repositories.AgentTeamScheduleRepository.FindByTimeRange(sqls.DB(), startAtValue, endAtValue, req.TeamID), nil +} + func (s *agentTeamScheduleService) Create(t *models.AgentTeamSchedule) error { return repositories.AgentTeamScheduleRepository.Create(sqls.DB(), t) } diff --git a/internal/services/agent_team_schedule_service_test.go b/internal/services/agent_team_schedule_service_test.go new file mode 100644 index 0000000..3fdf277 --- /dev/null +++ b/internal/services/agent_team_schedule_service_test.go @@ -0,0 +1,134 @@ +package services_test + +import ( + "strings" + "testing" + "time" + + "cs-agent/internal/models" + "cs-agent/internal/pkg/dto/request" + "cs-agent/internal/pkg/enums" + "cs-agent/internal/services" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestAgentTeamScheduleServiceFindCalendarSchedulesReturnsIntersectingSchedules(t *testing.T) { + db := setupAgentTeamScheduleTestDB(t) + createAgentTeamScheduleTestData(t, db) + + list, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{ + StartAt: "2026-04-27 00:00:00", + EndAt: "2026-05-04 00:00:00", + }) + if err != nil { + t.Fatalf("FindCalendarSchedules() error = %v", err) + } + + if len(list) != 3 { + t.Fatalf("expected 3 intersecting schedules, got %d: %+v", len(list), list) + } + gotIDs := make([]int64, 0, len(list)) + for _, item := range list { + gotIDs = append(gotIDs, item.ID) + } + wantIDs := []int64{1, 2, 3} + for i, want := range wantIDs { + if gotIDs[i] != want { + t.Fatalf("expected ids %v, got %v", wantIDs, gotIDs) + } + } +} + +func TestAgentTeamScheduleServiceFindCalendarSchedulesFiltersTeamID(t *testing.T) { + db := setupAgentTeamScheduleTestDB(t) + createAgentTeamScheduleTestData(t, db) + + list, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{ + StartAt: "2026-04-27 00:00:00", + EndAt: "2026-05-04 00:00:00", + TeamID: 2, + }) + if err != nil { + t.Fatalf("FindCalendarSchedules() error = %v", err) + } + + if len(list) != 1 { + t.Fatalf("expected 1 schedule for team 2, got %d: %+v", len(list), list) + } + if list[0].ID != 3 || list[0].TeamID != 2 { + t.Fatalf("unexpected schedule: %+v", list[0]) + } +} + +func TestAgentTeamScheduleServiceFindCalendarSchedulesValidatesTimeRange(t *testing.T) { + setupAgentTeamScheduleTestDB(t) + + _, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{ + StartAt: "2026-05-04 00:00:00", + EndAt: "2026-04-27 00:00:00", + }) + if err == nil { + t.Fatalf("expected invalid time range to fail") + } +} + +func setupAgentTeamScheduleTestDB(t *testing.T) *gorm.DB { + t.Helper() + + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.AgentTeam{}, &models.AgentTeamSchedule{}); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} + +func createAgentTeamScheduleTestData(t *testing.T, db *gorm.DB) { + t.Helper() + + teams := []models.AgentTeam{ + {ID: 1, Name: "售前组", Status: enums.StatusOk}, + {ID: 2, Name: "售后组", Status: enums.StatusOk}, + } + if err := db.Create(&teams).Error; err != nil { + t.Fatalf("create teams error = %v", err) + } + + parse := func(value string) time.Time { + t.Helper() + ret, err := time.ParseInLocation(time.DateTime, value, time.Local) + if err != nil { + t.Fatalf("parse time %q error = %v", value, err) + } + return ret + } + schedules := []models.AgentTeamSchedule{ + {ID: 1, TeamID: 1, StartAt: parse("2026-04-26 20:00:00"), EndAt: parse("2026-04-27 10:00:00"), SourceType: "manual", Status: enums.StatusOk}, + {ID: 2, TeamID: 1, StartAt: parse("2026-04-28 09:00:00"), EndAt: parse("2026-04-28 18:00:00"), SourceType: "manual", Status: enums.StatusOk}, + {ID: 3, TeamID: 2, StartAt: parse("2026-05-03 20:00:00"), EndAt: parse("2026-05-04 08:00:00"), SourceType: "manual", Status: enums.StatusOk}, + {ID: 4, TeamID: 1, StartAt: parse("2026-04-20 09:00:00"), EndAt: parse("2026-04-20 18:00:00"), SourceType: "manual", Status: enums.StatusOk}, + {ID: 5, TeamID: 2, StartAt: parse("2026-05-04 09:00:00"), EndAt: parse("2026-05-04 18:00:00"), SourceType: "manual", Status: enums.StatusOk}, + } + if err := db.Create(&schedules).Error; err != nil { + t.Fatalf("create schedules error = %v", err) + } +} diff --git a/web/app/dashboard/agent-team-schedules/_components/calendar.tsx b/web/app/dashboard/agent-team-schedules/_components/calendar.tsx new file mode 100644 index 0000000..f41cf58 --- /dev/null +++ b/web/app/dashboard/agent-team-schedules/_components/calendar.tsx @@ -0,0 +1,374 @@ +"use client" + +import { CalendarPlusIcon, GripVerticalIcon } from "lucide-react" + +import type { + AdminAgentTeam, + AdminAgentTeamSchedule, + CreateAdminAgentTeamSchedulePayload, + UpdateAdminAgentTeamSchedulePayload, +} from "@/lib/api/admin" +import { cn, formatDateTime } from "@/lib/utils" + +const dayNames = ["一", "二", "三", "四", "五", "六", "日"] +const dayMs = 24 * 60 * 60 * 1000 +const minuteMs = 60 * 1000 +const minDurationMs = 15 * minuteMs + +type ScheduleCalendarProps = { + weekStart: Date + teams: AdminAgentTeam[] + schedules: AdminAgentTeamSchedule[] + loading: boolean + savingId: number | null + onCreate: (defaults: Partial) => void + onEdit: (item: AdminAgentTeamSchedule) => void + onMove: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise + onResize: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise +} + +type DragState = + | { + type: "move" + item: AdminAgentTeamSchedule + startX: number + startY: number + moved: boolean + } + | { + type: "resize" + edge: "start" | "end" + item: AdminAgentTeamSchedule + moved: boolean + } + +function addDays(date: Date, days: number) { + const ret = new Date(date) + ret.setDate(ret.getDate() + days) + return ret +} + +function startOfDay(date: Date) { + const ret = new Date(date) + ret.setHours(0, 0, 0, 0) + return ret +} + +function parseLocalDateTime(value: string) { + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/) + if (!match) { + return new Date(value) + } + return new Date( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + Number(match[4]), + Number(match[5]), + Number(match[6] ?? 0) + ) +} + +function formatDate(date: Date) { + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + return `${date.getFullYear()}-${month}-${day}` +} + +function formatDateTimeValue(date: Date) { + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + const hour = String(date.getHours()).padStart(2, "0") + const minute = String(date.getMinutes()).padStart(2, "0") + const second = String(date.getSeconds()).padStart(2, "0") + return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}:${second}` +} + +function formatDayTitle(date: Date) { + return `${date.getMonth() + 1}/${date.getDate()}` +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +function roundToQuarterHour(date: Date) { + const ret = new Date(date) + ret.setSeconds(0, 0) + const minutes = ret.getHours() * 60 + ret.getMinutes() + const rounded = Math.round(minutes / 15) * 15 + ret.setHours(Math.floor(rounded / 60), rounded % 60, 0, 0) + return ret +} + +function getPointerDateInCell(event: PointerEvent | React.PointerEvent, cell: Element) { + const rect = cell.getBoundingClientRect() + const day = startOfDay(parseLocalDateTime(`${cell.getAttribute("data-date")} 00:00:00`)) + const ratio = clamp((event.clientX - rect.left) / rect.width, 0, 1) + return roundToQuarterHour(new Date(day.getTime() + ratio * dayMs)) +} + +function getDropCell(event: PointerEvent | React.PointerEvent) { + const element = document.elementFromPoint(event.clientX, event.clientY) + return element?.closest("[data-schedule-cell]") +} + +function getCellTeamAndDate(cell: Element) { + const teamID = Number(cell.getAttribute("data-team-id")) + const date = cell.getAttribute("data-date") ?? "" + return { teamID, date } +} + +function buildMovePayload(item: AdminAgentTeamSchedule, teamId: number, date: string): UpdateAdminAgentTeamSchedulePayload { + const originalStart = parseLocalDateTime(item.startAt) + const originalEnd = parseLocalDateTime(item.endAt) + const duration = originalEnd.getTime() - originalStart.getTime() + const nextDay = startOfDay(parseLocalDateTime(`${date} 00:00:00`)) + const nextStart = new Date(nextDay) + nextStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0) + const nextEnd = new Date(nextStart.getTime() + duration) + + return { + id: item.id, + teamId, + startAt: formatDateTimeValue(nextStart), + endAt: formatDateTimeValue(nextEnd), + sourceType: item.sourceType, + remark: item.remark, + } +} + +function buildResizePayload( + item: AdminAgentTeamSchedule, + edge: "start" | "end", + nextTime: Date +): UpdateAdminAgentTeamSchedulePayload | null { + const startAt = parseLocalDateTime(item.startAt) + const endAt = parseLocalDateTime(item.endAt) + if (edge === "start") { + if (endAt.getTime() - nextTime.getTime() < minDurationMs) { + return null + } + startAt.setTime(nextTime.getTime()) + } else { + if (nextTime.getTime() - startAt.getTime() < minDurationMs) { + return null + } + endAt.setTime(nextTime.getTime()) + } + return { + id: item.id, + teamId: item.teamId, + startAt: formatDateTimeValue(startAt), + endAt: formatDateTimeValue(endAt), + sourceType: item.sourceType, + remark: item.remark, + } +} + +function sliceScheduleForDay(item: AdminAgentTeamSchedule, day: Date) { + const dayStart = startOfDay(day) + const dayEnd = addDays(dayStart, 1) + const scheduleStart = parseLocalDateTime(item.startAt) + const scheduleEnd = parseLocalDateTime(item.endAt) + const visibleStart = new Date(Math.max(scheduleStart.getTime(), dayStart.getTime())) + const visibleEnd = new Date(Math.min(scheduleEnd.getTime(), dayEnd.getTime())) + if (!visibleEnd.getTime() || visibleEnd <= visibleStart) { + return null + } + const left = ((visibleStart.getTime() - dayStart.getTime()) / dayMs) * 100 + const width = ((visibleEnd.getTime() - visibleStart.getTime()) / dayMs) * 100 + return { left, width, visibleStart, visibleEnd } +} + +export function ScheduleCalendar({ + weekStart, + teams, + schedules, + loading, + savingId, + onCreate, + onEdit, + onMove, + onResize, +}: ScheduleCalendarProps) { + const days = Array.from({ length: 7 }, (_, index) => startOfDay(addDays(weekStart, index))) + + function handleBlankCellClick(teamId: number, day: Date) { + const startAt = new Date(day) + startAt.setHours(9, 0, 0, 0) + const endAt = new Date(day) + endAt.setHours(18, 0, 0, 0) + onCreate({ + teamId, + startAt: formatDateTimeValue(startAt), + endAt: formatDateTimeValue(endAt), + sourceType: "manual", + remark: "", + }) + } + + function handlePointerDown(event: React.PointerEvent, item: AdminAgentTeamSchedule, type: DragState["type"], edge?: "start" | "end") { + event.preventDefault() + event.stopPropagation() + const target = event.currentTarget as HTMLElement + target.setPointerCapture(event.pointerId) + const state: DragState = + type === "resize" + ? { type: "resize", edge: edge ?? "end", item, moved: false } + : { type: "move", item, startX: event.clientX, startY: event.clientY, moved: false } + + function handlePointerMove(moveEvent: PointerEvent) { + if (state.type === "move") { + if (Math.abs(moveEvent.clientX - state.startX) > 4 || Math.abs(moveEvent.clientY - state.startY) > 4) { + state.moved = true + } + } else { + state.moved = true + } + } + + async function handlePointerUp(upEvent: PointerEvent) { + target.releasePointerCapture(event.pointerId) + window.removeEventListener("pointermove", handlePointerMove) + window.removeEventListener("pointerup", handlePointerUp) + if (!state.moved) { + onEdit(item) + return + } + const cell = getDropCell(upEvent) + if (!cell) { + return + } + if (state.type === "move") { + const next = getCellTeamAndDate(cell) + if (!next.teamID || !next.date) { + return + } + await onMove(buildMovePayload(item, next.teamID, next.date)) + return + } + const payload = buildResizePayload(item, state.edge, getPointerDateInCell(upEvent, cell)) + if (payload) { + await onResize(payload) + } + } + + window.addEventListener("pointermove", handlePointerMove) + window.addEventListener("pointerup", handlePointerUp) + } + + if (teams.length === 0 && !loading) { + return ( +
+ 暂无客服组,无法展示排班日历 +
+ ) + } + + return ( +
+
+
+
客服组
+ {days.map((day, index) => ( +
+
周{dayNames[index]}
+
{formatDayTitle(day)}
+
+ ))} +
+ +
+ {teams.map((team) => ( +
+
+
+
{team.name}
+
组ID:{team.id}
+
+
+ {days.map((day) => { + const date = formatDate(day) + const daySchedules = schedules.filter((item) => item.teamId === team.id && sliceScheduleForDay(item, day)) + return ( + + ) + })} +
+ ))} +
+
+
+ ) +} diff --git a/web/app/dashboard/agent-team-schedules/_components/edit.tsx b/web/app/dashboard/agent-team-schedules/_components/edit.tsx index 3fb9479..dd9b4f7 100644 --- a/web/app/dashboard/agent-team-schedules/_components/edit.tsx +++ b/web/app/dashboard/agent-team-schedules/_components/edit.tsx @@ -35,8 +35,10 @@ type ScheduleEditDialogProps = { open: boolean saving: boolean itemId: number | null + defaultValues?: Partial | null onOpenChange: (open: boolean) => void onSubmit: (payload: CreateAdminAgentTeamSchedulePayload) => Promise + onDelete?: (id: number) => Promise } const sourceTypeOptions = [ @@ -75,9 +77,15 @@ function toDateTimeLocal(value?: string) { return value.replace(" ", "T").slice(0, 16) } -function buildForm(item: AdminAgentTeamSchedule | null): EditForm { +function buildForm(item: AdminAgentTeamSchedule | null, defaultValues?: Partial | null): EditForm { if (!item) { - return emptyForm + return { + teamId: defaultValues?.teamId ? String(defaultValues.teamId) : emptyForm.teamId, + startAt: toDateTimeLocal(defaultValues?.startAt), + endAt: toDateTimeLocal(defaultValues?.endAt), + sourceType: (defaultValues?.sourceType as EditForm["sourceType"] | undefined) ?? emptyForm.sourceType, + remark: defaultValues?.remark ?? emptyForm.remark, + } } return { teamId: String(item.teamId), @@ -102,8 +110,10 @@ export function EditDialog({ open, saving, itemId, + defaultValues, onOpenChange, onSubmit, + onDelete, }: ScheduleEditDialogProps) { return ( @@ -111,9 +121,11 @@ export function EditDialog({ ) : null} @@ -125,8 +137,10 @@ type ScheduleEditDialogBodyProps = Omit function ScheduleEditDialogBody({ saving, itemId, + defaultValues, onOpenChange, onSubmit, + onDelete, }: ScheduleEditDialogBodyProps) { const [teams, setTeams] = useState([]) const [loading, setLoading] = useState(false) @@ -157,7 +171,7 @@ function ScheduleEditDialogBody({ useEffect(() => { async function loadDetail() { if (!itemId) { - reset(emptyForm) + reset(buildForm(null, defaultValues)) return } setLoading(true) @@ -171,7 +185,7 @@ function ScheduleEditDialogBody({ } } void loadDetail() - }, [itemId, reset]) + }, [defaultValues, itemId, reset]) useEffect(() => { void loadOptions() @@ -269,6 +283,11 @@ function ScheduleEditDialogBody({ + {itemId && onDelete ? ( + + ) : null} diff --git a/web/app/dashboard/agent-team-schedules/page.tsx b/web/app/dashboard/agent-team-schedules/page.tsx index 180e690..80c94b2 100644 --- a/web/app/dashboard/agent-team-schedules/page.tsx +++ b/web/app/dashboard/agent-team-schedules/page.tsx @@ -1,8 +1,12 @@ "use client" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { CalendarClockIcon, + CalendarDaysIcon, + ChevronLeftIcon, + ChevronRightIcon, + ListIcon, MoreHorizontalIcon, PlusIcon, RefreshCwIcon, @@ -11,19 +15,7 @@ import { } from "lucide-react" import { toast } from "sonner" -import { - createAgentTeamSchedule, - deleteAgentTeamSchedule, - fetchAgentTeamSchedules, - fetchAgentTeams, - updateAgentTeamSchedule, - type AdminAgentTeam, - type AdminAgentTeamSchedule, - type CreateAdminAgentTeamSchedulePayload, - type PageResult, -} from "@/lib/api/admin" -import { formatDateTime } from "@/lib/utils" -import { EditDialog } from "./_components/edit" +import { ListPagination } from "@/components/list-pagination" import { Button } from "@/components/ui/button" import { ButtonGroup } from "@/components/ui/button-group" import { @@ -32,8 +24,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { Input } from "@/components/ui/input" -import { ListPagination } from "@/components/list-pagination" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Table, @@ -43,23 +33,87 @@ import { TableHeader, TableRow, } from "@/components/ui/table" +import { + createAgentTeamSchedule, + deleteAgentTeamSchedule, + fetchAgentTeamScheduleCalendar, + fetchAgentTeamSchedules, + fetchAgentTeamsAll, + updateAgentTeamSchedule, + type AdminAgentTeam, + type AdminAgentTeamSchedule, + type CreateAdminAgentTeamSchedulePayload, + type PageResult, + type UpdateAdminAgentTeamSchedulePayload, +} from "@/lib/api/admin" +import { formatDateTime } from "@/lib/utils" +import { ScheduleCalendar } from "./_components/calendar" +import { EditDialog } from "./_components/edit" + +type ViewMode = "calendar" | "list" + +function startOfDay(date: Date) { + const ret = new Date(date) + ret.setHours(0, 0, 0, 0) + return ret +} + +function startOfWeek(date: Date) { + const ret = startOfDay(date) + const day = ret.getDay() + const offset = day === 0 ? -6 : 1 - day + ret.setDate(ret.getDate() + offset) + return ret +} + +function addDays(date: Date, days: number) { + const ret = new Date(date) + ret.setDate(ret.getDate() + days) + return ret +} + +function formatDateTimeValue(date: Date) { + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + const hour = String(date.getHours()).padStart(2, "0") + const minute = String(date.getMinutes()).padStart(2, "0") + const second = String(date.getSeconds()).padStart(2, "0") + return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}:${second}` +} + +function formatWeekRange(weekStart: Date) { + const weekEnd = addDays(weekStart, 6) + return `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, "0")}-${String(weekStart.getDate()).padStart(2, "0")} 至 ${String(weekEnd.getMonth() + 1).padStart(2, "0")}-${String(weekEnd.getDate()).padStart(2, "0")}` +} export default function DashboardAgentTeamSchedulesPage() { + const [viewMode, setViewMode] = useState("calendar") const [teamFilterInput, setTeamFilterInput] = useState("all") const [teamFilter, setTeamFilter] = useState("all") + const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date())) const [page, setPage] = useState(1) const [limit, setLimit] = useState(20) const [loading, setLoading] = useState(true) + const [calendarLoading, setCalendarLoading] = useState(true) const [saving, setSaving] = useState(false) const [actionLoadingId, setActionLoadingId] = useState(null) const [dialogOpen, setDialogOpen] = useState(false) const [editingItem, setEditingItem] = useState(null) + const [dialogDefaults, setDialogDefaults] = useState | null>(null) const [teams, setTeams] = useState([]) + const [calendarItems, setCalendarItems] = useState([]) const [result, setResult] = useState>({ results: [], page: { page: 1, limit: 20, total: 0 }, }) + const visibleTeams = useMemo(() => { + if (teamFilter === "all") { + return teams + } + return teams.filter((team) => String(team.id) === teamFilter) + }, [teamFilter, teams]) + const loadData = useCallback(async () => { setLoading(true) try { @@ -76,18 +130,47 @@ export default function DashboardAgentTeamSchedulesPage() { } }, [limit, page, teamFilter]) + const loadCalendarData = useCallback(async () => { + setCalendarLoading(true) + try { + const data = await fetchAgentTeamScheduleCalendar({ + startAt: formatDateTimeValue(weekStart), + endAt: formatDateTimeValue(addDays(weekStart, 7)), + teamId: teamFilter === "all" ? undefined : teamFilter, + }) + setCalendarItems(data) + } catch (error) { + toast.error(error instanceof Error ? error.message : "加载客服组排班日历失败") + } finally { + setCalendarLoading(false) + } + }, [teamFilter, weekStart]) + const loadTeams = useCallback(async () => { try { - const data = await fetchAgentTeams() + const data = await fetchAgentTeamsAll() setTeams(data) } catch (error) { toast.error(error instanceof Error ? error.message : "加载客服组选项失败") } }, []) + const refreshActiveView = useCallback(async () => { + await Promise.all([ + loadCalendarData(), + viewMode === "list" ? loadData() : Promise.resolve(), + ]) + }, [loadCalendarData, loadData, viewMode]) + useEffect(() => { - void loadData() - }, [loadData]) + void loadCalendarData() + }, [loadCalendarData]) + + useEffect(() => { + if (viewMode === "list") { + void loadData() + } + }, [loadData, viewMode]) useEffect(() => { void loadTeams() @@ -105,12 +188,14 @@ export default function DashboardAgentTeamSchedulesPage() { setPage(nextPage) } - function openCreateDialog() { + function openCreateDialog(defaults?: Partial) { setEditingItem(null) + setDialogDefaults(defaults ?? null) setDialogOpen(true) } function openEditDialog(item: AdminAgentTeamSchedule) { + setDialogDefaults(null) setEditingItem(item) setDialogOpen(true) } @@ -121,6 +206,7 @@ export default function DashboardAgentTeamSchedulesPage() { } if (!open) { setEditingItem(null) + setDialogDefaults(null) } setDialogOpen(open) } @@ -140,7 +226,8 @@ export default function DashboardAgentTeamSchedulesPage() { } setDialogOpen(false) setEditingItem(null) - await loadData() + setDialogDefaults(null) + await refreshActiveView() } catch (error) { toast.error(error instanceof Error ? error.message : "保存客服组排班失败") } finally { @@ -148,12 +235,15 @@ export default function DashboardAgentTeamSchedulesPage() { } } - async function handleDelete(item: AdminAgentTeamSchedule) { - setActionLoadingId(item.id) + async function handleDeleteById(id: number) { + setActionLoadingId(id) try { - await deleteAgentTeamSchedule(item.id) + await deleteAgentTeamSchedule(id) toast.success("已删除客服组排班") - await loadData() + setDialogOpen(false) + setEditingItem(null) + setDialogDefaults(null) + await refreshActiveView() } catch (error) { toast.error(error instanceof Error ? error.message : "删除客服组排班失败") } finally { @@ -161,123 +251,201 @@ export default function DashboardAgentTeamSchedulesPage() { } } + async function handleDelete(item: AdminAgentTeamSchedule) { + await handleDeleteById(item.id) + } + + async function handleCalendarUpdate(payload: UpdateAdminAgentTeamSchedulePayload) { + setActionLoadingId(payload.id) + try { + await updateAgentTeamSchedule(payload) + toast.success("已更新客服组排班") + await loadCalendarData() + } catch (error) { + toast.error(error instanceof Error ? error.message : "更新客服组排班失败") + await loadCalendarData() + } finally { + setActionLoadingId(null) + } + } + return ( <>
-
- - - - -
-
-
- - - - 客服组 - 时间范围 - 来源 - 操作 - - - - {result.results.map((item) => ( - - -
-
- -
-
-
{item.teamName || `客服组#${item.teamId}`}
-
组ID:{item.teamId}
-
-
-
- -
{formatDateTime(item.startAt)}
-
{formatDateTime(item.endAt)}
-
- -
{item.sourceType}
-
- - - - - } - aria-label={`更多操作 ${item.startAt}`} - > - - - - void handleDelete(item)} - className="text-destructive focus:text-destructive" - > - - {actionLoadingId === item.id ? "删除中..." : "删除"} - - - - - -
- ))} - {!loading && result.results.length === 0 ? ( - - - 没有匹配的客服组排班 - - - ) : null} -
-
+
+
+ + + + + {viewMode === "calendar" ? ( + + + + + + ) : null} + {viewMode === "calendar" ? ( +
{formatWeekRange(weekStart)}
+ ) : null} +
+ +
+ + + +
- { - setLimit(nextLimit) - setPage(1) - }} - />
+ + {viewMode === "calendar" ? ( + + ) : ( +
+
+ + + + 客服组 + 时间范围 + 来源 + 操作 + + + + {result.results.map((item) => ( + + +
+
+ +
+
+
{item.teamName || `客服组#${item.teamId}`}
+
组ID:{item.teamId}
+
+
+
+ +
{formatDateTime(item.startAt)}
+
{formatDateTime(item.endAt)}
+
+ +
{item.sourceType}
+
+ + + + + } + aria-label={`更多操作 ${item.startAt}`} + > + + + + void handleDelete(item)} + className="text-destructive focus:text-destructive" + > + + {actionLoadingId === item.id ? "删除中..." : "删除"} + + + + + +
+ ))} + {!loading && result.results.length === 0 ? ( + + + 没有匹配的客服组排班 + + + ) : null} +
+
+
+ { + setLimit(nextLimit) + setPage(1) + }} + /> +
+ )}
) diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 261eb67..2333679 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -1092,6 +1092,14 @@ export function fetchAgentTeamSchedules( ) } +export function fetchAgentTeamScheduleCalendar( + query: Record +) { + return request( + `/api/dashboard/agent-team-schedule/calendar${toQueryString(query)}` + ) +} + export function fetchAgentTeamSchedule(id: number) { return request(`/api/dashboard/agent-team-schedule/${id}`) } From 944221a76cd153405d7fb6b92b1e8b7e3693bfb7 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Wed, 29 Apr 2026 10:30:27 +0800 Subject: [PATCH 02/19] feat(calendar): refactor to support month view and improve scheduling logic --- .../_components/calendar.tsx | 244 +++++++++--------- .../dashboard/agent-team-schedules/page.tsx | 81 ++++-- 2 files changed, 169 insertions(+), 156 deletions(-) diff --git a/web/app/dashboard/agent-team-schedules/_components/calendar.tsx b/web/app/dashboard/agent-team-schedules/_components/calendar.tsx index f41cf58..d218ee2 100644 --- a/web/app/dashboard/agent-team-schedules/_components/calendar.tsx +++ b/web/app/dashboard/agent-team-schedules/_components/calendar.tsx @@ -10,13 +10,15 @@ import type { } from "@/lib/api/admin" import { cn, formatDateTime } from "@/lib/utils" -const dayNames = ["一", "二", "三", "四", "五", "六", "日"] +const weekDayNames = ["一", "二", "三", "四", "五", "六", "日"] const dayMs = 24 * 60 * 60 * 1000 const minuteMs = 60 * 1000 const minDurationMs = 15 * minuteMs type ScheduleCalendarProps = { - weekStart: Date + monthStart: Date + calendarStart: Date + calendarEnd: Date teams: AdminAgentTeam[] schedules: AdminAgentTeamSchedule[] loading: boolean @@ -84,10 +86,6 @@ function formatDateTimeValue(date: Date) { return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}:${second}` } -function formatDayTitle(date: Date) { - return `${date.getMonth() + 1}/${date.getDate()}` -} - function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max) } @@ -101,25 +99,19 @@ function roundToQuarterHour(date: Date) { return ret } -function getPointerDateInCell(event: PointerEvent | React.PointerEvent, cell: Element) { +function getPointerDateInCell(event: PointerEvent, cell: Element) { const rect = cell.getBoundingClientRect() const day = startOfDay(parseLocalDateTime(`${cell.getAttribute("data-date")} 00:00:00`)) const ratio = clamp((event.clientX - rect.left) / rect.width, 0, 1) return roundToQuarterHour(new Date(day.getTime() + ratio * dayMs)) } -function getDropCell(event: PointerEvent | React.PointerEvent) { +function getDropCell(event: PointerEvent) { const element = document.elementFromPoint(event.clientX, event.clientY) return element?.closest("[data-schedule-cell]") } -function getCellTeamAndDate(cell: Element) { - const teamID = Number(cell.getAttribute("data-team-id")) - const date = cell.getAttribute("data-date") ?? "" - return { teamID, date } -} - -function buildMovePayload(item: AdminAgentTeamSchedule, teamId: number, date: string): UpdateAdminAgentTeamSchedulePayload { +function buildMovePayload(item: AdminAgentTeamSchedule, date: string): UpdateAdminAgentTeamSchedulePayload { const originalStart = parseLocalDateTime(item.startAt) const originalEnd = parseLocalDateTime(item.endAt) const duration = originalEnd.getTime() - originalStart.getTime() @@ -130,7 +122,7 @@ function buildMovePayload(item: AdminAgentTeamSchedule, teamId: number, date: st return { id: item.id, - teamId, + teamId: item.teamId, startAt: formatDateTimeValue(nextStart), endAt: formatDateTimeValue(nextEnd), sourceType: item.sourceType, @@ -166,23 +158,26 @@ function buildResizePayload( } } -function sliceScheduleForDay(item: AdminAgentTeamSchedule, day: Date) { +function intersectsDay(item: AdminAgentTeamSchedule, day: Date) { const dayStart = startOfDay(day) const dayEnd = addDays(dayStart, 1) const scheduleStart = parseLocalDateTime(item.startAt) const scheduleEnd = parseLocalDateTime(item.endAt) - const visibleStart = new Date(Math.max(scheduleStart.getTime(), dayStart.getTime())) - const visibleEnd = new Date(Math.min(scheduleEnd.getTime(), dayEnd.getTime())) - if (!visibleEnd.getTime() || visibleEnd <= visibleStart) { - return null + return scheduleStart < dayEnd && scheduleEnd > dayStart +} + +function buildCalendarDays(calendarStart: Date, calendarEnd: Date) { + const days: Date[] = [] + for (let current = startOfDay(calendarStart); current < calendarEnd; current = addDays(current, 1)) { + days.push(current) } - const left = ((visibleStart.getTime() - dayStart.getTime()) / dayMs) * 100 - const width = ((visibleEnd.getTime() - visibleStart.getTime()) / dayMs) * 100 - return { left, width, visibleStart, visibleEnd } + return days } export function ScheduleCalendar({ - weekStart, + monthStart, + calendarStart, + calendarEnd, teams, schedules, loading, @@ -192,15 +187,16 @@ export function ScheduleCalendar({ onMove, onResize, }: ScheduleCalendarProps) { - const days = Array.from({ length: 7 }, (_, index) => startOfDay(addDays(weekStart, index))) + const days = buildCalendarDays(calendarStart, calendarEnd) + const defaultTeamID = teams[0]?.id ?? 0 - function handleBlankCellClick(teamId: number, day: Date) { + function handleBlankCellClick(day: Date) { const startAt = new Date(day) startAt.setHours(9, 0, 0, 0) const endAt = new Date(day) endAt.setHours(18, 0, 0, 0) onCreate({ - teamId, + teamId: defaultTeamID || undefined, startAt: formatDateTimeValue(startAt), endAt: formatDateTimeValue(endAt), sourceType: "manual", @@ -241,11 +237,11 @@ export function ScheduleCalendar({ return } if (state.type === "move") { - const next = getCellTeamAndDate(cell) - if (!next.teamID || !next.date) { + const date = cell.getAttribute("data-date") + if (!date) { return } - await onMove(buildMovePayload(item, next.teamID, next.date)) + await onMove(buildMovePayload(item, date)) return } const payload = buildResizePayload(item, state.edge, getPointerDateInCell(upEvent, cell)) @@ -267,107 +263,97 @@ export function ScheduleCalendar({ } return ( -
-
-
-
客服组
- {days.map((day, index) => ( -
-
周{dayNames[index]}
-
{formatDayTitle(day)}
-
- ))} -
- -
- {teams.map((team) => ( -
-
-
-
{team.name}
-
组ID:{team.id}
-
+
+
+ {weekDayNames.map((name) => ( +
+ 周{name} +
+ ))} +
+
+ {days.map((day, dayIndex) => { + const date = formatDate(day) + const inMonth = day.getMonth() === monthStart.getMonth() + const daySchedules = schedules.filter((item) => intersectsDay(item, day)) + return ( +
{ + if ((event.target as HTMLElement).closest("[data-schedule-block]")) { + return + } + handleBlankCellClick(day) + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + handleBlankCellClick(day) + } + }} + > +
+
{day.getDate()}
+
- {days.map((day) => { - const date = formatDate(day) - const daySchedules = schedules.filter((item) => item.teamId === team.id && sliceScheduleForDay(item, day)) - return ( - - ) - })} +
handlePointerDown(event, item, "resize", "end")} + > + +
+
{teamName}
+
+ {formatDateTime(item.startAt).slice(11, 16)} - {formatDateTime(item.endAt).slice(11, 16)} +
+ {item.remark ?
{item.remark}
: null} +
+ ) + })} + {daySchedules.length > 5 ? ( +
还有 {daySchedules.length - 5} 条
+ ) : null} +
- ))} -
+ ) + })}
) diff --git a/web/app/dashboard/agent-team-schedules/page.tsx b/web/app/dashboard/agent-team-schedules/page.tsx index 80c94b2..ffb2c20 100644 --- a/web/app/dashboard/agent-team-schedules/page.tsx +++ b/web/app/dashboard/agent-team-schedules/page.tsx @@ -18,13 +18,13 @@ import { toast } from "sonner" import { ListPagination } from "@/components/list-pagination" import { Button } from "@/components/ui/button" import { ButtonGroup } from "@/components/ui/button-group" +import { OptionCombobox } from "@/components/option-combobox" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Table, TableBody, @@ -66,6 +66,26 @@ function startOfWeek(date: Date) { return ret } +function startOfMonth(date: Date) { + const ret = startOfDay(date) + ret.setDate(1) + return ret +} + +function startOfMonthCalendar(date: Date) { + return startOfWeek(startOfMonth(date)) +} + +function endOfMonthCalendar(date: Date) { + const monthEnd = startOfMonth(date) + monthEnd.setMonth(monthEnd.getMonth() + 1) + const ret = startOfWeek(monthEnd) + if (ret.getTime() < monthEnd.getTime()) { + ret.setDate(ret.getDate() + 7) + } + return ret +} + function addDays(date: Date, days: number) { const ret = new Date(date) ret.setDate(ret.getDate() + days) @@ -81,16 +101,21 @@ function formatDateTimeValue(date: Date) { return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}:${second}` } -function formatWeekRange(weekStart: Date) { - const weekEnd = addDays(weekStart, 6) - return `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, "0")}-${String(weekStart.getDate()).padStart(2, "0")} 至 ${String(weekEnd.getMonth() + 1).padStart(2, "0")}-${String(weekEnd.getDate()).padStart(2, "0")}` +function addMonths(date: Date, months: number) { + const ret = startOfMonth(date) + ret.setMonth(ret.getMonth() + months) + return ret +} + +function formatMonthTitle(monthStart: Date) { + return `${monthStart.getFullYear()}年${String(monthStart.getMonth() + 1).padStart(2, "0")}月` } export default function DashboardAgentTeamSchedulesPage() { const [viewMode, setViewMode] = useState("calendar") const [teamFilterInput, setTeamFilterInput] = useState("all") const [teamFilter, setTeamFilter] = useState("all") - const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date())) + const [monthStart, setMonthStart] = useState(() => startOfMonth(new Date())) const [page, setPage] = useState(1) const [limit, setLimit] = useState(20) const [loading, setLoading] = useState(true) @@ -134,8 +159,8 @@ export default function DashboardAgentTeamSchedulesPage() { setCalendarLoading(true) try { const data = await fetchAgentTeamScheduleCalendar({ - startAt: formatDateTimeValue(weekStart), - endAt: formatDateTimeValue(addDays(weekStart, 7)), + startAt: formatDateTimeValue(startOfMonthCalendar(monthStart)), + endAt: formatDateTimeValue(endOfMonthCalendar(monthStart)), teamId: teamFilter === "all" ? undefined : teamFilter, }) setCalendarItems(data) @@ -144,7 +169,7 @@ export default function DashboardAgentTeamSchedulesPage() { } finally { setCalendarLoading(false) } - }, [teamFilter, weekStart]) + }, [monthStart, teamFilter]) const loadTeams = useCallback(async () => { try { @@ -294,36 +319,36 @@ export default function DashboardAgentTeamSchedulesPage() { {viewMode === "calendar" ? ( - - - ) : null} {viewMode === "calendar" ? ( -
{formatWeekRange(weekStart)}
+
{formatMonthTitle(monthStart)}
) : null}
- +
+ ({ value: String(team.id), label: team.name })), + ]} + placeholder="筛选客服组" + searchPlaceholder="搜索客服组" + emptyText="未找到客服组" + onChange={(value) => setTeamFilterInput(value)} + /> +