feat(aihr): add employee mistake book audit

This commit is contained in:
2026-07-14 03:23:49 +08:00
parent 5c0f792f37
commit d98ff0ff8b
12 changed files with 317 additions and 10 deletions
@@ -13,6 +13,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; 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.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse; import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
@@ -91,6 +92,11 @@ public class AihrMobileController {
return detail == null ? R.fail("训练记录不存在") : R.ok(reviewDetailPayload(detail)); return detail == null ? R.fail("训练记录不存在") : R.ok(reviewDetailPayload(detail));
} }
@GetMapping("/practice/mistakes")
public R<List<MistakeResponse>> practiceMistakes(@RequestParam(required = false) String extPartyId) {
return R.ok(mobileSeedService.practiceMistakes(ownMobileExtPartyId(extPartyId)));
}
@GetMapping("/practice/reviews") @GetMapping("/practice/reviews")
public R<List<RecordResponse>> practiceReviews(@RequestParam(required = false) String projectCode, public R<List<RecordResponse>> practiceReviews(@RequestParam(required = false) String projectCode,
@RequestParam(required = false) String extPartyId, @RequestParam(required = false) String extPartyId,
@@ -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 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 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) { public DialogueResponse(String role, String label, String text) {
this(role, label, text, "", null, null, null, null, ""); this(role, label, text, "", null, null, null, null, "");
@@ -9,6 +9,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; 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.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse; import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
@@ -49,6 +50,10 @@ public class AihrMobileSeedService {
return practiceSeedService.mobileHistory(extPartyId, 5); return practiceSeedService.mobileHistory(extPartyId, 5);
} }
public List<MistakeResponse> practiceMistakes(String extPartyId) {
return practiceSeedService.mobileMistakes(extPartyId, 20);
}
public List<RecordResponse> practiceReviews() { public List<RecordResponse> practiceReviews() {
return practiceSeedService.pendingReviewRecords(5); return practiceSeedService.pendingReviewRecords(5);
} }
@@ -25,6 +25,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; 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.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricDimensionResponse; import org.dromara.aihr.domain.AihrPracticeDto.RubricDimensionResponse;
@@ -722,6 +723,65 @@ public class AihrPracticeSeedService {
""", this::mapRecord, TENANT_ID, extPartyId.trim(), safeLimit); """, this::mapRecord, TENANT_ID, extPartyId.trim(), safeLimit);
} }
public List<MistakeResponse> 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) { public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit) {
TeamScope scope = teamScope(supervisorExtPartyId); TeamScope scope = teamScope(supervisorExtPartyId);
int safeLimit = normalizeTeamLimit(limit); int safeLimit = normalizeTeamLimit(limit);
+1 -1
View File
@@ -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 代码级强制失效) | | 移动端手机号登录 | `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 保演示 | | 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 |
| 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 | | 移动端登录后角色识别 | `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,不兜前端示例答案 | | 移动端候选人闭环 | 页面拆为 `/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,不兜前端示例答案 |
## 后端落点 ## 后端落点
+62
View File
@@ -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 文档、验收脚本和本记录。
+7 -7
View File
@@ -40,7 +40,7 @@
| 优先级 | 项目 | 需要什么才能继续 | | 优先级 | 项目 | 需要什么才能继续 |
|---|---|---| |---|---|---|
| P0 | 员工今日线上视觉 | 发布当前 `mobile-uni` 静态包并做线上浏览器回归;本地源码不再继续堆视觉代码。 | | P0 | 线上包与角色路由 | 员工今日、训练前预习和主管工作台当前包已发布并完成浏览器回归;后续只在源码变更后回归,不再把旧线上包当作当前缺口。主管正式角色仍需正式组织数据验证。 |
| P1 | 案例学习/视频详情 | 先补真实案例详情模型:`caseId`、媒体 URL、学习点、主管点评、相关训练场景;字段和 API 确定后再实现页面。 | | P1 | 案例学习/视频详情 | 先补真实案例详情模型:`caseId`、媒体 URL、学习点、主管点评、相关训练场景;字段和 API 确定后再实现页面。 |
| P1 | 正式组织数据 | 用正式员工、项目和试点时间窗口验证团队量化、成长证据和 M5 导出;不使用本地 smoke 样本替代。 | | P1 | 正式组织数据 | 用正式员工、项目和试点时间窗口验证团队量化、成长证据和 M5 导出;不使用本地 smoke 样本替代。 |
| P2 | 三期页面 | 个人 AI 助理、开放问题榜、积分/荣誉、完整七维偏好、独立 AI 教练编排按阶段二/三单独立项。 | | P2 | 三期页面 | 个人 AI 助理、开放问题榜、积分/荣誉、完整七维偏好、独立 AI 教练编排按阶段二/三单独立项。 |
@@ -50,7 +50,7 @@
| # | 页面/状态 | Figma 位置 | 代码路径 | 当前实现 | 是否修正 | | # | 页面/状态 | 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] 已实现 | 已验证场景首屏、最近练过、每日三题和独立练习入口;蓝白视觉已复核 | | 2 | 员工端:练入口 | — | `mobile-uni/src/pages/user/practice/index.vue` | [x] 已实现 | 已验证场景首屏、最近练过、每日三题和独立练习入口;蓝白视觉已复核 |
| 3 | 员工端:问 | — | `mobile-uni/src/pages/user/sop/index.vue` | [x] 已实现 | 已验证搜索、引用、反馈状态和总结卡 | | 3 | 员工端:问 | — | `mobile-uni/src/pages/user/sop/index.vue` | [x] 已实现 | 已验证搜索、引用、反馈状态和总结卡 |
| 4 | 员工端:我 | — | `mobile-uni/src/pages/user/profile/index.vue` | [x] 已实现 | 已验证登录、岗位、成长摘要、训练历史和详情入口 | | 4 | 员工端:我 | — | `mobile-uni/src/pages/user/profile/index.vue` | [x] 已实现 | 已验证登录、岗位、成长摘要、训练历史和详情入口 |
@@ -127,8 +127,8 @@
## 当前优先级 ## 当前优先级
1. **P0:主管角色路由**。本地页面存在,但线上主管账号仍可能进入员工首页。 1. **P0:正式主管角色与项目范围**。线上视觉已发布,但正式主管手机号/外部组织映射和项目级权限仍需用正式组织数据验证。
2. **P0:线上包追平当前源码**。员工首页、训练页和登录页存在明显视觉版本滞后。 2. **P0:M5 正式试点证据**。本地严格门禁可跑通,但正式窗口内的员工、训练次数、校准、SOP 评审和完训率仍需真实数据。
3. **P1:按本矩阵完成阶段一验收**。重点是员工训练、主管复盘、候选人面试、知识库和案例闭环。 3. **P1:按 BRD 补齐一期业务缺口**。优先是岗位-SOP匹配与上岗资格、错题本可见闭环、案例学习/视频详情的稳定数据模型。
4. **P2:阶段二再做个人 AI 助理和开放问题榜**,先补 API、权限和数据模型,再做页面。 4. **P2:案例详情数据模型**。先补 `caseId`、媒体/样片、学习点、主管点评等稳定契约,再实现学习页,不用假视频占位。
5. **P3:量化、偏好、积分和荣誉**,等待稳定训练数据与人工校准后再实现。 5. **P3:阶段二/三页面**。个人 AI 助理、开放问题榜、积分/荣誉、完整偏好和独立 AI 教练编排按后续阶段单独立项。
+124 -2
View File
@@ -68,6 +68,39 @@
<view v-else class="empty">暂无今日任务</view> <view v-else class="empty">暂无今日任务</view>
</view> </view>
<view v-if="loggedIn" class="panel mistake-panel">
<view class="panel-head">
<view>
<text class="panel-title">错题本</text>
<text class="panel-caption">从低分和红线回合中自动归集</text>
</view>
<button class="ghost-button compact-button" :disabled="mistakeLoading" @click="loadMistakes">
{{ mistakeLoading ? '加载中' : '刷新' }}
</button>
</view>
<view v-if="mistakes.length" class="mistake-list">
<view v-for="item in mistakes" :key="item.id" class="mistake-row">
<view class="mistake-copy">
<view class="mistake-meta">
<text class="mistake-cause">{{ item.cause }}</text>
<text class="mistake-score">{{ item.score }} 分</text>
</view>
<text class="mistake-title">{{ item.scenarioName }}</text>
<text class="mistake-desc">{{ item.time }} · {{ item.summary }}</text>
<text class="mistake-status">再练状态:{{ item.retryStatus }}</text>
</view>
<button
v-if="item.retryAssignmentId"
class="secondary-button compact-button"
@click="openMistake(item)"
>
去再练
</button>
</view>
</view>
<view v-else-if="!mistakeLoading" class="empty">完成训练后,低分和红线回合会自动出现在这里</view>
</view>
<view v-if="loggedIn" class="panel"> <view v-if="loggedIn" class="panel">
<view class="panel-head"> <view class="panel-head">
<text class="panel-title">岗位 SOP</text> <text class="panel-title">岗位 SOP</text>
@@ -145,9 +178,10 @@ import { onShow } from '@dcloudio/uni-app';
import type { import type {
KnowledgeSearchResponse, KnowledgeSearchResponse,
PracticeAssignment, PracticeAssignment,
PracticeMistake,
PracticePrepCard PracticePrepCard
} from '@/types/api'; } from '@/types/api';
import { getPracticeAssignments, getPracticePrepCard } from '@/services/practice'; import { getPracticeAssignments, getPracticeMistakes, getPracticePrepCard } from '@/services/practice';
import { searchKnowledge } from '@/services/knowledge'; import { searchKnowledge } from '@/services/knowledge';
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { buildLearningTopics, selectLearningTopic } from '@/services/learning'; import { buildLearningTopics, selectLearningTopic } from '@/services/learning';
@@ -161,10 +195,12 @@ const pendingSopQueryKey = 'aihr_sop_pending_query';
const loggedIn = ref(false); const loggedIn = ref(false);
const message = ref(''); const message = ref('');
const assignments = ref<PracticeAssignment[]>([]); const assignments = ref<PracticeAssignment[]>([]);
const mistakes = ref<PracticeMistake[]>([]);
const prepCard = ref<PracticePrepCard | null>(null); const prepCard = ref<PracticePrepCard | null>(null);
const knowledgeResult = ref<KnowledgeSearchResponse | null>(null); const knowledgeResult = ref<KnowledgeSearchResponse | null>(null);
const activeTopic = ref('岗前 SOP'); const activeTopic = ref('岗前 SOP');
const assignmentLoading = ref(false); const assignmentLoading = ref(false);
const mistakeLoading = ref(false);
const prepLoading = ref(false); const prepLoading = ref(false);
const knowledgeLoading = ref(false); const knowledgeLoading = ref(false);
const positionProfile = ref(getSelectedPositionProfile()); 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 () => { const loadPrepCard = async () => {
if (!loggedIn.value) return; if (!loggedIn.value) return;
prepLoading.value = true; prepLoading.value = true;
@@ -267,6 +316,7 @@ const loadPage = async () => {
message.value = ''; message.value = '';
if (!loggedIn.value) { if (!loggedIn.value) {
assignments.value = []; assignments.value = [];
mistakes.value = [];
prepCard.value = null; prepCard.value = null;
knowledgeResult.value = null; knowledgeResult.value = null;
return; return;
@@ -275,7 +325,7 @@ const loadPage = async () => {
return; return;
} }
refreshAuthState(); refreshAuthState();
await loadAssignments(); await Promise.all([loadAssignments(), loadMistakes()]);
activeTopic.value = selectLearningTopic(assignments.value, defaultTopic.value); activeTopic.value = selectLearningTopic(assignments.value, defaultTopic.value);
await Promise.all([ await Promise.all([
loadKnowledge(activeTopic.value), loadKnowledge(activeTopic.value),
@@ -293,6 +343,12 @@ const openAssignment = (item: PracticeAssignment) => {
uni.switchTab({ url: '/pages/user/practice/index' }); 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 = () => { const goPractice = () => {
if (firstPracticeAssignment.value) { if (firstPracticeAssignment.value) {
uni.setStorageSync(pendingAssignmentKey, String(firstPracticeAssignment.value.id)); uni.setStorageSync(pendingAssignmentKey, String(firstPracticeAssignment.value.id));
@@ -320,6 +376,72 @@ onShow(() => {
padding-bottom: 42px; 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 { .login-panel {
display: flex; display: flex;
gap: 14px; gap: 14px;
+10
View File
@@ -2,6 +2,7 @@ import type {
PracticeFinishResponse, PracticeFinishResponse,
PracticePrepCard, PracticePrepCard,
PracticeRecord, PracticeRecord,
PracticeMistake,
PracticeTeamSnapshot, PracticeTeamSnapshot,
PracticeStartResponse, PracticeStartResponse,
PracticeTurnResponse, PracticeTurnResponse,
@@ -88,6 +89,15 @@ export const getPracticeHistoryDetail = (id: number) =>
timeout: 15000 timeout: 15000
}); });
export const getPracticeMistakes = () => {
const extPartyId = encodeURIComponent(currentExtPartyId());
return apiRequest<PracticeMistake[]>({
url: `/api/aihr/mobile/practice/mistakes?extPartyId=${extPartyId}`,
method: 'GET',
timeout: 15000
});
};
export const getCompetencyProfile = () => { export const getCompetencyProfile = () => {
const extPartyId = encodeURIComponent(currentExtPartyId()); const extPartyId = encodeURIComponent(currentExtPartyId());
return apiRequest<CompetencyProfile>({ return apiRequest<CompetencyProfile>({
+13
View File
@@ -193,6 +193,19 @@ export interface PracticeRecord {
summary: string; 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 { export interface PracticeTeamMember {
extPartyId: string; extPartyId: string;
name: string; name: string;
@@ -135,3 +135,16 @@ test('训练页保留任务卡、对话区、回复区和结果区', async () =>
assert.match(source, /reply-card/); assert.match(source, /reply-card/);
assert.match(source, /result-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/);
});
+2
View File
@@ -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/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/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/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/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_practice_scenario"
contains backend/script/sql/aihr_practice_mysql8.sql "aihr_prompt_template" contains backend/script/sql/aihr_practice_mysql8.sql "aihr_prompt_template"