feat(student): 实现学生学习分析功能

- 新增AnalyzeStudentStudyReqVO用于分析请求参数封装
- StudentService接口新增analyzeStudentStudy方法及其实现
- 实现分析逻辑,查询最近7天学生考试及单词掌握记录,构造分析数据
- 通过DifyArticleClient调用外部AI服务生成学习分析结果
- 使用Redis缓存分析结果,设置3天过期
- 新增ExamWordsJudgeResultDetail和WordMasteryDetail数据模型
- Mapper新增支持根据学生ID和时间范围查询考试结果和单词掌握日志
- DifyArticleClient新增sendStudentAnalyze方法调用分析接口
- 前端学生页面新增学习分析面板及调用接口,支持超时设置
- 修改路由权限配置,允许访问学习分析接口
- 添加markdown-it库支持分析结果富文本渲染
- 移除RoleServiceImpl中redis设置过期时间,改为永久保存
This commit is contained in:
lbw
2025-12-24 15:22:18 +08:00
parent 4135b72648
commit 260c2c79f1
19 changed files with 342 additions and 11 deletions

View File

@@ -33,6 +33,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
.notMatch("/student/detail") .notMatch("/student/detail")
.notMatch("/studentLessonPlans/list") .notMatch("/studentLessonPlans/list")
.notMatch("/studentLessonPlans/history") .notMatch("/studentLessonPlans/history")
.notMatch("/student/analyze")
.notMatch("/unit/list") .notMatch("/unit/list")
.notMatch("/vocabulary/list") .notMatch("/vocabulary/list")
.notMatch("/plan/download") .notMatch("/plan/download")

View File

@@ -0,0 +1,10 @@
package com.yinlihupo.enlish.service.constant;
public class StudentConstant {
public static final String ANALYZE_STUDENT_STUDY = "analyzeStudentStudy";
public static String buildAnalyzeStudentStudyKey(Integer studentId) {
return ANALYZE_STUDENT_STUDY + ":" + studentId;
}
}

View File

@@ -85,4 +85,11 @@ public class StudentController {
studentService.deleteStudent(deleteStudentReqVO.getStudentId()); studentService.deleteStudent(deleteStudentReqVO.getStudentId());
return Response.success(); return Response.success();
} }
@PostMapping("analyze")
@ApiOperationLog(description = "学生学习分析")
public Response<String> analyzeStudentStudy(@RequestBody AnalyzeStudentStudyReqVO analyzeStudentStudyReqVO) {
String analyzeStudentStudy = studentService.analyzeStudentStudy(analyzeStudentStudyReqVO.getStudentId());
return Response.success(analyzeStudentStudy);
}
} }

View File

@@ -22,4 +22,6 @@ public interface ExamWordsJudgeResultDOMapper {
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);
} }

View File

@@ -16,4 +16,6 @@ public interface WordMasteryLogDOMapper {
int batchUpdateStudentMastery(@Param("wordMasteryLogDOs") List<WordMasteryLogDO> wordMasteryLogDOs); int batchUpdateStudentMastery(@Param("wordMasteryLogDOs") List<WordMasteryLogDO> wordMasteryLogDOs);
int selectStudentStrengthCount(@Param("studentId") Integer studentId); int selectStudentStrengthCount(@Param("studentId") Integer studentId);
List<WordMasteryLogDO> selectByStudentIdAndLimitTime(@Param("studentId") Integer studentId);
} }

View File

@@ -0,0 +1,28 @@
package com.yinlihupo.enlish.service.model.bo.exam;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class ExamWordsJudgeResultDetail {
private Integer correctWordCount;
private Integer wrongWordCount;
private LocalDateTime startDate;
private List<String> correctWords;
private List<String> wrongWords;
private String msg;
}

View File

@@ -0,0 +1,23 @@
package com.yinlihupo.enlish.service.model.bo.exam;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class WordMasteryDetail {
private String word;
private Integer reviewCount;
private Double memoryStrength;
private LocalDateTime update_time;
}

View File

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

View File

@@ -20,4 +20,6 @@ public interface StudentService {
void addStudent(AddStudentReqVO addStudentReqVO); void addStudent(AddStudentReqVO addStudentReqVO);
void deleteStudent(Integer studentId); void deleteStudent(Integer studentId);
String analyzeStudentStudy(Integer studentId);
} }

