@startuml
' 定义接口
interface NoticeMapper {
    + insert(Notice)
    + update(Notice)
    + deleteById(Long)
    + selectById(Long): Notice
    + selectByCondition(String, String, Date): List~Notice~
}

class Notice {
    - id: Long
    - title: String
    - content: String
    - createTime: Date
    - updateTime: Date
    + getId(): Long
    + setId(Long): void
    + getTitle(): String
    + setTitle(String): void
    + getContent(): String
    + setContent(String): void
    + getCreateTime(): Date
    + setCreateTime(Date): void
    + getUpdateTime(): Date
    + setUpdateTime(Date): void
}

class NoticeService {
    - noticeMapper: NoticeMapper
    + createNotice(Notice): Notice
    + updateNotice(Notice): Notice
    + deleteNotice(Long): void
    + getNoticeById(Long): Notice
    + searchNotices(String, String, Date): List~Notice~
}

class NoticeController {
    - noticeService: NoticeService
    + create(Notice): Notice
    + update(Long, Notice): Notice
    + delete(Long): void
    + get(Long): Notice
    + search(String, String, Date): List~Notice~
}

' 关系定义

NoticeMapper <|.. NoticeService : 实现

NoticeService ..> NoticeMapper : 依赖
NoticeController ..> NoticeService : 依赖

NoticeService "1" -- "0..*" Notice : 关联
NoticeMapper "1" -- "0..*" Notice : 关联

@enduml根据上面的代码规范，帮我画类图。package com.example.springboot.controller;

import com.example.springboot.common.PageResult;
import com.example.springboot.entity.Word;
import com.example.springboot.service.WordService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.List;


@RestController
@RequestMapping("/words")
public class WordController {

    @Resource
    private WordService wordService;

    @PostMapping
    public int addWord(@RequestBody Word word) {
        return wordService.addWord(word);
    }

    @PutMapping
    public int updateWord(@RequestBody Word word) {
        return wordService.updateWord(word);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<String> deleteWord(@PathVariable int id) {
        try {
            int result = wordService.deleteWord(id);
            if (result > 0) {
                return ResponseEntity.ok("删除成功");
            } else {
                return ResponseEntity.status(HttpStatus.NOT_FOUND).body("未找到该单词");
            }
        } catch (DataIntegrityViolationException e) {
            // 捕获外键约束异常，提示不能删除
            return ResponseEntity.status(HttpStatus.CONFLICT).body("删除失败：该单词被使用，无法删除");
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("删除失败");
        }
    }


    @GetMapping("/{id}")
    public Word getWordById(@PathVariable int id) {
        return wordService.getWordById(id);
    }

    @GetMapping("/search")
    public List<Word> searchWords(@RequestParam String keyword) {
        return wordService.searchWords(keyword);
    }


    @GetMapping
    public PageResult<Word> getAllWords(
            @RequestParam(defaultValue = "1") int pageNum,
            @RequestParam(defaultValue = "10") int pageSize,
            @RequestParam(required = false) String keyword) {

        PageResult<Word> pageResult;
        if (keyword != null && !keyword.trim().isEmpty()) {
            pageResult = wordService.searchWordsPaged(keyword, pageNum, pageSize);
        } else {
            pageResult = wordService.getAllWordsPaged(pageNum, pageSize);
        }
        return pageResult;
    }


}
package com.example.springboot.controller;

import com.alibaba.excel.EasyExcel;
import com.example.springboot.dto.WordExcelDTO;
import com.example.springboot.entity.Word;
import com.example.springboot.service.WordService;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;


import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/word-excel")
public class WordExcelController {

    @Resource
    private WordService wordService;

    // ✅ 导入 Excel
    @PostMapping("/import")
    public String importExcel(@RequestParam("file") MultipartFile file) throws IOException {
        List<WordExcelDTO> data = EasyExcel.read(file.getInputStream())
                .head(WordExcelDTO.class)
                .sheet()
                .doReadSync();

        List<Word> words = data.stream().map(dto -> {
            Word word = new Word();
            word.setWord(dto.getWord());
            word.setDefinition(dto.getDefinition());
            word.setPronunciation(dto.getPronunciation());
            word.setCustomContent(dto.getCustomContent());
            return word;
        }).collect(Collectors.toList());

        for (Word word : words) {
            wordService.addWord(word);
        }

        return "导入成功：" + words.size() + " 条";
    }

