USER
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.springboot.mapper.LongTermMemoryMapper">
<!-- 插入新记录,返回自增主键 -->
<insert id="insertLongTermMemory" parameterType="com.example.springboot.entity.LongTermMemory" useGeneratedKeys="true" keyProperty="id">
INSERT INTO long_term_memory(
user_id, word_id, review_count, pass_count, interval_days,
next_review_date, last_review_date,
longmemory_score, longmemory_seen, created_at, updated_at
)
VALUES(
#{userId}, #{wordId}, #{reviewCount}, #{passCount}, #{intervalDays},
#{nextReviewDate}, #{lastReviewDate},
#{longmemoryScore}, #{longmemorySeen}, NOW(), NOW()
)
</insert>
<!-- 查询某用户的已有单词id列表 -->
<select id="selectExistingWordIds" resultType="int" parameterType="map">
SELECT word_id
FROM long_term_memory
WHERE user_id = #{userId}
AND word_id IN
<foreach collection="wordIds" item="wordId" open="(" separator="," close=")">
#{wordId}
</foreach>
</select>
<!-- 查询当天及以前可复习的长期记忆单词,左连接单词表 -->
<select id="selectDueReviews" resultType="com.example.springboot.dto.LongTermMemoryWithWord" parameterType="map">
SELECT ltm.*, w.word, w.definition
FROM long_term_memory ltm
LEFT JOIN words w ON ltm.word_id = w.id
WHERE ltm.user_id = #{userId}
AND (ltm.longmemory_seen = #{longmemorySeen} OR ltm.longmemory_seen IS NULL)
AND ltm.next_review_date <= #{now}
ORDER BY
ltm.longmemory_score ASC,
ltm.next_review_date ASC
LIMIT #{size} OFFSET #{offset}
</select>
<!-- 更新长期记忆记录 -->
<update id="updateLongTermMemory" parameterType="com.example.springboot.entity.LongTermMemory">
UPDATE long_term_memory
SET review_count = #{reviewCount},
pass_count = #{passCount},
interval_days = #{intervalDays},
next_review_date = #{nextReviewDate},
last_review_date = #{lastReviewDate},
longmemory_score = #{longmemoryScore},
longmemory_seen = #{longmemorySeen},
updated_at = NOW()
WHERE id = #{id}
</update>
<!-- 通过id查询长期记忆记录 -->
<select id="selectById" resultType="com.example.springboot.entity.LongTermMemory" parameterType="int">
SELECT *
FROM long_term_memory
WHERE id = #{id}
</select>
</mapper>
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.springboot.mapper.StudyMapper">
<!-- 从 user_word_plan 中抽取待学习且 user_word_memory 中不存在的单词 -->
<select id="selectPendingWordsFromPlan" parameterType="map" resultType="com.example.springboot.entity.Study">
SELECT
NULL AS id,
up.user_id AS userId,
up.word_id AS wordId,
w.word AS word,
w.definition AS definition,
0 AS score,
-1 AS lastSeen
FROM user_word_plan up
JOIN words w ON up.word_id = w.id
LEFT JOIN user_word_memory um ON up.user_id = um.user_id AND up.word_id = um.word_id
WHERE up.user_id = #{userId} AND up.status = 'pending' AND um.id IS NULL
ORDER BY up.added_at ASC
LIMIT #{limit}
</select>
<!-- 查询 user_word_memory -->
<select id="selectStudyByUserIdAndWordId" parameterType="map" resultType="com.example.springboot.entity.Study">
SELECT id, user_id AS userId, word_id AS wordId, score, last_seen AS lastSeen
FROM user_word_memory
WHERE user_id = #{userId} AND word_id = #{wordId}
</select>
<!-- 插入 user_word_memory -->
<insert id="insertStudy" parameterType="com.example.springboot.entity.Study" useGeneratedKeys="true" keyProperty="id">
INSERT INTO user_word_memory(user_id, word_id, score, last_seen)
VALUES(#{userId}, #{wordId}, #{score}, #{lastSeen})
</insert>
<!-- 更新 user_word_memory -->
<update id="updateStudy" parameterType="com.example.springboot.entity.Study">
UPDATE user_word_memory
SET score = #{score},
last_seen = #{lastSeen}
WHERE id = #{id}
</update>
<!-- 查询单词详情 -->
<select id="selectWordById" parameterType="int" resultType="com.example.springboot.entity.Word">
SELECT id, word, definition, pronunciation, custom_content AS customContent, created_at AS createdAt
FROM words
WHERE id = #{id}
</select>
<!-- 查询用户已选择的单词 -->
<select id="selectSelectedWordsByUserId" parameterType="int" resultType="com.example.springboot.entity.Word">
SELECT
w.id,
w.word,
w.definition,
w.pronunciation,
w.custom_content AS customContent,
w.created_at AS createdAt
FROM user_word_memory uwm
JOIN words w ON uwm.word_id = w.id
WHERE uwm.user_id = #{userId}
</select>
<!-- 删除单个单词 -->
<delete id="deleteStudyByUserIdAndWordId" parameterType="map">
DELETE FROM user_word_memory WHERE user_id = #{userId} AND word_id = #{wordId}
</delete>
<!-- 批量删除单词 -->
<delete id="deleteStudiesByUserIdAndWordIds" parameterType="map">
DELETE FROM user_word_memory WHERE user_id = #{userId} AND word_id IN
<foreach item="wordId" collection="wordIds" open="(" separator="," close=")">
#{wordId}
</foreach>
</delete>
<select id="selectReviewCandidates" resultType="com.example.springboot.entity.Study" parameterType="map">
SELECT
um.id,
um.user_id AS userId,
um.word_id AS wordId,
um.score,
um.last_seen AS lastSeen,
w.word,
w.definition,
w.pronunciation
FROM user_word_memory um
JOIN words w ON um.word_id = w.id
WHERE um.user_id = #{userId} AND (#{round} - um.last_seen) >= #{cooldown}
ORDER BY um.score ASC, um.last_seen ASC
LIMIT #{limit}
</select>
<!-- 删除user_word_plan中对应记录 -->
<delete id="deletePlanWord" parameterType="map">
DELETE FROM user_word_plan WHERE user_id = #{userId} AND word_id = #{wordId}
</delete>
<select id="countPendingWords" resultType="int" parameterType="int">
SELECT COUNT(*) FROM user_word_memory WHERE user_id = #{userId}
</select>
</mapper>
package com.example.springboot.service;
import com.example.springboot.dto.LongTermMemoryWithWord;
import com.example.springboot.entity.LongTermMemory;
import com.example.springboot.entity.Study;
import com.example.springboot.entity.Word;
import com.example.springboot.mapper.LongTermMemoryMapper;
import com.example.springboot.mapper.StudyMapper;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class StudyService {
// --- Mapper 注入 ---
@Resource
private StudyMapper studyMapper;
@Resource
private LongTermMemoryMapper longTermMemoryMapper;
// ================= 短期记忆相关 =================
private static final int COOLDOWN = 2;
private static final int PASS_SCORE = 3;
private static final int N = 3;
/**
* 获取当前轮次的短期记忆复习候选单词
*/
public List<Study> getReviewList(Integer userId, int round) {
return studyMapper.selectReviewCandidates(userId, round, COOLDOWN, N);
}
/**
* 处理短期记忆复习结果:
* 通过累加分数判断是否通过阶段,
* 通过后迁移到长期记忆表,否则更新分数和复习轮次
*/
public void processReviewResult(Integer userId, Integer wordId, int currentRound, boolean remembered) {
Study study = studyMapper.selectStudyByUserIdAndWordId(userId, wordId);
if (study == null) return;
int newScore = study.getScore();
if (remembered) {
newScore += 1;
} else {
newScore = Math.max(newScore - 1, 0);
}
if (newScore >= PASS_SCORE) {
// 通过短期考核,迁移到长期记忆,删除短期记录
moveToLongTermMemory(study, currentRound, newScore);
studyMapper.deleteStudyByUserIdAndWordId(userId, wordId);
} else {
// 未通过,更新短期记忆分数和最近复习的轮次
study.setScore(newScore);
study.setLastSeen(currentRound);
studyMapper.updateStudy(study);
}
}
/**
* 短期记忆→长期记忆迁移
* intervalDays=0 表示首次长期复习未完成,
* nextReviewDate设置为当前时间以供首次长期复习
*/
private void moveToLongTermMemory(Study study, int currentRound, int score) {
LongTermMemory ltm = new LongTermMemory();
ltm.setUserId(study.getUserId());
ltm.setWordId(study.getWordId());
ltm.setReviewCount(0); // 首次长期复习前 复习计数0
ltm.setIntervalDays(0); // 0表示首次长期复习阶段
LocalDateTime now = LocalDateTime.now();
ltm.setLastReviewDate(now);
ltm.setNextReviewDate(now); // 立即可复习首次长期任务
// longmemoryScore, longmemorySeen等字段保持默认即可
longTermMemoryMapper.insertLongTermMemory(ltm);
}
/**
* 用户手动添加单词到短期记忆
*/
public int insertUserSelectedWords(Integer userId, List<Integer> wordIds) {
int count = 0;
for (Integer wordId : wordIds) {
Study exist = studyMapper.selectStudyByUserIdAndWordId(userId, wordId);
if (exist == null) {
Study study = new Study();
study.setUserId(userId);
study.setWordId(wordId);
study.setScore(0);
study.setLastSeen(-1);
studyMapper.insertStudy(study);
count++;
studyMapper.deletePlanWord(userId, wordId);
}
}
return count;
}
/**
* 获取用户已选短期记忆单词列表
*/
public List<Word> getSelectedWordsByUserId(Integer userId) {
return studyMapper.selectSelectedWordsByUserId(userId);
}
/**
* 获取用户待学习的短期记忆单词(待加入短期记忆)
*/
public List<Study> getPendingWords(Integer userId, int limit) {
return studyMapper.selectPendingWordsFromPlan(userId, limit);
}
/**
* 删除单个短期记忆单词记录
*/
public int deleteWord(Integer userId, Integer wordId) {
return studyMapper.deleteStudyByUserIdAndWordId(userId, wordId);
}
/**
* 批量删除短期记忆单词记录
*/
public int deleteWords(Integer userId, List<Integer> wordIds) {
return studyMapper.deleteStudiesByUserIdAndWordIds(userId, wordIds);
}
/**
* 查询用户短期记忆待复习单词总数(用于展示)
*/
public int getPendingCount(Integer userId) {
return studyMapper.countPendingWords(userId);
}
// ================= 长期记忆相关 =================
// 长期记忆复习阶段间隔(单位:天)
private static final int[] INTERVALS = {1, 2, 4, 7, 15};
/**
* 获取长期记忆阶段待复习单词列表(nextReviewDate <= 当前时间)
*/
public List<LongTermMemoryWithWord> getLongTermReviewList(Integer userId, int longMemorySeen, int offset, int size) {
LocalDateTime now = LocalDateTime.now();
return longTermMemoryMapper.selectDueReviews(userId, now, offset, size, longMemorySeen);
}
/**
* 根据长期记忆ID查询对应实体
*/
public LongTermMemory getLongTermMemoryById(Integer id) {
return longTermMemoryMapper.selectById(id);
}
/**
* 处理长期记忆复习结果:
* 需累计通过3次才进入下一阶段间隔;
* 未通过扣减分数,但不降低阶段;
* 通过后更新时间间隔及复习时间。
*/
public void processLongTermReviewResult(LongTermMemory ltm, boolean remembered) {
if (ltm == null) return;
// 累计复习次数
Integer reviewCount = ltm.getReviewCount() == null ? 0 : ltm.getReviewCount() + 1;
// 当前阶段短期分数
Integer score = ltm.getLongmemoryScore() == null ? 0 : ltm.getLongmemoryScore();
// 当前累计连续通过次数
Integer passCount = ltm.getPassCount() == null ? 0 : ltm.getPassCount();
// 当前复习轮次,由 longmemorySeen 表示
int round = ltm.getLongmemorySeen() == null ? 0 : ltm.getLongmemorySeen();
int[] INTERVALS = {1, 2, 4, 7, 15};
int intervalIndex = 0;
// 计算当前阶段索引,根据 intervalDays 字段
Integer intervalDays = ltm.getIntervalDays() == null ? 0 : ltm.getIntervalDays();
for (int i = 0; i < INTERVALS.length; i++) {
if (intervalDays != null && INTERVALS[i] == intervalDays.intValue()) {
intervalIndex = i;
break;
}
}
if (remembered) {
// 复习通过,分数+1,计数+1
score++;
passCount++;
// 连续通过超过阈值,准备进入下一轮次
if (passCount >= 3) {
round++; // 轮次+1
passCount = 0; // 复位连续通过计数
score = 0; // 复位分数
// 阶段后移,不能超过最大间隔
if (intervalIndex < INTERVALS.length - 1) {
intervalIndex++;
}
int nextInterval = INTERVALS[intervalIndex];
// 设置下次复习时间(当前时间 + 阶段间隔)
ltm.setNextReviewDate(LocalDateTime.now().plusDays(nextInterval));
// 更新间隔天数字段
ltm.setIntervalDays(nextInterval);
// 更新轮次
ltm.setLongmemorySeen(round);
}
} else {
// 未通过则分数减1最低不低于0
score = Math.max(score - 1, 0);
// 不更新轮次和间隔
}
// 每次复习都更新以下字段
ltm.setReviewCount(reviewCount);
ltm.setLongmemoryScore(score);
ltm.setPassCount(passCount);
ltm.setLastReviewDate(LocalDateTime.now());
// 更新数据库
longTermMemoryMapper.updateLongTermMemory(ltm);
}
}
package com.example.springboot.controller;
import com.example.springboot.dto.LongTermMemoryWithWord;
import com.example.springboot.entity.LongTermMemory;
import com.example.springboot.entity.Study;
import com.example.springboot.service.StudyService;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/study")
public class StudyController {
@Resource
private StudyService studyService;
// 用户选择单词
@PostMapping("/select-words")
public ResponseEntity<?> userSelectWords(@RequestBody Map<String, Object> payload) {
Integer userId = (Integer) payload.get("userId");
List<Integer> wordIds = (List<Integer>) payload.get("wordIds");
if (userId == null || wordIds == null || wordIds.isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("参数错误");
}
int insertedCount = studyService.insertUserSelectedWords(userId, wordIds);
if (insertedCount == 0) {
return ResponseEntity.status(HttpStatus.CONFLICT).body("所有单词已存在,无需再添加");
}
return ResponseEntity.ok("成功添加 " + insertedCount + " 个新单词");
}
// 获取已经选择的单词列表
@GetMapping("/select-words")
public ResponseEntity<?> getSelectedWords(@RequestParam(value = "userId", required = false) String userIdStr) {
if (userIdStr == null || !userIdStr.matches("\\d+")) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("缺少或非法的 userId");
}
Integer userId = Integer.valueOf(userIdStr);
return ResponseEntity.ok(studyService.getSelectedWordsByUserId(userId));
}
// 获取用户可选的未学习单词(用于前端弹窗选择)
@GetMapping("/pending-words")
public ResponseEntity<?> getPendingWords(
@RequestParam("userId") Integer userId,
@RequestParam(value = "limit", defaultValue = "100") Integer limit) {
return ResponseEntity.ok(studyService.getPendingWords(userId, limit));
}
// 删除用户选择的单词
@PostMapping("/remove-word")
public ResponseEntity<?> removeWord(@RequestBody Map<String, Object> payload) {
Integer userId = (Integer) payload.get("userId");
Integer wordId = (Integer) payload.get("wordId");
if (userId == null || wordId == null) {
return ResponseEntity.badRequest().body("参数错误");
}
int rows = studyService.deleteWord(userId, wordId);
if (rows > 0) {
return ResponseEntity.ok("删除成功");
} else {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("未找到单词");
}
}
// 批量删除用户选择的单词
@PostMapping("/remove-words")
public ResponseEntity<?> removeWords(@RequestBody Map<String, Object> payload) {
Integer userId = (Integer) payload.get("userId");
List<Integer> wordIds = (List<Integer>) payload.get("wordIds");
if (userId == null || wordIds == null || wordIds.isEmpty()) {
return ResponseEntity.badRequest().body("参数错误");
}
int rows = studyService.deleteWords(userId, wordIds);
if (rows > 0) {
return ResponseEntity.ok("批量删除成功");
} else {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("未找到单词");
}
}
// 获取当前轮复习列表
@GetMapping("/review-list")
public ResponseEntity<?> getReviewList(@RequestParam Integer userId, @RequestParam Integer round) {
List<Study> reviewList = studyService.getReviewList(userId, round);
return ResponseEntity.ok(reviewList);
}
// 处理复习结果
@PostMapping("/review-result")
public ResponseEntity<?> postReviewResult(@RequestBody Map<String, Object> payload) {
Integer userId = (Integer) payload.get("userId");
Integer wordId = (Integer) payload.get("wordId");
Integer round = (Integer) payload.get("round");
Boolean remembered = (Boolean) payload.get("remembered");
if (userId == null || wordId == null || round == null || remembered == null) {
return ResponseEntity.badRequest().body("参数错误");
}
studyService.processReviewResult(userId, wordId, round, remembered);
return ResponseEntity.ok("复习结果处理成功");
}
// 获取用户待复习单词数量
@GetMapping("/pending-count")
public ResponseEntity<?> getPendingCount(@RequestParam Integer userId) {
int count = studyService.getPendingCount(userId);
return ResponseEntity.ok(count);
}
/**
* 获取长期记忆阶段待复习单词列表(nextReviewDate <= 当前时间)
*/
@GetMapping("/long-term-review-list")
public ResponseEntity<?> getLongTermReviewList(
@RequestParam Integer userId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "3") int size,
@RequestParam(defaultValue = "0") int longmemorySeen
) {
int offset = page * size;
List<LongTermMemoryWithWord> list = studyService.getLongTermReviewList(userId, longmemorySeen, offset, size);
return ResponseEntity.ok(list);
}
/**
* 处理长期记忆复习结果反馈
* 请求体示例:
* {
* "id": 123,
* "remembered": true
* }
*/
@PostMapping("/long-term-review-result")
public ResponseEntity<?> postLongTermReviewResult(@RequestBody Map<String, Object> payload) {
Integer id = (Integer) payload.get("id");
Boolean remembered = (Boolean) payload.get("remembered");
if (id == null || remembered == null) {
return ResponseEntity.badRequest().body("参数错误");
}
LongTermMemory ltm = studyService.getLongTermMemoryById(id);
if (ltm == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("未找到对应的长期记忆记录");
}
studyService.processLongTermReviewResult(ltm, remembered);
return ResponseEntity.ok("长期记忆复习结果处理成功");
}
}
package com.example.springboot.mapper;
import com.example.springboot.dto.LongTermMemoryWithWord;
import com.example.springboot.entity.LongTermMemory;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.List;
@Mapper
public interface LongTermMemoryMapper {
int insertLongTermMemory(LongTermMemory ltm);
List<Integer> selectExistingWordIds(@Param("userId") Integer userId, @Param("wordIds") List<Integer> wordIds);
// 注意返回类型改成 LongTermMemoryWithWord
List<LongTermMemoryWithWord> selectDueReviews(@Param("userId") Integer userId,
@Param("now") LocalDateTime now,
@Param("offset") Integer offset,
@Param("size") Integer size,
@Param("longmemorySeen") Integer longmemorySeen);
int updateLongTermMemory(LongTermMemory ltm);
LongTermMemory selectById(@Param("id") Integer id);
}
<template>
<el-row style="height: 100vh; padding: 20px;" :gutter="20">
<!-- 左侧:学习单词区域 -->
<el-col :span="12" class="left-panel" style="height: 100%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>短期记忆区域</h2>
<div>
<el-button type="primary" @click="openSelectedDialog">查看已选单词</el-button>
<el-button type="success" @click="openDialog" style="margin-left: 10px;">选择单词</el-button>
</div>
</div>
<!-- 待复习单词数量 -->
<div class="pending-count" v-if="reviewList.length">
待复习单词数量: {{ pendingCount }}
</div>
<!-- 单词卡片 -->
<div class="card-container" v-if="reviewList.length">
<div class="card-wrapper">
<div class="card" :class="{ flipped: isFlipped }">
<div class="card-face card-front">
<!-- 正面不显示通过次数,如果需要,类似写这里 -->
<h1>{{ currentWord.word }}</h1>
<el-button @click.stop="speakWord(currentWord.word)">🔊 发音</el-button>
</div>
<div class="card-face card-back" style="position: relative;">
<!-- 通过次数左上角绝对定位 -->
<div class="score-badge">
通过次数: {{ currentWord.score ?? 0 }}
</div>
<h1 style="margin-top: 40px;">{{ currentWord.word }}</h1>
<p><strong>释义:</strong>{{ currentWord.definition }}</p>
<el-button @click.stop="speakWord(currentWord.word)">🔊 发音</el-button>
</div>
</div>
<div class="btn-group">
<el-button type="danger" v-if="!isFlipped" @click="flipCard">忘记</el-button>
<el-button type="success" v-if="!isFlipped" @click="flipCard">认识</el-button>
<el-button type="danger" v-if="isFlipped" @click="submitReviewResult(false)">忘记</el-button>
<el-button type="success" v-if="isFlipped" @click="submitReviewResult(true)">认识</el-button>
</div>
</div>
</div>
<!-- 无可复习单词提示 -->
<div v-else style="text-align:center; margin-top: 50px; font-size: 20px; color: #999;">
无可复习单词,复习完成!
</div>
<!-- 已选单词弹窗 -->
<el-dialog v-model="selectedDialogVisible" title="已选单词列表" width="800px">
<el-table
:data="selectedWords"
style="width: 100%;"
border
@selection-change="handleSelectedChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="word" label="单词" />
<el-table-column prop="definition" label="释义" />
<el-table-column label="操作" width="100">
<template #default="scope">
<el-button type="danger" size="small" @click="removeWord(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<span class="dialog-footer">
<el-button @click="selectedDialogVisible = false">关闭</el-button>
<el-button type="danger" :disabled="!selectedToDelete.length" @click="batchRemoveWords">
批量删除({{ selectedToDelete.length }})
</el-button>
</span>
</template>
</el-dialog>
</el-col>
<!-- 右侧区域:长期记忆区域 -->
<el-col :span="12" class="right-panel" style="border: 1px solid #eee; padding: 20px;">
<h2>长期记忆区域</h2>
<div v-if="longTermReviewList.length">
<div class="card-container">
<div class="card-wrapper">
<div class="card" :class="{ flipped: longTermIsFlipped }">
<div class="card-face card-front">
<h1>{{ currentLongTermWord.word }}</h1>
<el-button @click.stop="speakWord(currentLongTermWord.word)">🔊 发音</el-button>
</div>
<div class="card-face card-back" style="position: relative;">
<div class="score-badge">
复习次数: {{ currentLongTermWord.reviewCount ?? 0 }} <br/>
间隔天数: {{ currentLongTermWord.intervalDays ?? 0 }}
</div>
<h1 style="margin-top: 40px;">{{ currentLongTermWord.word }}</h1>
<p><strong>释义:</strong>{{ currentLongTermWord.definition }}</p>
<el-button @click.stop="speakWord(currentLongTermWord.word)">🔊 发音</el-button>
</div>
</div>
<div class="btn-group">
<el-button type="danger" v-if="!longTermIsFlipped" @click="flipLongTermCard">忘记</el-button>
<el-button type="success" v-if="!longTermIsFlipped" @click="flipLongTermCard">认识</el-button>
<el-button type="danger" v-if="longTermIsFlipped" @click="submitLongTermReviewResult(false)">忘记</el-button>
<el-button type="success" v-if="longTermIsFlipped" @click="submitLongTermReviewResult(true)">认识</el-button>
</div>
</div>
</div>
</div>
<div v-else style="text-align:center; margin-top: 50px; font-size: 20px; color: #999;">
无可复习的长期记忆单词,复习完成!
</div>
</el-col>
<!-- 选择待学习单词弹窗 -->
<el-dialog v-model="dialogVisible" title="选择待学习单词" width="600px">
<el-table :data="pendingWords" style="width: 100%;" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table-column prop="word" label="单词" />
<el-table-column prop="definition" label="释义" />
</el-table>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitSelectedWords">确认选择({{ selectedToAdd.length }})</el-button>
</span>
</template>
</el-dialog>
</el-row>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
axios.defaults.baseURL = 'http://localhost:8080'
const selectedWords = ref([])
const pendingWords = ref([])
const selectedToAdd = ref([])
const selectedToDelete = ref([])
const dialogVisible = ref(false)
const selectedDialogVisible = ref(false)
const round = ref(1)
const reviewList = ref([])
const reviewIndex = ref(0)
const pendingCount = ref(0)
const isFlipped = ref(false)
const currentWord = computed(() => reviewList.value[reviewIndex.value] || {})
const utterance = new SpeechSynthesisUtterance()
utterance.lang = 'en-US'
// 长期记忆相关状态
const longTermReviewList = ref([])
const longTermReviewIndex = ref(0)
const longTermIsFlipped = ref(false)
const currentPage = ref(0)
const currentLongTermWord = computed(() => longTermReviewList.value[longTermReviewIndex.value] || {})
async function fetchPendingCount() {
const userId = localStorage.getItem('userId')
if (!userId) return ElMessage.warning('请先登录或设置 userId')
try {
const res = await axios.get('/study/pending-count', { params: { userId: Number(userId) } })
pendingCount.value = res.data
} catch {
ElMessage.error('获取待复习单词数量失败')
}
}
async function fetchSelectedWords() {
const userId = localStorage.getItem('userId')
if (!userId) return ElMessage.warning('请先登录或设置 userId')
try {
const res = await axios.get('/study/select-words', { params: { userId } })
selectedWords.value = res.data
} catch {
ElMessage.error('获取已选单词失败')
}
}
async function fetchReviewList() {
const userId = localStorage.getItem('userId')
if (!userId) return ElMessage.warning('请先登录或设置 userId')
try {
const res = await axios.get('/study/review-list', {
params: { userId: Number(userId), round: round.value }
})
reviewList.value = res.data
reviewIndex.value = 0
isFlipped.value = false
if (reviewList.value.length > 0) {
speakWord(currentWord.value.word)
}
} catch (e) {
ElMessage.error('加载复习单词失败')
console.error(e)
}
}
function flipCard() {
if (!currentWord.value.word) return
speakWord(currentWord.value.word)
isFlipped.value = true
}
async function submitReviewResult(remembered) {
const userId = localStorage.getItem('userId')
if (!userId) {
ElMessage.warning('请先登录或设置 userId')
return
}
try {
await axios.post('/study/review-result', {
userId: Number(userId),
wordId: currentWord.value.wordId || currentWord.value.id,
round: round.value,
remembered: remembered
})
reviewIndex.value++
isFlipped.value = false
await fetchPendingCount() // 刷新待复习数量
if (reviewIndex.value >= reviewList.value.length) {
round.value++
await fetchReviewList()
} else {
speakWord(currentWord.value.word)
}
} catch (e) {
ElMessage.error('提交复习结果失败')
console.error(e)
}
}
function speakWord(text) {
if (!text) return
if (speechSynthesis.speaking) speechSynthesis.cancel()
utterance.text = text
speechSynthesis.speak(utterance)
}
async function openSelectedDialog() {
await fetchSelectedWords()
selectedToDelete.value = []
selectedDialogVisible.value = true
}
async function openDialog() {
const userId = localStorage.getItem('userId')
if (!userId) return ElMessage.warning('请先登录或设置 userId')
try {
const res = await axios.get('/study/pending-words', {
params: { userId, limit: 100 }
})
pendingWords.value = res.data
dialogVisible.value = true
} catch {
ElMessage.error('获取待学习单词失败')
}
}
function handleSelectionChange(val) {
selectedToAdd.value = val
}
function handleSelectedChange(val) {
selectedToDelete.value = val
}
async function submitSelectedWords() {
const userId = localStorage.getItem('userId')
const wordIds = selectedToAdd.value.map(item => item.wordId || item.id)
if (wordIds.length < 10) {
ElMessage.warning('请至少选择10个单词')
return
}
try {
await axios.post('/study/select-words', { userId: Number(userId), wordIds })
dialogVisible.value = false
await fetchSelectedWords()
await fetchPendingCount() // 选择后刷新数量
await fetchReviewList()
ElMessage.success('添加成功')
} catch {
ElMessage.error('添加单词失败')
}
}
async function removeWord(row) {
const userId = localStorage.getItem('userId')
try {
await axios.post('/study/remove-word', {
userId: Number(userId),
wordId: row.wordId || row.id
})
await fetchSelectedWords()
await fetchPendingCount() // 删除后刷新数量
await fetchReviewList()
ElMessage.success('删除成功')
} catch {
ElMessage.error('删除失败')
}
}
async function batchRemoveWords() {
const userId = localStorage.getItem('userId')
const wordIds = selectedToDelete.value.map(item => item.wordId || item.id)
if (!wordIds.length) {
ElMessage.warning('请选择要删除的单词')
return
}
try {
await axios.post('/study/remove-words', { userId: Number(userId), wordIds })
await fetchSelectedWords()
selectedToDelete.value = []
await fetchPendingCount() // 批量删除后刷新数量
await fetchReviewList()
ElMessage.success('批量删除成功')
} catch {
ElMessage.error('批量删除失败')
}
}
// 长期记忆相关方法
async function fetchLongTermReviewList() {
const userId = localStorage.getItem('userId')
if (!userId) return ElMessage.warning('请先登录或设置 userId')
try {
const res = await axios.get('/study/long-term-review-list', {
params: {
userId: Number(userId),
page: currentPage.value,
size: 3,
longmemorySeen: 0 // 或传当前轮次
}
})
longTermReviewList.value = res.data || []
longTermIsFlipped.value = false
if (longTermReviewList.value.length > 0) {
speakWord(currentLongTermWord.value.word)
}
} catch (e) {
ElMessage.error('加载长期记忆复习列表失败')
console.error(e)
}
}
function flipLongTermCard() {
if (!currentLongTermWord.value.word) return
speakWord(currentLongTermWord.value.word)
longTermIsFlipped.value = true
}
async function submitLongTermReviewResult(remembered) {
const userId = localStorage.getItem('userId')
if (!userId) {
ElMessage.warning('请先登录或设置 userId')
return
}
const ltmId = currentLongTermWord.value.id
try {
await axios.post('/study/long-term-review-result', {
userId: Number(userId),
id: ltmId,
remembered
})
longTermReviewIndex.value++
longTermIsFlipped.value = false
if (longTermReviewIndex.value >= longTermReviewList.value.length) {
// 当前页单词复习完毕,拉取下一页
currentPage.value++
await fetchLongTermReviewList() // 重新请求下一页数据
if (longTermReviewList.value.length === 0) {
ElMessage.success('长期记忆阶段复习完成!')
} else {
longTermReviewIndex.value = 0 // 重置索引
speakWord(currentLongTermWord.value.word)
}
} else {
// 继续复习当前页剩余单词
speakWord(currentLongTermWord.value.word)
}
} catch (e) {
ElMessage.error('提交长期记忆复习结果失败')
console.error(e)
}
}
onMounted(async () => {
speechSynthesis.speak(new SpeechSynthesisUtterance(' '))
await fetchPendingCount()
await fetchReviewList()
await fetchLongTermReviewList()
})
</script>
<style scoped>
.left-panel {
display: flex;
flex-direction: column;
border: 1px solid #eee;
height: 100%;
}
.pending-count {
font-size: 18px;
font-weight: bold;
color: #409EFF;
padding: 10px 0 0 0;
margin-bottom: 10px;
user-select: none;
}
.card-container {
flex-grow: 1;
margin-top: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
perspective: 1000px;
width: 100%;
}
.card {
width: 100%;
max-width: 550px;
height: 500px;
position: relative;
transition: transform 0.6s;
transform-style: preserve-3d;
border-radius: 16px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
background-color: #fff;
}
.score-badge {
position: absolute;
top: 15px;
left: 15px;
background-color: #409EFF;
color: white;
font-weight: bold;
padding: 6px 12px;
border-radius: 12px;
font-size: 14px;
pointer-events: none;
user-select: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.card.flipped {
transform: rotateY(180deg);
}
.card-face {
position: absolute;
width: 100%;
height: 100%;
padding: 20px;
box-sizing: border-box;
backface-visibility: hidden;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.card-front {
font-size: 1em;
font-weight: bold;
}
.card-back {
transform: rotateY(180deg);
font-size: 1.2em;
text-align: center;
line-height: 1.6;
}
.card-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.btn-group {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 30px;
margin-top: 30px;
}
.btn-group .el-button {
padding: 18px 40px;
font-size: 18px;
border-radius: 10px;
font-weight: bold;
min-width: 140px;
transition: all 0.2s ease-in-out;
}
.btn-group .el-button:hover {
transform: scale(1.05);
}
</style>
package com.example.springboot.dto;
import com.example.springboot.entity.LongTermMemory;
public class LongTermMemoryWithWord extends LongTermMemory {
private String word;
private String definition;
// getter/setter
public String getWord() {
return word;
}
public void setWord(String word) {
this.word = word;
}
public String getDefinition() {
return definition;
}
public void setDefinition(String definition) {
this.definition = definition;
}
}
package com.example.springboot.entity;
import java.time.LocalDateTime;
public class LongTermMemory {
private Integer id;
private Integer userId;
private Integer wordId;
private Integer reviewCount; // 总复习次数
private Integer intervalDays; // 下次复习间隔(单位:天)
private LocalDateTime nextReviewDate; // 下一次复习目标日期
private LocalDateTime lastReviewDate; // 上一次复习日期
private Integer longmemoryScore;
private Integer longmemorySeen;
private Integer passCount; // 复习通过计数
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Integer round;
public Integer getRound() {
return round;
}
public void setRound(Integer round) {
this.round = round;
}
// 空构造函数
public LongTermMemory() {
}
// get/set 方法
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public Integer getUserId() {
return userId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
public Integer getWordId() {
return wordId;
}
public void setWordId(Integer wordId) {
this.wordId = wordId;
}
public Integer getReviewCount() {
return reviewCount;
}
public void setReviewCount(Integer reviewCount) {
this.reviewCount = reviewCount;
}
public Integer getIntervalDays() {
return intervalDays;
}
public void setIntervalDays(Integer intervalDays) {
this.intervalDays = intervalDays;
}
public LocalDateTime getNextReviewDate() {
return nextReviewDate;
}
public void setNextReviewDate(LocalDateTime nextReviewDate) {
this.nextReviewDate = nextReviewDate;
}
public LocalDateTime getLastReviewDate() {
return lastReviewDate;
}
public void setLastReviewDate(LocalDateTime lastReviewDate) {
this.lastReviewDate = lastReviewDate;
}
public Integer getLongmemoryScore() {
return longmemoryScore;
}
public void setLongmemoryScore(Integer longmemoryScore) {
this.longmemoryScore = longmemoryScore;
}
public Integer getLongmemorySeen() {
return longmemorySeen;
}
public void setLongmemorySeen(Integer longmemorySeen) {
this.longmemorySeen = longmemorySeen;
}
public Integer getPassCount() {
return passCount;
}
public void setPassCount(Integer passCount) {
this.passCount = passCount;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}