面试记录落 aihr_interview_result(GET /records 查询),候选人补充资料 走 OSS + aihr_candidate_material,管理端 AI 面试页可审核通过/驳回; 案例整理服务增强;两张新表纳入 reset 脚本。
1168 lines
32 KiB
Vue
1168 lines
32 KiB
Vue
<template>
|
|
<div class="interview-page">
|
|
<section class="page-head">
|
|
<div>
|
|
<h1>AI 面试出题</h1>
|
|
<p>岗位画像驱动出题、候选人作答、Rubric 评分、面试记录沉淀。</p>
|
|
</div>
|
|
<div class="head-actions">
|
|
<span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span>
|
|
<span class="status-pill" :class="flowPill.className">{{ flowPill.text }}</span>
|
|
<el-button @click="router.push('/index')">返回首页</el-button>
|
|
<el-button type="danger" :icon="Refresh" @click="resetFlow">重置演示</el-button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="flow-strip">
|
|
<button v-for="step in flowSteps" :key="step.title" type="button" class="flow-step" :class="{ active: step.active, done: step.done }">
|
|
<span>{{ step.index }}</span>
|
|
<strong>{{ step.title }}</strong>
|
|
<small>{{ step.desc }}</small>
|
|
</button>
|
|
</section>
|
|
|
|
<section class="interview-grid">
|
|
<article class="panel candidate-panel">
|
|
<div class="panel-title">
|
|
<el-icon><UserFilled /></el-icon>
|
|
<h2>面试对象</h2>
|
|
</div>
|
|
|
|
<div class="select-card">
|
|
<span>候选人</span>
|
|
<el-select v-model="candidateId" class="candidate-select" @change="resetFlow">
|
|
<el-option
|
|
v-for="candidate in candidates"
|
|
:key="candidate.id"
|
|
:label="`${candidate.name} / ${candidate.job.title}`"
|
|
:value="candidate.id"
|
|
/>
|
|
</el-select>
|
|
</div>
|
|
|
|
<div class="field-list">
|
|
<div v-for="item in candidateProfile" :key="item.label" class="field-item">
|
|
<span>{{ item.label }}</span>
|
|
<strong>{{ item.value }}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="job-card">
|
|
<span>岗位画像</span>
|
|
<p>{{ selectedCandidate.job.description }}</p>
|
|
<div class="tag-list">
|
|
<span v-for="tag in selectedCandidate.job.capabilities" :key="tag">{{ tag }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="action-row">
|
|
<el-button type="danger" :icon="DocumentChecked" :disabled="generated" @click="generateQuestions">生成题目</el-button>
|
|
<el-button :icon="Reading" @click="showRubric = !showRubric">{{ showRubric ? '收起 Rubric' : '查看 Rubric' }}</el-button>
|
|
</div>
|
|
|
|
<div v-if="showRubric" class="rubric-card">
|
|
<div v-for="item in rubric" :key="item.label" class="rubric-item">
|
|
<strong>{{ item.label }}</strong>
|
|
<span>{{ item.weight }} · {{ item.rule }}</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="panel question-panel">
|
|
<div class="panel-title title-with-extra">
|
|
<div>
|
|
<el-icon><DocumentChecked /></el-icon>
|
|
<h2>AI 生成面试题</h2>
|
|
</div>
|
|
<span>{{ answeredCount }}/{{ questions.length }} 已作答</span>
|
|
</div>
|
|
|
|
<div v-if="!generated" class="empty-state">
|
|
<strong>等待生成面试题</strong>
|
|
<p>点击左侧“生成题目”,系统会按当前岗位画像生成 3 道面试题。</p>
|
|
<el-button type="danger" :icon="DocumentChecked" @click="generateQuestions">生成题目</el-button>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div class="question-list">
|
|
<button
|
|
v-for="question in questions"
|
|
:key="question.id"
|
|
type="button"
|
|
class="question-item"
|
|
:class="{ active: question.id === activeQuestion.id }"
|
|
@click="activeQuestionId = question.id"
|
|
>
|
|
<div class="question-index">{{ question.seq }}</div>
|
|
<div>
|
|
<strong>{{ question.text }}</strong>
|
|
<span>{{ question.point }}</span>
|
|
</div>
|
|
<span class="status-pill" :class="questionPillClass(question)">{{ questionStatus(question) }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="answer-box">
|
|
<div class="answer-head">
|
|
<span>候选人回答 · {{ activeQuestion.scenario }}</span>
|
|
<el-button text type="danger" @click="fillSampleAnswer">填入参考回答</el-button>
|
|
</div>
|
|
<el-input
|
|
v-model="answers[activeQuestion.id]"
|
|
type="textarea"
|
|
:rows="6"
|
|
resize="none"
|
|
placeholder="输入候选人真实回答,或点击“填入参考回答”快速推进测试"
|
|
/>
|
|
</div>
|
|
|
|
<div class="action-row question-actions">
|
|
<el-button @click="moveQuestion(-1)">上一题</el-button>
|
|
<el-button @click="moveQuestion(1)">下一题</el-button>
|
|
<el-button @click="fillAllSampleAnswers">填满参考回答</el-button>
|
|
<el-button @click="saveDraft">保存草稿</el-button>
|
|
<el-button type="danger" :disabled="!canScore" @click="finishScore">完成评分</el-button>
|
|
</div>
|
|
</template>
|
|
</article>
|
|
|
|
<article class="panel score-panel">
|
|
<div class="panel-title">
|
|
<el-icon><TrendCharts /></el-icon>
|
|
<h2>AI 打分结果</h2>
|
|
</div>
|
|
|
|
<div v-if="!scored" class="empty-state score-empty">
|
|
<strong>等待评分</strong>
|
|
<p>完成 3 道回答后点击“完成评分”,右侧会展示总分、录用建议和证据引用。</p>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div class="total-score">
|
|
<span>总分</span>
|
|
<strong>{{ scoreResult.total }}</strong>
|
|
<small>{{ scoreResult.suggestion }}</small>
|
|
</div>
|
|
<div class="score-grid">
|
|
<div v-for="score in scoreResult.dimensions" :key="score.label" class="score-item">
|
|
<span>{{ score.label }}</span>
|
|
<strong>{{ score.value }}</strong>
|
|
<small>{{ score.note }}</small>
|
|
</div>
|
|
</div>
|
|
<div class="evidence-card">
|
|
<strong>证据引用</strong>
|
|
<p>{{ scoreResult.evidence }}</p>
|
|
</div>
|
|
</template>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="panel record-panel">
|
|
<div class="panel-title">
|
|
<el-icon><Tickets /></el-icon>
|
|
<h2>面试记录</h2>
|
|
</div>
|
|
<el-table :data="records" size="small" class="record-table">
|
|
<el-table-column prop="time" label="时间" width="128" />
|
|
<el-table-column prop="candidate" label="候选人" width="120" />
|
|
<el-table-column prop="position" label="岗位" min-width="150" />
|
|
<el-table-column prop="score" label="得分" width="80" />
|
|
<el-table-column prop="result" label="结论" width="120" />
|
|
<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, 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,
|
|
listCandidateMaterialsForReview,
|
|
listInterviewRecords,
|
|
reviewCandidateMaterial,
|
|
startInterview,
|
|
submitInterviewAnswer,
|
|
type CandidateMaterialReview,
|
|
type InterviewFinishResponse,
|
|
type InterviewRecord
|
|
} from '@/api/aihr/interview';
|
|
|
|
type InterviewQuestion = {
|
|
id: string;
|
|
seq: number;
|
|
text: string;
|
|
point: string;
|
|
scenario: string;
|
|
sampleAnswer: string;
|
|
};
|
|
|
|
type CandidateSeed = {
|
|
id: string;
|
|
name: string;
|
|
project: string;
|
|
mode: string;
|
|
experience: string;
|
|
risk: string;
|
|
job: {
|
|
title: string;
|
|
description: string;
|
|
capabilities: string[];
|
|
};
|
|
questions: InterviewQuestion[];
|
|
scoreResult: {
|
|
total: number;
|
|
suggestion: string;
|
|
result: string;
|
|
summary: string;
|
|
evidence: string;
|
|
dimensions: Array<{
|
|
label: string;
|
|
value: number | string;
|
|
note: string;
|
|
}>;
|
|
};
|
|
};
|
|
|
|
const router = useRouter();
|
|
|
|
const candidates: CandidateSeed[] = [
|
|
{
|
|
id: 'zhang-ming',
|
|
name: '张明',
|
|
project: '银城花园 / 住宅',
|
|
mode: '文本面试',
|
|
experience: '3 年客服与案场接待',
|
|
risk: '催缴冲突经验不足',
|
|
job: {
|
|
title: '生活顾问',
|
|
description: '负责业主接待、投诉处理、物业费沟通、基础 SOP 执行和跨部门反馈闭环。',
|
|
capabilities: ['投诉处理', '催缴沟通', 'SOP 执行', '情绪安抚']
|
|
},
|
|
questions: [
|
|
{
|
|
id: 'q1',
|
|
seq: 1,
|
|
text: '业主因漏水情绪激动到前台投诉,你第一句话怎么说?',
|
|
point: '考察情绪安抚、诉求确认、SOP 起手动作',
|
|
scenario: '投诉接待',
|
|
sampleAnswer:
|
|
'我会先说“您先别着急,我先把情况完整记下来,今天一定给您明确反馈”。随后确认漏水位置、影响范围和是否已经报修,再同步工程人员到场时间。'
|
|
},
|
|
{
|
|
id: 'q2',
|
|
seq: 2,
|
|
text: '催缴物业费时,业主质疑服务不到位,你如何回应?',
|
|
point: '考察费用争议、服务改进、冲突降级',
|
|
scenario: '费用沟通',
|
|
sampleAnswer:
|
|
'我会先承认服务感受需要重视,把业主提到的问题列成清单,同时说明费用缴纳和服务整改可以并行推进。对争议事项约定复核时限,必要时请主管一起回访。'
|
|
},
|
|
{
|
|
id: 'q3',
|
|
seq: 3,
|
|
text: '突发停水时,多名业主集中追问,你如何组织信息同步?',
|
|
point: '考察群体沟通、反馈时限和跨部门协同',
|
|
scenario: '突发事件',
|
|
sampleAnswer:
|
|
'我会先确认是否已有工程排查结论,再在业主群发布统一口径,包括影响范围、预计恢复时间和下一次更新时间。现场安排同事分流解释,避免多人重复给出不同说法。'
|
|
}
|
|
],
|
|
scoreResult: {
|
|
total: 86,
|
|
suggestion: '建议:进入复试,重点补强催缴冲突话术。',
|
|
result: '建议复试',
|
|
summary: '服务意识较好,能先安抚再确认诉求,催缴争议处理需要补强。',
|
|
evidence: '命中 SOP:先安抚情绪、确认诉求、明确反馈时限;遗漏:费用争议的升级处理路径还不够明确。',
|
|
dimensions: [
|
|
{ label: '表达清晰', value: 86, note: '结构完整' },
|
|
{ label: 'SOP 命中', value: 82, note: '漏升级路径' },
|
|
{ label: '情绪安抚', value: 90, note: '开场稳定' },
|
|
{ label: '录用风险', value: '低', note: '需复试确认' }
|
|
]
|
|
}
|
|
},
|
|
{
|
|
id: 'li-na',
|
|
name: '李娜',
|
|
project: '铂悦府 / 高端住宅',
|
|
mode: '文本面试',
|
|
experience: '2 年呼叫中心经验',
|
|
risk: '现场协调经验偏弱',
|
|
job: {
|
|
title: '客服管家',
|
|
description: '负责报事报修、客户回访、满意度维护和跨部门问题跟踪。',
|
|
capabilities: ['报事报修', '客户回访', '满意度维护', '协调跟进']
|
|
},
|
|
questions: [
|
|
{
|
|
id: 'q1',
|
|
seq: 1,
|
|
text: '业主反映报修两天没人跟进,你如何核实并回复?',
|
|
point: '考察问题核实、责任确认和反馈时限',
|
|
scenario: '报修跟进',
|
|
sampleAnswer: '我会先查报修工单和工程派单记录,确认卡点在哪里,再向业主说明当前状态和下一步处理时间。如果确实超时,我会先道歉并同步主管跟进。'
|
|
},
|
|
{
|
|
id: 'q2',
|
|
seq: 2,
|
|
text: '业主在群里连续表达不满,你如何避免情绪扩散?',
|
|
point: '考察群体沟通和单点安抚',
|
|
scenario: '社群沟通',
|
|
sampleAnswer: '我会在群里先给出统一、准确的处理进展,再私聊业主了解细节,避免群内反复争论。后续在约定时间把结果同步到群里。'
|
|
},
|
|
{
|
|
id: 'q3',
|
|
seq: 3,
|
|
text: '回访满意度低于预期时,你会如何复盘?',
|
|
point: '考察复盘意识和改进动作',
|
|
scenario: '满意度维护',
|
|
sampleAnswer: '我会拆分低分原因,是响应慢、解释不清还是结果未达预期,再对照工单节点找责任点。能当天补救的先补救,不能当天解决的给出明确计划。'
|
|
}
|
|
],
|
|
scoreResult: {
|
|
total: 78,
|
|
suggestion: '建议:进入观察名单,需补现场协调和群体沟通训练。',
|
|
result: '观察',
|
|
summary: '回答完整但反馈时限不够明确,建议补充 SOP 训练。',
|
|
evidence: '命中 SOP:核实工单、私聊安抚、结果回访;遗漏:跨部门升级责任人和节点确认不够具体。',
|
|
dimensions: [
|
|
{ label: '表达清晰', value: 80, note: '逻辑完整' },
|
|
{ label: 'SOP 命中', value: 74, note: '升级不足' },
|
|
{ label: '情绪安抚', value: 82, note: '能稳住群聊' },
|
|
{ label: '录用风险', value: '中', note: '需带教' }
|
|
]
|
|
}
|
|
}
|
|
];
|
|
|
|
const rubric = [
|
|
{ label: '表达清晰', weight: '30%', rule: '是否能按背景、动作、承诺三个层次组织回答' },
|
|
{ label: 'SOP 命中', weight: '30%', rule: '是否命中接待、记录、派单、反馈、升级关键节点' },
|
|
{ label: '情绪安抚', weight: '25%', rule: '是否先承接情绪,再推进事实核实和处理承诺' },
|
|
{ label: '录用风险', weight: '15%', rule: '是否暴露强冲突、推责或无法闭环的风险' }
|
|
] as const;
|
|
|
|
const baseRecords = [
|
|
{
|
|
time: '07-02 09:42',
|
|
candidate: '李娜',
|
|
position: '客服管家',
|
|
score: 78,
|
|
result: '观察',
|
|
summary: '回答完整但反馈时限不够明确,建议补充 SOP 训练。'
|
|
},
|
|
{
|
|
time: '07-01 17:15',
|
|
candidate: '王磊',
|
|
position: '秩序主管',
|
|
score: 91,
|
|
result: '建议录用',
|
|
summary: '突发事件处理清晰,风险意识和协调表达较强。'
|
|
}
|
|
];
|
|
|
|
const candidateId = ref(candidates[0].id);
|
|
const generated = ref(false);
|
|
const scored = ref(false);
|
|
const showRubric = ref(false);
|
|
const activeQuestionId = ref('');
|
|
const sessionId = ref('');
|
|
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<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));
|
|
const activeQuestion = computed(() => questions.value.find((question) => question.id === activeQuestionId.value) ?? questions.value[0]);
|
|
const scoreResult = computed(() => apiScoreResult.value ?? selectedCandidate.value.scoreResult);
|
|
|
|
const candidateProfile = computed(() => [
|
|
{ label: '候选人', value: selectedCandidate.value.name },
|
|
{ label: '应聘岗位', value: selectedCandidate.value.job.title },
|
|
{ label: '项目类型', value: selectedCandidate.value.project },
|
|
{ label: '面试模式', value: selectedCandidate.value.mode },
|
|
{ label: '履历摘要', value: selectedCandidate.value.experience },
|
|
{ label: '关注风险', value: selectedCandidate.value.risk }
|
|
]);
|
|
|
|
const answeredCount = computed(() => questions.value.filter((question) => answers[question.id]?.trim()).length);
|
|
const canScore = computed(() => generated.value && answeredCount.value === questions.value.length);
|
|
const apiPill = computed(() => {
|
|
if (apiMode.value === 'api') {
|
|
return { text: '真实 API', className: 'pill-info' };
|
|
}
|
|
if (apiMode.value === 'fallback') {
|
|
return { text: '本地 Rubric', className: 'pill-warning' };
|
|
}
|
|
return { text: '待调用 API', className: 'pill-info' };
|
|
});
|
|
const flowPill = computed(() => {
|
|
if (!generated.value) {
|
|
return { text: '待生成题目', className: 'pill-warning' };
|
|
}
|
|
if (!scored.value) {
|
|
return { text: '作答评分中', className: 'pill-warning' };
|
|
}
|
|
return { text: '已完成闭环', className: 'pill-success' };
|
|
});
|
|
const flowSteps = computed(() => [
|
|
{ index: '1', title: '选岗位画像', desc: selectedCandidate.value.job.title, active: !generated.value, done: generated.value || scored.value },
|
|
{
|
|
index: '2',
|
|
title: '生成与作答',
|
|
desc: `${answeredCount.value}/${questions.value.length} 题`,
|
|
active: generated.value && !scored.value,
|
|
done: scored.value
|
|
},
|
|
{ index: '3', title: 'AI 评分沉淀', desc: scored.value ? scoreResult.value.result : '等待评分', active: scored.value, done: scored.value }
|
|
]);
|
|
|
|
const resetAnswers = () => {
|
|
Object.keys(answers).forEach((key) => {
|
|
delete answers[key];
|
|
});
|
|
};
|
|
|
|
const resetFlow = () => {
|
|
generated.value = false;
|
|
scored.value = false;
|
|
showRubric.value = false;
|
|
activeQuestionId.value = '';
|
|
sessionId.value = '';
|
|
apiMode.value = 'idle';
|
|
apiQuestions.value = [];
|
|
apiScoreResult.value = null;
|
|
resetAnswers();
|
|
};
|
|
|
|
const generateQuestions = async () => {
|
|
resetAnswers();
|
|
scored.value = false;
|
|
apiQuestions.value = [];
|
|
apiScoreResult.value = null;
|
|
try {
|
|
const { data } = await startInterview({
|
|
candidateId: selectedCandidate.value.id,
|
|
positionCode: selectedCandidate.value.job.title,
|
|
mode: 'real'
|
|
});
|
|
sessionId.value = data.sessionId;
|
|
apiMode.value = 'api';
|
|
apiQuestions.value = (data.questions || []).map((question) => ({
|
|
id: question.questionId,
|
|
seq: question.seq,
|
|
text: question.questionText,
|
|
point: question.point,
|
|
scenario: question.scenario,
|
|
sampleAnswer: question.sampleAnswer
|
|
}));
|
|
activeQuestionId.value = apiQuestions.value[0]?.id ?? questions.value[0].id;
|
|
ElMessage.success(data.source === 'real-llm' ? '已由真实模型生成面试题' : '模型未配置,已使用本地 Rubric 题库');
|
|
} catch {
|
|
sessionId.value = '';
|
|
apiMode.value = 'fallback';
|
|
apiQuestions.value = [];
|
|
activeQuestionId.value = questions.value[0].id;
|
|
ElMessage.warning('后端 API 不可用,已使用本地 Rubric 题库');
|
|
}
|
|
generated.value = true;
|
|
};
|
|
|
|
const fillSampleAnswer = () => {
|
|
answers[activeQuestion.value.id] = activeQuestion.value.sampleAnswer;
|
|
};
|
|
|
|
const fillAllSampleAnswers = () => {
|
|
questions.value.forEach((question) => {
|
|
answers[question.id] = question.sampleAnswer;
|
|
});
|
|
ElMessage.success('已填入全部参考回答');
|
|
};
|
|
|
|
const moveQuestion = (offset: number) => {
|
|
const index = questions.value.findIndex((question) => question.id === activeQuestion.value.id);
|
|
const nextIndex = Math.min(Math.max(index + offset, 0), questions.value.length - 1);
|
|
activeQuestionId.value = questions.value[nextIndex].id;
|
|
};
|
|
|
|
const saveDraft = async () => {
|
|
const answerText = answers[activeQuestion.value.id]?.trim();
|
|
if (!answerText) {
|
|
ElMessage.warning('请先填写当前题回答');
|
|
return;
|
|
}
|
|
if (apiMode.value === 'api' && sessionId.value) {
|
|
try {
|
|
await submitInterviewAnswer({
|
|
sessionId: sessionId.value,
|
|
questionId: activeQuestion.value.id,
|
|
answerText
|
|
});
|
|
ElMessage.success('回答已提交后端 API');
|
|
return;
|
|
} catch {
|
|
apiMode.value = 'fallback';
|
|
ElMessage.warning('后端暂存 API 不可用,已保留本地草稿');
|
|
}
|
|
}
|
|
ElMessage.success('草稿已暂存到本地演示状态');
|
|
};
|
|
|
|
const mapApiScoreResult = (data: InterviewFinishResponse): CandidateSeed['scoreResult'] => ({
|
|
total: data.totalScore,
|
|
suggestion: data.suggestion,
|
|
result: data.result,
|
|
summary: data.aiSummary,
|
|
evidence: data.evidence,
|
|
dimensions: data.dimensions
|
|
});
|
|
|
|
const finishScore = async () => {
|
|
if (!canScore.value) {
|
|
ElMessage.warning('请先完成 3 道题的回答');
|
|
return;
|
|
}
|
|
let nextRecord = {
|
|
time: '07-02 10:35',
|
|
candidate: selectedCandidate.value.name,
|
|
position: selectedCandidate.value.job.title,
|
|
score: selectedCandidate.value.scoreResult.total,
|
|
result: selectedCandidate.value.scoreResult.result,
|
|
summary: selectedCandidate.value.scoreResult.summary
|
|
};
|
|
if (apiMode.value === 'api' && sessionId.value) {
|
|
try {
|
|
const { data } = await finishInterview({ sessionId: sessionId.value, answers: { ...answers } });
|
|
apiScoreResult.value = mapApiScoreResult(data);
|
|
nextRecord = data.records?.[0] ?? nextRecord;
|
|
ElMessage.success(data.source === 'real-llm' ? 'AI 面试评分已由真实模型返回' : 'AI 面试评分已由本地 Rubric 返回');
|
|
} catch {
|
|
apiMode.value = 'fallback';
|
|
apiScoreResult.value = null;
|
|
ElMessage.warning('后端评分 API 不可用,已回退本地 Rubric 评分');
|
|
}
|
|
} else {
|
|
apiScoreResult.value = null;
|
|
}
|
|
scored.value = true;
|
|
records.value = [nextRecord, ...records.value.filter((item) => item.time !== nextRecord.time || item.candidate !== nextRecord.candidate)];
|
|
if (apiMode.value !== 'api') {
|
|
ElMessage.success('AI 面试评分已生成,并写入本地记录');
|
|
}
|
|
};
|
|
|
|
const questionStatus = (question: InterviewQuestion) => {
|
|
if (scored.value && answers[question.id]?.trim()) {
|
|
return '已评分';
|
|
}
|
|
if (answers[question.id]?.trim()) {
|
|
return '已答';
|
|
}
|
|
return question.id === activeQuestion.value.id ? '作答中' : '待答';
|
|
};
|
|
|
|
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>
|
|
.interview-page {
|
|
min-height: calc(100vh - 84px);
|
|
padding: 22px;
|
|
background: #f5f7fb;
|
|
color: #162033;
|
|
}
|
|
|
|
.page-head {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: space-between;
|
|
gap: 18px;
|
|
margin-bottom: 16px;
|
|
|
|
h1 {
|
|
margin: 0 0 8px;
|
|
font-size: 24px;
|
|
line-height: 1.2;
|
|
font-weight: 700;
|
|
}
|
|
|
|
p {
|
|
margin: 0;
|
|
color: #657184;
|
|
font-size: 14px;
|
|
}
|
|
}
|
|
|
|
.head-actions,
|
|
.action-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.flow-strip {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 10px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.flow-step {
|
|
display: grid;
|
|
grid-template-columns: 34px minmax(0, 1fr);
|
|
gap: 4px 10px;
|
|
align-items: center;
|
|
min-height: 70px;
|
|
padding: 12px;
|
|
border: 1px solid #e4e9f2;
|
|
border-radius: 8px;
|
|
background: #fff;
|
|
text-align: left;
|
|
cursor: default;
|
|
|
|
span {
|
|
display: grid;
|
|
grid-row: span 2;
|
|
width: 32px;
|
|
height: 32px;
|
|
place-items: center;
|
|
border-radius: 50%;
|
|
background: #eef4ff;
|
|
color: #356bb4;
|
|
font-weight: 700;
|
|
}
|
|
|
|
strong {
|
|
color: #22314a;
|
|
font-size: 14px;
|
|
}
|
|
|
|
small {
|
|
color: #7a8597;
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
|
|
.flow-step.active {
|
|
border-color: #f5a1a8;
|
|
box-shadow: 0 8px 20px rgba(215, 15, 32, 0.08);
|
|
}
|
|
|
|
.flow-step.done span {
|
|
color: #15844a;
|
|
background: #e5f8ee;
|
|
}
|
|
|
|
.question-actions {
|
|
justify-content: flex-end;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.interview-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(280px, 0.9fr) minmax(440px, 1.35fr) minmax(320px, 1fr);
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.panel {
|
|
border: 1px solid #e4e9f2;
|
|
border-radius: 8px;
|
|
background: #fff;
|
|
box-shadow: 0 10px 24px rgba(21, 30, 45, 0.04);
|
|
padding: 18px;
|
|
}
|
|
|
|
.panel-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 16px;
|
|
color: #4971b6;
|
|
|
|
h2 {
|
|
margin: 0;
|
|
color: #4971b6;
|
|
font-size: 17px;
|
|
font-weight: 700;
|
|
}
|
|
}
|
|
|
|
.title-with-extra {
|
|
justify-content: space-between;
|
|
|
|
div {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
span {
|
|
color: #657184;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
}
|
|
|
|
.select-card,
|
|
.field-item,
|
|
.job-card,
|
|
.answer-box,
|
|
.evidence-card,
|
|
.rubric-card {
|
|
padding: 12px;
|
|
border: 1px solid #e5eaf3;
|
|
border-radius: 8px;
|
|
background: #f9fbfe;
|
|
}
|
|
|
|
.select-card {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
|
|
span {
|
|
color: #7a8597;
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
|
|
.candidate-select {
|
|
width: 100%;
|
|
}
|
|
|
|
.field-list {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.field-item {
|
|
display: grid;
|
|
gap: 4px;
|
|
|
|
span {
|
|
color: #7a8597;
|
|
font-size: 12px;
|
|
}
|
|
|
|
strong {
|
|
font-size: 14px;
|
|
}
|
|
}
|
|
|
|
.job-card {
|
|
margin: 12px 0;
|
|
|
|
> span {
|
|
color: #7a8597;
|
|
font-size: 12px;
|
|
}
|
|
|
|
p {
|
|
margin: 6px 0 10px;
|
|
color: #344255;
|
|
line-height: 1.6;
|
|
}
|
|
}
|
|
|
|
.tag-list {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
|
|
span {
|
|
padding: 4px 8px;
|
|
border-radius: 6px;
|
|
color: #4971b6;
|
|
background: #edf4ff;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
}
|
|
|
|
.rubric-card {
|
|
display: grid;
|
|
gap: 10px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.rubric-item {
|
|
display: grid;
|
|
gap: 4px;
|
|
|
|
strong {
|
|
color: #26354c;
|
|
}
|
|
|
|
span {
|
|
color: #657184;
|
|
line-height: 1.5;
|
|
}
|
|
}
|
|
|
|
.question-list {
|
|
display: grid;
|
|
gap: 0;
|
|
}
|
|
|
|
.question-item {
|
|
display: grid;
|
|
grid-template-columns: 34px minmax(0, 1fr) 68px;
|
|
gap: 12px;
|
|
align-items: start;
|
|
width: 100%;
|
|
padding: 12px 0;
|
|
border: 0;
|
|
border-bottom: 1px solid #edf1f6;
|
|
background: transparent;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
|
|
strong,
|
|
span {
|
|
display: block;
|
|
}
|
|
|
|
strong {
|
|
margin-bottom: 5px;
|
|
color: #172033;
|
|
font-size: 14px;
|
|
}
|
|
|
|
span {
|
|
color: #6b778a;
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
|
|
.question-item.active {
|
|
margin: 0 -10px;
|
|
padding: 12px 10px;
|
|
border-radius: 8px;
|
|
background: #fff7f8;
|
|
}
|
|
|
|
.question-index {
|
|
display: grid;
|
|
width: 30px;
|
|
height: 30px;
|
|
place-items: center;
|
|
border-radius: 50%;
|
|
background: #eef4ff;
|
|
color: #356bb4;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.answer-box {
|
|
margin-top: 12px;
|
|
|
|
:deep(.el-textarea__inner) {
|
|
line-height: 1.7;
|
|
}
|
|
}
|
|
|
|
.answer-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 8px;
|
|
|
|
span {
|
|
color: #7a8597;
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
|
|
.empty-state {
|
|
display: grid;
|
|
place-items: center;
|
|
gap: 10px;
|
|
min-height: 320px;
|
|
padding: 24px;
|
|
border: 1px dashed #dbe3ef;
|
|
border-radius: 8px;
|
|
background: #fbfcff;
|
|
text-align: center;
|
|
|
|
strong {
|
|
color: #22314a;
|
|
font-size: 16px;
|
|
}
|
|
|
|
p {
|
|
max-width: 360px;
|
|
margin: 0;
|
|
color: #657184;
|
|
line-height: 1.6;
|
|
}
|
|
}
|
|
|
|
.score-empty {
|
|
min-height: 430px;
|
|
}
|
|
|
|
.status-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: fit-content;
|
|
min-height: 24px;
|
|
padding: 0 10px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
line-height: 24px;
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.pill-success {
|
|
color: #15844a;
|
|
background: #e5f8ee;
|
|
}
|
|
|
|
.pill-warning {
|
|
color: #c46b00;
|
|
background: #fff2dc;
|
|
}
|
|
|
|
.pill-info {
|
|
color: #4971b6;
|
|
background: #edf4ff;
|
|
}
|
|
|
|
.total-score {
|
|
display: grid;
|
|
place-items: center;
|
|
min-height: 150px;
|
|
margin-bottom: 12px;
|
|
border-radius: 8px;
|
|
background: linear-gradient(135deg, #fff1f2, #f8fbff);
|
|
text-align: center;
|
|
|
|
span,
|
|
small {
|
|
color: #657184;
|
|
}
|
|
|
|
strong {
|
|
color: #d70f20;
|
|
font-size: 52px;
|
|
line-height: 1;
|
|
}
|
|
}
|
|
|
|
.score-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.score-item {
|
|
padding: 12px;
|
|
border: 1px solid #e5eaf3;
|
|
border-radius: 8px;
|
|
|
|
span,
|
|
small {
|
|
display: block;
|
|
color: #647084;
|
|
font-size: 12px;
|
|
}
|
|
|
|
strong {
|
|
display: block;
|
|
margin: 4px 0;
|
|
color: #142033;
|
|
font-size: 24px;
|
|
}
|
|
}
|
|
|
|
.evidence-card {
|
|
margin-top: 12px;
|
|
background: #fff8ed;
|
|
|
|
p {
|
|
margin: 6px 0 0;
|
|
color: #46566e;
|
|
line-height: 1.6;
|
|
}
|
|
}
|
|
|
|
.record-panel {
|
|
:deep(.el-table__inner-wrapper::before) {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
.record-table {
|
|
border: 1px solid #edf0f5;
|
|
border-radius: 8px;
|
|
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;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.interview-page {
|
|
padding: 14px;
|
|
}
|
|
|
|
.page-head {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.flow-strip {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.question-item {
|
|
grid-template-columns: 30px minmax(0, 1fr);
|
|
}
|
|
|
|
.question-item .status-pill {
|
|
grid-column: 2;
|
|
}
|
|
|
|
.question-actions {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.score-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|