feat(aihr): add employee mistake book audit
This commit is contained in:
+6
@@ -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,
|
||||||
|
|||||||
+14
@@ -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, "");
|
||||||
|
|||||||
+5
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+60
@@ -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);
|
||||||
|
|||||||
@@ -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,不兜前端示例答案 |
|
||||||
|
|
||||||
## 后端落点
|
## 后端落点
|
||||||
|
|||||||
@@ -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 文档、验收脚本和本记录。
|
||||||
@@ -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 教练编排按后续阶段单独立项。
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>({
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user