feat(aihr): persist interview results and candidate material review
面试记录落 aihr_interview_result(GET /records 查询),候选人补充资料 走 OSS + aihr_candidate_material,管理端 AI 面试页可审核通过/驳回; 案例整理服务增强;两张新表纳入 reset 脚本。
This commit is contained in:
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package org.dromara.aihr.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.dromara.aihr.service.AihrCandidateMaterialService;
|
||||||
|
import org.dromara.aihr.service.AihrCandidateMaterialService.MaterialResponse;
|
||||||
|
import org.dromara.aihr.service.AihrCandidateMaterialService.MaterialReviewResponse;
|
||||||
|
import org.dromara.aihr.service.AihrCandidateMaterialService.ReviewRequest;
|
||||||
|
import org.dromara.common.core.domain.R;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RestController
|
||||||
|
public class AihrCandidateMaterialController {
|
||||||
|
|
||||||
|
private final AihrCandidateMaterialService materialService;
|
||||||
|
|
||||||
|
@PostMapping(value = "/api/aihr/mobile/candidate/materials", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public R<MaterialResponse> upload(@RequestPart("file") MultipartFile file,
|
||||||
|
@RequestParam(value = "candidateId", required = false) String candidateId,
|
||||||
|
@RequestParam(value = "candidateName", required = false) String candidateName,
|
||||||
|
@RequestParam(value = "materialType", required = false) String materialType) {
|
||||||
|
return R.ok(materialService.upload(file, candidateId, candidateName, materialType));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/aihr/mobile/candidate/materials")
|
||||||
|
public R<List<MaterialResponse>> list(@RequestParam(value = "candidateId", required = false) String candidateId) {
|
||||||
|
return R.ok(materialService.list(candidateId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/aihr/hr/candidate/materials")
|
||||||
|
public R<List<MaterialReviewResponse>> reviewList(@RequestParam(value = "status", required = false) String status) {
|
||||||
|
return R.ok(materialService.reviewList(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/aihr/hr/candidate/materials/{id}/review")
|
||||||
|
public R<MaterialReviewResponse> review(@PathVariable Long id, @RequestBody ReviewRequest request) {
|
||||||
|
return R.ok(materialService.review(id, request));
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -5,15 +5,20 @@ import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest;
|
|||||||
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
|
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
|
||||||
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
|
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
|
||||||
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
|
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
|
||||||
|
import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
|
||||||
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
||||||
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
||||||
import org.dromara.aihr.service.AihrInterviewService;
|
import org.dromara.aihr.service.AihrInterviewService;
|
||||||
import org.dromara.common.core.domain.R;
|
import org.dromara.common.core.domain.R;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 面试 API。
|
* AI 面试 API。
|
||||||
*/
|
*/
|
||||||
@@ -38,4 +43,9 @@ public class AihrInterviewController {
|
|||||||
public R<FinishResponse> finish(@RequestBody FinishRequest request) {
|
public R<FinishResponse> finish(@RequestBody FinishRequest request) {
|
||||||
return R.ok(interviewService.finish(request));
|
return R.ok(interviewService.finish(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/records")
|
||||||
|
public R<List<RecordResponse>> records(@RequestParam(value = "limit", required = false, defaultValue = "20") int limit) {
|
||||||
|
return R.ok(interviewService.records(limit));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
package org.dromara.aihr.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.dromara.system.domain.vo.SysOssVo;
|
||||||
|
import org.dromara.system.service.ISysOssService;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AihrCandidateMaterialService {
|
||||||
|
|
||||||
|
private static final String TENANT_ID = "000000";
|
||||||
|
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("MM-dd HH:mm");
|
||||||
|
private static final long MAX_SIZE = 20L * 1024 * 1024;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final ISysOssService ossService;
|
||||||
|
|
||||||
|
private volatile boolean tableReady;
|
||||||
|
|
||||||
|
public MaterialResponse upload(MultipartFile file, String candidateId, String candidateName, String materialType) {
|
||||||
|
ensureTable();
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("请先选择补充资料");
|
||||||
|
}
|
||||||
|
if (file.getSize() > MAX_SIZE) {
|
||||||
|
throw new IllegalArgumentException("资料不能超过20MB");
|
||||||
|
}
|
||||||
|
String fileName = clean(file.getOriginalFilename(), "candidate-material");
|
||||||
|
String type = clean(materialType, materialType(fileName));
|
||||||
|
SysOssVo oss = ossService.upload(file);
|
||||||
|
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(connection -> {
|
||||||
|
PreparedStatement ps = connection.prepareStatement("""
|
||||||
|
insert into aihr_candidate_material
|
||||||
|
(tenant_id, candidate_id, candidate_name, material_type, file_name, file_size, content_type, oss_id, status, create_time, update_time)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
|
||||||
|
""", Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setString(1, TENANT_ID);
|
||||||
|
ps.setString(2, clean(candidateId, "mobile-candidate"));
|
||||||
|
ps.setString(3, clean(candidateName, "候选人"));
|
||||||
|
ps.setString(4, type);
|
||||||
|
ps.setString(5, fileName);
|
||||||
|
ps.setLong(6, file.getSize());
|
||||||
|
ps.setString(7, clean(file.getContentType(), "application/octet-stream"));
|
||||||
|
ps.setLong(8, oss.getOssId());
|
||||||
|
ps.setString(9, "待审核");
|
||||||
|
return ps;
|
||||||
|
}, keyHolder);
|
||||||
|
Number id = keyHolder.getKey();
|
||||||
|
return new MaterialResponse(id == null ? null : id.longValue(), oss.getOssId(), fileName, type, file.getSize(), "待审核", LocalDateTime.now().format(TIME_FORMAT));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<MaterialResponse> list(String candidateId) {
|
||||||
|
ensureTable();
|
||||||
|
return jdbcTemplate.query("""
|
||||||
|
select id, oss_id, file_name, material_type, file_size, status, create_time
|
||||||
|
from aihr_candidate_material
|
||||||
|
where tenant_id = ? and candidate_id = ?
|
||||||
|
order by id desc
|
||||||
|
limit 20
|
||||||
|
""", (rs, rowNum) -> new MaterialResponse(
|
||||||
|
rs.getLong("id"),
|
||||||
|
rs.getLong("oss_id"),
|
||||||
|
rs.getString("file_name"),
|
||||||
|
rs.getString("material_type"),
|
||||||
|
rs.getLong("file_size"),
|
||||||
|
rs.getString("status"),
|
||||||
|
rs.getTimestamp("create_time").toLocalDateTime().format(TIME_FORMAT)
|
||||||
|
), TENANT_ID, clean(candidateId, "mobile-candidate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<MaterialReviewResponse> reviewList(String status) {
|
||||||
|
ensureTable();
|
||||||
|
String normalizedStatus = clean(status, "");
|
||||||
|
return jdbcTemplate.query("""
|
||||||
|
select id, candidate_id, candidate_name, oss_id, file_name, material_type, file_size, status, create_time
|
||||||
|
from aihr_candidate_material
|
||||||
|
where tenant_id = ? and (? = '' or status = ?)
|
||||||
|
order by id desc
|
||||||
|
limit 50
|
||||||
|
""", (rs, rowNum) -> reviewRow(
|
||||||
|
rs.getLong("id"),
|
||||||
|
rs.getString("candidate_id"),
|
||||||
|
rs.getString("candidate_name"),
|
||||||
|
rs.getLong("oss_id"),
|
||||||
|
rs.getString("file_name"),
|
||||||
|
rs.getString("material_type"),
|
||||||
|
rs.getLong("file_size"),
|
||||||
|
rs.getString("status"),
|
||||||
|
rs.getTimestamp("create_time").toLocalDateTime()
|
||||||
|
), TENANT_ID, normalizedStatus, normalizedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MaterialReviewResponse review(Long id, ReviewRequest request) {
|
||||||
|
ensureTable();
|
||||||
|
String status = normalizeReviewStatus(request == null ? null : request.status());
|
||||||
|
int updated = jdbcTemplate.update("""
|
||||||
|
update aihr_candidate_material
|
||||||
|
set status = ?, update_time = now()
|
||||||
|
where tenant_id = ? and id = ?
|
||||||
|
""", status, TENANT_ID, id);
|
||||||
|
if (updated == 0) {
|
||||||
|
throw new IllegalArgumentException("候选资料不存在");
|
||||||
|
}
|
||||||
|
return findReview(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureTable() {
|
||||||
|
if (tableReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
if (tableReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jdbcTemplate.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS `aihr_candidate_material` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||||
|
`candidate_id` varchar(80) NOT NULL COMMENT '候选人外部ID',
|
||||||
|
`candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名',
|
||||||
|
`material_type` varchar(50) DEFAULT NULL COMMENT '资料类型',
|
||||||
|
`file_name` varchar(255) NOT NULL COMMENT '文件名',
|
||||||
|
`file_size` bigint DEFAULT 0 COMMENT '文件大小',
|
||||||
|
`content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME',
|
||||||
|
`oss_id` bigint NOT NULL COMMENT '对象存储ID',
|
||||||
|
`status` varchar(20) DEFAULT '待审核' COMMENT '审核状态',
|
||||||
|
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_aihr_candidate_material_candidate` (`tenant_id`, `candidate_id`, `id`),
|
||||||
|
KEY `idx_aihr_candidate_material_review` (`tenant_id`, `status`, `id`),
|
||||||
|
KEY `idx_aihr_candidate_material_oss` (`oss_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人补充资料';
|
||||||
|
""");
|
||||||
|
tableReady = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MaterialReviewResponse findReview(Long id) {
|
||||||
|
return jdbcTemplate.queryForObject("""
|
||||||
|
select id, candidate_id, candidate_name, oss_id, file_name, material_type, file_size, status, create_time
|
||||||
|
from aihr_candidate_material
|
||||||
|
where tenant_id = ? and id = ?
|
||||||
|
""", (rs, rowNum) -> reviewRow(
|
||||||
|
rs.getLong("id"),
|
||||||
|
rs.getString("candidate_id"),
|
||||||
|
rs.getString("candidate_name"),
|
||||||
|
rs.getLong("oss_id"),
|
||||||
|
rs.getString("file_name"),
|
||||||
|
rs.getString("material_type"),
|
||||||
|
rs.getLong("file_size"),
|
||||||
|
rs.getString("status"),
|
||||||
|
rs.getTimestamp("create_time").toLocalDateTime()
|
||||||
|
), TENANT_ID, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MaterialReviewResponse reviewRow(Long id, String candidateId, String candidateName, Long ossId,
|
||||||
|
String fileName, String materialType, Long fileSize, String status,
|
||||||
|
LocalDateTime createTime) {
|
||||||
|
return new MaterialReviewResponse(id, candidateId, candidateName, ossId, fileName, materialType, fileSize, status, createTime.format(TIME_FORMAT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String materialType(String fileName) {
|
||||||
|
String lower = fileName.toLowerCase();
|
||||||
|
if (lower.endsWith(".pdf") || lower.endsWith(".doc") || lower.endsWith(".docx")) {
|
||||||
|
return "简历/证书";
|
||||||
|
}
|
||||||
|
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".webp")) {
|
||||||
|
return "图片资料";
|
||||||
|
}
|
||||||
|
return "补充资料";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String clean(String value, String fallback) {
|
||||||
|
String text = value == null ? "" : value.trim();
|
||||||
|
return text.isEmpty() ? fallback : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeReviewStatus(String status) {
|
||||||
|
String text = clean(status, "待审核");
|
||||||
|
if ("已通过".equals(text) || "已驳回".equals(text) || "待审核".equals(text)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("审核状态只能是待审核、已通过或已驳回");
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MaterialResponse(Long id, Long ossId, String fileName, String materialType, Long fileSize, String status, String time) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MaterialReviewResponse(Long id, String candidateId, String candidateName, Long ossId, String fileName,
|
||||||
|
String materialType, Long fileSize, String status, String time) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReviewRequest(String status) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -12,6 +12,7 @@ import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
|
|||||||
import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse;
|
import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse;
|
||||||
import org.dromara.aihr.domain.AihrCaseDto.TagResponse;
|
import org.dromara.aihr.domain.AihrCaseDto.TagResponse;
|
||||||
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
|
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@@ -34,7 +35,9 @@ public class AihrCaseService {
|
|||||||
private final AihrSpeechService speechService;
|
private final AihrSpeechService speechService;
|
||||||
private final AihrModelSeedService modelService;
|
private final AihrModelSeedService modelService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final Map<String, CaseState> cases = new ConcurrentHashMap<>();
|
private final Map<String, CaseState> cases = new ConcurrentHashMap<>();
|
||||||
|
private volatile boolean tableReady;
|
||||||
|
|
||||||
public UploadResponse upload(MultipartFile file, String projectExtOrgId) {
|
public UploadResponse upload(MultipartFile file, String projectExtOrgId) {
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
@@ -48,6 +51,7 @@ public class AihrCaseService {
|
|||||||
String caseId = "case-" + UUID.randomUUID();
|
String caseId = "case-" + UUID.randomUUID();
|
||||||
CaseState state = new CaseState(caseId, fileName, projectExtOrgId, transcript, tagsFromText(transcript), null);
|
CaseState state = new CaseState(caseId, fileName, projectExtOrgId, transcript, tagsFromText(transcript), null);
|
||||||
cases.put(caseId, state);
|
cases.put(caseId, state);
|
||||||
|
saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript);
|
||||||
return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr");
|
return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr");
|
||||||
} catch (IllegalStateException e) {
|
} catch (IllegalStateException e) {
|
||||||
throw e;
|
throw e;
|
||||||
@@ -62,6 +66,7 @@ public class AihrCaseService {
|
|||||||
CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state));
|
CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state));
|
||||||
CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary());
|
CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary());
|
||||||
cases.put(state.id(), next);
|
cases.put(state.id(), next);
|
||||||
|
saveCase(next, "已整理", caseTitle(summary.summary(), state.fileName()), summary.aiSummary());
|
||||||
return new OrganizeResponse(state.id(), summary.summary(), summary.tags(), summary.aiSummary(), summary.source());
|
return new OrganizeResponse(state.id(), summary.summary(), summary.tags(), summary.aiSummary(), summary.source());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +82,69 @@ public class AihrCaseService {
|
|||||||
"培训组",
|
"培训组",
|
||||||
summary.stream().map(item -> item.label() + ":" + item.text()).findFirst().orElse(state.transcript())
|
summary.stream().map(item -> item.label() + ":" + item.text()).findFirst().orElse(state.transcript())
|
||||||
);
|
);
|
||||||
|
saveCase(state, "已入库", record.title(), record.summary());
|
||||||
return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,视频仍使用预渲染样片。");
|
return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,视频仍使用预渲染样片。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void saveCase(CaseState state, String status, String title, String summary) {
|
||||||
|
ensureCaseTable();
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO aihr_case_record
|
||||||
|
(tenant_id, case_id, file_name, project_ext_org_id, title, type, status, transcript, summary, owner, create_time, update_time)
|
||||||
|
VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, '培训组', now(), now())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
file_name = VALUES(file_name),
|
||||||
|
project_ext_org_id = VALUES(project_ext_org_id),
|
||||||
|
title = VALUES(title),
|
||||||
|
type = VALUES(type),
|
||||||
|
status = VALUES(status),
|
||||||
|
transcript = VALUES(transcript),
|
||||||
|
summary = VALUES(summary),
|
||||||
|
update_time = now()
|
||||||
|
""",
|
||||||
|
state.id(),
|
||||||
|
state.fileName(),
|
||||||
|
firstNonBlank(state.projectExtOrgId(), ""),
|
||||||
|
truncate(title, 120),
|
||||||
|
primaryTag(state.tags()),
|
||||||
|
status,
|
||||||
|
truncate(state.transcript(), 4000),
|
||||||
|
truncate(summary, 1000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureCaseTable() {
|
||||||
|
if (tableReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
if (tableReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jdbcTemplate.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS `aihr_case_record` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||||
|
`case_id` varchar(80) NOT NULL COMMENT '案例ID',
|
||||||
|
`file_name` varchar(255) DEFAULT NULL COMMENT '来源文件',
|
||||||
|
`project_ext_org_id` varchar(100) DEFAULT NULL COMMENT '项目外部组织ID',
|
||||||
|
`title` varchar(120) DEFAULT NULL COMMENT '标题',
|
||||||
|
`type` varchar(80) DEFAULT NULL COMMENT '案例类型',
|
||||||
|
`status` varchar(30) DEFAULT '已转写' COMMENT '状态',
|
||||||
|
`transcript` text DEFAULT NULL COMMENT 'ASR转写',
|
||||||
|
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
|
||||||
|
`owner` varchar(80) DEFAULT '培训组' COMMENT '负责人',
|
||||||
|
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_aihr_case_record_case` (`tenant_id`, `case_id`),
|
||||||
|
KEY `idx_aihr_case_record_status` (`tenant_id`, `status`, `update_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 案例沉淀记录';
|
||||||
|
""");
|
||||||
|
tableReady = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Optional<CaseSummary> organizeWithModel(CaseState state) {
|
private Optional<CaseSummary> organizeWithModel(CaseState state) {
|
||||||
String system = """
|
String system = """
|
||||||
你是物业培训案例编辑。请把转写内容整理成可复用案例。
|
你是物业培训案例编辑。请把转写内容整理成可复用案例。
|
||||||
|
|||||||
+118
-3
@@ -1,5 +1,6 @@
|
|||||||
package org.dromara.aihr.service;
|
package org.dromara.aihr.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -13,8 +14,11 @@ import org.dromara.aihr.domain.AihrInterviewDto.QuestionResponse;
|
|||||||
import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
|
import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
|
||||||
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
||||||
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -34,8 +38,10 @@ public class AihrInterviewService {
|
|||||||
|
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final AihrModelSeedService modelService;
|
private final AihrModelSeedService modelService;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final Map<String, InterviewSession> sessions = new ConcurrentHashMap<>();
|
private final Map<String, InterviewSession> sessions = new ConcurrentHashMap<>();
|
||||||
private final Map<String, CandidateProfile> candidates = buildCandidates();
|
private final Map<String, CandidateProfile> candidates = buildCandidates();
|
||||||
|
private volatile boolean tableReady;
|
||||||
|
|
||||||
public StartResponse start(StartRequest request) {
|
public StartResponse start(StartRequest request) {
|
||||||
CandidateProfile candidate = resolveCandidate(request == null ? null : request.candidateId(), null);
|
CandidateProfile candidate = resolveCandidate(request == null ? null : request.candidateId(), null);
|
||||||
@@ -60,8 +66,9 @@ public class AihrInterviewService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public FinishResponse finish(FinishRequest request) {
|
public FinishResponse finish(FinishRequest request) {
|
||||||
|
String sessionKey = request == null || isBlank(request.sessionId()) ? "iv-" + UUID.randomUUID() : clean(request.sessionId());
|
||||||
InterviewSession session = sessions.computeIfAbsent(
|
InterviewSession session = sessions.computeIfAbsent(
|
||||||
request == null ? "" : clean(request.sessionId()),
|
sessionKey,
|
||||||
key -> {
|
key -> {
|
||||||
CandidateProfile candidate = resolveCandidate(null, key);
|
CandidateProfile candidate = resolveCandidate(null, key);
|
||||||
return new InterviewSession(candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric");
|
return new InterviewSession(candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric");
|
||||||
@@ -76,6 +83,8 @@ public class AihrInterviewService {
|
|||||||
}
|
}
|
||||||
ScoreResult score = scoreWithModel(session).orElseGet(() -> localScore(session));
|
ScoreResult score = scoreWithModel(session).orElseGet(() -> localScore(session));
|
||||||
String time = LocalDateTime.now().format(TIME_FORMAT);
|
String time = LocalDateTime.now().format(TIME_FORMAT);
|
||||||
|
RecordResponse record = new RecordResponse(time, session.candidate().name(), session.candidate().position(), score.total(), score.result(), score.summary());
|
||||||
|
saveResult(sessionKey, session, score);
|
||||||
return new FinishResponse(
|
return new FinishResponse(
|
||||||
score.total(),
|
score.total(),
|
||||||
score.suggestion(),
|
score.suggestion(),
|
||||||
@@ -83,11 +92,117 @@ public class AihrInterviewService {
|
|||||||
score.summary(),
|
score.summary(),
|
||||||
score.evidence(),
|
score.evidence(),
|
||||||
score.dimensions(),
|
score.dimensions(),
|
||||||
List.of(new RecordResponse(time, session.candidate().name(), session.candidate().position(), score.total(), score.result(), score.summary())),
|
List.of(record),
|
||||||
score.source()
|
score.source()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<RecordResponse> records(int limit) {
|
||||||
|
ensureResultTable();
|
||||||
|
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100));
|
||||||
|
return jdbcTemplate.query("""
|
||||||
|
SELECT finished_time, candidate_name, position_code, total_score, result, ai_summary
|
||||||
|
FROM aihr_interview_result
|
||||||
|
WHERE tenant_id = '000000'
|
||||||
|
ORDER BY finished_time DESC, id DESC
|
||||||
|
LIMIT %d
|
||||||
|
""".formatted(safeLimit), this::mapRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
var finishedTime = rs.getTimestamp("finished_time");
|
||||||
|
return new RecordResponse(
|
||||||
|
finishedTime == null ? "" : finishedTime.toLocalDateTime().format(TIME_FORMAT),
|
||||||
|
rs.getString("candidate_name"),
|
||||||
|
rs.getString("position_code"),
|
||||||
|
rs.getInt("total_score"),
|
||||||
|
rs.getString("result"),
|
||||||
|
rs.getString("ai_summary")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveResult(String sessionId, InterviewSession session, ScoreResult score) {
|
||||||
|
ensureResultTable();
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO aihr_interview_result
|
||||||
|
(tenant_id, session_id, candidate_id, candidate_name, position_code, total_score, result,
|
||||||
|
suggestion, ai_summary, evidence, source, answers_json, dimensions_json, finished_time, create_time, update_time)
|
||||||
|
VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now(), now())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
candidate_id = VALUES(candidate_id),
|
||||||
|
candidate_name = VALUES(candidate_name),
|
||||||
|
position_code = VALUES(position_code),
|
||||||
|
total_score = VALUES(total_score),
|
||||||
|
result = VALUES(result),
|
||||||
|
suggestion = VALUES(suggestion),
|
||||||
|
ai_summary = VALUES(ai_summary),
|
||||||
|
evidence = VALUES(evidence),
|
||||||
|
source = VALUES(source),
|
||||||
|
answers_json = VALUES(answers_json),
|
||||||
|
dimensions_json = VALUES(dimensions_json),
|
||||||
|
finished_time = VALUES(finished_time),
|
||||||
|
update_time = now()
|
||||||
|
""",
|
||||||
|
sessionId,
|
||||||
|
session.candidate().id(),
|
||||||
|
session.candidate().name(),
|
||||||
|
session.candidate().position(),
|
||||||
|
score.total(),
|
||||||
|
score.result(),
|
||||||
|
score.suggestion(),
|
||||||
|
score.summary(),
|
||||||
|
score.evidence(),
|
||||||
|
score.source(),
|
||||||
|
json(session.answers()),
|
||||||
|
json(score.dimensions())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureResultTable() {
|
||||||
|
if (tableReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
if (tableReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jdbcTemplate.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS `aihr_interview_result` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||||
|
`session_id` varchar(100) NOT NULL COMMENT '面试会话ID',
|
||||||
|
`candidate_id` varchar(80) DEFAULT NULL COMMENT '候选人ID',
|
||||||
|
`candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名',
|
||||||
|
`position_code` varchar(120) DEFAULT NULL COMMENT '岗位',
|
||||||
|
`total_score` int DEFAULT NULL COMMENT '总分',
|
||||||
|
`result` varchar(40) DEFAULT NULL COMMENT '结论',
|
||||||
|
`suggestion` varchar(500) DEFAULT NULL COMMENT '建议',
|
||||||
|
`ai_summary` varchar(1000) DEFAULT NULL COMMENT 'AI摘要',
|
||||||
|
`evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据',
|
||||||
|
`source` varchar(40) DEFAULT NULL COMMENT '评分来源',
|
||||||
|
`answers_json` text DEFAULT NULL COMMENT '候选人回答JSON',
|
||||||
|
`dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON',
|
||||||
|
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||||
|
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_aihr_interview_session` (`tenant_id`, `session_id`),
|
||||||
|
KEY `idx_aihr_interview_candidate` (`tenant_id`, `candidate_id`, `finished_time`),
|
||||||
|
KEY `idx_aihr_interview_finished` (`tenant_id`, `finished_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 面试结果';
|
||||||
|
""");
|
||||||
|
tableReady = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String json(Object value) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(value);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Optional<GeneratedQuestions> generateQuestions(CandidateProfile candidate) {
|
private Optional<GeneratedQuestions> generateQuestions(CandidateProfile candidate) {
|
||||||
String system = """
|
String system = """
|
||||||
你是物业 HR 面试官。请基于岗位画像生成 3 道现场面试题。
|
你是物业 HR 面试官。请基于岗位画像生成 3 道现场面试题。
|
||||||
@@ -166,7 +281,7 @@ public class AihrInterviewService {
|
|||||||
}
|
}
|
||||||
return Optional.of(new ScoreResult(
|
return Optional.of(new ScoreResult(
|
||||||
total,
|
total,
|
||||||
firstNonBlank(root.path("suggestion").asText(), "建议复试后确认现场协调能力。"),
|
truncate(firstNonBlank(root.path("suggestion").asText(), "建议复试后确认现场协调能力。"), 240),
|
||||||
firstNonBlank(root.path("result").asText(), total >= 85 ? "建议录用" : total >= 75 ? "建议复试" : "观察"),
|
firstNonBlank(root.path("result").asText(), total >= 85 ? "建议录用" : total >= 75 ? "建议复试" : "观察"),
|
||||||
truncate(firstNonBlank(root.path("summary").asText(), "回答已完成,需结合人工复核。"), 180),
|
truncate(firstNonBlank(root.path("summary").asText(), "回答已完成,需结合人工复核。"), 180),
|
||||||
truncate(firstNonBlank(root.path("evidence").asText(), "依据候选人回答中的 SOP 命中、情绪安抚和反馈时限评分。"), 240),
|
truncate(firstNonBlank(root.path("evidence").asText(), "依据候选人回答中的 SOP 命中、情绪安抚和反馈时限评分。"), 240),
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 候选人补充资料:文件走 sys_oss/MinIO,这里只保存候选人与附件关系和审核状态。
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `aihr_candidate_material` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||||
|
`candidate_id` varchar(80) NOT NULL COMMENT '候选人外部ID',
|
||||||
|
`candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名',
|
||||||
|
`material_type` varchar(50) DEFAULT NULL COMMENT '资料类型',
|
||||||
|
`file_name` varchar(255) NOT NULL COMMENT '文件名',
|
||||||
|
`file_size` bigint DEFAULT 0 COMMENT '文件大小',
|
||||||
|
`content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME',
|
||||||
|
`oss_id` bigint NOT NULL COMMENT '对象存储ID',
|
||||||
|
`status` varchar(20) DEFAULT '待审核' COMMENT '审核状态',
|
||||||
|
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_aihr_candidate_material_candidate` (`tenant_id`, `candidate_id`, `id`),
|
||||||
|
KEY `idx_aihr_candidate_material_review` (`tenant_id`, `status`, `id`),
|
||||||
|
KEY `idx_aihr_candidate_material_oss` (`oss_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人补充资料';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- AI 面试结果最小持久化表:finish 后保存评分结果、回答快照和维度分。
|
||||||
|
-- ponytail: one result table covers MVP history and dashboard count; split question/answer tables when audit workflows need it.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `aihr_interview_result` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||||
|
`session_id` varchar(100) NOT NULL COMMENT '面试会话ID',
|
||||||
|
`candidate_id` varchar(80) DEFAULT NULL COMMENT '候选人ID',
|
||||||
|
`candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名',
|
||||||
|
`position_code` varchar(120) DEFAULT NULL COMMENT '岗位',
|
||||||
|
`total_score` int DEFAULT NULL COMMENT '总分',
|
||||||
|
`result` varchar(40) DEFAULT NULL COMMENT '结论',
|
||||||
|
`suggestion` varchar(500) DEFAULT NULL COMMENT '建议',
|
||||||
|
`ai_summary` varchar(1000) DEFAULT NULL COMMENT 'AI摘要',
|
||||||
|
`evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据',
|
||||||
|
`source` varchar(40) DEFAULT NULL COMMENT '评分来源',
|
||||||
|
`answers_json` text DEFAULT NULL COMMENT '候选人回答JSON',
|
||||||
|
`dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON',
|
||||||
|
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||||
|
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_aihr_interview_session` (`tenant_id`, `session_id`),
|
||||||
|
KEY `idx_aihr_interview_candidate` (`tenant_id`, `candidate_id`, `finished_time`),
|
||||||
|
KEY `idx_aihr_interview_finished` (`tenant_id`, `finished_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 面试结果';
|
||||||
@@ -59,17 +59,31 @@ export type InterviewFinishResponse = {
|
|||||||
value: number | string;
|
value: number | string;
|
||||||
note: string;
|
note: string;
|
||||||
}>;
|
}>;
|
||||||
records: Array<{
|
records: InterviewRecord[];
|
||||||
time: string;
|
|
||||||
candidate: string;
|
|
||||||
position: string;
|
|
||||||
score: number;
|
|
||||||
result: string;
|
|
||||||
summary: string;
|
|
||||||
}>;
|
|
||||||
source: string;
|
source: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InterviewRecord = {
|
||||||
|
time: string;
|
||||||
|
candidate: string;
|
||||||
|
position: string;
|
||||||
|
score: number;
|
||||||
|
result: string;
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CandidateMaterialReview = {
|
||||||
|
id: number | string;
|
||||||
|
candidateId: string;
|
||||||
|
candidateName: string;
|
||||||
|
ossId: number | string;
|
||||||
|
fileName: string;
|
||||||
|
materialType: string;
|
||||||
|
fileSize: number;
|
||||||
|
status: string;
|
||||||
|
time: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function startInterview(data: InterviewStartRequest): Promise<ApiResult<InterviewStartResponse>> {
|
export function startInterview(data: InterviewStartRequest): Promise<ApiResult<InterviewStartResponse>> {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/recruit/interview/start',
|
url: '/api/recruit/interview/start',
|
||||||
@@ -96,3 +110,28 @@ export function finishInterview(data: InterviewFinishRequest): Promise<ApiResult
|
|||||||
headers: { repeatSubmit: false }
|
headers: { repeatSubmit: false }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listInterviewRecords(limit = 20): Promise<ApiResult<InterviewRecord[]>> {
|
||||||
|
return request({
|
||||||
|
url: '/api/recruit/interview/records',
|
||||||
|
method: 'get',
|
||||||
|
params: { limit }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCandidateMaterialsForReview(status = ''): Promise<ApiResult<CandidateMaterialReview[]>> {
|
||||||
|
return request({
|
||||||
|
url: '/api/aihr/hr/candidate/materials',
|
||||||
|
method: 'get',
|
||||||
|
params: { status }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewCandidateMaterial(id: number | string, status: string): Promise<ApiResult<CandidateMaterialReview>> {
|
||||||
|
return request({
|
||||||
|
url: `/api/aihr/hr/candidate/materials/${id}/review`,
|
||||||
|
method: 'post',
|
||||||
|
data: { status },
|
||||||
|
headers: { repeatSubmit: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -172,15 +172,64 @@
|
|||||||
<el-table-column prop="summary" label="AI 摘要" min-width="260" />
|
<el-table-column prop="summary" label="AI 摘要" min-width="260" />
|
||||||
</el-table>
|
</el-table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel record-panel material-review-panel">
|
||||||
|
<div class="panel-title title-with-extra">
|
||||||
|
<div>
|
||||||
|
<el-icon><Tickets /></el-icon>
|
||||||
|
<h2>候选资料审核</h2>
|
||||||
|
</div>
|
||||||
|
<div class="review-actions">
|
||||||
|
<el-select v-model="materialStatus" size="small" class="review-filter" @change="loadMaterialReviews">
|
||||||
|
<el-option v-for="status in materialStatusOptions" :key="status" :label="status" :value="status" />
|
||||||
|
</el-select>
|
||||||
|
<el-button size="small" @click="loadMaterialReviews">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="materialLoading" :data="materialReviews" size="small" class="record-table">
|
||||||
|
<el-table-column prop="time" label="提交时间" width="128" />
|
||||||
|
<el-table-column prop="candidateName" label="候选人" width="120" />
|
||||||
|
<el-table-column prop="materialType" label="资料类型" width="120" />
|
||||||
|
<el-table-column label="文件" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="material-name">
|
||||||
|
<strong>{{ row.fileName }}</strong>
|
||||||
|
<small>#{{ row.id }} · OSS {{ row.ossId }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="materialTagType(row.status)" size="small">{{ row.status }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" :disabled="row.status === '已通过'" @click="reviewMaterial(row, '已通过')">通过</el-button>
|
||||||
|
<el-button link type="danger" :disabled="row.status === '已驳回'" @click="reviewMaterial(row, '已驳回')">驳回</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup name="RecruitInterview" lang="ts">
|
<script setup name="RecruitInterview" lang="ts">
|
||||||
import { computed, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import { DocumentChecked, Reading, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
|
import { DocumentChecked, Reading, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { finishInterview, startInterview, submitInterviewAnswer, type InterviewFinishResponse } from '@/api/aihr/interview';
|
import {
|
||||||
|
finishInterview,
|
||||||
|
listCandidateMaterialsForReview,
|
||||||
|
listInterviewRecords,
|
||||||
|
reviewCandidateMaterial,
|
||||||
|
startInterview,
|
||||||
|
submitInterviewAnswer,
|
||||||
|
type CandidateMaterialReview,
|
||||||
|
type InterviewFinishResponse,
|
||||||
|
type InterviewRecord
|
||||||
|
} from '@/api/aihr/interview';
|
||||||
|
|
||||||
type InterviewQuestion = {
|
type InterviewQuestion = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -366,7 +415,11 @@ const apiMode = ref<'idle' | 'api' | 'fallback'>('idle');
|
|||||||
const apiQuestions = ref<InterviewQuestion[]>([]);
|
const apiQuestions = ref<InterviewQuestion[]>([]);
|
||||||
const apiScoreResult = ref<CandidateSeed['scoreResult'] | null>(null);
|
const apiScoreResult = ref<CandidateSeed['scoreResult'] | null>(null);
|
||||||
const answers = reactive<Record<string, string>>({});
|
const answers = reactive<Record<string, string>>({});
|
||||||
const records = ref([...baseRecords]);
|
const records = ref<InterviewRecord[]>([...baseRecords]);
|
||||||
|
const materialReviews = ref<CandidateMaterialReview[]>([]);
|
||||||
|
const materialStatus = ref('待审核');
|
||||||
|
const materialLoading = ref(false);
|
||||||
|
const materialStatusOptions = ['全部', '待审核', '已通过', '已驳回'];
|
||||||
|
|
||||||
const selectedCandidate = computed(() => candidates.find((candidate) => candidate.id === candidateId.value) ?? candidates[0]);
|
const selectedCandidate = computed(() => candidates.find((candidate) => candidate.id === candidateId.value) ?? candidates[0]);
|
||||||
const questions = computed(() => (apiQuestions.value.length > 0 ? apiQuestions.value : selectedCandidate.value.questions));
|
const questions = computed(() => (apiQuestions.value.length > 0 ? apiQuestions.value : selectedCandidate.value.questions));
|
||||||
@@ -430,7 +483,6 @@ const resetFlow = () => {
|
|||||||
apiQuestions.value = [];
|
apiQuestions.value = [];
|
||||||
apiScoreResult.value = null;
|
apiScoreResult.value = null;
|
||||||
resetAnswers();
|
resetAnswers();
|
||||||
records.value = [...baseRecords];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateQuestions = async () => {
|
const generateQuestions = async () => {
|
||||||
@@ -438,7 +490,6 @@ const generateQuestions = async () => {
|
|||||||
scored.value = false;
|
scored.value = false;
|
||||||
apiQuestions.value = [];
|
apiQuestions.value = [];
|
||||||
apiScoreResult.value = null;
|
apiScoreResult.value = null;
|
||||||
records.value = [...baseRecords];
|
|
||||||
try {
|
try {
|
||||||
const { data } = await startInterview({
|
const { data } = await startInterview({
|
||||||
candidateId: selectedCandidate.value.id,
|
candidateId: selectedCandidate.value.id,
|
||||||
@@ -544,7 +595,7 @@ const finishScore = async () => {
|
|||||||
apiScoreResult.value = null;
|
apiScoreResult.value = null;
|
||||||
}
|
}
|
||||||
scored.value = true;
|
scored.value = true;
|
||||||
records.value = [nextRecord, ...baseRecords];
|
records.value = [nextRecord, ...records.value.filter((item) => item.time !== nextRecord.time || item.candidate !== nextRecord.candidate)];
|
||||||
if (apiMode.value !== 'api') {
|
if (apiMode.value !== 'api') {
|
||||||
ElMessage.success('AI 面试评分已生成,并写入本地记录');
|
ElMessage.success('AI 面试评分已生成,并写入本地记录');
|
||||||
}
|
}
|
||||||
@@ -563,6 +614,51 @@ const questionStatus = (question: InterviewQuestion) => {
|
|||||||
const questionPillClass = (question: InterviewQuestion) => {
|
const questionPillClass = (question: InterviewQuestion) => {
|
||||||
return answers[question.id]?.trim() ? 'pill-success' : 'pill-warning';
|
return answers[question.id]?.trim() ? 'pill-success' : 'pill-warning';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadInterviewRecords = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await listInterviewRecords(20);
|
||||||
|
records.value = data?.length ? data : [...baseRecords];
|
||||||
|
} catch {
|
||||||
|
records.value = [...baseRecords];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMaterialReviews = async () => {
|
||||||
|
materialLoading.value = true;
|
||||||
|
try {
|
||||||
|
const status = materialStatus.value === '全部' ? '' : materialStatus.value;
|
||||||
|
const { data } = await listCandidateMaterialsForReview(status);
|
||||||
|
materialReviews.value = data || [];
|
||||||
|
} catch {
|
||||||
|
ElMessage.warning('候选资料审核列表暂不可用');
|
||||||
|
} finally {
|
||||||
|
materialLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewMaterial = async (row: CandidateMaterialReview, status: string) => {
|
||||||
|
try {
|
||||||
|
const { data } = await reviewCandidateMaterial(row.id, status);
|
||||||
|
materialReviews.value = materialReviews.value
|
||||||
|
.map((item) => (item.id === row.id ? data : item))
|
||||||
|
.filter((item) => materialStatus.value === '全部' || item.status === materialStatus.value);
|
||||||
|
ElMessage.success(`候选资料已${status === '已通过' ? '通过' : '驳回'}`);
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('候选资料审核失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const materialTagType = (status: string) => {
|
||||||
|
if (status === '已通过') return 'success';
|
||||||
|
if (status === '已驳回') return 'danger';
|
||||||
|
return 'warning';
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadInterviewRecords();
|
||||||
|
loadMaterialReviews();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -1003,6 +1099,35 @@ const questionPillClass = (question: InterviewQuestion) => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.material-review-panel {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-filter {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-name {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #172033;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: #7a8597;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1360px) {
|
@media (max-width: 1360px) {
|
||||||
.interview-grid {
|
.interview-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
|
|||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_knowledge_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_knowledge_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql"
|
||||||
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_interview_result_mysql8.sql"
|
||||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_org_snapshot_mysql8.sql"
|
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_org_snapshot_mysql8.sql"
|
||||||
|
|
||||||
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \
|
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \
|
||||||
|
|||||||
Reference in New Issue
Block a user