From 27f652045782a036e9ad1581994fefec8e49a89b Mon Sep 17 00:00:00 2001 From: mlogclub Date: Wed, 29 Apr 2026 10:54:18 +0800 Subject: [PATCH] feat(calendar): enhance drag-and-drop interaction with preview functionality --- .../_components/calendar.tsx | 122 ++++++++++++++++-- 1 file changed, 114 insertions(+), 8 deletions(-) diff --git a/web/app/dashboard/agent-team-schedules/_components/calendar.tsx b/web/app/dashboard/agent-team-schedules/_components/calendar.tsx index 9fa8e44..df3f4ee 100644 --- a/web/app/dashboard/agent-team-schedules/_components/calendar.tsx +++ b/web/app/dashboard/agent-team-schedules/_components/calendar.tsx @@ -1,6 +1,7 @@ "use client" import { CalendarPlusIcon, GripVerticalIcon } from "lucide-react" +import { useState, type PointerEvent as ReactPointerEvent } from "react" import type { AdminAgentTeam, @@ -44,6 +45,15 @@ type DragState = moved: boolean } +type InteractionPreview = { + itemId: number + date: string | null + label: string + invalid: boolean + x: number + y: number +} + function addDays(date: Date, days: number) { const ret = new Date(date) ret.setDate(ret.getDate() + days) @@ -86,6 +96,10 @@ function formatDateTimeValue(date: Date) { return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}:${second}` } +function formatTime(dateTime: string) { + return formatDateTime(dateTime).slice(11, 16) +} + function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max) } @@ -158,6 +172,23 @@ function buildResizePayload( } } +function buildPreviewFromPayload( + itemId: number, + date: string | null, + payload: UpdateAdminAgentTeamSchedulePayload | null, + point: { x: number; y: number }, + fallbackLabel: string +): InteractionPreview { + return { + itemId, + date, + label: payload ? `${formatTime(payload.startAt)} - ${formatTime(payload.endAt)}` : fallbackLabel, + invalid: !payload, + x: point.x, + y: point.y, + } +} + function intersectsDay(item: AdminAgentTeamSchedule, day: Date) { const dayStart = startOfDay(day) const dayEnd = addDays(dayStart, 1) @@ -189,6 +220,7 @@ export function ScheduleCalendar({ }: ScheduleCalendarProps) { const days = buildCalendarDays(calendarStart, calendarEnd) const defaultTeamID = teams[0]?.id ?? 0 + const [interactionPreview, setInteractionPreview] = useState(null) function handleBlankCellClick(day: Date) { const startAt = new Date(day) @@ -204,11 +236,60 @@ export function ScheduleCalendar({ }) } - function handlePointerDown(event: React.PointerEvent, item: AdminAgentTeamSchedule, type: DragState["type"], edge?: "start" | "end") { + function buildInteractionPreview(state: DragState, pointerEvent: PointerEvent): InteractionPreview | null { + const cell = getDropCell(pointerEvent) + if (!cell) { + return { + itemId: state.item.id, + date: null, + label: "拖到日历日期格内", + invalid: true, + x: pointerEvent.clientX, + y: pointerEvent.clientY, + } + } + + const date = cell.getAttribute("data-date") + if (!date) { + return null + } + + const point = { x: pointerEvent.clientX, y: pointerEvent.clientY } + if (state.type === "move") { + return buildPreviewFromPayload(state.item.id, date, buildMovePayload(state.item, date), point, "无法移动到这里") + } + + const payload = buildResizePayload(state.item, state.edge, getPointerDateInCell(pointerEvent, cell)) + return buildPreviewFromPayload(state.item.id, date, payload, point, "至少保留 15 分钟") + } + + function cleanupPointerInteraction( + target: HTMLElement, + pointerId: number, + handlePointerMove: (moveEvent: PointerEvent) => void, + handlePointerUp: (upEvent: PointerEvent) => void, + handlePointerCancel: () => void + ) { + if (target.hasPointerCapture(pointerId)) { + target.releasePointerCapture(pointerId) + } + window.removeEventListener("pointermove", handlePointerMove) + window.removeEventListener("pointerup", handlePointerUp) + window.removeEventListener("pointercancel", handlePointerCancel) + setInteractionPreview(null) + } + + function handlePointerDown(event: ReactPointerEvent, item: AdminAgentTeamSchedule, type: DragState["type"], edge?: "start" | "end") { event.preventDefault() event.stopPropagation() const target = event.currentTarget as HTMLElement - target.setPointerCapture(event.pointerId) + if (target.isConnected) { + try { + target.setPointerCapture(event.pointerId) + } catch { + // Some synthetic/browser edge events do not expose a capturable pointer id. + } + } const state: DragState = type === "resize" ? { type: "resize", edge: edge ?? "end", item, moved: false } @@ -218,16 +299,17 @@ export function ScheduleCalendar({ if (state.type === "move") { if (Math.abs(moveEvent.clientX - state.startX) > 4 || Math.abs(moveEvent.clientY - state.startY) > 4) { state.moved = true + } else { + return } } else { state.moved = true } + setInteractionPreview(buildInteractionPreview(state, moveEvent)) } async function handlePointerUp(upEvent: PointerEvent) { - target.releasePointerCapture(event.pointerId) - window.removeEventListener("pointermove", handlePointerMove) - window.removeEventListener("pointerup", handlePointerUp) + cleanupPointerInteraction(target, event.pointerId, handlePointerMove, handlePointerUp, handlePointerCancel) if (!state.moved) { onEdit(item) return @@ -250,8 +332,13 @@ export function ScheduleCalendar({ } } + function handlePointerCancel() { + cleanupPointerInteraction(target, event.pointerId, handlePointerMove, handlePointerUp, handlePointerCancel) + } + window.addEventListener("pointermove", handlePointerMove) window.addEventListener("pointerup", handlePointerUp) + window.addEventListener("pointercancel", handlePointerCancel) } if (teams.length === 0 && !loading) { @@ -286,7 +373,9 @@ export function ScheduleCalendar({ className={cn( "min-h-36 border-l border-t bg-background p-2 text-left outline-none transition-colors first:border-l-0 hover:bg-muted/20 focus-visible:ring-2 focus-visible:ring-ring", dayIndex % 7 === 0 && "border-l-0", - !inMonth && "bg-muted/20 text-muted-foreground" + !inMonth && "bg-muted/20 text-muted-foreground", + interactionPreview?.date === date && + (interactionPreview.invalid ? "bg-destructive/5 ring-2 ring-destructive/30" : "bg-primary/5 ring-2 ring-primary/35") )} onClick={(event) => { if ((event.target as HTMLElement).closest("[data-schedule-block]")) { @@ -309,6 +398,7 @@ export function ScheduleCalendar({ {daySchedules.slice(0, 5).map((item) => { const teamName = item.teamName || teams.find((team) => team.id === item.teamId)?.name || `客服组#${item.teamId}` const busy = savingId === item.id + const active = interactionPreview?.itemId === item.id return (
handlePointerDown(event, item, "move")} @@ -341,7 +432,7 @@ export function ScheduleCalendar({
{teamName}
- {formatDateTime(item.startAt).slice(11, 16)} - {formatDateTime(item.endAt).slice(11, 16)} + {formatTime(item.startAt)} - {formatTime(item.endAt)}
{item.remark ?
{item.remark}
: null} @@ -355,6 +446,21 @@ export function ScheduleCalendar({ ) })} + {interactionPreview ? ( +
+ {interactionPreview.label} +
+ ) : null} ) }