feat(aihr): link candidates to employee principals
This commit is contained in:
+21
@@ -1,15 +1,20 @@
|
||||
package org.dromara.aihr.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import cn.dev33.satoken.annotation.SaMode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkRequest;
|
||||
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.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
||||
import org.dromara.aihr.service.AihrInterviewService;
|
||||
import org.dromara.common.core.constant.TenantConstants;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
@@ -32,6 +37,8 @@ import java.util.List;
|
||||
@SaCheckLogin
|
||||
public class AihrInterviewController {
|
||||
|
||||
private static final String HR_OPERATOR_ROLE = "hr_operator";
|
||||
|
||||
private final AihrInterviewService interviewService;
|
||||
|
||||
@PostMapping("/start")
|
||||
@@ -56,6 +63,20 @@ 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)
|
||||
@GetMapping("/candidate-links")
|
||||
public R<CandidateLinkResponse> candidateLink(@RequestParam String candidateId) {
|
||||
return R.ok(interviewService.candidateLink(candidateId));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@PostMapping("/candidate-links")
|
||||
public R<CandidateLinkResponse> linkCandidate(@RequestBody CandidateLinkRequest request) {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
String operator = loginUser == null ? "unknown" : loginUser.getUsername();
|
||||
return R.ok(interviewService.linkCandidate(request, operator));
|
||||
}
|
||||
|
||||
private static StartRequest bindAppCandidate(StartRequest request) {
|
||||
String appUsername = currentAppUsername();
|
||||
if (appUsername.isBlank()) {
|
||||
|
||||
+6
@@ -26,6 +26,12 @@ public final class AihrInterviewDto {
|
||||
public record FinishRequest(String sessionId, Map<String, String> answers) {
|
||||
}
|
||||
|
||||
public record CandidateLinkRequest(String candidateId, String extPartyId) {
|
||||
}
|
||||
|
||||
public record CandidateLinkResponse(String candidateId, String extPartyId, String status, String linkedTime) {
|
||||
}
|
||||
|
||||
public record FinishResponse(
|
||||
Integer totalScore,
|
||||
String suggestion,
|
||||
|
||||
+84
@@ -7,6 +7,8 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.DimensionResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
|
||||
@@ -44,6 +46,7 @@ public class AihrInterviewService {
|
||||
private final Map<String, InterviewSession> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, CandidateProfile> candidates = buildCandidates();
|
||||
private volatile boolean tableReady;
|
||||
private volatile boolean candidateLinkTableReady;
|
||||
|
||||
public StartResponse start(StartRequest request) {
|
||||
CandidateProfile candidate = resolveCandidate(
|
||||
@@ -129,6 +132,59 @@ public class AihrInterviewService {
|
||||
return jdbcTemplate.query(sql.toString(), this::mapRecord, args.toArray());
|
||||
}
|
||||
|
||||
public CandidateLinkResponse candidateLink(String candidateId) {
|
||||
ensureCandidateLinkTable();
|
||||
String normalizedCandidateId = clean(candidateId);
|
||||
if (normalizedCandidateId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return jdbcTemplate.query("""
|
||||
SELECT candidate_id, ext_party_id, status, linked_time
|
||||
FROM aihr_candidate_employee_link
|
||||
WHERE tenant_id = '000000' AND candidate_id = ?
|
||||
LIMIT 1
|
||||
""", (rs, rowNum) -> new CandidateLinkResponse(
|
||||
rs.getString("candidate_id"),
|
||||
rs.getString("ext_party_id"),
|
||||
rs.getString("status"),
|
||||
rs.getTimestamp("linked_time") == null ? "" : rs.getTimestamp("linked_time").toLocalDateTime().format(TIME_FORMAT)
|
||||
), normalizedCandidateId).stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
public CandidateLinkResponse linkCandidate(CandidateLinkRequest request, String operator) {
|
||||
ensureCandidateLinkTable();
|
||||
String candidateId = clean(request == null ? null : request.candidateId());
|
||||
String extPartyId = clean(request == null ? null : request.extPartyId());
|
||||
if (candidateId.isBlank() || candidateId.length() > 80) {
|
||||
throw new IllegalArgumentException("候选人ID不能为空且不能超过80个字符");
|
||||
}
|
||||
if (extPartyId.isBlank() || extPartyId.length() > 100) {
|
||||
throw new IllegalArgumentException("员工主体ID不能为空且不能超过100个字符");
|
||||
}
|
||||
Integer activeEmployee = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM aihr_org_snapshot
|
||||
WHERE tenant_id = '000000'
|
||||
AND employment_status = 'active'
|
||||
AND ext_party_id = ?
|
||||
""", Integer.class, extPartyId);
|
||||
if (activeEmployee == null || activeEmployee == 0) {
|
||||
throw new IllegalArgumentException("员工主体不存在或已离职,请先同步在职组织快照");
|
||||
}
|
||||
CandidateLinkResponse current = candidateLink(candidateId);
|
||||
if (current != null && !extPartyId.equals(current.extPartyId())) {
|
||||
throw new IllegalArgumentException("候选人已关联其他员工主体,请先由HR核对后处理");
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO aihr_candidate_employee_link
|
||||
(tenant_id, candidate_id, ext_party_id, status, linked_by, linked_time, create_time, update_time)
|
||||
VALUES ('000000', ?, ?, '已关联', ?, now(), now(), now())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = '已关联', linked_by = VALUES(linked_by), linked_time = now(), update_time = now()
|
||||
""", candidateId, extPartyId, firstNonBlank(operator, "unknown"));
|
||||
return candidateLink(candidateId);
|
||||
}
|
||||
|
||||
private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException {
|
||||
var finishedTime = rs.getTimestamp("finished_time");
|
||||
return new RecordResponse(
|
||||
@@ -226,6 +282,34 @@ public class AihrInterviewService {
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureCandidateLinkTable() {
|
||||
if (candidateLinkTableReady) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (candidateLinkTableReady) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_candidate_employee_link` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`candidate_id` varchar(80) NOT NULL COMMENT '本地候选人ID',
|
||||
`ext_party_id` varchar(100) NOT NULL COMMENT '外部员工主体ID',
|
||||
`status` varchar(20) DEFAULT '已关联' COMMENT '关联状态',
|
||||
`linked_by` varchar(100) DEFAULT NULL COMMENT '关联操作人',
|
||||
`linked_time` datetime DEFAULT NULL COMMENT '关联时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_candidate_employee_link_candidate` (`tenant_id`, `candidate_id`),
|
||||
KEY `idx_aihr_candidate_employee_link_party` (`tenant_id`, `ext_party_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人与员工主体关联';
|
||||
""");
|
||||
candidateLinkTableReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private List<QuestionRecordResponse> questionRecords(InterviewSession session) {
|
||||
return session.questions().stream().map(question -> {
|
||||
String answer = clean(session.answers().get(question.questionId()));
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkResponse;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AihrInterviewServiceTest {
|
||||
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void linkCandidateRejectsMissingOrInactiveEmployee() {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
AihrInterviewService service = new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate);
|
||||
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("employee-inactive"))).thenReturn(0);
|
||||
|
||||
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.linkCandidate(
|
||||
new CandidateLinkRequest("candidate-1", "employee-inactive"), "hr"
|
||||
));
|
||||
|
||||
assertEquals("员工主体不存在或已离职,请先同步在职组织快照", error.getMessage());
|
||||
verify(jdbcTemplate, never()).update(anyString(), eq("candidate-1"), eq("employee-inactive"), eq("hr"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void linkCandidateRejectsChangingAnExistingEmployee() {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
AihrInterviewService service = spy(new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate));
|
||||
CandidateLinkResponse current = new CandidateLinkResponse("candidate-2", "employee-old", "已关联", "07-14 06:00");
|
||||
doReturn(current).when(service).candidateLink("candidate-2");
|
||||
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("employee-new"))).thenReturn(1);
|
||||
|
||||
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.linkCandidate(
|
||||
new CandidateLinkRequest("candidate-2", "employee-new"), "hr"
|
||||
));
|
||||
|
||||
assertEquals("候选人已关联其他员工主体,请先由HR核对后处理", error.getMessage());
|
||||
verify(jdbcTemplate, never()).update(anyString(), eq("candidate-2"), eq("employee-new"), eq("hr"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void linkCandidateIsIdempotentForTheSameEmployee() {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
AihrInterviewService service = spy(new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate));
|
||||
CandidateLinkResponse linked = new CandidateLinkResponse("candidate-3", "employee-3", "已关联", "07-14 06:10");
|
||||
doReturn(linked).when(service).candidateLink("candidate-3");
|
||||
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("employee-3"))).thenReturn(1);
|
||||
|
||||
CandidateLinkResponse result = service.linkCandidate(
|
||||
new CandidateLinkRequest("candidate-3", "employee-3"), "hr"
|
||||
);
|
||||
|
||||
assertSame(linked, result);
|
||||
verify(jdbcTemplate).update(anyString(), eq("candidate-3"), eq("employee-3"), eq("hr"));
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,18 @@ CREATE TABLE IF NOT EXISTS `aihr_interview_result` (
|
||||
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 面试结果';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_candidate_employee_link` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`candidate_id` varchar(80) NOT NULL COMMENT '本地候选人ID',
|
||||
`ext_party_id` varchar(100) NOT NULL COMMENT '外部员工主体ID',
|
||||
`status` varchar(20) DEFAULT '已关联' COMMENT '关联状态',
|
||||
`linked_by` varchar(100) DEFAULT NULL COMMENT '关联操作人',
|
||||
`linked_time` datetime DEFAULT NULL COMMENT '关联时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_candidate_employee_link_candidate` (`tenant_id`, `candidate_id`),
|
||||
KEY `idx_aihr_candidate_employee_link_party` (`tenant_id`, `ext_party_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人与员工主体关联';
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
| 页面 | 后端接口 | 处理 |
|
||||
|---|---|---|
|
||||
| AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数 |
|
||||
| 候选人入职主体关联 `/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/开发身份 |
|
||||
| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,返回 `{audioUrl}` base64 dataURL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 |
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
- 2026-07-14 权限审查:候选人面试、案例处理、ASR/TTS、SOP 搜索/反馈/总结卡、候选人资料接口均补控制器级登录门禁;匿名首页与 `position-sop` 导航摘要仍保持公开只读,写入、模型调用和候选人私有记录不再依赖业务层“失败返回”兜底。
|
||||
- 2026-07-14 案例详情审查:管理端案例详情已同步展示 `mediaUrl` 原始音频播放器,与员工端音频回放保持一致;当前仍没有正式预渲染视频样片、学习点、主管点评和相关训练场景字段,因此不把音频回放计为完整视频学习闭环。
|
||||
- 2026-07-14 BRD 深度复核:重新按“业务条款→源码路径→数据证据”核对岗位-SOP、训练/错题本、案例详情、RAG、候选人面试、组织权限和成长证据,未发现新的高置信“文档已承诺但代码完全未接通”缺口;`mobile-uni` H5 构建、类型检查与 49 个用户侧单测通过,AIHR 案例/对练定向测试 31 个通过。剩余 P0/P1 仍是正式组织映射、住宅项目试点数据、方言供应商与样本、预渲染视频资产、HR 规则契约等外部输入,不能用继续扩页面或 seed 数据替代。
|
||||
- 2026-07-14 候选人入职链路补齐:管理端候选人面试页新增 HR/管理员将本地候选人 ID 关联至已同步在职 `ext_party_id` 的入口,后端以独立关系表保存租户、主体 ID、状态和操作审计,不复制组织姓名/部门;关联目标必须存在于在职组织快照,重复同主体幂等,关联其他主体拒绝。正式组织同步和真实入职数据仍需试点验证。
|
||||
- 2026-07-14 线上资源只读核对:生产管理端当前加载 `/assets/index-CVcvQ1KE.js`,可检出提示词模板标记但检不出本分支新增的 `case-audio` 管理端播放器标记;因此 `6292e741` 的管理端案例音频回放尚未发布,不能把本地源码状态当作线上状态。发布前需按现有备份、资源哈希和浏览器回归流程执行。
|
||||
- 2026-07-14 发布差异取证:当前分支生产构建管理端主 JS `frontend/dist/assets/index-CJZ3Ax3Z.js` SHA-256 为 `8b9278a26ccb760abce489b12a20b221748c64d4c4153613d2511777bb9677bc`,后端 `backend/ruoyi-admin/target/ruoyi-admin.jar` 为 `2817e0b30a4a0bc52cd5d88a8dd68c52981698185fb5320c24fa2743ab17ccf7`;生产仍加载 `index-CVcvQ1KE.js`,远端 jar SHA-256 为 `18f4643f401aafeab3ee896d7d6341736d5d7ee2343fee0fa86992c5797adcb9`。本轮只读核对,未执行远端写入。
|
||||
- 2026-07-14 06:07 发布完成:备份目录为 `/opt/wygj/backups/release-20260714060705`;管理端、`mobile-uni` H5 和后端 jar 均已同步,线上管理端加载 `index-CJZ3Ax3Z.js`,H5 加载 `index-D4-NrEpb.js`,H5 主 JS SHA-256 为 `ab15bfd17cabe58e2f34b0ac61ee198a837128ed85dc6bb0ddf77bc3e22dcd9c`,后端 jar SHA-256 为 `2817e0b30a4a0bc52cd5d88a8dd68c52981698185fb5320c24fa2743ab17ccf7`;`wygj-aihr.service` active,生产根站点、`/h5/`、`/prod-api/auth/tenant/list` 和员工首页 API 均返回 200,线上管理端资源可检出 `case-audio`。重启瞬间的短暂 502 在服务启动完成后恢复,未形成持续故障;Chrome headless 已确认管理端标题、H5 标题和员工首页真实渲染。
|
||||
|
||||
@@ -72,6 +72,13 @@ export type InterviewRecord = {
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type CandidateLinkResponse = {
|
||||
candidateId: string;
|
||||
extPartyId: string;
|
||||
status: string;
|
||||
linkedTime: string;
|
||||
};
|
||||
|
||||
export type CandidateMaterialReview = {
|
||||
id: number | string;
|
||||
candidateId: string;
|
||||
@@ -119,6 +126,23 @@ export function listInterviewRecords(limit = 20): Promise<ApiResult<InterviewRec
|
||||
});
|
||||
}
|
||||
|
||||
export function getCandidateEmployeeLink(candidateId: string): Promise<ApiResult<CandidateLinkResponse | null>> {
|
||||
return request({
|
||||
url: '/api/recruit/interview/candidate-links',
|
||||
method: 'get',
|
||||
params: { candidateId }
|
||||
});
|
||||
}
|
||||
|
||||
export function linkCandidateEmployee(candidateId: string, extPartyId: string): Promise<ApiResult<CandidateLinkResponse>> {
|
||||
return request({
|
||||
url: '/api/recruit/interview/candidate-links',
|
||||
method: 'post',
|
||||
data: { candidateId, extPartyId },
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function listCandidateMaterialsForReview(status = ''): Promise<ApiResult<CandidateMaterialReview[]>> {
|
||||
return request({
|
||||
url: '/api/aihr/hr/candidate/materials',
|
||||
|
||||
@@ -47,6 +47,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="candidate-link-card">
|
||||
<div class="link-card-head">
|
||||
<div>
|
||||
<span>入职主体关联</span>
|
||||
<small>只绑定外部员工主体 ID,不复制组织姓名或部门</small>
|
||||
</div>
|
||||
<el-tag v-if="candidateLink" type="success" size="small">{{ candidateLink.status }}</el-tag>
|
||||
</div>
|
||||
<div v-if="candidateLink" class="linked-party">{{ candidateLink.extPartyId }} · {{ candidateLink.linkedTime }}</div>
|
||||
<div class="link-card-actions">
|
||||
<el-input v-model="linkExtPartyId" size="small" placeholder="输入已同步的 ext_party_id" clearable />
|
||||
<el-button type="danger" size="small" :loading="linkLoading" @click="submitCandidateLink">关联</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="job-card">
|
||||
<span>岗位画像</span>
|
||||
<p>{{ selectedCandidate.job.description }}</p>
|
||||
@@ -207,18 +222,21 @@
|
||||
</template>
|
||||
|
||||
<script setup name="RecruitInterview" lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { DocumentChecked, Reading, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
finishInterview,
|
||||
getCandidateEmployeeLink,
|
||||
linkCandidateEmployee,
|
||||
listCandidateMaterialsForReview,
|
||||
listInterviewRecords,
|
||||
reviewCandidateMaterial,
|
||||
startInterview,
|
||||
submitInterviewAnswer,
|
||||
type CandidateMaterialReview,
|
||||
type CandidateLinkResponse,
|
||||
type InterviewFinishResponse,
|
||||
type InterviewRecord
|
||||
} from '@/api/aihr/interview';
|
||||
@@ -412,6 +430,9 @@ const materialReviews = ref<CandidateMaterialReview[]>([]);
|
||||
const materialStatus = ref('待审核');
|
||||
const materialLoading = ref(false);
|
||||
const materialStatusOptions = ['全部', '待审核', '已通过', '已驳回'];
|
||||
const candidateLink = ref<CandidateLinkResponse | null>(null);
|
||||
const linkExtPartyId = ref('');
|
||||
const linkLoading = ref(false);
|
||||
|
||||
const selectedCandidate = computed(() => candidates.find((candidate) => candidate.id === candidateId.value) ?? candidates[0]);
|
||||
const questions = computed(() => (apiQuestions.value.length > 0 ? apiQuestions.value : selectedCandidate.value.questions));
|
||||
@@ -605,6 +626,37 @@ const loadInterviewRecords = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadCandidateLink = async () => {
|
||||
candidateLink.value = null;
|
||||
linkExtPartyId.value = '';
|
||||
try {
|
||||
const { data } = await getCandidateEmployeeLink(candidateId.value);
|
||||
candidateLink.value = data;
|
||||
linkExtPartyId.value = data?.extPartyId ?? '';
|
||||
} catch {
|
||||
// Candidate linkage is optional until the HR process reaches onboarding.
|
||||
}
|
||||
};
|
||||
|
||||
const submitCandidateLink = async () => {
|
||||
const extPartyId = linkExtPartyId.value.trim();
|
||||
if (!extPartyId) {
|
||||
ElMessage.warning('请输入已同步的员工主体 ID');
|
||||
return;
|
||||
}
|
||||
linkLoading.value = true;
|
||||
try {
|
||||
const { data } = await linkCandidateEmployee(candidateId.value, extPartyId);
|
||||
candidateLink.value = data;
|
||||
linkExtPartyId.value = data.extPartyId;
|
||||
ElMessage.success('候选人已关联员工主体');
|
||||
} catch {
|
||||
ElMessage.error('关联失败,请确认主体已在职组织快照中');
|
||||
} finally {
|
||||
linkLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMaterialReviews = async () => {
|
||||
materialLoading.value = true;
|
||||
try {
|
||||
@@ -639,7 +691,10 @@ const materialTagType = (status: string) => {
|
||||
onMounted(() => {
|
||||
loadInterviewRecords();
|
||||
loadMaterialReviews();
|
||||
loadCandidateLink();
|
||||
});
|
||||
|
||||
watch(candidateId, loadCandidateLink);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -844,6 +899,53 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.candidate-link-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5eaf3;
|
||||
border-radius: 8px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.link-card-head,
|
||||
.link-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.link-card-head {
|
||||
span,
|
||||
small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #172033;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #68758a;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.linked-party {
|
||||
color: #3c577c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.link-card-actions {
|
||||
:deep(.el-input) {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -371,7 +371,10 @@ contains frontend/src/views/system/prompts.vue "保存并启用"
|
||||
contains frontend/src/views/competency/growth.vue "当前运营口径(只读)"
|
||||
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 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 "@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"
|
||||
contains frontend/src/api/aihr/practice.ts "/api/aihr/prompt-templates"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/api/train/practice"
|
||||
|
||||
Reference in New Issue
Block a user