|
| 1 | +/** |
| 2 | + * PresetEditor — 番茄预设创建/编辑弹窗 |
| 3 | + * |
| 4 | + * 表单字段:名称、图标、专注/短休/长休时长、长休间隔、静默开关。 |
| 5 | + * 底部实时预览循环标记数量。 |
| 6 | + * |
| 7 | + * @ai-context: 新增组件,供 PomodoroPage "+"按钮和设置页编辑按钮调用。 |
| 8 | + * 图标选择网格预置 13 个学习相关 lucide 图标。 |
| 9 | + * @ai-context: Preset create/edit modal with live cycle-marker preview. |
| 10 | + */ |
| 11 | +import { useState } from 'react'; |
| 12 | +import { motion, AnimatePresence } from 'framer-motion'; |
| 13 | +import { X, GraduationCap, BookOpen, PenLine, BookMarked, Brain, Timer, Moon, Coffee, Dumbbell, Music, Languages, Calculator, Microscope } from 'lucide-react'; |
| 14 | +import { cn } from '@/lib/utils'; |
| 15 | +import { Button, Input } from '@/components/ui'; |
| 16 | +import CycleMarkers from './CycleMarkers'; |
| 17 | +import type { PomodoroPreset } from '@/types/models'; |
| 18 | + |
| 19 | +/** 可选图标列表 */ |
| 20 | +const ICON_OPTIONS = [ |
| 21 | + { name: 'GraduationCap', Icon: GraduationCap }, |
| 22 | + { name: 'BookOpen', Icon: BookOpen }, |
| 23 | + { name: 'PenLine', Icon: PenLine }, |
| 24 | + { name: 'BookMarked', Icon: BookMarked }, |
| 25 | + { name: 'Brain', Icon: Brain }, |
| 26 | + { name: 'Timer', Icon: Timer }, |
| 27 | + { name: 'Moon', Icon: Moon }, |
| 28 | + { name: 'Coffee', Icon: Coffee }, |
| 29 | + { name: 'Dumbbell', Icon: Dumbbell }, |
| 30 | + { name: 'Music', Icon: Music }, |
| 31 | + { name: 'Languages', Icon: Languages }, |
| 32 | + { name: 'Calculator', Icon: Calculator }, |
| 33 | + { name: 'Microscope', Icon: Microscope }, |
| 34 | +] as const; |
| 35 | + |
| 36 | +interface PresetEditorProps { |
| 37 | + open: boolean; |
| 38 | + onClose: () => void; |
| 39 | + onSave: (data: Omit<PomodoroPreset, 'id' | 'sortOrder' | 'createdAt' | 'builtin'>) => void; |
| 40 | + /** 编辑模式时传入已有预设 */ |
| 41 | + initial?: PomodoroPreset | null; |
| 42 | +} |
| 43 | + |
| 44 | +export default function PresetEditor({ open, onClose, onSave, initial }: PresetEditorProps) { |
| 45 | + const [name, setName] = useState(initial?.name ?? ''); |
| 46 | + const [icon, setIcon] = useState(initial?.icon ?? 'BookOpen'); |
| 47 | + const [workDuration, setWorkDuration] = useState(initial?.workDuration ?? 25); |
| 48 | + const [shortBreak, setShortBreak] = useState(initial?.shortBreakDuration ?? 5); |
| 49 | + const [longBreak, setLongBreak] = useState(initial?.longBreakDuration ?? 15); |
| 50 | + const [interval, setInterval_] = useState(initial?.longBreakInterval ?? 4); |
| 51 | + const [silent, setSilent] = useState(initial?.silent ?? false); |
| 52 | + |
| 53 | + const handleSubmit = () => { |
| 54 | + if (!name.trim()) return; |
| 55 | + onSave({ |
| 56 | + name: name.trim().slice(0, 8), |
| 57 | + icon, |
| 58 | + workDuration: Math.max(1, Math.min(180, workDuration)), |
| 59 | + shortBreakDuration: Math.max(1, Math.min(60, shortBreak)), |
| 60 | + longBreakDuration: Math.max(1, Math.min(60, longBreak)), |
| 61 | + longBreakInterval: Math.max(0, Math.min(12, interval)), |
| 62 | + silent, |
| 63 | + }); |
| 64 | + onClose(); |
| 65 | + }; |
| 66 | + |
| 67 | + return ( |
| 68 | + <AnimatePresence> |
| 69 | + {open && ( |
| 70 | + <motion.div |
| 71 | + className="fixed inset-0 z-50 flex items-center justify-center p-4" |
| 72 | + initial={{ opacity: 0 }} |
| 73 | + animate={{ opacity: 1 }} |
| 74 | + exit={{ opacity: 0 }} |
| 75 | + > |
| 76 | + {/* 背景遮罩 */} |
| 77 | + <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} /> |
| 78 | + |
| 79 | + {/* 弹窗主体 */} |
| 80 | + <motion.div |
| 81 | + className="relative w-full max-w-md bg-bg-elevated rounded-2xl border border-border/30 shadow-xl p-6 max-h-[85vh] overflow-y-auto" |
| 82 | + initial={{ scale: 0.95, y: 10 }} |
| 83 | + animate={{ scale: 1, y: 0 }} |
| 84 | + exit={{ scale: 0.95, y: 10 }} |
| 85 | + transition={{ duration: 0.2 }} |
| 86 | + > |
| 87 | + <div className="flex items-center justify-between mb-5"> |
| 88 | + <h2 className="text-h3 font-semibold text-text-primary"> |
| 89 | + {initial ? '编辑预设' : '新建预设'} |
| 90 | + </h2> |
| 91 | + <button onClick={onClose} className="p-1.5 rounded-full hover:bg-bg-secondary text-text-tertiary"> |
| 92 | + <X className="w-4 h-4" /> |
| 93 | + </button> |
| 94 | + </div> |
| 95 | + |
| 96 | + <div className="space-y-4"> |
| 97 | + {/* 名称 */} |
| 98 | + <Input |
| 99 | + label="预设名称" |
| 100 | + value={name} |
| 101 | + onChange={(e) => setName(e.target.value)} |
| 102 | + placeholder="如:刷题、背单词" |
| 103 | + maxLength={8} |
| 104 | + /> |
| 105 | + |
| 106 | + {/* 图标选择 */} |
| 107 | + <div> |
| 108 | + <p className="text-b2 font-medium text-text-primary mb-2">图标</p> |
| 109 | + <div className="grid grid-cols-7 gap-1.5"> |
| 110 | + {ICON_OPTIONS.map(({ name: iconName, Icon }) => ( |
| 111 | + <button |
| 112 | + key={iconName} |
| 113 | + type="button" |
| 114 | + onClick={() => setIcon(iconName)} |
| 115 | + className={cn( |
| 116 | + 'p-2 rounded-lg flex items-center justify-center transition-all', |
| 117 | + icon === iconName |
| 118 | + ? 'bg-brand-500 text-white shadow-sm' |
| 119 | + : 'bg-bg-secondary hover:bg-bg-tertiary text-text-secondary', |
| 120 | + )} |
| 121 | + > |
| 122 | + <Icon className="w-4 h-4" strokeWidth={1.5} /> |
| 123 | + </button> |
| 124 | + ))} |
| 125 | + </div> |
| 126 | + </div> |
| 127 | + |
| 128 | + {/* 时长设置 */} |
| 129 | + <div className="grid grid-cols-2 gap-3"> |
| 130 | + <Input |
| 131 | + label="专注(分钟)" |
| 132 | + type="number" |
| 133 | + value={String(workDuration)} |
| 134 | + onChange={(e) => setWorkDuration(parseInt(e.target.value) || 0)} |
| 135 | + min={1} |
| 136 | + max={180} |
| 137 | + /> |
| 138 | + <Input |
| 139 | + label="短休(分钟)" |
| 140 | + type="number" |
| 141 | + value={String(shortBreak)} |
| 142 | + onChange={(e) => setShortBreak(parseInt(e.target.value) || 0)} |
| 143 | + min={1} |
| 144 | + max={60} |
| 145 | + /> |
| 146 | + <Input |
| 147 | + label="长休(分钟)" |
| 148 | + type="number" |
| 149 | + value={String(longBreak)} |
| 150 | + onChange={(e) => setLongBreak(parseInt(e.target.value) || 0)} |
| 151 | + min={1} |
| 152 | + max={60} |
| 153 | + /> |
| 154 | + <Input |
| 155 | + label="长休间隔" |
| 156 | + type="number" |
| 157 | + value={String(interval)} |
| 158 | + onChange={(e) => setInterval_(parseInt(e.target.value) || 0)} |
| 159 | + min={0} |
| 160 | + max={12} |
| 161 | + suffix={<span className="text-text-tertiary text-c1">个</span>} |
| 162 | + /> |
| 163 | + </div> |
| 164 | + <p className="text-c1 text-text-tertiary -mt-2"> |
| 165 | + 长休间隔 = 0 表示不设长休(类似上课模式) |
| 166 | + </p> |
| 167 | + |
| 168 | + {/* 静默开关 */} |
| 169 | + <label className="flex items-center justify-between py-1 cursor-pointer"> |
| 170 | + <span className="text-b2 text-text-primary">静默模式</span> |
| 171 | + <button |
| 172 | + type="button" |
| 173 | + role="switch" |
| 174 | + aria-checked={silent} |
| 175 | + onClick={() => setSilent(!silent)} |
| 176 | + className={cn( |
| 177 | + 'relative w-11 h-6 rounded-full transition-colors', |
| 178 | + silent ? 'bg-brand-600' : 'bg-bg-tertiary border border-border/50', |
| 179 | + )} |
| 180 | + > |
| 181 | + <span className={cn( |
| 182 | + 'absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform', |
| 183 | + silent && 'translate-x-5', |
| 184 | + )} /> |
| 185 | + </button> |
| 186 | + </label> |
| 187 | + |
| 188 | + {/* 循环标记预览 */} |
| 189 | + <div className="pt-2 border-t border-border/20"> |
| 190 | + <p className="text-c1 text-text-tertiary mb-2">循环标记预览</p> |
| 191 | + <CycleMarkers total={interval > 0 ? interval : 4} filled={1} /> |
| 192 | + </div> |
| 193 | + </div> |
| 194 | + |
| 195 | + {/* 操作按钮 */} |
| 196 | + <div className="flex gap-3 mt-6"> |
| 197 | + <Button variant="secondary" className="flex-1" onClick={onClose}>取消</Button> |
| 198 | + <Button className="flex-1" onClick={handleSubmit} disabled={!name.trim()}> |
| 199 | + {initial ? '保存' : '创建'} |
| 200 | + </Button> |
| 201 | + </div> |
| 202 | + </motion.div> |
| 203 | + </motion.div> |
| 204 | + )} |
| 205 | + </AnimatePresence> |
| 206 | + ); |
| 207 | +} |
0 commit comments