123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688 |
- <script setup lang="ts">
- // 导入必要的组件和类型
- import { ref, onMounted } from 'vue'
- import { useRoute } from 'vue-router'
- import { Session } from '/@/utils/storage';
- // 定义面试记录的接口
- interface InterviewRecord {
- question: string
- answer: string
- analysis: string
- score: string
- videoUrl?: string
- thumbnail?: string
- question_form?: number
- is_correct?: boolean | string
- question_image_url?: string
- parent_question_id?: number
- follow_up_questions?: InterviewRecord[]
- question_id?: number
- options?: {
- id: number
- option_text: string
- is_correct: boolean
- sort: number
- }[]
- answer_data?: {
- text_answer: string
- selected_options: number[]
- selected_option_details: {
- id: number
- is_correct: boolean
- option_text: string
- }[]
- }
- }
- // 定义响应式数据
- const route = useRoute()
- const interviewRecords = ref<InterviewRecord[]>([])
- const loading = ref(true)
- const error = ref('')
- // 添加base64编码的内联图片作为fallback
- const fallbackImageBase64Ref = ref('data:image/svg+xml;base64,PHN2ZyB...')
- // 图片错误处理函数
- const handleImageError = (event: Event) => {
- const target = event.target as HTMLImageElement
- if (target) {
- target.src = fallbackImageBase64Ref.value
- target.onerror = null
- }
- }
- // 视频相关处理函数
- const handleVideoLoaded = (event: Event) => {
- const videoElement = event.target as HTMLVideoElement
- if (videoElement) {
- if (videoElement.readyState >= 1) {
- captureVideoFirstFrame(videoElement)
- } else {
- videoElement.addEventListener('loadedmetadata', () => {
- captureVideoFirstFrame(videoElement)
- })
- }
- }
- }
- const handleVideoError = (event: Event) => {
- const videoElement = event.target as HTMLVideoElement
- if (videoElement) {
- videoElement.poster = fallbackImageBase64Ref.value
- console.error('视频加载错误')
- }
- }
- // 获取面试记录数据
- const fetchInterviewRecords = async () => {
- loading.value = true
- try {
- const id = route.query.id as string || '1'
- const tenant_id = route.query.tenant_id as string || '1'
- const application_id = route.query.applicationId as string || '1'
-
- const response = await fetch(`${import.meta.env.VITE_API_URL}/api/job/application_detail?tenant_id=${Session.get('tenant_id')}&application_id=${application_id}`)
- const result = await response.json()
-
- if (result.code === 2000 && result.data?.interview_progress) {
- // 处理面试记录
- const processedRecords = result.data.interview_progress
- /* .filter((q: any) => q.video_answer || q.answer_data) */
- .map((q: any) => {
- const record: InterviewRecord = {
- question: q.question_text || '未提供问题',
- answer: '',
- analysis: '',
- score: '',
- question_form: q.question_form,
- parent_question_id: q.parent_question_id,
- options: q.options,
- answer_data: q.answer_data,
- follow_up_questions: [],
- question_id: q.question_id
- }
- if (q.video_answer) {
- record.answer = q.video_answer.transcript || ''
- record.analysis = q.video_answer.ai_analysis?.comment || ''
- record.score = q.video_answer.ai_score ? `${q.video_answer.ai_score}分` : ''
- record.videoUrl = q.video_answer.video_url || ''
- }
- if (q.answer_data) {
- record.answer = q.answer_data.text_answer || ''
- record.is_correct = q.answer_data.selected_option_details?.some((opt: any) => opt.is_correct) || false
- }
- if (q.question_form === 3) {
- record.question_image_url = q.question_image_url || ''
- }
- return record
- })
- // 将记录分为主题和追问两组
- const mainQuestions: InterviewRecord[] = []
- const followUpQuestions: InterviewRecord[] = []
-
- processedRecords.forEach((record: InterviewRecord) => {
- if (record.question_form === 5 && record.parent_question_id) {
- followUpQuestions.push(record)
- } else {
- mainQuestions.push(record)
- }
- })
- // 对主题进行排序
- const sortedMainQuestions = mainQuestions.sort((a: InterviewRecord, b: InterviewRecord) => {
- const getPriority = (questionForm: number) => {
- switch (questionForm) {
- case 0: return 1; // 视频问答题最优先
- case 5: return 2; // 开放问答题次优先
- case 1: return 3; // 单选题
- case 2: return 4; // 多选题
- case 3: return 5; // 图片选择题
- case 4: return 6; // 问卷题
- default: return 99;
- }
- }
- return getPriority(a.question_form || -1) - getPriority(b.question_form || -1)
- })
- // 将追问题数组倒序
- const reversedFollowUps = [...followUpQuestions].reverse()
- // 将倒序后的追问题添加到对应的父问题中
- reversedFollowUps.forEach(followUp => {
- const parentQuestion = sortedMainQuestions.find(
- q => q.question_form === 0 && q.question_id === followUp.parent_question_id
- )
- if (parentQuestion) {
- // 如果follow_up_questions还没有初始化,先初始化为空数组
- if (!parentQuestion.follow_up_questions) {
- parentQuestion.follow_up_questions = []
- }
- // 使用unshift而不是push,这样新的追问题会被添加到数组开头
- parentQuestion.follow_up_questions.unshift(followUp)
- }
- })
- // 设置最终结果
- interviewRecords.value = sortedMainQuestions
- console.log(interviewRecords.value)
- } else {
- error.value = result.msg || '获取数据失败'
- }
- } catch (err) {
- console.error('获取面试记录失败:', err)
- error.value = '获取数据失败,请稍后重试'
- } finally {
- loading.value = false
- }
- }
- // 在script setup部分添加以下函数
- const captureVideoFirstFrame = (videoElement: HTMLVideoElement) => {
- // 创建一个一次性的事件监听器,在视频可以播放时捕获第一帧
- const captureFrame = () => {
- try {
- // 确保视频已加载足够的数据
- if (videoElement.readyState >= 2) {
- // 创建canvas元素
- const canvas = document.createElement('canvas')
- canvas.width = videoElement.videoWidth || 320
- canvas.height = videoElement.videoHeight || 240
-
- // 在canvas上绘制视频当前帧
- const ctx = canvas.getContext('2d')
- if (ctx) {
- // 确保视频当前时间设置为开始位置
- videoElement.currentTime = 0.1
-
- // 绘制视频帧到canvas
- ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height)
-
- // 将canvas转换为图片URL
- const thumbnailUrl = canvas.toDataURL('image/jpeg')
-
- // 设置为视频的poster属性
- videoElement.poster = thumbnailUrl
-
- // 清理
- videoElement.removeEventListener('loadeddata', captureFrame)
- }
- }
- } catch (error) {
- console.error('捕获视频第一帧失败:', error)
- // 设置默认缩略图
- videoElement.poster = fallbackImageBase64Ref.value
- }
- }
-
- // 添加事件监听器
- videoElement.addEventListener('loadeddata', captureFrame)
-
- // 如果视频已经加载,立即尝试捕获
- if (videoElement.readyState >= 2) {
- captureFrame()
- }
-
- // 添加错误处理
- videoElement.addEventListener('error', () => {
- console.error('视频加载失败')
- videoElement.poster = fallbackImageBase64Ref.value
- })
-
- // 设置超时,确保即使视频加载缓慢也能显示默认缩略图
- setTimeout(() => {
- if (!videoElement.poster || videoElement.poster === '') {
- videoElement.poster = fallbackImageBase64Ref.value
- }
- }, 3000)
- }
- // 在script setup中添加题型名称映射函数
- const getQuestionTypeName = (questionForm: number): string => {
- const typeMap: Record<number, string> = {
- 0: '视频问答题(AI面试官提问)',
- 1: '单选题(知识检测)',
- 2: '多选题(综合选择)',
- 3: '图片选择题(场景判断)',
- 4: '问卷题(个性测评)',
- 5: '开放问答题(追问互动)',
- 6: '填空题'
- }
- return typeMap[questionForm] || '未知题型'
- }
- onMounted(() => {
- fetchInterviewRecords()
- })
- </script>
- <template>
- <div class="page-container">
- <div class="content-container">
- <div class="max-w-4xl mx-auto p-6">
- <!-- 加载状态 -->
- <a-spin :spinning="loading" tip="加载中...">
- <!-- 错误提示 -->
- <div v-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
- <strong class="font-bold">错误!</strong>
- <span class="block sm:inline">{{ error }}</span>
- </div>
- <!-- 暂无数据提示 -->
- <div v-else-if="!loading && (!interviewRecords || interviewRecords.length === 0)"
- class="flex flex-col items-center justify-center py-12 bg-gray-50 rounded-lg">
- <svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
- d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
- </path>
- </svg>
- <h3 class="mt-4 text-lg font-medium text-gray-900">暂无面试记录</h3>
- <p class="mt-1 text-sm text-gray-500">当前没有任何面试记录数据</p>
- </div>
- <!-- 面试记录列表 -->
- <div v-else-if="interviewRecords.length > 0" class="space-y-4">
- <div v-for="(record, index) in interviewRecords" :key="index" class="bg-gray-50 rounded-lg p-4">
- <!-- 题目标题部分 -->
- <div class="mb-4">
- <div class="flex items-start space-x-2">
- <div class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm">第{{ index + 1 }}题</div>
- <div class="bg-gray-100 text-gray-800 px-2 py-1 rounded text-sm">{{ getQuestionTypeName(record.question_form || 0) }}</div>
- <div class="flex-1">
- <h3 class="font-semibold text-gray-800">{{ record.question }}</h3>
- </div>
- </div>
- <!-- 选择题布局 -->
- <div v-if="record.question_form === 1 || record.question_form === 2 || record.question_form === 4" class="mt-4">
- <div class="bg-white p-4 rounded-lg">
- <!-- 显示选项 -->
- <div v-if="record.options && record.options.length > 0" class="space-y-3">
- <div v-for="(option, optIndex) in record.options" :key="optIndex"
- :class="[
- 'p-3 rounded border',
- record.answer_data?.selected_option_details?.some(
- selected => selected.id === option.id
- )
- ? 'border-blue-300 bg-blue-50'
- : 'border-gray-200'
- ]">
- <div class="flex items-center justify-between">
- <div class="flex-1">
- <span class="text-gray-700">{{ option.option_text }}</span>
- </div>
- <div class="flex items-center space-x-2">
- <template v-if="record.question_form !== 4">
- <span v-if="option.is_correct" class="text-green-500">✓ 正确答案</span>
- <span v-if="record.answer_data?.selected_option_details?.some(
- selected => selected.id === option.id
- )"
- :class="option.is_correct ? 'text-green-500' : 'text-red-500'">
- {{ option.is_correct ? '(已选 - 正确)' : '(已选 - 错误)' }}
- </span>
- </template>
- <template v-else>
- <span v-if="record.answer_data?.selected_option_details?.some(
- selected => selected.id === option.id
- )"
- class="text-blue-500">
- (已选)
- </span>
- </template>
- </div>
- </div>
- </div>
- </div>
-
- <!-- 显示答案状态 -->
- <div class="mt-4 flex justify-between items-center">
- <div class="text-gray-600">
- 候选人: {{ record.answer_data?.selected_option_details?.map(
- opt => opt.option_text
- ).join(', ') || '未作答' }}
- </div>
- <div v-if="record.question_form !== 4" class="w-1/2 text-right">
- <span :class="[
- 'px-3 py-1 rounded-full text-sm',
- record.answer_data?.selected_option_details?.every(
- opt => opt.is_correct
- )
- ? 'bg-green-100 text-green-800'
- : 'bg-red-100 text-red-800'
- ]">
- {{ record.answer_data?.selected_option_details?.every(
- opt => opt.is_correct
- ) ? '正确' : '错误' }}
- </span>
- </div>
- </div>
- </div>
- </div>
- <!-- 视频问答题布局 -->
- <div v-else-if="record.question_form === 0 || record.question_form === 5" class="mt-4">
- <!-- 原有的视频问答题布局 -->
- <div class="flex space-x-4">
- <!-- 右侧答案和评分 -->
- <div class="w-2/3">
- <div class="bg-white p-4 rounded-lg">
- <div class="mb-4">
- <div class="flex items-start space-x-2">
- <div class="bg-green-100 text-green-800 px-2 py-1 rounded text-sm">候选人</div>
- <div class="flex-1">
- <p class="text-gray-700">{{ record.answer }}</p>
- </div>
- </div>
- </div>
-
- <div>
- <div class="flex items-start space-x-2">
- <div class="bg-yellow-100 text-yellow-800 px-2 py-1 rounded text-sm">AI评分</div>
- <div class="flex-1">
- <p class="text-gray-600 m-0">{{ record.analysis }}</p>
- <div class="mt-1 text-blue-500">得分:{{ record.score }}</div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 左侧视频播放器 -->
- <div class="w-1/3">
- <div class="video-container">
- <video
- class="w-full h-full object-cover rounded-lg"
- controls
- :src="record.videoUrl"
- preload="metadata"
- :poster="fallbackImageBase64Ref"
- @loadeddata="handleVideoLoaded"
- @error="handleVideoError"
- >
- 您的浏览器不支持视频播放。
- </video>
- </div>
- </div>
- </div>
-
- <!-- 追问题列表 -->
- <div v-if="record.follow_up_questions && record.follow_up_questions.length > 0"
- class="mt-4 pl-8 border-l-2 border-gray-200">
- <div v-for="(followUp, fIndex) in record.follow_up_questions"
- :key="fIndex"
- class="mb-4">
- <div class="flex items-start space-x-2">
- <div class="bg-purple-100 text-purple-800 px-2 py-1 rounded text-sm">追问题</div>
- <div class="flex-1">
- <h4 class="font-medium text-gray-800">{{ followUp.question }}</h4>
- <!-- 追问题的视频和答案布局 -->
- <div class="mt-2">
- <div class="flex space-x-4">
- <!-- 右侧答案和评分 -->
- <div class="w-2/3">
- <div class="bg-white p-4 rounded-lg">
- <div class="mb-4">
- <div class="flex items-start space-x-2">
- <div class="bg-green-100 text-green-800 px-2 py-1 rounded text-sm">候选人</div>
- <div class="flex-1">
- <p class="text-gray-700">{{ followUp.answer }}</p>
- </div>
- </div>
- </div>
-
- <div>
- <div class="flex items-start space-x-2">
- <div class="bg-yellow-100 text-yellow-800 px-2 py-1 rounded text-sm">AI评分</div>
- <div class="flex-1">
- <p class="text-gray-600 m-0">{{ followUp.analysis }}</p>
- <div class="mt-1 text-blue-500">得分:{{ followUp.score }}</div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 左侧视频播放器 -->
- <div v-if="followUp.videoUrl" class="w-1/3">
- <div class="video-container">
- <video
- class="w-full h-full object-cover rounded-lg"
- controls
- :src="followUp.videoUrl"
- preload="metadata"
- :poster="fallbackImageBase64Ref"
- @loadeddata="handleVideoLoaded"
- @error="handleVideoError"
- >
- 您的浏览器不支持视频播放。
- </video>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 图片题布局 -->
- <div v-else-if="record.question_form === 3" class="mt-4">
- <div class="grid grid-cols-2 gap-4">
- <!-- 左侧图片显示 -->
- <div class="bg-white p-4 rounded-lg">
- <div class="aspect-w-16 aspect-h-9 rounded-lg overflow-hidden bg-gray-100">
- <img
- :src="record.question_image_url"
- alt="题目图片"
- class="w-full h-full object-contain"
- @error="handleImageError"
- >
- </div>
- </div>
-
- <!-- 右侧选项和答案 -->
- <div class="bg-white p-4 rounded-lg">
- <!-- 显示选项 -->
- <div v-if="record.options && record.options.length > 0" class="space-y-3">
- <div v-for="(option, optIndex) in record.options" :key="optIndex"
- :class="[
- 'p-3 rounded border',
- record.answer_data?.selected_option_details?.some(
- selected => selected.id === option.id
- )
- ? 'border-blue-300 bg-blue-50'
- : 'border-gray-200'
- ]">
- <div class="flex items-center justify-between">
- <div class="flex-1">
- <span class="text-gray-700">{{ option.option_text }}</span>
- </div>
- <div class="flex items-center space-x-2">
- <span v-if="option.is_correct" class="text-green-500">✓ 正确答案</span>
- <span v-if="record.answer_data?.selected_option_details?.some(
- selected => selected.id === option.id
- )"
- :class="option.is_correct ? 'text-green-500' : 'text-red-500'">
- {{ option.is_correct ? '(已选 - 正确)' : '(已选 - 错误)' }}
- </span>
- </div>
- </div>
- </div>
- </div>
-
- <!-- 显示答案状态 -->
- <div class="mt-4 flex justify-between items-center">
- <div class="text-gray-600">
- 答案: {{ record.answer_data?.selected_option_details?.map(
- opt => opt.option_text
- ).join(', ') || '未作答' }}
- </div>
- <div>
- <span :class="[
- 'px-3 py-1 rounded-full text-sm',
- record.answer_data?.selected_option_details?.every(
- opt => opt.is_correct
- )
- ? 'bg-green-100 text-green-800'
- : 'bg-red-100 text-red-800'
- ]">
- {{ record.answer_data?.selected_option_details?.every(
- opt => opt.is_correct
- ) ? '正确' : '错误' }}
- </span>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 填空题 -->
- <div v-if="record.question_form === 6" class="mt-4">
- <div class="bg-white p-4 rounded-lg">
- <!-- 显示选项 -->
- <!-- <div v-if="record.options && record.options.length > 0" class="space-y-3">
- <div v-for="(option, optIndex) in record.options" :key="optIndex"
- :class="[
- 'p-3 rounded border',
- record.answer_data?.selected_option_details?.some(
- selected => selected.id === option.id
- )
- ? 'border-blue-300 bg-blue-50'
- : 'border-gray-200'
- ]">
- <div class="flex items-center justify-between">
- <div class="flex-1">
- <span class="text-gray-700">{{ option.option_text }}</span>
- </div>
- <div class="flex items-center space-x-2">
- <template v-if="record.question_form !== 4">
- <span v-if="option.is_correct" class="text-green-500">✓ 正确答案</span>
- <span v-if="record.answer_data?.selected_option_details?.some(
- selected => selected.id === option.id
- )"
- :class="option.is_correct ? 'text-green-500' : 'text-red-500'">
- {{ option.is_correct ? '(已选 - 正确)' : '(已选 - 错误)' }}
- </span>
- </template>
- <template v-else>
- <span v-if="record.answer_data?.selected_option_details?.some(
- selected => selected.id === option.id
- )"
- class="text-blue-500">
- (已选)
- </span>
- </template>
- </div>
- </div>
- </div>
- </div> -->
-
- <!-- 显示答案状态 -->
- <div class="mt-4 flex justify-between items-center">
- <div class="text-gray-600" >
- 候选人作答: {{ record.answer_data?.blank_answers?.map(
- opt => opt.answer_text
- ).join(', ') || '未作答' }}
- </div>
- <div v-if="record.question_form !== 4">
- <span :class="[
- 'px-3 py-1 rounded-full text-sm',
- record.answer_data?.blank_answers?.every(
- opt => opt.is_correct
- )
- ? 'bg-green-100 text-green-800'
- : 'bg-red-100 text-red-800'
- ]">
- {{ record.answer_data?.blank_answers?.every(
- opt => opt.is_correct
- ) ? '正确' : '错误' }}
- </span>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </a-spin>
- </div>
- </div>
- </div>
- </template>
- <style scoped>
- .video-container {
- position: relative;
- width: 100%;
- max-width: 240px;
- aspect-ratio: 3/4;
- margin: 0 auto;
- background: #000;
- border-radius: 12px;
- overflow: hidden;
- }
- /* 确保内容区域可以正常滚动 */
- .max-w-4xl {
- position: relative;
- height: auto;
- padding-bottom: 2rem;
- }
- /* 打印样式 */
- @media print {
- /* 确保内容完整显示 */
- .max-w-4xl {
- max-width: 100% !important;
- padding: 0 !important;
- margin: 0 !important;
- }
-
- /* 调整页面边距 */
- @page {
- margin: 1cm;
- }
-
- /* 确保背景色和图片打印 */
- * {
- -webkit-print-color-adjust: exact !important;
- color-adjust: exact !important;
- print-color-adjust: exact !important;
- }
-
- /* 避免视频容器在打印时出现问题 */
- .video-container {
- page-break-inside: avoid;
- break-inside: avoid;
- }
- }
- /* 响应式布局 */
- @media (max-width: 768px) {
- .max-w-4xl {
- padding: 1rem;
- }
-
- .grid-cols-3 {
- grid-template-columns: 1fr;
- }
- }
- /* 添加新的样式 */
- .page-container {
- height: 100vh;
- overflow-y: auto;
- background-color: white;
- }
- .content-container {
- max-width: 64rem; /* 4xl = 64rem */
- margin: 0 auto;
- padding: 1.5rem;
- }
- </style>
|