diff --git a/backend/internal/domain/availability_submission.go b/backend/internal/domain/availability_submission.go index 6b37740..2aa86ec 100644 --- a/backend/internal/domain/availability_submission.go +++ b/backend/internal/domain/availability_submission.go @@ -11,6 +11,7 @@ type AvailabilitySubmission struct { ID int64 `json:"id"` SchedulePlanID int64 `json:"schedulePlanID"` UserID int64 `json:"userID"` + Username string `json:"username"` Items []AvailabilitySubmissionItem `json:"items"` CreatedAt time.Time `json:"createdAt"` Version int32 `json:"-"` diff --git a/backend/internal/handler/schedule_plans.go b/backend/internal/handler/schedule_plans.go index a46c409..71926c4 100644 --- a/backend/internal/handler/schedule_plans.go +++ b/backend/internal/handler/schedule_plans.go @@ -305,13 +305,13 @@ func (h *Handler) SubmitSchedulingResult(w http.ResponseWriter, r *http.Request) return } - if err := utils.ValidateSchedulingResultWithSubmissions(schedulingResult, submissions); err != nil { + if err := utils.ValidateSchedulingResultWithSubmissions(schedulingResult, submissions, template, h.repository); err != nil { h.badRequest(w, r, err) return } // 最后要检查是否存在重复的助理 - if err := utils.ValidIfExistsDuplicateAssistant(schedulingResult); err != nil { + if err := utils.ValidIfExistsDuplicateAssistant(schedulingResult, template, h.repository); err != nil { h.badRequest(w, r, err) return } @@ -401,8 +401,21 @@ func (h *Handler) GenerateSchedulingResult(w http.ResponseWriter, r *http.Reques return } - res, err := scheduler.Schedule() - if err != nil { + res := scheduler.Schedule() + + // 还需要检查一下结果是否满足约束条件(调用 validate 包中的方法就可以了) + schedulingResult := &domain.SchedulingResult{ + Shifts: make([]domain.SchedulingResultShift, len(res)), + } + for i, shift := range res { + schedulingResult.Shifts[i] = *shift + } + + if err := utils.ValidateSchedulingResultWithSubmissions(schedulingResult, submissions, template, h.repository); err != nil { + h.internalServerError(w, r, err) + return + } + if err := utils.ValidIfExistsDuplicateAssistant(schedulingResult, template, h.repository); err != nil { h.internalServerError(w, r, err) return } diff --git a/backend/internal/scheduler/scheduler.go b/backend/internal/scheduler/scheduler.go index a58caa2..6b794c0 100644 --- a/backend/internal/scheduler/scheduler.go +++ b/backend/internal/scheduler/scheduler.go @@ -7,7 +7,6 @@ import ( "sort" "github.com/sysu-ecnc-dev/shift-manager/backend/internal/domain" - "github.com/sysu-ecnc-dev/shift-manager/backend/internal/utils" ) type Scheduler struct { @@ -68,7 +67,7 @@ func New(parameters *Parameters, users []*domain.User, template *domain.Schedule return s, nil } -func (s *Scheduler) Schedule() ([]*domain.SchedulingResultShift, error) { +func (s *Scheduler) Schedule() []*domain.SchedulingResultShift { // 生成初始种群 pop := make([]*Chromosome, s.parameters.PopulationSize) for i := 0; i < int(s.parameters.PopulationSize); i++ { @@ -167,20 +166,5 @@ func (s *Scheduler) Schedule() ([]*domain.SchedulingResultShift, error) { }) } - // 还需要检查一下结果是否满足约束条件(调用 validate 包中的方法就可以了) - schedulingResult := &domain.SchedulingResult{ - Shifts: make([]domain.SchedulingResultShift, len(result)), - } - for i, shift := range result { - schedulingResult.Shifts[i] = *shift - } - - if err := utils.ValidateSchedulingResultWithSubmissions(schedulingResult, s.submissions); err != nil { - return nil, err - } - if err := utils.ValidIfExistsDuplicateAssistant(schedulingResult); err != nil { - return nil, err - } - - return result, nil + return result } diff --git a/backend/internal/utils/validation.go b/backend/internal/utils/validation.go index 97e4e2f..0633b82 100644 --- a/backend/internal/utils/validation.go +++ b/backend/internal/utils/validation.go @@ -7,6 +7,7 @@ import ( "time" "github.com/sysu-ecnc-dev/shift-manager/backend/internal/domain" + "github.com/sysu-ecnc-dev/shift-manager/backend/internal/repository" ) func ValidateScheduleTemplateShiftTime(st *domain.ScheduleTemplate) error { @@ -14,14 +15,14 @@ func ValidateScheduleTemplateShiftTime(st *domain.ScheduleTemplate) error { for id, shift := range st.Shifts { startTime, err := time.Parse("15:04:05", shift.StartTime) if err != nil { - return fmt.Errorf("班次 %d 的开始时间格式错误", id) + return fmt.Errorf("班次 %d 的开始时间格式错误: %s", id, shift.StartTime) } endTime, err := time.Parse("15:04:05", shift.EndTime) if err != nil { - return fmt.Errorf("班次 %d 的结束时间格式错误", id) + return fmt.Errorf("班次 %d 的结束时间格式错误: %s", id, shift.EndTime) } if endTime.Before(startTime) { - return fmt.Errorf("班次 %d 的结束时间不能小于开始时间", id) + return fmt.Errorf("班次 %d 的结束时间 %s 不能小于开始时间 %s", id, shift.EndTime, shift.StartTime) } } @@ -35,7 +36,7 @@ func ValidateScheduleTemplateShiftTime(st *domain.ScheduleTemplate) error { jEndTime, _ := time.Parse("15:04:05", st.Shifts[j].EndTime) if !(jStartTime.After(iEndTime) || jStartTime.Equal(iEndTime) || iStartTime.After(jEndTime) || iStartTime.Equal(jEndTime)) { - return fmt.Errorf("班次 %d 和班次 %d 之间的时间冲突", i, j) + return fmt.Errorf("班次 %d (%s~%s) 和班次 %d (%s~%s) 之间的时间冲突", i, iStartTime, iEndTime, j, jStartTime, jEndTime) } } } @@ -58,34 +59,62 @@ func ValidateSchedulePlanTime(plan *domain.SchedulePlan) error { return nil } +// 根据班次的 ID 获取模板中对应的班次 +func GetShiftByID(template *domain.ScheduleTemplate, shiftID int64) (*domain.ScheduleTemplateShift, error) { + var templateShift *domain.ScheduleTemplateShift = nil + + for _, shift := range template.Shifts { + if shift.ID == shiftID { + templateShift = &shift + break + } + } + + if templateShift == nil { + return nil, fmt.Errorf("班次 %d 不存在于该排班模版中", shiftID) + } + + return templateShift, nil +} + +func WeekDay(i int32) string { + switch i { + case 1: + return "周一" + case 2: + return "周二" + case 3: + return "周三" + case 4: + return "周四" + case 5: + return "周五" + case 6: + return "周六" + case 7: + return "周日" + default: + return "[未知]" + } +} + func ValidateSubmissionWithTemplate(submission *domain.AvailabilitySubmission, template *domain.ScheduleTemplate) error { if len(template.Shifts) != len(submission.Items) { return errors.New("提交的空闲时间中的班次数量和模板中的班次数量不匹配") } for i, item := range submission.Items { - isValid := false - - for _, shift := range template.Shifts { - if shift.ID == item.ShiftID { - containAllDays := true - - for _, day := range item.Days { - if !slices.Contains(shift.ApplicableDays, day) { - containAllDays = false - break - } - } - - if containAllDays { - isValid = true - break - } - } + shift, err := GetShiftByID(template, item.ShiftID) + if err != nil { + return fmt.Errorf("第 %d 项提交的班次 %d 不存在于该排班模版中", i, item.ShiftID) } - - if !isValid { - return fmt.Errorf("第 %d 项不符合模板中的班次", i+1) + for _, day := range item.Days { + if !slices.Contains(shift.ApplicableDays, day) { + return fmt.Errorf( + "第 %d 项提交的班次时间不存在, 对应模板中%s班次 %d (%s~%s) 无值班安排", + i+1, WeekDay(day), shift.ID, shift.StartTime, shift.EndTime, + ) + } } } @@ -98,17 +127,9 @@ func ValidateSchedulingResultWithTemplate(result *domain.SchedulingResult, templ } for _, resultShift := range result.Shifts { - // 找到模板中对应的班次 - var templateShift *domain.ScheduleTemplateShift = nil - - for _, shift := range template.Shifts { - if shift.ID == resultShift.ShiftID { - templateShift = &shift - } - } - - if templateShift == nil { - return fmt.Errorf("排班结果中的第 %d 项不存在于排班模板中", resultShift.ShiftID) + templateShift, err := GetShiftByID(template, resultShift.ShiftID) + if err != nil { + return err } for _, day := range templateShift.ApplicableDays { @@ -122,17 +143,26 @@ func ValidateSchedulingResultWithTemplate(result *domain.SchedulingResult, templ } if !containDay { - return fmt.Errorf("排班结果中的第 %d 项的班次存在没有提交结果的天数 %d", resultShift.ShiftID, day) + return fmt.Errorf( + "排班结果中%s班次 %d (%s~%s) 无人值班", + WeekDay(day), templateShift.ID, templateShift.StartTime, templateShift.EndTime, + ) } } for _, item := range resultShift.Items { if !slices.Contains(templateShift.ApplicableDays, item.Day) { - return fmt.Errorf("排班结果中的第 %d 项的第 %d 天不符合模板中的班次", resultShift.ShiftID, item.Day) + return fmt.Errorf( + "排班结果中%s班次 %d (%s~%s) 在排班模版中不需要安排值班", + WeekDay(item.Day), resultShift.ShiftID, templateShift.StartTime, templateShift.EndTime, + ) } // +1 是因为负责人也算一个助理 if len(item.AssistantIDs)+1 > int(templateShift.RequiredAssistantNumber) { - return fmt.Errorf("排班结果中的第 %d 项的第 %d 天的助理人数超过了模板中的要求", resultShift.ShiftID, item.Day) + return fmt.Errorf( + "排班结果中的%s班次 %d (%s~%s) 的的助理人数超过了模板中的要求", + WeekDay(item.Day), resultShift.ShiftID, templateShift.StartTime, templateShift.EndTime, + ) } } } @@ -149,14 +179,30 @@ func getSubmissionByAssistantID(submissions []*domain.AvailabilitySubmission, as return nil } -func ValidateSchedulingResultWithSubmissions(result *domain.SchedulingResult, submissions []*domain.AvailabilitySubmission) error { - for i, shift := range result.Shifts { +func ValidateSchedulingResultWithSubmissions( + result *domain.SchedulingResult, + submissions []*domain.AvailabilitySubmission, + template *domain.ScheduleTemplate, + repo *repository.Repository, +) error { + for _, shift := range result.Shifts { for _, item := range shift.Items { if item.PrincipalID != nil { // 找到这个负责人对应的提交 submission := getSubmissionByAssistantID(submissions, *item.PrincipalID) if submission == nil { - return fmt.Errorf("班次 %d 的第 %d 天的 id 为 %d 的负责人没有提交空闲时间", i+1, item.Day, item.PrincipalID) + templateShift, err := GetShiftByID(template, shift.ShiftID) + if err != nil { + return err + } + user, err := repo.GetUserByID(*item.PrincipalID) + if err != nil { + return err + } + return fmt.Errorf( + "负责人%s没有提交%s班次 %d (%s~%s) 的空闲时间", + user.FullName, WeekDay(item.Day), templateShift.ID, templateShift.StartTime, templateShift.EndTime, + ) } // 检查这个负责人是否在第 item.Day 天有空闲时间 @@ -168,14 +214,36 @@ func ValidateSchedulingResultWithSubmissions(result *domain.SchedulingResult, su } } if !ok { - return fmt.Errorf("id 为 %d 的负责人在班次 %d 的第 %d 天没有空闲时间", item.PrincipalID, shift.ShiftID, item.Day) + templateShift, err := GetShiftByID(template, shift.ShiftID) + if err != nil { + return err + } + user, err := repo.GetUserByID(*item.PrincipalID) + if err != nil { + return err + } + return fmt.Errorf( + "负责人%s在%s班次 %d (%s~%s) 没有空闲时间", + user.FullName, WeekDay(item.Day), templateShift.ID, templateShift.StartTime, templateShift.EndTime, + ) } } for _, assistantID := range item.AssistantIDs { // 找到这个助理对应的提交 submission := getSubmissionByAssistantID(submissions, assistantID) if submission == nil { - return fmt.Errorf("班次 %d 的第 %d 天的 id 为 %d 的助理没有提交空闲时间", i+1, item.Day, assistantID) + templateShift, err := GetShiftByID(template, shift.ShiftID) + if err != nil { + return err + } + user, err := repo.GetUserByID(assistantID) + if err != nil { + return err + } + return fmt.Errorf( + "助理%s没有提交%s班次 %d (%s~%s) 的空闲时间", + user.FullName, WeekDay(item.Day), templateShift.ID, templateShift.StartTime, templateShift.EndTime, + ) } // 检查这个助理是否在第 item.Day 天有空闲时间 @@ -187,7 +255,18 @@ func ValidateSchedulingResultWithSubmissions(result *domain.SchedulingResult, su } } if !ok { - return fmt.Errorf("id 为 %d 的助理在班次 %d 的第 %d 天没有空闲时间", assistantID, shift.ShiftID, item.Day) + templateShift, err := GetShiftByID(template, shift.ShiftID) + if err != nil { + return err + } + user, err := repo.GetUserByID(assistantID) + if err != nil { + return err + } + return fmt.Errorf( + "助理%s在%s班次 %d (%s~%s) 没有空闲时间", + user.FullName, WeekDay(item.Day), templateShift.ID, templateShift.StartTime, templateShift.EndTime, + ) } } } @@ -196,19 +275,45 @@ func ValidateSchedulingResultWithSubmissions(result *domain.SchedulingResult, su return nil } -func ValidIfExistsDuplicateAssistant(result *domain.SchedulingResult) error { +func ValidIfExistsDuplicateAssistant( + result *domain.SchedulingResult, + template *domain.ScheduleTemplate, + repo *repository.Repository, +) error { // 检查是否存在某个班次中的某一天有重复的助理 - for i, resultShift := range result.Shifts { + for _, resultShift := range result.Shifts { for _, resultShiftItem := range resultShift.Items { // 先检查负责人是不是存在于助理数组中 if resultShiftItem.PrincipalID != nil && slices.Contains(resultShiftItem.AssistantIDs, *resultShiftItem.PrincipalID) { - return fmt.Errorf("班次 %d 的第 %d 天中负责人和助理重复", i, resultShiftItem.Day) + templateShift, err := GetShiftByID(template, resultShift.ShiftID) + if err != nil { + return err + } + user, err := repo.GetUserByID(*resultShiftItem.PrincipalID) + if err != nil { + return err + } + return fmt.Errorf( + "%s班次 %d (%s~%s) 的负责人和助理重复(%s)", + WeekDay(resultShiftItem.Day), resultShift.ShiftID, templateShift.StartTime, templateShift.EndTime, user.FullName, + ) } // 检查助理之间是否有重复 seen := make(map[int64]bool) for _, assistantID := range resultShiftItem.AssistantIDs { if seen[assistantID] { - return fmt.Errorf("班次 %d 的第 %d 天中存在重复助理", i, resultShiftItem.Day) + templateShift, err := GetShiftByID(template, resultShift.ShiftID) + if err != nil { + return err + } + user, err := repo.GetUserByID(assistantID) + if err != nil { + return err + } + return fmt.Errorf( + "%s班次 %d (%s~%s) 的助理重复(%s)", + WeekDay(resultShiftItem.Day), resultShift.ShiftID, templateShift.StartTime, templateShift.EndTime, user.FullName, + ) } seen[assistantID] = true } diff --git a/frontend/index.html b/frontend/index.html index 8ac53d2..3dcca7b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + ECNC 假勤系统 diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..9128ae3 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/ui/badge-removable.tsx b/frontend/src/components/ui/badge-removable.tsx new file mode 100644 index 0000000..0b73257 --- /dev/null +++ b/frontend/src/components/ui/badge-removable.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { type VariantProps } from "class-variance-authority" +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils" +import { badgeVariants } from "./badge"; + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps { + onRemove: () => void; + } + +function BadgeRemovable({ className, variant, onRemove, ...props }: BadgeProps) { + return ( + + +
+ + + + 删除 + + + + ) +} + +export { BadgeRemovable } diff --git a/frontend/src/feat/scheduling-area/scheduling-area-drag-overlay.tsx b/frontend/src/feat/scheduling-area/scheduling-area-drag-overlay.tsx index df77407..718b1cf 100644 --- a/frontend/src/feat/scheduling-area/scheduling-area-drag-overlay.tsx +++ b/frontend/src/feat/scheduling-area/scheduling-area-drag-overlay.tsx @@ -29,7 +29,7 @@ export default function SchedulingAreaDragOverlay() { return ( - + {roleIcons[activeUser.role]} {activeUser.fullName} ({assignedHours}) diff --git a/frontend/src/feat/scheduling-area/scheduling-area-table-row-cell-item.tsx b/frontend/src/feat/scheduling-area/scheduling-area-table-row-cell-item.tsx index c9a3cff..a6de8b5 100644 --- a/frontend/src/feat/scheduling-area/scheduling-area-table-row-cell-item.tsx +++ b/frontend/src/feat/scheduling-area/scheduling-area-table-row-cell-item.tsx @@ -1,4 +1,4 @@ -import { Badge } from "@/components/ui/badge"; +import { BadgeRemovable } from "@/components/ui/badge-removable"; import { getScheduleTemplateQueryOptions, getUsersQueryOptions, @@ -49,7 +49,7 @@ export default function SchedulingAreaTableRowCellItem({ const { data: scheduleTemplate } = useSuspenseQuery( getScheduleTemplateQueryOptions(schedulePlan.scheduleTemplateID) ); - const { schedulingSubmission } = useSchedulingSubmissionStore(); + const { schedulingSubmission, setSchedulingSubmission } = useSchedulingSubmissionStore(); const activeSubmission = active?.data?.current?.submission as | AvailabilitySubmission @@ -70,18 +70,37 @@ export default function SchedulingAreaTableRowCellItem({ ((activeUser && activeUser.role === "资深助理") || (activeUser && activeUser.role === "黑心")); + const onRemoveBadge = () => { + var submission = schedulingSubmission; + for (let i = 0; i < submission.length; i++) { + if (submission[i].shiftID === shiftID) { + for (let j = 0; j < submission[i].items.length; j++) { + if (submission[i].items[j].day === schedulingResultShiftItem.day) { + // 找到这一班次这一天后, 然后根据是负责人还是助理进行删除 + if (isPrincipal && schedulingResultShiftItem.principalID !== null) { + submission[i].items[j].principalID = null; + } else if (index !== undefined) { + submission[i].items[j].assistantIDs.splice(index, 1); + } + } + } + } + } + setSchedulingSubmission(submission); + } + return (
{isPrincipal ? ( schedulingResultShiftItem.principalID !== null ? ( - + { @@ -99,13 +118,13 @@ export default function SchedulingAreaTableRowCellItem({ )} ) - + ) : ( 缺少负责人 ) ) : index !== undefined && schedulingResultShiftItem.assistantIDs.at(index) !== undefined ? ( - + { @@ -124,7 +143,7 @@ export default function SchedulingAreaTableRowCellItem({ )} ) - + ) : ( 缺少助理 )} diff --git a/frontend/src/feat/scheduling-area/scheduling-area-table-row.tsx b/frontend/src/feat/scheduling-area/scheduling-area-table-row.tsx index 3be3e0e..41272f3 100644 --- a/frontend/src/feat/scheduling-area/scheduling-area-table-row.tsx +++ b/frontend/src/feat/scheduling-area/scheduling-area-table-row.tsx @@ -20,8 +20,10 @@ export default function SchedulingAreaTableRow({ return (
{/* 班次情况 */} -
- {templateShift.startTime}~{templateShift.endTime} +
+
{templateShift.startTime}
+
~
+
{templateShift.endTime}
{/* 每天的排班情况 */} diff --git a/frontend/src/feat/scheduling-area/scheduling-area-table.tsx b/frontend/src/feat/scheduling-area/scheduling-area-table.tsx index 8ce0089..4fa8f44 100644 --- a/frontend/src/feat/scheduling-area/scheduling-area-table.tsx +++ b/frontend/src/feat/scheduling-area/scheduling-area-table.tsx @@ -50,14 +50,14 @@ export default function SchedulingAreaTable({ return (
{/* 展示星期 */} -
+
{[{ key: 0, label: "班次" }, ...DayOfWeek].map((day) => ( -
+
{day.label}
))} @@ -70,15 +70,11 @@ export default function SchedulingAreaTable({ (shift) => shift.shiftID === scheduleTemplateShift.id ); - if (resultShift === undefined) { - return null; - } - return ( ); diff --git a/frontend/src/feat/scheduling-area/scheduling-area-users.tsx b/frontend/src/feat/scheduling-area/scheduling-area-users.tsx index 0520468..881c824 100644 --- a/frontend/src/feat/scheduling-area/scheduling-area-users.tsx +++ b/frontend/src/feat/scheduling-area/scheduling-area-users.tsx @@ -70,7 +70,7 @@ export default function SchedulingAreaUsers({ return (
{/* 排班基本情况 */}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7e138c4..79ffb1b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,6 +6,7 @@ import viteReact from "@vitejs/plugin-react"; // https://vite.dev/config/ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()); + const API_URL = env.VITE_API_URL || "http://localhost:3000"; return { plugins: [TanStackRouterVite(), viteReact()], @@ -17,7 +18,7 @@ export default defineConfig(({ mode }) => { server: { proxy: { "/api": { - target: env.VITE_API_URL, + target: API_URL, changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ""), },