From e55abec63005287eafdf51a6a8be469914c76e20 Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 09:44:03 +0800 Subject: [PATCH] feat(aihr): persist interview human review --- .../controller/AihrInterviewController.java | 11 ++ .../dromara/aihr/domain/AihrInterviewDto.java | 24 +++- .../aihr/service/AihrInterviewService.java | 81 +++++++++++-- .../service/AihrInterviewServiceTest.java | 28 +++++ .../sql/aihr_interview_result_mysql8.sql | 5 + docs/API_INTEGRATION.md | 2 +- docs/BRD_IMPLEMENTATION_AUDIT.md | 2 +- frontend/src/api/aihr/interview.ts | 20 ++++ frontend/src/views/recruit/interview.vue | 108 +++++++++++++++++- scripts/demo-check.sh | 4 + 10 files changed, 271 insertions(+), 14 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java index e29934c6..6b9d4b1e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java @@ -12,6 +12,7 @@ import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkResponse; import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest; import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse; import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse; +import org.dromara.aihr.domain.AihrInterviewDto.ReviewRequest; import org.dromara.aihr.domain.AihrInterviewDto.StartRequest; import org.dromara.aihr.domain.AihrInterviewDto.StartResponse; import org.dromara.aihr.service.AihrInterviewService; @@ -22,6 +23,7 @@ import org.dromara.common.core.enums.UserType; import org.dromara.common.satoken.utils.LoginHelper; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -70,6 +72,15 @@ public class AihrInterviewController { return R.ok(interviewService.records(limit, appUsername.isBlank() ? candidateId : appUsername)); } + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @PostMapping("/records/{sessionId}/review") + public R review(@PathVariable String sessionId, @RequestBody ReviewRequest request) { + LoginUser loginUser = LoginHelper.getLoginUser(); + String operator = loginUser == null ? "unknown" : loginUser.getUsername(); + interviewService.reviewResult(sessionId, request, operator); + return R.ok(); + } + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @GetMapping("/candidate-links") public R candidateLink(@RequestParam String candidateId) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java index 6d16cc99..b83a9b95 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java @@ -26,6 +26,9 @@ public final class AihrInterviewDto { public record FinishRequest(String sessionId, Map answers) { } + public record ReviewRequest(Integer reviewedScore, String reviewNote) { + } + public record CandidateLinkRequest(String candidateId, String extPartyId) { } @@ -50,7 +53,24 @@ public final class AihrInterviewDto { public record QuestionRecordResponse(Integer seq, String question, String answer, String rating) { } - public record RecordResponse(String time, String candidate, String position, Integer score, String result, String summary, - List questions) { + public record RecordResponse( + String sessionId, + String time, + String candidate, + String position, + Integer score, + Integer reviewedScore, + Integer finalScore, + String reviewStatus, + String reviewer, + String reviewNote, + String result, + String summary, + List questions + ) { + public RecordResponse(String time, String candidate, String position, Integer score, String result, String summary, + List questions) { + this("", time, candidate, position, score, null, score, "待复核", "", "", result, summary, questions); + } } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java index 117ac3d6..927ad318 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java @@ -16,6 +16,7 @@ import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse; import org.dromara.aihr.domain.AihrInterviewDto.QuestionResponse; import org.dromara.aihr.domain.AihrInterviewDto.QuestionRecordResponse; import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse; +import org.dromara.aihr.domain.AihrInterviewDto.ReviewRequest; import org.dromara.aihr.domain.AihrInterviewDto.StartRequest; import org.dromara.aihr.domain.AihrInterviewDto.StartResponse; import org.springframework.jdbc.core.JdbcTemplate; @@ -117,7 +118,8 @@ public class AihrInterviewService { ensureResultTable(); int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100)); StringBuilder sql = new StringBuilder(""" - SELECT finished_time, candidate_name, position_code, total_score, result, ai_summary, questions_json + SELECT session_id, finished_time, candidate_name, position_code, total_score, + reviewed_score, review_status, reviewer, review_note, result, ai_summary, questions_json FROM aihr_interview_result WHERE tenant_id = ? """); @@ -134,6 +136,30 @@ public class AihrInterviewService { return jdbcTemplate.query(sql.toString(), this::mapRecord, args.toArray()); } + public void reviewResult(String sessionId, ReviewRequest request, String operator) { + ensureResultTable(); + String normalizedSessionId = clean(sessionId); + if (normalizedSessionId.isBlank() || normalizedSessionId.length() > 100) { + throw new IllegalArgumentException("面试会话ID不能为空且不能超过100个字符"); + } + Integer reviewedScore = request == null ? null : request.reviewedScore(); + if (reviewedScore == null || reviewedScore < 0 || reviewedScore > 100) { + throw new IllegalArgumentException("人工复核分必须是0到100之间的整数"); + } + String reviewNote = clean(request.reviewNote()); + if (reviewNote.length() > 1000) { + throw new IllegalArgumentException("人工复核意见不能超过1000个字符"); + } + int updated = jdbcTemplate.update(""" + UPDATE aihr_interview_result + SET reviewed_score = ?, review_status = '已复核', reviewer = ?, review_note = ?, reviewed_time = now(), update_time = now() + WHERE tenant_id = ? AND session_id = ? + """, reviewedScore, firstNonBlank(operator, "unknown"), reviewNote, tenantId(), normalizedSessionId); + if (updated == 0) { + throw new IllegalArgumentException("面试记录不存在或不属于当前租户"); + } + } + public CandidateLinkResponse candidateLink(String candidateId) { ensureCandidateLinkTable(); String normalizedCandidateId = clean(candidateId); @@ -193,11 +219,23 @@ public class AihrInterviewService { private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException { var finishedTime = rs.getTimestamp("finished_time"); + Integer aiScore = rs.getInt("total_score"); + if (rs.wasNull()) { + aiScore = null; + } + Object reviewedScoreValue = rs.getObject("reviewed_score"); + Integer reviewedScore = reviewedScoreValue instanceof Number number ? number.intValue() : null; return new RecordResponse( + rs.getString("session_id"), finishedTime == null ? "" : finishedTime.toLocalDateTime().format(TIME_FORMAT), rs.getString("candidate_name"), rs.getString("position_code"), - rs.getInt("total_score"), + aiScore, + reviewedScore, + reviewedScore == null ? aiScore : reviewedScore, + firstNonBlank(rs.getString("review_status"), "待复核"), + firstNonBlank(rs.getString("reviewer"), ""), + firstNonBlank(rs.getString("review_note"), ""), rs.getString("result"), rs.getString("ai_summary"), parseQuestionRecords(rs.getString("questions_json")) @@ -274,6 +312,11 @@ public class AihrInterviewService { `candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名', `position_code` varchar(120) DEFAULT NULL COMMENT '岗位', `total_score` int DEFAULT NULL COMMENT '总分', + `reviewed_score` int DEFAULT NULL COMMENT '人工复核分', + `review_status` varchar(20) DEFAULT '待复核' COMMENT '人工复核状态', + `reviewer` varchar(100) DEFAULT NULL COMMENT '复核人', + `review_note` varchar(1000) DEFAULT NULL COMMENT '人工复核意见', + `reviewed_time` datetime DEFAULT NULL COMMENT '复核时间', `result` varchar(40) DEFAULT NULL COMMENT '结论', `suggestion` varchar(500) DEFAULT NULL COMMENT '建议', `ai_summary` varchar(1000) DEFAULT NULL COMMENT 'AI摘要', @@ -291,17 +334,37 @@ public class AihrInterviewService { KEY `idx_aihr_interview_finished` (`tenant_id`, `finished_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 面试结果'; """); - try { - jdbcTemplate.execute("ALTER TABLE aihr_interview_result ADD COLUMN questions_json text DEFAULT NULL COMMENT '面试问题与回答快照JSON' AFTER answers_json"); - } catch (DataAccessException error) { - if (!String.valueOf(error.getMessage()).contains("Duplicate column")) { - throw error; - } - } + addColumnIfMissing("ALTER TABLE aihr_interview_result ADD COLUMN questions_json text DEFAULT NULL COMMENT '面试问题与回答快照JSON' AFTER answers_json"); + addColumnIfMissing("ALTER TABLE aihr_interview_result ADD COLUMN reviewed_score int DEFAULT NULL COMMENT '人工复核分' AFTER total_score"); + addColumnIfMissing("ALTER TABLE aihr_interview_result ADD COLUMN review_status varchar(20) DEFAULT '待复核' COMMENT '人工复核状态' AFTER reviewed_score"); + addColumnIfMissing("ALTER TABLE aihr_interview_result ADD COLUMN reviewer varchar(100) DEFAULT NULL COMMENT '复核人' AFTER review_status"); + addColumnIfMissing("ALTER TABLE aihr_interview_result ADD COLUMN review_note varchar(1000) DEFAULT NULL COMMENT '人工复核意见' AFTER reviewer"); + addColumnIfMissing("ALTER TABLE aihr_interview_result ADD COLUMN reviewed_time datetime DEFAULT NULL COMMENT '复核时间' AFTER review_note"); tableReady = true; } } + private void addColumnIfMissing(String ddl) { + try { + jdbcTemplate.execute(ddl); + } catch (DataAccessException error) { + if (!isDuplicateColumnError(error)) { + throw error; + } + } + } + + private boolean isDuplicateColumnError(DataAccessException error) { + Throwable current = error; + while (current != null) { + if (String.valueOf(current.getMessage()).contains("Duplicate column")) { + return true; + } + current = current.getCause(); + } + return false; + } + private void ensureCandidateLinkTable() { if (candidateLinkTableReady) { return; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java index 69841023..fab49376 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkRequest; import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkResponse; import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse; +import org.dromara.aihr.domain.AihrInterviewDto.ReviewRequest; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; @@ -120,4 +121,31 @@ class AihrInterviewServiceTest { assertTrue(controllerSource.contains("StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE)")); assertTrue(controllerSource.contains("return R.fail(\"无权查看面试记录\")")); } + + @Test + @Tag("dev") + void reviewResultPersistsHumanScoreWithoutChangingAiScore() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + AihrInterviewService service = new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate); + when(jdbcTemplate.update(anyString(), eq(88), eq("hr-operator"), eq("人工确认现场协调能力不足"), eq("000000"), eq("iv-1"))) + .thenReturn(1); + + service.reviewResult("iv-1", new ReviewRequest(88, "人工确认现场协调能力不足"), "hr-operator"); + + verify(jdbcTemplate).update(anyString(), eq(88), eq("hr-operator"), eq("人工确认现场协调能力不足"), eq("000000"), eq("iv-1")); + } + + @Test + @Tag("dev") + void reviewResultRejectsOutOfRangeScore() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + AihrInterviewService service = new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.reviewResult( + "iv-1", new ReviewRequest(101, "超出范围"), "hr-operator" + )); + + assertEquals("人工复核分必须是0到100之间的整数", error.getMessage()); + verify(jdbcTemplate, never()).update(anyString(), eq(101), eq("hr-operator"), eq("超出范围"), eq("000000"), eq("iv-1")); + } } diff --git a/backend/script/sql/aihr_interview_result_mysql8.sql b/backend/script/sql/aihr_interview_result_mysql8.sql index e9d09122..68bd77f8 100644 --- a/backend/script/sql/aihr_interview_result_mysql8.sql +++ b/backend/script/sql/aihr_interview_result_mysql8.sql @@ -9,6 +9,11 @@ CREATE TABLE IF NOT EXISTS `aihr_interview_result` ( `candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名', `position_code` varchar(120) DEFAULT NULL COMMENT '岗位', `total_score` int DEFAULT NULL COMMENT '总分', + `reviewed_score` int DEFAULT NULL COMMENT '人工复核分', + `review_status` varchar(20) DEFAULT '待复核' COMMENT '人工复核状态', + `reviewer` varchar(100) DEFAULT NULL COMMENT '复核人', + `review_note` varchar(1000) DEFAULT NULL COMMENT '人工复核意见', + `reviewed_time` datetime DEFAULT NULL COMMENT '复核时间', `result` varchar(40) DEFAULT NULL COMMENT '结论', `suggestion` varchar(500) DEFAULT NULL COMMENT '建议', `ai_summary` varchar(1000) DEFAULT NULL COMMENT 'AI摘要', diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 693f5adf..389dcdd0 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -6,7 +6,7 @@ | 页面 | 后端接口 | 处理 | |---|---|---| -| AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数 | +| AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish`、`GET /records`、`POST /records/{sessionId}/review` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数。AI 分数只作辅助参考,HR/管理员可提交 `reviewedScore/reviewNote` 完成人工复核;记录同时保留 AI 分、人工复核分、最终采用分和复核状态 | | 候选人入职主体关联 `/recruit/interview` | `GET/POST /api/recruit/interview/candidate-links` | HR/管理员在当前租户范围内把本地候选人 ID 关联到已同步的在职 `ext_party_id`;只保存外部主体 ID,不复制姓名、部门等组织字段,重复关联同一主体幂等,候选人更换主体或同一主体已关联其他候选人会拒绝 | | 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);模型未配置或调用失败自动回退 seed,契约不变 | | 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 | diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index d0fc83cb..fd459226 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -15,7 +15,7 @@ | 员工端今日/练/问/我 | `mobile-uni/src/pages/user/*`;员工首页、训练前预习、独立练习路由已实现 | 2026-07-14 已同步线上 H5;线上主 JS 与本地构建 SHA-256 一致,学习页已显示错题本和原因筛选,后续只做发布后回归 | | 主管工作台/复盘/派发 | `mobile-uni/src/pages/supervisor/*`;线上工作台已完成视觉回归 | 页面主链路已在位;正式主管身份仍依赖组织数据 | | 训练音频对象权限 | `AihrMobileController`、`AihrPracticeSeedService`;`GET /api/aihr/mobile/oss/{ossId}` | 已按员工本人、主管项目范围和系统用户区分音频读取权限;本地真实 HTTP 验证:归属员工 200,其他员工 403;生产正式主管范围仍需组织同步数据支撑 | -| 候选人面试与 HR 复核 | `mobile-uni/src/pages/candidate/*`、`frontend/src/views/recruit/interview.vue` | 工程闭环已在位;候选人资料移动端上传/列表现在只接受 APP 候选人本人身份,HR 继续通过独立审核接口处理;需继续保留人工复核证据 | +| 候选人面试与 HR 复核 | `mobile-uni/src/pages/candidate/*`、`frontend/src/views/recruit/interview.vue`、`AihrInterviewController` | 候选人资料移动端上传/列表只接受 APP 候选人本人身份,HR 通过独立审核接口处理;AI 面试结果现已持久化 `待复核/已复核`、AI 分、人工复核分、最终采用分、复核人、意见和时间,管理端可提交人工复核;正式试点仍需抽查复核证据 | | SOP/RAG/资料处理 | `frontend/src/views/knowledge/*`、`backend/.../AihrSopSeedService.java` | 搜索、引用、反馈、异步处理和评审入口已在位 | | 对练/每日题/主管带教 | `backend/.../AihrPracticeSeedService.java`、`mobile-uni/src/pages/user/practice` | 每日三题现支持文字或录音转写,语音 `audioUrl/ossId` 随作业提交并持久化;普通陪练、即时反馈、低分再练、错题归因和主管带教已在位,正式窗口仍需验证真实音频与低分样本 | | 案例上传/整理/筛选 | `backend/.../AihrCaseService.java`、`mobile-uni/src/pages/user/cases`、`frontend/src/views/knowledge/cases.vue` | 上传与整理已在位,案例状态在内存缺失时可从 `aihr_case_record` 回读恢复;APP 用户的读取按项目范围校验,写入接口已增加“主管/项目经理/系统用户”门禁,普通员工不能提交、整理或入库案例;原始音频现在上传到 `sys_oss` 并在 `aihr_case_record.media_oss_id/media_url` 留痕,员工端和管理端详情均可回放;本轮补上管理端上传的项目选择与后端无项目拒绝,避免案例落成员工不可见的无范围记录;当前仓库仍没有可用预渲染视频样片资产,也没有稳定的学习点/主管点评模型,不能用音频回放冒充完整案例学习闭环 | diff --git a/frontend/src/api/aihr/interview.ts b/frontend/src/api/aihr/interview.ts index 145cf53d..1e309d77 100644 --- a/frontend/src/api/aihr/interview.ts +++ b/frontend/src/api/aihr/interview.ts @@ -64,14 +64,25 @@ export type InterviewFinishResponse = { }; export type InterviewRecord = { + sessionId?: string; time: string; candidate: string; position: string; score: number; + reviewedScore?: number | null; + finalScore?: number; + reviewStatus?: string; + reviewer?: string; + reviewNote?: string; result: string; summary: string; }; +export type InterviewReviewRequest = { + reviewedScore: number; + reviewNote?: string; +}; + export type CandidateLinkResponse = { candidateId: string; extPartyId: string; @@ -126,6 +137,15 @@ export function listInterviewRecords(limit = 20): Promise> { + return request({ + url: `/api/recruit/interview/records/${encodeURIComponent(sessionId)}/review`, + method: 'post', + data, + headers: { repeatSubmit: false } + }); +} + export function getCandidateEmployeeLink(candidateId: string): Promise> { return request({ url: '/api/recruit/interview/candidate-links', diff --git a/frontend/src/views/recruit/interview.vue b/frontend/src/views/recruit/interview.vue index 979b30fa..c161d3ba 100644 --- a/frontend/src/views/recruit/interview.vue +++ b/frontend/src/views/recruit/interview.vue @@ -174,12 +174,56 @@ - + + + + + + + + + + + + + + +
+ {{ reviewingRecord.candidate }} · {{ reviewingRecord.position }} + AI 得分 {{ reviewingRecord.score }},当前最终采用分 {{ reviewingRecord.finalScore ?? reviewingRecord.score }} +
+ + + + + + + + + +
+
@@ -232,6 +276,7 @@ import { linkCandidateEmployee, listCandidateMaterialsForReview, listInterviewRecords, + reviewInterviewRecord, reviewCandidateMaterial, startInterview, submitInterviewAnswer, @@ -426,6 +471,11 @@ const apiQuestions = ref([]); const apiScoreResult = ref(null); const answers = reactive>({}); const records = ref([...baseRecords]); +const interviewReviewVisible = ref(false); +const interviewReviewSaving = ref(false); +const reviewingRecord = ref(null); +const reviewScore = ref(undefined); +const reviewNote = ref(''); const materialReviews = ref([]); const materialStatus = ref('待审核'); const materialLoading = ref(false); @@ -626,6 +676,38 @@ const loadInterviewRecords = async () => { } }; +const openInterviewReview = (row: InterviewRecord) => { + if (!row.sessionId) { + ElMessage.warning('当前记录没有可复核的后端会话'); + return; + } + reviewingRecord.value = row; + reviewScore.value = row.reviewedScore ?? row.finalScore ?? row.score; + reviewNote.value = row.reviewNote ?? ''; + interviewReviewVisible.value = true; +}; + +const submitInterviewReview = async () => { + if (!reviewingRecord.value?.sessionId || reviewScore.value === undefined) { + ElMessage.warning('请输入人工复核分'); + return; + } + interviewReviewSaving.value = true; + try { + await reviewInterviewRecord(reviewingRecord.value.sessionId, { + reviewedScore: reviewScore.value, + reviewNote: reviewNote.value.trim() + }); + interviewReviewVisible.value = false; + await loadInterviewRecords(); + ElMessage.success('面试结果人工复核已保存'); + } catch { + ElMessage.error('面试结果人工复核失败'); + } finally { + interviewReviewSaving.value = false; + } +}; + const loadCandidateLink = async () => { candidateLink.value = null; linkExtPartyId.value = ''; @@ -1196,6 +1278,30 @@ watch(candidateId, loadCandidateLink); width: 110px; } +.interview-review-context { + display: grid; + gap: 6px; + margin-bottom: 18px; + padding: 12px; + border-radius: 8px; + background: #fff8ed; + color: #46566e; + + strong { + color: #26354c; + } + + span { + font-size: 12px; + } +} + +.interview-review-form { + :deep(.el-input-number) { + width: 180px; + } +} + .material-name { display: grid; gap: 2px; diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index 37746b4a..a1f24b90 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -383,8 +383,12 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service contains frontend/src/router/index.ts "/system/prompts" contains frontend/src/api/aihr/interview.ts "/api/recruit/interview/start" contains frontend/src/api/aihr/interview.ts "/api/recruit/interview/candidate-links" +contains frontend/src/api/aihr/interview.ts "/records/\${encodeURIComponent(sessionId)}/review" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java "/api/recruit/interview" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java "无权查看面试记录" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java "/records/{sessionId}/review" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "reviewed_score" +contains frontend/src/views/recruit/interview.vue "人工复核 AI 面试结果" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java "@SaCheckRole" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "aihr_candidate_employee_link" contains frontend/src/api/aihr/practice.ts "/api/train/practice/start"