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
+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;