duqing2026 commited on
Commit
a62012c
·
0 Parent(s):

Initial commit of Smart Flashcard Master

Browse files
Files changed (6) hide show
  1. Dockerfile +16 -0
  2. README.md +48 -0
  3. app.py +16 -0
  4. requirements.txt +2 -0
  5. static/js/app.js +401 -0
  6. templates/index.html +196 -0
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # Create a non-root user for Hugging Face Spaces
11
+ RUN useradd -m -u 1000 user
12
+ USER user
13
+ ENV HOME=/home/user \
14
+ PATH=/home/user/.local/bin:$PATH
15
+
16
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Smart Flashcard Master (智能闪卡记忆大师)
2
+
3
+ 一个简洁、高效的基于 Web 的闪卡记忆工具,采用间隔重复算法(Spaced Repetition System)帮助你长期记忆知识点。
4
+
5
+ ## ✨ 特性
6
+
7
+ - 🧠 **科学记忆**: 内置简化版 SM-2 间隔重复算法,根据你的反馈自动安排复习时间。
8
+ - 📱 **响应式设计**: 完美支持桌面端和移动端,随时随地背单词、背题。
9
+ - 🔒 **隐私安全**: 数据完全存储在本地浏览器 (LocalStorage),无需注册登录,数据自己掌握。
10
+ - 💾 **导入导出**: 支持 JSON 格式的数据备份与恢复。
11
+ - 🎨 **现代化 UI**: 简洁清爽的界面,专注学习体验。
12
+
13
+ ## 🚀 快速开始
14
+
15
+ ### 本地运行
16
+
17
+ 1. 克隆仓库
18
+ 2. 安装依赖: `pip install -r requirements.txt`
19
+ 3. 运行: `python app.py`
20
+ 4. 浏览器访问: `http://localhost:7860`
21
+
22
+ ### Docker 运行
23
+
24
+ ```bash
25
+ docker build -t flashcard-master .
26
+ docker run -p 7860:7860 flashcard-master
27
+ ```
28
+
29
+ ## 🛠️ 技术栈
30
+
31
+ - **Backend**: Python Flask (用于静态文件服务)
32
+ - **Frontend**: Vanilla JavaScript + Tailwind CSS
33
+ - **Storage**: LocalStorage (Client-side)
34
+
35
+ ## 📝 使用指南
36
+
37
+ 1. **创建卡组**: 点击首页的 "新建卡组",输入名称(如 "英语四级", "Python面试")。
38
+ 2. **添加卡片**: 进入卡组,点击编辑图标,添加正面(问题)和背面(答案)。
39
+ 3. **开始复习**: 首页点击 "开始复习",系统会自动筛选今日需要复习的卡片。
40
+ 4. **反馈评分**:
41
+ - **重来 (< 1m)**: 完全忘记,今日重来。
42
+ - **困难 (2d)**: 记得一点,但很费劲。
43
+ - **良好 (4d)**: 记得,但稍微想了一下。
44
+ - **简单 (7d)**: 秒答,非常熟悉。
45
+
46
+ ## License
47
+
48
+ MIT
app.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from flask import Flask, render_template, send_from_directory
3
+
4
+ app = Flask(__name__)
5
+
6
+ @app.route('/')
7
+ def index():
8
+ return render_template('index.html')
9
+
10
+ @app.route('/static/<path:path>')
11
+ def send_static(path):
12
+ return send_from_directory('static', path)
13
+
14
+ if __name__ == '__main__':
15
+ port = int(os.environ.get('PORT', 7860))
16
+ app.run(host='0.0.0.0', port=port)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ Flask==3.0.0
2
+ gunicorn==21.2.0
static/js/app.js ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const app = {
2
+ data: {
3
+ decks: []
4
+ },
5
+ currentDeckId: null,
6
+ currentStudySession: {
7
+ queue: [],
8
+ currentIndex: 0,
9
+ isFlipped: false
10
+ },
11
+
12
+ init() {
13
+ this.loadData();
14
+ this.renderDashboard();
15
+ },
16
+
17
+ // --- Data Management ---
18
+ loadData() {
19
+ const stored = localStorage.getItem('smart_flashcards_data');
20
+ if (stored) {
21
+ this.data = JSON.parse(stored);
22
+ } else {
23
+ // Initial Seed Data if empty
24
+ this.data = {
25
+ decks: [
26
+ {
27
+ id: this.generateId(),
28
+ name: '示例卡组: Python 基础',
29
+ cards: [
30
+ this.createCard('Python 中的列表推导式是什么?', '[expression for item in list]'),
31
+ this.createCard('Python 是解释型语言吗?', '是的,Python 代码在运行时被解释器转换。'),
32
+ this.createCard('如何定义一个函数?', 'def function_name(args):')
33
+ ]
34
+ }
35
+ ]
36
+ };
37
+ this.saveData();
38
+ }
39
+ },
40
+
41
+ saveData() {
42
+ localStorage.setItem('smart_flashcards_data', JSON.stringify(this.data));
43
+ },
44
+
45
+ generateId() {
46
+ return Date.now().toString(36) + Math.random().toString(36).substr(2);
47
+ },
48
+
49
+ createCard(front, back) {
50
+ return {
51
+ id: this.generateId(),
52
+ front,
53
+ back,
54
+ interval: 0,
55
+ repetition: 0,
56
+ efactor: 2.5,
57
+ dueDate: Date.now(), // Due immediately
58
+ state: 'new'
59
+ };
60
+ },
61
+
62
+ // --- Navigation ---
63
+ showSection(sectionId) {
64
+ ['dashboard-section', 'editor-section', 'study-section'].forEach(id => {
65
+ document.getElementById(id).classList.add('hidden');
66
+ });
67
+ document.getElementById(sectionId + '-section').classList.remove('hidden');
68
+
69
+ if (sectionId === 'dashboard') {
70
+ this.renderDashboard();
71
+ }
72
+ },
73
+
74
+ // --- Dashboard ---
75
+ renderDashboard() {
76
+ const list = document.getElementById('deck-list');
77
+ list.innerHTML = '';
78
+
79
+ this.data.decks.forEach(deck => {
80
+ const dueCount = deck.cards.filter(c => c.dueDate <= Date.now()).length;
81
+
82
+ const div = document.createElement('div');
83
+ div.className = 'bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition flex flex-col justify-between h-48 group relative';
84
+ div.innerHTML = `
85
+ <div>
86
+ <div class="flex justify-between items-start mb-2">
87
+ <h3 class="text-xl font-bold text-gray-800 truncate pr-6">${deck.name}</h3>
88
+ <div class="opacity-0 group-hover:opacity-100 transition absolute top-4 right-4">
89
+ <button onclick="event.stopPropagation(); app.deleteDeck('${deck.id}')" class="text-gray-400 hover:text-red-500 p-1">
90
+ <i class="fa-solid fa-trash"></i>
91
+ </button>
92
+ </div>
93
+ </div>
94
+ <p class="text-gray-500 text-sm">${deck.cards.length} 张卡片</p>
95
+ </div>
96
+ <div class="mt-4">
97
+ <div class="flex justify-between items-center mb-3">
98
+ <span class="text-sm font-medium text-gray-600">待复习</span>
99
+ <span class="text-lg font-bold ${dueCount > 0 ? 'text-indigo-600' : 'text-green-500'}">${dueCount}</span>
100
+ </div>
101
+ <div class="flex space-x-2">
102
+ <button onclick="app.startStudy('${deck.id}')" class="flex-1 bg-indigo-600 hover:bg-indigo-700 text-white py-2 rounded-lg text-sm font-medium transition ${dueCount === 0 ? 'opacity-50 cursor-not-allowed' : ''}" ${dueCount === 0 ? 'disabled' : ''}>
103
+ 开始复习
104
+ </button>
105
+ <button onclick="app.editDeck('${deck.id}')" class="px-3 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
106
+ <i class="fa-solid fa-pen"></i>
107
+ </button>
108
+ </div>
109
+ </div>
110
+ `;
111
+ list.appendChild(div);
112
+ });
113
+ },
114
+
115
+ openCreateDeckModal() {
116
+ document.getElementById('create-deck-modal').classList.remove('hidden');
117
+ document.getElementById('new-deck-name').focus();
118
+ },
119
+
120
+ closeCreateDeckModal() {
121
+ document.getElementById('create-deck-modal').classList.add('hidden');
122
+ document.getElementById('new-deck-name').value = '';
123
+ },
124
+
125
+ createDeck() {
126
+ const name = document.getElementById('new-deck-name').value.trim();
127
+ if (!name) return;
128
+
129
+ this.data.decks.push({
130
+ id: this.generateId(),
131
+ name: name,
132
+ cards: []
133
+ });
134
+ this.saveData();
135
+ this.closeCreateDeckModal();
136
+ this.renderDashboard();
137
+ this.showToast('卡组创建成功');
138
+ },
139
+
140
+ deleteDeck(id) {
141
+ if (!confirm('确定要删除这个卡组吗?此操作不可撤销。')) return;
142
+ this.data.decks = this.data.decks.filter(d => d.id !== id);
143
+ this.saveData();
144
+ this.renderDashboard();
145
+ this.showToast('卡组已删除');
146
+ },
147
+
148
+ // --- Deck Editor ---
149
+ editDeck(id) {
150
+ this.currentDeckId = id;
151
+ const deck = this.data.decks.find(d => d.id === id);
152
+ if (!deck) return;
153
+
154
+ document.getElementById('editor-deck-title').textContent = deck.name;
155
+ document.getElementById('card-front-input').value = '';
156
+ document.getElementById('card-back-input').value = '';
157
+ this.renderCardList(deck);
158
+ this.showSection('editor');
159
+ },
160
+
161
+ addCard() {
162
+ const front = document.getElementById('card-front-input').value.trim();
163
+ const back = document.getElementById('card-back-input').value.trim();
164
+ if (!front || !back) {
165
+ this.showToast('请填写正面和背面内容');
166
+ return;
167
+ }
168
+
169
+ const deck = this.data.decks.find(d => d.id === this.currentDeckId);
170
+ if (deck) {
171
+ deck.cards.push(this.createCard(front, back));
172
+ this.saveData();
173
+
174
+ // Reset inputs
175
+ document.getElementById('card-front-input').value = '';
176
+ document.getElementById('card-back-input').value = '';
177
+ document.getElementById('card-front-input').focus();
178
+
179
+ this.renderCardList(deck);
180
+ this.showToast('卡片添加成功');
181
+ }
182
+ },
183
+
184
+ deleteCard(cardId) {
185
+ const deck = this.data.decks.find(d => d.id === this.currentDeckId);
186
+ if (deck) {
187
+ deck.cards = deck.cards.filter(c => c.id !== cardId);
188
+ this.saveData();
189
+ this.renderCardList(deck);
190
+ }
191
+ },
192
+
193
+ renderCardList(deck) {
194
+ const list = document.getElementById('card-list');
195
+ list.innerHTML = '';
196
+ document.getElementById('card-count').textContent = deck.cards.length;
197
+
198
+ // Sort by creation time (newest first) implicitly by array order?
199
+ // Let's just reverse for display so newest is top
200
+ [...deck.cards].reverse().forEach(card => {
201
+ const li = document.createElement('li');
202
+ li.className = 'px-6 py-4 hover:bg-gray-50 flex justify-between items-center group';
203
+ li.innerHTML = `
204
+ <div class="flex-1 min-w-0 mr-4">
205
+ <p class="text-sm font-medium text-gray-900 truncate">${card.front}</p>
206
+ <p class="text-sm text-gray-500 truncate">${card.back}</p>
207
+ </div>
208
+ <button onclick="app.deleteCard('${card.id}')" class="text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition">
209
+ <i class="fa-solid fa-times"></i>
210
+ </button>
211
+ `;
212
+ list.appendChild(li);
213
+ });
214
+ },
215
+
216
+ // --- Study Mode ---
217
+ startStudy(deckId) {
218
+ const deck = this.data.decks.find(d => d.id === deckId);
219
+ if (!deck) return;
220
+
221
+ // Filter due cards
222
+ const now = Date.now();
223
+ const dueCards = deck.cards.filter(c => c.dueDate <= now);
224
+
225
+ if (dueCards.length === 0) {
226
+ this.showToast('没有需要复习的卡片');
227
+ return;
228
+ }
229
+
230
+ this.currentStudySession = {
231
+ deckId: deckId,
232
+ queue: dueCards, // Simple queue, could be randomized
233
+ currentIndex: 0,
234
+ isFlipped: false
235
+ };
236
+
237
+ document.getElementById('study-deck-title').textContent = deck.name;
238
+ document.getElementById('study-finished').classList.add('hidden');
239
+ document.getElementById('study-area').classList.remove('hidden');
240
+ this.showSection('study');
241
+ this.renderStudyCard();
242
+ },
243
+
244
+ renderStudyCard() {
245
+ const session = this.currentStudySession;
246
+ const card = session.queue[session.currentIndex];
247
+
248
+ if (!card) {
249
+ // Session finished
250
+ document.getElementById('study-area').classList.add('hidden');
251
+ document.getElementById('rating-buttons').classList.add('opacity-0', 'pointer-events-none');
252
+ document.getElementById('study-finished').classList.remove('hidden');
253
+ return;
254
+ }
255
+
256
+ document.getElementById('study-due-count').textContent = session.queue.length - session.currentIndex;
257
+
258
+ // Reset Flip
259
+ const cardEl = document.getElementById('active-card');
260
+ cardEl.classList.remove('flipped');
261
+ session.isFlipped = false;
262
+
263
+ // Hide buttons
264
+ document.getElementById('rating-buttons').classList.add('opacity-0', 'pointer-events-none');
265
+
266
+ // Set Content (Delay slightly to allow flip back animation if coming from prev card)
267
+ setTimeout(() => {
268
+ document.getElementById('study-front-content').textContent = card.front;
269
+ document.getElementById('study-back-content').textContent = card.back;
270
+ }, 200);
271
+ },
272
+
273
+ flipCard() {
274
+ if (this.currentStudySession.isFlipped) return; // Already flipped
275
+
276
+ const cardEl = document.getElementById('active-card');
277
+ cardEl.classList.add('flipped');
278
+ this.currentStudySession.isFlipped = true;
279
+
280
+ // Show buttons
281
+ document.getElementById('rating-buttons').classList.remove('opacity-0', 'pointer-events-none');
282
+ },
283
+
284
+ rateCard(grade) {
285
+ const session = this.currentStudySession;
286
+ const card = session.queue[session.currentIndex];
287
+ const deck = this.data.decks.find(d => d.id === session.deckId);
288
+ const realCard = deck.cards.find(c => c.id === card.id); // Get reference to modify
289
+
290
+ this.updateCardSRS(realCard, grade);
291
+ this.saveData();
292
+
293
+ session.currentIndex++;
294
+ this.renderStudyCard();
295
+ },
296
+
297
+ updateCardSRS(card, grade) {
298
+ // Simple SM-2 Implementation
299
+ // Grade: 1=Again, 2=Hard, 3=Good, 4=Easy
300
+
301
+ if (grade < 3) {
302
+ card.repetition = 0;
303
+ card.interval = 1; // 1 day interval for failure (simplified)
304
+ } else {
305
+ // Update E-Factor
306
+ // EF' = EF + (0.1 - (5-q)*(0.08+(5-q)*0.02))
307
+ // q = grade (using 1-4 scale mapping to SM2 0-5 roughly?)
308
+ // Let's just use standard formula but map 1-4 to something useful
309
+ // Map: 1->0, 2->3, 3->4, 4->5? Or just use simplified logic
310
+
311
+ // Simplified Logic for robustness:
312
+ if (grade === 3) {
313
+ // Good
314
+ card.efactor = Math.max(1.3, card.efactor); // Keep same
315
+ } else if (grade === 4) {
316
+ // Easy - Increase EF
317
+ card.efactor = card.efactor + 0.15;
318
+ }
319
+
320
+ card.repetition += 1;
321
+
322
+ if (card.repetition === 1) {
323
+ card.interval = 1;
324
+ } else if (card.repetition === 2) {
325
+ card.interval = 6;
326
+ } else {
327
+ card.interval = Math.round(card.interval * card.efactor);
328
+ }
329
+ }
330
+
331
+ // Set new Due Date (Now + Interval days)
332
+ // If grade is 1 (Again), maybe we should show it again in same session?
333
+ // For simplicity, we just push it to tomorrow (interval=1)
334
+ const ONE_DAY = 24 * 60 * 60 * 1000;
335
+
336
+ // If "Again" (1), usually we want to see it again soon (e.g. 1 min), but for this MVP,
337
+ // we'll just set it to "Due Now" effectively if we want loop, or "Due Tomorrow" if we follow strict daily.
338
+ // Let's make "Again" mean "Show me again at end of queue"?
339
+ // For this MVP, let's stick to "Due Tomorrow" for simplicity.
340
+
341
+ // Actually, "Again" should probably reset interval to 0 (Due immediately) but maybe not show immediately in UI to avoid infinite loop without shuffle.
342
+ // Let's set to tomorrow for "Again" to avoid frustration in simple version.
343
+ // Or better: If grade < 3, interval = 0 (Today), but since we filtered "due <= now" at start of session, it won't reappear in *this* session unless we push to queue.
344
+ // Let's just set it to tomorrow.
345
+
346
+ card.dueDate = Date.now() + (card.interval * ONE_DAY);
347
+ },
348
+
349
+ // --- Utils ---
350
+ showToast(message) {
351
+ const toast = document.getElementById('toast');
352
+ toast.textContent = message;
353
+ toast.classList.remove('translate-y-20');
354
+ setTimeout(() => {
355
+ toast.classList.add('translate-y-20');
356
+ }, 3000);
357
+ },
358
+
359
+ exportData() {
360
+ const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(this.data));
361
+ const downloadAnchorNode = document.createElement('a');
362
+ downloadAnchorNode.setAttribute("href", dataStr);
363
+ downloadAnchorNode.setAttribute("download", "smart_flashcards_backup.json");
364
+ document.body.appendChild(downloadAnchorNode);
365
+ downloadAnchorNode.click();
366
+ downloadAnchorNode.remove();
367
+ },
368
+
369
+ importData(input) {
370
+ const file = input.files[0];
371
+ if (!file) return;
372
+
373
+ const reader = new FileReader();
374
+ reader.onload = (e) => {
375
+ try {
376
+ const imported = JSON.parse(e.target.result);
377
+ if (imported.decks && Array.isArray(imported.decks)) {
378
+ // Merge or Replace? Let's Replace for simplicity
379
+ if (confirm('导入将覆盖当前所有数据,确定继续吗?')) {
380
+ this.data = imported;
381
+ this.saveData();
382
+ this.renderDashboard();
383
+ this.showToast('数据导入成功');
384
+ }
385
+ } else {
386
+ alert('文件格式不正确');
387
+ }
388
+ } catch (err) {
389
+ alert('无法解析文件');
390
+ }
391
+ };
392
+ reader.readAsText(file);
393
+ // Reset input
394
+ input.value = '';
395
+ }
396
+ };
397
+
398
+ // Initialize
399
+ document.addEventListener('DOMContentLoaded', () => {
400
+ app.init();
401
+ });
templates/index.html ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>智能闪卡记忆大师 (Smart Flashcard Master)</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
9
+ <style>
10
+ .card-flip {
11
+ perspective: 1000px;
12
+ }
13
+ .card-inner {
14
+ transition: transform 0.6s;
15
+ transform-style: preserve-3d;
16
+ }
17
+ .card-flip.flipped .card-inner {
18
+ transform: rotateY(180deg);
19
+ }
20
+ .card-front, .card-back {
21
+ backface-visibility: hidden;
22
+ }
23
+ .card-back {
24
+ transform: rotateY(180deg);
25
+ }
26
+ /* Custom Scrollbar */
27
+ ::-webkit-scrollbar {
28
+ width: 8px;
29
+ }
30
+ ::-webkit-scrollbar-track {
31
+ background: #f1f1f1;
32
+ }
33
+ ::-webkit-scrollbar-thumb {
34
+ background: #cbd5e1;
35
+ border-radius: 4px;
36
+ }
37
+ ::-webkit-scrollbar-thumb:hover {
38
+ background: #94a3b8;
39
+ }
40
+ </style>
41
+ </head>
42
+ <body class="bg-gray-50 text-gray-800 min-h-screen flex flex-col">
43
+
44
+ <!-- Navbar -->
45
+ <nav class="bg-white shadow-sm border-b border-gray-200">
46
+ <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
47
+ <div class="flex justify-between h-16">
48
+ <div class="flex items-center">
49
+ <i class="fa-solid fa-brain text-indigo-600 text-2xl mr-3"></i>
50
+ <h1 class="text-xl font-bold text-gray-900">记忆大师 <span class="text-xs text-gray-500 font-normal">v1.0</span></h1>
51
+ </div>
52
+ <div class="flex items-center space-x-4">
53
+ <button onclick="app.showSection('dashboard')" class="px-3 py-2 rounded-md text-sm font-medium hover:text-indigo-600 transition">我的卡组</button>
54
+ <button onclick="app.showStats()" class="px-3 py-2 rounded-md text-sm font-medium hover:text-indigo-600 transition">统计</button>
55
+ <button onclick="app.exportData()" class="px-3 py-2 rounded-md text-sm font-medium hover:text-indigo-600 transition" title="导出数据"><i class="fa-solid fa-download"></i></button>
56
+ <button onclick="document.getElementById('fileInput').click()" class="px-3 py-2 rounded-md text-sm font-medium hover:text-indigo-600 transition" title="导入数据"><i class="fa-solid fa-upload"></i></button>
57
+ <input type="file" id="fileInput" class="hidden" accept=".json" onchange="app.importData(this)">
58
+ </div>
59
+ </div>
60
+ </div>
61
+ </nav>
62
+
63
+ <!-- Main Content -->
64
+ <main class="flex-grow max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full">
65
+
66
+ <!-- Dashboard Section -->
67
+ <div id="dashboard-section" class="space-y-6">
68
+ <div class="flex justify-between items-center">
69
+ <h2 class="text-2xl font-bold text-gray-800">我的卡组</h2>
70
+ <button onclick="app.openCreateDeckModal()" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg shadow transition flex items-center">
71
+ <i class="fa-solid fa-plus mr-2"></i> 新建卡组
72
+ </button>
73
+ </div>
74
+
75
+ <div id="deck-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
76
+ <!-- Deck items will be injected here -->
77
+ </div>
78
+ </div>
79
+
80
+ <!-- Deck Editor Section -->
81
+ <div id="editor-section" class="hidden space-y-6">
82
+ <div class="flex items-center mb-6">
83
+ <button onclick="app.showSection('dashboard')" class="mr-4 text-gray-500 hover:text-gray-700">
84
+ <i class="fa-solid fa-arrow-left text-xl"></i>
85
+ </button>
86
+ <h2 id="editor-deck-title" class="text-2xl font-bold text-gray-800">编辑卡组</h2>
87
+ </div>
88
+
89
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
90
+ <div class="flex space-x-4 mb-4">
91
+ <div class="flex-1">
92
+ <label class="block text-sm font-medium text-gray-700 mb-1">正面 (问题)</label>
93
+ <textarea id="card-front-input" rows="3" class="w-full border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2 border" placeholder="输入问题..."></textarea>
94
+ </div>
95
+ <div class="flex-1">
96
+ <label class="block text-sm font-medium text-gray-700 mb-1">背面 (答案)</label>
97
+ <textarea id="card-back-input" rows="3" class="w-full border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2 border" placeholder="输入答案..."></textarea>
98
+ </div>
99
+ </div>
100
+ <button onclick="app.addCard()" class="w-full bg-indigo-50 text-indigo-700 hover:bg-indigo-100 py-2 rounded-lg transition border border-indigo-200 font-medium">
101
+ <i class="fa-solid fa-plus mr-1"></i> 添加卡片
102
+ </button>
103
+ </div>
104
+
105
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
106
+ <div class="px-6 py-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
107
+ <h3 class="font-semibold text-gray-700">卡片列表 (<span id="card-count">0</span>)</h3>
108
+ <span class="text-xs text-gray-500">点击卡片可编辑</span>
109
+ </div>
110
+ <ul id="card-list" class="divide-y divide-gray-200 max-h-[500px] overflow-y-auto">
111
+ <!-- Cards will be listed here -->
112
+ </ul>
113
+ </div>
114
+ </div>
115
+
116
+ <!-- Study Mode Section -->
117
+ <div id="study-section" class="hidden h-full flex flex-col items-center justify-center max-w-3xl mx-auto w-full">
118
+ <div class="w-full flex justify-between items-center mb-6">
119
+ <button onclick="app.showSection('dashboard')" class="text-gray-500 hover:text-gray-700">
120
+ <i class="fa-solid fa-times text-xl"></i> 退出
121
+ </button>
122
+ <div class="text-center">
123
+ <h2 id="study-deck-title" class="text-xl font-bold text-gray-800">卡组名称</h2>
124
+ <p class="text-sm text-gray-500">待复习: <span id="study-due-count" class="font-medium text-indigo-600">0</span></p>
125
+ </div>
126
+ <div class="w-8"></div> <!-- Spacer -->
127
+ </div>
128
+
129
+ <div id="study-area" class="w-full aspect-[4/3] md:aspect-[16/9] relative perspective cursor-pointer group" onclick="app.flipCard()">
130
+ <div id="active-card" class="card-flip w-full h-full relative">
131
+ <div class="card-inner w-full h-full absolute transition-all duration-500 shadow-xl rounded-2xl">
132
+ <!-- Front -->
133
+ <div class="card-front w-full h-full absolute bg-white rounded-2xl border border-gray-200 flex flex-col items-center justify-center p-8 text-center backface-hidden">
134
+ <span class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-4">Question</span>
135
+ <div id="study-front-content" class="text-2xl md:text-4xl font-medium text-gray-800">Loading...</div>
136
+ <div class="mt-8 text-sm text-gray-400">点击翻转</div>
137
+ </div>
138
+ <!-- Back -->
139
+ <div class="card-back w-full h-full absolute bg-indigo-50 rounded-2xl border border-indigo-100 flex flex-col items-center justify-center p-8 text-center backface-hidden">
140
+ <span class="text-xs font-semibold text-indigo-400 uppercase tracking-wider mb-4">Answer</span>
141
+ <div id="study-back-content" class="text-xl md:text-3xl text-gray-800">Loading...</div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+
147
+ <!-- Rating Buttons -->
148
+ <div id="rating-buttons" class="mt-8 grid grid-cols-4 gap-4 w-full opacity-0 pointer-events-none transition-opacity duration-300">
149
+ <button onclick="app.rateCard(1)" class="flex flex-col items-center p-3 bg-red-50 hover:bg-red-100 text-red-700 rounded-lg border border-red-200 transition">
150
+ <span class="font-bold">重来</span>
151
+ <span class="text-xs mt-1 text-red-400">&lt; 1m</span>
152
+ </button>
153
+ <button onclick="app.rateCard(2)" class="flex flex-col items-center p-3 bg-orange-50 hover:bg-orange-100 text-orange-700 rounded-lg border border-orange-200 transition">
154
+ <span class="font-bold">困难</span>
155
+ <span class="text-xs mt-1 text-orange-400">2d</span>
156
+ </button>
157
+ <button onclick="app.rateCard(3)" class="flex flex-col items-center p-3 bg-blue-50 hover:bg-blue-100 text-blue-700 rounded-lg border border-blue-200 transition">
158
+ <span class="font-bold">良好</span>
159
+ <span class="text-xs mt-1 text-blue-400">4d</span>
160
+ </button>
161
+ <button onclick="app.rateCard(4)" class="flex flex-col items-center p-3 bg-green-50 hover:bg-green-100 text-green-700 rounded-lg border border-green-200 transition">
162
+ <span class="font-bold">简单</span>
163
+ <span class="text-xs mt-1 text-green-400">7d</span>
164
+ </button>
165
+ </div>
166
+
167
+ <div id="study-finished" class="hidden text-center py-20">
168
+ <div class="text-6xl text-green-500 mb-4"><i class="fa-solid fa-check-circle"></i></div>
169
+ <h2 class="text-3xl font-bold text-gray-800 mb-2">太棒了!</h2>
170
+ <p class="text-gray-600 mb-8">你已经完成了该卡组今日的所有复习任务。</p>
171
+ <button onclick="app.showSection('dashboard')" class="bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition">返回首页</button>
172
+ </div>
173
+ </div>
174
+
175
+ </main>
176
+
177
+ <!-- Create Deck Modal -->
178
+ <div id="create-deck-modal" class="fixed inset-0 bg-gray-900 bg-opacity-50 hidden flex items-center justify-center z-50">
179
+ <div class="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
180
+ <h3 class="text-xl font-bold text-gray-900 mb-4">新建卡组</h3>
181
+ <input type="text" id="new-deck-name" class="w-full border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2 border mb-4" placeholder="卡组名称 (例如: 英语单词, Python面试题)">
182
+ <div class="flex justify-end space-x-3">
183
+ <button onclick="app.closeCreateDeckModal()" class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition">取消</button>
184
+ <button onclick="app.createDeck()" class="px-4 py-2 bg-indigo-600 text-white hover:bg-indigo-700 rounded-lg transition">创建</button>
185
+ </div>
186
+ </div>
187
+ </div>
188
+
189
+ <!-- Toast Notification -->
190
+ <div id="toast" class="fixed bottom-5 right-5 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg transform translate-y-20 transition-transform duration-300 z-50">
191
+ Operation successful
192
+ </div>
193
+
194
+ <script src="/static/js/app.js"></script>
195
+ </body>
196
+ </html>