// server/routes/vocabulary.ts import express from 'express'; import multer from 'multer'; import fs from 'fs'; import path from 'path'; import { auth as authMiddleware } from '../middleware/auth'; import { Word, WordSet, WordRecord, VocabularyTestRecord } from '../models/Vocabulary'; import mongoose from 'mongoose'; import csv from 'csv-parser'; import { User } from '../models/User'; const router = express.Router(); // 配置 multer 用于文件上传 const storage = multer.diskStorage({ destination: function (req, file, cb) { const uploadDir = path.join(__dirname, '../uploads'); // 确保上传目录存在 if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } cb(null, uploadDir); }, filename: function (req, file, cb) { cb(null, `${Date.now()}-${file.originalname}`); } }); const upload = multer({ storage: storage, limits: { fileSize: 5 * 1024 * 1024 // 限制5MB }, fileFilter: function (req, file, cb) { // 只允许上传CSV文件 if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) { cb(null, true); } else { cb(new Error('只支持CSV文件')); } } }); // 获取所有单词集 router.get('/word-sets', authMiddleware, async (req, res) => { try { const wordSets = await WordSet.find() .select('name description totalWords createdAt') .sort({ createdAt: -1 }); res.json(wordSets); } catch (error) { console.error('获取单词集失败:', error); res.status(500).json({ message: '获取单词集失败' }); } }); // 创建新的单词集 router.post('/word-sets', authMiddleware, async (req, res) => { try { const { name, description } = req.body; // 检查是否已存在同名单词集 const existingSet = await WordSet.findOne({ name: name }); if (existingSet) { return res.status(400).json({ message: '已存在同名单词集' }); } const newWordSet = await WordSet.create({ name, description, totalWords: 0 }); res.status(201).json(newWordSet); } catch (error) { console.error('创建单词集失败:', error); res.status(500).json({ message: '创建单词集失败' }); } }); // 上传单词文件并创建单词集 router.post('/upload', authMiddleware, upload.single('file'), async (req, res) => { if (!req.file) { return res.status(400).json({ message: '请上传文件' }); } try { const { name } = req.body; const fileName = req.file.filename; const filePath = req.file.path; // 检查是否已存在同名单词集 const existingSet = await WordSet.findOne({ name: name || path.basename(fileName, '.csv') }); if (existingSet) { // 删除上传的文件 fs.unlinkSync(filePath); return res.status(400).json({ message: '已存在同名单词集' }); } // 创建单词集 const wordSet = await WordSet.create({ name: name || path.basename(fileName, '.csv'), totalWords: 0 }); const results: any[] = []; let wordCount = 0; // 处理CSV文件 fs.createReadStream(filePath) .pipe(csv()) .on('data', (data) => { // 检查必要的字段是否存在 if (data.word && data.translation) { results.push({ word: data.word.trim(), translation: data.translation.trim(), pronunciation: data.pronunciation?.trim(), example: data.example?.trim(), wordSet: wordSet._id }); wordCount++; } }) .on('end', async () => { try { // 批量插入单词 if (results.length > 0) { await Word.insertMany(results); // 更新单词集的单词数量 await WordSet.findByIdAndUpdate(wordSet._id, { totalWords: wordCount }); } // 删除上传的文件 fs.unlinkSync(filePath); res.status(201).json({ message: `成功创建单词集并导入${wordCount}个单词`, wordSet }); } catch (error) { // 如果插入单词失败,删除创建的单词集 await WordSet.findByIdAndDelete(wordSet._id); console.error('导入单词失败:', error); res.status(500).json({ message: '导入单词失败' }); } }) .on('error', (error) => { console.error('处理CSV文件失败:', error); res.status(500).json({ message: '处理CSV文件失败' }); }); } catch (error) { console.error('上传文件失败:', error); res.status(500).json({ message: '上传文件失败' }); } }); // 获取学习单词(智能抽取算法,嵌套结构版) router.get('/study-words/:wordSetId', authMiddleware, async (req, res) => { try { const { wordSetId } = req.params; let targetCount = 100; if (req.query.count) { const parsed = parseInt(req.query.count as string, 10); if (!isNaN(parsed) && parsed > 0) { targetCount = Math.min(parsed, 100); } } // 检查单词集是否存在 const wordSet = await WordSet.findOne({ _id: wordSetId }); if (!wordSet) { return res.status(404).json({ message: '未找到单词集' }); } // 1. 获取该单词集下所有单词 const allWords = await Word.find({ wordSet: wordSetId }); const allWordIds = allWords.map(w => w._id.toString()); // 2. 获取该用户所有WordRecord(嵌套结构) const allRecords = await WordRecord.find({ user: req.user._id, word: { $in: allWordIds } }); // 用 wordId 做 key const recordMap = new Map(); allRecords.forEach(r => { recordMap.set(r.word.toString(), r); }); // 3. 分类 const wrongBookWords: any[] = []; const notMasteredWords: any[] = []; const masteredWords: { word: any, lastMasteredAt: Date }[] = []; const neverLearnedWords: any[] = []; for (const word of allWords) { const wordId = word._id.toString(); const record = recordMap.get(wordId); // 没有任何记录 if (!record) { neverLearnedWords.push(word); continue; } // 判断是否在错词本 const modes = ['chineseToEnglish', 'audioToEnglish', 'multipleChoice']; const anyInWrongBook = modes.some(m => record[m]?.inWrongBook); if (anyInWrongBook) { wrongBookWords.push(word); continue; } // 判断是否全部掌握 if (record.isFullyMastered) { masteredWords.push({ word, lastMasteredAt: record.lastFullyMasteredAt }); continue; } // 未完全掌握但有学习记录 if (modes.some(m => record[m] && Object.keys(record[m]).length > 0)) { notMasteredWords.push(word); continue; } } // 过滤一周内掌握的单词 const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); let eligibleMasteredWords = masteredWords.filter(item => item.lastMasteredAt && item.lastMasteredAt < oneWeekAgo ); // 4. 按比例抽取 let wrongBookQuota = Math.round(targetCount * 0.4); let masteredQuota = Math.round(targetCount * 0.1); let neverLearnedQuota = targetCount - wrongBookQuota - masteredQuota; // 剩下的给未学和新词 // 错词本优先 let selectedWrongBook = wrongBookWords.slice(0, wrongBookQuota); let selectedMastered = eligibleMasteredWords .sort((a, b) => (a.lastMasteredAt?.getTime() || 0) - (b.lastMasteredAt?.getTime() || 0)) .slice(0, masteredQuota) .map(item => item.word); function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } let selectedNeverLearned = shuffle([...neverLearnedWords]).slice(0, neverLearnedQuota); // 补足不足部分 let remain = targetCount - (selectedWrongBook.length + selectedMastered.length + selectedNeverLearned.length); // 先补未掌握(不在错词本的) let notMasteredPool = notMasteredWords.filter(w => !selectedWrongBook.includes(w)); let selectedNotMastered: any[] = []; if (remain > 0 && notMasteredPool.length > 0) { selectedNotMastered = notMasteredPool.slice(0, remain); remain -= selectedNotMastered.length; } // 再补新词 if (remain > 0) { const moreNew = neverLearnedWords.slice(neverLearnedQuota, neverLearnedQuota + remain); selectedNeverLearned = selectedNeverLearned.concat(moreNew); remain -= moreNew.length; } // 再补已掌握 if (remain > 0) { const moreMastered = eligibleMasteredWords .sort((a, b) => (a.lastMasteredAt?.getTime() || 0) - (b.lastMasteredAt?.getTime() || 0)) .slice(masteredQuota, masteredQuota + remain) .map(item => item.word); selectedMastered = selectedMastered.concat(moreMastered); remain -= moreMastered.length; } // 再补错词本 if (remain > 0) { const moreWrong = wrongBookWords.slice(wrongBookQuota, wrongBookQuota + remain); selectedWrongBook = selectedWrongBook.concat(moreWrong); remain -= moreWrong.length; } // 合并所有选中的单词 let finalWords = [ ...selectedWrongBook, ...selectedNotMastered, ...selectedMastered, ...selectedNeverLearned ]; // 去重 const seen = new Set(); finalWords = finalWords.filter(w => { const id = w._id.toString(); if (seen.has(id)) return false; seen.add(id); return true; }); // 最终数量限制 finalWords = finalWords.slice(0, targetCount); res.json(finalWords); } catch (error) { console.error('获取学习单词失败:', error); res.status(500).json({ message: '获取学习单词失败' }); } }); // 记录单词学习结果(嵌套结构版,upsert防止重复) router.post('/word-record', (req, res, next) => { console.log('收到 /word-record 请求', req.method, req.body); next(); }, authMiddleware, async (req, res) => { try { const { wordId, isCorrect, testType } = req.body; const userId = req.user._id; // 检查单词是否存在 const word = await Word.findById(wordId); if (!word) { return res.status(404).json({ message: '未找到单词' }); } // 嵌套字段名映射 const modeMap = { 'chinese-to-english': 'chineseToEnglish', 'audio-to-english': 'audioToEnglish', 'multiple-choice': 'multipleChoice' }; const modeKey = modeMap[testType]; if (!modeKey) { return res.status(400).json({ message: '未知的测试类型' }); } // 先查当前记录 let record = await WordRecord.findOne({ user: userId, word: wordId }); let modeObj = record ? (record[modeKey] || {}) : {}; // 更新 streak、totalCorrect、totalWrong if (isCorrect) { modeObj.streak = (modeObj.streak || 0) + 1; modeObj.totalCorrect = (modeObj.totalCorrect || 0) + 1; } else { modeObj.streak = 0; modeObj.totalWrong = (modeObj.totalWrong || 0) + 1; } modeObj.lastTestedAt = new Date(); // 判定是否掌握 if (modeObj.streak >= 1) { modeObj.mastered = true; modeObj.lastMasteredAt = new Date(); modeObj.inWrongBook = false; // 掌握后自动移出错词本 } // 判定是否进入错词本 if (!modeObj.mastered && modeObj.totalWrong >= 5) { modeObj.inWrongBook = true; } // 构造更新对象 const updateObj = {}; updateObj[modeKey] = modeObj; record = await WordRecord.findOneAndUpdate( { user: userId, word: wordId }, { $set: updateObj, $setOnInsert: { user: userId, word: wordId, createdAt: new Date() } }, { upsert: true, new: true } ); // 检查三种模式是否都已掌握 const allModes = ['chineseToEnglish', 'audioToEnglish', 'multipleChoice']; const isFullyMastered = allModes.every(m => record[m]?.mastered); let lastMasteredAt: Date | null = null; if (isFullyMastered) { // 取三种模式中最近一次掌握的时间 lastMasteredAt = allModes.reduce((latest: Date | null, m) => { const t = record[m]?.lastMasteredAt; if (!latest && t) return t; if (t instanceof Date && latest instanceof Date && t > latest) { return t; } return latest; }, null); // 取三种模式中最近一次测试的时间,如果比掌握时间更新则使用测试时间 const lastTestedAt = allModes.reduce((latest: Date | null, m) => { const t = record[m]?.lastTestedAt; if (!latest && t) return t; if (t instanceof Date && latest instanceof Date && t > latest) { return t; } return latest; }, null); // 使用最新的日期(掌握时间或测试时间) if (lastTestedAt && (!lastMasteredAt || lastTestedAt > lastMasteredAt)) { lastMasteredAt = lastTestedAt; } // 更新isFullyMastered和lastFullyMasteredAt字段 if (!record.isFullyMastered || !record.lastFullyMasteredAt || (lastMasteredAt && record.lastFullyMasteredAt < lastMasteredAt)) { record = await WordRecord.findOneAndUpdate( { _id: record._id }, { $set: { isFullyMastered: true, lastFullyMasteredAt: lastMasteredAt } }, { new: true } ); } } else if (record.isFullyMastered) { // 如果之前标记为完全掌握,但现在不是,更新状态 record = await WordRecord.findOneAndUpdate( { _id: record._id }, { $set: { isFullyMastered: false } }, { new: true } ); } // 返回当前单词的所有模式掌握状态和是否在错词本 const masteryStatus = {}; allModes.forEach(m => { masteryStatus[m] = { mastered: record[m]?.mastered || false, streak: record[m]?.streak || 0, totalCorrect: record[m]?.totalCorrect || 0, totalWrong: record[m]?.totalWrong || 0, inWrongBook: record[m]?.inWrongBook || false, lastMasteredAt: record[m]?.lastMasteredAt || null }; }); res.status(201).json({ message: '记录已保存', masteryStatus, isFullyMastered: record.isFullyMastered, lastMasteredAt: record.lastFullyMasteredAt, inWrongBook: allModes.some(m => record[m]?.inWrongBook) }); } catch (error) { console.error('保存单词学习记录失败:', error); res.status(500).json({ message: '保存单词学习记录失败' }); } }); // 保存测试记录 router.post('/test-record', authMiddleware, async (req, res) => { try { const { wordSetId, testType, stats } = req.body; // 检查单词集是否存在并属于当前用户 const wordSet = await WordSet.findOne({ _id: wordSetId }); if (!wordSet) { return res.status(404).json({ message: '未找到单词集' }); } // 创建测试记录 await VocabularyTestRecord.create({ user: req.user._id, wordSet: wordSetId, testType, stats: { totalWords: stats.totalWords, correctWords: stats.correctWords, accuracy: stats.accuracy, startTime: new Date(stats.startTime), endTime: new Date(stats.endTime), duration: stats.duration } }); res.status(201).json({ message: '测试记录已保存' }); } catch (error) { console.error('保存测试记录失败:', error); res.status(500).json({ message: '保存测试记录失败' }); } }); // 获取单词详情 router.get('/word/:wordId', authMiddleware, async (req, res) => { try { const { wordId } = req.params; const word = await Word.findById(wordId); if (!word) { return res.status(404).json({ message: '未找到单词' }); } // 检查用户是否有权限访问这个单词 const wordSet = await WordSet.findOne({ _id: word.wordSet }); if (!wordSet) { return res.status(403).json({ message: '没有权限访问该单词' }); } res.json(word); } catch (error) { console.error('获取单词详情失败:', error); res.status(500).json({ message: '获取单词详情失败' }); } }); // 获取单词测试记录 router.get('/test-records', authMiddleware, async (req, res) => { try { const testRecords = await VocabularyTestRecord.find({ user: req.user._id }).populate('wordSet', 'name').sort({ createdAt: -1 }); res.json(testRecords); } catch (error) { console.error('获取测试记录失败:', error); res.status(500).json({ message: '获取测试记录失败' }); } }); // 排行榜接口:按掌握单词数和正确率排序 router.get('/leaderboard', authMiddleware, async (req, res) => { try { // 1. 查出所有 WordRecord const allRecords = await WordRecord.find({}); // 2. 直接使用isFullyMastered统计每个用户掌握的单词数 const userMasteredCount = {}; const userStats = {}; for (const rec of allRecords) { const userId = rec.user.toString(); // 初始化用户统计数据 if (!userStats[userId]) { userStats[userId] = { correct: 0, total: 0 }; } // 使用isFullyMastered直接统计掌握单词数 if (rec.isFullyMastered) { userMasteredCount[userId] = (userMasteredCount[userId] || 0) + 1; } // 统计正确率 ['chineseToEnglish', 'audioToEnglish', 'multipleChoice'].forEach(mode => { const m = rec[mode]; if (m) { userStats[userId].correct += m.totalCorrect || 0; userStats[userId].total += (m.totalCorrect || 0) + (m.totalWrong || 0); } }); } // 3. 查询用户名 const userIds = Object.keys(userMasteredCount); const users = await mongoose.model('User').find({ _id: { $in: userIds } }, { fullname: 1 }); const userMap = {}; users.forEach(u => { userMap[u._id.toString()] = u.fullname; }); // 4. 组装排行榜数组 const leaderboard = userIds.map(userId => ({ userId, fullname: userMap[userId] || '未知用户', totalWordsLearned: userMasteredCount[userId], accuracy: userStats[userId] && userStats[userId].total > 0 ? Math.round((userStats[userId].correct / userStats[userId].total) * 100) : 0, rank: 0 // 添加rank属性,初始值为0 })); // 5. 排序 leaderboard.sort((a, b) => { if (b.totalWordsLearned !== a.totalWordsLearned) { return b.totalWordsLearned - a.totalWordsLearned; } return b.accuracy - a.accuracy; }); // 6. 添加排名 leaderboard.forEach((item, idx) => { item.rank = idx + 1; }); res.json(leaderboard); } catch (error) { console.error('获取排行榜失败:', error); res.status(500).json({ message: '获取排行榜失败' }); } }); // 获取单词集详细信息 router.get('/word-set/:id', authMiddleware, async (req, res) => { try { const wordSet = await WordSet.findById(req.params.id); if (!wordSet) { return res.status(404).json({ message: '未找到单词集' }); } res.json(wordSet); } catch (error) { console.error('获取单词集详细信息失败:', error); res.status(500).json({ message: '获取单词集详细信息失败' }); } }); // 更新单词集 router.put('/word-set/:id', authMiddleware, async (req, res) => { try { const { name, description } = req.body; const wordSet = await WordSet.findByIdAndUpdate( req.params.id, { name, description }, { new: true } ); if (!wordSet) { return res.status(404).json({ message: '未找到单词集' }); } res.json(wordSet); } catch (error) { console.error('更新单词集失败:', error); res.status(500).json({ message: '更新单词集失败' }); } }); // 获取单词集中的单词 router.get('/word-set/:id/words', authMiddleware, async (req, res) => { try { const wordSetId = req.params.id; console.log('Received request for wordSetId:', wordSetId); // 打印请求的 wordSetId // 检查数据库中是否存在该单词集 const wordSetExists = await WordSet.exists({ _id: wordSetId }); console.log('WordSet exists:', wordSetExists); // 打印单词集是否存在 if (!wordSetExists) { console.log('WordSet not found for wordSetId:', wordSetId); return res.status(404).json({ message: '请求的资源不存在' }); } // 查询单词集中的单词 const words = await Word.find({ wordSet: wordSetId }); console.log('Words found:', words.length); // 打印找到的单词数量 if (!words || words.length === 0) { console.log('No words found for wordSetId:', wordSetId); // 打印未找到的情况 return res.status(404).json({ message: '请求的资源不存在' }); } res.json(words); } catch (error) { console.error('获取单词失败:', error); res.status(500).json({ message: '获取单词失败' }); } }); // 更新单词 router.put('/words', authMiddleware, async (req, res) => { try { const { words } = req.body; console.log('Received words to update:', JSON.stringify(words, null, 2)); // 更详细的日志 for (const word of words) { const { _id, word: wordText, translation, pronunciation, example } = word; console.log(`Updating word ${_id}:`, { wordText, translation, pronunciation, example }); // 每个单词的更新日志 const updatedWord = await Word.findByIdAndUpdate( _id, { word: wordText, translation, pronunciation, example }, { new: true } // 返回更新后的文档 ); console.log('Updated word result:', updatedWord); // 更新结果日志 } res.json({ message: '单词更新成功' }); } catch (error) { console.error('更新单词失败:', error); res.status(500).json({ message: '更新单词失败' }); } }); export default router;