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'
419import { TaskBoardCard } from '@/components/task-board-card'
520import { Badge } from '@/components/ui/badge'
621import 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+
16114export 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