feat(aihr): link candidates to employee principals

This commit is contained in:
2026-07-14 06:22:36 +08:00
parent 0287698214
commit ef188d3283
10 changed files with 330 additions and 1 deletions
@@ -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()) {
@@ -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,
@@ -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()));
@@ -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 候选人与员工主体关联';
+1
View File
@@ -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,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 |
+1
View File
@@ -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 标题和员工首页真实渲染。
+24
View File
@@ -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',
+103 -1
View File
@@ -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;
+3
View File
@@ -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"