Skip to content

Commit 1119e79

Browse files
committed
feat(flashcards): 批量管理(选择/重学/搁置/删除)+牌组重命名+会话上限可配置
1 parent 40d7366 commit 1119e79

6 files changed

Lines changed: 275 additions & 18 deletions

File tree

‎client/src/features/flashcards/components/DeckCardList.tsx‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import { motion } from 'framer-motion';
99
import { Card, Tag } from '@/components/ui';
10-
import { BookOpen, Sparkles, Clock, CheckCircle2, Pencil, Trash2, ExternalLink } from 'lucide-react';
10+
import { BookOpen, Sparkles, Clock, CheckCircle2, Pencil, Trash2, ExternalLink, CheckSquare, Square } from 'lucide-react';
1111
import { cn } from '@/lib/utils';
1212
import { Tip } from '@/components/ui/Tip';
1313
import type { Flashcard } from '@/types/models';
@@ -70,10 +70,15 @@ export interface DeckCardListProps {
7070
onEdit: (card: Flashcard) => void;
7171
onDelete: (cardId: string | null) => void;
7272
onOpenSourceNote: (noteId: string) => void;
73+
/** 批量模式:卡片点击切换选中,隐藏编辑/删除按钮 */
74+
batchMode?: boolean;
75+
selectedIds?: Set<string>;
76+
onToggleSelect?: (id: string) => void;
7377
}
7478

