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:
@@ -59,17 +59,31 @@ export type InterviewFinishResponse = {
|
||||
value: number | string;
|
||||
note: string;
|
||||
}>;
|
||||
records: Array<{
|
||||
time: string;
|
||||
candidate: string;
|
||||
position: string;
|
||||
score: number;
|
||||
result: string;
|
||||
summary: string;
|
||||
}>;
|
||||
records: InterviewRecord[];
|
||||
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>> {
|
||||
return request({
|
||||
url: '/api/recruit/interview/start',
|
||||
@@ -96,3 +110,28 @@ export function finishInterview(data: InterviewFinishRequest): Promise<ApiResult
|
||||
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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<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 { ElMessage } from 'element-plus';
|
||||
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 = {
|
||||
id: string;
|
||||
@@ -366,7 +415,11 @@ const apiMode = ref<'idle' | 'api' | 'fallback'>('idle');
|
||||
const apiQuestions = ref<InterviewQuestion[]>([]);
|
||||
const apiScoreResult = ref<CandidateSeed['scoreResult'] | null>(null);
|
||||
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 questions = computed(() => (apiQuestions.value.length > 0 ? apiQuestions.value : selectedCandidate.value.questions));
|
||||
@@ -430,7 +483,6 @@ const resetFlow = () => {
|
||||
apiQuestions.value = [];
|
||||
apiScoreResult.value = null;
|
||||
resetAnswers();
|
||||
records.value = [...baseRecords];
|
||||
};
|
||||
|
||||
const generateQuestions = async () => {
|
||||
@@ -438,7 +490,6 @@ const generateQuestions = async () => {
|
||||
scored.value = false;
|
||||
apiQuestions.value = [];
|
||||
apiScoreResult.value = null;
|
||||
records.value = [...baseRecords];
|
||||
try {
|
||||
const { data } = await startInterview({
|
||||
candidateId: selectedCandidate.value.id,
|
||||
@@ -544,7 +595,7 @@ const finishScore = async () => {
|
||||
apiScoreResult.value = null;
|
||||
}
|
||||
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') {
|
||||
ElMessage.success('AI 面试评分已生成,并写入本地记录');
|
||||
}
|
||||
@@ -563,6 +614,51 @@ const questionStatus = (question: InterviewQuestion) => {
|
||||
const questionPillClass = (question: InterviewQuestion) => {
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -1003,6 +1099,35 @@ const questionPillClass = (question: InterviewQuestion) => {
|
||||
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) {
|
||||
.interview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user