From d98ff0ff8ba8df6f24f54772d485ee1d4e63ccda Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 03:23:49 +0800 Subject: [PATCH] feat(aihr): add employee mistake book audit --- .../aihr/controller/AihrMobileController.java | 6 + .../dromara/aihr/domain/AihrPracticeDto.java | 14 ++ .../aihr/service/AihrMobileSeedService.java | 5 + .../aihr/service/AihrPracticeSeedService.java | 60 +++++++++ docs/API_INTEGRATION.md | 2 +- docs/BRD_IMPLEMENTATION_AUDIT.md | 62 +++++++++ docs/FIGMA_IMPLEMENTATION_MATRIX.md | 14 +- mobile-uni/src/pages/user/learning/index.vue | 126 +++++++++++++++++- mobile-uni/src/services/practice.ts | 10 ++ mobile-uni/src/types/api.ts | 13 ++ mobile-uni/tests/mobile-user-pages.test.mjs | 13 ++ scripts/demo-check.sh | 2 + 12 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 docs/BRD_IMPLEMENTATION_AUDIT.md diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java index 672ccce5..0738460a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java @@ -13,6 +13,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; +import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse; import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse; @@ -91,6 +92,11 @@ public class AihrMobileController { return detail == null ? R.fail("训练记录不存在") : R.ok(reviewDetailPayload(detail)); } + @GetMapping("/practice/mistakes") + public R> practiceMistakes(@RequestParam(required = false) String extPartyId) { + return R.ok(mobileSeedService.practiceMistakes(ownMobileExtPartyId(extPartyId))); + } + @GetMapping("/practice/reviews") public R> practiceReviews(@RequestParam(required = false) String projectCode, @RequestParam(required = false) String extPartyId, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java index 06561529..747b2794 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java @@ -142,6 +142,20 @@ public final class AihrPracticeDto { public record RecordResponse(Long id, String sessionId, String time, String trainee, String scene, Integer score, String status, String summary, String extPartyId) { } + public record MistakeResponse( + Long id, + String sessionId, + String scenarioId, + String scenarioName, + Integer score, + String cause, + Long retryAssignmentId, + String retryStatus, + String time, + String summary + ) { + } + public record DialogueResponse(String role, String label, String text, String audioUrl, Long audioOssId, Integer emotion, Integer trust, Boolean redFlag, String coachHint) { public DialogueResponse(String role, String label, String text) { this(role, label, text, "", null, null, null, null, ""); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java index c37b86e5..eaa0c278 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java @@ -9,6 +9,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; +import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse; import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse; @@ -49,6 +50,10 @@ public class AihrMobileSeedService { return practiceSeedService.mobileHistory(extPartyId, 5); } + public List practiceMistakes(String extPartyId) { + return practiceSeedService.mobileMistakes(extPartyId, 20); + } + public List practiceReviews() { return practiceSeedService.pendingReviewRecords(5); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index dbfee1e6..958345ac 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -25,6 +25,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest; import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; +import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse; import org.dromara.aihr.domain.AihrPracticeDto.RubricDimensionResponse; @@ -722,6 +723,65 @@ public class AihrPracticeSeedService { """, this::mapRecord, TENANT_ID, extPartyId.trim(), safeLimit); } + public List mobileMistakes(String extPartyId, int limit) { + ensurePracticeTable(); + ensureAssignmentTable(); + if (isBlank(extPartyId)) { + return List.of(); + } + int safeLimit = normalizeLimit(limit); + return jdbcTemplate.query(""" + SELECT s.id, + s.session_id, + s.scenario_id, + s.scenario_name, + s.total_score, + CASE + WHEN s.dialogue_json LIKE '%"redFlag":true%' THEN '红线话术' + WHEN COALESCE(s.dim_emotion, 100) < 60 THEN '情绪承接' + WHEN COALESCE(s.dim_compliance, 100) < 60 THEN '流程合规' + ELSE '综合得分不足' + END AS cause, + (SELECT a.id + FROM aihr_practice_assignment a + WHERE a.tenant_id = s.tenant_id + AND a.ext_party_id = s.ext_party_id + AND a.scenario_id = s.scenario_id + AND a.source = 'retry' + ORDER BY a.id DESC + LIMIT 1) AS retry_assignment_id, + COALESCE((SELECT a.status + FROM aihr_practice_assignment a + WHERE a.tenant_id = s.tenant_id + AND a.ext_party_id = s.ext_party_id + AND a.scenario_id = s.scenario_id + AND a.source = 'retry' + ORDER BY a.id DESC + LIMIT 1), '未生成') AS retry_status, + s.finished_time, + s.summary + FROM aihr_practice_session s + WHERE s.tenant_id = ? + AND s.mode = 'mobile' + AND s.ext_party_id = ? + AND s.finished_time IS NOT NULL + AND (s.total_score < 80 OR s.dialogue_json LIKE '%"redFlag":true%') + ORDER BY s.finished_time DESC, s.id DESC + LIMIT ? + """, (rs, rowNum) -> new MistakeResponse( + rs.getLong("id"), + rs.getString("session_id"), + rs.getString("scenario_id"), + firstNonBlank(rs.getString("scenario_name"), "场景训练"), + rs.getInt("total_score"), + firstNonBlank(rs.getString("cause"), "综合得分不足"), + rs.getObject("retry_assignment_id") == null ? null : rs.getLong("retry_assignment_id"), + firstNonBlank(rs.getString("retry_status"), "未生成"), + rs.getTimestamp("finished_time") == null ? "" : rs.getTimestamp("finished_time").toLocalDateTime().format(TIME_FORMATTER), + firstNonBlank(rs.getString("summary"), "建议回看训练对话和导师改写") + ), TENANT_ID, extPartyId.trim(), safeLimit); + } + public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit) { TeamScope scope = teamScope(supervisorExtPartyId); int safeLimit = normalizeTeamLimit(limit); diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 05baeffc..06096112 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -17,7 +17,7 @@ | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) | | 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 | | 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 | -| 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/profile`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项;普通员工返回“无主管权限”。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度 | +| 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项;普通员工返回“无主管权限”。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度 | | 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 | ## 后端落点 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md new file mode 100644 index 00000000..4b4ce5e2 --- /dev/null +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -0,0 +1,62 @@ +# BRD 功能审查与未完成项记录 + +更新时间:2026-07-14 + +## 1. 审查口径 + +本记录以 [物业AI人力资源系统业务需求文档BRD.md](物业AI人力资源系统业务需求文档BRD.md) 为业务事实源,以 [FIGMA_IMPLEMENTATION_MATRIX.md](FIGMA_IMPLEMENTATION_MATRIX.md) 判断页面与视觉落地,以 [API_INTEGRATION.md](API_INTEGRATION.md) 和真实接口/浏览器结果判断运行闭环。 + +`Figma` 的 `[x]` 只表示画板对应页面已实现,不代表正式试点数据、组织权限或线上生产验收已经完成。BRD 的“阶段二个人 AI 助理”和三期运营页面不纳入当前一期修复范围。 + +## 2. 已确认完成的当前基线 + +| 领域 | 当前证据 | 结论 | +|---|---|---| +| 员工端今日/练/问/我 | `mobile-uni/src/pages/user/*`;员工首页、训练前预习、独立练习路由已发布 | 当前代码和线上包一致,后续只做回归 | +| 主管工作台/复盘/派发 | `mobile-uni/src/pages/supervisor/*`;线上工作台已完成视觉回归 | 页面主链路已在位;正式主管身份仍依赖组织数据 | +| 候选人面试与 HR 复核 | `mobile-uni/src/pages/candidate/*`、`frontend/src/views/recruit/interview.vue` | 工程闭环已在位,需继续保留人工复核证据 | +| SOP/RAG/资料处理 | `frontend/src/views/knowledge/*`、`backend/.../AihrSopSeedService.java` | 搜索、引用、反馈、异步处理和评审入口已在位 | +| 对练/每日题/主管带教 | `backend/.../AihrPracticeSeedService.java`、`mobile-uni/src/pages/user/practice` | 主闭环已在位;低分再练、错题归因仍需更明确的用户可见闭环 | +| 案例上传/整理/筛选 | `backend/.../AihrCaseService.java`、`mobile-uni/src/pages/user/cases`、`frontend/src/views/knowledge/cases.vue` | 上传与整理已在位,学习详情尚未形成稳定模型 | + +## 3. 未完成项与优先级 + +### P0:必须先解决,否则不能称为正式试点完成 + +| 缺口 | BRD依据 | 当前事实 | 下一步 | +|---|---|---|---| +| 正式主管身份、项目范围和数据权限 | 5.4、L1-B、G3 | 代码已有 `aihr_org_snapshot`、岗位/项目过滤和主管接口,但正式上游快照的手机号关联不足,线上视觉测试不能替代真实主管身份验证 | 用正式组织同步 dry-run 先核对项目、岗位、`ext_party_id`、手机号;只在上游数据合规后执行正式同步 | +| M5 正式试点证据 | L1-B/C/D、G1/G3/G4 | 本地严格门禁和导出能力已存在;本地样本不能代表正式窗口,不能用 seed/烟测数据替代 | 固定 1–2 个住宅项目和窗口,按唯一在职身份采集训练、人工校准、SOP 评审、完训率和满意度 | +| AI 分数与人工复核边界 | 4.1、4.5、G1/G2 | 面试和对练已有人工复核/校准入口;仍需在正式试点报告中保留“AI 建议、人工可否决”的证据 | 导出中保留 AI 分、人工分、复核人、复核时间和最终采用值 | + +### P1:一期 BRD 功能仍未形成完整业务闭环 + +| 缺口 | BRD依据 | 当前事实 | 建议实现顺序 | +|---|---|---|---| +| 岗位-SOP匹配与上岗资格 gating | 3.1、4.2、TechSpec P1 | 当前有岗位选择、SOP 搜索和训练任务,但未发现稳定的 `onboard_task` / `qualification_gate` 业务表与页面闭环 | 先建岗位适用 SOP 只读结果,再补培训任务状态,最后补“未通过不得标记上岗”的资格规则 | +| 智能错题本 | 3.1、4.4、TechSpec P1 | 已补 `/api/aihr/mobile/practice/mistakes` 和员工“学”页错题本:按本人聚合低分/红线回合,展示原因、分数、再练状态,并可跳已有 retry assignment;暂未做复杂原因筛选和独立错题表 | 下一步补原因筛选、复盘详情关联和主管侧聚合,仍复用现有 session/assignment/annotation | +| 案例学习/视频详情 | 3.1、4.6、矩阵第35行 | `AihrCaseService` 当前用内存 `CaseState` + `aihr_case_record` 保存转写/摘要,没有稳定 media URL、学习点、主管点评和案例详情 API | 先扩持久化契约和权限,再接预渲染样片;没有真实媒体 URL 时不造播放按钮 | +| 案例库质量与数量 | L1-B、4.6/4.7 | 上传/整理/筛选路径已有;“至少20条脱敏案例”和每周每项目一条需要正式数据证明 | 增加案例统计/脱敏检查导出,按项目窗口采集,不用演示 seed 冒充 | +| RAG 可用率与知识治理 | 4.7、L1-C、G3 | 搜索、引用、反馈、知识缺口和人工评审入口已有;住宅核心流程覆盖率与可用率仍需正式评审数据 | 按住宅核心流程建立评审批次,保留命中片段、评审人、状态、版本和来源 | + +### P2/P3:明确暂缓,不应混入当前修复 + +- 个人 AI 助理、个人资料库、网页抓取、引用问答、PPT 生成:BRD 4.8,阶段二。 +- 开放问题榜、积分/排行榜/荣誉墙、完整七维偏好、独立 AI 教练编排:阶段二/三或后续运营范围。 +- 师徒关系、拜师池、认证和带徒福利:业务已取消,继续保持不实现。 + +## 4. 首批执行顺序 + +1. 先完成正式组织快照 dry-run 与项目范围核对;不修改正式数据,不用手机号猜角色。 +2. 在本地/测试环境实现“错题本只读聚合”,复用现有训练记录和 retry assignment,补 API、员工入口和主管查看边界。 +3. 再实现岗位-SOP匹配与培训任务状态;资格 gating 放在有真实资格规则后接入,避免先做一个无法运营的开关。 +4. 单独设计案例详情数据契约,确定媒体来源、样片、学习点和主管点评后再开发详情页。 +5. 用正式试点窗口跑 BRD L1/M5,而不是继续增加视觉页面。 + +## 5. 每批完成定义 + +- 有明确 BRD 条款、代码路径和数据证据。 +- 读写权限按员工本人、主管项目范围、HR/运营角色区分。 +- 真实接口或浏览器验证通过;不能验证的外部数据依赖必须标明。 +- 不用 seed、烟测样本或固定演示账号冒充正式试点达标。 +- 发布前同步矩阵、API 文档、验收脚本和本记录。 diff --git a/docs/FIGMA_IMPLEMENTATION_MATRIX.md b/docs/FIGMA_IMPLEMENTATION_MATRIX.md index 9514bc40..a68b4c3d 100644 --- a/docs/FIGMA_IMPLEMENTATION_MATRIX.md +++ b/docs/FIGMA_IMPLEMENTATION_MATRIX.md @@ -40,7 +40,7 @@ | 优先级 | 项目 | 需要什么才能继续 | |---|---|---| -| P0 | 员工今日线上视觉 | 发布当前 `mobile-uni` 静态包并做线上浏览器回归;本地源码不再继续堆视觉代码。 | +| P0 | 线上包与角色路由 | 员工今日、训练前预习和主管工作台当前包已发布并完成浏览器回归;后续只在源码变更后回归,不再把旧线上包当作当前缺口。主管正式角色仍需正式组织数据验证。 | | P1 | 案例学习/视频详情 | 先补真实案例详情模型:`caseId`、媒体 URL、学习点、主管点评、相关训练场景;字段和 API 确定后再实现页面。 | | P1 | 正式组织数据 | 用正式员工、项目和试点时间窗口验证团队量化、成长证据和 M5 导出;不使用本地 smoke 样本替代。 | | P2 | 三期页面 | 个人 AI 助理、开放问题榜、积分/荣誉、完整七维偏好、独立 AI 教练编排按阶段二/三单独立项。 | @@ -50,7 +50,7 @@ | # | 页面/状态 | Figma 位置 | 代码路径 | 当前实现 | 是否修正 | |---:|---|---|---|---|---| -| 1 | 员工端:今日 | — | `mobile-uni/src/pages/user/today/index.vue` | [x] 已实现 | 本地源码已对齐;线上待发布,线上仍是旧视觉 | +| 1 | 员工端:今日 | — | `mobile-uni/src/pages/user/today/index.vue` | [x] 已实现 | 本地源码已对齐;2026-07-14 已发布并用员工账号完成线上浏览器回归 | | 2 | 员工端:练入口 | — | `mobile-uni/src/pages/user/practice/index.vue` | [x] 已实现 | 已验证场景首屏、最近练过、每日三题和独立练习入口;蓝白视觉已复核 | | 3 | 员工端:问 | — | `mobile-uni/src/pages/user/sop/index.vue` | [x] 已实现 | 已验证搜索、引用、反馈状态和总结卡 | | 4 | 员工端:我 | — | `mobile-uni/src/pages/user/profile/index.vue` | [x] 已实现 | 已验证登录、岗位、成长摘要、训练历史和详情入口 | @@ -127,8 +127,8 @@ ## 当前优先级 -1. **P0:主管角色路由**。本地页面存在,但线上主管账号仍可能进入员工首页。 -2. **P0:线上包追平当前源码**。员工首页、训练页和登录页存在明显视觉版本滞后。 -3. **P1:按本矩阵完成阶段一验收**。重点是员工训练、主管复盘、候选人面试、知识库和案例闭环。 -4. **P2:阶段二再做个人 AI 助理和开放问题榜**,先补 API、权限和数据模型,再做页面。 -5. **P3:量化、偏好、积分和荣誉**,等待稳定训练数据与人工校准后再实现。 +1. **P0:正式主管角色与项目范围**。线上视觉已发布,但正式主管手机号/外部组织映射和项目级权限仍需用正式组织数据验证。 +2. **P0:M5 正式试点证据**。本地严格门禁可跑通,但正式窗口内的员工、训练次数、校准、SOP 评审和完训率仍需真实数据。 +3. **P1:按 BRD 补齐一期业务缺口**。优先是岗位-SOP匹配与上岗资格、错题本可见闭环、案例学习/视频详情的稳定数据模型。 +4. **P2:案例详情数据模型**。先补 `caseId`、媒体/样片、学习点、主管点评等稳定契约,再实现学习页,不用假视频占位。 +5. **P3:阶段二/三页面**。个人 AI 助理、开放问题榜、积分/荣誉、完整偏好和独立 AI 教练编排按后续阶段单独立项。 diff --git a/mobile-uni/src/pages/user/learning/index.vue b/mobile-uni/src/pages/user/learning/index.vue index 3d351eed..a162cd34 100644 --- a/mobile-uni/src/pages/user/learning/index.vue +++ b/mobile-uni/src/pages/user/learning/index.vue @@ -68,6 +68,39 @@ 暂无今日任务 + + + + 错题本 + 从低分和红线回合中自动归集 + + + + + + + + {{ item.cause }} + {{ item.score }} 分 + + {{ item.scenarioName }} + {{ item.time }} · {{ item.summary }} + 再练状态:{{ item.retryStatus }} + + + + + 完成训练后,低分和红线回合会自动出现在这里 + + 岗位 SOP @@ -145,9 +178,10 @@ import { onShow } from '@dcloudio/uni-app'; import type { KnowledgeSearchResponse, PracticeAssignment, + PracticeMistake, PracticePrepCard } from '@/types/api'; -import { getPracticeAssignments, getPracticePrepCard } from '@/services/practice'; +import { getPracticeAssignments, getPracticeMistakes, getPracticePrepCard } from '@/services/practice'; import { searchKnowledge } from '@/services/knowledge'; import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { buildLearningTopics, selectLearningTopic } from '@/services/learning'; @@ -161,10 +195,12 @@ const pendingSopQueryKey = 'aihr_sop_pending_query'; const loggedIn = ref(false); const message = ref(''); const assignments = ref([]); +const mistakes = ref([]); const prepCard = ref(null); const knowledgeResult = ref(null); const activeTopic = ref('岗前 SOP'); const assignmentLoading = ref(false); +const mistakeLoading = ref(false); const prepLoading = ref(false); const knowledgeLoading = ref(false); const positionProfile = ref(getSelectedPositionProfile()); @@ -235,6 +271,19 @@ const loadAssignments = async () => { } }; +const loadMistakes = async () => { + if (!loggedIn.value) return; + mistakeLoading.value = true; + try { + mistakes.value = await getPracticeMistakes(); + } catch (error) { + mistakes.value = []; + handleRequestError(error, '错题本加载失败'); + } finally { + mistakeLoading.value = false; + } +}; + const loadPrepCard = async () => { if (!loggedIn.value) return; prepLoading.value = true; @@ -267,6 +316,7 @@ const loadPage = async () => { message.value = ''; if (!loggedIn.value) { assignments.value = []; + mistakes.value = []; prepCard.value = null; knowledgeResult.value = null; return; @@ -275,7 +325,7 @@ const loadPage = async () => { return; } refreshAuthState(); - await loadAssignments(); + await Promise.all([loadAssignments(), loadMistakes()]); activeTopic.value = selectLearningTopic(assignments.value, defaultTopic.value); await Promise.all([ loadKnowledge(activeTopic.value), @@ -293,6 +343,12 @@ const openAssignment = (item: PracticeAssignment) => { uni.switchTab({ url: '/pages/user/practice/index' }); }; +const openMistake = (item: PracticeMistake) => { + if (!item.retryAssignmentId) return; + uni.setStorageSync(pendingAssignmentKey, String(item.retryAssignmentId)); + uni.switchTab({ url: '/pages/user/practice/index' }); +}; + const goPractice = () => { if (firstPracticeAssignment.value) { uni.setStorageSync(pendingAssignmentKey, String(firstPracticeAssignment.value.id)); @@ -320,6 +376,72 @@ onShow(() => { padding-bottom: 42px; } +.panel-caption { + display: block; + margin-top: 4px; + color: #7b8494; + font-size: 12px; +} + +.mistake-list { + display: grid; + gap: 10px; +} + +.mistake-row { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px; + border: 1px solid #fee2e2; + border-radius: 12px; + background: #fff8f8; +} + +.mistake-copy { + min-width: 0; + flex: 1; +} + +.mistake-meta { + display: flex; + gap: 8px; + align-items: center; +} + +.mistake-cause { + color: #bd0010; + font-size: 12px; + font-weight: 700; +} + +.mistake-score { + color: #7b8494; + font-size: 12px; +} + +.mistake-title, +.mistake-desc, +.mistake-status { + display: block; +} + +.mistake-title { + margin-top: 4px; + color: #1f2937; + font-size: 14px; + font-weight: 700; +} + +.mistake-desc, +.mistake-status { + margin-top: 4px; + color: #7b8494; + font-size: 12px; + line-height: 1.5; +} + .login-panel { display: flex; gap: 14px; diff --git a/mobile-uni/src/services/practice.ts b/mobile-uni/src/services/practice.ts index a1bfea49..ffe59157 100644 --- a/mobile-uni/src/services/practice.ts +++ b/mobile-uni/src/services/practice.ts @@ -2,6 +2,7 @@ import type { PracticeFinishResponse, PracticePrepCard, PracticeRecord, + PracticeMistake, PracticeTeamSnapshot, PracticeStartResponse, PracticeTurnResponse, @@ -88,6 +89,15 @@ export const getPracticeHistoryDetail = (id: number) => timeout: 15000 }); +export const getPracticeMistakes = () => { + const extPartyId = encodeURIComponent(currentExtPartyId()); + return apiRequest({ + url: `/api/aihr/mobile/practice/mistakes?extPartyId=${extPartyId}`, + method: 'GET', + timeout: 15000 + }); +}; + export const getCompetencyProfile = () => { const extPartyId = encodeURIComponent(currentExtPartyId()); return apiRequest({ diff --git a/mobile-uni/src/types/api.ts b/mobile-uni/src/types/api.ts index 7d64941f..dda9befb 100644 --- a/mobile-uni/src/types/api.ts +++ b/mobile-uni/src/types/api.ts @@ -193,6 +193,19 @@ export interface PracticeRecord { summary: string; } +export interface PracticeMistake { + id: number; + sessionId: string; + scenarioId: string; + scenarioName: string; + score: number; + cause: string; + retryAssignmentId?: number | null; + retryStatus: string; + time: string; + summary: string; +} + export interface PracticeTeamMember { extPartyId: string; name: string; diff --git a/mobile-uni/tests/mobile-user-pages.test.mjs b/mobile-uni/tests/mobile-user-pages.test.mjs index f00dd734..7c43b600 100644 --- a/mobile-uni/tests/mobile-user-pages.test.mjs +++ b/mobile-uni/tests/mobile-user-pages.test.mjs @@ -135,3 +135,16 @@ test('训练页保留任务卡、对话区、回复区和结果区', async () => assert.match(source, /reply-card/); assert.match(source, /result-card/); }); + +test('学习页展示真实错题本并可进入已有再练任务', async () => { + const [source, practiceService] = await Promise.all([ + pageSource('../src/pages/user/learning/index.vue'), + pageSource('../src/services/practice.ts') + ]); + + assert.match(source, /错题本/); + assert.match(source, /getPracticeMistakes/); + assert.match(source, /retryAssignmentId/); + assert.match(source, /pendingAssignmentKey/); + assert.match(practiceService, /\/api\/aihr\/mobile\/practice\/mistakes/); +}); diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index f2d013d1..710ef1ba 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -330,6 +330,8 @@ contains frontend/src/api/aihr/practice.ts "/api/aihr/prompt-templates" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/api/train/practice" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPromptTemplateController.java "/api/aihr/prompt-templates" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "mobileCompletedCount" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/mistakes" +contains mobile-uni/src/pages/user/learning/index.vue "错题本" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "aihr_practice_session" contains backend/script/sql/aihr_practice_mysql8.sql "aihr_practice_scenario" contains backend/script/sql/aihr_practice_mysql8.sql "aihr_prompt_template"