7579
export function DeckCardList({
7680
cards, prefersReduced, onContextMenu, onEdit, onDelete, onOpenSourceNote,
81+
batchMode = false, selectedIds, onToggleSelect,
7782
}: DeckCardListProps) {
7883
return (
7984
<motion.div
@@ -95,9 +100,25 @@ export function DeckCardList({
95100
>
96101
<Card
97102
padding="sm"
98-
className={cn('flex items-center gap-3', evolutionClass(card))}
99-
onContextMenu={(e) => onContextMenu(e, card)}
103+
className={cn(
104+
'flex items-center gap-3',
105+
evolutionClass(card),
106+
batchMode && 'cursor-pointer',
107+
batchMode && selectedIds?.has(card.id ?? '')
108+
? 'ring-2 ring-brand-400/50 bg-brand-50/20'
109+
: undefined,
110+
)}
111+
onContextMenu={batchMode ? undefined : (e) => onContextMenu(e, card)}
112+
onClick={batchMode ? () => onToggleSelect?.(card.id ?? '') : undefined}
100113
>
114+
{/* 批量模式勾选指示 */}
115+
{batchMode && (
116+
<span className="flex-shrink-0">
117+
{selectedIds?.has(card.id ?? '')
118+
? <CheckSquare className="w-5 h-5 text-brand-500" strokeWidth={1.5} />
119+
: <Square className="w-5 h-5 text-text-tertiary/50" strokeWidth={1.5} />}
120+
</span>
121+
)}
101122
<div className="flex-1 min-w-0">
102123
<p className="text-b2 font-medium text-text-primary truncate">{card.front}</p>
103124
<div className="flex items-center gap-3 mt-1 text-c1 text-text-tertiary">
@@ -109,7 +130,7 @@ export function DeckCardList({
109130
</div>
110131
</div>
111132
<div className="flex items-center gap-1 flex-shrink-0">
112-
{card.sourceNoteId && (
133+
{card.sourceNoteId && !batchMode && (
113134
<button
114135
onClick={() => onOpenSourceNote(card.sourceNoteId!)}
115136
className="inline-flex items-center gap-1 px-2 py-1 rounded-kb-sm text-c1 text-text-tertiary hover:text-brand-600 hover:bg-brand-50 transition-all duration-kb-fast"
@@ -121,6 +142,7 @@ export function DeckCardList({
121142
</button>
122143
)}
123144
{/* 编辑卡片按钮,带 tooltip */}
145+
{!batchMode && (
124146
<Tip text="编辑卡片">
125147
<button
126148
onClick={() => onEdit(card)}
@@ -130,7 +152,9 @@ export function DeckCardList({
130152
<Pencil className="w-4 h-4" strokeWidth={1.5} />
131153
</button>
132154
</Tip>
155+
)}
133156
{/* 删除卡片按钮,带 tooltip */}
157+
{!batchMode && (
134158
<Tip text="删除卡片">
135159
<button
136160
onClick={() => onDelete(card.id ?? null)}
@@ -140,6 +164,7 @@ export function DeckCardList({
140164
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
141165
</button>
142166
</Tip>
167+
)}
143168
</div>
144169
</Card>
145170
</motion.div>

‎client/src/features/flashcards/pages/DeckDetailPage.tsx‎

Lines changed: 130 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,18 @@
77
* @ai-context: 右键菜单语义——重学=重置 SM2 状态(createNewCardState)使卡片
88
* 立即可学;搁置=到期日推后一年;删除走二次确认。
99
*/
10-
import { useEffect, useCallback, useMemo } from 'react';
10+
import { useEffect, useCallback, useMemo, useState } from 'react';
1111
import { 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';
1314
import { 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';
1517
import { useReducedMotion } from '@/hooks/useReducedMotion';
1618
import { useFlashcardStore } from '../store/useFlashcardStore';
1719
import { useShallow } from 'zustand/react/shallow';
1820
import { useContextMenu } from '@/lib/contextMenu/useContextMenu';
21+
import { useBatchSelection } from '@/hooks/useBatchSelection';
1922
import type { Flashcard } from '@/types/models';
2023
import { createNewCardState } from '@/lib/sm2';
2124
import { 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
}

‎client/src/features/flashcards/pages/FlashcardsPage.tsx‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,18 @@ const emptyVariants = {
110110

111111
export default function FlashcardsPage() {
112112
const navigate = useNavigate();
113-
const { decks, isLoading, loadDecks, createDeck, deleteDeck } = useFlashcardStore(useShallow(s => s));
113+
const { decks, isLoading, loadDecks, createDeck, renameDeck, deleteDeck } = useFlashcardStore(useShallow(s => s));
114114

115115
const [allCards, setAllCards] = useState<Flashcard[]>([]);
116116
const [modalOpen, setModalOpen] = useState(false);
117117
const [newName, setNewName] = useState('');
118118
const [newDesc, setNewDesc] = useState('');
119119
const [creating, setCreating] = useState(false);
120120
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
121+
// 牌组重命名弹窗状态(右键「编辑牌组」)
122+
const [renameTarget, setRenameTarget] = useState<FlashcardDeck | null>(null);
123+
const [renameName, setRenameName] = useState('');
124+
const [renaming, setRenaming] = useState(false);
121125
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
122126
const fileInputRef = useRef<HTMLInputElement>(null);
123127
const [importing, setImporting] = useState(false);
@@ -146,7 +150,12 @@ export default function FlashcardsPage() {
146150
const handleDeckSelect = useCallback(async (itemKey: string, deck: FlashcardDeck) => {
147151
switch (itemKey) {
148152
case 'study': navigate(`/flashcards/${deck.id}`); break;
149-
case 'edit': navigate(`/flashcards/${deck.id}`); break;
153+
case 'edit': {
154+
// 编辑牌组 = 重命名(store 已有 renameDeck,补齐 UI 入口)
155+
setRenameTarget(deck);
156+
setRenameName(deck.name);
157+
break;
158+
}
150159
case 'share': {
151160
try {
152161
const data = await exportDeck(deck.id);
@@ -264,6 +273,20 @@ export default function FlashcardsPage() {
264273
setDeleteTarget(null);
265274
};
266275

276+
// 牌组重命名保存
277+
const handleRename = async () => {
278+
if (!renameTarget || !renameName.trim() || renameName.trim() === renameTarget.name) {
279+
setRenameTarget(null);
280+
return;
281+
}
282+
setRenaming(true);
283+
try {
284+
await renameDeck(renameTarget.id, renameName.trim());
285+
toast({ type: 'success', message: `牌组已重命名为「${renameName.trim()}」`, silent: true });
286+
setRenameTarget(null);
287+
} finally { setRenaming(false); }
288+
};
289+
267290
const handlePointerDown = (id: string) => {
268291
longPressTimer.current = setTimeout(() => setDeleteTarget(id), LONG_PRESS_THRESHOLD_MS);
269292
};
@@ -559,6 +582,30 @@ export default function FlashcardsPage() {
559582
<div />
560583
</Modal>
561584

585+
{/* ── 编辑牌组(重命名)Modal ── */}
586+
<Modal
587+
open={renameTarget !== null}
588+
onClose={() => setRenameTarget(null)}
589+
title="编辑牌组"
590+
description="修改牌组名称"
591+
footer={
592+
<>
593+
<Button variant="secondary" onClick={() => setRenameTarget(null)}>取消</Button>
594+
<Button onClick={handleRename} loading={renaming} disabled={!renameName.trim() || renameName.trim() === renameTarget?.name}>保存</Button>
595+
</>
596+
}
597+
>
598+
<div className="flex flex-col gap-kb-md">
599+
<Input
600+
label="牌组名称"
601+
placeholder="例如:数据结构基础"
602+
value={renameName}
603+
onChange={(e) => setRenameName(e.target.value)}
604+
autoFocus
605+
/>
606+
</div>
607+
</Modal>
608+
562609
{/* ── 导入预览 ── */}
563610
<ImportPreviewModal
564611
open={previewOpen}

‎client/src/features/flashcards/store/useStudySessionStore.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from '@/lib/storage';
99
import { Rating } from '@/lib/sm2';
1010
import { getScheduler } from '@/lib/schedulingFactory';
11-
import { getMaxNewCardsPerDay, getMaxReviewsPerDay } from '@/lib/schedulingFactory';
11+
import { getMaxNewCardsPerDay, getMaxReviewsPerDay, getMaxSessionCards } from '@/lib/schedulingFactory';
1212
import type { Flashcard, FlashcardReview, Confidence, GoldenError } from '@/types/models';
1313
import { useFlashcardStore } from './useFlashcardStore';
1414
import { generateId } from '@/lib/utils/uuid';
@@ -21,8 +21,7 @@ import { useWorldEvents } from '@/features/retention/store/useWorldEvents';
2121

2222
/** 到期卡片不足此数量时,补充新卡 */
2323
const MIN_DUE_THRESHOLD = 10;
24-
/** 单次会话最多卡片数 */
25-
const MAX_SESSION_CARDS = 20;
24+
/** 单次会话上限:由设置页配置(kb-max-session-cards,默认 20) */
2625
/** 黄金错误加速复习:下次间隔压缩上限(天) */
2726
const GOLDEN_ERROR_MAX_INTERVAL_DAYS = 1;
2827

@@ -181,17 +180,18 @@ export const useStudySessionStore = create<StudySessionState>((set, get) => {
181180
};
182181

183182
// 组装会话卡片列表(受每日限额约束)
183+
const maxSessionCards = getMaxSessionCards();
184184
let sessionCards: Flashcard[];
185185
if (dueCards.length >= MIN_DUE_THRESHOLD) {
186-
// 到期卡充足:只用到期卡(上限 MAX_SESSION_CARDS 和 remainingReviews)
187-
const limit = Math.min(MAX_SESSION_CARDS, remainingReviews);
186+
// 到期卡充足:只用到期卡(上限 maxSessionCards 和 remainingReviews)
187+
const limit = Math.min(maxSessionCards, remainingReviews);
188188
sessionCards = dedupe(dueCards).slice(0, limit);
189189
} else {
190-
// 到期卡不足:补充新卡,总量不超过 MAX_SESSION_CARDS 和 remainingReviews
190+
// 到期卡不足:补充新卡,总量不超过 maxSessionCards 和 remainingReviews
191191
const dedupedDue = dedupe(dueCards);
192192
const dueIds = new Set(dedupedDue.map((c) => c.id!));
193193
const dedupedNew = dedupe(newCards, dueIds);
194-
const totalLimit = Math.min(MAX_SESSION_CARDS, remainingReviews);
194+
const totalLimit = Math.min(maxSessionCards, remainingReviews);
195195
const needNew = Math.min(
196196
totalLimit - dedupedDue.length,
197197
dedupedNew.length,

0 commit comments

Comments
 (0)