feat(aihr): persist interview human review
This commit is contained in:
+11
@@ -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<Void> 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<CandidateLinkResponse> candidateLink(@RequestParam String candidateId) {
|
||||
|
||||
+22
-2
@@ -26,6 +26,9 @@ public final class AihrInterviewDto {
|
||||
public record FinishRequest(String sessionId, Map<String, String> 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<QuestionRecordResponse> 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<QuestionRecordResponse> questions
|
||||
) {
|
||||
public RecordResponse(String time, String candidate, String position, Integer score, String result, String summary,
|
||||
List<QuestionRecordResponse> questions) {
|
||||
this("", time, candidate, position, score, null, score, "待复核", "", "", result, summary, questions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+72
-9
@@ -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;
|
||||
|
||||
+28
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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摘要',
|
||||
|
||||
@@ -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/开发身份 |
|
||||
|
||||
@@ -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` 留痕,员工端和管理端详情均可回放;本轮补上管理端上传的项目选择与后端无项目拒绝,避免案例落成员工不可见的无范围记录;当前仓库仍没有可用预渲染视频样片资产,也没有稳定的学习点/主管点评模型,不能用音频回放冒充完整案例学习闭环 |
|
||||
|
||||
@@ -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<ApiResult<InterviewRec
|
||||
});
|
||||
}
|
||||
|
||||
export function reviewInterviewRecord(sessionId: string, data: InterviewReviewRequest): Promise<ApiResult<null>> {
|
||||
return request({
|
||||
url: `/api/recruit/interview/records/${encodeURIComponent(sessionId)}/review`,
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function getCandidateEmployeeLink(candidateId: string): Promise<ApiResult<CandidateLinkResponse | null>> {
|
||||
return request({
|
||||
url: '/api/recruit/interview/candidate-links',
|
||||
|
||||
@@ -174,12 +174,56 @@
|
||||
<el-table-column prop="time" label="时间" width="128" />
|
||||
<el-table-column prop="candidate" label="候选人" width="120" />
|
||||
<el-table-column prop="position" label="岗位" min-width="150" />
|
||||
<el-table-column prop="score" label="得分" width="80" />
|
||||
<el-table-column prop="score" label="AI得分" width="90" />
|
||||
<el-table-column label="人工复核分" width="110">
|
||||
<template #default="{ row }">{{ row.reviewedScore ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最终采用分" width="110">
|
||||
<template #default="{ row }">{{ row.finalScore ?? row.score }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="result" label="结论" width="120" />
|
||||
<el-table-column label="复核状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.reviewStatus === '已复核' ? 'success' : 'warning'" size="small">
|
||||
{{ row.reviewStatus || '待复核' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="summary" label="AI 摘要" min-width="260" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :disabled="!row.sessionId" @click="openInterviewReview(row)">人工复核</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="interviewReviewVisible" title="人工复核 AI 面试结果" width="520px">
|
||||
<div v-if="reviewingRecord" class="interview-review-context">
|
||||
<strong>{{ reviewingRecord.candidate }} · {{ reviewingRecord.position }}</strong>
|
||||
<span>AI 得分 {{ reviewingRecord.score }},当前最终采用分 {{ reviewingRecord.finalScore ?? reviewingRecord.score }}</span>
|
||||
</div>
|
||||
<el-form label-width="104px" class="interview-review-form">
|
||||
<el-form-item label="人工复核分" required>
|
||||
<el-input-number v-model="reviewScore" :min="0" :max="100" :precision="0" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item label="复核意见">
|
||||
<el-input
|
||||
v-model="reviewNote"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
placeholder="记录人工判断依据,可说明是否覆盖 AI 建议"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="interviewReviewVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="interviewReviewSaving" @click="submitInterviewReview">保存复核</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<section class="panel record-panel material-review-panel">
|
||||
<div class="panel-title title-with-extra">
|
||||
<div>
|
||||
@@ -232,6 +276,7 @@ import {
|
||||
linkCandidateEmployee,
|
||||
listCandidateMaterialsForReview,
|
||||
listInterviewRecords,
|
||||
reviewInterviewRecord,
|
||||
reviewCandidateMaterial,
|
||||
startInterview,
|
||||
submitInterviewAnswer,
|
||||
@@ -426,6 +471,11 @@ const apiQuestions = ref<InterviewQuestion[]>([]);
|
||||
const apiScoreResult = ref<CandidateSeed['scoreResult'] | null>(null);
|
||||
const answers = reactive<Record<string, string>>({});
|
||||
const records = ref<InterviewRecord[]>([...baseRecords]);
|
||||
const interviewReviewVisible = ref(false);
|
||||
const interviewReviewSaving = ref(false);
|
||||
const reviewingRecord = ref<InterviewRecord | null>(null);
|
||||
const reviewScore = ref<number | undefined>(undefined);
|
||||
const reviewNote = ref('');
|
||||
const materialReviews = ref<CandidateMaterialReview[]>([]);
|
||||
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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user