Files
d1kt/server/routes/vocabulary.ts

699 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;