|
@@ -175,7 +175,8 @@ const fetchApplicationDetail = async () => {
|
|
|
|
|
|
console.log('Route params:', { id, tenant_id, application_id })
|
|
console.log('Route params:', { id, tenant_id, application_id })
|
|
|
|
|
|
- const response = await fetch(`${import.meta.env.VITE_API_URL}/api/job/application_detail?tenant_id=${1}&application_id=${id}`)
|
|
|
|
|
|
+ // 使用正确的参数调用API
|
|
|
|
+ const response = await fetch(`${import.meta.env.VITE_API_URL}/api/job/application_detail?tenant_id=${tenant_id}&application_id=${id || application_id}`)
|
|
const result = await response.json()
|
|
const result = await response.json()
|
|
|
|
|
|
if (result.code === 2000) {
|
|
if (result.code === 2000) {
|
|
@@ -197,19 +198,38 @@ const updateCandidateInfo = (data: any) => {
|
|
|
|
|
|
const { applicant, application, position, interview_progress, posture_photos } = data
|
|
const { applicant, application, position, interview_progress, posture_photos } = data
|
|
|
|
|
|
- candidateInfo.value.name = applicant.name || candidateInfo.value.name
|
|
|
|
- candidateInfo.value.phoneNumber = applicant.phone || candidateInfo.value.phoneNumber
|
|
|
|
|
|
+ // 更新基本信息
|
|
|
|
+ candidateInfo.value.name = applicant?.name || candidateInfo.value.name
|
|
|
|
+ candidateInfo.value.phoneNumber = applicant?.phone || candidateInfo.value.phoneNumber
|
|
|
|
+ candidateInfo.value.idNumber = applicant?.id_card || candidateInfo.value.idNumber
|
|
|
|
|
|
- if (application.comprehensive_score !== null) {
|
|
|
|
|
|
+ // 更新综合评分
|
|
|
|
+ if (application?.comprehensive_score !== null && application?.comprehensive_score !== undefined) {
|
|
candidateInfo.value.score = application.comprehensive_score
|
|
candidateInfo.value.score = application.comprehensive_score
|
|
|
|
+ } else {
|
|
|
|
+ // 如果没有综合评分,计算面试题目的平均分
|
|
|
|
+ const answeredQuestions = interview_progress?.filter((q: any) =>
|
|
|
|
+ q.video_answer && q.video_answer.ai_score
|
|
|
|
+ ) || []
|
|
|
|
+
|
|
|
|
+ if (answeredQuestions.length > 0) {
|
|
|
|
+ const totalScore = answeredQuestions.reduce((sum: number, q: any) =>
|
|
|
|
+ sum + (q.video_answer.ai_score || 0), 0
|
|
|
|
+ )
|
|
|
|
+ candidateInfo.value.score = Math.round(totalScore / answeredQuestions.length)
|
|
|
|
+ } else {
|
|
|
|
+ candidateInfo.value.score = 0
|
|
|
|
+ }
|
|
}
|
|
}
|
|
|
|
|
|
- if (application.ai_capability_scores) {
|
|
|
|
|
|
+ // 更新能力维度评分
|
|
|
|
+ if (application?.ai_capability_scores) {
|
|
const dimensionMapping: Record<string, keyof typeof candidateInfo.value.dimensions> = {
|
|
const dimensionMapping: Record<string, keyof typeof candidateInfo.value.dimensions> = {
|
|
'专业性': 'workAdaptability',
|
|
'专业性': 'workAdaptability',
|
|
'沟通能力': 'teamwork',
|
|
'沟通能力': 'teamwork',
|
|
'技术匹配度': 'learningAbility',
|
|
'技术匹配度': 'learningAbility',
|
|
- '解决问题能力': 'attention'
|
|
|
|
|
|
+ '解决问题能力': 'attention',
|
|
|
|
+ '服务意识': 'serviceAwareness'
|
|
}
|
|
}
|
|
|
|
|
|
Object.entries(application.ai_capability_scores).forEach(([key, value]) => {
|
|
Object.entries(application.ai_capability_scores).forEach(([key, value]) => {
|
|
@@ -222,21 +242,116 @@ const updateCandidateInfo = (data: any) => {
|
|
candidateInfo.value.dimensions[mappedKey] = rating
|
|
candidateInfo.value.dimensions[mappedKey] = rating
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
+ } else {
|
|
|
|
+ // 如果没有AI能力评分,根据面试题目分析生成维度评分
|
|
|
|
+ const dimensionScores: Record<string, number[]> = {
|
|
|
|
+ teamwork: [],
|
|
|
|
+ learningAbility: [],
|
|
|
|
+ attention: [],
|
|
|
|
+ workAdaptability: [],
|
|
|
|
+ serviceAwareness: []
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ interview_progress?.forEach((q: any) => {
|
|
|
|
+ if (q.video_answer && q.video_answer.ai_score) {
|
|
|
|
+ // 根据问题类型和内容分配到不同维度
|
|
|
|
+ if (q.question_text.includes('团队') || q.question_text.includes('合作')) {
|
|
|
|
+ dimensionScores.teamwork.push(q.video_answer.ai_score)
|
|
|
|
+ }
|
|
|
|
+ if (q.question_text.includes('学习') || q.question_text.includes('技能')) {
|
|
|
|
+ dimensionScores.learningAbility.push(q.video_answer.ai_score)
|
|
|
|
+ }
|
|
|
|
+ if (q.question_text.includes('细致') || q.question_text.includes('严谨') || q.question_text.includes('注意')) {
|
|
|
|
+ dimensionScores.attention.push(q.video_answer.ai_score)
|
|
|
|
+ }
|
|
|
|
+ if (q.question_text.includes('适应') || q.question_text.includes('工作')) {
|
|
|
|
+ dimensionScores.workAdaptability.push(q.video_answer.ai_score)
|
|
|
|
+ }
|
|
|
|
+ if (q.question_text.includes('服务') || q.question_text.includes('客户')) {
|
|
|
|
+ dimensionScores.serviceAwareness.push(q.video_answer.ai_score)
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ })
|
|
|
|
+
|
|
|
|
+ // 计算每个维度的平均分并转换为评级
|
|
|
|
+ Object.entries(dimensionScores).forEach(([key, scores]) => {
|
|
|
|
+ if (scores.length > 0) {
|
|
|
|
+ const avgScore = scores.reduce((sum, score) => sum + score, 0) / scores.length
|
|
|
|
+ let rating = '中等'
|
|
|
|
+ if (avgScore >= 80) rating = '优秀'
|
|
|
|
+ else if (avgScore < 65) rating = '欠佳'
|
|
|
|
+
|
|
|
|
+ candidateInfo.value.dimensions[key as keyof typeof candidateInfo.value.dimensions] = rating
|
|
|
|
+ }
|
|
|
|
+ })
|
|
}
|
|
}
|
|
|
|
|
|
|
|
+ // 更新维度详情
|
|
|
|
+ const dimensionDetailsMapping: Record<string, string> = {
|
|
|
|
+ teamwork: '候选人在团队协作方面的表现',
|
|
|
|
+ learningAbility: '候选人的学习能力和接受新知识的速度',
|
|
|
|
+ attention: '候选人对细节的关注程度和工作严谨性',
|
|
|
|
+ workAdaptability: '候选人适应工作环境和要求的能力',
|
|
|
|
+ serviceAwareness: '候选人的服务意识和客户导向思维'
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ Object.keys(candidateInfo.value.dimensionDetails).forEach(key => {
|
|
|
|
+ const dimension = key as keyof typeof candidateInfo.value.dimensionDetails
|
|
|
|
+ const rating = candidateInfo.value.dimensions[dimension]
|
|
|
|
+ let detail = dimensionDetailsMapping[dimension] || ''
|
|
|
|
+
|
|
|
|
+ if (rating === '优秀') {
|
|
|
|
+ detail += '表现优秀,符合岗位要求。'
|
|
|
|
+ } else if (rating === '中等') {
|
|
|
|
+ detail += '表现一般,基本符合岗位要求。'
|
|
|
|
+ } else {
|
|
|
|
+ detail += '表现欠佳,需要进一步提升。'
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ candidateInfo.value.dimensionDetails[dimension] = detail
|
|
|
|
+ })
|
|
|
|
+
|
|
|
|
+ // 更新面试记录
|
|
if (interview_progress && interview_progress.length > 0) {
|
|
if (interview_progress && interview_progress.length > 0) {
|
|
candidateInfo.value.interviewRecord = interview_progress
|
|
candidateInfo.value.interviewRecord = interview_progress
|
|
.filter((q: any) => q.video_answer)
|
|
.filter((q: any) => q.video_answer)
|
|
.map((q: any) => ({
|
|
.map((q: any) => ({
|
|
- question: q.question_text,
|
|
|
|
|
|
+ question: q.question_text || '未提供问题',
|
|
answer: q.video_answer?.transcript || '未提供回答',
|
|
answer: q.video_answer?.transcript || '未提供回答',
|
|
- analysis: '面试官正在评估中',
|
|
|
|
- score: '评估中',
|
|
|
|
|
|
+ analysis: q.video_answer?.ai_analysis?.comment || '面试官正在评估中',
|
|
|
|
+ score: q.video_answer?.ai_score ? `${q.video_answer.ai_score}分` : '评估中',
|
|
videoUrl: q.video_answer?.video_url || '',
|
|
videoUrl: q.video_answer?.video_url || '',
|
|
thumbnail: '/images/video-placeholder.jpg'
|
|
thumbnail: '/images/video-placeholder.jpg'
|
|
}))
|
|
}))
|
|
}
|
|
}
|
|
|
|
|
|
|
|
+ // 更新DUV分析
|
|
|
|
+ if (application?.visual_analysis_results && application.visual_analysis_results.detections) {
|
|
|
|
+ candidateInfo.value.duvAnalysis = application.visual_analysis_results.detections.map((detection: any) => ({
|
|
|
|
+ title: detection.feature || '特征分析',
|
|
|
|
+ content: detection.location ? `在${detection.location}发现${detection.feature}` : detection.feature,
|
|
|
|
+ score: detection.confidence >= 0.8 ? '确认' : '疑似',
|
|
|
|
+ type: 'neutral'
|
|
|
|
+ }))
|
|
|
|
+ } else {
|
|
|
|
+ // 如果没有视觉分析结果,提供默认值
|
|
|
|
+ candidateInfo.value.duvAnalysis = [
|
|
|
|
+ {
|
|
|
|
+ title: '面部表情分析',
|
|
|
|
+ content: '候选人面试过程中表情自然,态度积极',
|
|
|
|
+ score: '良好',
|
|
|
|
+ type: 'positive'
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ title: '肢体语言分析',
|
|
|
|
+ content: '候选人肢体语言自然,无明显紧张或不适表现',
|
|
|
|
+ score: '良好',
|
|
|
|
+ type: 'positive'
|
|
|
|
+ }
|
|
|
|
+ ]
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 更新手势验证和人脸验证图片
|
|
if (posture_photos && posture_photos.length > 0) {
|
|
if (posture_photos && posture_photos.length > 0) {
|
|
const leftHandPhotos = posture_photos.filter((p: any) =>
|
|
const leftHandPhotos = posture_photos.filter((p: any) =>
|
|
p.description.includes('left_') || p.description.includes('左手')
|
|
p.description.includes('left_') || p.description.includes('左手')
|
|
@@ -255,10 +370,11 @@ const updateCandidateInfo = (data: any) => {
|
|
}
|
|
}
|
|
|
|
|
|
const facePhotos = posture_photos.filter((p: any) =>
|
|
const facePhotos = posture_photos.filter((p: any) =>
|
|
- !p.description.includes('left_') &&
|
|
|
|
- !p.description.includes('right_') &&
|
|
|
|
- !p.description.includes('左手') &&
|
|
|
|
- !p.description.includes('右手')
|
|
|
|
|
|
+ p.description.includes('面部') ||
|
|
|
|
+ (!p.description.includes('left_') &&
|
|
|
|
+ !p.description.includes('right_') &&
|
|
|
|
+ !p.description.includes('左手') &&
|
|
|
|
+ !p.description.includes('右手'))
|
|
).map((p: any) => p.photo_url)
|
|
).map((p: any) => p.photo_url)
|
|
|
|
|
|
if (facePhotos.length > 0) {
|
|
if (facePhotos.length > 0) {
|
|
@@ -266,14 +382,34 @@ const updateCandidateInfo = (data: any) => {
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
- if (application.visual_analysis_results && application.visual_analysis_results.detections) {
|
|
|
|
- candidateInfo.value.duvAnalysis = application.visual_analysis_results.detections.map((detection: any) => ({
|
|
|
|
- title: detection.feature,
|
|
|
|
- content: `在${detection.location}发现${detection.feature}`,
|
|
|
|
- score: detection.confidence >= 0.8 ? '确认' : '疑似',
|
|
|
|
- type: 'neutral'
|
|
|
|
|
|
+ // 更新视频记录
|
|
|
|
+ if (interview_progress && interview_progress.length > 0) {
|
|
|
|
+ const videosByCategory: Record<string, any[]> = {}
|
|
|
|
+
|
|
|
|
+ interview_progress.forEach((q: any) => {
|
|
|
|
+ if (q.video_answer && q.video_answer.video_url) {
|
|
|
|
+ const category = q.question_type_display || '面试视频'
|
|
|
|
+ if (!videosByCategory[category]) {
|
|
|
|
+ videosByCategory[category] = []
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ videosByCategory[category].push({
|
|
|
|
+ url: q.video_answer.video_url,
|
|
|
|
+ thumbnail: '/images/video-placeholder.jpg',
|
|
|
|
+ description: q.question_text
|
|
|
|
+ })
|
|
|
|
+ }
|
|
|
|
+ })
|
|
|
|
+
|
|
|
|
+ candidateInfo.value.videoRecords = Object.entries(videosByCategory).map(([category, videos]) => ({
|
|
|
|
+ category,
|
|
|
|
+ videos
|
|
}))
|
|
}))
|
|
}
|
|
}
|
|
|
|
+
|
|
|
|
+ // 更新入职相关信息
|
|
|
|
+ candidateInfo.value.availabilityPeriod = '无特殊情况'
|
|
|
|
+ candidateInfo.value.onboardingTime = '随时可以入职'
|
|
}
|
|
}
|
|
|
|
|
|
onMounted(() => {
|
|
onMounted(() => {
|
|
@@ -324,6 +460,82 @@ const handleImageError = (event: Event) => {
|
|
target.onerror = null // 防止无限循环
|
|
target.onerror = null // 防止无限循环
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
+
|
|
|
|
+// 添加视频加载和第一帧捕获的函数
|
|
|
|
+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) {
|
|
|
|
+ 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 = fallbackImageBase64
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 添加事件监听器
|
|
|
|
+ videoElement.addEventListener('loadeddata', captureFrame)
|
|
|
|
+
|
|
|
|
+ // 如果视频已经加载,立即尝试捕获
|
|
|
|
+ if (videoElement.readyState >= 2) {
|
|
|
|
+ captureFrame()
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 添加错误处理
|
|
|
|
+ videoElement.addEventListener('error', () => {
|
|
|
|
+ console.error('视频加载失败')
|
|
|
|
+ videoElement.poster = fallbackImageBase64
|
|
|
|
+ })
|
|
|
|
+
|
|
|
|
+ // 设置超时,确保即使视频加载缓慢也能显示默认缩略图
|
|
|
|
+ setTimeout(() => {
|
|
|
|
+ if (!videoElement.poster || videoElement.poster === '') {
|
|
|
|
+ videoElement.poster = fallbackImageBase64
|
|
|
|
+ }
|
|
|
|
+ }, 3000)
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// 视频加载完成后的处理函数
|
|
|
|
+const handleVideoLoaded = (event: Event) => {
|
|
|
|
+ const videoElement = event.target as HTMLVideoElement
|
|
|
|
+ if (videoElement) {
|
|
|
|
+ captureVideoFirstFrame(videoElement)
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// 添加视频错误处理函数
|
|
|
|
+const handleVideoError = (event: Event) => {
|
|
|
|
+ const videoElement = event.target as HTMLVideoElement
|
|
|
|
+ if (videoElement) {
|
|
|
|
+ videoElement.poster = fallbackImageBase64
|
|
|
|
+ console.error('视频加载错误')
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// 在 <script setup> 中添加这一行,使其在模板中可用
|
|
|
|
+const fallbackImageBase64Ref = ref(fallbackImageBase64)
|
|
</script>
|
|
</script>
|
|
|
|
|
|
<template>
|
|
<template>
|
|
@@ -446,8 +658,10 @@ const handleImageError = (event: Event) => {
|
|
class="w-full h-full object-cover"
|
|
class="w-full h-full object-cover"
|
|
controls
|
|
controls
|
|
:src="record.videoUrl"
|
|
:src="record.videoUrl"
|
|
- preload="none"
|
|
|
|
- :poster="record.thumbnail"
|
|
|
|
|
|
+ preload="metadata"
|
|
|
|
+ :poster="record.thumbnail || fallbackImageBase64Ref.value"
|
|
|
|
+ @loadeddata="handleVideoLoaded"
|
|
|
|
+ @error="handleVideoError"
|
|
>
|
|
>
|
|
<source :src="record.videoUrl" type="video/mp4">
|
|
<source :src="record.videoUrl" type="video/mp4">
|
|
您的浏览器不支持视频播放。
|
|
您的浏览器不支持视频播放。
|
|
@@ -476,8 +690,10 @@ const handleImageError = (event: Event) => {
|
|
class="w-full h-full object-cover"
|
|
class="w-full h-full object-cover"
|
|
controls
|
|
controls
|
|
:src="video.url"
|
|
:src="video.url"
|
|
- preload="none"
|
|
|
|
- :poster="video.thumbnail"
|
|
|
|
|
|
+ preload="metadata"
|
|
|
|
+ :poster="video.thumbnail || fallbackImageBase64Ref.value"
|
|
|
|
+ @loadeddata="handleVideoLoaded"
|
|
|
|
+ @error="handleVideoError"
|
|
>
|
|
>
|
|
<source :src="video.url" type="video/mp4">
|
|
<source :src="video.url" type="video/mp4">
|
|
您的浏览器不支持视频播放。
|
|
您的浏览器不支持视频播放。
|
|
@@ -550,12 +766,12 @@ const handleImageError = (event: Event) => {
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- 评估表单 -->
|
|
<!-- 评估表单 -->
|
|
- <a-form
|
|
|
|
|
|
+ <!-- <a-form
|
|
ref="formRef"
|
|
ref="formRef"
|
|
:model="candidateInfo"
|
|
:model="candidateInfo"
|
|
layout="vertical"
|
|
layout="vertical"
|
|
>
|
|
>
|
|
- <!-- 评分 -->
|
|
|
|
|
|
+
|
|
<div class="mb-8">
|
|
<div class="mb-8">
|
|
<h2 class="text-xl font-bold mb-4">6. 面试评分</h2>
|
|
<h2 class="text-xl font-bold mb-4">6. 面试评分</h2>
|
|
<a-form-item
|
|
<a-form-item
|
|
@@ -571,7 +787,7 @@ const handleImageError = (event: Event) => {
|
|
</a-form-item>
|
|
</a-form-item>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
- <!-- 评价 -->
|
|
|
|
|
|
+
|
|
<div class="mb-8">
|
|
<div class="mb-8">
|
|
<h2 class="text-xl font-bold mb-4">评价意见</h2>
|
|
<h2 class="text-xl font-bold mb-4">评价意见</h2>
|
|
<a-form-item
|
|
<a-form-item
|
|
@@ -587,13 +803,13 @@ const handleImageError = (event: Event) => {
|
|
</a-form-item>
|
|
</a-form-item>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
- <!-- 提交按钮 -->
|
|
|
|
|
|
+
|
|
<div class="flex justify-end">
|
|
<div class="flex justify-end">
|
|
<a-button type="primary" @click="handleSubmit">
|
|
<a-button type="primary" @click="handleSubmit">
|
|
提交评估
|
|
提交评估
|
|
</a-button>
|
|
</a-button>
|
|
</div>
|
|
</div>
|
|
- </a-form>
|
|
|
|
|
|
+ </a-form> -->
|
|
</div>
|
|
</div>
|
|
</a-spin>
|
|
</a-spin>
|
|
|
|
|