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:
2026-07-04 16:05:50 +08:00
parent c47a9bb075
commit 0539adf612
10 changed files with 675 additions and 17 deletions
+47 -8
View File
@@ -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 }
});
}
+131 -6
View File
@@ -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;