View File

@@ -46,7 +46,7 @@ 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);
stringRedisTemplate.opsForValue().set(RoleConstants.buildUserRoleKey(userId), JsonUtils.toJsonString(user2RoleKeys), 60 * 60 * 24); stringRedisTemplate.opsForValue().set(RoleConstants.buildUserRoleKey(userId), JsonUtils.toJsonString(user2RoleKeys));
}); });
} }

View File

@@ -1,19 +1,24 @@
package com.yinlihupo.enlish.service.service.student; package com.yinlihupo.enlish.service.service.student;
import com.yinlihupo.enlish.service.domain.dataobject.ClassDO; import com.yinlihupo.enlish.service.constant.StudentConstant;
import com.yinlihupo.enlish.service.domain.dataobject.GradeDO; import com.yinlihupo.enlish.service.domain.dataobject.*;
import com.yinlihupo.enlish.service.domain.dataobject.StudentDO;
import com.yinlihupo.enlish.service.domain.mapper.*; import com.yinlihupo.enlish.service.domain.mapper.*;
import com.yinlihupo.enlish.service.model.bo.StudentDetail; import com.yinlihupo.enlish.service.model.bo.StudentDetail;
import com.yinlihupo.enlish.service.model.bo.exam.ExamWordsJudgeResultDetail;
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.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.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.*;
import java.util.Map; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@@ -29,6 +34,12 @@ public class StudentServiceImpl implements StudentService {
private VocabularyBankDOMapper vocabularyBankMapper; private VocabularyBankDOMapper vocabularyBankMapper;
@Resource @Resource
private WordMasteryLogDOMapper wordMasteryLogDOMapper; private WordMasteryLogDOMapper wordMasteryLogDOMapper;
@Resource
private ExamWordsJudgeResultDOMapper examWordsJudgeResultDOMapper;
@Resource
private DifyArticleClient difyArticleClient;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Override @Override
public List<StudentDO> getStudentsByClassIdAndGradeId(Integer classId, Integer gradeId, String name, Integer pageNo, Integer pageSize) { public List<StudentDO> getStudentsByClassIdAndGradeId(Integer classId, Integer gradeId, String name, Integer pageNo, Integer pageSize) {
@@ -94,4 +105,66 @@ public class StudentServiceImpl implements StudentService {
public void deleteStudent(Integer studentId) { public void deleteStudent(Integer studentId) {
studentDOMapper.deleteById(studentId); studentDOMapper.deleteById(studentId);
} }
@Override
public String analyzeStudentStudy(Integer studentId) {
String key = StudentConstant.buildAnalyzeStudentStudyKey(studentId);
if (redisTemplate.hasKey(key)) {
Object ans = redisTemplate.opsForValue().get(key);
return JsonUtils.toJsonString(ans);
}
List<ExamWordsJudgeResultDO> examWordsJudgeResultDOS = examWordsJudgeResultDOMapper.selectByStudentIdAndLimitTime(studentId);
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());
List<VocabularyBankDO> vocabularyBankDOS = vocabularyBankMapper.selectVocabularyBankDOListByIds(wordIds);
Map<Integer, VocabularyBankDO> id2Word = vocabularyBankDOS.stream().collect(Collectors.toMap(VocabularyBankDO::getId, vocabularyBankDO -> vocabularyBankDO));
List<ExamWordsJudgeResultDetail> examWordsJudgeResultDetails = new ArrayList<>();
for (ExamWordsJudgeResultDO examWordsJudgeResultDO : examWordsJudgeResultDOS) {
List<Integer> correctWordIds = examWordsJudgeResultDO.getCorrectWordIds();
List<String> correctWords = correctWordIds.stream().map(id2Word::get).map(VocabularyBankDO::getWord).toList();
List<Integer> wrongWordIds = examWordsJudgeResultDO.getWrongWordIds();
List<String> wrongWords = wrongWordIds.stream().map(id2Word::get).map(VocabularyBankDO::getWord).toList();
examWordsJudgeResultDetails.add(ExamWordsJudgeResultDetail.builder()
.correctWordCount(examWordsJudgeResultDO.getCorrectWordCount())
.wrongWordCount(examWordsJudgeResultDO.getWrongWordCount())
.startDate(examWordsJudgeResultDO.getStartDate())
.correctWords(correctWords)
.wrongWords(wrongWords)
.msg(examWordsJudgeResultDO.getMsg()).build()
);
}
Map<String, Object> studentStudyInfo = new HashMap<>();
studentStudyInfo.put("考试记录", examWordsJudgeResultDetails);
List<WordMasteryLogDO> wordMasteryLogDOS = wordMasteryLogDOMapper.selectByStudentIdAndLimitTime(studentId);
List<VocabularyBankDO> masteredWords = vocabularyBankMapper.selectVocabularyBankDOListByIds(wordMasteryLogDOS.stream().map(WordMasteryLogDO::getWordId).toList());
Map<Integer, VocabularyBankDO> id2MasteryWord = masteredWords.stream().collect(Collectors.toMap(VocabularyBankDO::getId, vocabularyBankDO -> vocabularyBankDO));
List<WordMasteryDetail> wordMasteryDetails = new ArrayList<>();
for (WordMasteryLogDO wordMasteryLogDO : wordMasteryLogDOS) {
wordMasteryDetails.add(WordMasteryDetail.builder()
.word(id2MasteryWord.get(wordMasteryLogDO.getWordId()).getWord())
.reviewCount(wordMasteryLogDO.getReviewCount())
.memoryStrength(wordMasteryLogDO.getMemoryStrength())
.update_time(wordMasteryLogDO.getUpdate_time())
.build());
}
studentStudyInfo.put("单词掌握情况", wordMasteryDetails);
try {
String analyze = difyArticleClient.sendStudentAnalyze(JsonUtils.toJsonString(studentStudyInfo)).getAnswer();
// 设置过期时间 3 天
redisTemplate.opsForValue().set(key, analyze);
redisTemplate.expire(key, 3, TimeUnit.DAYS);
return analyze;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
} }

View File

@@ -21,6 +21,7 @@ public class DifyArticleClient {
@Value("${ai.key}") @Value("${ai.key}")
private String apiKey; private String apiKey;
private String anaKey = "app-hrUFcopdcpnflsvpHWRuBfCp";
@Value("${ai.url}") @Value("${ai.url}")
private String baseUrl; private String baseUrl;
private final HttpClient httpClient; private final HttpClient httpClient;
@@ -35,6 +36,40 @@ public class DifyArticleClient {
this.objectMapper = new ObjectMapper(); this.objectMapper = new ObjectMapper();
} }
public DifyResponse sendStudentAnalyze(String query) throws Exception {
String endpoint = this.baseUrl;
// 1. 构建请求体对象
ChatRequest payload = new ChatRequest();
payload.setQuery(query);
payload.setUser(String.valueOf(1));
payload.setResponseMode("blocking"); // 使用阻塞模式,一次性返回
payload.setInputs(new HashMap<>());
// 2. 序列化为 JSON 字符串
String jsonBody = objectMapper.writeValueAsString(payload);
// 3. 构建 HTTP 请求
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(endpoint))
.header("Authorization", "Bearer " + anaKey)
.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. 反序列化响应体
return objectMapper.readValue(response.body(), DifyResponse.class);
}
/** /**
* 发送对话请求 (阻塞模式) * 发送对话请求 (阻塞模式)
* *
@@ -64,7 +99,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 " + this.apiKey) .header("Authorization", "Bearer " + apiKey)
.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

@@ -74,5 +74,11 @@
order by start_date desc order by start_date desc
limit 500; limit 500;
</select> </select>
<select id="selectByStudentIdAndLimitTime" resultMap="ResultMapWithBLOBs">
select *
from exam_words_judge_result
where student_id = #{studentId}
and start_date between date_sub(now(), interval 7 day) and now()
</select>
</mapper> </mapper>

View File

@@ -52,4 +52,10 @@
</foreach> </foreach>
</update> </update>
<select id="selectByStudentIdAndLimitTime" resultMap="BaseResultMap">
select *
from word_mastery_log
where student_id = #{studentId}
and update_time between date_sub(now(), interval 7 day) and now()
</select>
</mapper> </mapper>

View File

@@ -2,6 +2,8 @@ package com.yinlihupo.enlish.service.service.exam;
import com.yinlihupo.enlish.service.domain.dataobject.ExamWordsJudgeResultDO; import com.yinlihupo.enlish.service.domain.dataobject.ExamWordsJudgeResultDO;
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.utils.DifyArticleClient;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -16,7 +18,10 @@ public class ExamWordsJudgeServiceTest {
@Resource @Resource
private ExamWordsJudgeService examWordsJudgeService; private ExamWordsJudgeService examWordsJudgeService;
@Resource
private StudentService studentService;
@Resource
private DifyArticleClient difyArticleClient;
@Test @Test
public void judgeExamWords() { public void judgeExamWords() {
examWordsJudgeService.judgeExamWords(1); examWordsJudgeService.judgeExamWords(1);
@@ -27,4 +32,17 @@ public class ExamWordsJudgeServiceTest {
List<ExamWordsJudgeResultDO> examWordsJudgeResult = examWordsJudgeService.getExamWordsJudgeResult(1, 10); List<ExamWordsJudgeResultDO> examWordsJudgeResult = examWordsJudgeService.getExamWordsJudgeResult(1, 10);
log.info("examWordsJudgeResult:{}", examWordsJudgeResult); log.info("examWordsJudgeResult:{}", examWordsJudgeResult);
} }
@Test
public void selectExamWordsJudgeResult2() {
String s = studentService.analyzeStudentStudy(1);
try {
DifyArticleClient.DifyResponse difyResponse = difyArticleClient.sendStudentAnalyze(s);
String answer = difyResponse.getAnswer();
log.info("answer:{}", answer);
} catch (Exception e) {
throw new RuntimeException(e);
}
log.info("s:{}", s);
}
} }

View File

@@ -13,6 +13,7 @@
"echarts": "^6.0.0", "echarts": "^6.0.0",
"element-plus": "^2.12.0", "element-plus": "^2.12.0",
"flowbite": "^1.8.1", "flowbite": "^1.8.1",
"markdown-it": "^14.1.0",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"universal-cookie": "^8.0.1", "universal-cookie": "^8.0.1",
@@ -1413,6 +1414,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/async-validator": { "node_modules/async-validator": {
"version": "4.2.5", "version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@@ -2326,6 +2333,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/local-pkg": { "node_modules/local-pkg": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
@@ -2378,6 +2394,23 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2387,6 +2420,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
@@ -2865,6 +2904,15 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/quansync": { "node_modules/quansync": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@@ -3233,6 +3281,12 @@
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/ufo": { "node_modules/ufo": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",

View File

@@ -14,6 +14,7 @@
"echarts": "^6.0.0", "echarts": "^6.0.0",
"element-plus": "^2.12.0", "element-plus": "^2.12.0",
"flowbite": "^1.8.1", "flowbite": "^1.8.1",
"markdown-it": "^14.1.0",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"universal-cookie": "^8.0.1", "universal-cookie": "^8.0.1",

View File

@@ -24,3 +24,10 @@ export function deleteStudent(id) {
studentId: id studentId: id
}) })
} }
// 加一个最大响应时间 20 秒
export function getStudentStudyAnalyze(data) {
return axios.post('/student/analyze', data, {
timeout: 20000
})
}

View File

@@ -32,6 +32,20 @@
<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="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="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>
</el-main> </el-main>
@@ -41,17 +55,28 @@
<script setup> <script setup>
import Header from '@/layouts/components/Header.vue' import Header from '@/layouts/components/Header.vue'
import { ref, onMounted } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { getStudentDetail } from '@/api/student' import { getStudentDetail, getStudentStudyAnalyze } from '@/api/student'
import { getStudentExamHistory } from '@/api/exam' import { getStudentExamHistory } from '@/api/exam'
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 MarkdownIt from 'markdown-it'
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 md = new MarkdownIt({
html: false,
linkify: true,
breaks: true
})
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
@@ -76,6 +101,22 @@ async function fetchExamHistory() {
}) : [] }) : []
} }
async function fetchStudyAnalyze() {
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
}
}
onMounted(() => { onMounted(() => {
fetchDetail() fetchDetail()
fetchExamHistory() fetchExamHistory()