Compare commits

..

26 Commits

Author SHA1 Message Date
lbw
fcf381b8f1 fix(exam): 优化考试单词识别与文件上传功能
- 修改application.yml默认激活环境为pro,调整上传文件大小限制为30MB
- 更新pro配置中的模板文件版本和路径
- 修复考试单词ID乱序问题,添加生成试卷成功日志
- 优化考试识别逻辑,确保ansSheetPath变量正确处理,完善异常捕获与文件删除机制
- 增加上传文件格式白名单校验,强化文件存储路径和命名安全性
- 用NIO替换File操作,确保上传目录存在并合理创建
- 优化PngUtil图像二值化处理,增加自适应阈值和形态学操作减少噪声
- 修改未背熟单词识别阈值,调整检测区域坐标和日志输出
- 注释部分冗余图像预处理代码,完善日志和异常信息提示
- 统一文件上传与识别过程的错误处理和日志记录,提升系统稳定性和可维护性
2026-01-06 14:48:01 +08:00
lbw
fb29acc145 feat(ui): 提升界面响应式支持和移动端适配体验
- 新增移动端全屏对话框支持及标签宽度和位置动态调整,优化新增班级、年级和学生弹窗布局
- 所有对话框增加屏幕宽度监听,实现自动切换移动端和桌面端样式
- 表格组件增加移动端列表视图,隐藏侧边栏并改进分页和按钮自适应,提升小屏幕浏览体验
- Dialog及详情弹窗添加最大高度限制并启用滚动,防止移动端显示区域拥挤
- 登录页增加安全区域内边距,保证iOS等设备显示完整性
- 新增移动端菜单抽屉组件,支持手机端侧边栏交互显示
- 学生详情页调整词汇热力图列数,实现移动端更合理布局
- 表格和按钮统一增设触控友好大尺寸区域,提升移动端操作便利性
- 修正后端空词汇ID查询问题,避免空列表导致查询异常
- 统一隐藏小屏幕时的固定侧边栏,避免界面混乱和重复显示
- 搜索页和上传页表格添加移动端适配样式和展开收起逻辑,提升列表浏览灵活性
2026-01-05 18:47:50 +08:00
lbw
7182371c92 fix(enlish-service): 优化单词数据处理及模板配置更新
- 修改开发和生产环境配置中的数据库连接及模板文件路径
- ExamWordsController新增单词列表拆分为两部分返回
- ExamWordsServiceImpl增加单词数量不足时补充逻辑,确保单词数量满足要求
- LessonPlansServiceImpl优化教案数据组装,增加班级信息及单词列表拆分功能
- PngUtil调整图像二值化阈值,完善轮廓检测及未背熟单词识别逻辑,移除冗余代码
- SaTokenConfigure更新路由权限配置,添加对tts接口的不拦截支持
- 删除StudentExamWordsDOMapper中is_completed条件,调整查询方式
- UserController修正接口日志注释,准确描述修改用户信息功能
- VocabularyBankDOMapper新增根据年级与排除ID查询单词接口及SQL映射
- WordExportUtil更新导出配置,支持拆分单词列表绑定两个集合以适应新结构
2026-01-05 18:09:36 +08:00
lbw
09b326c07a feat(login): 优化登录注册页UI和交互体验
- 使用全屏背景图和渐变叠加提升视觉效果
- 增加登录注册切换标签及动画过渡效果
- 表单输入框增加图标和玻璃质感样式
- 调整表单布局,简化登录表单,丰富注册表单字段
- 替换原有按钮为渐变风格提升点击感
- 引入FontAwesome图标库支持图标显示
- 增加输入框聚焦和悬停状态样式
- 优化Loading状态文案展示逻辑
2026-01-05 12:56:32 +08:00
lbw
49963bb49c feat(user): 添加用户信息修改功能及对应验证码校验
- 在管理员页面新增修改用户信息表单,支持姓名、手机号、密码修改
- 实现验证码发送倒计时与发送状态管理
- 新增接口支持用户信息更新,包含密码和手机号校验
- 后端校验验证码有效性,编码密码后更新用户信息
- 修改用户信息后强制登出,确保安全性
- 优化登录状态判断,登出后跳转至登录页
- 取消部分日志打印,调整发送验证码缓存过期时间为5分钟
2026-01-05 12:07:15 +08:00
lbw
bf2a80917c style(ui): 优化页面容器及布局样式,调整头部组件图标和交互
- 为多个页面容器添加最小高度类,保证页面满屏显示
- 调整部分主内容区高度样式,增强布局一致性和视觉整洁
- 替换头部组件Logo为自定义SVG图标,提升品牌识别度
- 优化头部用户菜单按钮交互和样式,统一暗黑模式视觉效果
- 调整TTS页面词汇列表布局,支持移动端和桌面端不同显示方式
- 修改学生详情页面样式,提升各模块容器的统一性和分隔感
- 修正历史数据日期格式,将“T”替换为空格以增强可读性
2026-01-05 11:21:55 +08:00
lbw
deabd5f7f5 feat(exam): 优化考试判卷逻辑并完善相关接口
- 新增获取未完成判卷数量接口 getExamUnfinishedCount
- 修改定时任务 autoJudgeExamWords 使用未完成判卷数量判断
- Mapper 添加 selectUnfinishedCount 方法,SQL查询未完成记录数
- 优化文件上传判卷时日志输出,增加“上传文件成功”日志
- 修正判卷时未识别学生和考试的提示信息
- 移除学生查询页面中班级和年级ID显示,仅保留名称显示
- 测试类中添加判卷方法调用测试逻辑
- 配置文件增加 Sa-Token 相关日志调试配置
2026-01-04 18:52:50 +08:00
lbw
0802f6fe70 fix(lesson-plan): 优化学案生成文本及缓存时长
- 调整学案生成时异常提示文案为“学案正在生成,请耐心等待”
- 缩短Redis缓存过期时间由12分钟改为7分钟,提高资源释放效率
- 修正生成标题中去除学生ID显示,避免冗余信息
- 将词汇列表转换为Word对象,隐藏单词展示内容以模拟填空效果
- 调整多轮练习词汇列表展示样式,统一处理单词显示
- 混合练习中隐藏词汇释义文本,防止提前泄露答案
- 保持数据结构适配前端练习需求,提升用户体验
2026-01-04 15:01:42 +08:00
lbw
679241588f style(ui): 优化页面布局和样式细节
- admid页面将主区域拆分为两栏布局,调整新增用户按钮颜色统一为primary
- admid页面生成邀请码区域样式微调,增加标题字体大小并统一边距
- 全局主色从#409eff更改为#2563eb,更新顶部加载条颜色
- class页和learningPlan页整体布局调整,包含容器内间距、面板样式统一及按钮样式修改
- uploadpng页布局调整,容器添加内边距,使用panel-shell统一面板样式
- header组件logo图片添加width和height属性,调整导航栏最小高度及z-index提升层级
- 修改CSS变量定义,统一主色调、圆角和背景样式,实现暗黑模式和浅色模式背景渐变效果
- 移除一些多余的class名和调整padding,统一整体界面空间分布和层次感
2026-01-04 12:11:39 +08:00
lbw
fe7128dd4e feat(layout): 为多页面添加侧边栏布局组件
- 在admid页面和class页面包裹el-container,插入Sidebar侧边栏组件
- 在LearningPlan和PlanTTS页面同样新增侧边栏布局
- 重构Header组件样式,采用fluent风格透明卡片和按钮样式
- 增加main.css中panel-shell的样式定义以支持新布局视觉效果
- 优化部分按钮及菜单交互样式,提升整体一致性与视觉体验
2026-01-04 11:10:29 +08:00
lbw
1184ea7895 feat(student): 添加学习分析功能组件及相关交互
- 在班级页面列表操作栏增加“学情分析”按钮,点击弹出学情分析对话框
- 新增StudyAnalysis组件,封装学习分析生成与展示逻辑
- 学生详情页替换原有学习分析区域,统一使用StudyAnalysis组件
- 移除学生页原有学习分析相关状态管理和接口调用,简化代码
- 通过定时器模拟加载进度条,提升生成学习分析时的用户体验
2026-01-04 10:17:59 +08:00
lbw
e468be74b7 feat(exam): 添加按班级、年级、学生姓名筛选考试结果功能
- 在ExamWordsResultReqVO中新增classId、gradeId、studentName字段
- 修改ExamWordsJudgeService接口及实现,支持按筛选条件获取考试结果
- 在ExamWordsJudgeResultDOMapper中添加按学生ID列表分页查询方法
- 扩展StudentDOMapper,新增按班级、年级和姓名查询学生列表方法
- 修改ExamWordsController,支持从请求体接收筛选参数
- 修改前端exam.js,调用接口时传递筛选参数
- 在uploadpng.vue页面新增筛选表单,支持班级、年级、学生姓名输入
- 增加班级和年级选项数据的获取
- 实现筛选查询、重置功能及班级切换自动同步年级
- 在App.vue中配置Element Plus中文语言环境
- 调整配置文件,更新学习计划模板路径
- 修改接口权限配置,关闭plan/word/voice路径的鉴权
2025-12-31 19:21:19 +08:00
lbw
57166af2f4 Merge branch 'master' of https://git.yinlihupo.cn/LeiBoWen/en-edu 2025-12-31 17:24:03 +08:00
lbw
fcb8ac9c22 feat(plan): 添加周末计划生成日志记录
- 在生成周末计划开始处添加日志输出
- 有助于调试和监控计划生成流程
2025-12-31 17:22:55 +08:00
7f2fda16ec Merge pull request 'fix(lesson-plans): 修正补差词汇汇总及周末计划分割逻辑' (#1) from detached into master
Reviewed-on: #1
2025-12-31 16:55:54 +08:00
lbw
9cd43c0e74 fix(lesson-plans): 修正补差词汇汇总及周末计划分割逻辑
- 新增totalWords列表汇总同步词汇和补差词汇
- 修改周末计划分割逻辑,使用totalWords列表长度均分两部分
- 纠正checkList子列表截取范围,避免越界错误
- 优化学习计划数据构建过程,保证数据完整性
2025-12-31 16:53:28 +08:00
lbw
b86f37443c 保存数据库文件 2025-12-31 16:28:35 +08:00
lbw
36e5231c6c feat(plan): 添加学生学案查看及下载功能
- 在学生列表表格中新增“查看学案”按钮,支持查看对应学生的学案列表
- 新增StudentPlanListDialog组件,实现学案列表展示和学案文件下载
- 后端新增查询学生学案接口,支持按学生ID获取未完成学案列表
- 后端数据层和服务层添加按学生ID查询学案的方法
- 调整计划生成相关逻辑,优化学案数据字段命名
- Vue前端调用新增接口,实现学生学案列表动态加载与下载操作
- 完善学案状态显示和列表交互体验
2025-12-31 16:20:52 +08:00
lbw
868e0bb7bd feat(plan): 支持学案生成状态轮询与进度显示
- 新增接口检查学案是否正在生成,防止重复生成任务
- 使用 Redis 缓存标识学案生成状态,设置 12 分钟过期时间
- 生成学案时记录状态至 Redis,生成完成后自动清除
- Vue 学案列表新增学案生成进度条显示与已生成标签
- 新增组件事件监听生成成功,触发轮询检测学案状态
- 轮询间隔 10 秒,动态更新学案生成进度,最高至 95%
- 路由离开与组件卸载时停止所有轮询,防止内存泄漏
- 优化学案生成逻辑,新增小测试卷自动关联及数据入库
- 更新配置文件模板路径,提高文档管理一致性
2025-12-31 15:41:53 +08:00
lbw
504dd8d964 refactor(exam): 重构考试判卷任务与新增教案小测功能
- 将自动判卷任务类从job包迁移到task包并添加日志打印,增强可维护性
- 优化自动判卷逻辑,仅当有未完成试卷时触发判卷处理
- 修正判卷结果中学生姓名和考试标题的空值保护,防止空指针异常
- 扩展考试类型常量,新增教案小测类型常量EXAM_TYPE_TEST
- 修改exam_words表插入语句,新增type字段支持不同考试类型
- 优化判卷逻辑:仅摸底考试计算等级,其他考试添加答题数提示信息
- 判卷完成后调用学案服务,自动完成学生对应学案计划
- 在教案计划生成中增加教案小测试卷的生成与存储
- 新增PlanExamDO数据对象及对应Mapper,实现学案与考试的关联映射
- 修改MySQL及Redis配置文件,完善环境配置
- 修正文档生成模板路径及生成逻辑,优化导出功能
- 屏蔽部分测试用例中的直接调用,防止无效执行和输出
- 清理部分测试代码中的硬编码和无用注释,提升代码整洁度
- 删除无用OMR测试代码,注释多余测试实现,简化测试类
- 统一二维码链接域名,指向正式环境地址
- 移除enlish-service中对词汇库插入的测试注释,避免误执行
2025-12-31 14:16:43 +08:00
lbw
6277e3ab42 fix(permission): 修复权限路由跳转逻辑
- 调整判断条件,允许未登录用户访问 /plan/tts 路径
- 防止未登录时强制跳转到登录页,提升用户体验
- 修正 application-dev.yml 中 speech 配置缩进问题,保证配置正确解析
2025-12-30 11:59:47 +08:00
lbw
0f5169c1d7 refactor(ai): 重构AI客户端类并添加句子分析接口
- 将 DifyArticleClient 重命名为 DifyClient,调整相关调用引用
- 增加 sendSentenceAnalyze 方法,实现批量词汇句子分析功能
- 更新配置文件,新增 analyzeKey 和 sentenceKey 两个AI接口密钥配置
- 修改LessonPlansServiceImpl中生成连词成句功能,调用新句子分析接口
- 优化LessonPlanConstant常量,调整部分键名格式,去除空格
- 修改相关测试类适配 DifyClient 改动
- 相关VO类添加学生姓名和试题名称字段,丰富展示数据
- 更新前端table组件,替换显示学生姓名、班级名、年级名等信息字段
- 调整路由首页路径为“/”,修改Header组件对应链接
- 其他若干细节修改,如异常日志优化、时间格式展示等
2025-12-30 10:30:26 +08:00
lbw
5ebf40101d refactor(class): 优化班级页面布局与删除功能
- 调整学生查询区域样式,使其占用两行空间
- 移除年级和单元列表中的删除按钮及新增操作相关代码
- 新增删除学生与班级前的确认弹窗提示,防止误操作
- 捕获删除确认框的取消或关闭事件,避免错误提示
- 主动导入 Element Plus 组件库样式文件,保证样式完整
- 修正角色同步代码中 Redis 写入方式,避免二次序列化问题
2025-12-29 15:55:36 +08:00
lbw
5858bf2ecc feat(admin): 新增邀请码生成及注册校验功能
- 在管理员页面新增邀请码生成面板,支持限制使用次数和有效期
- 新增后端接口支持创建邀请码,邀请码存储在Redis并设置过期时间
- 用户注册接口新增邀请码参数,校验邀请码有效性和剩余使用次数
- 注册时成功使用邀请码后,Redis中对应邀请码的使用次数减1
- 登录接口及相关服务层逻辑新增邀请码字段支持
- 后端权限配置增加/admin路径的root角色校验
- 优化角色权限同步时Redis存储格式为列表类型
- 调整SaToken相关接口实现以支持角色ID转换逻辑
2025-12-29 15:44:05 +08:00
lbw
bddf6c0936 feat(student): 添加学习分析生成进度显示
- 在分析生成期间显示进度条和提示信息
- 引入 analyzeProgress 变量动态更新进度百分比
- 使用定时器模拟进度增长,达到 90% 后等待完成
- 分析完成后将进度设置为 100% 并清除定时器
- 调整模板逻辑,区分加载中和结果显示状态
2025-12-29 14:50:27 +08:00
lbw
340bc5b5e3 feat(auth): 实现登录注册功能和路由权限控制
- 新增 Login.vue 实现登录与注册界面,支持手机号、密码、验证码等验证
- 添加登录状态保持并在登录成功后设置 token
- 修改路由配置,新增 /login 路由,并调整默认班级页路由为 /class
- 移除 Header 组件中原有登录按钮,改为通过路由控制访问权限
- 实现路由前置守卫,根据 token 自动跳转登录页或班级页
- 添加验证码发送功能及倒计时禁用按钮逻辑
- 完善表单校验规则,区分登录和注册模式验证字段
2025-12-29 14:45:44 +08:00
103 changed files with 14276 additions and 919 deletions

11395
docx/enlish_v3.sql Normal file

File diff suppressed because one or more lines are too long

View File

@@ -24,24 +24,16 @@ public class SaTokenConfigure implements WebMvcConfigurer {
.back(); .back();
SaRouter.match("/**") SaRouter.match("/**")
.notMatch("/class/list")
.notMatch("/exam/words/get")
.notMatch("/exam/words/detail")
.notMatch("/exam/words/student/history")
.notMatch("grade/list")
.notMatch("/student/list")
.notMatch("/student/detail")
.notMatch("/studentLessonPlans/list")
.notMatch("/studentLessonPlans/history")
.notMatch("/student/analyze")
.notMatch("/student/mastery/detail")
.notMatch("/unit/list")
.notMatch("/vocabulary/list")
.notMatch("/vocabulary/student/detail")
.notMatch("/plan/download")
.notMatch("/login/**") .notMatch("/login/**")
.notMatch("/plan/word/voice")
.notMatch("/plan/word/voice/tts")
.check(r -> StpUtil.checkLogin()); .check(r -> StpUtil.checkLogin());
SaRouter.match("/admin/**")
.notMatch("/plan/word/voice")
.notMatch("/plan/word/voice/tts")
.check(r -> StpUtil.checkRole("root"));
})) }))
.addPathPatterns("/**") .addPathPatterns("/**")
.excludePathPatterns("/error"); .excludePathPatterns("/error");

View File

@@ -28,7 +28,11 @@ public class StpInterfaceImpl implements StpInterface {
@Override @Override
public List<String> getRoleList(Object loginId, String loginType) { public List<String> getRoleList(Object loginId, String loginType) {
return userToRole((Long) loginId) ; long l = 0L;
if (loginId instanceof String loginIdStr) {
l = Long.parseLong(loginIdStr);
}
return userToRole(l);
} }
private List<String> userToRole(Long userId) { private List<String> userToRole(Long userId) {

View File

@@ -31,9 +31,15 @@ public class ExamWordsConstant {
public static final int ZONE_F_SIZE = 7; public static final int ZONE_F_SIZE = 7;
// 摸底
public static final int EXAM_TYPE_BASELINE = 1; public static final int EXAM_TYPE_BASELINE = 1;
// 中期
public static final int EXAM_TYPE_MIDTERM = 2; public static final int EXAM_TYPE_MIDTERM = 2;
// 期末
public static final int EXAM_TYPE_FINAL = 3; public static final int EXAM_TYPE_FINAL = 3;
// 小测
public static final int EXAM_TYPE_TEST = 4;
public static int getZoneA(int gradeId) { public static int getZoneA(int gradeId) {
return switch (gradeId) { return switch (gradeId) {
@@ -131,4 +137,17 @@ public class ExamWordsConstant {
default -> ""; default -> "";
}; };
} }
public static String day2Chinese(int day) {
return switch (day) {
case 1 -> "";
case 2 -> "";
case 3 -> "";
case 4 -> "";
case 5 -> "";
case 6 -> "";
case 7 -> "";
default -> "";
};
}
} }

View File

@@ -1,10 +1,17 @@
package com.yinlihupo.enlish.service.constant; package com.yinlihupo.enlish.service.constant;
public interface LessonPlanConstant { public class LessonPlanConstant {
String TITLE = "Title"; public static final String TITLE = "Title";
String PASSAGE = "The Passage"; public static final String PASSAGE = "ThePassage";
String QUIZ = "Quiz"; public static final String QUIZ = "Quiz";
String ANSWER_KEY_EXPLANATION = "Answer Key & Explanation"; public static final String ANSWER_KEY_EXPLANATION = "AnswerKey&Explanation";
String FULL_TRANSLATION = "Full Translation"; public static final String FULL_TRANSLATION = "FullTranslation";
// 正在生成学案标识
public static final String GENERATING_PLAN = "GeneratingPlan";
public static String buildGeneratePlanContent(Integer studentId) {
return GENERATING_PLAN + ":" + studentId;
}
} }

View File

@@ -4,7 +4,13 @@ public class UserRedisConstants {
public static final String USER_LOGIN_CODE = "user:login:code:"; public static final String USER_LOGIN_CODE = "user:login:code:";
public static final String USER_INVITATION_CODE = "user:invitation:code:";
public static String buildUserLoginCode(String phone) { public static String buildUserLoginCode(String phone) {
return USER_LOGIN_CODE + phone; return USER_LOGIN_CODE + phone;
} }
public static String buildUserInvitationCode(String code) {
return USER_INVITATION_CODE + code;
}
} }

View File

@@ -1,7 +1,10 @@
package com.yinlihupo.enlish.service.controller; package com.yinlihupo.enlish.service.controller;
import com.yinlihupo.enlish.service.constant.UserRedisConstants;
import com.yinlihupo.enlish.service.domain.dataobject.RoleDO; import com.yinlihupo.enlish.service.domain.dataobject.RoleDO;
import com.yinlihupo.enlish.service.domain.dataobject.UserDO; import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
import com.yinlihupo.enlish.service.model.vo.admin.CreateInvitationCodeReqVO;
import com.yinlihupo.enlish.service.model.vo.admin.CreateInvitationCodeRspVO;
import com.yinlihupo.enlish.service.model.vo.user.CreateUserReqVO; import com.yinlihupo.enlish.service.model.vo.user.CreateUserReqVO;
import com.yinlihupo.enlish.service.model.vo.user.FindUserListRepVO; import com.yinlihupo.enlish.service.model.vo.user.FindUserListRepVO;
import com.yinlihupo.enlish.service.model.vo.user.FindUserListRspVO; import com.yinlihupo.enlish.service.model.vo.user.FindUserListRspVO;
@@ -11,6 +14,7 @@ import com.yinlihupo.framework.biz.operationlog.aspect.ApiOperationLog;
import com.yinlihupo.framework.common.response.PageResponse; import com.yinlihupo.framework.common.response.PageResponse;
import com.yinlihupo.framework.common.response.Response; import com.yinlihupo.framework.common.response.Response;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -19,6 +23,8 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@RequestMapping("/admin/") @RequestMapping("/admin/")
@RestController @RestController
@@ -30,6 +36,8 @@ public class AdminController {
private PasswordEncoder passwordEncoder; private PasswordEncoder passwordEncoder;
@Resource @Resource
private RoleService roleService; private RoleService roleService;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@PostMapping("user/list") @PostMapping("user/list")
@ApiOperationLog(description = "查询用户列表") @ApiOperationLog(description = "查询用户列表")
@@ -66,4 +74,18 @@ public class AdminController {
return Response.success(); return Response.success();
} }
@PostMapping("user/create/invitation/code")
@ApiOperationLog(description = "创建邀请码")
public Response<CreateInvitationCodeRspVO> createInvitationCode(@RequestBody CreateInvitationCodeReqVO createInvitationCodeReqVO) {
String code = String.valueOf(new Random().nextInt(1000000));
Integer limit = createInvitationCodeReqVO.getLimit();
Integer expire = createInvitationCodeReqVO.getExpire();
redisTemplate.opsForValue().set(UserRedisConstants.buildUserInvitationCode(code), limit);
redisTemplate.expire(UserRedisConstants.buildUserInvitationCode(code), expire, TimeUnit.DAYS);
return Response.success(CreateInvitationCodeRspVO.builder().invitationCode(code).build());
}
} }

View File

