77 * @ai -context: 右键菜单语义——重学=重置 SM2 状态(createNewCardState)使卡片
88 * 立即可学;搁置=到期日推后一年;删除走二次确认。
99 */
10- import { useEffect , useCallback , useMemo } from 'react' ;
10+ import { useEffect , useCallback , useMemo , useState } from 'react' ;
1111import { useNavigate , useParams } from 'react-router-dom' ;
12- import { Button , EmptyState , Skeleton , useToast } from '@/components/ui' ;
12+ import { AnimatePresence , motion } from 'framer-motion' ;
13+ import { Button , EmptyState , Skeleton , useToast , Modal } from '@/components/ui' ;
1314import { ContextMenu , type ContextMenuGroup } from '@/components/ui/ContextMenu' ;
14- import { BookOpen , Plus , Pencil , Trash2 , PauseCircle , RotateCcw } from 'lucide-react' ;
15+ import { BookOpen , Plus , Pencil , Trash2 , PauseCircle , RotateCcw , Layers } from 'lucide-react' ;
16+ import { cn } from '@/lib/utils' ;
1517import { useReducedMotion } from '@/hooks/useReducedMotion' ;
1618import { useFlashcardStore } from '../store/useFlashcardStore' ;
1719import { useShallow } from 'zustand/react/shallow' ;
1820import { useContextMenu } from '@/lib/contextMenu/useContextMenu' ;
21+ import { useBatchSelection } from '@/hooks/useBatchSelection' ;
1922import type { Flashcard } from '@/types/models' ;
2023import { createNewCardState } from '@/lib/sm2' ;
2124import { DueCardStack } from '../components/DueCardStack' ;
@@ -121,6 +124,46 @@ export default function DeckDetailPage() {
121124
122125 const deleteTargetCard = cards . find ( ( c ) => c . id === dc . deleteCardId ) ;
123126
127+ // 批量管理模式(对齐萤火海沟批量交互)
128+ const batch = useBatchSelection < Flashcard > ( { items : cards } ) ;
129+ const [ batchDeleteOpen , setBatchDeleteOpen ] = useState ( false ) ;
130+
131+ // 批量删除:逐卡走 store 级联清理(复习记录 + 卡片)
132+ const handleBatchDelete = useCallback ( async ( ) => {
133+ const ids = Array . from ( batch . selectedIds ) ;
134+ if ( ids . length === 0 ) return ;
135+ await Promise . all ( ids . map ( ( id ) => deleteCard ( id ) ) ) ;
136+ setBatchDeleteOpen ( false ) ;
137+ batch . exit ( ) ;
138+ } , [ batch , deleteCard ] ) ;
139+
140+ // 批量重学:重置 SM-2 状态,使卡片立即变为可学习状态
141+ const handleBatchRelearn = useCallback ( async ( ) => {
142+ const ids = Array . from ( batch . selectedIds ) ;
143+ if ( ids . length === 0 ) return ;
144+ const fresh = createNewCardState ( ) ;
145+ await Promise . all ( ids . map ( ( id ) => updateCard ( id , {
146+ easeFactor : fresh . easeFactor ,
147+ interval : fresh . interval ,
148+ repetitions : fresh . repetitions ,
149+ lapses : fresh . lapses ,
150+ dueDate : fresh . dueDate ,
151+ } ) ) ) ;
152+ toast ( { type : 'success' , message : `已重置 ${ ids . length } 张卡片` } ) ;
153+ batch . exit ( ) ;
154+ } , [ batch , updateCard , toast ] ) ;
155+
156+ // 批量搁置:将到期日设为 1 年后
157+ const handleBatchSuspend = useCallback ( async ( ) => {
158+ const ids = Array . from ( batch . selectedIds ) ;
159+ if ( ids . length === 0 ) return ;
160+ const farFuture = new Date ( ) ;
161+ farFuture . setFullYear ( farFuture . getFullYear ( ) + 1 ) ;
162+ await Promise . all ( ids . map ( ( id ) => updateCard ( id , { dueDate : farFuture } ) ) ) ;
163+ toast ( { type : 'success' , message : `已搁置 ${ ids . length } 张卡片` } ) ;
164+ batch . exit ( ) ;
165+ } , [ batch , updateCard , toast ] ) ;
166+
124167 return (
125168 < div className = "flex flex-col h-full" >
126169 < DeckDetailHeader
@@ -136,6 +179,71 @@ export default function DeckDetailPage() {
136179
137180 < DeckStatsRow stats = { stats } />
138181
182+ { /* 批量管理模式(对齐萤火海沟批量交互) */ }
183+ < div className = "flex items-center justify-between px-kb-md py-2 flex-shrink-0" >
184+ < button
185+ onClick = { ( ) => batch . setBatchMode ( v => ! v ) }
186+ className = { cn (
187+ 'flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-colors' ,
188+ batch . batchMode
189+ ? 'bg-brand-50 border-brand-300/60 text-brand-700'
190+ : 'bg-bg-secondary border-border/40 text-text-tertiary hover:text-text-secondary' ,
191+ ) }
192+ >
193+ < Layers className = "w-3.5 h-3.5" />
194+ { batch . batchMode ? '退出批量' : '批量管理' }
195+ </ button >
196+ { batch . batchMode && batch . count > 0 && (
197+ < span className = "text-c1 text-text-tertiary" > 已选中 { batch . count } 张</ span >
198+ ) }
199+ </ div >
200+ < AnimatePresence >
201+ { batch . batchMode && (
202+ < motion . div
203+ initial = { { opacity : 0 , height : 0 } }
204+ animate = { { opacity : 1 , height : 'auto' } }
205+ exit = { { opacity : 0 , height : 0 } }
206+ className = "flex items-center gap-2 flex-wrap px-kb-md pb-2 flex-shrink-0"
207+ >
208+ < motion . button whileTap = { { scale : 0.95 } } onClick = { batch . selectAll }
209+ className = "px-2.5 py-1 rounded-full text-xs font-medium text-text-secondary bg-bg-secondary border border-border/40 hover:text-text-primary transition-colors" >
210+ 全选
211+ </ motion . button >
212+ < motion . button whileTap = { { scale : 0.95 } } onClick = { batch . clear }
213+ className = "px-2.5 py-1 rounded-full text-xs font-medium text-text-tertiary bg-bg-secondary border border-border/40 hover:text-text-secondary transition-colors" >
214+ 取消全选
215+ </ motion . button >
216+ < motion . button
217+ whileTap = { { scale : 0.95 } }
218+ onClick = { handleBatchRelearn }
219+ disabled = { batch . count === 0 }
220+ className = "flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium bg-bg-secondary border border-border/40 text-text-secondary hover:text-text-primary transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
221+ >
222+ < RotateCcw className = "w-3 h-3" strokeWidth = { 1.5 } />
223+ 重学
224+ </ motion . button >
225+ < motion . button
226+ whileTap = { { scale : 0.95 } }
227+ onClick = { handleBatchSuspend }
228+ disabled = { batch . count === 0 }
229+ className = "flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium bg-bg-secondary border border-border/40 text-text-secondary hover:text-text-primary transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
230+ >
231+ < PauseCircle className = "w-3 h-3" strokeWidth = { 1.5 } />
232+ 搁置
233+ </ motion . button >
234+ < motion . button
235+ whileTap = { { scale : 0.95 } }
236+ onClick = { ( ) => setBatchDeleteOpen ( true ) }
237+ disabled = { batch . count === 0 }
238+ className = "flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium bg-semantic-error/10 text-semantic-error border border-semantic-error/30 hover:bg-semantic-error/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
239+ >
240+ < Trash2 className = "w-3 h-3" strokeWidth = { 1.5 } />
241+ 删除
242+ </ motion . button >
243+ </ motion . div >
244+ ) }
245+ </ AnimatePresence >
246+
139247 { /* 卡片列表 */ }
140248 < div className = "flex-1 overflow-y-auto px-kb-md pb-kb-md" >
141249 { isLoading ? (
@@ -169,6 +277,9 @@ export default function DeckDetailPage() {
169277 onEdit = { dc . openEditModal }
170278 onDelete = { dc . setDeleteCardId }
171279 onOpenSourceNote = { ( noteId ) => navigate ( `/notes/${ noteId } ` ) }
280+ batchMode = { batch . batchMode }
281+ selectedIds = { batch . selectedIds }
282+ onToggleSelect = { batch . toggle }
172283 />
173284 </ >
174285 ) }
@@ -230,6 +341,22 @@ export default function DeckDetailPage() {
230341 onClose = { ( ) => dc . setDeleteCardId ( null ) }
231342 onConfirm = { dc . handleDeleteCard }
232343 />
344+
345+ { /* 批量删除确认 */ }
346+ < Modal
347+ open = { batchDeleteOpen }
348+ onClose = { ( ) => setBatchDeleteOpen ( false ) }
349+ title = "批量删除卡片"
350+ description = { `确定要删除选中的 ${ batch . count } 张卡片吗?该操作将同时删除相关复习记录,且无法撤销。` }
351+ footer = {
352+ < >
353+ < Button variant = "secondary" onClick = { ( ) => setBatchDeleteOpen ( false ) } > 取消</ Button >
354+ < Button variant = "danger" icon = { < Trash2 className = "w-4 h-4" strokeWidth = { 1.5 } /> } onClick = { handleBatchDelete } > 删除 { batch . count } 张</ Button >
355+ </ >
356+ }
357+ >
358+ < div />
359+ </ Modal >
233360 </ div >
234361 ) ;
235362}
0 commit comments