Skip to content

Commit 16b38ff

Browse files
committed
refactor: flashcards 三文件(630/490/507→276/296/284)与 Onboarding 577→94/ProfileSettings 443→234 拆分
1 parent 5f63ef4 commit 16b38ff

31 files changed

Lines changed: 3286 additions & 2436 deletions
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/**
2+
* 牌组卡 — 网格中的单张牌组卡片
3+
*
4+
* @ai-context: 从 FlashcardsPage 拆出。负责牌组卡片的视觉呈现:顶部光泽/
5+
* shimmer 扫光/到期脉冲/标题区/掌握进度条/底栏统计;点击、右键、长按等
6+
* 交互回调由父级传入。掌握进度 = (total - due - new) / total。
7+
* @ai-context: Extracted from FlashcardsPage. Renders a single deck card
8+
* (gloss / shimmer / due pulse / title / mastery progress / bottom stats);
9+
* click, context-menu and long-press callbacks are provided by the parent.
10+
* Mastery progress = (total - due - new) / total.
11+
*/
12+
import { motion } from 'framer-motion';
13+
import { Tag } from '@/components/ui';
14+
import { Layers, Clock } from 'lucide-react';
15+
import { DeckCardDescription } from './DeckCardDescription';
16+
import type { FlashcardDeck } from '@/types/models';
17+
18+
/** 牌组本地统计(由父级基于全部卡片实时计算) */
19+
export interface DeckLocalStats {
20+
total: number;
21+
due: number;
22+
newCards: number;
23+
}
24+
25+
/* ── 动画 variants ── */
26+
const deckCardVariants = {
27+
hidden: { opacity: 0, y: 20, scale: 0.92 },
28+
visible: {
29+
opacity: 1, y: 0, scale: 1,
30+
transition: { duration: 0.4, ease: [0.22, 0.61, 0.36, 1] as const },
31+
},
32+
exit: { opacity: 0, scale: 0.9, transition: { duration: 0.2 } },
33+
};
34+
35+
export interface DeckCardProps {
36+
deck: FlashcardDeck;
37+
stats: DeckLocalStats;
38+
onClick: () => void;
39+
onContextMenu: (e: React.MouseEvent) => void;
40+
onPointerDown: () => void;
41+
onPointerUp: () => void;
42+
onPointerLeave: () => void;
43+
}
44+
45+
export function DeckCard({
46+
deck, stats, onClick, onContextMenu, onPointerDown, onPointerUp, onPointerLeave,
47+
}: DeckCardProps) {
48+
const progress = stats.total > 0
49+
? (stats.total - stats.due - stats.newCards) / stats.total : 0;
50+
const pct = Math.round(progress * 100);
51+
const hasDue = stats.due > 0;
52+
53+
return (
54+
<motion.div
55+
layout
56+
variants={deckCardVariants}
57+
whileHover={{ y: -4, transition: { duration: 0.2 } }}
58+
exit={deckCardVariants.exit}
59+
>
60+
<div
61+
className="group relative flex flex-col gap-3 p-kb-md rounded-[var(--kb-radius-md)]
62+
bg-bg-secondary/60 backdrop-blur-xl border border-border/30
63+
hover:border-flashcard/30 cursor-pointer overflow-hidden
64+
transition-colors duration-300"
65+
onClick={onClick}
66+
onContextMenu={onContextMenu}
67+
onPointerDown={onPointerDown}
68+
onPointerUp={onPointerUp}
69+
onPointerLeave={onPointerLeave}
70+
>
71+
{/* ── 卡片顶部光泽 ── */}
72+
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"
73+
style={{
74+
background: 'linear-gradient(135deg, rgba(123,196,184,0.06) 0%, transparent 50%, rgba(123,196,184,0.03) 100%)',
75+
}}
76+
/>
77+
{/* ── shimmer 扫光 ── */}
78+
<div className="absolute inset-0 -translate-x-full group-hover:translate-x-full transition-transform duration-[1.2s] ease-in-out pointer-events-none"
79+
style={{
80+
background: 'linear-gradient(90deg, transparent 0%, rgba(123,196,184,0.06) 50%, transparent 100%)',
81+
}}
82+
/>
83+
84+
{/* ── 到期脉冲指示 ── */}
85+
{hasDue && (
86+
<motion.div
87+
className="absolute top-3 right-3 w-2 h-2 rounded-full bg-flashcard"
88+
animate={{ scale: [1, 1.4, 1], opacity: [0.8, 0.4, 0.8] }}
89+
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
90+
/>
91+
)}
92+
93+
{/* ── 标题区 ── */}
94+
<div className="flex items-start justify-between gap-2 relative z-10">
95+
<div className="flex-1 min-w-0">
96+
<div className="flex items-center gap-2">
97+
{deck.color && (
98+
<motion.span
99+
className="w-2.5 h-2.5 rounded-kb-full flex-shrink-0 shadow-sm"
100+
style={{ backgroundColor: deck.color }}
101+
whileHover={{ scale: 1.5 }}
102+
/>
103+
)}
104+
<h3 className="text-b1 font-semibold text-text-primary truncate">{deck.name}</h3>
105+
</div>
106+
<DeckCardDescription description={deck.description} />
107+
</div>
108+
<Tag color="flashcard">{stats.total} 卡</Tag>
109+
</div>
110+
111+
{/* ── 进度条 ── */}
112+
<div className="relative z-10">
113+
<div className="flex items-center justify-between mb-1.5">
114+
<span className="text-c1 text-text-tertiary">已掌握</span>
115+
<motion.span
116+
className="text-c1 font-medium text-flashcard tabular-nums"
117+
key={pct}
118+
initial={{ opacity: 0.5, y: 4 }}
119+
animate={{ opacity: 1, y: 0 }}
120+
transition={{ duration: 0.3 }}
121+
>
122+
{pct}%
123+
</motion.span>
124+
</div>
125+
<div className="h-1.5 rounded-kb-full bg-bg-tertiary/80 overflow-hidden">
126+
<motion.div
127+
className="h-full rounded-kb-full relative overflow-hidden"
128+
style={{ background: 'linear-gradient(90deg, #7BC4B8, #5BAFA2)' }}
129+
initial={{ width: 0 }}
130+
animate={{ width: `${pct}%` }}
131+
transition={{ duration: 0.8, ease: [0.25, 0.1, 0.25, 1] as const, delay: 0.3 }}
132+
>
133+
{/* 进度条流光 */}
134+
<div className="absolute inset-0 animate-[kb-progress-shine_2s_ease-in-out_infinite]"
135+
style={{
136+
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%)',
137+
}}
138+
/>
139+
</motion.div>
140+
</div>
141+
</div>
142+
143+
{/* ── 底栏 ── */}
144+
<div className="flex items-center justify-between text-c1 text-text-tertiary pt-1 border-t border-border/20 relative z-10">
145+
<span className="flex items-center gap-1">
146+
<Layers className="w-3.5 h-3.5" strokeWidth={1.5} />
147+
共 {stats.total} 张
148+
</span>
149+
{hasDue ? (
150+
<span className="flex items-center gap-1 text-flashcard font-medium">
151+
<Clock className="w-3.5 h-3.5" strokeWidth={1.5} />
152+
{stats.due} 张到期
153+
</span>
154+
) : stats.total > 0 ? (
155+
<motion.span
156+
className="text-semantic-success font-medium"
157+
initial={{ opacity: 0 }}
158+
animate={{ opacity: 1 }}
159+
transition={{ delay: 0.5 }}
160+
>
161+
✓ 全部已学完
162+
</motion.span>
163+
) : (
164+
<span className="text-text-tertiary">暂无卡片</span>
165+
)}
166+
</div>
167+
</div>
168+
</motion.div>
169+
);
170+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* 牌组卡 — 描述行(v0.30)
3+
*
4+
* @ai-context: 从 FlashcardsPage 拆出。无描述时预留一行高度保证卡片尺寸一致;
5+
* 描述超一行时截断并展示“⋯”按钮,点击展开完整内容(scrollHeight 溢出检测)。
6+
* @ai-context: Extracted from FlashcardsPage. Reserves one line of height when
7+
* there is no description; truncates overflow with a “⋯” expand/collapse button
8+
* (overflow detected via scrollHeight/scrollWidth).
9+
*/
10+
import { useState, useRef, useLayoutEffect } from 'react';
11+
import { cn } from '@/lib/utils';
12+
13+
export function DeckCardDescription({ description }: { description?: string }) {
14+
const [expanded, setExpanded] = useState(false);
15+
const [overflows, setOverflows] = useState(false);
16+
const textRef = useRef<HTMLParagraphElement>(null);
17+
18+
// 收起状态下测量是否溢出一行(垂直换行或水平不可断行溢出均算)
19+
useLayoutEffect(() => {
20+
if (expanded) return;
21+
const el = textRef.current;
22+
if (el) {
23+
setOverflows(
24+
el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1,
25+
);
26+
}
27+
}, [description, expanded]);
28+
29+
if (!description) {
30+
// 无描述:占位一行高度(12px × 1.5 行高),确保卡片尺寸一致
31+
return <div className="mt-0.5 min-h-[1.125rem]" aria-hidden="true" />;
32+
}
33+
34+
return (
35+
<div className="flex items-start gap-1 mt-0.5">
36+
<p
37+
ref={textRef}
38+
className={cn(
39+
// break-words:无空格长串(如 URL/连续字符)也能正常断行,
40+
// 收起时配合 line-clamp-1 转为垂直溢出以便检测,展开时不撑出卡片
41+
'text-b3 text-text-tertiary flex-1 min-w-0 min-h-[1.125rem] break-words',
42+
!expanded && 'line-clamp-1',
43+
)}
44+
>
45+
{description}
46+
</p>
47+
{overflows && (
48+
<button
49+
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v); }}
50+
className="flex-shrink-0 text-b3 leading-[1.125rem] text-text-tertiary hover:text-text-primary transition-colors"
51+
title={expanded ? '收起描述' : '查看完整描述'}
52+
aria-label={expanded ? '收起描述' : '查看完整描述'}
53+
>
54+
{expanded ? '▴' : '⋯'}
55+
</button>
56+
)}
57+
</div>
58+
);
59+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* 牌组列表 — 网格容器(加载骨架 / 空态 / 卡片网格)
3+
*
4+
* @ai-context: 从 FlashcardsPage 拆出。聚合 RecoveryPackPanel(F5 中断恢复
5+
* 包)与牌组网格,按 isLoading / 空列表分三态渲染;点击、右键、长按等交互
6+
* 回调由父级传入,自身不持有业务状态。掌握统计由父级 statsFor 计算后透传。
7+
* @ai-context: Extracted from FlashcardsPage. Composes the recovery-pack
8+
* banner and the deck grid with three render states (loading / empty / grid);
9+
* interaction callbacks come from the parent and no business state lives here.
10+
*/
11+
import { motion, AnimatePresence } from 'framer-motion';
12+
import { Skeleton, EmptyState } from '@/components/ui';
13+
import { Layers3, Plus } from 'lucide-react';
14+
import { DeckCard, type DeckLocalStats } from './DeckCard';
15+
import RecoveryPackPanel from './RecoveryPackPanel';
16+
import type { FlashcardDeck } from '@/types/models';
17+
18+
/* ── 动画 variants ── */
19+
const gridVariants = {
20+
hidden: {},
21+
visible: { transition: { staggerChildren: 0.06, delayChildren: 0.12 } },
22+
};
23+
const emptyVariants = {
24+
hidden: { opacity: 0, scale: 0.9 },
25+
visible: { opacity: 1, scale: 1, transition: { duration: 0.5, ease: 'easeOut' as const } },
26+
};
27+
28+
export interface DeckCardGridProps {
29+
decks: FlashcardDeck[];
30+
isLoading: boolean;
31+
/** 按牌组 id 计算本地统计(到期/新卡/总数) */
32+
statsFor: (deckId: string) => DeckLocalStats;
33+
onNavigate: (deckId: string) => void;
34+
onContextMenu: (e: React.MouseEvent, deck: FlashcardDeck) => void;
35+
onLongPressStart: (deckId: string) => void;
36+
onLongPressEnd: () => void;
37+
onCreateClick: () => void;
38+
}
39+
40+
export function DeckCardGrid({
41+
decks, isLoading, statsFor, onNavigate, onContextMenu,
42+
onLongPressStart, onLongPressEnd, onCreateClick,
43+
}: DeckCardGridProps) { return (
44+
<div className="flex-1 overflow-y-auto px-kb-md pb-kb-lg relative z-10">
45+
{/* F5 中断恢复包:多日未复习时顶部展示回温包(内部自判条件) */}
46+
<RecoveryPackPanel />
47+
{isLoading ? (
48+
<motion.div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-kb-md"
49+
initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }}>
50+
{Array.from({ length: 6 }).map((_, i) => (
51+
<Skeleton key={i} variant="rectangular" height={160} />
52+
))}
53+
</motion.div>
54+
) : decks.length === 0 ? (
55+
<motion.div variants={emptyVariants}>
56+
<EmptyState
57+
icon={<Layers3 className="w-12 h-12" strokeWidth={1.2} />}
58+
title="记忆的泥土还在沉睡"
59+
description="创建你的第一个牌组,让知识的种子开始生根发芽"
60+
action={
61+
<motion.button
62+
whileHover={{ scale: 1.05 }}
63+
whileTap={{ scale: 0.95 }}
64+
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-b2 font-medium
65+
text-white bg-gradient-to-r from-flashcard to-flashcard/80 shadow-lg shadow-flashcard/20"
66+
onClick={onCreateClick}
67+
>
68+
<Plus className="w-icon-sm h-icon-sm" strokeWidth={2} />
69+
新建牌组
70+
</motion.button>
71+
}
72+
/>
73+
</motion.div>
74+
) : (
75+
<motion.div
76+
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-kb-md"
77+
data-allow-context-menu
78+
variants={gridVariants}
79+
>
80+
<AnimatePresence mode="popLayout">
81+
{decks.map((deck) => (
82+
<DeckCard
83+
key={deck.id}
84+
deck={deck}
85+
stats={statsFor(deck.id!)}
86+
onClick={() => onNavigate(deck.id)}
87+
onContextMenu={(e) => onContextMenu(e, deck)}
88+
onPointerDown={() => onLongPressStart(deck.id!)}
89+
onPointerUp={onLongPressEnd}
90+
onPointerLeave={onLongPressEnd}
91+
/>
92+
))}
93+
</AnimatePresence>
94+
</motion.div>
95+
)}
96+
</div>
97+
);
98+
}

0 commit comments

Comments
 (0)