Skip to content

Commit 620cd21

Browse files
author
赵汝波
committed
feat: 任务看板支持托拽更新
1 parent e5d7631 commit 620cd21

4 files changed

Lines changed: 233 additions & 29 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
]
2727
},
2828
"dependencies": {
29+
"@dnd-kit/core": "^6.3.1",
30+
"@dnd-kit/utilities": "^3.2.2",
2931
"@hookform/resolvers": "^5.2.2",
3032
"@prisma/client": "^6.19.3",
3133
"@radix-ui/react-dialog": "^1.1.15",

pnpm-lock.yaml

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/task-board-card.tsx

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
'use client'
22

3+
import type {
4+
DraggableAttributes,
5+
DraggableSyntheticListeners,
6+
} from '@dnd-kit/core'
7+
import { GripVertical } from 'lucide-react'
38
import { useEffect, useState } from 'react'
49
import { TaskDetailSheet } from '@/components/task-detail-sheet'
510
import { PRIORITY_BADGE_CLASS, PRIORITY_LABEL } from '@/lib/task-priority'
@@ -22,10 +27,17 @@ export function TaskBoardCard({
2227
task,
2328
initialDetailOpen = false,
2429
onDetailClose,
30+
boardDragSetHandle,
31+
boardDragAttributes,
32+
boardDragListeners,
2533
}: {
2634
task: TaskListItem
2735
initialDetailOpen?: boolean
2836
onDetailClose?: () => void
37+
/** 看板拖拽:与下面两项同时传入时渲染左侧手柄(dnd-kit `setActivatorNodeRef`) */
38+
boardDragSetHandle?: (element: HTMLElement | null) => void
39+
boardDragAttributes?: DraggableAttributes
40+
boardDragListeners?: DraggableSyntheticListeners
2941
}) {
3042
const [detailOpen, setDetailOpen] = useState(initialDetailOpen)
3143

@@ -35,12 +47,31 @@ export function TaskBoardCard({
3547

3648
const dueLabel = formatDueShort(task.dueDate)
3749

38-
return (
39-
<>
50+
const showBoardDrag =
51+
boardDragSetHandle != null &&
52+
boardDragAttributes != null &&
53+
boardDragListeners != null
54+
55+
const body = (
56+
<div
57+
className={`flex w-full items-stretch gap-0.5 rounded-lg border border-zinc-200 border-t-[3px] bg-white text-left shadow-sm transition ${COLUMN_TOP[task.status]} hover:border-zinc-300 hover:shadow`}
58+
>
59+
{showBoardDrag ? (
60+
<button
61+
type='button'
62+
ref={boardDragSetHandle}
63+
className='touch-none cursor-grab rounded-l-md px-1 py-3 text-zinc-400 hover:bg-zinc-50 active:cursor-grabbing'
64+
aria-label='拖动以改变状态列'
65+
{...boardDragAttributes}
66+
{...boardDragListeners}
67+
>
68+
<GripVertical className='h-4 w-4 shrink-0' />
69+
</button>
70+
) : null}
4071
<button
4172
type='button'
4273
onClick={() => setDetailOpen(true)}
43-
className={`w-full rounded-lg border border-zinc-200 border-t-[3px] bg-white p-3 text-left shadow-sm transition ${COLUMN_TOP[task.status]} hover:border-zinc-300 hover:shadow`}
74+
className='min-w-0 flex-1 p-3 text-left'
4475
>
4576
<div className='flex items-start justify-between gap-2'>
4677
<span className='line-clamp-3 text-sm font-medium leading-snug text-zinc-900'>
@@ -63,7 +94,12 @@ export function TaskBoardCard({
6394
</p>
6495
) : null}
6596
</button>
97+
</div>
98+
)
6699

100+
return (
101+
<>
102+
{body}
67103
<TaskDetailSheet
68104
task={task}
69105
open={detailOpen}

src/components/tasks-board.tsx

Lines changed: 152 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
'use client'
22

3-
import { useMemo } from 'react'
3+
import {
4+
DndContext,
5+
PointerSensor,
6+
closestCorners,
7+
useDraggable,
8+
useDroppable,
9+
useSensor,
10+
useSensors,
11+
type DragEndEvent,
12+
type UniqueIdentifier,
13+
} from '@dnd-kit/core'
14+
import { CSS } from '@dnd-kit/utilities'
15+
import { useMemo, useTransition } from 'react'
16+
import { useRouter } from 'next/navigation'
17+
import { toast } from 'sonner'
18+
import { updateTaskStatusAction } from '@/app/actions/tasks'
419
import { TaskBoardCard } from '@/components/task-board-card'
520
import { Badge } from '@/components/ui/badge'
621
import type { TaskListItem, TaskStatus } from '@/types/task'
@@ -13,16 +28,107 @@ const STATUS_TITLE: Record<TaskStatus, string> = {
1328
done: '已完成',
1429
}
1530

31+
function resolveDropColumn(
32+
overId: UniqueIdentifier,
33+
tasks: TaskListItem[]
34+
): TaskStatus | null {
35+
const s = String(overId)
36+
if (s === 'todo' || s === 'doing' || s === 'done') return s
37+
const hit = tasks.find(t => t.id === s)
38+
return hit?.status ?? null
39+
}
40+
41+
function BoardDraggableCard({
42+
task,
43+
openTaskId,
44+
onStripTaskId,
45+
}: {
46+
task: TaskListItem
47+
openTaskId?: string
48+
onStripTaskId?: () => void
49+
}) {
50+
const {
51+
attributes,
52+
listeners,
53+
setNodeRef,
54+
setActivatorNodeRef,
55+
transform,
56+
isDragging,
57+
} = useDraggable({ id: task.id })
58+
59+
const style = {
60+
transform: CSS.Translate.toString(transform),
61+
opacity: isDragging ? 0.55 : undefined,
62+
zIndex: isDragging ? 20 : undefined,
63+
}
64+
65+
return (
66+
<div ref={setNodeRef} style={style}>
67+
<TaskBoardCard
68+
task={task}
69+
initialDetailOpen={openTaskId === task.id}
70+
onDetailClose={openTaskId === task.id ? onStripTaskId : undefined}
71+
boardDragSetHandle={setActivatorNodeRef}
72+
boardDragAttributes={attributes}
73+
boardDragListeners={listeners}
74+
/>
75+
</div>
76+
)
77+
}
78+
79+
function BoardColumn({
80+
status,
81+
count,
82+
children,
83+
}: {
84+
status: TaskStatus
85+
count: number
86+
children: React.ReactNode
87+
}) {
88+
const { setNodeRef, isOver } = useDroppable({ id: status })
89+
90+
return (
91+
<section
92+
className={`flex min-h-[min(60vh,28rem)] flex-col rounded-xl border bg-zinc-50/40 p-3 transition-colors ${
93+
isOver
94+
? 'border-violet-400 ring-2 ring-violet-400/30'
95+
: 'border-zinc-200'
96+
}`}
97+
>
98+
<div className='mb-3 flex items-center justify-between gap-2'>
99+
<span className='text-xs font-semibold uppercase tracking-wide text-zinc-500'>
100+
{STATUS_TITLE[status]}
101+
</span>
102+
<Badge variant='secondary'>{count}</Badge>
103+
</div>
104+
<div
105+
ref={setNodeRef}
106+
className='flex min-h-[12rem] flex-col gap-2 rounded-lg border border-transparent'
107+
>
108+
{children}
109+
</div>
110+
</section>
111+
)
112+
}
113+
16114
export function TasksBoard({
17115
tasks,
18116
openTaskId,
19117
onStripTaskId,
20118
}: {
21119
tasks: TaskListItem[]
22120
openTaskId?: string
23-
/** 关闭详情时从 URL 去掉 `taskId`(与列表行一致) */
24121
onStripTaskId?: () => void
25122
}) {
123+
const router = useRouter()
124+
const [pending, startTransition] = useTransition()
125+
126+
const sensors = useSensors(
127+
useSensor(PointerSensor, {
128+
activationConstraint: { distance: 8 },
129+
})
130+
)
131+
26132
const grouped = useMemo(() => {
27133
const m: Record<TaskStatus, TaskListItem[]> = {
28134
todo: [],
@@ -35,39 +141,59 @@ export function TasksBoard({
35141
return m
36142
}, [tasks])
37143

144+
const handleDragEnd = (event: DragEndEvent) => {
145+
const { active, over } = event
146+
if (!over || pending) return
147+
148+
const taskId = String(active.id)
149+
const nextStatus = resolveDropColumn(over.id, tasks)
150+
if (!nextStatus) return
151+
152+
const task = tasks.find(t => t.id === taskId)
153+
if (!task || task.status === nextStatus) return
154+
155+
startTransition(async () => {
156+
const res = await updateTaskStatusAction(taskId, nextStatus)
157+
if (!res.ok) {
158+
toast.error(res.error ?? '更新状态失败')
159+
return
160+
}
161+
router.refresh()
162+
toast.success(`已移至「${STATUS_TITLE[nextStatus]}」`)
163+
})
164+
}
165+
38166
return (
39-
<div className='grid grid-cols-1 gap-4 md:grid-cols-3 md:items-start'>
40-
{STATUSES.map(s => (
41-
<section
42-
key={s}
43-
className='flex min-h-[min(60vh,28rem)] flex-col rounded-xl border border-zinc-200 bg-zinc-50/40 p-3'
44-
>
45-
<div className='mb-3 flex items-center justify-between gap-2'>
46-
<span className='text-xs font-semibold uppercase tracking-wide text-zinc-500'>
47-
{STATUS_TITLE[s]}
48-
</span>
49-
<Badge variant='secondary'>{grouped[s].length}</Badge>
50-
</div>
51-
<div className='flex flex-col gap-2'>
167+
<DndContext
168+
// 固定 id:避免 useUniqueId 的全局计数在 SSR 与客户端不一致导致 aria-describedby 水合不匹配
169+
id='tasks-board-dnd'
170+
sensors={sensors}
171+
collisionDetection={closestCorners}
172+
onDragEnd={handleDragEnd}
173+
>
174+
<p className='mb-2 text-xs text-zinc-500'>
175+
拖动卡片左侧手柄到其它列即可更新状态(与列表共用同一套权限校验)。
176+
</p>
177+
<div className='grid grid-cols-1 gap-4 md:grid-cols-3 md:items-start'>
178+
{STATUSES.map(s => (
179+
<BoardColumn key={s} status={s} count={grouped[s].length}>
52180
{grouped[s].length === 0 ? (
53-
<p className='rounded-lg border border-dashed border-zinc-200 bg-white/60 py-6 text-center text-xs text-zinc-400'>
54-
无任务
181+
<p className='rounded-lg border border-dashed border-zinc-200 bg-white/60 py-8 text-center text-xs text-zinc-400'>
182+
拖入任务到此处
55183
</p>
56184
) : (
57185
grouped[s].map(task => (
58-
<TaskBoardCard
186+
<BoardDraggableCard
59187
key={task.id}
60188
task={task}
61-
initialDetailOpen={openTaskId === task.id}
62-
onDetailClose={
63-
openTaskId === task.id ? onStripTaskId : undefined
64-
}
189+
openTaskId={openTaskId}
190+
onStripTaskId={onStripTaskId}
65191
/>
66192
))
67193
)}
68-
</div>
69-
</section>
70-
))}
71-
</div>
194+
</BoardColumn>
195+
))}
196+
</div>
197+
</DndContext>
72198
)
73199
}

0 commit comments

Comments
 (0)