    @GetMapping(value = "/export", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
    public void exportExcel(HttpServletResponse response) {
        try {
            List<Word> words = wordService.getAllWords();
            List<WordExcelDTO> data = words.stream().map(word -> {
                WordExcelDTO dto = new WordExcelDTO();
                dto.setWord(word.getWord());
                dto.setDefinition(word.getDefinition());
                dto.setPronunciation(word.getPronunciation());
                dto.setCustomContent(word.getCustomContent());
                return dto;
            }).collect(Collectors.toList());

            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            response.setCharacterEncoding("utf-8");
            String fileName = URLEncoder.encode("单词表", StandardCharsets.UTF_8.toString()).replaceAll("\\+", "%20");
            response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");

            EasyExcel.write(response.getOutputStream(), WordExcelDTO.class)
                    .sheet("单词")
                    .doWrite(data);

        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("导出Excel失败", e);
        }
    }

    @GetMapping("/export-test")
    public void exportTest(HttpServletResponse response) throws IOException {
        response.setContentType("text/plain");
        response.getWriter().write("Hello Export Test");
    }

    @PostMapping(value = "/export-selected", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
    public void exportSelectedWords(@RequestBody List<Integer> ids, HttpServletResponse response) {
        try {
            // 根据 ID 查询选中的单词
            List<Word> words = wordService.getWordsByIds(ids);

            // 转换为 Excel DTO
            List<WordExcelDTO> data = words.stream().map(word -> {
                WordExcelDTO dto = new WordExcelDTO();
                dto.setWord(word.getWord());
                dto.setDefinition(word.getDefinition());
                dto.setPronunciation(word.getPronunciation());
                dto.setCustomContent(word.getCustomContent());
                return dto;
            }).collect(Collectors.toList());

            // 设置响应头
            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            response.setCharacterEncoding("utf-8");
            String fileName = URLEncoder.encode("选中单词表", StandardCharsets.UTF_8.toString()).replaceAll("\\+", "%20");
            response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");

            // 写入 Excel
            EasyExcel.write(response.getOutputStream(), WordExcelDTO.class)
                    .sheet("选中单词")
                    .doWrite(data);

        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("导出选中单词失败", e);
        }
    }




}
package com.example.springboot.dto;

import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Data;

@Data
public class WordExcelDTO {

    @ExcelProperty("单词")
    private String word;

    @ExcelProperty("释义")
    private String definition;

    @ExcelProperty("发音")
    private String pronunciation;

    @ExcelProperty("例句")
    private String customContent;
}
package com.example.springboot.dto;

import java.util.List;

public class AddWordsRequest {
    private Integer userId;
    private List<Integer> wordIds;

    // getter/setter
    public Integer getUserId() {
        return userId;
    }
    public void setUserId(Integer userId) {
        this.userId = userId;
    }
    public List<Integer> getWordIds() {
        return wordIds;
    }
    public void setWordIds(List<Integer> wordIds) {
        this.wordIds = wordIds;
    }
}
package com.example.springboot.mapper;

import com.example.springboot.entity.Word;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;


import java.util.List;

@Mapper
public interface WordMapper {

    int insert(Word word);

    int update(Word word);

    int delete(int id);

    Word selectById(int id);

    List<Word> searchByWord(String keyword);

    List<Word> selectAll();



    List<Word> selectByIds(List<Integer> ids);




}
package com.example.springboot.service;

import com.example.springboot.common.PageResult;
import com.example.springboot.entity.Word;
import com.example.springboot.mapper.WordMapper;

import com.github.pagehelper.Page;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;

import com.github.pagehelper.PageHelper;

@Service
public class WordService {

    @Resource
    private WordMapper wordMapper;

    public int addWord(Word word) {
        return wordMapper.insert(word);
    }

    public int updateWord(Word word) {
        return wordMapper.update(word);
    }

    public int deleteWord(int id) {
        return wordMapper.delete(id);
    }

    public Word getWordById(int id) {
        return wordMapper.selectById(id);
    }

    public List<Word> searchWords(String keyword) {
        return wordMapper.searchByWord(keyword);
    }

    public List<Word> getAllWords() {
        return wordMapper.selectAll();
    }

    public PageResult<Word> getAllWordsPaged(int pageNum, int pageSize) {
        com.github.pagehelper.Page<Object> page = PageHelper.startPage(pageNum, pageSize);
        List<Word> list = wordMapper.selectAll();
        return new PageResult<>(list, ((com.github.pagehelper.Page<?>) page).getTotal());
    }

    public PageResult<Word> searchWordsPaged(String keyword, int pageNum, int pageSize) {
        Page<Object> page = PageHelper.startPage(pageNum, pageSize);
        List<Word> list = wordMapper.searchByWord(keyword);
        return new PageResult<>(list, ((com.github.pagehelper.Page<?>) page).getTotal());
    }


    public List<Word> getWordsByIds(List<Integer> ids) {
        return wordMapper.selectByIds(ids);
    }

}
<?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.WordMapper">

    <insert id="insert" parameterType="com.example.springboot.entity.Word">
        INSERT INTO words(word, definition, pronunciation, custom_content)
        VALUES(#{word}, #{definition}, #{pronunciation}, #{customContent})
    </insert>

    <update id="update" parameterType="com.example.springboot.entity.Word">
        UPDATE words
        SET definition = #{definition},
            pronunciation = #{pronunciation},
            custom_content = #{customContent}
        WHERE word = #{word}
    </update>

    <delete id="delete" parameterType="int">
        DELETE FROM words WHERE id = #{id}
    </delete>

    <select id="selectById" resultType="com.example.springboot.entity.Word" parameterType="int">
        SELECT * FROM words WHERE id = #{id}
    </select>

    <select id="searchByWord" resultType="com.example.springboot.entity.Word" parameterType="string">
        SELECT * FROM words WHERE word LIKE CONCAT('%', #{keyword}, '%')
    </select>

    <select id="selectAll" resultType="com.example.springboot.entity.Word">
        SELECT * FROM words
    </select>

    <select id="selectByIds" resultType="com.example.springboot.entity.Word" parameterType="list">
        SELECT * FROM words
        WHERE id IN
        <foreach item="id" collection="list" open="(" separator="," close=")">
            #{id}
        </foreach>
    </select>


</mapper>





<template>
  <div>
    <!-- 查询区域 -->
    <div class="card" style="margin-bottom: 10px">
      <el-input
          style="width: 240px; margin-right: 10px"
          v-model="vocabulary.name"
          placeholder="请输入名称查询"
          prefix-icon="Search"
      />
      <el-button type="primary" @click="fetchWords">查 询</el-button>
      <el-button type="warning" @click="reset">重 置</el-button>
    </div>

    <!-- 操作按钮区域 -->
    <div class="card" style="margin-bottom: 10px">
      <el-button type="primary" @click="openDialog()" style="margin-right: 10px">新 增</el-button>
      <el-button type="danger" :disabled="selectedIds.size === 0" @click="handleBatchDelete" style="margin-right: 10px">
        批量删除
      </el-button>
      <el-upload
          action="http://localhost:8080/word-excel/import"
          accept=".xlsx,.xls"
          :show-file-list="false"
          :on-success="handleImportSuccess"
          style="display: inline-block; margin-right: 10px"
      >
        <el-button type="info" style="margin-right: 10px">导 入</el-button>
      </el-upload>
      <el-button type="success" @click="handleExport">导 出</el-button>
    </div>

    <!-- 表格区域 -->
    <div class="card">
      <el-table
          :data="tableData"
          stripe
          border
          style="width: 100%"
          @selection-change="handleSelectionChange"
          :row-key="row => row.id"
          :default-selection="getSelectedRows()"
      >
        <el-table-column type="selection" width="55" />
        <el-table-column prop="word" label="单词" />
        <el-table-column prop="definition" label="词义" />
        <el-table-column prop="customContent" label="例句" />
        <el-table-column label="操作" width="260">
          <template #default="{ row }">
            <el-button size="small" @click="playWord(row.word)">🔊 发音</el-button>
            <el-button type="primary" size="small" @click="openDialog(row)">编 辑</el-button>
            <el-button type="danger" size="small" @click="handleDelete(row.id)">删 除</el-button>
          </template>
        </el-table-column>
      </el-table>

      <!-- 分页 -->
      <el-pagination
          layout="total, sizes, prev, pager, next, jumper"
          :total="total"
          :page-size="pageSize"
          :page-sizes="[10, 20, 30, 50]"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
          style="margin-top: 15px; text-align: right"
      />
    </div>

    <!-- 新增/编辑弹窗 -->
    <el-dialog v-model="dialogVisible" :title="form.id ? '编辑单词' : '新增单词'">
      <el-form :model="form" label-width="100px">
        <el-form-item label="单词">
          <el-input v-model="form.word" />
        </el-form-item>
        <el-form-item label="词义">
          <el-input v-model="form.definition" />
        </el-form-item>
        <el-form-item label="例句">
          <el-input v-model="form.customContent" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible = false">取 消</el-button>
        <el-button type="primary" @click="submitForm">确 定</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import axios from 'axios'

const vocabulary = reactive({ name: '' })
const tableData = ref([])
const total = ref(0)
const pageSize = ref(10)
const currentPage = ref(1)
const dialogVisible = ref(false)
const form = reactive({ id: null, word: '', definition: '', customContent: '' })

// 跨页选中 id 集合（Set 更高效去重）
const selectedIds = ref(new Set())

// 获取单词列表
const fetchWords = async () => {
  try {
    const params = {
      pageNum: currentPage.value,
      pageSize: pageSize.value,
      keyword: vocabulary.name || undefined
    }
    const res = await axios.get('http://localhost:8080/words', { params })
    tableData.value = res.data.records
    total.value = res.data.total
  } catch (err) {
    ElMessage.error('获取单词列表失败')
  }
}

// 重置查询，同时清空选中
const reset = () => {
  vocabulary.name = ''
  currentPage.value = 1
  selectedIds.value.clear()
  fetchWords()
}

// 发音（Web Speech API）
const playWord = (text) => {
  const utterance = new SpeechSynthesisUtterance(text)
  utterance.lang = 'en-US'
  window.speechSynthesis.speak(utterance)
}

// 新增或编辑弹窗
const openDialog = (row = null) => {
  if (row) {
    Object.assign(form, row)
  } else {
    Object.assign(form, { id: null, word: '', definition: '', customContent: '' })
  }
  dialogVisible.value = true
}

// 提交新增或编辑
const submitForm = async () => {
  try {
    if (form.id) {
      await axios.put('http://localhost:8080/words', form)
      ElMessage.success('更新成功')
    } else {
      await axios.post('http://localhost:8080/words', form)
      ElMessage.success('新增成功')
    }
    dialogVisible.value = false
    fetchWords()
  } catch (err) {
    ElMessage.error('提交失败')
  }
}

// 删除单词
const handleDelete = async (id) => {
  try {
    await ElMessageBox.confirm('确定删除该单词？', '提示', { type: 'warning' })
    await axios.delete(`http://localhost:8080/words/${id}`)
    ElMessage.success('删除成功')
    selectedIds.value.delete(id)
    fetchWords()
  } catch (err) {
    if (err !== 'cancel') {
      const word = tableData.value.find(item => item.id === id)?.word || `ID ${id}`
      if (err.response && err.response.data) {
        ElMessage.error(`单词「${word}」删除失败：${err.response.data}`)
      } else {
        ElMessage.error(`单词「${word}」删除失败`)
      }
    }
  }
}


// 批量删除
const handleBatchDelete = async () => {
  if (selectedIds.value.size === 0) {
    ElMessage.warning('请先选择要删除的单词')
    return
  }

  try {
    await ElMessageBox.confirm('确定批量删除选中项？', '提示', { type: 'warning' })

    const ids = Array.from(selectedIds.value)
    const failed = [] // 保存删除失败的单词

    for (const id of ids) {
      try {
        await axios.delete(`http://localhost:8080/words/${id}`)
        selectedIds.value.delete(id)
      } catch (err) {
        const word = tableData.value.find(item => item.id === id)?.word || `ID ${id}`
        failed.push(word)
      }
    }

    if (failed.length === 0) {
      ElMessage.success('批量删除成功')
    } else {
      ElMessage.warning(`以下单词删除失败（可能是用户添加的，禁止删除）：${failed.join('、')}`)
    }

    fetchWords()
  } catch (err) {
    if (err !== 'cancel') {
      ElMessage.error('批量删除中发生错误')
    }
  }
}



// 获取当前页默认选中行，用于表格选中回显
const getSelectedRows = () => {
  return tableData.value.filter(item => selectedIds.value.has(item.id))
}

// 选中项变化，更新跨页选中id集合
const handleSelectionChange = (selection) => {
  // 先移除当前页所有id
  tableData.value.forEach(item => selectedIds.value.delete(item.id))
  // 添加当前页选中的id
  selection.forEach(item => selectedIds.value.add(item.id))
}

// 导出 Excel（支持跨页选中）
const handleExport = async () => {
  try {
    if (selectedIds.value.size === 0) {
      // 无选中，导出所有
      window.open('http://localhost:8080/word-excel/export')
    } else {
      // 有选中，导出选中id
      const ids = Array.from(selectedIds.value)
      const res = await axios.post('http://localhost:8080/word-excel/export-selected', ids, {
        responseType: 'blob'
      })
      const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
      const url = window.URL.createObjectURL(blob)
      const link = document.createElement('a')
      link.href = url
      link.download = '选中单词表.xlsx'
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)
      window.URL.revokeObjectURL(url)
      ElMessage.success('导出成功')
    }
  } catch (err) {
    ElMessage.error('导出失败')
  }
}

// 导入成功提示
const handleImportSuccess = () => {
  ElMessage.success('导入成功')
  fetchWords()
}

// 分页大小改变
const handleSizeChange = (val) => {
  pageSize.value = val
  currentPage.value = 1
  fetchWords()
}

// 当前页改变
const handleCurrentChange = (val) => {
  currentPage.value = val
  fetchWords()
}
//  页面加载时获取单词列表
onMounted(() => {
  fetchWords()
})
</script>
<template>
  <div>
    <!-- 查询区域 -->
    <div class="card" style="margin-bottom: 10px">
      <el-input
          style="width: 240px; margin-right: 10px"
          v-model="vocabulary.name"
          placeholder="请输入名称查询"
          prefix-icon="Search"
      />
      <el-button type="primary" @click="fetchWords">查 询</el-button>
      <el-button type="warning" @click="reset">重 置</el-button>
      <el-button type="primary" @click="openTodayPlanDialog">今日背诵计划</el-button>
    </div>

    <!-- 操作按钮区域 -->
    <div class="card" style="margin-bottom: 10px">
      <el-button
          type="primary"
          :disabled="!selectedIds.size"
          @click="addSelectedWordsToPlan"
          style="margin-right: 10px"
      >
        加入背诵计划
      </el-button>
    </div>

    <!-- 表格区域 -->
    <div class="card">
      <el-table
          ref="tableRef"
          :data="tableData"
          :row-key="row => row.id"
          stripe
          border
          style="width: 100%"
          @selection-change="handleSelectionChange"
          :row-class-name="getRowClassName"
      >
        <el-table-column type="selection" width="55" :selectable="isSelectable" />
        // 单词列
        <el-table-column prop="word" label="单词">
          <template #default="{ row }">
            <el-tooltip v-if="todayPlanWordIds.has(row.id)" content="已在今日计划中" placement="top">
              <span>{{ row.word }}</span>
            </el-tooltip>
            <span v-else>{{ row.word }}</span>
          </template>
        </el-table-column>

        <el-table-column prop="definition" label="词义" />
        <el-table-column prop="customContent" label="例句" />
        <el-table-column label="操作" width="100">
          <template #default="{ row }">
            <el-button size="small" @click="playWord(row.word)">🔊 发音</el-button>
          </template>
        </el-table-column>
      </el-table>


      <!-- 分页 -->
      <el-pagination
          layout="total, sizes, prev, pager, next, jumper"
          :total="total"
          :page-size="pageSize"
          :page-sizes="[10, 20, 30, 50]"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
          style="margin-top: 15px; text-align: right"
      />
    </div>

    <!-- 今日背诵计划弹窗 -->
    <el-dialog title="📘 今日背诵计划" v-model="todayPlanDialogVisible" width="60%">
      <div style="margin-bottom: 10px; display: flex; justify-content: space-between;">
        <div>共 {{ todayPlanTotal }} 个单词</div>
      </div>

      <el-table
          :data="todayPlanWords"
          @selection-change="handleTodaySelectionChange"
          stripe
          border
          style="width: 100%"
      >
        <el-table-column type="selection" width="55" />
        <el-table-column prop="wordText" label="单词" />
        <el-table-column prop="meaning" label="词义" />
      </el-table>

      <template #footer>
        <el-button @click="todayPlanDialogVisible = false">关闭</el-button>
        <el-button
            type="success"
            :disabled="!selectedTodayWords.length"
            @click="exportSelectedTodayWords"
        >
          导出选中单词
        </el-button>
        <el-button
            type="danger"
            :disabled="!selectedTodayWords.length"
            @click="deleteSelectedTodayWords"
        >
          删除选中单词
        </el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import axios from 'axios'

// 用户 ID
const userId = localStorage.getItem('userId')

// 表格相关
const vocabulary = reactive({ name: '' })
const tableData = ref([])
const total = ref(0)
const pageSize = ref(10)
const currentPage = ref(1)
const selectedIds = ref(new Set())
const tableRef = ref(null)

// 今日计划相关
const todayPlanDialogVisible = ref(false)
const todayPlanWords = ref([])
const selectedTodayWords = ref([])
const todayPlanTotal = ref(0)
const todayPlanCurrentPage = ref(1)
const todayPlanWordIds = ref(new Set()) // ✅ 新增：保存已加入计划的单词 ID

// 判断某行是否可选中
const isSelectable = (row) => {
  return !todayPlanWordIds.value.has(row.id)
}




// 获取单词数据
const fetchWords = async () => {
  try {
    const params = {
      pageNum: currentPage.value,
      pageSize: pageSize.value,
      keyword: vocabulary.name || undefined
    }
    const res = await axios.get('http://localhost:8080/words', { params })
    tableData.value = res.data.records
    total.value = res.data.total

    await nextTick()
    restoreSelection()
  } catch (err) {
    ElMessage.error('获取单词列表失败')
  }
}

// 多选变化
const handleSelectionChange = (selection) => {
  selection.forEach(item => selectedIds.value.add(item.id))
  tableData.value.forEach(row => {
    if (!selection.find(s => s.id === row.id)) {
      selectedIds.value.delete(row.id)
    }
  })
}

// 恢复勾选状态
const restoreSelection = () => {
  if (!tableRef.value) return
  tableRef.value.clearSelection()
  tableData.value.forEach(row => {
    if (selectedIds.value.has(row.id)) {
      tableRef.value.toggleRowSelection(row, true)
    }
  })
}

// 重置查询
const reset = () => {
  vocabulary.name = ''
  currentPage.value = 1
  fetchWords()
}

// 分页
const handleSizeChange = (val) => {
  pageSize.value = val
  currentPage.value = 1
  fetchWords()
}
const handleCurrentChange = (val) => {
  currentPage.value = val
  fetchWords()
}

// 发音
const playWord = (text) => {
  if (!text) {
    ElMessage.warning('无效的单词发音')
    return
  }
  const utterance = new SpeechSynthesisUtterance(text)
  utterance.lang = 'en-US'
  window.speechSynthesis.speak(utterance)
}

// 加入背诵计划
const addSelectedWordsToPlan = async () => {
  if (!selectedIds.value.size) {
    ElMessage.warning('请先选择单词')
    return
  }
  try {
    const wordIds = Array.from(selectedIds.value)
    await axios.post('http://localhost:8080/today-plan/add', {
      userId,
      wordIds
    })

    ElMessage.success('单词已加入背诵计划')
    if (todayPlanDialogVisible.value) {
      loadTodayPlan(todayPlanCurrentPage.value)
    }

    // 重新加载已加入计划的 ID，刷新禁选状态
    await loadTodayPlan()
    fetchWords()

  } catch (err) {
    if (err.response && err.response.status === 409) {
      const data = err.response.data || {}
      const existingIds = data.existingWordIds || []

      // 你可以根据已有单词ID在tableData中找到对应的单词, 拼接提示内容
      const existingWords = tableData.value
          .filter(item => existingIds.includes(item.id))
          .map(item => item.word)
          .join('，')

      ElMessage.warning(`部分单词已存在长期记忆，请不要重复添加: ${existingWords}`)

      // 仍然刷新接口，保持界面状态
      if (todayPlanDialogVisible.value) {
        await loadTodayPlan(todayPlanCurrentPage.value)
      }
      await loadTodayPlan()
      fetchWords()
    } else {
      ElMessage.error('加入背诵计划失败')
    }
  }
}

// 打开今日计划弹窗
const openTodayPlanDialog = () => {
  todayPlanDialogVisible.value = true
  loadTodayPlan()
}

// 加载今日计划
const loadTodayPlan = async () => {
  try {
    const res = await axios.get('http://localhost:8080/today-plan/todaylist', {
      params: { userId }
    })

    todayPlanWords.value = res.data
    todayPlanTotal.value = res.data.length
    todayPlanWordIds.value = new Set(res.data.map(item => item.wordId || item.id)) // ✅ 更新禁选 ID 集合

  } catch (err) {
    ElMessage.error('加载今日背诵单词失败')
  }
}

// 今日计划选择
const handleTodaySelectionChange = (val) => {
  selectedTodayWords.value = val
}

// 导出选中
const exportSelectedTodayWords = async () => {
  if (!selectedTodayWords.value.length) {
    ElMessage.warning('请先选择单词')
    return
  }
  try {
    const selectedIds = selectedTodayWords.value.map(item => item.id)
    const params = new URLSearchParams()
    params.append('userId', userId)
    selectedIds.forEach(id => params.append('ids', id))

    const res = await axios.get('http://localhost:8080/plan-excel/export-selected', {
      params,
      paramsSerializer: params => params.toString(),
      responseType: 'blob'
    })

    const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
    const url = window.URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = url
    link.setAttribute('download', 'today_words.xlsx')
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
    window.URL.revokeObjectURL(url)

  } catch (err) {
    ElMessage.error('导出失败')
  }
}

// 删除今日单词
const deleteSelectedTodayWords = async () => {
  if (!selectedTodayWords.value.length) {
    ElMessage.warning('请先选择要删除的单词')
    return
  }

  try {
    const confirm = await ElMessageBox.confirm(
        '确定要从今日背诵计划中删除选中的单词吗？',
        '提示',
        {
          confirmButtonText: '确定',
          cancelButtonText: '取消',
          type: 'warning'
        }
    )

    const idsToDelete = selectedTodayWords.value.map(item => item.id)

    await axios.post('http://localhost:8080/today-plan/delete', {
      userId,
      wordIds: idsToDelete
    })

    ElMessage.success('删除成功')
    loadTodayPlan()

  } catch (err) {
    if (err !== 'cancel') {
      ElMessage.error('删除失败')
    }
  }
}

// 初始化加载
onMounted(async () => {
  await loadTodayPlan()
  fetchWords()
})
</script>

package com.example.springboot.controller;

import com.example.springboot.dto.AddWordsRequest;
import com.example.springboot.entity.TodayPlan;
import com.example.springboot.service.TodayPlanService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/today-plan")
public class TodayPlanController {

    @Resource
    private TodayPlanService todayPlanService;

    @PostMapping("/add")
    public ResponseEntity<?> addWordsToPlan(@RequestBody AddWordsRequest request) {
        List<Integer> existingWordIds = todayPlanService.addWordsToPlan(request.getUserId(), request.getWordIds());
        if (existingWordIds == null || existingWordIds.isEmpty()) {
            return ResponseEntity.ok(Map.of("message", "单词已全部成功加入背诵计划"));
        } else {
            return ResponseEntity.status(409).body(Map.of(
                    "message", "部分单词已存在长期记忆，请不要重复添加",
                    "existingWordIds", existingWordIds
            ));
        }
    }

    //  获取今日计划
    @GetMapping("/todaylist")
    public List<TodayPlan> getTodayPlan(@RequestParam Integer userId) {
        return todayPlanService.getTodayPlan(userId);
    }

    @PostMapping("/delete")
    public void deleteWordsFromPlan(@RequestBody AddWordsRequest request) {
        todayPlanService.deletePlansByIds(request.getUserId(), request.getWordIds());
    }
}
package com.example.springboot.controller;

import com.alibaba.excel.EasyExcel;
import com.example.springboot.dto.WordExcelDTO;
import com.example.springboot.entity.TodayPlan;
import com.example.springboot.service.TodayPlanService;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.*;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/plan-excel")
public class TodayPlanExcelController {

    @Resource
    private TodayPlanService todayPlanService;

    @GetMapping(value = "/export-selected", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
    public void exportSelectedPlanExcel(@RequestParam Integer userId,
                                        @RequestParam List<Integer> ids,
                                        HttpServletResponse response) {
        try {
            List<TodayPlan> planList = todayPlanService.exportPlanByIds(userId, ids);
            List<WordExcelDTO> exportData = planList.stream().map(plan -> {
                WordExcelDTO dto = new WordExcelDTO();
                dto.setWord(plan.getWordText());
                dto.setDefinition(plan.getMeaning());
                dto.setPronunciation("");
                dto.setCustomContent("");
                return dto;
            }).collect(Collectors.toList());

            String fileName = URLEncoder.encode("选中背诵计划", StandardCharsets.UTF_8).replaceAll("\\+", "%20");
            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            response.setCharacterEncoding("utf-8");
            response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");

            EasyExcel.write(response.getOutputStream(), WordExcelDTO.class)
                    .sheet("选中背诵计划")
                    .doWrite(exportData);

        } catch (Exception e) {
            throw new RuntimeException("导出选中计划失败", e);
        }
    }

}
package com.example.springboot.mapper;

import com.example.springboot.entity.TodayPlan;
import org.apache.ibatis.annotations.Param;
import java.util.List;

public interface TodayPlanMapper {
    void batchInsertPlans(@Param("userId") Integer userId, @Param("wordIds") List<Integer> wordIds);
    List<TodayPlan> getTodayPlans(@Param("userId") Integer userId);
    List<TodayPlan> exportPlans(@Param("userId") Integer userId);

    List<TodayPlan> selectPlansByIds(@Param("userId") Integer userId, @Param("ids") List<Integer> ids);
    void deletePlansByIds(@Param("userId") Integer userId, @Param("ids") List<Integer> ids);

    List<Integer> selectExistingWordIds(@Param("userId") Integer userId, @Param("wordIds") List<Integer> wordIds);


}
package com.example.springboot.service;

import com.example.springboot.entity.TodayPlan;
import com.example.springboot.mapper.LongTermMemoryMapper;
import com.example.springboot.mapper.TodayPlanMapper;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.*;
import java.util.stream.Collectors;

@Service
public class TodayPlanService {

    @Resource
    private TodayPlanMapper todayPlanMapper;

    @Resource
    private LongTermMemoryMapper longTermMemoryMapper;

    /**
     * 添加单词到今日计划，自动过滤已存在于user_word_plan或long_term_memory的单词
     * @param userId 用户ID
     * @param wordIds 待添加单词ID列表
     * @return 已存在的单词ID列表
     */
    public List<Integer> addWordsToPlan(Integer userId, List<Integer> wordIds) {
        // 查询今天计划和长期记忆中已存在的单词ID
        List<Integer> existingInPlan = todayPlanMapper.selectExistingWordIds(userId, wordIds);
        List<Integer> existingInLtm = longTermMemoryMapper.selectExistingWordIds(userId, wordIds);

        Set<Integer> existingSet = new HashSet<>();
        if (existingInPlan != null) existingSet.addAll(existingInPlan);
        if (existingInLtm != null) existingSet.addAll(existingInLtm);

        // 过滤要插入的单词ID
        List<Integer> toAdd = wordIds.stream()
                .filter(id -> !existingSet.contains(id))
                .collect(Collectors.toList());

        if (!toAdd.isEmpty()) {
            todayPlanMapper.batchInsertPlans(userId, toAdd);
        }

        // 返回已存在的单词ID给前端做提示
        return new ArrayList<>(existingSet);
    }

    public List<TodayPlan> getTodayPlan(Integer userId) {
        return todayPlanMapper.getTodayPlans(userId);
    }

    public List<TodayPlan> exportPlanByIds(Integer userId, List<Integer> ids) {
        return todayPlanMapper.selectPlansByIds(userId, ids);
    }

    public void deletePlansByIds(Integer userId, List<Integer> ids) {
        todayPlanMapper.deletePlansByIds(userId, ids);
    }
}
<?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.TodayPlanMapper">

    <insert id="batchInsertPlans">
        INSERT IGNORE INTO user_word_plan (user_id, word_id)
        VALUES
        <foreach collection="wordIds" item="wordId" separator=",">
            (#{userId}, #{wordId})
        </foreach>
    </insert>

    <select id="getTodayPlans" resultType="com.example.springboot.entity.TodayPlan">
        SELECT uwp.*,
               w.word AS wordText,
               w.definition AS meaning
        FROM user_word_plan uwp
                 JOIN words w ON uwp.word_id = w.id
        WHERE uwp.user_id = #{userId}
          AND DATE(uwp.added_at) = CURDATE()
        ORDER BY uwp.added_at DESC
    </select>

    <select id="exportPlans" resultType="com.example.springboot.entity.TodayPlan">
        SELECT uwp.*,
               w.word AS wordText,
               w.definition AS meaning
        FROM user_word_plan uwp
                 JOIN words w ON uwp.word_id = w.id
        WHERE uwp.user_id = #{userId}
        ORDER BY uwp.added_at DESC
    </select>

    <select id="selectPlansByIds" resultType="com.example.springboot.entity.TodayPlan">
        SELECT uwp.*,
        w.word AS wordText,
        w.definition AS meaning
        FROM user_word_plan uwp
        JOIN words w ON uwp.word_id = w.id
        WHERE uwp.user_id = #{userId}
        AND uwp.id IN
        <foreach item="id" collection="ids" open="(" separator="," close=")">
            #{id}
        </foreach>
    </select>

    <delete id="deletePlansByIds">
        DELETE FROM user_word_plan
        WHERE user_id = #{userId}
        AND id IN
        <foreach collection="ids" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </delete>

    <select id="selectExistingWordIds" resultType="int" parameterType="map">
        SELECT word_id FROM user_word_plan
        WHERE user_id = #{userId}
        AND word_id IN
        <foreach collection="wordIds" item="wordId" open="(" separator="," close=")">
            #{wordId}
        </foreach>
    </select>




</mapper>