Files
prop-ai-hr/frontend/src/views/recruit/interview.vue
T
admin 0539adf612 feat(aihr): persist interview results and candidate material review
面试记录落 aihr_interview_result(GET /records 查询),候选人补充资料
走 OSS + aihr_candidate_material,管理端 AI 面试页可审核通过/驳回;
案例整理服务增强;两张新表纳入 reset 脚本。
2026-07-04 16:05:50 +08:00

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>