@@ -62,7 +62,7 @@ public class ExamWordsController {
// bug: 获取单词后单词的id会乱序、 需要重新更新考试记录中的 id // bug: 获取单词后单词的id会乱序、 需要重新更新考试记录中的 id
examWordsDO.setWordIds(assessmentWords.stream().map(Word::getId).toList()); examWordsDO.setWordIds(assessmentWords.stream().map(Word::getId).toList());
examWordsService.updateExamWordsWordIdsOrder(examWordsDO); examWordsService.updateExamWordsWordIdsOrder(examWordsDO);
log.info("生成试卷成功 {}", examWordsDO);
List<StudentDetail> studentDetailList = studentService.getStudentDetailList(Collections.singletonList(studentId)); List<StudentDetail> studentDetailList = studentService.getStudentDetailList(Collections.singletonList(studentId));
List<Map<String, Object>> maps = studentDetailList.stream().map(studentDetail -> { List<Map<String, Object>> maps = studentDetailList.stream().map(studentDetail -> {
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
@@ -72,6 +72,12 @@ public class ExamWordsController {
data.put("examStr", examWordsDO.getTitle()); data.put("examStr", examWordsDO.getTitle());
data.put("words", assessmentWords); data.put("words", assessmentWords);
data.put("answer", assessmentWords); data.put("answer", assessmentWords);
List<Word> words1 = assessmentWords.subList(0, assessmentWords.size() / 2);
List<Word> words2 = assessmentWords.subList(assessmentWords.size() / 2, assessmentWords.size());
data.put("words1", words1);
data.put("words2", words2);
return data; return data;
}).toList(); }).toList();
@@ -107,12 +113,17 @@ public class ExamWordsController {
PageResponse<ExamWordsResultRspVO> getExamWordsResult(@RequestBody ExamWordsResultReqVO examWordsResultReqVO) { PageResponse<ExamWordsResultRspVO> getExamWordsResult(@RequestBody ExamWordsResultReqVO examWordsResultReqVO) {
Integer page = examWordsResultReqVO.getPage(); Integer page = examWordsResultReqVO.getPage();
Integer size = examWordsResultReqVO.getSize(); Integer size = examWordsResultReqVO.getSize();
Integer classId = examWordsResultReqVO.getClassId();
Integer gradeId = examWordsResultReqVO.getGradeId();
String studentName = examWordsResultReqVO.getStudentName();
Integer total = examWordsJudgeService.getExamWordsJudgeResultCount(); Integer total = examWordsJudgeService.getExamWordsJudgeResultCount();
List<ExamWordsJudgeResultDO> examWordsJudgeResult = examWordsJudgeService.getExamWordsJudgeResult(page, size); List<ExamWordsJudgeResultDO> examWordsJudgeResult = examWordsJudgeService.getExamWordsJudgeResult(page, size, classId, gradeId, studentName);
List<ExamWordsResultRspVO> list = examWordsJudgeResult.stream().map(examWordsJudgeResultDO -> ExamWordsResultRspVO List<ExamWordsResultRspVO> list = examWordsJudgeResult.stream().map(examWordsJudgeResultDO -> ExamWordsResultRspVO
.builder() .builder()
.id(examWordsJudgeResultDO.getId()) .id(examWordsJudgeResultDO.getId())
.studentId(examWordsJudgeResultDO.getStudentId()) .studentId(examWordsJudgeResultDO.getStudentId())
.studentName(examWordsJudgeResultDO.getStudentId() != null ? studentService.getStudentById(examWordsJudgeResultDO.getStudentId()).getName() : "")
.examWordsTitle(examWordsJudgeResultDO.getExamWordsId() != null ? examWordsService.getExamWordsDOById(examWordsJudgeResultDO.getExamWordsId()).getTitle() : "")
.examWordsId(examWordsJudgeResultDO.getExamWordsId()) .examWordsId(examWordsJudgeResultDO.getExamWordsId())
.startDate(examWordsJudgeResultDO.getStartDate()) .startDate(examWordsJudgeResultDO.getStartDate())
.correctWordCount(examWordsJudgeResultDO.getCorrectWordCount()) .correctWordCount(examWordsJudgeResultDO.getCorrectWordCount())

View File

@@ -1,5 +1,6 @@
package com.yinlihupo.enlish.service.controller; package com.yinlihupo.enlish.service.controller;
import com.yinlihupo.enlish.service.constant.LessonPlanConstant;
import com.yinlihupo.enlish.service.domain.dataobject.LessonPlansDO; import com.yinlihupo.enlish.service.domain.dataobject.LessonPlansDO;
import com.yinlihupo.enlish.service.domain.dataobject.VocabularyBankDO; import com.yinlihupo.enlish.service.domain.dataobject.VocabularyBankDO;
import com.yinlihupo.enlish.service.model.vo.plan.*; import com.yinlihupo.enlish.service.model.vo.plan.*;
@@ -7,12 +8,14 @@ import com.yinlihupo.enlish.service.service.LessonPlansService;
import com.yinlihupo.enlish.service.utils.TTSUtil; import com.yinlihupo.enlish.service.utils.TTSUtil;
import com.yinlihupo.enlish.service.utils.WordExportUtil; import com.yinlihupo.enlish.service.utils.WordExportUtil;
import com.yinlihupo.framework.biz.operationlog.aspect.ApiOperationLog; import com.yinlihupo.framework.biz.operationlog.aspect.ApiOperationLog;
import com.yinlihupo.framework.common.response.PageResponse;
import com.yinlihupo.framework.common.response.Response; import com.yinlihupo.framework.common.response.Response;
import com.yinlihupo.framework.common.util.JsonUtils; import com.yinlihupo.framework.common.util.JsonUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -30,6 +33,9 @@ public class LessonPlanController {
@Resource @Resource
private LessonPlansService lessonPlanService; private LessonPlansService lessonPlanService;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource(name = "taskExecutor") @Resource(name = "taskExecutor")
private Executor taskExecutor; private Executor taskExecutor;
@Resource @Resource
@@ -47,6 +53,9 @@ public class LessonPlanController {
Integer unitId = addLessonPlanReqVO.getUnitId(); Integer unitId = addLessonPlanReqVO.getUnitId();
Integer wordSize = addLessonPlanReqVO.getWordSize(); Integer wordSize = addLessonPlanReqVO.getWordSize();
try { try {
if (redisTemplate.opsForValue().get(LessonPlanConstant.buildGeneratePlanContent(studentId)) != null) {
throw new RuntimeException("学案正在生成,请耐心等待");
}
taskExecutor.execute(() -> lessonPlanService.generateLessonPlans(studentId, unitId, wordSize)); taskExecutor.execute(() -> lessonPlanService.generateLessonPlans(studentId, unitId, wordSize));
return Response.success("生成学案成功,请等待 10 分钟"); return Response.success("生成学案成功,请等待 10 分钟");
} catch (Exception e) { } catch (Exception e) {
@@ -96,4 +105,31 @@ public class LessonPlanController {
public void findPlanWordVoiceTTS(@RequestBody FindWordTTSVoiceReqVO findWordVoiceReqVO, HttpServletResponse response) { public void findPlanWordVoiceTTS(@RequestBody FindWordTTSVoiceReqVO findWordVoiceReqVO, HttpServletResponse response) {
ttsUtil.generateWordVoice(findWordVoiceReqVO.getText(), response); ttsUtil.generateWordVoice(findWordVoiceReqVO.getText(), response);
} }
@PostMapping("check")
@ApiOperationLog(description = "检测学案是否在生成")
public Response<String> checkLessonPlan(@RequestBody FindIsGeneratePlanReqVO findIsGeneratePlanReqVO) {
Integer studentId = findIsGeneratePlanReqVO.getStudentId();
String key = LessonPlanConstant.buildGeneratePlanContent(studentId);
if (redisTemplate.opsForValue().get(key) != null) {
return Response.fail();
}
return Response.success("学案生成完成");
}
@PostMapping("student/list")
@ApiOperationLog(description = "查询学生学案")
public Response<FindPlanStudentListRspVO> findStudentPlans(@RequestBody FindPlanStudentReqVO findPlanStudentReqVO) {
List<LessonPlansDO> lessonPlansDOS = lessonPlanService.findLessonPlansByStudentId(findPlanStudentReqVO.getStudentId());
List<LessonPlanItem> list = lessonPlansDOS.stream().map(lessonPlansDO -> LessonPlanItem
.builder()
.id(lessonPlansDO.getId())
.isFinished(0)
.title(lessonPlansDO.getTitle())
.build())
.toList();
return Response.success(FindPlanStudentListRspVO.builder().lessonPlanItems(list).build());
}
} }

View File

@@ -25,7 +25,7 @@ public class LoginController {
@ApiOperationLog(description = "登录") @ApiOperationLog(description = "登录")
public Response<String> login(@RequestBody LoginReqVO loginReqVO) { public Response<String> login(@RequestBody LoginReqVO loginReqVO) {
try { try {
loginService.login(loginReqVO.getPhone(), loginReqVO.getName(), loginReqVO.getPassword(), loginReqVO.getCode()); loginService.login(loginReqVO.getPhone(), loginReqVO.getName(), loginReqVO.getPassword(), loginReqVO.getCode(), loginReqVO.getInvitationCode());
return Response.success(StpUtil.getTokenInfo().getTokenValue()); return Response.success(StpUtil.getTokenInfo().getTokenValue());
} catch (Exception e) { } catch (Exception e) {
log.error("注册或登录失败 {}", e.getMessage()); log.error("注册或登录失败 {}", e.getMessage());

View File

@@ -48,6 +48,8 @@ public class StudentController {
.id(studentDO.getId()) .id(studentDO.getId())
.name(studentDO.getName()) .name(studentDO.getName())
.classId(studentDO.getClassId()) .classId(studentDO.getClassId())
.className(classService.findClassById(studentDO.getClassId()).getTitle())
.gradeName(gradeService.findByClassId(studentDO.getGradeId()).getTitle())
.gradeId(studentDO.getGradeId()) .gradeId(studentDO.getGradeId())
.build()).toList(); .build()).toList();

View File

@@ -1,16 +1,22 @@
package com.yinlihupo.enlish.service.controller; package com.yinlihupo.enlish.service.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.yinlihupo.enlish.service.domain.dataobject.UserDO; import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
import com.yinlihupo.enlish.service.model.vo.user.FindUserInfoRspVO; import com.yinlihupo.enlish.service.model.vo.user.FindUserInfoRspVO;
import com.yinlihupo.enlish.service.model.vo.user.UpdateUserInfoReqVO;
import com.yinlihupo.enlish.service.service.UserService; import com.yinlihupo.enlish.service.service.UserService;
import com.yinlihupo.framework.biz.operationlog.aspect.ApiOperationLog;
import com.yinlihupo.framework.common.response.Response; import com.yinlihupo.framework.common.response.Response;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequestMapping("/user/") @RequestMapping("/user/")
@Slf4j
public class UserController { public class UserController {
@Resource @Resource
@@ -27,4 +33,23 @@ public class UserController {
return Response.success(findUserInfoRspVO); return Response.success(findUserInfoRspVO);
} }
@PostMapping("update-user-info")
@ApiOperationLog(description = "修改用户信息")
public Response<String> updatePassword(@RequestBody UpdateUserInfoReqVO updateUserInfoReqVO) {
try {
String code = updateUserInfoReqVO.getCode();
String newPassword = updateUserInfoReqVO.getNewPassword();
String phone = updateUserInfoReqVO.getPhone();
String name = updateUserInfoReqVO.getName();
userService.updateUserInfo(newPassword, code, phone, name);
StpUtil.logout();
return Response.success();
} catch (Exception e) {
log.error("修改密码失败 {}", e.getMessage());
return Response.fail(e.getMessage());
}
}
} }

View File

@@ -0,0 +1,21 @@
package com.yinlihupo.enlish.service.domain.dataobject;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class PlanExamDO {
private Integer id;
private Integer planId;
private Integer examId;
}

View File

@@ -19,9 +19,13 @@ public interface ExamWordsJudgeResultDOMapper {
Integer selectCount(); Integer selectCount();
Integer selectUnfinishedCount();
ExamWordsJudgeResultDO selectDetailById(@Param("id") Integer id); ExamWordsJudgeResultDO selectDetailById(@Param("id") Integer id);
List<ExamWordsJudgeResultDO> selectByStudentId(@Param("studentId") Integer studentId); List<ExamWordsJudgeResultDO> selectByStudentId(@Param("studentId") Integer studentId);
List<ExamWordsJudgeResultDO> selectByStudentIdAndLimitTime(@Param("studentId") Integer studentId); List<ExamWordsJudgeResultDO> selectByStudentIdAndLimitTime(@Param("studentId") Integer studentId);
List<ExamWordsJudgeResultDO> selectByPageAndStudentIds(@Param("startIndex") Integer startIndex, @Param("pageSize") Integer pageSize, @Param("studentIds") List<Integer> studentIds);
} }

View File

@@ -14,4 +14,6 @@ public interface LessonPlansDOMapper {
List<LessonPlansDO> findLessonPlansByStudentId(@Param("ids") List<Integer> ids); List<LessonPlansDO> findLessonPlansByStudentId(@Param("ids") List<Integer> ids);
LessonPlansDO selectByLessonId(@Param("lessonId") Integer lessonId); LessonPlansDO selectByLessonId(@Param("lessonId") Integer lessonId);
List<LessonPlansDO> selectByStudentId(@Param("studentId") Integer studentId);
} }

View File

@@ -0,0 +1,9 @@
package com.yinlihupo.enlish.service.domain.mapper;
import com.yinlihupo.enlish.service.domain.dataobject.PlanExamDO;
public interface PlanExamDOMapper {
void insert(PlanExamDO record);
PlanExamDO selectByExamId(Integer examId);
}

View File

@@ -23,4 +23,10 @@ public interface StudentDOMapper {
int selectStudentCountByClassId(@Param("classId") Integer classId); int selectStudentCountByClassId(@Param("classId") Integer classId);
int updateStudentActualGradeId(@Param("studentId") Integer studentId, @Param("gradeId") Integer gradeId); int updateStudentActualGradeId(@Param("studentId") Integer studentId, @Param("gradeId") Integer gradeId);
List<StudentDO> selectStudentDOListByClassId(@Param("classId") Integer classId);
List<StudentDO> selectStudentDOListByGradeId(@Param("gradeId") Integer gradeId);
List<StudentDO> selectStudentDOListByName(@Param("name") String name);
} }

View File

@@ -11,6 +11,10 @@ public interface UserDOMapper {
void insert(UserDO userDO); void insert(UserDO userDO);
void updatePassword(@Param("id") Long id, @Param("password") String password);
void updateUserInfo(@Param("id") Long id, @Param("name") String name, @Param("password") String password, @Param("phone") String phone);
UserDO selectById(Long id); UserDO selectById(Long id);
List<UserDO> selectUserDOList(@Param("name") String name, @Param("startIndex") Integer startIndex, @Param("pageSize") Integer pageSize); List<UserDO> selectUserDOList(@Param("name") String name, @Param("startIndex") Integer startIndex, @Param("pageSize") Integer pageSize);

View File

@@ -26,4 +26,6 @@ public interface VocabularyBankDOMapper {
Integer selectWordTotal(); Integer selectWordTotal();
List<VocabularyBankDO> selectByUnitIds(@Param("unitIds") List<Integer> unitIds); List<VocabularyBankDO> selectByUnitIds(@Param("unitIds") List<Integer> unitIds);
List<VocabularyBankDO> selectByGradeIdAndNotMatchIds(@Param("gradeId") Integer gradeId, @Param("ids") List<Integer> ids, @Param("wordCount") Integer wordCount);
} }

View File

@@ -0,0 +1,27 @@
package com.yinlihupo.enlish.service.model.bo;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class Sentence {
@JsonProperty("target_word")
private String targetWord;
@JsonProperty("grade_level")
private String gradeLevel;
@JsonProperty("question")
private String question;
@JsonProperty("chinese_clue")
private String chineseClue;
@JsonProperty("correct_answer")
private String correctAnswer;
@JsonProperty("grammar_point")
private String grammarPoint;
}

View File

@@ -0,0 +1,18 @@
package com.yinlihupo.enlish.service.model.vo.admin;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class CreateInvitationCodeReqVO {
// 限制人数
private Integer limit;
// 有效期
private Integer expire;
}

View File

@@ -0,0 +1,16 @@
package com.yinlihupo.enlish.service.model.vo.admin;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class CreateInvitationCodeRspVO {
private String invitationCode;
}

View File

@@ -13,4 +13,8 @@ public class ExamWordsResultReqVO {
private Integer page; private Integer page;
private Integer size; private Integer size;
private Integer classId;
private Integer gradeId;
private String studentName;
} }

View File

@@ -12,6 +12,7 @@ import java.time.LocalDateTime;
@Data @Data
@Builder @Builder
public class ExamWordsResultRspVO { public class ExamWordsResultRspVO {
private Integer id; private Integer id;
private String ansSheetPath; private String ansSheetPath;
@@ -20,6 +21,10 @@ public class ExamWordsResultRspVO {
private Integer examWordsId; private Integer examWordsId;
private String studentName;
private String examWordsTitle;
private Integer correctWordCount; private Integer correctWordCount;
private Integer wrongWordCount; private Integer wrongWordCount;

View File

@@ -15,4 +15,5 @@ public class LoginReqVO {
private String name; private String name;
private String password; private String password;
private String code; private String code;
private String invitationCode;
} }

View File

@@ -0,0 +1,15 @@
package com.yinlihupo.enlish.service.model.vo.plan;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class FindIsGeneratePlanReqVO {
private Integer studentId;
}

View File

@@ -0,0 +1,17 @@
package com.yinlihupo.enlish.service.model.vo.plan;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class FindPlanStudentListRspVO {
List<LessonPlanItem> lessonPlanItems;
}

View File

@@ -0,0 +1,14 @@
package com.yinlihupo.enlish.service.model.vo.plan;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class FindPlanStudentReqVO {
private Integer studentId;
}

View File

@@ -1,5 +1,6 @@
package com.yinlihupo.enlish.service.model.vo.student; package com.yinlihupo.enlish.service.model.vo.student;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
@@ -16,5 +17,6 @@ public class AddStudentReqVO {
private String name; private String name;
private Integer classId; private Integer classId;
private Integer gradeId; private Integer gradeId;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime; private LocalDateTime createTime;
} }

View File

@@ -15,4 +15,6 @@ public class StudentItemRspVO {
private String name; private String name;
private Integer classId; private Integer classId;
private Integer gradeId; private Integer gradeId;
private String className;
private String gradeName;
} }

View File

@@ -0,0 +1,18 @@
package com.yinlihupo.enlish.service.model.vo.user;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Data
public class UpdateUserInfoReqVO {
private String newPassword;
private String name;
private String phone;
private String code;
}

View File

@@ -9,10 +9,12 @@ public interface ExamWordsJudgeService {
void judgeExamWords(int count); void judgeExamWords(int count);
List<ExamWordsJudgeResultDO> getExamWordsJudgeResult(Integer page, Integer pageSize); List<ExamWordsJudgeResultDO> getExamWordsJudgeResult(Integer page, Integer pageSize, Integer classId, Integer gradeId, String studentName);
Integer getExamWordsJudgeResultCount(); Integer getExamWordsJudgeResultCount();
Integer getExamUnfinishedCount();
ExamWordsJudgeResultDO getExamWordsJudgeResultDOById(Integer id); ExamWordsJudgeResultDO getExamWordsJudgeResultDOById(Integer id);
List<ExamWordsJudgeResultDO> getStudentExamWordsResultList(Integer studentId); List<ExamWordsJudgeResultDO> getStudentExamWordsResultList(Integer studentId);

View File

@@ -13,4 +13,6 @@ public interface ExamWordsService {
int saveExamWordsPngToDbAndLocal(MultipartFile file); int saveExamWordsPngToDbAndLocal(MultipartFile file);
void updateExamWordsWordIdsOrder(ExamWordsDO examWordsDO); void updateExamWordsWordIdsOrder(ExamWordsDO examWordsDO);
ExamWordsDO getExamWordsDOById(Integer id);
} }

View File

@@ -10,4 +10,6 @@ public interface LessonPlansService {
List<LessonPlansDO> findLessonPlans(List<Integer> ids); List<LessonPlansDO> findLessonPlans(List<Integer> ids);
LessonPlansDO findLessonPlanById(Integer id); LessonPlansDO findLessonPlanById(Integer id);
List<LessonPlansDO> findLessonPlansByStudentId(Integer studentId);
} }

View File

@@ -2,7 +2,7 @@ package com.yinlihupo.enlish.service.service;
public interface LoginService { public interface LoginService {
void login(String phone, String name, String reqPassword, String reqCode); void login(String phone, String name, String reqPassword, String reqCode, String invitationCode);
void sendVerificationCode(String phone); void sendVerificationCode(String phone);
} }

View File

@@ -13,4 +13,6 @@ public interface UserService {
Integer findUserTotal(); Integer findUserTotal();
void createUser(UserDO userDO); void createUser(UserDO userDO);
void updateUserInfo(String password, String reqCode, String phone, String name);
} }

View File

@@ -10,16 +10,17 @@ import org.checkerframework.checker.nullness.qual.NonNull;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.*;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
@Service @Service
@Slf4j @Slf4j
@@ -47,7 +48,6 @@ public class ExamWordsServiceImpl implements ExamWordsService {
@Transactional(rollbackFor = RuntimeException.class) @Transactional(rollbackFor = RuntimeException.class)
public ExamWordsDO generateExamWords(Integer studentId, Integer type) { public ExamWordsDO generateExamWords(Integer studentId, Integer type) {
ExamWordsDO examWordsDO; ExamWordsDO examWordsDO;
if (type == ExamWordsConstant.EXAM_TYPE_BASELINE) { if (type == ExamWordsConstant.EXAM_TYPE_BASELINE) {
@@ -61,6 +61,16 @@ public class ExamWordsServiceImpl implements ExamWordsService {
examWordsDO = generateFinalExamWords(studentId); examWordsDO = generateFinalExamWords(studentId);
} }
List<Integer> wordIds = new ArrayList<>(examWordsDO.getWordIds());
if (wordIds.size() < wordCount) {
log.info("单词数量不足,补充单词");
StudentDO studentDO = studentDOMapper.selectStudentById(studentId);
List<VocabularyBankDO> vocabularyBankDOS = vocabularyBankDOMapper.selectByGradeIdAndNotMatchIds(studentDO.getGradeId(), wordIds, wordCount - wordIds.size());
List<Integer> list = new ArrayList<>(vocabularyBankDOS.stream().map(VocabularyBankDO::getId).toList());
wordIds.addAll(list);
examWordsDO.setWordIds(wordIds);
}
return examWordsDO; return examWordsDO;
} }
@@ -109,7 +119,7 @@ public class ExamWordsServiceImpl implements ExamWordsService {
StudentDO studentDO = studentDOMapper.selectStudentById(studentId); StudentDO studentDO = studentDOMapper.selectStudentById(studentId);
Integer gradeId = studentDO.getGradeId(); Integer gradeId = studentDO.getGradeId();
List<UnitDO> unitDOS = unitDOMapper.selectByUnitName(ExamWordsConstant.getGradeName(gradeId) + ""); List<UnitDO> unitDOS = unitDOMapper.selectByUnitName(ExamWordsConstant.getGradeName(gradeId) + "");
ExamWordsDO examWordsDO = getExamWordsDO(studentId, studentDO, gradeId, unitDOS); ExamWordsDO examWordsDO = getExamWordsDO(studentId, studentDO, gradeId, unitDOS, ExamWordsConstant.EXAM_TYPE_MIDTERM);
examWordsDO.setTitle("期中测试" + studentDO.getName()); examWordsDO.setTitle("期中测试" + studentDO.getName());
return getExamWordsDO(studentId, examWordsDO); return getExamWordsDO(studentId, examWordsDO);
@@ -119,13 +129,13 @@ public class ExamWordsServiceImpl implements ExamWordsService {
StudentDO studentDO = studentDOMapper.selectStudentById(studentId); StudentDO studentDO = studentDOMapper.selectStudentById(studentId);
Integer gradeId = studentDO.getGradeId(); Integer gradeId = studentDO.getGradeId();
List<UnitDO> unitDOS = unitDOMapper.selectByUnitName(ExamWordsConstant.getGradeName(gradeId)); List<UnitDO> unitDOS = unitDOMapper.selectByUnitName(ExamWordsConstant.getGradeName(gradeId));
ExamWordsDO examWordsDO = getExamWordsDO(studentId, studentDO, gradeId, unitDOS); ExamWordsDO examWordsDO = getExamWordsDO(studentId, studentDO, gradeId, unitDOS, ExamWordsConstant.EXAM_TYPE_FINAL);
examWordsDO.setTitle("期末测试" + studentDO.getName()); examWordsDO.setTitle("期末测试" + studentDO.getName());
return getExamWordsDO(studentId, examWordsDO); return getExamWordsDO(studentId, examWordsDO);
} }
@NonNull @NonNull
private ExamWordsDO getExamWordsDO(Integer studentId, StudentDO studentDO, Integer gradeId, List<UnitDO> unitDOS) { private ExamWordsDO getExamWordsDO(Integer studentId, StudentDO studentDO, Integer gradeId, List<UnitDO> unitDOS, Integer type) {
if (unitDOS.isEmpty()) { if (unitDOS.isEmpty()) {
throw new RuntimeException("没有找到对应的单元"); throw new RuntimeException("没有找到对应的单元");
} }
@@ -134,7 +144,7 @@ public class ExamWordsServiceImpl implements ExamWordsService {
ExamWordsDO examWordsDO = ExamWordsDO.builder() ExamWordsDO examWordsDO = ExamWordsDO.builder()
.gradeId(gradeId) .gradeId(gradeId)
.level(1) .level(1)
.type(ExamWordsConstant.EXAM_TYPE_BASELINE) .type(type)
.title(studentDO.getName()) .title(studentDO.getName())
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.wordIds(vocabularyBankDOS.stream().map(VocabularyBankDO::getId).toList()) .wordIds(vocabularyBankDOS.stream().map(VocabularyBankDO::getId).toList())
@@ -161,29 +171,55 @@ public class ExamWordsServiceImpl implements ExamWordsService {
@Override @Override
@Transactional(rollbackFor = RuntimeException.class) @Transactional(rollbackFor = RuntimeException.class)
public int saveExamWordsPngToDbAndLocal(MultipartFile file) { public int saveExamWordsPngToDbAndLocal(MultipartFile file) {
// 1. 基础校验:判空
File dir = new File(tmpPng); if (file == null || file.isEmpty()) {
if (!dir.exists()) { throw new RuntimeException("上传文件不能为空");
dir.mkdirs();
} }
try { // 2. 安全校验:检查后缀名白名单
String originalFilename = file.getOriginalFilename(); String originalFilename = file.getOriginalFilename();
String suffix = ""; String extension = StringUtils.getFilenameExtension(originalFilename); // Spring工具类
if (originalFilename != null && originalFilename.contains(".")) { List<String> allowedExtensions = Arrays.asList("png", "jpg", "jpeg");
suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
if (extension == null || !allowedExtensions.contains(extension.toLowerCase())) {
throw new RuntimeException("不支持的文件格式,仅支持: " + allowedExtensions);
} }
String newFileName = UUID.randomUUID() + suffix;
String path = tmpPng + newFileName;
File dest = new File(path); // 3. 准备目录 (使用 NIO)
file.transferTo(dest); // 假设 tmpPng 是配置好的基础路径字符串
Path directoryPath = Paths.get(tmpPng);
try {
if (!Files.exists(directoryPath)) {
Files.createDirectories(directoryPath);
}
// 4. 生成文件名 (防止文件名冲突)
String newFileName = UUID.randomUUID().toString() + "." + extension;
// 5. 组合最终路径 (自动处理分隔符)
Path targetPath = directoryPath.resolve(newFileName);
// 6. 保存文件
file.transferTo(targetPath.toAbsolutePath().toFile());
String string = targetPath.toAbsolutePath().toFile().toString();
log.info("文件上传成功路径为 {}", string);
if (!targetPath.toFile().exists()) {
log.error("文件上传失败: {}", newFileName);
throw new RuntimeException("文件上传失败");
}
// 7. 入库
// 建议:存相对路径或文件名,不要存 targetPath.toString() 的绝对路径
// 这里为了演示,假设 insert 依然接受字符串,建议存 newFileName
int insert = examWordsJudgeResultDOMapper.insert(targetPath.toString());
log.info("上传文件成功: {}", newFileName);
return insert;
return examWordsJudgeResultDOMapper.insert(path);
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException("上传失败", e); log.error("文件上传失败: {}", originalFilename, e);
throw new RuntimeException("上传失败,请稍后重试", e);
} }
} }
@Override @Override
@@ -191,5 +227,10 @@ public class ExamWordsServiceImpl implements ExamWordsService {
examWordsDOMapper.updateWordIdsOrder(examWordsDO); examWordsDOMapper.updateWordIdsOrder(examWordsDO);
} }
@Override
public ExamWordsDO getExamWordsDOById(Integer id) {
return examWordsDOMapper.selectById(id);
}
} }

View File

@@ -9,6 +9,7 @@ import com.yinlihupo.enlish.service.model.bo.exam.ActionType;
import com.yinlihupo.enlish.service.model.bo.exam.DiagnosisResult; import com.yinlihupo.enlish.service.model.bo.exam.DiagnosisResult;
import com.yinlihupo.enlish.service.model.bo.exam.ZoneStats; import com.yinlihupo.enlish.service.model.bo.exam.ZoneStats;
import com.yinlihupo.enlish.service.service.ExamWordsJudgeService; import com.yinlihupo.enlish.service.service.ExamWordsJudgeService;
import com.yinlihupo.enlish.service.service.StudentLessonPlansService;
import com.yinlihupo.enlish.service.utils.PngUtil; import com.yinlihupo.enlish.service.utils.PngUtil;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -40,23 +41,29 @@ public class ExamWordsJudgeServiceImpl implements ExamWordsJudgeService {
private GradeUnitDOMapper gradeUnitDOMapper; private GradeUnitDOMapper gradeUnitDOMapper;
@Resource @Resource
private StudentDOMapper studentDOMapper; private StudentDOMapper studentDOMapper;
@Resource
private PlanExamDOMapper planExamDOMapper;
@Resource
private StudentLessonPlansService studentLessonPlansService;
@Value("${templates.data}") @Value("${templates.data}")
private String tessdataPath; private String tessdataPath;
@Override @Override
@Transactional(rollbackFor = Exception.class)
public void judgeExamWords(int count) { public void judgeExamWords(int count) {
List<ExamWordsJudgeResultDO> examWordsJudgeResultDOS = examWordsJudgeResultDOMapper.selectUnfinishedExamWordsJudgeResultDOList(count); List<ExamWordsJudgeResultDO> examWordsJudgeResultDOS = examWordsJudgeResultDOMapper.selectUnfinishedExamWordsJudgeResultDOList(count);
for (ExamWordsJudgeResultDO examWordsJudgeResultDO : examWordsJudgeResultDOS) { for (ExamWordsJudgeResultDO examWordsJudgeResultDO : examWordsJudgeResultDOS) {
String ansSheetPath = null;
try { try {
String ansSheetPath = examWordsJudgeResultDO.getAnsSheetPath(); ansSheetPath = examWordsJudgeResultDO.getAnsSheetPath();
List<CoordinatesXY> coordinatesXIES = PngUtil.analysisXY(ansSheetPath); List<CoordinatesXY> coordinatesXIES = PngUtil.analysisXY(ansSheetPath);
// 从图片中获取学生 id 和考试 id // 从图片中获取学生 id 和考试 id
StudentExamId studentExamId = PngUtil.analyzeExamWordsIdAndStudentId(ansSheetPath, tessdataPath, coordinatesXIES); StudentExamId studentExamId = PngUtil.analyzeExamWordsIdAndStudentId(ansSheetPath, tessdataPath, coordinatesXIES);
Integer examWordsJudgeResultDOId = examWordsJudgeResultDO.getId(); Integer examWordsJudgeResultDOId = examWordsJudgeResultDO.getId();
if (studentExamId == null) { if (studentExamId == null) {
examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDOId, "识别学生 id 和考试 id"); log.info("找到学生 id 和考试 id");
examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDOId, "未识别学生和考试");
continue; continue;
} }
@@ -74,7 +81,7 @@ public class ExamWordsJudgeServiceImpl implements ExamWordsJudgeService {
} }
ExamWordsDO examWordsDO = examWordsDOMapper.selectById(examWordsId); ExamWordsDO examWordsDO = examWordsDOMapper.selectById(examWordsId);
if(examWordsDO == null) { if (examWordsDO == null) {
examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDOId, "未找到考试"); examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDOId, "未找到考试");
continue; continue;
} }
@@ -93,9 +100,13 @@ public class ExamWordsJudgeServiceImpl implements ExamWordsJudgeService {
.wrongWordCount(unmemorizedWordIds.size()) .wrongWordCount(unmemorizedWordIds.size())
.isFinished(1) .isFinished(1)
.build(); .build();
if (examWordsDO.getType().equals(ExamWordsConstant.EXAM_TYPE_BASELINE)) {
// 判断考试等级 // 判断考试等级
judgeExamActualGrade(wordsJudgeResultDO, examWordsDO); judgeExamActualGrade(wordsJudgeResultDO, examWordsDO);
} else {
wordsJudgeResultDO.setMsg("此次考试" + examWordsDO.getTitle() + "答对单词数为" + memorizedWordIds.size());
}
int updated = examWordsJudgeResultDOMapper.updateExamWordsJudgeResultDO(wordsJudgeResultDO); int updated = examWordsJudgeResultDOMapper.updateExamWordsJudgeResultDO(wordsJudgeResultDO);
if (updated != 1) { if (updated != 1) {
examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDOId, "更新考试记录失败"); examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDOId, "更新考试记录失败");
@@ -103,6 +114,12 @@ public class ExamWordsJudgeServiceImpl implements ExamWordsJudgeService {
} }
log.info("更新考试记录成功"); log.info("更新考试记录成功");
PlanExamDO planExamDO = planExamDOMapper.selectByExamId(examWordsId);
if (planExamDO != null) {
studentLessonPlansService.finishStudentLessonPlan(studentId, planExamDO.getPlanId());
log.info("完成学案成功, planId: {}", planExamDO.getPlanId());
}
List<WordMasteryLogDO> wordMasteryLogDOS = new ArrayList<>(unmemorizedWordIds.stream().map(wordId -> WordMasteryLogDO.builder() List<WordMasteryLogDO> wordMasteryLogDOS = new ArrayList<>(unmemorizedWordIds.stream().map(wordId -> WordMasteryLogDO.builder()
.wordId(wordId) .wordId(wordId)
.studentId(studentId) .studentId(studentId)
@@ -131,9 +148,14 @@ public class ExamWordsJudgeServiceImpl implements ExamWordsJudgeService {
boolean delete = new File(ansSheetPath).delete(); boolean delete = new File(ansSheetPath).delete();
if (delete) { if (delete) {
log.info("删除文件成功:{}", ansSheetPath); log.info("删除文件成功:{}", ansSheetPath);
} else {
log.error("删除文件失败:{}", ansSheetPath);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("识别考试失败 {}", e.getMessage()); log.error("识别考试失败 {}", e.getMessage());
if (ansSheetPath != null) {
new File(ansSheetPath).delete();
}
examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDO.getId(), e.getMessage()); examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDO.getId(), e.getMessage());
} }
} }
@@ -310,8 +332,24 @@ public class ExamWordsJudgeServiceImpl implements ExamWordsJudgeService {
} }
@Override @Override
public List<ExamWordsJudgeResultDO> getExamWordsJudgeResult(Integer page, Integer pageSize) { public List<ExamWordsJudgeResultDO> getExamWordsJudgeResult(Integer page, Integer pageSize, Integer classId, Integer gradeId, String studentName) {
List<Integer> studentIds = new ArrayList<>();
if (classId != null) {
studentIds.addAll(studentDOMapper.selectStudentDOListByClassId(classId).stream().map(StudentDO::getId).toList());
}
if (gradeId != null) {
studentIds.addAll(studentDOMapper.selectStudentDOListByGradeId(gradeId).stream().map(StudentDO::getId).toList());
}
if (!studentName.isEmpty()) {
studentIds.addAll(studentDOMapper.selectStudentDOListByName(studentName).stream().map(StudentDO::getId).toList());
}
if (studentIds.isEmpty()) {
return examWordsJudgeResultDOMapper.selectByPage((page - 1) * pageSize, pageSize); return examWordsJudgeResultDOMapper.selectByPage((page - 1) * pageSize, pageSize);
} else {
return examWordsJudgeResultDOMapper.selectByPageAndStudentIds((page - 1) * pageSize, page * pageSize, studentIds);
}
} }
@Override @Override
@@ -319,6 +357,11 @@ public class ExamWordsJudgeServiceImpl implements ExamWordsJudgeService {
return examWordsJudgeResultDOMapper.selectCount(); return examWordsJudgeResultDOMapper.selectCount();
} }
@Override
public Integer getExamUnfinishedCount() {
return examWordsJudgeResultDOMapper.selectUnfinishedCount();
}
@Override @Override
public ExamWordsJudgeResultDO getExamWordsJudgeResultDOById(Integer id) { public ExamWordsJudgeResultDO getExamWordsJudgeResultDOById(Integer id) {
return examWordsJudgeResultDOMapper.selectDetailById(id); return examWordsJudgeResultDOMapper.selectDetailById(id);

View File

@@ -13,11 +13,15 @@ import com.yinlihupo.enlish.service.service.LoginService;
import com.yinlihupo.framework.common.util.JsonUtils; import com.yinlihupo.framework.common.util.JsonUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration; import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@Service @Service
@Slf4j @Slf4j
@@ -28,20 +32,32 @@ public class LoginServiceImpl implements LoginService {
@Resource @Resource
private PasswordEncoder passwordEncoder; private PasswordEncoder passwordEncoder;
@Resource @Resource
private StringRedisTemplate stringRedisTemplate; private RedisTemplate<String, Object> redisTemplate;
@Resource @Resource
private Client client; private Client client;
@Override @Override
public void login(String phone, String name, String reqPassword, String reqCode) { @Transactional(rollbackFor = Exception.class)
public void login(String phone, String name, String reqPassword, String reqCode, String invitationCode) {
UserDO userDO = userDOMapper.selectByPhone(phone); UserDO userDO = userDOMapper.selectByPhone(phone);
log.info("userDO:{}", userDO); log.info("userDO:{}", userDO);
String code = stringRedisTemplate.opsForValue().get(UserRedisConstants.buildUserLoginCode(phone)); String code = JsonUtils.toJsonString(redisTemplate.opsForValue().get(UserRedisConstants.buildUserLoginCode(phone)));
if (userDO == null) { if (userDO == null) {
if (code == null || !code.equals(reqCode)) { if (code == null || !code.equals(reqCode)) {
throw new RuntimeException("验证码错误"); throw new RuntimeException("验证码错误");
} }
Object invitationObj = redisTemplate.opsForValue().get(UserRedisConstants.buildUserInvitationCode(invitationCode));
if (invitationObj == null) {
throw new RuntimeException("邀请码错误");
}
int invitationLimit = Integer.parseInt(JsonUtils.toJsonString(invitationObj));
if (invitationLimit <= 0) {
throw new RuntimeException("邀请码已使用完毕");
}
redisTemplate.opsForValue().set(UserRedisConstants.buildUserInvitationCode(invitationCode), invitationLimit - 1);
userDO = UserDO.builder() userDO = UserDO.builder()
.phone(phone) .phone(phone)
.name(name) .name(name)
@@ -68,7 +84,9 @@ public class LoginServiceImpl implements LoginService {
@Override @Override
public void sendVerificationCode(String phone) { public void sendVerificationCode(String phone) {
String code = RandomUtil.randomNumbers(6); String code = RandomUtil.randomNumbers(6);
stringRedisTemplate.opsForValue().set(UserRedisConstants.buildUserLoginCode(phone), code, Duration.ofSeconds(60)); String key = UserRedisConstants.buildUserLoginCode(phone);
redisTemplate.opsForValue().set(key, code);
redisTemplate.expire(key, 5, TimeUnit.MINUTES);
String signName = "短信测试"; String signName = "短信测试";
String templateCode = "SMS_154950909"; String templateCode = "SMS_154950909";
String templateParam = String.format("{\"code\":\"%s\"}", code); String templateParam = String.format("{\"code\":\"%s\"}", code);

View File

@@ -1,22 +1,26 @@
package com.yinlihupo.enlish.service.service.plan; package com.yinlihupo.enlish.service.service.plan;
import com.yinlihupo.enlish.service.constant.ExamWordsConstant;
import com.yinlihupo.enlish.service.constant.LessonPlanConstant; import com.yinlihupo.enlish.service.constant.LessonPlanConstant;
import com.yinlihupo.enlish.service.domain.dataobject.*; import com.yinlihupo.enlish.service.domain.dataobject.*;
import com.yinlihupo.enlish.service.domain.mapper.*; import com.yinlihupo.enlish.service.domain.mapper.*;
import com.yinlihupo.enlish.service.model.bo.Sentence;
import com.yinlihupo.enlish.service.model.bo.Word;
import com.yinlihupo.enlish.service.service.LessonPlansService; import com.yinlihupo.enlish.service.service.LessonPlansService;
import com.yinlihupo.enlish.service.utils.DifyArticleClient; import com.yinlihupo.enlish.service.utils.DifyClient;
import com.yinlihupo.enlish.service.utils.StringToPlanMapUtil; import com.yinlihupo.enlish.service.utils.StringToPlanMapUtil;
import com.yinlihupo.framework.common.util.JsonUtils; import com.yinlihupo.framework.common.util.JsonUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.NonNull;
import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import java.util.concurrent.TimeUnit;
@Service @Service
@Slf4j @Slf4j
@@ -35,17 +39,34 @@ public class LessonPlansServiceImpl implements LessonPlansService {
@Resource @Resource
private GradeDOMapper gradeDOMapper; private GradeDOMapper gradeDOMapper;
@Resource @Resource
private DifyArticleClient difyArticleClient; private DifyClient difyClient;
@Resource
private ExamWordsDOMapper examWordsDOMapper;
@Resource
private StudentExamWordsDOMapper studentExamWordsDOMapper;
@Resource
private StudentDOMapper studentDOMapper;
@Resource
private PlanExamDOMapper planExamDOMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private ClassDOMapper classDOMapper;
@Override @Override
@Transactional(rollbackFor = Exception.class)
public void generateLessonPlans(Integer studentId, Integer unitId, Integer wordSize) { public void generateLessonPlans(Integer studentId, Integer unitId, Integer wordSize) {
String key = LessonPlanConstant.buildGeneratePlanContent(studentId);
redisTemplate.opsForValue().set(key, studentId);
redisTemplate.expire(key, 7, TimeUnit.MINUTES);
log.info("开始生成计划");
List<VocabularyBankDO> vocabularyBankDOS = vocabularyBankDOMapper.selectVocabularyBankDOAllByUnitId(unitId); List<VocabularyBankDO> vocabularyBankDOS = vocabularyBankDOMapper.selectVocabularyBankDOAllByUnitId(unitId);
UnitDO unitDO = unitDOMapper.selectByPrimaryKey(unitId); UnitDO unitDO = unitDOMapper.selectByPrimaryKey(unitId);
GradeUnitDO gradeUnitDO = gradeUnitDOMapper.selectByUnitId(unitId); GradeUnitDO gradeUnitDO = gradeUnitDOMapper.selectByUnitId(unitId);
GradeDO gradeDO = gradeDOMapper.selectById(gradeUnitDO.getGradeId()); GradeDO gradeDO = gradeDOMapper.selectById(gradeUnitDO.getGradeId());
List<VocabularyBankDO> totalWords = new ArrayList<>();
// 补差词汇所用词汇的 // 补差词汇所用词汇的
List<VocabularyBankDO> vocabularyBankListStudentNotMaster = getVocabListRandom(vocabularyBankDOMapper List<VocabularyBankDO> vocabularyBankListStudentNotMaster = getVocabListRandom(vocabularyBankDOMapper
.selectVocabularyBankListStudentNotMaster(gradeUnitDO.getGradeId(), studentId), 50); .selectVocabularyBankListStudentNotMaster(gradeUnitDO.getGradeId(), studentId), 50);
@@ -57,18 +78,21 @@ public class LessonPlansServiceImpl implements LessonPlansService {
int checkTotal = 50; int checkTotal = 50;
List<List<VocabularyBankDO>> weeksSync = new ArrayList<>(); List<List<VocabularyBankDO>> weeksSync = new ArrayList<>();
List<List<VocabularyBankDO>> weeksGap = new ArrayList<>(); List<List<VocabularyBankDO>> weeksGap = new ArrayList<>();
int j = 0;
for (int i = 0; i < 5; i++) { for (int i = 0; i < 5; i++) {
List<VocabularyBankDO> syncVocabList; List<VocabularyBankDO> syncVocabList;
if ((i + 1) * wordSize < syncSize) { if ((i + 1) * wordSize < syncSize) {
syncVocabList = vocabularyBankDOS.subList(i * wordSize, (i + 1) * wordSize); syncVocabList = vocabularyBankDOS.subList(i * wordSize, (i + 1) * wordSize);
} else if (i == 4) {
syncVocabList = vocabularyBankDOS.subList(i * wordSize, syncSize);
} else { } else {
syncVocabList = vocabularyBankDOS.subList((syncSize - i) * wordSize, Math.min((syncSize - i + 1) * wordSize, syncSize)); syncVocabList = new ArrayList<>(weeksSync.get(Math.min(j++, weeksSync.size() - 1)));
} }
List<VocabularyBankDO> gapVocabList = vocabularyBankListStudentNotMaster.subList(i * countGap, Math.min(i * countGap + countGap, gapSize)); List<VocabularyBankDO> gapVocabList = vocabularyBankListStudentNotMaster.subList(i * countGap, Math.min(i * countGap + countGap, gapSize));
weeksSync.add(syncVocabList); weeksSync.add(syncVocabList);
weeksGap.add(gapVocabList); weeksGap.add(gapVocabList);
totalWords.addAll(syncVocabList);
totalWords.addAll(gapVocabList);
List<VocabularyBankDO> reviewVocabList = new ArrayList<>(); List<VocabularyBankDO> reviewVocabList = new ArrayList<>();
List<VocabularyBankDO> checkList = new ArrayList<>(); List<VocabularyBankDO> checkList = new ArrayList<>();
// 艾宾浩斯遗忘曲线 // 艾宾浩斯遗忘曲线
@@ -118,21 +142,29 @@ public class LessonPlansServiceImpl implements LessonPlansService {
.build(); .build();
studentLessonPlansDOMapper.insert(studentLessonPlansDO); studentLessonPlansDOMapper.insert(studentLessonPlansDO);
Integer examId = (Integer) lessonPlanMap.get("examId");
PlanExamDO planExamDO = PlanExamDO.builder()
.planId(lessonPlansDO.getId())
.examId(examId)
.build();
planExamDOMapper.insert(planExamDO);
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException(e); log.info("生成第{}天计划失败,失败原因 {}", i + 1, e.getMessage());
} }
log.info("生成第{}天计划成功", i + 1); log.info("生成第{}天计划成功", i + 1);
} }
try { try {
int syncWeekender = syncSize / 2; log.info("开始生成周末计划");
int syncWeekendSize = totalWords.size() / 2;
for (int i = 0; i < 2; i++) { for (int i = 0; i < 2; i++) {
List<VocabularyBankDO> checkList = vocabularyBankDOS.subList(i * syncWeekender, Math.min((i + 1) * syncWeekender, syncSize)); List<VocabularyBankDO> checkList = totalWords.subList(i * syncWeekendSize, Math.min((i + 1) * syncWeekendSize, syncWeekendSize));
Map<String, Object> map = generateWeekendPlans(checkList, i + 6, gradeDO, unitDO, studentId); Map<String, Object> map = generateWeekendPlans(checkList, i + 6, gradeDO, unitDO, studentId);
LessonPlansDO lessonPlansDO = LessonPlansDO.builder() LessonPlansDO lessonPlansDO = LessonPlansDO.builder()
.title(map.get("title").toString()) .title(map.get("examStr").toString() + "复习")
.gradeId(gradeDO.getId().toString()) .gradeId(gradeDO.getId().toString())
.unitId(unitDO.getId()) .unitId(unitDO.getId())
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
@@ -140,6 +172,13 @@ public class LessonPlansServiceImpl implements LessonPlansService {
.build(); .build();
lessonPlansDOMapper.insert(lessonPlansDO); lessonPlansDOMapper.insert(lessonPlansDO);
Integer examId = (Integer) map.get("examId");
PlanExamDO planExamDO = PlanExamDO.builder()
.planId(lessonPlansDO.getId())
.examId(examId)
.build();
planExamDOMapper.insert(planExamDO);
StudentLessonPlansDO studentLessonPlansDO = StudentLessonPlansDO.builder() StudentLessonPlansDO studentLessonPlansDO = StudentLessonPlansDO.builder()
.studentId(studentId) .studentId(studentId)
.planId(lessonPlansDO.getId()) .planId(lessonPlansDO.getId())
@@ -162,14 +201,43 @@ public class LessonPlansServiceImpl implements LessonPlansService {
return lessonPlansDOMapper.selectByLessonId(id); return lessonPlansDOMapper.selectByLessonId(id);
} }
@Override
public List<LessonPlansDO> findLessonPlansByStudentId(Integer studentId) {
return lessonPlansDOMapper.selectByStudentId(studentId);
}
private Map<String, Object> generateWeekendPlans(List<VocabularyBankDO> checkList,
private Map<String, Object> generateWeekendPlans(List<VocabularyBankDO> words,
int day, int day,
GradeDO gradeDO, UnitDO unitDO, Integer studentId) throws IOException { GradeDO gradeDO, UnitDO unitDO, Integer studentId) throws IOException {
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("title", "" + day + "" + "复习" + gradeDO.getTitle() + unitDO.getTitle() + studentId); words.forEach(word -> word.setDefinition(word.getDefinition().length() > 5 ? word.getDefinition().substring(0, 5) : word.getDefinition()));
data.put("checkList", checkList); List<Integer> wordIds = words.stream().map(VocabularyBankDO::getId).toList();
StudentDO studentDO = studentDOMapper.selectStudentById(studentId);
String ExamTitle = gradeDO.getTitle() + unitDO.getTitle() + "教案小测第" + ExamWordsConstant.day2Chinese(day) + "" + studentDO.getName();
ExamWordsDO examWordsDO = ExamWordsDO.builder()
.gradeId(gradeDO.getId())
.level(1)
.wordIds(wordIds)
.type(ExamWordsConstant.EXAM_TYPE_TEST)
.title(ExamTitle)
.createdAt(LocalDateTime.now())
.build();
examWordsDOMapper.insert(examWordsDO);
studentExamWordsDOMapper.insertStudentsExam(studentId, examWordsDO.getId());
ClassDO classDO = classDOMapper.selectClassDOById(studentDOMapper.selectStudentById(studentId).getClassId());
data.put("examId", examWordsDO.getId());
data.put("studentId", studentId);
data.put("studentStr", gradeDO.getTitle() + " " + classDO.getTitle() + " " + studentDO.getName());
data.put("examStr", ExamTitle);
List<VocabularyBankDO> words1 = words.subList(0, words.size() / 2);
List<VocabularyBankDO> words2 = words.subList(words.size() / 2, words.size());
data.put("words1", words1);
data.put("words2", words2);
// LoopRowTableRenderPolicy policy = new LoopRowTableRenderPolicy(); // LoopRowTableRenderPolicy policy = new LoopRowTableRenderPolicy();
// Configure config = Configure.builder() // Configure config = Configure.builder()
// .bind("checkList", policy) // .bind("checkList", policy)
@@ -188,7 +256,8 @@ public class LessonPlansServiceImpl implements LessonPlansService {
List<VocabularyBankDO> checkList, List<VocabularyBankDO> checkList,
int day, int day,
GradeDO gradeDO, UnitDO unitDO, Integer studentId) throws Exception { GradeDO gradeDO, UnitDO unitDO, Integer studentId) throws Exception {
String title = gradeDO.getTitle() + " " + unitDO.getTitle() + " " + "" + day + "" + studentId;
String title = gradeDO.getTitle() + " " + unitDO.getTitle() + " " + "" + day + "";
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("title", title); data.put("title", title);
data.put("syncVocabList", syncVocabList); data.put("syncVocabList", syncVocabList);
@@ -196,15 +265,17 @@ public class LessonPlansServiceImpl implements LessonPlansService {
data.put("reviewVocabList", reviewVocabList); data.put("reviewVocabList", reviewVocabList);
data.put("checkList", checkList); data.put("checkList", checkList);
data.put("checkListAns", checkList); data.put("checkListAns", checkList);
// 中译英 // 中译英
List<VocabularyBankDO> drillRound1 = new ArrayList<>(syncVocabList); List<Word> list = syncVocabList.stream().map(vocabularyBankDO -> Word.builder().title(vocabularyBankDO.getWord()).definition(vocabularyBankDO.getDefinition()).build()).toList();
list.forEach(word -> word.setTitle(" "));
List<Word> drillRound1 = new ArrayList<>(list);
Collections.shuffle(drillRound1); Collections.shuffle(drillRound1);
data.put("drillRound1", drillRound1); data.put("drillRound1", drillRound1);
List<VocabularyBankDO> drillRound2 = new ArrayList<>(syncVocabList); List<Word> drillRound2 = new ArrayList<>(list);
Collections.shuffle(drillRound2); Collections.shuffle(drillRound2);
data.put("drillRound2", drillRound2); data.put("drillRound2", drillRound2);
List<VocabularyBankDO> drillRound3 = new ArrayList<>(syncVocabList); List<Word> drillRound3 = new ArrayList<>(list);
Collections.shuffle(drillRound3); Collections.shuffle(drillRound3);
data.put("drillRound3", drillRound3); data.put("drillRound3", drillRound3);
@@ -213,8 +284,10 @@ public class LessonPlansServiceImpl implements LessonPlansService {
mixedDrill.addAll(syncVocabList); mixedDrill.addAll(syncVocabList);
mixedDrill.addAll(gapVocabList); mixedDrill.addAll(gapVocabList);
mixedDrill.addAll(reviewVocabList); mixedDrill.addAll(reviewVocabList);
Collections.shuffle(mixedDrill); List<Word> mixedList = new ArrayList<>(mixedDrill.stream().map(vocabularyBankDO -> Word.builder().title(vocabularyBankDO.getWord()).definition(vocabularyBankDO.getDefinition()).build()).toList());
data.put("mixedDrill", mixedDrill); mixedList.forEach(word -> word.setDefinition(" "));
Collections.shuffle(mixedList);
data.put("mixedDrill", mixedList);
// 文章 A // 文章 A
log.info("生成文章 A 中文开始"); log.info("生成文章 A 中文开始");
@@ -241,6 +314,50 @@ public class LessonPlansServiceImpl implements LessonPlansService {
data.put("articleBans", mapB.get(LessonPlanConstant.ANSWER_KEY_EXPLANATION)); data.put("articleBans", mapB.get(LessonPlanConstant.ANSWER_KEY_EXPLANATION));
data.put("articleBtran", mapB.get(LessonPlanConstant.FULL_TRANSLATION)); data.put("articleBtran", mapB.get(LessonPlanConstant.FULL_TRANSLATION));
// 连词成句
List<Sentence> sentences = difyClient.sendSentenceAnalyze(syncVocabList.subList(0, Math.max(10, syncVocabList.size())), gradeDO.getTitle());
data.put("sentences", sentences);
data.put("sentencesAns", sentences);
log.info( "生成连词成句成功");
// 教案小测
List<VocabularyBankDO> words = new ArrayList<>(syncVocabList);
words.addAll(gapVocabList);
words.addAll(reviewVocabList);
if (words.size() < 100) {
words.addAll(vocabularyBankDOMapper.selectVocabularyBankListByGradeIdRandom(gradeDO.getId(), 100 - words.size()));
} else {
words = words.subList(0, 100);
}
words.forEach(word -> word.setDefinition(word.getDefinition().length() > 5 ? word.getDefinition().substring(0, 5) : word.getDefinition()));
List<Integer> wordIds = words.stream().map(VocabularyBankDO::getId).toList();
StudentDO studentDO = studentDOMapper.selectStudentById(studentId);
String ExamTitle = gradeDO.getTitle() + unitDO.getTitle() + "教案小测 第" + ExamWordsConstant.day2Chinese(day) + "" + studentDO.getName();
ExamWordsDO examWordsDO = ExamWordsDO.builder()
.gradeId(gradeDO.getId())
.level(1)
.wordIds(wordIds)
.type(ExamWordsConstant.EXAM_TYPE_TEST)
.title(ExamTitle)
.createdAt(LocalDateTime.now())
.build();
examWordsDOMapper.insert(examWordsDO);
studentExamWordsDOMapper.insertStudentsExam(studentId, examWordsDO.getId());
data.put("examId", examWordsDO.getId());
data.put("studentId", studentId);
data.put("studentStr", studentDO.getName());
data.put("examStr", ExamTitle);
List<VocabularyBankDO> words1 = words.subList(0, wordIds.size() / 2);
List<VocabularyBankDO> words2 = words.subList(wordIds.size() / 2, wordIds.size());
data.put("words1", words1);
data.put("words2", words2);
log.info("生成教案小测成功");
// LoopRowTableRenderPolicy policy = new LoopRowTableRenderPolicy(); // LoopRowTableRenderPolicy policy = new LoopRowTableRenderPolicy();
// Configure config = Configure.builder() // Configure config = Configure.builder()
// .bind("syncVocabList", policy) // .bind("syncVocabList", policy)
@@ -271,7 +388,7 @@ public class LessonPlansServiceImpl implements LessonPlansService {
int i = 0; int i = 0;
do { do {
log.info("第{}次生成文章中文开始", ++i); log.info("第{}次生成文章中文开始", ++i);
String answer = difyArticleClient.sendChat(string, String.valueOf(studentId) + UUID.randomUUID(), null).getAnswer(); String answer = difyClient.sendChat(string, String.valueOf(studentId) + UUID.randomUUID(), null).getAnswer();
map = StringToPlanMapUtil.parseTextToMap(answer); map = StringToPlanMapUtil.parseTextToMap(answer);
} while (map.get(LessonPlanConstant.TITLE) == null } while (map.get(LessonPlanConstant.TITLE) == null
|| map.get(LessonPlanConstant.PASSAGE) == null || map.get(LessonPlanConstant.PASSAGE) == null

View File

@@ -48,7 +48,8 @@ public class RoleServiceImpl implements RoleService {
List<RoleDO> roleDOs = roleIds.stream().map(roleId2RoleDO::get).toList(); List<RoleDO> roleDOs = roleIds.stream().map(roleId2RoleDO::get).toList();
List<String> user2RoleKeys = roleDOs.stream().map(RoleDO::getRoleKey).toList(); List<String> user2RoleKeys = roleDOs.stream().map(RoleDO::getRoleKey).toList();
log.info("将用户 {} 的角色同步到 redis 中, {}", userId, roleKeys); log.info("将用户 {} 的角色同步到 redis 中, {}", userId, roleKeys);
redisTemplate.opsForValue().set(RoleConstants.buildUserRoleKey(userId), JsonUtils.toJsonString(user2RoleKeys)); // 不要使用 JsonUtils.toJsonString(user2RoleKeys); 会造成二次序列化
redisTemplate.opsForValue().set(RoleConstants.buildUserRoleKey(userId), user2RoleKeys);
}); });
} }

View File

@@ -9,11 +9,10 @@ import com.yinlihupo.enlish.service.model.bo.exam.ExamWordsJudgeResultDetail;
import com.yinlihupo.enlish.service.model.bo.exam.WordMasteryDetail; import com.yinlihupo.enlish.service.model.bo.exam.WordMasteryDetail;
import com.yinlihupo.enlish.service.model.vo.student.AddStudentReqVO; import com.yinlihupo.enlish.service.model.vo.student.AddStudentReqVO;
import com.yinlihupo.enlish.service.service.StudentService; import com.yinlihupo.enlish.service.service.StudentService;
import com.yinlihupo.enlish.service.utils.DifyArticleClient; import com.yinlihupo.enlish.service.utils.DifyClient;
import com.yinlihupo.framework.common.util.JsonUtils; import com.yinlihupo.framework.common.util.JsonUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -37,7 +36,7 @@ public class StudentServiceImpl implements StudentService {
@Resource @Resource
private ExamWordsJudgeResultDOMapper examWordsJudgeResultDOMapper; private ExamWordsJudgeResultDOMapper examWordsJudgeResultDOMapper;
@Resource @Resource
private DifyArticleClient difyArticleClient; private DifyClient difyClient;
@Resource @Resource
private RedisTemplate<String, Object> redisTemplate; private RedisTemplate<String, Object> redisTemplate;
@Resource @Resource
@@ -121,7 +120,10 @@ public class StudentServiceImpl implements StudentService {
List<Integer> wordIds = new java.util.ArrayList<>(examWordsJudgeResultDOS.stream().map(ExamWordsJudgeResultDO::getCorrectWordIds).flatMap(List::stream).toList()); List<Integer> wordIds = new java.util.ArrayList<>(examWordsJudgeResultDOS.stream().map(ExamWordsJudgeResultDO::getCorrectWordIds).flatMap(List::stream).toList());
wordIds.addAll(examWordsJudgeResultDOS.stream().map(ExamWordsJudgeResultDO::getWrongWordIds).flatMap(List::stream).toList()); wordIds.addAll(examWordsJudgeResultDOS.stream().map(ExamWordsJudgeResultDO::getWrongWordIds).flatMap(List::stream).toList());
List<VocabularyBankDO> vocabularyBankDOS = vocabularyBankMapper.selectVocabularyBankDOListByIds(wordIds); List<VocabularyBankDO> vocabularyBankDOS = new ArrayList<>();
if (!wordIds.isEmpty()) {
vocabularyBankDOS = vocabularyBankMapper.selectVocabularyBankDOListByIds(wordIds);
}
Map<Integer, VocabularyBankDO> id2Word = vocabularyBankDOS.stream().collect(Collectors.toMap(VocabularyBankDO::getId, vocabularyBankDO -> vocabularyBankDO)); Map<Integer, VocabularyBankDO> id2Word = vocabularyBankDOS.stream().collect(Collectors.toMap(VocabularyBankDO::getId, vocabularyBankDO -> vocabularyBankDO));
List<ExamWordsJudgeResultDetail> examWordsJudgeResultDetails = new ArrayList<>(); List<ExamWordsJudgeResultDetail> examWordsJudgeResultDetails = new ArrayList<>();
@@ -159,7 +161,7 @@ public class StudentServiceImpl implements StudentService {
studentStudyInfo.put("单词掌握情况", wordMasteryDetails); studentStudyInfo.put("单词掌握情况", wordMasteryDetails);
try { try {
String analyze = difyArticleClient.sendStudentAnalyze(JsonUtils.toJsonString(studentStudyInfo)).getAnswer(); String analyze = difyClient.sendStudentAnalyze(JsonUtils.toJsonString(studentStudyInfo)).getAnswer();
// 设置过期时间 3 天 // 设置过期时间 3 天
redisTemplate.opsForValue().set(key, analyze); redisTemplate.opsForValue().set(key, analyze);
redisTemplate.expire(key, 3, TimeUnit.DAYS); redisTemplate.expire(key, 3, TimeUnit.DAYS);

View File

@@ -1,14 +1,18 @@
package com.yinlihupo.enlish.service.service.user; package com.yinlihupo.enlish.service.service.user;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import com.yinlihupo.enlish.service.constant.UserRedisConstants;
import com.yinlihupo.enlish.service.domain.dataobject.UserDO; import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
import com.yinlihupo.enlish.service.domain.mapper.UserDOMapper; import com.yinlihupo.enlish.service.domain.mapper.UserDOMapper;
import com.yinlihupo.enlish.service.service.UserService; import com.yinlihupo.enlish.service.service.UserService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Objects;
@Service @Service
@Slf4j @Slf4j
@@ -17,8 +21,10 @@ public class UserServiceImpl implements UserService {
@Resource @Resource
private UserDOMapper userDOMapper; private UserDOMapper userDOMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private PasswordEncoder passwordEncoder;
@Override @Override
public UserDO findUser() { public UserDO findUser() {
@@ -43,4 +49,21 @@ public class UserServiceImpl implements UserService {
userDOMapper.insert(userDO); userDOMapper.insert(userDO);
} }
@Override
public void updateUserInfo(String password, String reqCode, String phone, String name) {
long id = Integer.parseInt(String.valueOf(StpUtil.getLoginId()));
UserDO userDO = userDOMapper.selectById(id);
String key = UserRedisConstants.buildUserLoginCode(userDO.getPhone());
String code = Objects.requireNonNull(redisTemplate.opsForValue().get(key)).toString();
if (code == null || !code.equals(reqCode)) {
throw new RuntimeException("验证码错误");
}
if (password != null) {
password = passwordEncoder.encode(password);
}
userDOMapper.updateUserInfo(id, name, password, phone);
}
} }

View File

@@ -1,13 +1,13 @@
package com.yinlihupo.enlish.service.job; package com.yinlihupo.enlish.service.task;
import com.yinlihupo.enlish.service.service.ExamWordsJudgeService; import com.yinlihupo.enlish.service.service.ExamWordsJudgeService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Component @Component
@Slf4j
public class AutoJudgeExamWordsTask { public class AutoJudgeExamWordsTask {
@Resource @Resource
@@ -15,7 +15,9 @@ public class AutoJudgeExamWordsTask {
@Scheduled(fixedRate = 5000) @Scheduled(fixedRate = 5000)
public void autoJudgeExamWords() { public void autoJudgeExamWords() {
System.out.println("【固定频率】开始自动判卷,时间:" + LocalDateTime.now()); if (examWordsJudgeService.getExamUnfinishedCount() != 0) {
log.info("有试卷待检测,开始检测");
examWordsJudgeService.judgeExamWords(5); examWordsJudgeService.judgeExamWords(5);
} }
}
} }

View File

@@ -3,6 +3,9 @@ package com.yinlihupo.enlish.service.utils; // 修改为你的包名
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yinlihupo.enlish.service.domain.dataobject.VocabularyBankDO;
import com.yinlihupo.enlish.service.model.bo.Sentence;
import com.yinlihupo.framework.common.util.JsonUtils;
import lombok.Data; import lombok.Data;
import lombok.ToString; import lombok.ToString;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -14,21 +17,25 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.time.Duration; import java.time.Duration;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
@Component @Component
public class DifyArticleClient { public class DifyClient {
@Value("${ai.key}") @Value("${ai.key}")
private String apiKey; private String apiKey;
private String anaKey = "app-hrUFcopdcpnflsvpHWRuBfCp"; @Value("${ai.analyzeKey}")
private String analyzeKey;
@Value("${ai.sentenceKey}")
private String sentenceKey;
@Value("${ai.url}") @Value("${ai.url}")
private String baseUrl; private String baseUrl;
private final HttpClient httpClient; private final HttpClient httpClient;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
// 构造函数 // 构造函数
public DifyArticleClient() { public DifyClient() {
this.httpClient = HttpClient.newBuilder() this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10)) // 连接超时 .connectTimeout(Duration.ofSeconds(10)) // 连接超时
@@ -36,6 +43,48 @@ public class DifyArticleClient {
this.objectMapper = new ObjectMapper(); this.objectMapper = new ObjectMapper();
} }
public List<Sentence> sendSentenceAnalyze(List<VocabularyBankDO> list, String grade) throws Exception {
// 1. 构建请求体对象
ChatRequest payload = new ChatRequest();
payload.setQuery(JsonUtils.toJsonString(list.stream().map(VocabularyBankDO::getWord).toList()));
payload.setUser("admin");
HashMap<String, Object> objectObjectHashMap = new HashMap<>();
objectObjectHashMap.put("grade", grade);
payload.setResponseMode("blocking"); // 使用阻塞模式一次性返回
payload.setInputs(objectObjectHashMap);
// 2. 序列化为 JSON 字符串
String jsonBody = objectMapper.writeValueAsString(payload);
// 3. 构建 HTTP 请求
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl))
.header("Authorization", "Bearer " + sentenceKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.timeout(Duration.ofSeconds(30)) // 读取超时
.build();
// 4. 发送请求
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
// 5. 检查状态码
if (response.statusCode() != 200) {
throw new RuntimeException("Dify 请求失败: HTTP " + response.statusCode() + " | Body: " + response.body());
}
// 6. 反序列化响应体
DifyResponse difyResponse = objectMapper.readValue(response.body(), DifyResponse.class);
String answer = difyResponse.getAnswer();
answer = answer.replace("json", "");
answer = answer.replace("```", "");
return JsonUtils.parseList(answer, Sentence.class);
}
public DifyResponse sendStudentAnalyze(String query) throws Exception { public DifyResponse sendStudentAnalyze(String query) throws Exception {
String endpoint = this.baseUrl; String endpoint = this.baseUrl;
@@ -52,7 +101,7 @@ public class DifyArticleClient {
// 3. 构建 HTTP 请求 // 3. 构建 HTTP 请求
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(endpoint)) .uri(URI.create(endpoint))
.header("Authorization", "Bearer " + anaKey) .header("Authorization", "Bearer " + analyzeKey)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody)) .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.timeout(Duration.ofSeconds(30)) // 读取超时 .timeout(Duration.ofSeconds(30)) // 读取超时

View File

@@ -15,6 +15,7 @@ import org.opencv.imgproc.Imgproc;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte; import java.awt.image.DataBufferByte;
import java.io.File;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
@@ -31,29 +32,11 @@ public class PngUtil {
// 获取起始坐标 // 获取起始坐标
public static List<CoordinatesXY> analysisXY(String imagePath) { public static List<CoordinatesXY> analysisXY(String imagePath) {
Mat binary = image2BinaryMath(imagePath);
Mat src = Imgcodecs.imread(imagePath); Mat src = Imgcodecs.imread(imagePath);
if (src.empty()) {
System.out.println("无法读取图片,请检查路径。");
return null;
}
// 3. 预处理
// 3.1 转换为灰度图
Mat gray = new Mat();
Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY);
// 3.2 二值化处理 (Thresholding)
// 使用 THRESH_BINARY_INV (反转二值化),因为我们需要找的是白色背景上的黑色块。
// 反转后,黑色块变成白色(255),背景变成黑色(0),方便 findContours 查找。
Mat binary = new Mat();
// 阈值设为 50 左右即可,因为块是纯黑的
Imgproc.threshold(gray, binary, 50, 255, Imgproc.THRESH_BINARY_INV);
// 4. 查找轮廓 // 4. 查找轮廓
List<MatOfPoint> contours = new ArrayList<>(); List<MatOfPoint> contours = new ArrayList<>();
Mat hierarchy = new Mat(); Mat hierarchy = new Mat();
// RETR_EXTERNAL 只检测最外层轮廓,忽略块内部可能存在的噪点
Imgproc.findContours(binary, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE); Imgproc.findContours(binary, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE);
System.out.println("检测到的轮廓总数: " + contours.size()); System.out.println("检测到的轮廓总数: " + contours.size());
@@ -89,67 +72,44 @@ public class PngUtil {
System.out.println("------------------------------------------------"); System.out.println("------------------------------------------------");
list.add(CoordinatesXY.builder().x(rect.x).y(rect.y).width(rect.width).height(rect.height).build()); list.add(CoordinatesXY.builder().x(rect.x).y(rect.y).width(rect.width).height(rect.height).build());
// 可选:在原图上画出框,用于调试验证 // 可选:在原图上画出框,用于调试验证
// Imgproc.rectangle(src, rect, new Scalar(0, 0, 255), 2); // 红色框 Imgproc.rectangle(src, rect, new Scalar(0, 0, 255), 2); // 红色框
// Imgproc.putText(src, "#" + blockCount, new Point(rect.x, rect.y - 5), Imgproc.FONT_HERSHEY_SIMPLEX, 0.5, new Scalar(0, 0, 255), 1); Imgproc.putText(src, "#" + blockCount, new Point(rect.x, rect.y - 5), Imgproc.FONT_HERSHEY_SIMPLEX, 0.5, new Scalar(0, 0, 255), 1);
// Imgcodecs.imwrite("output_red.png", src);
} }
} }
Imgcodecs.imwrite("output_red.png", src);
System.out.println("找到 " + blockCount + " 个黑色块。"); System.out.println("找到 " + blockCount + " 个黑色块。");
// 获取每一列的宽度 // 计算起始坐标
list.sort(Comparator.comparingInt(CoordinatesXY::getHeight));
int height = list.get(list.size() - 1).getHeight() / ExamWordsConstant.PGN_COL;
// 删除两列答题卡区块
list.sort(Comparator.comparingInt(CoordinatesXY::getWidth));
list.remove(list.size() - 1);
list.remove(list.size() - 1);
list.sort(Comparator.comparingInt(CoordinatesXY::getX)); list.sort(Comparator.comparingInt(CoordinatesXY::getX));
// 计算起始坐标 list.forEach(coordinatesXY -> coordinatesXY.setHeight(coordinatesXY.getHeight() / 51));
List<CoordinatesXY> ans = getCoordinatesXIES(list, height); list.forEach(coordinatesXY -> coordinatesXY.setWidth(coordinatesXY.getWidth() / 3));
list.forEach(coordinatesXY -> coordinatesXY.setX(coordinatesXY.getX() + coordinatesXY.getWidth() * 2));
src.release(); log.info("起始坐标: {}", list);
binary.release();
hierarchy.release();
binary.release();
return ans; return list;
} }
// 获取(未背熟)单词的 id // 获取(未背熟)单词的 id
public static List<Integer> analyzePngForUnmemorizedWordIds(String filePath, List<Integer> wordIds, List<CoordinatesXY> coordinatesXYList) { public static List<Integer> analyzePngForUnmemorizedWordIds(String filePath, List<Integer> wordIds, List<CoordinatesXY> coordinatesXYList) {
Mat src = Imgcodecs.imread(filePath);
if (src.empty()) {
log.error("无法读取图片,请检查路径: {}", filePath);
throw new RuntimeException("无法读取图片");
}
Mat gray = new Mat(); Mat binary = image2BinaryMath(filePath);
Mat binary = new Mat();
try { try {
Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY);
// 建议:如果光照不均匀,考虑使用 THRESH_OTSU 自动阈值,或者自适应阈值
Imgproc.threshold(gray, binary, 150, 255, Imgproc.THRESH_BINARY_INV);
// 调试时打印
// Imgcodecs.imwrite("output_binary.png", binary);
List<Integer> answer = new ArrayList<>(); List<Integer> answer = new ArrayList<>();
int words_index = 0; int words_index = 0;
for (int i = 0; i < coordinatesXYList.size(); i++) { for (CoordinatesXY coordinatesXY : coordinatesXYList) {
CoordinatesXY coordinatesXY = coordinatesXYList.get(i);
int width = coordinatesXY.getWidth(); int width = coordinatesXY.getWidth();
int height = coordinatesXY.getHeight(); int height = coordinatesXY.getHeight();
int currentX = coordinatesXY.getX(); int currentX = coordinatesXY.getX();
int currentY = coordinatesXY.getY(); int currentY = coordinatesXY.getY() + height;
int count = i == 0 ? ExamWordsConstant.PGN_COL - 1 : ExamWordsConstant.PGN_COL;
// 内层循环:遍历这一列的每一行 // 内层循环:遍历这一列的每一行
for (int j = 0; j < count; j++) { for (int j = 0; j < 50; j++) {
// 安全检查:防止单词列表比格子少导致越界 // 安全检查:防止单词列表比格子少导致越界
if (words_index >= wordIds.size()) { if (words_index >= wordIds.size()) {
log.warn("单词列表耗尽,停止检测。格子数多于单词数。"); log.warn("单词列表耗尽,停止检测。格子数多于单词数。");
@@ -168,12 +128,15 @@ public class PngUtil {
Rect rect = new Rect(currentX + 1, currentY + 1, width - 2, height - 2); Rect rect = new Rect(currentX + 1, currentY + 1, width - 2, height - 2);
Mat region = binary.submat(rect); Mat region = binary.submat(rect);
int countNonZero = Core.countNonZero(region); int countNonZero = Core.countNonZero(region);
log.info("当前位置为 words_index={},坐标为 x={} y={} 当前区域非零像素数: {}", words_index, currentX, currentY, countNonZero);
if (countNonZero > 500) { if (countNonZero > 1000) {
Integer id = wordIds.get(words_index); Integer id = wordIds.get(words_index);
answer.add(id); answer.add(id);
log.info("检测到标记未背熟ID={}, 当前坐标 x = {} y = {} ", id, currentX + 1, currentY + 1); log.info("检测到标记未背熟ID={}, 当前坐标 x = {} y = {} ", id, currentX + 1, currentY + 1);
} }
if (countNonZero == 0) {
break;
}
region.release(); region.release();
words_index++; words_index++;
@@ -185,8 +148,6 @@ public class PngUtil {
} finally { } finally {
src.release();
gray.release();
binary.release(); binary.release();
} }
} }
@@ -206,21 +167,21 @@ public class PngUtil {
Rect roiRect = new Rect(0, 0, left.getX(), left.getY()); Rect roiRect = new Rect(0, 0, left.getX(), left.getY());
Mat roi = new Mat(src, roiRect); Mat roi = new Mat(src, roiRect);
// 3. 图像预处理 (提高 OCR 准确率) // // 3. 图像预处理 (提高 OCR 准确率)
// 3.1 转为灰度图 // // 3.1 转为灰度图
Mat gray = new Mat(); // Mat gray = new Mat();
Imgproc.cvtColor(roi, gray, Imgproc.COLOR_BGR2GRAY); // Imgproc.cvtColor(roi, gray, Imgproc.COLOR_BGR2GRAY);
//
// 3.2 二值化 (Thresholding) // // 3.2 二值化 (Thresholding)
// 使用 OTSU 算法自动寻找最佳阈值,或者手动指定阈值 // // 使用 OTSU 算法自动寻找最佳阈值,或者手动指定阈值
Mat binary = new Mat(); // Mat binary = new Mat();
Imgproc.threshold(gray, binary, 0, 255, Imgproc.THRESH_BINARY | Imgproc.THRESH_OTSU); // Imgproc.threshold(gray, binary, 0, 255, Imgproc.THRESH_BINARY | Imgproc.THRESH_OTSU);
// 可选:保存预处理后的图片查看效果 // 可选:保存预处理后的图片查看效果
// Imgcodecs.imwrite("debug_roi.jpg", binary); Imgcodecs.imwrite("debug_roi.jpg", src);
// 4. 将 OpenCV Mat 转换为 BufferedImage (供 Tess4J 使用) // 4. 将 OpenCV Mat 转换为 BufferedImage (供 Tess4J 使用)
BufferedImage processedImage = matToBufferedImage(binary); BufferedImage processedImage = matToBufferedImage(src);
// 5. 使用 Tesseract 进行 OCR 识别 // 5. 使用 Tesseract 进行 OCR 识别
ITesseract instance = new Tesseract(); ITesseract instance = new Tesseract();
@@ -245,6 +206,50 @@ public class PngUtil {
return null; return null;
} }
private static Mat image2BinaryMath(String imagePath) {
if (!new File(imagePath).exists()) {
log.error("图片不存在,请检查路径: {}", imagePath);
throw new RuntimeException("图片不存在");
}
Mat src = Imgcodecs.imread(imagePath);
if (src.empty()) {
log.info("无法读取图片,请检查路径: {}", imagePath);
throw new RuntimeException("无法读取图片");
}
Mat gray = new Mat();
//转换为灰度图
Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY);
Imgproc.GaussianBlur(gray, gray, new Size(5, 5), 0);
Mat binary = new Mat();
Imgproc.adaptiveThreshold(gray, binary, 255,
Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C,
Imgproc.THRESH_BINARY_INV, 25, 10);
Mat kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(3, 3));
//开运算 (Open):先腐蚀后膨胀,用于去除背景中的微小噪点
Imgproc.morphologyEx(binary, binary, Imgproc.MORPH_OPEN, kernel);
// 闭运算 (Close):先膨胀后腐蚀,用于连接断裂的区域并填充块内部的空洞
// 如果块比较大且内部反光严重,可以将 Size(3,3) 改为 Size(5,5) 或更大
Mat closeKernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(7, 7));
Imgproc.morphologyEx(binary, binary, Imgproc.MORPH_CLOSE, closeKernel);
// 保存二值化过程图用于调试 (生产环境可注释)
Imgcodecs.imwrite("debug_binary_natural.png", binary);
src.release();
gray.release();
return binary;
}
private static @NonNull StudentExamId getStudentExamId(Pattern pattern, String result) { private static @NonNull StudentExamId getStudentExamId(Pattern pattern, String result) {
Matcher matcher = pattern.matcher(result); Matcher matcher = pattern.matcher(result);
StudentExamId studentExamId = new StudentExamId(0, 0); StudentExamId studentExamId = new StudentExamId(0, 0);
@@ -276,21 +281,4 @@ public class PngUtil {
return image; return image;
} }
private static @NonNull List<CoordinatesXY> getCoordinatesXIES(List<CoordinatesXY> list, int height) {
List<CoordinatesXY> ans = new ArrayList<>();
CoordinatesXY left = new CoordinatesXY();
left.setX(list.get(1).getX());
left.setWidth(list.get(1).getWidth());
left.setHeight(height);
left.setY(list.get(0).getY() + left.getHeight());
ans.add(left);
CoordinatesXY right = new CoordinatesXY();
right.setX(list.get(2).getX());
right.setY(list.get(0).getY());
right.setWidth(list.get(1).getWidth());
right.setHeight(height);
ans.add(right);
return ans;
}
} }

View File

@@ -36,6 +36,8 @@ public class WordExportUtil {
LoopRowTableRenderPolicy policy = new LoopRowTableRenderPolicy(); LoopRowTableRenderPolicy policy = new LoopRowTableRenderPolicy();
config = Configure.builder() config = Configure.builder()
.bind("words", policy) .bind("words", policy)
.bind("words1", policy)
.bind("words2", policy)
.bind("answer", policy) .bind("answer", policy)
.build(); .build();
@@ -50,11 +52,16 @@ public class WordExportUtil {
.bind("mixedDrill", policyLessonPlanWeekday) .bind("mixedDrill", policyLessonPlanWeekday)
.bind("checkList", policyLessonPlanWeekday) .bind("checkList", policyLessonPlanWeekday)
.bind("checkListAns", policyLessonPlanWeekday) .bind("checkListAns", policyLessonPlanWeekday)
.bind("sentences", policyLessonPlanWeekday)
.bind("sentencesAns", policyLessonPlanWeekday)
.bind("words1", policy)
.bind("words2", policy)
.build(); .build();
LoopRowTableRenderPolicy policyLessonPlan = new LoopRowTableRenderPolicy(); LoopRowTableRenderPolicy policyLessonPlan = new LoopRowTableRenderPolicy();
configLessonPlanWeekend = Configure.builder() configLessonPlanWeekend = Configure.builder()
.bind("checkList", policyLessonPlan) .bind("words1", policy)
.bind("words2", policy)
.build(); .build();
} }
@@ -94,7 +101,7 @@ public class WordExportUtil {
} else { } else {
template = XWPFTemplate.compile(inputStream, configLessonPlanWeekend); template = XWPFTemplate.compile(inputStream, configLessonPlanWeekend);
} }
String url = "http://localhost:5173/#/plan/tts?planId=" + lessonPlan.getId(); String url = "http://english.yinlihupo.cn/#/plan/tts?planId=" + lessonPlan.getId();
map.put("img", Pictures.ofBytes(generateQR(url), PictureType.PNG).create()); map.put("img", Pictures.ofBytes(generateQR(url), PictureType.PNG).create());
OutputStream out = response.getOutputStream(); OutputStream out = response.getOutputStream();
template.render(map); template.render(map);

View File

@@ -2,7 +2,7 @@ spring:
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver # 指定数据库驱动类 driver-class-name: com.mysql.cj.jdbc.Driver # 指定数据库驱动类
# 数据库连接信息 # 数据库连接信息
url: jdbc:mysql://124.220.58.5:3306/enlish?allowMultiQueries=true url: jdbc:mysql://124.220.58.5:3306/dev_english?allowMultiQueries=true
username: root # 数据库用户名 username: root # 数据库用户名
password: YLHP@admin123 # 数据库密码 password: YLHP@admin123 # 数据库密码
data: data:
@@ -31,18 +31,20 @@ spring:
templates: templates:
word: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\assessment_v5.docx word: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\assessment_v9.docx
count: 100 count: 100
data: C:\project\tess data: C:\project\tess
plan: plan:
weekday: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\tem_study_plan_v3.docx weekday: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\tem_study_plan_v7.docx
weekend: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\study_plan_review_v1.docx weekend: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\study_plan_review_v3.docx
plan_day: 7 plan_day: 7
tmp: tmp:
png: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\tmp\png\ png:
ai: ai:
key: app-loC6IrJpj4cS54MAYp73QtGl key: app-loC6IrJpj4cS54MAYp73QtGl
analyzeKey: app-hrUFcopdcpnflsvpHWRuBfCp
sentenceKey: app-Emk5YQBaD2YruRXuE5sK1vEU
url: https://chat.cosonggle.com/v1/chat-messages url: https://chat.cosonggle.com/v1/chat-messages
aliyun: aliyun:

View File

@@ -0,0 +1,52 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver # 指定数据库驱动类
# 数据库连接信息
url: jdbc:mysql://124.220.58.5:3306/enlish?allowMultiQueries=true
username: root # 数据库用户名
password: YLHP@admin123 # 数据库密码
data:
redis:
database: 6 # Redis 数据库索引(默认为 0
host: 124.220.58.5 # Redis 服务器地址
port: 6543 # Redis 服务器连接端口
password: 741963 # Redis 服务器连接密码(默认为空)
timeout: 5s # 读超时时间
connect-timeout: 5s # 链接超时时间
lettuce:
pool:
max-active: 200 # 连接池最大连接数
max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制)
min-idle: 0 # 连接池中的最小空闲连接
max-idle: 10 # 连接池中的最大空闲连接
ai:
openai:
api-key: your_api_key_here
base-url: http://124.220.58.5:2233
audio:
speech:
options:
model: tts-1
voice: alloy
templates:
word: assessment_v9.docx
count: 100
data:
plan:
weekday: tem_study_plan_v7.docx
weekend: study_plan_review_v3.docx
plan_day: 7
tmp:
png:
ai:
key: app-loC6IrJpj4cS54MAYp73QtGl
analyzeKey: app-hrUFcopdcpnflsvpHWRuBfCp
sentenceKey: app-Emk5YQBaD2YruRXuE5sK1vEU
url: https://chat.cosonggle.com/v1/chat-messages
aliyun:
accessKeyId:
accessKeySecret:

View File

@@ -3,12 +3,16 @@ server:
spring: spring:
profiles: profiles:
active: dev # 默认激活 dev 本地开发环境 active: pro # 默认激活 dev 本地开发环境
servlet:
multipart:
max-file-size: 30MB
max-request-size: 30MB
mybatis: mybatis:
# MyBatis xml 配置文件路径 # MyBatis xml 配置文件路径
mapper-locations: classpath:/mapper/**/*.xml mapper-locations: classpath:/mapper/**/*.xml
############## Sa-Token 配置 (文档: https://sa-token.cc) ############## ############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token: sa-token:
# token 名称(同时也是 cookie 名称) # token 名称(同时也是 cookie 名称)
@@ -27,3 +31,6 @@ sa-token:
is-share: true is-share: true
# 是否输出操作日志 # 是否输出操作日志
is-log: true is-log: true
#logging:
# level:
# com.yinlihupo.enlish.service.domain.mapper: debug

View File

@@ -45,7 +45,7 @@
targetProject="src/main/java"/> targetProject="src/main/java"/>
<!-- 需要生成的表-实体类 --> <!-- 需要生成的表-实体类 -->
<table tableName="student_stage_learning_remark" domainObjectName="StudentStageLearningRemarkDO" <table tableName="plan_exam" domainObjectName="PlanExamDO"
enableCountByExample="false" enableCountByExample="false"
enableUpdateByExample="false" enableUpdateByExample="false"
enableDeleteByExample="false" enableDeleteByExample="false"

View File

@@ -14,8 +14,8 @@
</resultMap> </resultMap>
<insert id="insert" useGeneratedKeys="true" keyProperty="id"> <insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into exam_words (grade_id, level, title, word_ids, created_at) insert into exam_words (grade_id, level, title, word_ids, type, created_at)
VALUES (#{gradeId}, #{level}, #{title}, #{wordIds, typeHandler=com.yinlihupo.enlish.service.config.ListWordIdTypeHandler}, #{createdAt}) VALUES (#{gradeId}, #{level}, #{title}, #{wordIds, typeHandler=com.yinlihupo.enlish.service.config.ListWordIdTypeHandler}, #{type}, #{createdAt})
</insert> </insert>
<update id="updateWordIdsOrder"> <update id="updateWordIdsOrder">

View File

@@ -62,6 +62,7 @@
select count(1) select count(1)
from exam_words_judge_result from exam_words_judge_result
</select> </select>
<select id="selectDetailById" resultMap="ResultMapWithBLOBs"> <select id="selectDetailById" resultMap="ResultMapWithBLOBs">
select * select *
from exam_words_judge_result from exam_words_judge_result
@@ -81,4 +82,22 @@
and start_date between date_sub(now(), interval 7 day) and now() and start_date between date_sub(now(), interval 7 day) and now()
</select> </select>
<select id="selectByPageAndStudentIds" resultMap="BaseResultMap">
select *
from exam_words_judge_result
where student_id in
<foreach item="item" index="index" collection="studentIds"
open="(" separator="," close=")">
#{item}
</foreach>
order by start_date
limit #{startIndex}, #{pageSize}
</select>
<select id="selectUnfinishedCount" resultType="java.lang.Integer">
select count(*)
from exam_words_judge_result
where is_finished = 0
</select>
</mapper> </mapper>

View File

@@ -39,4 +39,15 @@
where id = #{lessonId} where id = #{lessonId}
</select> </select>
<select id="selectByStudentId" resultMap="BaseResultMap">
select *
from lesson_plans
where id in (
select student_lesson_plans.plan_id
from student_lesson_plans
where student_id = #{studentId}
and is_finished = 0
)
</select>
</mapper> </mapper>

View File

@@ -0,0 +1,18 @@
<?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.yinlihupo.enlish.service.domain.mapper.PlanExamDOMapper">
<resultMap id="BaseResultMap" type="com.yinlihupo.enlish.service.domain.dataobject.PlanExamDO">
<id column="id" jdbcType="INTEGER" property="id" />
<result column="plan_id" jdbcType="INTEGER" property="planId" />
<result column="exam_id" jdbcType="INTEGER" property="examId" />
</resultMap>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into plan_exam (plan_id, exam_id)
values (#{planId}, #{examId})
</insert>
<select id="selectByExamId" resultMap="BaseResultMap">
select * from plan_exam where exam_id = #{examId}
</select>
</mapper>

View File

@@ -75,4 +75,24 @@
where class_id = #{classId} where class_id = #{classId}
and is_deleted = 0 and is_deleted = 0
</select> </select>
<select id="selectStudentDOListByClassId" resultMap="BaseResultMap">
select *
from student
where class_id = #{classId}
and is_deleted = 0
</select>
<select id="selectStudentDOListByGradeId" resultMap="BaseResultMap">
select *
from student
where grade_id = #{gradeId}
and is_deleted = 0
</select>
<select id="selectStudentDOListByName" resultMap="BaseResultMap">
select *
from student
where name like concat('%', #{name}, '%')
and is_deleted = 0
</select>
</mapper> </mapper>

View File

@@ -21,7 +21,6 @@
from student_exam_words from student_exam_words
where student_id = #{studentId} where student_id = #{studentId}
and exam_words_id = #{examWordsId} and exam_words_id = #{examWordsId}
and is_completed = 0
</select> </select>
<update id="updateStudentExamWordsFinished"> <update id="updateStudentExamWordsFinished">

View File

@@ -20,6 +20,28 @@
values (#{phone}, #{name}, #{password}) values (#{phone}, #{name}, #{password})
</insert> </insert>
<update id="updatePassword">
update user
set password = #{password}
where id = #{id}
</update>
<update id="updateUserInfo">
update user
<set>
<if test="password != null">
password = #{password},
</if>
<if test="name != null">
`name` = #{name},
</if>
<if test="phone != null">
phone = #{phone},
</if>
</set>
where id = #{id}
</update>
<select id="selectByPhone" resultMap="BaseResultMap"> <select id="selectByPhone" resultMap="BaseResultMap">
select * select *
from user from user

View File

@@ -145,5 +145,21 @@
order by rand() order by rand()
limit 100 limit 100
</select> </select>
<select id="selectByGradeIdAndNotMatchIds"
resultType="com.yinlihupo.enlish.service.domain.dataobject.VocabularyBankDO">
select *
from vocabulary_bank
where unit_id in (
select unit_id
from grade_unit
where grade_id = #{gradeId}
)
and id not in
<foreach item="id" collection="ids" separator="," open="(" close=")">
#{id}
</foreach>
order by rand()
limit #{wordCount}
</select>
</mapper> </mapper>

View File

@@ -1,47 +1,32 @@
package com.yinlihupo.enlish.service.ai; package com.yinlihupo.enlish.service.ai;
import com.yinlihupo.enlish.service.utils.DifyArticleClient; import com.yinlihupo.enlish.service.domain.dataobject.VocabularyBankDO;
import com.yinlihupo.enlish.service.domain.mapper.VocabularyBankDOMapper;
import com.yinlihupo.enlish.service.model.bo.Sentence;
import com.yinlihupo.enlish.service.utils.DifyClient;
import com.yinlihupo.enlish.service.utils.StringToPlanMapUtil; import com.yinlihupo.enlish.service.utils.StringToPlanMapUtil;
import com.yinlihupo.framework.common.util.JsonUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import java.io.IOException; import java.io.IOException;
import java.util.List;
import java.util.Map; import java.util.Map;
@SpringBootTest @SpringBootTest
public class AITest { public class AITest {
@Resource @Resource
private DifyArticleClient client; private DifyClient client;
@Resource
private VocabularyBankDOMapper vocabularyBankDOMapper;
@Test @Test
public void test1() throws IOException { public void test1() throws IOException {
try { try {
// 2. 第一轮对话 (没有 conversation_id)
System.out.println("--- Round 1 ---");
String userId = "user-1001";
DifyArticleClient.DifyResponse response1 = client.sendChat("ruler, pencil, eraser, crayon, bag, pen, book, red, green, yellow, blue, face, ear, eye, nose, mouth, duck, pig, cat, bear, dog, elephant, monkey, bird, tiger, panda, bread, juice, egg, milk", userId, null);
//System.out.println("AI 回复: " + response1.getAnswer());
System.out.println("当前会话ID: " + response1.getConversationId());
// // 3. 第二轮对话 (传入上一轮的 conversation_id 以保持记忆)
// System.out.println("\n--- Round 2 ---");
// // 注意这里传入了 response1.getConversationId()
// DifyClient.DifyResponse response2 = client.sendChat("我刚才说了我叫什么?", userId, response1.getConversationId());
//
// System.out.println("AI 回复: " + response2.getAnswer());
System.out.println("\n--- Round 2 ---");
Map<String, String> stringStringMap = StringToPlanMapUtil.parseTextToMap(response1.getAnswer());
System.out.println(stringStringMap.get("Title"));
System.out.println(stringStringMap.get("The Passage"));
System.out.println(stringStringMap.get("Quiz"));
System.out.println(stringStringMap.get("Answer Key & Explanation"));
System.out.println(stringStringMap.get("Full Translation"));
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();

View File

@@ -35,68 +35,68 @@ public class TestVocabularyBankInsert {
private GradeUnitDOMapper gradeUnitDOMapper; private GradeUnitDOMapper gradeUnitDOMapper;
@Test @Test
void test() { void test() {
String file = "C:\\project\\java\\enlish_edu\\enlish\\enlish-service\\src\\test\\java\\com\\yinlihupo\\enlish\\service\\mapper\\八下.xlsx"; // String file = "C:\\project\\java\\enlish_edu\\enlish\\enlish-service\\src\\test\\java\\com\\yinlihupo\\enlish\\service\\mapper\\八下.xlsx";
HashMap<String, Integer> map = new HashMap<>(); // HashMap<String, Integer> map = new HashMap<>();
int gradeId = 8; // int gradeId = 8;
try (FileInputStream fis = new FileInputStream(file); Workbook workbook = new XSSFWorkbook(fis)) { // try (FileInputStream fis = new FileInputStream(file); Workbook workbook = new XSSFWorkbook(fis)) {
//
Sheet sheet = workbook.getSheetAt(0); // Sheet sheet = workbook.getSheetAt(0);
//
for (int i = 1; i <= sheet.getLastRowNum(); i++) { // for (int i = 1; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i); // Row row = sheet.getRow(i);
if (row == null) { // if (row == null) {
continue; // continue;
} // }
//
//
// String word = row.getCell(0).getStringCellValue(); // String word = row.getCell(0).getStringCellValue();
// String pronunciation = row.getCell(1) != null ? row.getCell(1).getStringCellValue() : ""; // String pronunciation = row.getCell(1) != null ? row.getCell(1).getStringCellValue() : "";
// String pos = row.getCell(2) != null ? row.getCell(2).getStringCellValue() : ""; // String pos = row.getCell(2) != null ? row.getCell(2).getStringCellValue() : "";
// String meaning = row.getCell(3) != null ? row.getCell(3).getStringCellValue() : ""; // String meaning = row.getCell(3) != null ? row.getCell(3).getStringCellValue() : "";
// String gradeUnit = row.getCell(4) != null ? row.getCell(4).getStringCellValue() : ""; // String gradeUnit = row.getCell(4) != null ? row.getCell(4).getStringCellValue() : "";
//
String word = row.getCell(0).getStringCellValue(); // String word = row.getCell(0).getStringCellValue();
String meaning = row.getCell(1) != null ? row.getCell(1).getStringCellValue() : ""; // String meaning = row.getCell(1) != null ? row.getCell(1).getStringCellValue() : "";
String gradeUnit = row.getCell(2) != null ? row.getCell(2).getStringCellValue() : ""; // String gradeUnit = row.getCell(2) != null ? row.getCell(2).getStringCellValue() : "";
String pronunciation = ""; // String pronunciation = "";
String pos = ""; // String pos = "";
if (word.contains("Unit")) { // if (word.contains("Unit")) {
continue; // continue;
} // }
//
int gradeUnitId; // int gradeUnitId;
if (map.containsKey(gradeUnit)) { // if (map.containsKey(gradeUnit)) {
gradeUnitId = map.get(gradeUnit); // gradeUnitId = map.get(gradeUnit);
} else { // } else {
UnitDO unitDO = unitDOMapper.selectByTitle(gradeUnit); // UnitDO unitDO = unitDOMapper.selectByTitle(gradeUnit);
if (unitDO == null) { // if (unitDO == null) {
unitDO = UnitDO.builder() // unitDO = UnitDO.builder()
.title(gradeUnit) // .title(gradeUnit)
.version("人教版") // .version("人教版")
.createAt(LocalDateTime.now()) // .createAt(LocalDateTime.now())
.build(); // .build();
unitDOMapper.insert(unitDO); // unitDOMapper.insert(unitDO);
gradeUnitDOMapper.insert(GradeUnitDO.builder().unitId(unitDO.getId()).gradeId(gradeId).build()); // gradeUnitDOMapper.insert(GradeUnitDO.builder().unitId(unitDO.getId()).gradeId(gradeId).build());
gradeUnitId = unitDO.getId(); // gradeUnitId = unitDO.getId();
} else { // } else {
gradeUnitId = unitDO.getId(); // gradeUnitId = unitDO.getId();
} // }
//
map.put(gradeUnit, gradeUnitId); // map.put(gradeUnit, gradeUnitId);
} // }
VocabularyBankDO vocabularyBankDO = VocabularyBankDO.builder() // VocabularyBankDO vocabularyBankDO = VocabularyBankDO.builder()
.word(word) // .word(word)
.definition(meaning) // .definition(meaning)
.pronunciation(pronunciation) // .pronunciation(pronunciation)
.pos(pos) // .pos(pos)
.unitId(gradeUnitId) // .unitId(gradeUnitId)
.build(); // .build();
vocabularyBankMapper.insertSelective(vocabularyBankDO); // vocabularyBankMapper.insertSelective(vocabularyBankDO);
log.info("插入数据 {} ", vocabularyBankDO); // log.info("插入数据 {} ", vocabularyBankDO);
} // }
//
} catch (IOException e) { // } catch (IOException e) {
throw new RuntimeException(e); // throw new RuntimeException(e);
} // }
} }
} }

View File

@@ -22,7 +22,7 @@ public class WordMasteryLogInsertTest {
@Test @Test
void test() { void test() {
List<Integer> integers = vocabularyBankMapper.selectAllIds(); // List<Integer> integers = vocabularyBankMapper.selectAllIds();
wordMasteryLogDOMapper.batchInsertInitialization(integers, 1); // wordMasteryLogDOMapper.batchInsertInitialization(integers, 1);
} }
} }

View File

@@ -24,25 +24,25 @@ public class TestOmr {
public void testOmr(){ public void testOmr(){
OpenCV.loadLocally(); OpenCV.loadLocally();
List<Integer> knownIds = Arrays.asList(3184, 3185, 3186, 3187, 3188, 3189, 3190, 3191, 3192, 3193, // List<Integer> knownIds = Arrays.asList(3184, 3185, 3186, 3187, 3188, 3189, 3190, 3191, 3192, 3193,
3195, 3196, 3197, 3198, 3199, 3200, 3201, 3202, 3203, 3204, // 3195, 3196, 3197, 3198, 3199, 3200, 3201, 3202, 3203, 3204,
3206, 3208, 3209, 3210, 3211, 3212, 3507, 3508, 3509, 3510, // 3206, 3208, 3209, 3210, 3211, 3212, 3507, 3508, 3509, 3510,
3511, 3512, 3513, 3514, 3515, 3516, 3517, 3519, 3521, 3522, // 3511, 3512, 3513, 3514, 3515, 3516, 3517, 3519, 3521, 3522,
3523, 3524, 3525, 3526, 3527, 3528, 3529, 3530, 3531, 3532, // 3523, 3524, 3525, 3526, 3527, 3528, 3529, 3530, 3531, 3532,
3533, 3535, 3536, 3537, 3538, 3539); // 3533, 3535, 3536, 3537, 3538, 3539);
//
List<String> knowsIds = knownIds.stream().map(id -> id + "").toList(); // List<String> knowsIds = knownIds.stream().map(id -> id + "").toList();
String path = "C:\\project\\java\\enlish_edu\\enlish\\enlish-service\\src\\main\\resources\\templates\\p3.png"; // String path = "C:\\project\\java\\enlish_edu\\enlish\\enlish-service\\src\\main\\resources\\templates\\p3.png";
List<CoordinatesXY> coordinatesXIES = PngUtil.analysisXY(path); // List<CoordinatesXY> coordinatesXIES = PngUtil.analysisXY(path);
} }
@Test @Test
public void testInteger(){ public void testInteger(){
String filePath = "C:\\project\\java\\enlish_edu\\enlish\\enlish-service\\src\\main\\resources\\templates\\p3.png"; // String filePath = "C:\\project\\java\\enlish_edu\\enlish\\enlish-service\\src\\main\\resources\\templates\\p3.png";
List<CoordinatesXY> coordinatesXIES = PngUtil.analysisXY(filePath); // List<CoordinatesXY> coordinatesXIES = PngUtil.analysisXY(filePath);
StudentExamId studentExamId = PngUtil.analyzeExamWordsIdAndStudentId(filePath, tessdataPath, coordinatesXIES); // StudentExamId studentExamId = PngUtil.analyzeExamWordsIdAndStudentId(filePath, tessdataPath, coordinatesXIES);
log.info("studentExamId:{}",studentExamId); // log.info("studentExamId:{}",studentExamId);
} }
} }

View File

@@ -5,6 +5,7 @@ import com.deepoove.poi.config.Configure;
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy; import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
import com.yinlihupo.enlish.service.domain.dataobject.ExamWordsDO; import com.yinlihupo.enlish.service.domain.dataobject.ExamWordsDO;
import com.yinlihupo.enlish.service.domain.dataobject.VocabularyBankDO; import com.yinlihupo.enlish.service.domain.dataobject.VocabularyBankDO;
import com.yinlihupo.enlish.service.domain.mapper.ExamWordsJudgeResultDOMapper;
import com.yinlihupo.enlish.service.model.bo.Word; import com.yinlihupo.enlish.service.model.bo.Word;
import com.yinlihupo.enlish.service.service.ExamWordsService; import com.yinlihupo.enlish.service.service.ExamWordsService;
import com.yinlihupo.enlish.service.service.VocabularyService; import com.yinlihupo.enlish.service.service.VocabularyService;
@@ -24,38 +25,40 @@ public class ExamTest {
private ExamWordsService examWordsService; private ExamWordsService examWordsService;
@Resource @Resource
private VocabularyService vocabularyService; private VocabularyService vocabularyService;
@Resource
private ExamWordsJudgeResultDOMapper examWordsJudgeResultDOMapper;
@Test @Test
public void test() { public void test() {
ExamWordsDO examWordsDO = examWordsService.generateExamWords(5, 0); // ExamWordsDO examWordsDO = examWordsService.generateExamWords(5, 0);
log.info("{}", examWordsDO); // log.info("{}", examWordsDO);
List<VocabularyBankDO> vocabularyBankDOS = vocabularyService.findVocabularyBankDOListById(examWordsDO.getWordIds()); // List<VocabularyBankDO> vocabularyBankDOS = vocabularyService.findVocabularyBankDOListById(examWordsDO.getWordIds());
List<Word> assessmentWords = vocabularyBankDOS.stream().map(vocabularyBankDO -> Word.builder() // List<Word> assessmentWords = vocabularyBankDOS.stream().map(vocabularyBankDO -> Word.builder()
.id(vocabularyBankDO.getId()) // .id(vocabularyBankDO.getId())
.title(vocabularyBankDO.getWord()) // .title(vocabularyBankDO.getWord())
.definition(vocabularyBankDO.getDefinition()) // .definition(vocabularyBankDO.getDefinition())
.build()).toList(); // .build()).toList();
//
//
LoopRowTableRenderPolicy policy = new LoopRowTableRenderPolicy(); // LoopRowTableRenderPolicy policy = new LoopRowTableRenderPolicy();
Configure config = Configure.builder() // Configure config = Configure.builder()
.bind("words", policy) // .bind("words", policy)
.build(); // .build();
//
Map<String, Object> data = new HashMap<>(); // Map<String, Object> data = new HashMap<>();
data.put("examId", examWordsDO.getId()); // data.put("examId", examWordsDO.getId());
data.put("studentId", 1); // data.put("studentId", 1);
data.put("studentStr","小明三班一年级"); // data.put("studentStr","小明三班一年级");
data.put("examStr", examWordsDO.getTitle()); // data.put("examStr", examWordsDO.getTitle());
data.put("words", assessmentWords); // data.put("words", assessmentWords);
data.put("answer", assessmentWords); // data.put("answer", assessmentWords);
//
// 4. 渲染并输出 // // 4. 渲染并输出
try (XWPFTemplate template = XWPFTemplate.compile("C:\\project\\java\\enlish_edu\\enlish\\enlish-service\\src\\main\\resources\\templates\\assessment_v5.docx", config)) { // try (XWPFTemplate template = XWPFTemplate.compile("C:\\project\\java\\enlish_edu\\enlish\\enlish-service\\src\\main\\resources\\templates\\assessment_v5.docx", config)) {
template.render(data); // template.render(data);
template.write(new FileOutputStream("学生单词测试卷.docx")); // template.write(new FileOutputStream("学生单词测试卷.docx"));
System.out.println("文档生成成功!"); // System.out.println("文档生成成功!");
} catch (Exception e) { // } catch (Exception e) {
e.printStackTrace(); // e.printStackTrace();
} // }
} }
} }

View File

@@ -9,7 +9,7 @@ import com.yinlihupo.enlish.service.model.bo.CoordinatesXY;
import com.yinlihupo.enlish.service.model.bo.StudentExamId; import com.yinlihupo.enlish.service.model.bo.StudentExamId;
import com.yinlihupo.enlish.service.service.ExamWordsJudgeService; import com.yinlihupo.enlish.service.service.ExamWordsJudgeService;
import com.yinlihupo.enlish.service.service.StudentService; import com.yinlihupo.enlish.service.service.StudentService;
import com.yinlihupo.enlish.service.utils.DifyArticleClient; import com.yinlihupo.enlish.service.utils.DifyClient;
import com.yinlihupo.enlish.service.utils.PngUtil; import com.yinlihupo.enlish.service.utils.PngUtil;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -28,7 +28,7 @@ public class ExamWordsJudgeServiceTest {
@Resource @Resource
private StudentService studentService; private StudentService studentService;
@Resource @Resource
private DifyArticleClient difyArticleClient; private DifyClient difyClient;
@Resource @Resource
private ExamWordsDOMapper examWordsDOMapper; private ExamWordsDOMapper examWordsDOMapper;
@Resource @Resource
@@ -36,40 +36,45 @@ public class ExamWordsJudgeServiceTest {
@Test @Test
public void judgeExamWords() { public void judgeExamWords() {
String ansSheetPath = "C:\\project\\java\\enlish_edu\\enlish\\enlish-service\\src\\main\\resources\\templates\\3.png"; // String ansSheetPath = "C:\\project\\java\\enlish_edu\\enlish\\enlish-service\\src\\main\\resources\\templates\\3.png";
List<CoordinatesXY> coordinatesXIES = PngUtil.analysisXY(ansSheetPath); // List<CoordinatesXY> coordinatesXIES = PngUtil.analysisXY(ansSheetPath);
// 从图片中获取学生 id 和考试 id // // 从图片中获取学生 id 和考试 id
String tessdataPath = "C:\\project\\tess"; // String tessdataPath = "C:\\project\\tess";
StudentExamId studentExamId = PngUtil.analyzeExamWordsIdAndStudentId(ansSheetPath, tessdataPath, coordinatesXIES); // StudentExamId studentExamId = PngUtil.analyzeExamWordsIdAndStudentId(ansSheetPath, tessdataPath, coordinatesXIES);
Integer examWordsId = 41; // Integer examWordsId = 41;
//
ExamWordsDO examWordsDO = examWordsDOMapper.selectById(examWordsId); // ExamWordsDO examWordsDO = examWordsDOMapper.selectById(examWordsId);
//
List<Integer> wordIds = examWordsDO.getWordIds(); // List<Integer> wordIds = examWordsDO.getWordIds();
List<Integer> unmemorizedWordIds = PngUtil.analyzePngForUnmemorizedWordIds(ansSheetPath, wordIds, coordinatesXIES); // List<Integer> unmemorizedWordIds = PngUtil.analyzePngForUnmemorizedWordIds(ansSheetPath, wordIds, coordinatesXIES);
List<Integer> memorizedWordIds = wordIds.stream().filter(wordId -> !unmemorizedWordIds.contains(wordId)).toList(); // List<Integer> memorizedWordIds = wordIds.stream().filter(wordId -> !unmemorizedWordIds.contains(wordId)).toList();
List<VocabularyBankDO> vocabularyBankDOS = vocabularyBankDOMapper.selectVocabularyBankDOListByIds(unmemorizedWordIds); // List<VocabularyBankDO> vocabularyBankDOS = vocabularyBankDOMapper.selectVocabularyBankDOListByIds(unmemorizedWordIds);
for (VocabularyBankDO vocabularyBankDO : vocabularyBankDOS) { // for (VocabularyBankDO vocabularyBankDO : vocabularyBankDOS) {
log.info("未掌握的单词:{}", vocabularyBankDO); // log.info("未掌握的单词:{}", vocabularyBankDO);
// }
} }
@Test
public void judege() {
examWordsJudgeService.judgeExamWords(5);
} }
@Test @Test
public void selectExamWordsJudgeResult() { public void selectExamWordsJudgeResult() {
List<ExamWordsJudgeResultDO> examWordsJudgeResult = examWordsJudgeService.getExamWordsJudgeResult(1, 10); // List<ExamWordsJudgeResultDO> examWordsJudgeResult = examWordsJudgeService.getExamWordsJudgeResult(1, 10);
log.info("examWordsJudgeResult:{}", examWordsJudgeResult); // log.info("examWordsJudgeResult:{}", examWordsJudgeResult);
} }
@Test // @Test
public void selectExamWordsJudgeResult2() { // public void selectExamWordsJudgeResult2() {
String s = studentService.analyzeStudentStudy(1); // String s = studentService.analyzeStudentStudy(1);
try { // try {
DifyArticleClient.DifyResponse difyResponse = difyArticleClient.sendStudentAnalyze(s); // DifyClient.DifyResponse difyResponse = difyClient.sendStudentAnalyze(s);
String answer = difyResponse.getAnswer(); // String answer = difyResponse.getAnswer();
log.info("answer:{}", answer); // log.info("answer:{}", answer);
} catch (Exception e) { // } catch (Exception e) {
throw new RuntimeException(e); // throw new RuntimeException(e);
} // }
log.info("s:{}", s); // log.info("s:{}", s);
} // }
} }

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>enlish-vue</title> <title>enlish-vue</title>
</head> </head>
<body> <body>

View File

@@ -1 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> <svg width="40" height="40" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M5 6C5 4.89543 5.89543 4 7 4H12C13.6569 4 15 5.34315 15 7V17C15 18.6569 13.6569 20 12 20H7C5.89543 20 5 19.1046 5 18V6ZM7 6V10H12C12.5523 10 13 9.55228 13 9C13 8.44772 12.5523 8 12 8H8.5C7.67157 8 7 7.32843 7 6.5V6ZM7 18L7 14H10C10.5523 14 11 13.5523 11 13C11 12.4477 10.5523 12 10 12H7V17.5C7 17.7761 7.22386 18 7.5 18H12C12.5523 18 13 17.5523 13 17V17C13 16.4477 12.5523 16 12 16H8.5C7.67157 16 7 16.6716 7 17.5V18Z"
fill="#0056D2" />
<circle cx="17.5" cy="6.5" r="2.5" fill="#FFAB00" />
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 865 B

View File

@@ -1,16 +1,24 @@
<template> <template>
<router-view></router-view> <el-config-provider :locale="locale">
<router-view />
<el-drawer v-model="mobileSidebarOpen" class="md:hidden" title="菜单" size="260px">
<Sidebar />
</el-drawer>
</el-config-provider>
</template> </template>
<script setup> <script setup>
import zhCn from 'element-plus/es/locale/lang/zh-cn'
const locale = zhCn
import Sidebar from '@/layouts/components/Sidebar.vue'
import { mobileSidebarOpen } from '@/composables/ui.js'
</script> </script>
<style> <style>
/* 自定义顶部加载 Loading 颜色 */ /* 自定义顶部加载 Loading 颜色 */
#nprogress .bar { #nprogress .bar {
background: #409eff !important; background: #2563eb !important;
} }
</style> </style>

View File

@@ -8,3 +8,6 @@ export function createUser(data) {
return axios.post('/admin/user/create', data) return axios.post('/admin/user/create', data)
} }
export function createInvitationCode(data) {
return axios.post('/admin/user/create/invitation/code', data)
}

View File

@@ -5,10 +5,13 @@ export function uploadExamWordsPng(data) {
return axios.post('/exam/words/submit', data) return axios.post('/exam/words/submit', data)
} }
export function getExamWordsResult(page, size) { export function getExamWordsResult(page, size, classId, gradeId, studentName) {
return axios.post('/exam/words/get', { return axios.post('/exam/words/get', {
page: page, page: page,
size: size size: size,
classId: classId,
gradeId: gradeId,
studentName: studentName
}) })
} }

View File

@@ -51,6 +51,18 @@ export function getLessonPlanWords(planId) {
}) })
} }
export function checkIsGenerated(studentId) {
return axios.post('plan/check', {
studentId: studentId
})
}
export function getPlanListByStudentId(studentId) {
return axios.post('plan/student/list', {
studentId: studentId
})
}
const resolveBlob = (res, fileName) => { const resolveBlob = (res, fileName) => {
// 创建 Blob 对象,可以指定 type也可以让浏览器自动推断 // 创建 Blob 对象,可以指定 type也可以让浏览器自动推断
const blob = new Blob([res], { type: 'application/octet-stream' }); const blob = new Blob([res], { type: 'application/octet-stream' });

View File

@@ -15,3 +15,7 @@ export function getVerificationCode(data) {
export function getUserInfo() { export function getUserInfo() {
return axios.post("/user/info") return axios.post("/user/info")
} }
export function updateUserInfo(data) {
return axios.post("/user/update-user-info", data)
}

View File

@@ -1,3 +1,76 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer components {
.panel-shell {
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(8px);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.dark .panel-shell {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(55, 65, 81, 0.4);
}
.sidebar-fixed {
width: 220px;
min-width: 220px;
flex: 0 0 220px;
box-sizing: border-box;
}
}
:root {
--el-color-primary: #2563eb;
--el-border-radius-base: 10px;
--el-border-radius-small: 8px;
--el-border-radius-round: 9999px;
}
html, body, #app {
min-height: 100%;
background: radial-gradient(1200px at 10% 10%, #e0f2fe 0%, transparent 40%),
radial-gradient(1200px at 90% 10%, #dbeafe 0%, transparent 40%),
linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
}
.dark html, .dark body, .dark #app {
background: radial-gradient(1000px at 10% 10%, rgba(30,58,138,0.35) 0%, transparent 40%),
radial-gradient(1000px at 90% 10%, rgba(2,132,199,0.3) 0%, transparent 40%),
linear-gradient(180deg, #0f172a 0%, #111827 100%);
}
.safe-area {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.media-fluid img,
.media-fluid video {
max-width: 100%;
height: auto;
display: block;
}
.touch-target {
min-height: 44px;
min-width: 44px;
}
@media (max-width: 768px) {
.sidebar-fixed {
display: none;
width: 0;
min-width: 0;
flex: 0 0 auto;
}
.panel-shell {
border-radius: 0.5rem;
}
}

View File

@@ -0,0 +1,12 @@
import { ref } from 'vue'
export const mobileSidebarOpen = ref(false)
export function openMobileSidebar() {
mobileSidebarOpen.value = true
}
export function closeMobileSidebar() {
mobileSidebarOpen.value = false
}

View File

@@ -1,28 +1,28 @@
<template> <template>
<el-dialog v-model="visible" title="新增班级" width="480px" :close-on-click-modal="false"> <el-dialog v-model="visible" title="新增班级" width="480px" :fullscreen="isMobile" :close-on-click-modal="false">
<div class="space-y-4" v-loading="loading"> <div class="space-y-4" v-loading="loading">
<el-form label-width="80px"> <el-form :label-width="isMobile ? 0 : 80" :label-position="isMobile ? 'top' : 'right'">
<el-form-item label="班级名称"> <el-form-item label="班级名称">
<el-input v-model="name" placeholder="请输入班级名称,如:二班" clearable /> <el-input v-model="name" placeholder="请输入班级名称,如:二班" clearable class="w-full" />
</el-form-item> </el-form-item>
<el-form-item label="年级"> <el-form-item label="年级">
<el-select v-model="gradeId" placeholder="请选择年级" style="width: 260px"> <el-select v-model="gradeId" placeholder="请选择年级" class="w-full sm:w-[260px]">
<el-option v-for="g in gradeOptions" :key="g.id" :label="g.title" :value="g.id" /> <el-option v-for="g in gradeOptions" :key="g.id" :label="g.title" :value="g.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div> </div>
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="footer-actions flex sm:justify-end gap-2 sm:flex-row flex-col">
<el-button @click="visible = false">取消</el-button> <el-button class="w-full sm:w-auto touch-target" @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!canSubmit" @click="handleSubmit">确定</el-button> <el-button class="w-full sm:w-auto touch-target" type="primary" :disabled="!canSubmit" @click="handleSubmit">确定</el-button>
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { getGradeList } from '@/api/grade' import { getGradeList } from '@/api/grade'
import { addClass } from '@/api/class' import { addClass } from '@/api/class'
@@ -41,6 +41,7 @@ const loading = ref(false)
const name = ref('') const name = ref('')
const gradeId = ref(null) const gradeId = ref(null)
const gradeOptions = ref([]) const gradeOptions = ref([])
const isMobile = ref(false)
const canSubmit = computed(() => name.value.trim().length > 0 && !!gradeId.value) const canSubmit = computed(() => name.value.trim().length > 0 && !!gradeId.value)
@@ -71,6 +72,10 @@ async function handleSubmit() {
} }
} }
function updateIsMobile() {
isMobile.value = window.matchMedia('(max-width: 768px)').matches
}
watch( watch(
() => props.modelValue, () => props.modelValue,
(v) => { (v) => {
@@ -78,9 +83,22 @@ watch(
name.value = '' name.value = ''
gradeId.value = props.defaultGradeId ? Number(props.defaultGradeId) : null gradeId.value = props.defaultGradeId ? Number(props.defaultGradeId) : null
fetchGrades() fetchGrades()
updateIsMobile()
} }
} }
) )
onMounted(() => {
updateIsMobile()
window.addEventListener('resize', updateIsMobile)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateIsMobile)
})
</script> </script>
<style scoped></style> <style scoped>
.footer-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
</style>

View File

@@ -1,19 +1,19 @@
<template> <template>
<el-dialog v-model="visible" title="新增年级" width="420px" :close-on-click-modal="false"> <el-dialog v-model="visible" title="新增年级" width="420px" :fullscreen="isMobile" :close-on-click-modal="false">
<div class="space-y-4" v-loading="loading"> <div class="space-y-4" v-loading="loading">
<el-input v-model="name" placeholder="请输入年级名称,如:一年级" clearable /> <el-input v-model="name" placeholder="请输入年级名称,如:一年级" clearable class="w-full" />
</div> </div>
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="footer-actions flex sm:justify-end gap-2 sm:flex-row flex-col">
<el-button @click="visible = false">取消</el-button> <el-button class="w-full sm:w-auto touch-target" @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!canSubmit" @click="handleSubmit">确定</el-button> <el-button class="w-full sm:w-auto touch-target" type="primary" :disabled="!canSubmit" @click="handleSubmit">确定</el-button>
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { addGrade } from '@/api/grade' import { addGrade } from '@/api/grade'
const props = defineProps({ const props = defineProps({
@@ -29,6 +29,7 @@ const visible = computed({
const loading = ref(false) const loading = ref(false)
const name = ref('') const name = ref('')
const canSubmit = computed(() => name.value.trim().length > 0) const canSubmit = computed(() => name.value.trim().length > 0)
const isMobile = ref(false)
async function handleSubmit() { async function handleSubmit() {
if (!canSubmit.value) return if (!canSubmit.value) return
@@ -43,12 +44,29 @@ async function handleSubmit() {
} }
} }
function updateIsMobile() {
isMobile.value = window.matchMedia('(max-width: 768px)').matches
}
watch( watch(
() => props.modelValue, () => props.modelValue,
(v) => { (v) => {
if (v) name.value = '' if (v) name.value = ''
updateIsMobile()
} }
) )
onMounted(() => {
updateIsMobile()
window.addEventListener('resize', updateIsMobile)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateIsMobile)
})
</script> </script>
<style scoped></style> <style scoped>
.footer-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
</style>

View File

@@ -1,17 +1,17 @@
<template> <template>
<el-dialog v-model="visible" title="新增学生" width="560px" :close-on-click-modal="false"> <el-dialog v-model="visible" title="新增学生" width="560px" :fullscreen="isMobile" :close-on-click-modal="false" class="responsive-dialog">
<div class="space-y-4" v-loading="loading"> <div class="space-y-4" v-loading="loading">
<el-form label-width="90px"> <el-form :label-width="isMobile ? 0 : 90" :label-position="isMobile ? 'top' : 'right'">
<el-form-item label="姓名"> <el-form-item label="姓名">
<el-input v-model="name" placeholder="请输入学生姓名" clearable /> <el-input v-model="name" placeholder="请输入学生姓名" clearable class="w-full" />
</el-form-item> </el-form-item>
<el-form-item label="年级"> <el-form-item label="年级">
<el-select v-model="gradeId" placeholder="请选择年级" style="width: 260px" @change="handleGradeChange"> <el-select v-model="gradeId" placeholder="请选择年级" class="w-full sm:w-[260px]" @change="handleGradeChange">
<el-option v-for="g in gradeOptions" :key="g.id" :label="g.title" :value="g.id" /> <el-option v-for="g in gradeOptions" :key="g.id" :label="g.title" :value="g.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="班级"> <el-form-item label="班级">
<el-select v-model="classId" placeholder="请选择班级" style="width: 260px"> <el-select v-model="classId" placeholder="请选择班级" class="w-full sm:w-[260px]">
<el-option v-for="c in filteredClassOptions" :key="c.id" :label="c.title" :value="c.id" /> <el-option v-for="c in filteredClassOptions" :key="c.id" :label="c.title" :value="c.id" />
</el-select> </el-select>
<div v-if="gradeId && filteredClassOptions.length === 0" class="mt-2 flex items-center gap-2"> <div v-if="gradeId && filteredClassOptions.length === 0" class="mt-2 flex items-center gap-2">
@@ -20,15 +20,15 @@
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="入学时间"> <el-form-item label="入学时间">
<el-date-picker v-model="startDate" type="datetime" placeholder="选择日期时间" <el-date-picker v-model="startDate" type="datetime" placeholder="选择日期时间" class="w-full"
format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" /> format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" />
</el-form-item> </el-form-item>
</el-form> </el-form>
</div> </div>
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="footer-actions flex sm:justify-end gap-2 sm:flex-row flex-col">
<el-button @click="visible = false">取消</el-button> <el-button class="w-full sm:w-auto touch-target" @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!canSubmit" @click="handleSubmit">确定</el-button> <el-button class="w-full sm:w-auto touch-target" type="primary" :disabled="!canSubmit" @click="handleSubmit">确定</el-button>
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
@@ -36,7 +36,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { getGradeList } from '@/api/grade' import { getGradeList } from '@/api/grade'
import { getClassList } from '@/api/class' import { getClassList } from '@/api/class'
import { addStudent } from '@/api/student' import { addStudent } from '@/api/student'
@@ -61,6 +61,7 @@ const classId = ref(null)
const startDate = ref('') const startDate = ref('')
const gradeOptions = ref([]) const gradeOptions = ref([])
const classOptions = ref([]) const classOptions = ref([])
const isMobile = ref(false)
const filteredClassOptions = computed(() => { const filteredClassOptions = computed(() => {
if (!gradeId.value) return [] if (!gradeId.value) return []
@@ -116,6 +117,10 @@ async function handleSubmit() {
} }
} }
function updateIsMobile() {
isMobile.value = window.matchMedia('(max-width: 768px)').matches
}
watch( watch(
() => props.modelValue, () => props.modelValue,
async (v) => { async (v) => {
@@ -126,9 +131,26 @@ watch(
startDate.value = '' startDate.value = ''
await fetchBaseOptions() await fetchBaseOptions()
if (gradeId.value) handleGradeChange() if (gradeId.value) handleGradeChange()
updateIsMobile()
} }
} }
) )
onMounted(() => {
updateIsMobile()
window.addEventListener('resize', updateIsMobile)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateIsMobile)
})
</script> </script>
<style scoped></style> <style scoped>
.footer-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.responsive-dialog :deep(.el-dialog__body) {
max-height: 70vh;
overflow-y: auto;
}
</style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<el-dialog v-model="visible" title="词条记录详情" width="820px" :close-on-click-modal="false"> <el-dialog v-model="visible" title="词条记录详情" width="820px" :fullscreen="isMobile" :close-on-click-modal="false" class="responsive-dialog">
<div class="space-y-4" v-loading="loading"> <div class="space-y-4" v-loading="loading">
<el-card shadow="hover"> <el-card shadow="hover">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -79,7 +79,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { getExamWordsDetailResult } from '@/api/exam' import { getExamWordsDetailResult } from '@/api/exam'
import { getStudentDetail } from '@/api/student' import { getStudentDetail } from '@/api/student'
import { getWordsListByIds } from '@/api/words' import { getWordsListByIds } from '@/api/words'
@@ -101,6 +101,7 @@ const activeNames = ref(['correct', 'wrong'])
const correctTitles = ref([]) const correctTitles = ref([])
const wrongTitles = ref([]) const wrongTitles = ref([])
const studentDetail = ref(null) const studentDetail = ref(null)
const isMobile = ref(false)
async function fetchDetail() { async function fetchDetail() {
if (!props.id && props.id !== 0) return if (!props.id && props.id !== 0) return
@@ -149,10 +150,17 @@ async function fetchStudent() {
studentDetail.value = res?.data?.data ?? null studentDetail.value = res?.data?.data ?? null
} }
function updateIsMobile() {
isMobile.value = window.matchMedia('(max-width: 768px)').matches
}
watch( watch(
() => props.modelValue, () => props.modelValue,
(v) => { (v) => {
if (v) fetchDetail() if (v) {
updateIsMobile()
fetchDetail()
}
} }
) )
watch( watch(
@@ -161,6 +169,19 @@ watch(
if (visible.value && v !== undefined && v !== null) fetchDetail() if (visible.value && v !== undefined && v !== null) fetchDetail()
} }
) )
onMounted(() => {
updateIsMobile()
window.addEventListener('resize', updateIsMobile)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateIsMobile)
})
</script> </script>
<style scoped></style> <style scoped>
.responsive-dialog :deep(.el-dialog__body) {
max-height: 70vh;
overflow-y: auto;
}
</style>

View File

@@ -1,50 +1,48 @@
<template> <template>
<header> <header>
<nav class="bg-white border-gray-200 px-4 lg:px-6 py-2.5 dark:bg-gray-800"> <div class="p-2">
<div class="flex flex-wrap justify-between items-center mx-auto max-w-screen-xl"> <div class="panel-shell">
<nav class="fluent-nav px-4 lg:px-6 py-2.5">
<div class="flex flex-wrap justify-between items-center">
<a href="#" class="flex items-center"> <a href="#" class="flex items-center">
<img src="https://flowbite.com/docs/images/logo.svg" class="mr-3 h-6 sm:h-9" alt="Flowbite Logo" /> <svg width="40" height="40" viewBox="0 0 24 24" fill="none"
<span class="self-center text-xl font-semibold whitespace-nowrap dark:text-white">Flowbite</span> xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M5 6C5 4.89543 5.89543 4 7 4H12C13.6569 4 15 5.34315 15 7V17C15 18.6569 13.6569 20 12 20H7C5.89543 20 5 19.1046 5 18V6ZM7 6V10H12C12.5523 10 13 9.55228 13 9C13 8.44772 12.5523 8 12 8H8.5C7.67157 8 7 7.32843 7 6.5V6ZM7 18L7 14H10C10.5523 14 11 13.5523 11 13C11 12.4477 10.5523 12 10 12H7V17.5C7 17.7761 7.22386 18 7.5 18H12C12.5523 18 13 17.5523 13 17V17C13 16.4477 12.5523 16 12 16H8.5C7.67157 16 7 16.6716 7 17.5V18Z"
fill="#0056D2" />
<circle cx="17.5" cy="6.5" r="2.5" fill="#FFAB00" />
</svg>
<span class="self-center text-xl font-semibold whitespace-nowrap">英语教育</span>
</a> </a>
<div class="flex items-center lg:order-2"> <div class="flex items-center lg:order-2">
<template v-if="userName"> <template v-if="userName">
<div class="relative" ref="menuRef"> <div class="relative" ref="menuRef">
<button <button @click="menuOpen = !menuOpen"
@click="menuOpen = !menuOpen" class="fluent-btn font-medium rounded-lg text-sm px-4 lg:px-5 py-2 lg:py-2.5 mr-2 flex items-center">
class="text-gray-800 dark:text-white font-medium rounded-lg text-sm px-4 lg:px-5 py-2 lg:py-2.5 mr-2 flex items-center">
<span class="mr-2">{{ userName }}</span> <span class="mr-2">{{ userName }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7" />
</svg> </svg>
</button> </button>
<div <div v-if="menuOpen" class="fluent-card absolute right-0 mt-2 z-50">
v-if="menuOpen" <router-link to="/admid" @click="menuOpen = false"
class="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded shadow z-50"> class="block px-4 py-2 fluent-link">
<router-link
to="/admid"
@click="menuOpen = false"
class="block px-4 py-2 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600">
后台 后台
</router-link> </router-link>
<button <button @click="handleLogout"
@click="handleLogout" class="w-full text-left block px-4 py-2 fluent-link">
class="w-full text-left block px-4 py-2 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600">
登出 登出
</button> </button>
</div> </div>
</div> </div>
</template> </template>
<template v-else> <button data-collapse-toggle="mobile-menu-2" type="button" @click="openMobileSidebar()"
<a href="#" @click.prevent="showLogin = true" class="inline-flex items-center p-2 ml-1 text-sm rounded-lg lg:hidden fluent-btn"
class="text-gray-800 dark:text-white hover:bg-gray-50 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-4 lg:px-5 py-2 lg:py-2.5 mr-2 dark:hover:bg-gray-700 focus:outline-none dark:focus:ring-gray-800">
Login
</a>
</template>
<button data-collapse-toggle="mobile-menu-2" type="button"
class="inline-flex items-center p-2 ml-1 text-sm text-gray-500 rounded-lg lg:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="mobile-menu-2" aria-expanded="false"> aria-controls="mobile-menu-2" aria-expanded="false">
<span class="sr-only">Open main menu</span> <span class="sr-only">Open main menu</span>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"> <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" <path fill-rule="evenodd"
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clip-rule="evenodd"></path> clip-rule="evenodd"></path>
@@ -57,35 +55,14 @@
</svg> </svg>
</button> </button>
</div> </div>
<div class="hidden justify-between items-center w-full lg:flex lg:w-auto lg:order-1" id="mobile-menu-2"> <div class="hidden justify-between items-center w-full lg:flex lg:w-auto lg:order-1"
<ul class="flex flex-col mt-4 font-medium lg:flex-row lg:space-x-8 lg:mt-0"> id="mobile-menu-2">
<li>
<a href="#"
class="block py-2 pr-4 pl-3 text-white rounded bg-primary-700 lg:bg-transparent lg:text-primary-700 lg:p-0 dark:text-white"
aria-current="page">Home</a>
</li>
<li>
<router-link to="/"
class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-primary-700 lg:p-0 dark:text-gray-400 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700">
班级
</router-link>
</li>
<li>
<router-link to="/learningplan"
class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-primary-700 lg:p-0 dark:text-gray-400 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700">
学案
</router-link>
</li>
<li>
<router-link to="/uploadpng"
class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-primary-700 lg:p-0 dark:text-gray-400 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700">
上传图片
</router-link>
</li>
</ul>
</div> </div>
</div> </div>
</nav> </nav>
</div>
</div>
<LoginDialog v-model="showLogin" @success="refreshUser" /> <LoginDialog v-model="showLogin" @success="refreshUser" />
</header> </header>
</template> </template>
@@ -97,6 +74,7 @@ import { getUserInfo, logout } from '@/api/user'
import { removeToken } from '@/composables/auth' import { removeToken } from '@/composables/auth'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showMessage } from '@/composables/util.js' import { showMessage } from '@/composables/util.js'
import { openMobileSidebar } from '@/composables/ui.js'
const showLogin = ref(false) const showLogin = ref(false)
const userName = ref('') const userName = ref('')
const menuOpen = ref(false) const menuOpen = ref(false)
@@ -106,9 +84,14 @@ async function refreshUser() {
try { try {
const r = await getUserInfo() const r = await getUserInfo()
const d = r?.data const d = r?.data
userName.value = d?.success ? (d?.data?.name || '') : '' console.log("header" + d.success)
if (d?.success) {
userName.value = d?.data?.name || ''
} else {
handleLogout()
}
} catch { } catch {
userName.value = '' handleLogout()
} }
} }
async function handleLogout() { async function handleLogout() {
@@ -119,7 +102,7 @@ async function handleLogout() {
userName.value = '' userName.value = ''
menuOpen.value = false menuOpen.value = false
showMessage('已退出登录', 'success') showMessage('已退出登录', 'success')
router.push('/') router.push('/login')
} }
} }
function onDocClick(e) { function onDocClick(e) {
@@ -137,3 +120,83 @@ onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick) document.removeEventListener('click', onDocClick)
}) })
</script> </script>
<style scoped>
.fluent-nav {
background: transparent;
border-bottom: 0;
backdrop-filter: none;
min-height: 56px;
}
:global(.dark) .fluent-nav {
background: transparent;
border-bottom: 0;
}
.fluent-card {
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
backdrop-filter: blur(16px);
transition: box-shadow 200ms ease, transform 200ms ease;
}
:global(.dark) .fluent-card {
background: rgba(55, 65, 81, 0.4);
border-color: rgba(148, 163, 184, 0.25);
}
.fluent-card:hover {
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
transform: translateY(-1px);
}
.fluent-btn {
color: #0f172a;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 10px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
transition: background 200ms ease, box-shadow 200ms ease, transform 200ms ease;
}
.fluent-btn:hover {
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
}
:global(.dark) .fluent-btn {
color: #e5e7eb;
background: rgba(55, 65, 81, 0.4);
border-color: rgba(148, 163, 184, 0.25);
}
.fluent-link {
color: #2563eb;
border-radius: 10px;
transition: color 200ms ease, background 200ms ease, box-shadow 200ms ease;
}
.fluent-link:hover {
color: #1d4ed8;
background: rgba(255, 255, 255, 0.35);
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.25);
}
:global(.dark) .fluent-link:hover {
background: rgba(55, 65, 81, 0.35);
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.25);
}
.fluent-card {
overflow: visible;
}
:global(.el-header) {
overflow: visible;
padding: 0;
position: relative;
z-index: 1000;
}
</style>

View File

@@ -39,7 +39,7 @@ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
studentId: { type: [Number, String], required: true } studentId: { type: [Number, String], required: true }
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue', 'success'])
const visible = computed({ const visible = computed({
get: () => props.modelValue, get: () => props.modelValue,
@@ -71,6 +71,7 @@ async function handleGenerate() {
const d = res?.data const d = res?.data
if (d.success) { if (d.success) {
ElMessage.success('生成学案任务已提交,请等待十分钟') ElMessage.success('生成学案任务已提交,请等待十分钟')
emit('success', { studentId: Number(props.studentId) })
visible.value = false visible.value = false
} else { } else {
showMessage(d.message || '生成学案失败,请联系管理员', 'error') showMessage(d.message || '生成学案失败,请联系管理员', 'error')

View File

@@ -0,0 +1,50 @@
<template>
<div class="h-full p-2">
<div class="h-full panel-shell">
<el-menu router :default-active="activePath" class="h-full rounded-xl bg-transparent" :collapse="false">
<el-menu-item index="/">
<span>班级列表</span>
</el-menu-item>
<el-menu-item index="/learningplan">
<span>学案</span>
</el-menu-item>
<el-menu-item index="/uploadpng">
<span>上传图片</span>
</el-menu-item>
</el-menu>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import router from '@/router'
const route = useRoute()
const activePath = computed(() => route.path)
const allRoutes = router.getRoutes()
const menuItems = computed(() => {
return allRoutes
.filter(r => r.meta && r.meta.title)
.filter(r => !r.path.includes(':'))
.filter(r => r.path !== '/login')
.map(r => ({ path: r.path, title: r.meta.title }))
.sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'))
})
</script>
<style scoped>
/* Fluent 2 气质的轻盈质感:柔和玻璃、圆角、细描边 */
.el-menu {
--el-menu-bg-color: transparent;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.35);
--el-menu-active-color: #2563eb;
}
.el-menu-item {
border-radius: 10px;
transition: all 0.2s ease;
}
.el-menu-item:hover {
backdrop-filter: saturate(1.2);
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<el-dialog v-model="visible" title="学生学案列表" width="680px" :close-on-click-modal="false">
<div v-loading="loading">
<el-table :data="plans" border>
<el-table-column prop="title" label="标题" min-width="360" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.isFinished === 1 ? 'success' : 'info'" effect="plain">
{{ row.isFinished === 1 ? '已完成' : '未完成' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
:loading="downloadingIds.includes(row.id)"
@click="handleDownload(row)"
>下载</el-button>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<el-button @click="visible = false">关闭</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getPlanListByStudentId, downloadLessonPlan } from '@/api/plan'
import { showMessage } from '../../composables/util'
const props = defineProps({
modelValue: { type: Boolean, default: false },
studentId: { type: [Number, String], required: true }
})
const emit = defineEmits(['update:modelValue'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const loading = ref(false)
const plans = ref([])
const downloadingIds = ref([])
async function fetchPlans() {
if (!props.studentId) return
loading.value = true
try {
const res = await getPlanListByStudentId(Number(props.studentId))
const d = res?.data
if (d?.success !== false) {
const items = d?.data?.lessonPlanItems
plans.value = Array.isArray(items) ? items : []
} else {
showMessage(d?.message || '获取学案失败', 'error')
}
} finally {
loading.value = false
}
}
async function handleDownload(row) {
if (!row?.id) {
showMessage('无效的计划ID', 'error')
return
}
if (!downloadingIds.value.includes(row.id)) {
downloadingIds.value = [...downloadingIds.value, row.id]
}
try {
await downloadLessonPlan({ id: row.id })
showMessage('开始下载', 'success')
} finally {
downloadingIds.value = downloadingIds.value.filter(id => id !== row.id)
}
}
watch(
() => props.modelValue,
(v) => {
if (v) {
fetchPlans()
}
}
)
</script>
<style scoped></style>

View File

@@ -25,7 +25,7 @@ function sortData(arr) {
function toSource(arr) { function toSource(arr) {
return sortData(arr).map(it => ({ return sortData(arr).map(it => ({
startTime: it.startTime, startTime: it.startTime.replace('T', ' '),
totalCount: Number(it.totalCount) || 0, totalCount: Number(it.totalCount) || 0,
planId: it.planId ?? null, planId: it.planId ?? null,
id: it.id ?? null id: it.id ?? null

View File

@@ -0,0 +1,79 @@
<template>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex items-center justify-between mb-3">
<div class="text-md font-semibold">学习分析</div>
<el-button type="primary" size="small" :loading="analyzeLoading" @click="fetchStudyAnalyze">
生成学习分析
</el-button>
</div>
<template v-if="analyzeLoading">
<div class="space-y-2">
<el-progress :percentage="analyzeProgress" :stroke-width="10" />
<div class="text-sm text-gray-500 dark:text-gray-400">正在生成学习分析请稍候</div>
</div>
</template>
<template v-else-if="analysisHtml">
<div class="leading-7 text-gray-700 dark:text-gray-200" v-html="analysisHtml"></div>
</template>
<template v-else>
<el-empty description="点击右上按钮生成学习分析" />
</template>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import MarkdownIt from 'markdown-it'
import { getStudentStudyAnalyze } from '@/api/student'
const props = defineProps({
studentId: {
type: [String, Number],
required: true
}
})
const analyzeLoading = ref(false)
const analyzeProgress = ref(0)
let analyzeTimer = null
const analysisText = ref('')
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true
})
const analysisHtml = computed(() => {
return analysisText.value ? md.render(analysisText.value) : ''
})
async function fetchStudyAnalyze() {
const id = props.studentId
if (!id) return
analyzeLoading.value = true
analyzeProgress.value = 0
if (analyzeTimer) {
clearInterval(analyzeTimer)
analyzeTimer = null
}
analyzeTimer = setInterval(() => {
const inc = Math.floor(Math.random() * 8) + 3
const next = analyzeProgress.value + inc
analyzeProgress.value = next >= 90 ? 90 : next
}, 300)
try {
const res = await getStudentStudyAnalyze({
studentId: Number(id)
})
const d = res.data
const raw = typeof d?.data === 'string' ? d.data : ''
analysisText.value = raw.replace(/\\n/g, '\n')
} finally {
analyzeProgress.value = 100
if (analyzeTimer) {
clearInterval(analyzeTimer)
analyzeTimer = null
}
analyzeLoading.value = false
}
}
</script>

View File

@@ -1,5 +1,6 @@
import '@/assets/main.css' import '@/assets/main.css'
import 'nprogress/nprogress.css' import 'nprogress/nprogress.css'
import 'element-plus/dist/index.css'
// 导入路由 // 导入路由
import router from '@/router' import router from '@/router'
// 导入全局路由守卫 // 导入全局路由守卫

View File

@@ -1,60 +1,95 @@
<template> <template>
<div class="common-layout"> <div class="common-layout">
<el-container> <el-container class="min-h-screen">
<el-header> <el-header>
<Header></Header> <Header></Header>
</el-header> </el-header>
<el-main class="p-4"> <el-container class="pt-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<el-aside width="200px" class="hidden md:block sidebar-fixed">
<Sidebar />
</el-aside>
<el-main class="">
<div class="panel-shell p-6">
<div class="text-lg font-semibold mb-4">学案查询</div> <div class="text-lg font-semibold mb-4">学案查询</div>
<div class="flex flex-wrap items-center gap-3 mb-4"> <div class="flex flex-wrap items-center gap-3 mb-4">
<el-input v-model="searchName" placeholder="按姓名查询" clearable style="max-width: 220px" /> <el-input v-model="searchName" placeholder="按姓名查询" clearable style="max-width: 220px" />
<el-button type="primary" @click="onSearch">查询</el-button> <el-button type="primary" @click="onSearch">查询</el-button>
<el-button @click="onReset">重置</el-button> <el-button @click="onReset">重置</el-button>
</div> </div>
<el-table ref="tableRef" :data="rows" border class="w-full" v-loading="loading" row-key="id"> <div class="hidden sm:block overflow-x-auto">
<el-table ref="tableRef" :data="rows" border class="min-w-[720px]" v-loading="loading" row-key="id">
<el-table-column type="expand"> <el-table-column type="expand">
<template #default="{ row }"> <template #default="{ row }">
<div class="p-3"> <div class="p-3">
<div class="text-sm font-semibold mb-2">学案</div> <div class="text-sm font-semibold mb-2">学案</div>
<el-table :data="row.plans || []" size="small" border> <div class="overflow-x-auto">
<el-table-column prop="id" label="计划ID" width="100" /> <el-table :data="row.plans || []" size="small" border class="min-w-[600px]">
<el-table-column prop="title" label="标题" min-width="280" /> <el-table-column prop="title" label="标题" min-width="280" />
<el-table-column label="状态" width="120"> <el-table-column label="状态" width="120">
<template #default="{ row: plan }"> <template #default="{ row: plan }">
<el-tag :type="plan.isFinished === 1 ? 'success' : 'info'" effect="plain"> <el-tag :type="plan.isFinished === 1 ? 'success' : 'info'"
effect="plain">
{{ plan.isFinished === 1 ? '已完成' : '未完成' }} {{ plan.isFinished === 1 ? '已完成' : '未完成' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200" fixed="right"> <el-table-column label="操作" width="200" fixed="right">
<template #default="{ row: plan }"> <template #default="{ row: plan }">
<el-button <el-button type="primary" size="small"
type="primary"
size="small"
:loading="downloadingIds.includes(plan.id)" :loading="downloadingIds.includes(plan.id)"
@click="onDownload(plan)" @click="onDownload(plan)">下载</el-button>
>下载</el-button> <el-button class="ml-2" type="primary" size="small"
<el-button
class="ml-2"
type="success"
size="small"
:disabled="plan.isFinished === 1" :disabled="plan.isFinished === 1"
:loading="finishingIds.includes(plan.id)" :loading="finishingIds.includes(plan.id)"
@click="onFinish(row.id, plan.id, plan)" @click="onFinish(row.id, plan.id, plan)">完成</el-button>
>完成</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div> </div>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="id" label="学生ID" width="100" /> <el-table-column prop="name" label="姓名" min-width="140" />
<el-table-column prop="name" label="姓名" min-width="120" /> <el-table-column prop="className" label="班级" min-width="140" />
<el-table-column prop="className" label="级" min-width="120" /> <el-table-column prop="gradeName" label="级" min-width="140" />
<el-table-column prop="gradeName" label="年级" min-width="120" />
</el-table> </el-table>
</div>
<div class="sm:hidden space-y-3">
<div v-for="row in rows" :key="row.id" class="panel-shell p-4">
<div class="text-base font-semibold mb-2">{{ row.name }}</div>
<div class="text-sm text-gray-700 mb-1">班级{{ row.className }}</div>
<div class="text-sm text-gray-700 mb-3">年级{{ row.gradeName }}</div>
<div class="flex justify-between items-center">
<div class="text-sm font-medium">学案</div>
<el-button size="small" @click="toggleMobileExpand(row.id)">
{{ mobileExpanded[row.id] ? '收起' : '展开' }}
</el-button>
</div>
<div v-if="mobileExpanded[row.id]" class="mt-3 space-y-2">
<div v-for="plan in (row.plans || [])" :key="plan.id"
class="rounded-lg border border-white/30 bg-white/50 p-3">
<div class="text-sm font-medium mb-2">{{ plan.title }}</div>
<div class="mb-2">
<el-tag :type="plan.isFinished === 1 ? 'success' : 'info'" effect="plain">
{{ plan.isFinished === 1 ? '已完成' : '未完成' }}
</el-tag>
</div>
<div class="flex gap-2">
<el-button type="primary" size="small"
:loading="downloadingIds.includes(plan.id)"
@click="onDownload(plan)">下载</el-button>
<el-button type="primary" size="small"
:disabled="plan.isFinished === 1"
:loading="finishingIds.includes(plan.id)"
@click="onFinish(row.id, plan.id, plan)">完成</el-button>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4 flex justify-end"> <div class="mt-4 flex justify-end">
<el-pagination background layout="prev, pager, next, sizes, total" :total="totalCount" <el-pagination background layout="prev, pager, next, sizes, total" :total="totalCount"
:page-size="pageSize" :current-page="pageNo" @current-change="handlePageChange" :page-size="pageSize" :current-page="pageNo" @current-change="handlePageChange"
@@ -62,6 +97,8 @@
</div> </div>
</div> </div>
</el-main> </el-main>
</el-container>
</el-container> </el-container>
</div> </div>
@@ -73,6 +110,7 @@ import { ref, onMounted } from 'vue'
import { findStudentLessonPlans, finishLessonPlan } from '@/api/studentLessonPlans' import { findStudentLessonPlans, finishLessonPlan } from '@/api/studentLessonPlans'
import { downloadLessonPlan } from '@/api/plan' import { downloadLessonPlan } from '@/api/plan'
import { showMessage } from '@/composables/util' import { showMessage } from '@/composables/util'
import Sidebar from '@/layouts/components/Sidebar.vue'
const rows = ref([]) const rows = ref([])
const loading = ref(false) const loading = ref(false)
@@ -83,6 +121,12 @@ const searchName = ref('')
const tableRef = ref(null) const tableRef = ref(null)
const downloadingIds = ref([]) const downloadingIds = ref([])
const finishingIds = ref([]) const finishingIds = ref([])
const mobileExpanded = ref({})
function toggleMobileExpand(id) {
const v = mobileExpanded.value[id]
mobileExpanded.value[id] = !v
}
async function fetchLessonPlans() { async function fetchLessonPlans() {
loading.value = true loading.value = true

View File

@@ -0,0 +1,247 @@
<template>
<div
class="min-h-screen relative flex items-center justify-center bg-scroll md:bg-fixed bg-cover bg-center bg-[url('https://cube.elemecdn.com/6/94/4d3ea53c084bad6931a56d5158a48jpeg.jpeg')] safe-area">
<div class="absolute inset-0 bg-gradient-to-br from-[rgba(30,20,50,0.4)] to-[rgba(10,10,20,0.6)]"></div>
<div
class="relative z-10 w-full sm:w-[400px] max-w-[420px] sm:max-w-[92%] bg-white/10 backdrop-blur-2xl border border-white/20 rounded-2xl p-6 sm:p-8 shadow-2xl text-white">
<div class="text-center mb-6">
<h2 class="text-xl sm:text-2xl font-semibold tracking-wide mb-1">Welcome Back</h2>
<p class="text-sm text-white/80">智慧英语 · 让学习更简单</p>
</div>
<div class="flex justify-center mb-6 border-b border-white/10">
<button class="px-4 sm:px-5 py-2 text-white/70 hover:text-white transition"
:class="mode === 'login' ? 'font-bold text-white border-b-2 border-white' : ''"
@click="switchMode('login')">登录</button>
<button class="px-4 sm:px-5 py-2 text-white/70 hover:text-white transition"
:class="mode === 'register' ? 'font-bold text-white border-b-2 border-white' : ''"
@click="switchMode('register')">注册</button>
</div>
<el-form :model="form" :rules="rules" ref="formRef" class="mt-2">
<el-form-item prop="phone">
<div class="input-glass relative mb-4 w-full">
<el-input v-model="form.phone" maxlength="11" placeholder="请输入手机号 / 账号" />
<i class="fas fa-user absolute left-4 top-1/2 -translate-y-1/2 text-white/80 text-lg pointer-events-none"></i>
</div>
</el-form-item>
<transition name="tabfade" mode="out-in">
<div :key="mode">
<template v-if="mode === 'login'">
<el-form-item prop="password">
<div class="input-glass relative mb-4 w-full">
<el-input v-model="form.password" type="password" maxlength="20" placeholder="请输入密码" />
<i class="fas fa-lock absolute left-4 top-1/2 -translate-y-1/2 text-white/80 text-lg pointer-events-none"></i>
</div>
</el-form-item>
</template>
<template v-else>
<el-form-item prop="name">
<div class="input-glass relative mb-4 w-full">
<el-input v-model="form.name" maxlength="20" placeholder="姓名" />
<i class="fas fa-id-card absolute left-4 top-1/2 -translate-y-1/2 text-white/80 text-lg pointer-events-none"></i>
</div>
</el-form-item>
<el-form-item prop="password">
<div class="input-glass relative mb-4 w-full">
<el-input v-model="form.password" type="password" maxlength="20" placeholder="设置密码" />
<i class="fas fa-lock absolute left-4 top-1/2 -translate-y-1/2 text-white/80 text-lg pointer-events-none"></i>
</div>
</el-form-item>
<el-form-item prop="password_repeat">
<div class="input-glass relative mb-4 w-full">
<el-input v-model="form.password_repeat" type="password" maxlength="20" placeholder="重复密码" />
<i class="fas fa-lock absolute left-4 top-1/2 -translate-y-1/2 text-white/80 text-lg pointer-events-none"></i>
</div>
</el-form-item>
<el-form-item prop="invitationCode">
<div class="input-glass relative mb-4 w-full">
<el-input v-model="form.invitationCode" maxlength="6" placeholder="邀请码" />
<i class="fas fa-ticket absolute left-4 top-1/2 -translate-y-1/2 text-white/80 text-lg pointer-events-none"></i>
</div>
</el-form-item>
<el-form-item prop="code">
<div class="relative mb-2 flex items-center gap-2">
<div class="input-glass relative flex-1">
<el-input v-model="form.code" maxlength="6" placeholder="验证码" />
<i class="fas fa-shield absolute left-4 top-1/2 -translate-y-1/2 text-white/80 text-lg pointer-events-none"></i>
</div>
<el-button class="rounded-lg bg-gradient-to-r from-blue-600 to-blue-400 text-white px-3 py-2"
@click="sendCode" :disabled="codeDisabled || !form.phone">
{{ codeBtnText }}
</el-button>
</div>
</el-form-item>
</template>
</div>
</transition>
</el-form>
<button
class="w-full mt-3 px-4 py-3 rounded-xl bg-gradient-to-r from-blue-600 to-blue-400 text-white font-semibold shadow-lg hover:-translate-y-0.5 hover:shadow-xl transition disabled:opacity-70 disabled:cursor-not-allowed"
:disabled="loading" @click="userLogin">
<span v-if="!loading && mode === 'login'">立即登录</span>
<span v-if="loading && mode === 'login'">登录中...</span>
<span v-if="!loading && mode === 'register'">立即注册</span>
<span v-if="loading && mode === 'register'">处理中...</span>
</button>
</div>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import router from '@/router'
import { ElMessage } from 'element-plus'
import { login, getVerificationCode } from '@/api/user'
import { setToken } from '@/composables/auth'
const loading = ref(false)
const formRef = ref()
const mode = ref('login')
const codeDisabled = ref(false)
const codeBtnText = ref('发送验证码')
let timer = null
const form = reactive({
phone: '',
name: '',
password: '',
password_repeat: '',
code: '',
invitationCode: '',
})
const rules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入合法的手机号', trigger: ['blur', 'change'] },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度为6-20位', trigger: ['blur', 'change'] },
],
name: [
{
validator: (_r, v, cb) => {
if (mode.value !== 'register') return cb()
if (!v?.trim()) return cb(new Error('请输入姓名'))
cb()
}, trigger: ['blur', 'change']
},
],
password_repeat: [
{
validator: (_r, v, cb) => {
if (mode.value !== 'register') return cb()
if (!v) return cb(new Error('请再次输入密码'))
if (v !== form.password) return cb(new Error('两次密码不一致'))
cb()
}, trigger: ['blur', 'change']
},
],
code: [
{
validator: (_r, v, cb) => {
if (mode.value !== 'register') return cb()
if (!v?.trim()) return cb(new Error('请输入验证码'))
cb()
}, trigger: ['blur', 'change']
},
],
}
async function userLogin() {
if (loading.value) return
formRef.value?.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
const payload = mode.value === 'login'
? {
phone: form.phone.trim(),
password: form.password.trim(),
}
: {
phone: form.phone.trim(),
name: form.name.trim(),
password: form.password.trim(),
code: form.code.trim(),
invitationCode: form.invitationCode.trim()
}
const res = await login(payload)
const data = res.data
if (data?.success) {
if (mode.value === 'login') {
try { setToken(data.data) } catch { }
ElMessage.success('登录成功')
router.push('/')
} else {
ElMessage.success('注册成功')
mode.value = 'login'
}
} else {
ElMessage.error(data?.message || '登录失败')
}
} finally {
loading.value = false
}
})
}
function switchMode(m) {
mode.value = m
formRef.value?.clearValidate()
}
async function sendCode() {
if (!form.phone || codeDisabled.value) return
codeDisabled.value = true
try {
await getVerificationCode({ phone: form.phone.trim() })
ElMessage.success('验证码已发送')
let count = 60
codeBtnText.value = `${count}s`
timer = setInterval(() => {
count -= 1
if (count <= 0) {
clearInterval(timer)
timer = null
codeDisabled.value = false
codeBtnText.value = '发送验证码'
} else {
codeBtnText.value = `${count}s`
}
}, 1000)
} catch {
codeDisabled.value = false
codeBtnText.value = '发送验证码'
}
}
</script>
<style scoped>
.input-glass :deep(.el-input__wrapper) {
@apply w-full bg-white/10 border border-white/10 rounded-xl pl-10 transition;
}
.input-glass :deep(.el-input__wrapper:hover),
.input-glass :deep(.el-input__wrapper.is-focus) {
@apply bg-white/15 border-white/50;
}
.input-glass :deep(.el-input__inner) {
@apply text-white placeholder:text-white/70;
}
.tabfade-enter-active, .tabfade-leave-active {
transition: all .25s ease;
}
.tabfade-enter-from {
opacity: 0;
transform: translateY(8px) scale(.98);
}
.tabfade-leave-to {
opacity: 0;
transform: translateY(-8px) scale(.98);
}
</style>

View File

@@ -1,54 +1,70 @@
<template> <template>
<div class="common-layout"> <div class="common-layout">
<el-container> <el-container>
<el-header>
<Header></Header>
</el-header> <el-container class="pt-4">
<el-main class="p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <el-main class="p-2">
<div class="panel-shell p-6">
<div class="text-lg font-semibold mb-4">TTS</div> <div class="text-lg font-semibold mb-4">TTS</div>
<div class="flex items-center gap-3 mb-4"> <div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 mb-4">
<el-input v-model="planIdInput" placeholder="planId" style="max-width: 220px" /> <!-- <el-input v-model="planIdInput" placeholder="planId" class="w-full sm:w-auto" style="max-width: 220px" /> -->
<el-button type="primary" :loading="loadingWords" @click="onLoadWords">加载词汇</el-button> <el-button type="primary" :loading="loadingWords" @click="onLoadWords">加载词汇</el-button>
<el-select v-model="voice" placeholder="选择声线" style="max-width: 160px"> <!-- <el-select v-model="voice" placeholder="选择声线" class="w-full sm:w-auto" style="max-width: 160px">
<el-option label="alloy" value="alloy" /> <el-option label="alloy" value="alloy" />
<el-option label="verse" value="verse" /> <el-option label="verse" value="verse" />
<el-option label="nova" value="nova" /> <el-option label="nova" value="nova" />
</el-select> </el-select> -->
<el-select v-model="format" placeholder="格式" style="max-width: 120px"> <!-- <el-select v-model="format" placeholder="格式" class="w-full sm:w-auto" style="max-width: 120px">
<el-option label="mp3" value="mp3" /> <el-option label="mp3" value="mp3" />
<el-option label="wav" value="wav" /> <el-option label="wav" value="wav" />
<el-option label="ogg" value="ogg" /> <el-option label="ogg" value="ogg" />
</el-select> </el-select> -->
<el-button type="success" :disabled="words.length === 0" :loading="generatingAll" <el-button type="success" :disabled="words.length === 0" :loading="generatingAll" class="!ml-0"
@click="onGenerateAll">生成全部音频</el-button> @click="onGenerateAll">生成音频</el-button>
</div> </div>
<el-table :data="tableData" border class="w-full" v-loading="loadingWords"> <div class="sm:hidden">
<el-table-column prop="word" label="词汇/短语" min-width="260" /> <div v-for="row in tableData" :key="row.word" class="panel-shell p-4 mb-3">
<el-table-column label="状态" width="160"> <div class="flex items-center justify-between">
<div class="font-medium">{{ row.word }}</div>
<el-tag :type="row.audioUrl ? 'success' : 'info'" effect="plain">
{{ row.audioUrl ? '已生成' : '未生成' }}
</el-tag>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<el-button size="small" type="primary" :loading="row.loading" @click="onGenerateOne(row)">生成音频</el-button>
<el-button size="small" :disabled="!row.audioUrl" @click="onPlay(row)">播放</el-button>
<el-button size="small" :disabled="!row.audioUrl" @click="onDownload(row)">下载</el-button>
</div>
</div>
</div>
<div class="hidden sm:block overflow-x-auto">
<el-table :data="tableData" border class="min-w-[640px]" v-loading="loadingWords" size="small">
<el-table-column prop="word" label="词汇/短语" min-width="200" />
<el-table-column label="状态" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.audioUrl ? 'success' : 'info'" effect="plain"> <el-tag :type="row.audioUrl ? 'success' : 'info'" effect="plain">
{{ row.audioUrl ? '已生成' : '未生成' }} {{ row.audioUrl ? '已生成' : '未生成' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="360" fixed="right"> <el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" type="primary" :loading="row.loading" <el-button size="small" type="primary" :loading="row.loading" @click="onGenerateOne(row)">生成音频</el-button>
@click="onGenerateOne(row)">生成音频</el-button> <el-button size="small" class="ml-2" :disabled="!row.audioUrl" @click="onPlay(row)">播放</el-button>
<el-button size="small" class="ml-2" :disabled="!row.audioUrl" <el-button size="small" class="ml-2" :disabled="!row.audioUrl" @click="onDownload(row)">下载</el-button>
@click="onPlay(row)">播放</el-button>
<el-button size="small" class="ml-2" :disabled="!row.audioUrl"
@click="onDownload(row)">下载</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<div class="mt-3 text-sm text-gray-500"> <div class="mt-3 text-sm text-gray-500">
{{ words.length }} {{ words.length }}
</div> </div>
</div> </div>
</el-main> </el-main>
</el-container> </el-container>
</el-container>
</div> </div>
</template> </template>
@@ -59,7 +75,7 @@ import { useRoute } from 'vue-router'
import { getLessonPlanWords } from '@/api/plan' import { getLessonPlanWords } from '@/api/plan'
import { synthesizeOpenAITTS } from '@/api/tts' import { synthesizeOpenAITTS } from '@/api/tts'
import { showMessage } from '@/composables/util' import { showMessage } from '@/composables/util'
import Sidebar from '@/layouts/components/Sidebar.vue'
const route = useRoute() const route = useRoute()
const planIdInput = ref(route.query.planId ? String(route.query.planId) : '') const planIdInput = ref(route.query.planId ? String(route.query.planId) : '')
const words = ref([]) const words = ref([])

View File

@@ -5,13 +5,15 @@
<Header></Header> <Header></Header>
</el-header> </el-header>
<el-main class="p-4"> <el-container>
<el-card> <el-main class="p-2">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="panel-shell p-6">
<div class="flex items-center mb-4"> <div class="flex items-center mb-4">
<el-input v-model="query.name" placeholder="姓名" clearable style="max-width:220px" /> <el-input v-model="query.name" placeholder="姓名" clearable style="max-width:220px" />
<el-button type="primary" class="ml-2" @click="fetchList">查询</el-button> <el-button type="primary" class="ml-2" @click="fetchList">查询</el-button>
<el-button class="ml-2" @click="resetSearch">重置</el-button> <el-button class="ml-2" @click="resetSearch">重置</el-button>
<el-button type="success" class="ml-2" @click="openCreate">新增用户</el-button> <el-button type="primary" class="ml-2" @click="openCreate">新增用户</el-button>
</div> </div>
<el-table :data="list" v-loading="loading" border stripe> <el-table :data="list" v-loading="loading" border stripe>
<el-table-column prop="name" label="姓名" /> <el-table-column prop="name" label="姓名" />
@@ -19,17 +21,68 @@
<el-table-column prop="roleName" label="角色" /> <el-table-column prop="roleName" label="角色" />
</el-table> </el-table>
<div class="mt-4 flex justify-end"> <div class="mt-4 flex justify-end">
<el-pagination <el-pagination background :current-page="page" :page-size="pageSize" :total="totalCount"
background layout="prev, pager, next, sizes, total" @current-change="onPageChange"
:current-page="page" @size-change="onSizeChange" />
:page-size="pageSize" </div>
:total="totalCount" </div>
layout="prev, pager, next, sizes, total"
@current-change="onPageChange" <div class="panel-shell p-6">
@size-change="onSizeChange" <div class="flex items-center justify-between mb-4">
/> <span class="text-lg font-semibold">生成邀请码</span>
</div>
<el-form :model="inviteForm" :rules="inviteRules" ref="inviteFormRef" label-width="120px">
<el-form-item label="使用次数限制" prop="limit">
<el-input-number v-model="inviteForm.limit" :min="1" :max="9999" />
</el-form-item>
<el-form-item label="有效期(天)" prop="expire">
<el-input-number v-model="inviteForm.expire" :min="1" :max="3650" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="inviteLoading"
@click="submitInvite">生成邀请码</el-button>
<el-button class="ml-2" @click="resetInvite">重置</el-button>
</el-form-item>
</el-form>
<div v-if="inviteCode" class="mt-2">
<el-alert type="success" :closable="false" :title="`邀请码:${inviteCode}`" show-icon />
</div>
</div>
<div class="panel-shell p-6">
<div class="flex items-center justify-between mb-4">
<span class="text-lg font-semibold">修改用户信息</span>
</div>
<el-form :model="pwForm" :rules="pwRules" ref="pwFormRef" label-width="120px">
<el-form-item label="姓名" prop="name">
<el-input v-model="pwForm.name" />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="pwForm.newPassword" type="password" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="pwForm.confirmPassword" type="password" show-password />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="pwForm.phone" />
</el-form-item>
<el-form-item label="验证码" prop="code">
<el-input v-model="pwForm.code">
<template #append>
<el-button :disabled="codeCountdown > 0 || codeSending || !pwForm.phone"
@click="sendCode">
{{ codeCountdown > 0 ? `${codeCountdown}s` : '获取验证码' }}
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="pwLoading" @click="submitPw">修改用户信息</el-button>
<el-button class="ml-2" @click="resetPw">重置</el-button>
</el-form-item>
</el-form>
</div>
</div> </div>
</el-card>
<el-dialog v-model="createVisible" title="新增用户" width="420px"> <el-dialog v-model="createVisible" title="新增用户" width="420px">
<el-form :model="createForm" :rules="rules" ref="createFormRef" label-width="80px"> <el-form :model="createForm" :rules="rules" ref="createFormRef" label-width="80px">
@@ -44,11 +97,12 @@
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="createVisible=false">取消</el-button> <el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">提交</el-button> <el-button type="primary" :loading="createLoading" @click="submitCreate">提交</el-button>
</template> </template>
</el-dialog> </el-dialog>
</el-main> </el-main>
</el-container>
</el-container> </el-container>
</div> </div>
@@ -56,9 +110,11 @@
<script setup> <script setup>
import Header from '@/layouts/components/Header.vue' import Header from '@/layouts/components/Header.vue'
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { getUserList, createUser } from '@/api/admin' import { getUserList, createUser, createInvitationCode } from '@/api/admin'
import { updateUserInfo, getVerificationCode } from '@/api/user'
import { showMessage } from '@/composables/util.js' import { showMessage } from '@/composables/util.js'
import Sidebar from '@/layouts/components/Sidebar.vue'
const loading = ref(false) const loading = ref(false)
const list = ref([]) const list = ref([])
@@ -144,4 +200,147 @@ onMounted(() => {
fetchList() fetchList()
}) })
const inviteForm = reactive({ limit: 10, expire: 3 })
const inviteFormRef = ref()
const inviteLoading = ref(false)
const inviteCode = ref('')
const inviteRules = {
limit: [{ required: true, message: '请输入使用次数限制', trigger: 'change' }],
expire: [{ required: true, message: '请输入有效期', trigger: 'change' }],
}
function resetInvite() {
inviteForm.limit = 10
inviteForm.expire = 3
inviteCode.value = ''
}
function submitInvite() {
inviteFormRef.value?.validate(async (valid) => {
if (!valid) return
inviteLoading.value = true
try {
const r = await createInvitationCode({ limit: inviteForm.limit, expire: inviteForm.expire })
const d = r?.data
if (d?.success) {
inviteCode.value = d?.data?.invitationCode || ''
if (inviteCode.value) {
showMessage('邀请码生成成功', 'success')
} else {
showMessage('生成成功,但未返回邀请码', 'warning')
}
} else {
showMessage(d?.message || '邀请码生成失败', 'error')
}
} finally {
inviteLoading.value = false
}
})
}
const pwForm = reactive({ name: '', phone: '', newPassword: '', confirmPassword: '', code: '' })
const pwFormRef = ref()
const pwLoading = ref(false)
const pwRules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
newPassword: [],
confirmPassword: [
{
validator: (rule, value, callback) => {
if (pwForm.newPassword) {
if (!value) {
callback(new Error('请确认密码'))
return
}
if (value !== pwForm.newPassword) {
callback(new Error('两次输入的密码不一致'))
return
}
}
callback()
},
trigger: 'blur'
}
],
code: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
}
function resetPw() {
if (codeTimer) {
clearInterval(codeTimer)
codeTimer = null
}
codeCountdown.value = 0
codeSending.value = false
pwForm.name = ''
pwForm.phone = ''
pwForm.newPassword = ''
pwForm.confirmPassword = ''
pwForm.code = ''
}
const codeCountdown = ref(0)
const codeSending = ref(false)
let codeTimer = null
async function sendCode() {
if (!pwForm.phone) {
showMessage('请输入手机号', 'warning')
return
}
if (codeSending.value || codeCountdown.value > 0) return
codeSending.value = true
try {
const r = await getVerificationCode({ phone: pwForm.phone })
const d = r?.data
if (d?.success) {
showMessage('验证码已发送', 'success')
codeCountdown.value = 60
codeTimer = setInterval(() => {
if (codeCountdown.value > 0) {
codeCountdown.value -= 1
} else {
clearInterval(codeTimer)
codeTimer = null
}
}, 1000)
} else {
showMessage(d?.message || '发送验证码失败', 'error')
}
} finally {
codeSending.value = false
}
}
onUnmounted(() => {
if (codeTimer) {
clearInterval(codeTimer)
codeTimer = null
}
})
function submitPw() {
pwFormRef.value?.validate(async (valid) => {
if (!valid) return
pwLoading.value = true
try {
const r = await updateUserInfo({
newPassword: pwForm.newPassword || '',
name: pwForm.name || '',
phone: pwForm.phone,
code: pwForm.code
})
const d = r?.data
if (d?.success) {
showMessage('用户信息修改成功', 'success')
resetPw()
} else {
showMessage(d?.message || '用户信息修改失败', 'error')
}
} finally {
pwLoading.value = false
}
})
}
</script> </script>

View File

@@ -1,20 +1,24 @@
<template> <template>
<div class="common-layout"> <div class="common-layout">
<el-container> <el-container class="min-h-screen">
<el-header> <el-header>
<Header></Header> <Header></Header>
</el-header> </el-header>
<el-main class="p-4"> <el-container class="pt-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <el-aside width="200px" class="hidden md:block sidebar-fixed">
<Sidebar></Sidebar>
</el-aside>
<el-main class="h-full">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="lg:col-span-1 flex flex-col gap-6"> <div class="lg:col-span-1 flex flex-col gap-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div class="panel-shell p-6">
<div class="text-lg font-semibold mb-4">班级列表</div> <div class="text-lg font-semibold mb-4">班级列表</div>
<el-table ref="classTableRef" :data="classes" border class="w-full" v-loading="loading" highlight-current-row <div class="hidden sm:block overflow-x-auto">
<el-table ref="classTableRef" :data="classes" border class="min-w-[520px]" v-loading="loading" highlight-current-row
row-key="id" :current-row-key="selectedClassId" @row-click="onClassRowClick"> row-key="id" :current-row-key="selectedClassId" @row-click="onClassRowClick">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="title" label="班级名称" min-width="160" />
<el-table-column prop="title" label="班级名称" min-width="120" />
<el-table-column prop="gradeName" label="年级" min-width="120" /> <el-table-column prop="gradeName" label="年级" min-width="120" />
<el-table-column label="操作" width="120" fixed="right"> <el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
@@ -22,10 +26,21 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<div class="sm:hidden space-y-3">
<div v-for="row in classes" :key="row.id" class="panel-shell p-4">
<div class="text-base font-semibold mb-1">{{ row.title }}</div>
<div class="text-sm mb-2">年级{{ row.gradeName }}</div>
<div class="flex gap-2">
<el-button size="small" type="primary" @click="onClassRowClick(row)">选择</el-button>
<el-button size="small" type="danger" @click="onDeleteClass(row)">删除</el-button>
</div>
</div>
</div>
<div class="mt-4 flex justify-end"> <div class="mt-4 flex justify-end">
<el-pagination background layout="prev, pager, next, sizes, total" :total="totalCount" <el-pagination background layout="prev, pager, next, sizes, total"
:page-size="pageSize" :current-page="pageNo" @current-change="handlePageChange" :total="totalCount" :page-size="pageSize" :current-page="pageNo"
@size-change="handleSizeChange" /> @current-change="handlePageChange" @size-change="handleSizeChange" />
</div> </div>
<div class="mt-3 flex justify-end"> <div class="mt-3 flex justify-end">
<el-button type="primary" @click="showAddClassDialog = true">新增班级</el-button> <el-button type="primary" @click="showAddClassDialog = true">新增班级</el-button>
@@ -35,14 +50,12 @@
</div> </div>
</div> </div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 lg:col-span-1 lg:row-span-1"> <div class="panel-shell p-6 lg:col-span-1 lg:row-span-2">
<div class="text-lg font-semibold mb-4">学生查询</div> <div class="text-lg font-semibold mb-4">学生查询</div>
<div class="flex flex-wrap items-center gap-3 mb-4"> <div class="flex flex-wrap items-center gap-3 mb-4">
<el-input v-model="studentName" placeholder="按姓名查询" clearable style="max-width: 220px" /> <el-input v-model="studentName" placeholder="按姓名查询" clearable style="max-width: 220px" />
<el-tag v-if="selectedClassId" effect="plain">班级{{ selectedClassTitle }} (ID: {{ <el-tag v-if="selectedClassId" effect="plain">班级{{ selectedClassTitle }}</el-tag>
selectedClassId }})</el-tag> <el-tag v-if="selectedGradeId" effect="plain">年级{{ selectedGradeTitle }}</el-tag>
<el-tag v-if="selectedGradeId" effect="plain">年级{{ selectedGradeTitle }} (ID: {{
selectedGradeId }})</el-tag>
<el-button type="primary" @click="fetchStudents">查询</el-button> <el-button type="primary" @click="fetchStudents">查询</el-button>
<el-button @click="resetStudentFilters">重置</el-button> <el-button @click="resetStudentFilters">重置</el-button>
<el-button type="success" :disabled="selectedStudentIds.length !== 1" <el-button type="success" :disabled="selectedStudentIds.length !== 1"
@@ -54,102 +67,109 @@
生成学案 生成学案
</el-button> </el-button>
</div> </div>
<el-table ref="studentTableRef" :data="students" border class="w-full" <div class="hidden sm:block overflow-x-auto">
<el-table ref="studentTableRef" :data="students" border class="min-w-[760px]"
v-loading="studentLoading" @selection-change="onStudentSelectionChange"> v-loading="studentLoading" @selection-change="onStudentSelectionChange">
<el-table-column type="selection" width="48" /> <el-table-column type="selection" width="48" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="姓名" min-width="120" /> <el-table-column prop="name" label="姓名" min-width="120" />
<el-table-column prop="classId" label="班级ID" width="100" /> <el-table-column prop="className" label="班级" min-width="120" />
<el-table-column prop="gradeId" label="年级ID" width="100" /> <el-table-column prop="gradeName" label="年级" min-width="120" />
<el-table-column label="操作" width="180" fixed="right"> <el-table-column prop="phone" label="学案" min-width="120">
<template #default="{ row }"> <template #default="{ row }">
<template v-if="generatingPercents[row.id] !== undefined">
<div class="flex items-center gap-2">
<el-progress :percentage="generatingPercents[row.id]" :stroke-width="8" />
</div>
</template>
<template v-else>
<div class="flex items-center gap-2">
<el-button type="primary" size="small" @click="planStudentId = row.id; showPlanListDialog = true">查看学案</el-button>
</div>
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button type="info" size="small" @click.stop="onShowAnalysis(row)">学情分析</el-button>
<el-button type="primary" size="small" @click.stop="onViewStudent(row)">详情</el-button> <el-button type="primary" size="small" @click.stop="onViewStudent(row)">详情</el-button>
<el-button type="danger" size="small" @click.stop="onDeleteStudent(row)">删除</el-button> <el-button type="danger" size="small" @click.stop="onDeleteStudent(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<div class="sm:hidden space-y-3">
<div v-for="row in students" :key="row.id" class="panel-shell p-4">
<div class="flex items-center justify-between mb-1">
<div class="text-base font-semibold">{{ row.name }}</div>
<el-checkbox
:model-value="isStudentSelected(row.id)"
@change="(val) => setStudentSelection(row.id, val)">
选中
</el-checkbox>
</div>
<div class="text-sm mb-1">班级{{ row.className }}</div>
<div class="text-sm mb-2">年级{{ row.gradeName }}</div>
<template v-if="generatingPercents[row.id] !== undefined">
<div class="mb-2">
<el-progress :percentage="generatingPercents[row.id]" :stroke-width="8" />
</div>
</template>
<div class="flex flex-wrap gap-2">
<el-button size="small" @click="planStudentId = row.id; showPlanListDialog = true">查看学案</el-button>
<el-button size="small" type="info" @click="onShowAnalysis(row)">学情分析</el-button>
<el-button size="small" type="primary" @click="onViewStudent(row)">详情</el-button>
<el-button size="small" type="danger" @click="onDeleteStudent(row)">删除</el-button>
</div>
</div>
</div>
<div class="mt-4 flex justify-end"> <div class="mt-4 flex justify-end">
<el-pagination background layout="prev, pager, next, sizes, total" <el-pagination background layout="prev, pager, next, sizes, total"
:total="studentTotalCount" :page-size="studentPageSize" :current-page="studentPageNo" :total="studentTotalCount" :page-size="studentPageSize"
@current-change="handleStudentPageChange" @size-change="handleStudentSizeChange" /> :current-page="studentPageNo" @current-change="handleStudentPageChange"
@size-change="handleStudentSizeChange" />
</div> </div>
<ExamGenerateDialog v-model="showGenerateDialog" :student-ids="selectedStudentIds" <ExamGenerateDialog v-model="showGenerateDialog" :student-ids="selectedStudentIds"
:default-grade-id="selectedGradeId" /> :default-grade-id="selectedGradeId" />
<div class="mt-3 flex justify-end"> <div class="mt-3 flex justify-end">
<el-button type="primary" @click="showAddStudentDialog = true">新增学生</el-button> <el-button type="primary" @click="showAddStudentDialog = true">新增学生</el-button>
</div> </div>
<AddStudentDialog <AddStudentDialog v-model="showAddStudentDialog" :default-class-id="selectedClassId"
v-model="showAddStudentDialog" :default-grade-id="selectedGradeId" @success="fetchStudents" />
:default-class-id="selectedClassId" <LessonPlanDialog v-model="showLessonPlanDialog" :student-id="selectedStudentIds[0]"
:default-grade-id="selectedGradeId" @success="onLessonPlanGenerateSuccess" />
@success="fetchStudents" <StudentPlanListDialog v-model="showPlanListDialog" :student-id="planStudentId" />
/> <el-dialog v-model="showAnalysisDialog" title="学情分析" width="60%">
<LessonPlanDialog <StudyAnalysis v-if="showAnalysisDialog" :student-id="analysisStudentId" />
v-model="showLessonPlanDialog" </el-dialog>
:student-id="selectedStudentIds[0]"
/>
</div> </div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6" v-loading="gradeLoading"> <div class="panel-shell p-6" v-loading="gradeLoading">
<div class="text-lg font-semibold mb-4">年级列表</div> <div class="text-lg font-semibold mb-4">年级列表</div>
<el-table ref="gradeTableRef" :data="grades" border class="w-full" highlight-current-row <div class="hidden sm:block overflow-x-auto">
<el-table ref="gradeTableRef" :data="grades" border class="min-w-[360px]" highlight-current-row
row-key="id" :current-row-key="selectedGradeId" @row-click="onGradeRowClick"> row-key="id" :current-row-key="selectedGradeId" @row-click="onGradeRowClick">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="年级名称" min-width="160" /> <el-table-column prop="title" label="年级名称" min-width="160" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="danger" size="small"
@click.stop="onDeleteGrade(row)">删除</el-button>
</template>
</el-table-column>
</el-table> </el-table>
</div>
<div class="sm:hidden space-y-3">
<div v-for="row in grades" :key="row.id" class="panel-shell p-4">
<div class="text-base font-semibold mb-1">{{ row.title }}</div>
<div class="flex gap-2">
<el-button size="small" type="primary" @click="onGradeRowClick(row)">选择</el-button>
<el-button size="small" type="danger" @click="onDeleteGrade(row)">删除</el-button>
</div>
</div>
</div>
<div class="mt-4 flex justify-end"> <div class="mt-4 flex justify-end">
<el-pagination background layout="prev, pager, next, sizes, total" :total="gradeTotalCount" <el-pagination background layout="prev, pager, next, sizes, total"
:page-size="gradePageSize" :current-page="gradePageNo" :total="gradeTotalCount" :page-size="gradePageSize" :current-page="gradePageNo"
@current-change="handleGradePageChange" @size-change="handleGradeSizeChange" /> @current-change="handleGradePageChange" @size-change="handleGradeSizeChange" />
</div> </div>
<div class="mt-3 flex justify-end">
<el-button type="primary" @click="showAddGradeDialog = true">新增年级</el-button>
</div>
<AddGradeDialog v-model="showAddGradeDialog" @success="fetchGrades" />
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6" v-loading="unitLoading">
<div class="text-lg font-semibold mb-4">单元列表</div>
<el-table ref="unitTableRef" :data="units" border class="w-full">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="单元名称" min-width="200" />
<el-table-column prop="version" label="版本" min-width="120" />
<el-table-column prop="createAt" label="创建时间" min-width="160" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="danger" size="small" @click.stop="onDeleteUnit(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt-4 flex justify-end">
<el-pagination
background
layout="prev, pager, next, sizes, total"
:total="unitTotalCount"
:page-size="unitPageSize"
:current-page="unitPageNo"
@current-change="handleUnitPageChange"
@size-change="handleUnitSizeChange"
/>
</div>
<div class="mt-3 flex justify-end">
<el-button type="primary" :disabled="!selectedGradeId" @click="showAddUnitDialog = true">新增单元</el-button>
</div>
<AddUnitDialog
v-model="showAddUnitDialog"
:default-grade-id="selectedGradeId"
@success="fetchUnits"
/>
</div> </div>
</div> </div>
</el-main> </el-main>
</el-container>
</el-container> </el-container>
</div> </div>
@@ -157,7 +177,8 @@
<script setup> <script setup>
import Header from '@/layouts/components/Header.vue' import Header from '@/layouts/components/Header.vue'
import { ref, onMounted } from 'vue' import Sidebar from '@/layouts/components/Sidebar.vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { getClassList, deleteClass } from '@/api/class' import { getClassList, deleteClass } from '@/api/class'
import { getGradeList, deleteGrade } from '@/api/grade' import { getGradeList, deleteGrade } from '@/api/grade'
import { getStudentList, deleteStudent } from '@/api/student' import { getStudentList, deleteStudent } from '@/api/student'
@@ -166,9 +187,13 @@ import AddClassDialog from '@/layouts/components/AddClassDialog.vue'
import AddGradeDialog from '@/layouts/components/AddGradeDialog.vue' import AddGradeDialog from '@/layouts/components/AddGradeDialog.vue'
import AddStudentDialog from '@/layouts/components/AddStudentDialog.vue' import AddStudentDialog from '@/layouts/components/AddStudentDialog.vue'
import LessonPlanDialog from '@/layouts/components/LessonPlanDialog.vue' import LessonPlanDialog from '@/layouts/components/LessonPlanDialog.vue'
import StudentPlanListDialog from '@/layouts/components/StudentPlanListDialog.vue'
import StudyAnalysis from '@/layouts/components/student/StudyAnalysis.vue'
import { getUnitList, deleteUnit } from '@/api/unit' import { getUnitList, deleteUnit } from '@/api/unit'
import AddUnitDialog from '@/layouts/components/AddUnitDialog.vue' import AddUnitDialog from '@/layouts/components/AddUnitDialog.vue'
import { useRouter } from 'vue-router' import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { checkIsGenerated } from '@/api/plan'
const classes = ref([]) const classes = ref([])
const pageNo = ref(1) const pageNo = ref(1)
@@ -201,6 +226,24 @@ const selectedStudentIds = ref([])
const showGenerateDialog = ref(false) const showGenerateDialog = ref(false)
const showAddStudentDialog = ref(false) const showAddStudentDialog = ref(false)
const showLessonPlanDialog = ref(false) const showLessonPlanDialog = ref(false)
const generatingPercents = ref({})
const pollingTimers = {}
const showPlanListDialog = ref(false)
const planStudentId = ref(null)
const showAnalysisDialog = ref(false)
const analysisStudentId = ref(null)
function isStudentSelected(id) {
return selectedStudentIds.value.includes(id)
}
function setStudentSelection(id, selected) {
const exists = selectedStudentIds.value.includes(id)
if (selected && !exists) {
selectedStudentIds.value = [...selectedStudentIds.value, id]
} else if (!selected && exists) {
selectedStudentIds.value = selectedStudentIds.value.filter(x => x !== id)
}
}
const units = ref([]) const units = ref([])
const unitPageNo = ref(1) const unitPageNo = ref(1)
@@ -255,6 +298,7 @@ async function fetchStudents() {
studentTotalCount.value = d.totalCount || 0 studentTotalCount.value = d.totalCount || 0
studentPageNo.value = d.pageNo || studentPageNo.value studentPageNo.value = d.pageNo || studentPageNo.value
studentPageSize.value = d.pageSize || studentPageSize.value studentPageSize.value = d.pageSize || studentPageSize.value
ensurePollingForCurrentStudents()
} finally { } finally {
studentLoading.value = false studentLoading.value = false
} }
@@ -295,6 +339,10 @@ function onStudentSelectionChange(rows) {
function onViewStudent(row) { function onViewStudent(row) {
router.push(`/student/${row.id}`) router.push(`/student/${row.id}`)
} }
function onShowAnalysis(row) {
analysisStudentId.value = row.id
showAnalysisDialog.value = true
}
function onClassRowClick(row) { function onClassRowClick(row) {
selectedClassId.value = row.id selectedClassId.value = row.id
selectedClassTitle.value = row.title selectedClassTitle.value = row.title
@@ -309,8 +357,64 @@ function onGradeRowClick(row) {
studentPageNo.value = 1 studentPageNo.value = 1
fetchStudents() fetchStudents()
} }
function startLessonPlanPolling(studentId) {
if (!studentId) return
if (pollingTimers[studentId]) return
const pollOnce = async () => {
try {
const res = await checkIsGenerated(studentId)
const d = res?.data
const ok = d?.success === false || d?.success === false || d === false
if (ok) {
const p = Number(generatingPercents.value[studentId]) || 1
generatingPercents.value[studentId] = Math.min(p + 5, 95)
} else {
if (generatingPercents.value[studentId] !== undefined) {
delete generatingPercents.value[studentId]
}
}
} catch (e) {
const p = Number(generatingPercents.value[studentId]) || 1
generatingPercents.value[studentId] = Math.min(p + 3, 95)
}
}
pollOnce()
pollingTimers[studentId] = setInterval(pollOnce, 10000)
}
function onLessonPlanGenerateSuccess(payload) {
const sid = payload?.studentId || selectedStudentIds.value?.[0]
startLessonPlanPolling(sid)
}
function ensurePollingForCurrentStudents() {
(students.value || []).forEach(s => startLessonPlanPolling(s.id))
}
function stopPolling(studentId) {
const t = pollingTimers[studentId]
if (t) {
clearInterval(t)
delete pollingTimers[studentId]
}
if (generatingPercents.value[studentId] !== undefined) {
delete generatingPercents.value[studentId]
}
}
function stopAllPolling() {
Object.keys(pollingTimers).forEach(id => stopPolling(id))
}
onUnmounted(() => {
stopAllPolling()
})
onBeforeRouteLeave(() => {
stopAllPolling()
})
async function onDeleteStudent(row) { async function onDeleteStudent(row) {
try { try {
await ElMessageBox.confirm('确认删除该学生?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await deleteStudent(row.id) await deleteStudent(row.id)
ElMessage.success('删除成功') ElMessage.success('删除成功')
if (selectedStudentIds.value?.length) { if (selectedStudentIds.value?.length) {
@@ -319,11 +423,17 @@ async function onDeleteStudent(row) {
} }
await fetchStudents() await fetchStudents()
} catch (e) { } catch (e) {
if (e === 'cancel' || e === 'close') return
ElMessage.error('删除失败') ElMessage.error('删除失败')
} }
} }
async function onDeleteClass(row) { async function onDeleteClass(row) {
try { try {
await ElMessageBox.confirm('确认删除该班级?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await deleteClass(row.id) await deleteClass(row.id)
ElMessage.success('删除成功') ElMessage.success('删除成功')
if (selectedClassId.value === row.id) { if (selectedClassId.value === row.id) {
@@ -333,6 +443,7 @@ async function onDeleteClass(row) {
} }
await fetchClasses() await fetchClasses()
} catch (e) { } catch (e) {
if (e === 'cancel' || e === 'close') return
ElMessage.error('删除失败') ElMessage.error('删除失败')
} }
} }

View File

@@ -1,13 +1,14 @@
<template> <template>
<div class="common-layout"> <div class="common-layout">
<el-container> <el-container class="min-h-screen">
<el-header> <el-header>
<Header></Header> <Header></Header>
</el-header> </el-header>
<el-main class="p-4"> <el-container class="pt-4">
<el-main class="h-full">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6" v-loading="loading"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6" v-loading="loading">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div class="panel-shell p-4 sm:p-6">
<div class="text-lg font-semibold mb-4">学生详情</div> <div class="text-lg font-semibold mb-4">学生详情</div>
<template v-if="detail"> <template v-if="detail">
<el-descriptions :column="1" border> <el-descriptions :column="1" border>
@@ -22,8 +23,7 @@
<el-empty description="请从班级页跳转" /> <el-empty description="请从班级页跳转" />
</template> </template>
</div> </div>
<div class="panel-shell p-4 sm:p-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="text-lg font-semibold mb-4">学生词汇统计</div> <div class="text-lg font-semibold mb-4">学生词汇统计</div>
<template v-if="wordStat"> <template v-if="wordStat">
<el-descriptions :column="1" border> <el-descriptions :column="1" border>
@@ -36,36 +36,25 @@
<el-empty description="暂无统计" /> <el-empty description="暂无统计" />
</template> </template>
</div> </div>
<div class="panel-shell p-4 sm:p-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="text-md font-semibold mb-3">学生考试记录</div> <div class="text-md font-semibold mb-3">学生考试记录</div>
<ExamHistoryChart :data="history" /> <ExamHistoryChart :data="history" />
</div> </div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div class="panel-shell p-4 sm:p-6">
<div class="text-md font-semibold mb-3">学生学案记录</div> <div class="text-md font-semibold mb-3">学生学案记录</div>
<PlanHistoryChart :student-id="route.params.id" /> <PlanHistoryChart :student-id="route.params.id" />
</div> </div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div class="panel-shell p-4 sm:p-6 lg:col-span-2">
<div class="text-md font-semibold mb-3">词汇掌握热力图</div> <div class="text-md font-semibold mb-3">词汇掌握热力图</div>
<WordMasteryHeatmap :student-id="route.params.id" :columns="50" /> <WordMasteryHeatmap :student-id="route.params.id" :columns="heatmapColumns" />
</div> </div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div class="panel-shell p-4 sm:p-6 lg:col-span-2">
<div class="flex items-center justify-between mb-3"> <div class="text-md font-semibold mb-3">学情分析</div>
<div class="text-md font-semibold">学习分析</div> <StudyAnalysis :student-id="route.params.id" />
<el-button type="primary" size="small" :loading="analyzeLoading" @click="fetchStudyAnalyze">
生成学习分析
</el-button>
</div>
<template v-if="analysisHtml">
<div class="leading-7 text-gray-700 dark:text-gray-200" v-html="analysisHtml"></div>
</template>
<template v-else>
<el-empty description="点击右上按钮生成学习分析" />
</template>
</div> </div>
</div> </div>
</el-main> </el-main>
</el-container>
</el-container> </el-container>
</div> </div>
</template> </template>
@@ -74,29 +63,26 @@
import Header from '@/layouts/components/Header.vue' import Header from '@/layouts/components/Header.vue'
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { getStudentDetail, getStudentStudyAnalyze } from '@/api/student' import { getStudentDetail } from '@/api/student'
import { getStudentExamHistory } from '@/api/exam' import { getStudentExamHistory } from '@/api/exam'
import { getWordStudentDetail } from '@/api/words' import { getWordStudentDetail } from '@/api/words'
import ExamHistoryChart from '@/layouts/components/student/ExamHistoryChart.vue' import ExamHistoryChart from '@/layouts/components/student/ExamHistoryChart.vue'
import PlanHistoryChart from '@/layouts/components/student/PlanHistoryChart.vue' import PlanHistoryChart from '@/layouts/components/student/PlanHistoryChart.vue'
import WordMasteryHeatmap from '@/layouts/components/student/WordMasteryHeatmap.vue' import WordMasteryHeatmap from '@/layouts/components/student/WordMasteryHeatmap.vue'
import MarkdownIt from 'markdown-it' import StudyAnalysis from '@/layouts/components/student/StudyAnalysis.vue'
import Sidebar from '@/layouts/components/Sidebar.vue'
const loading = ref(false) const loading = ref(false)
const detail = ref(null) const detail = ref(null)
const route = useRoute() const route = useRoute()
const history = ref([]) const history = ref([])
const analyzeLoading = ref(false)
const analysisText = ref('')
const wordStat = ref(null) const wordStat = ref(null)
const md = new MarkdownIt({ const isMobile = ref(false)
html: false, const heatmapColumns = computed(() => isMobile.value ? 20 : 50)
linkify: true,
breaks: true function updateIsMobile() {
}) isMobile.value = window.matchMedia('(max-width: 768px)').matches
const analysisHtml = computed(() => { }
return analysisText.value ? md.render(analysisText.value) : ''
})
async function fetchDetail() { async function fetchDetail() {
const id = route.params.id const id = route.params.id
@@ -119,22 +105,10 @@ async function fetchExamHistory() {
history.value = Array.isArray(d?.data) ? d.data.slice().sort((a, b) => { history.value = Array.isArray(d?.data) ? d.data.slice().sort((a, b) => {
return new Date(a.startDate).getTime() - new Date(b.startDate).getTime() return new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
}) : [] }) : []
} // 遍历 history 中的 startDate 去掉其中的 T
history.value.forEach(item => {
async function fetchStudyAnalyze() { item.startDate = item.startDate.replace('T', ' ')
const id = route.params.id
if (!id) return
analyzeLoading.value = true
try {
const res = await getStudentStudyAnalyze({
studentId: Number(id)
}) })
const d = res.data
const raw = typeof d?.data === 'string' ? d.data : ''
analysisText.value = raw.replace(/\\n/g, '\n')
} finally {
analyzeLoading.value = false
}
} }
async function fetchWordStat() { async function fetchWordStat() {
@@ -149,5 +123,7 @@ onMounted(() => {
fetchDetail() fetchDetail()
fetchExamHistory() fetchExamHistory()
fetchWordStat() fetchWordStat()
updateIsMobile()
window.addEventListener('resize', updateIsMobile)
}) })
</script> </script>

Some files were not shown because too many files have changed in this diff Show More