index.vue 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116
  1. <script setup lang="ts">
  2. import { ref, onMounted } from 'vue'
  3. import { message } from 'ant-design-vue'
  4. import type { FormInstance } from 'ant-design-vue'
  5. import { useRoute } from 'vue-router'
  6. import { ElMessage } from 'element-plus'
  7. import { Share, Download, Printer, ArrowUp } from '@element-plus/icons-vue'
  8. interface CandidateInfo {
  9. name: string
  10. idNumber: string
  11. phoneNumber: string
  12. score: number
  13. availabilityPeriod: string
  14. onboardingTime: string
  15. suggestedSalary: string
  16. salaryRange:string
  17. dimensions: {
  18. teamwork: string
  19. learningAbility: string
  20. attention: string
  21. workAdaptability: string
  22. serviceAwareness: string
  23. }
  24. dimensionDetails: {
  25. teamwork: string
  26. learningAbility: string
  27. attention: string
  28. workAdaptability: string
  29. serviceAwareness: string
  30. }
  31. duvAnalysis: {
  32. title: string
  33. content: string
  34. score: string
  35. type: 'positive' | 'negative' | 'neutral'
  36. }[]
  37. interviewRecord: {
  38. question: string
  39. answer: string
  40. analysis: string
  41. score: string
  42. videoUrl?: string
  43. thumbnail?: string
  44. question_form?: number
  45. is_correct?: boolean | string
  46. question_image_url?: string
  47. }[]
  48. videoRecords: {
  49. category: string
  50. videos: {
  51. url: string
  52. thumbnail: string
  53. description: string
  54. }[]
  55. }[]
  56. additionalInfo: {
  57. idVerification: string
  58. phoneVerification: string
  59. leftHandGestures: {
  60. passed: boolean
  61. images: string[]
  62. }
  63. rightHandGestures: {
  64. passed: boolean
  65. images: string[]
  66. }
  67. faceVerification: {
  68. passed: boolean
  69. images: string[]
  70. }
  71. }
  72. strengths?: string[]
  73. weaknesses?: string[]
  74. hireRecommendation?: string
  75. hireReason?: string
  76. }
  77. const candidateInfo = ref<CandidateInfo>({
  78. name: '',
  79. idNumber: '',
  80. phoneNumber: '',
  81. score: 0,
  82. availabilityPeriod: '',
  83. onboardingTime: '',
  84. suggestedSalary: '',
  85. salaryRange: '',
  86. dimensions: {
  87. teamwork: '欠佳',
  88. learningAbility: '优秀',
  89. attention: '欠佳',
  90. workAdaptability: '优秀',
  91. serviceAwareness: '中等'
  92. },
  93. dimensionDetails: {
  94. teamwork: '',
  95. learningAbility: '',
  96. attention: '',
  97. workAdaptability: '',
  98. serviceAwareness: ''
  99. },
  100. duvAnalysis: [
  101. {
  102. title: '',
  103. content: '',
  104. score: '',
  105. type: 'neutral'
  106. },
  107. {
  108. title: '',
  109. content: '',
  110. score: '',
  111. type: 'neutral'
  112. }
  113. ],
  114. interviewRecord: [
  115. {
  116. question: '',
  117. answer: '',
  118. analysis: '',
  119. score: '',
  120. videoUrl: '',
  121. thumbnail: '',
  122. question_form: 0,
  123. is_correct: false,
  124. question_image_url: ''
  125. },
  126. {
  127. question: '',
  128. answer: '',
  129. analysis: '',
  130. score: '',
  131. videoUrl: '',
  132. thumbnail: '',
  133. question_form: 0,
  134. is_correct: false,
  135. question_image_url: ''
  136. }
  137. ],
  138. videoRecords: [
  139. {
  140. category: '服务态度测评',
  141. videos: [
  142. {
  143. url: '',
  144. thumbnail: '',
  145. description: ''
  146. },
  147. {
  148. url: '',
  149. thumbnail: '',
  150. description: ''
  151. }
  152. ]
  153. }
  154. ],
  155. additionalInfo: {
  156. idVerification: '通过',
  157. phoneVerification: '通过',
  158. leftHandGestures: {
  159. passed: true,
  160. images: [
  161. ]
  162. },
  163. rightHandGestures: {
  164. passed: true,
  165. images: [
  166. ]
  167. },
  168. faceVerification: {
  169. passed: true,
  170. images: [
  171. ]
  172. }
  173. },
  174. strengths: [],
  175. weaknesses: [],
  176. hireRecommendation: '',
  177. hireReason: ''
  178. })
  179. const apiData = ref<any>(null)
  180. const loading = ref<boolean>(true)
  181. const error = ref<string>('')
  182. const route = useRoute()
  183. const fetchApplicationDetail = async () => {
  184. loading.value = true
  185. try {
  186. // 从Vue Router获取参数
  187. const id = route.query.id as string || '1'
  188. const tenant_id = route.query.tenant_id as string || '1'
  189. const application_id = route.query.application_id as string || '1'
  190. console.log('Route params:', { id, tenant_id, application_id })
  191. // 使用正确的参数调用API
  192. const response = await fetch(`${import.meta.env.VITE_API_URL}/api/job/application_detail?tenant_id=${tenant_id}&application_id=${id || application_id}`)
  193. const result = await response.json()
  194. if (result.code === 2000) {
  195. apiData.value = result.data
  196. updateCandidateInfo(result.data)
  197. } else {
  198. error.value = result.msg || '获取数据失败'
  199. }
  200. } catch (err) {
  201. console.error('获取申请详情失败:', err)
  202. error.value = '获取数据失败,请稍后重试'
  203. } finally {
  204. loading.value = false
  205. }
  206. }
  207. const updateCandidateInfo = (data: any) => {
  208. if (!data) return
  209. const { applicant, application, position, interview_progress, posture_photos } = data
  210. // 更新基本信息
  211. candidateInfo.value.name = applicant?.name || candidateInfo.value.name
  212. candidateInfo.value.phoneNumber = applicant?.phone || candidateInfo.value.phoneNumber
  213. candidateInfo.value.idNumber = applicant?.id_card || candidateInfo.value.idNumber
  214. // 添加薪资相关信息处理
  215. if (position) {
  216. // 如果API返回了薪资范围,使用API数据
  217. if (position.salary_range) {
  218. candidateInfo.value.salaryRange = position.salary_range || '面议'
  219. /* candidateInfo.value.salaryRange.max = position.salary_range || '面议' */
  220. } else {
  221. // 否则使用默认值
  222. candidateInfo.value.salaryRange = '面议'
  223. }
  224. // 根据候选人评分计算建议薪资
  225. if (candidateInfo.value.score >= 80) {
  226. // 高分候选人,建议给予较高薪资
  227. candidateInfo.value.suggestedSalary = position.salary_range?.max || '面议'
  228. } else if (candidateInfo.value.score >= 70) {
  229. // 中高分候选人,建议给予中等偏上薪资
  230. const min = parseInt(position.salary_range?.min || '0')
  231. const max = parseInt(position.salary_range?.max || '0')
  232. if (min > 0 && max > 0) {
  233. const suggested = Math.floor(min + (max - min) * 0.7)
  234. candidateInfo.value.suggestedSalary = suggested.toString()
  235. } else {
  236. candidateInfo.value.suggestedSalary = '面议'
  237. }
  238. } else if (candidateInfo.value.score >= 60) {
  239. // 及格候选人,建议给予中等薪资
  240. const min = parseInt(position.salary_range?.min || '0')
  241. const max = parseInt(position.salary_range?.max || '0')
  242. if (min > 0 && max > 0) {
  243. const suggested = Math.floor(min + (max - min) * 0.5)
  244. candidateInfo.value.suggestedSalary = suggested.toString()
  245. } else {
  246. candidateInfo.value.suggestedSalary = '面议'
  247. }
  248. } else {
  249. // 低分候选人,建议给予较低薪资或不录用
  250. candidateInfo.value.suggestedSalary = position.salary_range?.min || '面议'
  251. }
  252. } else {
  253. // 如果没有职位信息,设置默认值
  254. candidateInfo.value.salaryRange = '面议'
  255. candidateInfo.value.suggestedSalary = '面议'
  256. }
  257. // 更新综合分析
  258. if (application?.comprehensive_analysis) {
  259. // 更新综合评分
  260. if (application.comprehensive_analysis.comprehensive_score !== null &&
  261. application.comprehensive_analysis.comprehensive_score !== undefined) {
  262. candidateInfo.value.score = application.comprehensive_analysis.comprehensive_score
  263. }
  264. // 更新优缺点分析
  265. if (application.comprehensive_analysis.video_analysis_data) {
  266. const videoData = application.comprehensive_analysis.video_analysis_data
  267. // 更新优点
  268. if (videoData.strengths && videoData.strengths.length > 0) {
  269. candidateInfo.value.strengths = videoData.strengths.filter((s: string) =>
  270. s && !s.includes('无法从响应中提取')
  271. )
  272. }
  273. // 更新缺点
  274. if (videoData.weaknesses && videoData.weaknesses.length > 0) {
  275. candidateInfo.value.weaknesses = videoData.weaknesses.filter((w: string) =>
  276. w && !w.includes('无法从响应中提取')
  277. )
  278. }
  279. // 更新录用建议
  280. if (application.comprehensive_analysis.hire_recommendation) {
  281. candidateInfo.value.hireRecommendation = application.comprehensive_analysis.hire_recommendation
  282. }
  283. // 更新录用理由
  284. if (application.comprehensive_analysis.hire_reason) {
  285. candidateInfo.value.hireReason = application.comprehensive_analysis.hire_reason
  286. }
  287. }
  288. }
  289. // 更新能力维度评分
  290. if (application?.ai_capability_scores) {
  291. const dimensionMapping: Record<string, keyof typeof candidateInfo.value.dimensions> = {
  292. '专业性': 'workAdaptability',
  293. '沟通能力': 'teamwork',
  294. '技术匹配度': 'learningAbility',
  295. '解决问题能力': 'attention',
  296. '服务意识': 'serviceAwareness'
  297. }
  298. Object.entries(application.ai_capability_scores).forEach(([key, value]) => {
  299. const mappedKey = dimensionMapping[key]
  300. if (mappedKey && typeof value === 'number') {
  301. let rating = '中等'
  302. if (value >= 80) rating = '优秀'
  303. else if (value < 65) rating = '欠佳'
  304. candidateInfo.value.dimensions[mappedKey] = rating
  305. }
  306. })
  307. } else {
  308. // 如果没有AI能力评分,根据面试题目分析生成维度评分
  309. const dimensionScores: Record<string, number[]> = {
  310. teamwork: [],
  311. learningAbility: [],
  312. attention: [],
  313. workAdaptability: [],
  314. serviceAwareness: []
  315. }
  316. interview_progress?.forEach((q: any) => {
  317. if (q.video_answer && q.video_answer.ai_score) {
  318. // 根据问题类型和内容分配到不同维度
  319. if (q.question_text.includes('团队') || q.question_text.includes('合作')) {
  320. dimensionScores.teamwork.push(q.video_answer.ai_score)
  321. }
  322. if (q.question_text.includes('学习') || q.question_text.includes('技能')) {
  323. dimensionScores.learningAbility.push(q.video_answer.ai_score)
  324. }
  325. if (q.question_text.includes('细致') || q.question_text.includes('严谨') || q.question_text.includes('注意')) {
  326. dimensionScores.attention.push(q.video_answer.ai_score)
  327. }
  328. if (q.question_text.includes('适应') || q.question_text.includes('工作')) {
  329. dimensionScores.workAdaptability.push(q.video_answer.ai_score)
  330. }
  331. if (q.question_text.includes('服务') || q.question_text.includes('客户')) {
  332. dimensionScores.serviceAwareness.push(q.video_answer.ai_score)
  333. }
  334. }
  335. })
  336. // 计算每个维度的平均分并转换为评级
  337. Object.entries(dimensionScores).forEach(([key, scores]) => {
  338. if (scores.length > 0) {
  339. const avgScore = scores.reduce((sum, score) => sum + score, 0) / scores.length
  340. let rating = '中等'
  341. if (avgScore >= 80) rating = '优秀'
  342. else if (avgScore < 65) rating = '欠佳'
  343. candidateInfo.value.dimensions[key as keyof typeof candidateInfo.value.dimensions] = rating
  344. }
  345. })
  346. }
  347. // 更新维度详情
  348. const dimensionDetailsMapping: Record<string, string> = {
  349. teamwork: '候选人在团队协作方面的表现',
  350. learningAbility: '候选人的学习能力和接受新知识的速度',
  351. attention: '候选人对细节的关注程度和工作严谨性',
  352. workAdaptability: '候选人适应工作环境和要求的能力',
  353. serviceAwareness: '候选人的服务意识和客户导向思维'
  354. }
  355. Object.keys(candidateInfo.value.dimensionDetails).forEach(key => {
  356. const dimension = key as keyof typeof candidateInfo.value.dimensionDetails
  357. const rating = candidateInfo.value.dimensions[dimension]
  358. let detail = dimensionDetailsMapping[dimension] || ''
  359. if (rating === '优秀') {
  360. detail += '表现优秀,符合岗位要求。'
  361. } else if (rating === '中等') {
  362. detail += '表现一般,基本符合岗位要求。'
  363. } else {
  364. detail += '表现欠佳,需要进一步提升。'
  365. }
  366. candidateInfo.value.dimensionDetails[dimension] = detail
  367. })
  368. // 更新面试记录
  369. if (interview_progress && interview_progress.length > 0) {
  370. candidateInfo.value.interviewRecord = interview_progress
  371. .filter((q: any) => q.video_answer)
  372. .map((q: any) => {
  373. // 处理答案文本
  374. let answerText = q.video_answer?.transcript || '未提供回答';
  375. let is_correct = false;
  376. // 如果有answer_data且包含选项,则使用选项文本
  377. if (q.answer_data && q.answer_data.selected_option_details && q.answer_data.selected_option_details.length > 0) {
  378. answerText = q.answer_data.selected_option_details
  379. .map((option: any) => option.option_text)
  380. .join(', ');
  381. is_correct = q.answer_data.selected_option_details
  382. .map((option: any) => option.is_correct)
  383. .join(', ');
  384. }
  385. console.log(q)
  386. return {
  387. question: q.question_text || '未提供问题',
  388. answer: answerText,
  389. analysis: q.video_answer?.ai_analysis?.comment || '面试官正在评估中',
  390. score: q.video_answer?.ai_score ? `${q.video_answer.ai_score}分` : '评估中',
  391. question_form: q.question_form,
  392. is_correct:is_correct,
  393. videoUrl: q.video_answer?.video_url || '',
  394. question_image_url: q.question_image_url || '',
  395. thumbnail: '/images/video-placeholder.jpg'
  396. }
  397. })
  398. }
  399. // 更新DUV分析
  400. if (application?.visual_analysis_results && application.visual_analysis_results.photo_results) {
  401. // 从photo_results中收集所有detections
  402. const allDetections: any[] = []
  403. application.visual_analysis_results.photo_results.forEach((photo: any) => {
  404. if (photo.detections && photo.detections.length > 0) {
  405. photo.detections.forEach((detection: any) => {
  406. allDetections.push({
  407. title: detection.feature || '特征分析',
  408. content: detection.location ?
  409. `在${detection.location}发现${detection.feature}${detection.description ? ',' + detection.description : ''}` :
  410. detection.feature + (detection.description ? ',' + detection.description : ''),
  411. score: detection.confidence >= 0.8 ? '确认' : '疑似',
  412. type: detection.description && detection.description.includes('影响') ? 'negative' : 'neutral'
  413. })
  414. })
  415. }
  416. })
  417. // 如果有检测结果,更新DUV分析
  418. if (allDetections.length > 0) {
  419. candidateInfo.value.duvAnalysis = allDetections
  420. } else {
  421. // 如果没有检测结果,提供默认值
  422. candidateInfo.value.duvAnalysis = [
  423. {
  424. title: '未发现特殊特征',
  425. content: '未在照片中检测到特殊特征',
  426. score: '正常',
  427. type: 'positive'
  428. }
  429. ]
  430. }
  431. } else if (application?.visual_analysis_results && application.visual_analysis_results.detections) {
  432. // 兼容旧版API格式
  433. candidateInfo.value.duvAnalysis = application.visual_analysis_results.detections.map((detection: any) => ({
  434. title: detection.feature || '特征分析',
  435. content: detection.location ? `在${detection.location}发现${detection.feature}` : detection.feature,
  436. score: detection.confidence >= 0.8 ? '确认' : '疑似',
  437. type: 'neutral'
  438. }))
  439. } else {
  440. // 如果没有视觉分析结果,提供默认值
  441. candidateInfo.value.duvAnalysis = [
  442. {
  443. title: '未进行DUV分析',
  444. content: '未提供DUV分析数据',
  445. score: '未知',
  446. type: 'neutral'
  447. }
  448. ]
  449. }
  450. // 更新手势验证和人脸验证图片
  451. if (posture_photos && posture_photos.length > 0) {
  452. const leftHandPhotos = posture_photos.filter((p: any) =>
  453. p.description.includes('left_') || p.description.includes('左手')
  454. ).map((p: any) => p.photo_url)
  455. if (leftHandPhotos.length > 0) {
  456. candidateInfo.value.additionalInfo.leftHandGestures.images = leftHandPhotos
  457. }
  458. const rightHandPhotos = posture_photos.filter((p: any) =>
  459. p.description.includes('right_') || p.description.includes('右手')
  460. ).map((p: any) => p.photo_url)
  461. if (rightHandPhotos.length > 0) {
  462. candidateInfo.value.additionalInfo.rightHandGestures.images = rightHandPhotos
  463. }
  464. const facePhotos = posture_photos.filter((p: any) =>
  465. p.description.includes('面部') ||
  466. (!p.description.includes('left_') &&
  467. !p.description.includes('right_') &&
  468. !p.description.includes('左手') &&
  469. !p.description.includes('右手'))
  470. ).map((p: any) => p.photo_url)
  471. if (facePhotos.length > 0) {
  472. candidateInfo.value.additionalInfo.faceVerification.images = facePhotos
  473. }
  474. }
  475. // 更新视频记录
  476. if (interview_progress && interview_progress.length > 0) {
  477. const videosByCategory: Record<string, any[]> = {}
  478. interview_progress.forEach((q: any) => {
  479. if (q.video_answer && q.video_answer.video_url) {
  480. const category = q.question_type_display || '面试视频'
  481. if (!videosByCategory[category]) {
  482. videosByCategory[category] = []
  483. }
  484. videosByCategory[category].push({
  485. url: q.video_answer.video_url,
  486. thumbnail: '/images/video-placeholder.jpg',
  487. description: q.question_text
  488. })
  489. }
  490. })
  491. candidateInfo.value.videoRecords = Object.entries(videosByCategory).map(([category, videos]) => ({
  492. category,
  493. videos
  494. }))
  495. }
  496. // 更新入职相关信息
  497. candidateInfo.value.availabilityPeriod = '无特殊情况'
  498. candidateInfo.value.onboardingTime = '随时可以入职'
  499. }
  500. onMounted(() => {
  501. console.log('完整URL:', window.location.href)
  502. console.log('路径:', window.location.pathname)
  503. console.log('查询字符串:', window.location.search)
  504. console.log('哈希:', window.location.hash)
  505. fetchApplicationDetail()
  506. })
  507. const evaluationScore = ref<number>(0)
  508. const evaluationComments = ref<string>('')
  509. const formRef = ref<FormInstance>()
  510. const handleSubmit = async () => {
  511. try {
  512. await formRef.value?.validate()
  513. message.success('评估已提交')
  514. } catch (error) {
  515. message.error('请完成所有必填项')
  516. }
  517. }
  518. const reportTop = ref<HTMLElement | null>(null)
  519. const scrollToTop = () => {
  520. console.log('尝试滚动到顶部')
  521. // 使用ref引用直接滚动到顶部元素
  522. if (reportTop.value) {
  523. reportTop.value.scrollIntoView({ behavior: 'smooth', block: 'start' })
  524. console.log('使用ref滚动到顶部')
  525. return
  526. }
  527. // 如果ref不可用,尝试通过ID查找元素
  528. const topElement = document.getElementById('report-top')
  529. if (topElement) {
  530. topElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
  531. console.log('使用ID滚动到顶部')
  532. return
  533. }
  534. // 如果以上方法都失败,使用之前的备用方法
  535. // ... 之前的代码 ...
  536. }
  537. const handleShare = () => {
  538. ElMessage.success('分享链接已复制')
  539. }
  540. const handleDownload = () => {
  541. ElMessage.success('报告下载中...')
  542. }
  543. const handlePrint = () => {
  544. ElMessage.success('准备打印报告...')
  545. setTimeout(() => {
  546. window.print()
  547. }, 300)
  548. }
  549. // 添加base64编码的内联图片作为fallback
  550. const fallbackImageBase64Ref = ref('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2VlZWVlZSIvPjxwYXRoIGQ9Ik03NSA2NUExMCAxMCAwIDAgMSA4NSA3NUExMCAxMCAwIDAgMSA3NSA4NUExMCAxMCAwIDAgMSA2NSA3NUExMCAxMCAwIDAgMSA3NSA2NVoiIGZpbGw9IiNhYWFhYWEiLz48cGF0aCBkPSJNMTIwIDEzNUw5MCAxMDVMNjAgMTM1TDYwIDE0MEwxNDAgMTQwTDE0MCAxMzVMMTIwIDExNVoiIGZpbGw9IiNhYWFhYWEiLz48L3N2Zz4=')
  551. // 简化的图片错误处理函数
  552. const handleImageError = (event: Event) => {
  553. const target = event.target as HTMLImageElement
  554. if (target) {
  555. target.src = fallbackImageBase64Ref.value
  556. target.onerror = null // 防止无限循环
  557. }
  558. }
  559. // 修改视频加载和第一帧捕获的函数
  560. const captureVideoFirstFrame = (videoElement: HTMLVideoElement) => {
  561. // 创建一个一次性的事件监听器,在视频可以播放时捕获第一帧
  562. const captureFrame = () => {
  563. try {
  564. // 确保视频已加载足够的数据
  565. if (videoElement.readyState >= 2) {
  566. // 创建canvas元素
  567. const canvas = document.createElement('canvas')
  568. canvas.width = videoElement.videoWidth || 320
  569. canvas.height = videoElement.videoHeight || 240
  570. // 在canvas上绘制视频当前帧
  571. const ctx = canvas.getContext('2d')
  572. if (ctx) {
  573. // 确保视频当前时间设置为开始位置
  574. videoElement.currentTime = 0.1
  575. // 绘制视频帧到canvas
  576. ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height)
  577. // 将canvas转换为图片URL
  578. const thumbnailUrl = canvas.toDataURL('image/jpeg')
  579. // 设置为视频的poster属性
  580. videoElement.poster = thumbnailUrl
  581. // 清理
  582. videoElement.removeEventListener('loadeddata', captureFrame)
  583. }
  584. }
  585. } catch (error) {
  586. console.error('捕获视频第一帧失败:', error)
  587. // 设置默认缩略图
  588. videoElement.poster = fallbackImageBase64Ref.value
  589. }
  590. }
  591. // 添加事件监听器
  592. videoElement.addEventListener('loadeddata', captureFrame)
  593. // 如果视频已经加载,立即尝试捕获
  594. if (videoElement.readyState >= 2) {
  595. captureFrame()
  596. }
  597. // 添加错误处理
  598. videoElement.addEventListener('error', () => {
  599. console.error('视频加载失败')
  600. videoElement.poster = fallbackImageBase64Ref.value
  601. })
  602. // 设置超时,确保即使视频加载缓慢也能显示默认缩略图
  603. setTimeout(() => {
  604. if (!videoElement.poster || videoElement.poster === '') {
  605. videoElement.poster = fallbackImageBase64Ref.value
  606. }
  607. }, 3000)
  608. }
  609. // 视频加载完成后的处理函数
  610. const handleVideoLoaded = (event: Event) => {
  611. const videoElement = event.target as HTMLVideoElement
  612. if (videoElement) {
  613. // 确保视频已经加载元数据
  614. if (videoElement.readyState >= 1) {
  615. captureVideoFirstFrame(videoElement)
  616. } else {
  617. // 如果元数据尚未加载,添加元数据加载事件
  618. videoElement.addEventListener('loadedmetadata', () => {
  619. captureVideoFirstFrame(videoElement)
  620. })
  621. }
  622. }
  623. }
  624. // 添加视频错误处理函数
  625. const handleVideoError = (event: Event) => {
  626. const videoElement = event.target as HTMLVideoElement
  627. if (videoElement) {
  628. videoElement.poster = fallbackImageBase64Ref.value
  629. console.error('视频加载错误')
  630. }
  631. }
  632. </script>
  633. <template>
  634. <div class="max-w-4xl mx-auto p-6 relative overflow-y-auto h-full">
  635. <!-- 添加顶部锚点 -->
  636. <div id="report-top" ref="reportTop"></div>
  637. <!-- 加载状态 -->
  638. <a-spin :spinning="loading" tip="加载中...">
  639. <!-- 页面标题 -->
  640. <div class="bg-[#1d1e3a] text-white p-4 rounded-t-lg">
  641. <div class="flex justify-between items-center">
  642. <div class="text-sm">No.{{ apiData?.application?.report_id || candidateInfo.idNumber }}</div>
  643. <div class="text-sm">{{ new Date().toLocaleString() }}</div>
  644. </div>
  645. </div>
  646. <div v-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
  647. <strong class="font-bold">错误!</strong>
  648. <span class="block sm:inline">{{ error }}</span>
  649. </div>
  650. <div v-else class="bg-white rounded-b-lg shadow-lg p-6">
  651. <!-- 头部信息 -->
  652. <div class="flex justify-between items-center mb-8 pb-4 border-b">
  653. <div class="flex items-center space-x-4">
  654. <div class="w-20 h-20 bg-gray-200 rounded-lg overflow-hidden">
  655. <img
  656. :src="candidateInfo.additionalInfo.faceVerification.images[0]"
  657. alt="头像"
  658. class="w-full h-full object-cover"
  659. @error="handleImageError"
  660. >
  661. </div>
  662. <div>
  663. <h1 class="text-2xl font-bold">{{ candidateInfo.name }}</h1>
  664. <p class="text-gray-600">{{ candidateInfo.phoneNumber }}</p>
  665. <p v-if="apiData?.position" class="text-gray-500 text-sm">
  666. 应聘职位: {{ apiData.position.title }} - {{ apiData.position.department }}
  667. </p>
  668. </div>
  669. </div>
  670. <div class="text-right">
  671. <div class="text-4xl font-bold text-yellow-500">{{ candidateInfo.score }}<span class="text-base ml-1">分</span></div>
  672. <div class="text-sm text-yellow-500">
  673. {{ candidateInfo.score >= 70 ? '推荐 👍' : candidateInfo.score >= 60 ? '待定 🤔' : '不推荐 👎' }}
  674. </div>
  675. </div>
  676. </div>
  677. <!-- 基本信息 -->
  678. <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
  679. <div>
  680. <label class="text-gray-600">身份证号</label>
  681. <div>{{ candidateInfo.idNumber }}</div>
  682. </div>
  683. <div>
  684. <label class="text-gray-600">入职之后,您缺岗的班次时间段是?</label>
  685. <div>{{ candidateInfo.availabilityPeriod }}</div>
  686. </div>
  687. <div>
  688. <label class="text-gray-600">请问您通过面试之后多久可以入职?</label>
  689. <div>{{ candidateInfo.onboardingTime }}</div>
  690. </div>
  691. <div>
  692. <label class="text-gray-600">薪资范围</label>
  693. <div>{{ candidateInfo.salaryRange }}</div>
  694. </div>
  695. <div>
  696. <label class="text-gray-600">建议薪资</label>
  697. <div class="font-semibold text-blue-600">{{ candidateInfo.suggestedSalary }}</div>
  698. </div>
  699. </div>
  700. <!-- 1. 综合评估 -->
  701. <div class="mb-8">
  702. <h2 class="text-xl font-bold mb-6">1. 综合评估</h2>
  703. <div class="space-y-4">
  704. <div class="border-b pb-4">
  705. <div class="flex items-center justify-between mb-2">
  706. <span class="text-gray-600">录用建议</span>
  707. <span :class="{
  708. 'text-green-500': candidateInfo.hireRecommendation?.includes('推荐'),
  709. 'text-red-500': candidateInfo.hireRecommendation?.includes('不推荐'),
  710. 'text-yellow-500': !candidateInfo.hireRecommendation?.includes('推荐') && !candidateInfo.hireRecommendation?.includes('不推荐')
  711. }">{{ candidateInfo.hireRecommendation || '无建议' }}</span>
  712. </div>
  713. <p class="text-gray-600 text-sm">{{ candidateInfo.hireReason || '无详细说明' }}</p>
  714. </div>
  715. <div v-if="candidateInfo.strengths && candidateInfo.strengths.length > 0" class="border-b pb-4">
  716. <h3 class="font-semibold mb-2">优点</h3>
  717. <ul class="list-disc pl-5 text-gray-600 text-sm">
  718. <li v-for="(strength, index) in candidateInfo.strengths" :key="'strength-'+index">
  719. {{ strength }}
  720. </li>
  721. </ul>
  722. </div>
  723. <div v-if="candidateInfo.weaknesses && candidateInfo.weaknesses.length > 0" class="border-b pb-4">
  724. <h3 class="font-semibold mb-2">需改进的地方</h3>
  725. <ul class="list-disc pl-5 text-gray-600 text-sm">
  726. <li v-for="(weakness, index) in candidateInfo.weaknesses" :key="'weakness-'+index">
  727. {{ weakness }}
  728. </li>
  729. </ul>
  730. </div>
  731. </div>
  732. </div>
  733. <!-- 2. DUV分析评估 -->
  734. <div class="mb-8">
  735. <h2 class="text-xl font-bold mb-6">2. DUV分析评估</h2>
  736. <div class="space-y-4">
  737. <div v-for="(item, index) in candidateInfo.duvAnalysis" :key="index" class="border-b pb-4">
  738. <div class="flex items-center justify-between mb-2">
  739. <span class="text-gray-600">{{ item.title }}</span>
  740. <span :class="{
  741. 'text-green-500': item.type === 'positive',
  742. 'text-red-500': item.type === 'negative',
  743. 'text-yellow-500': item.type === 'neutral'
  744. }">{{ item.score }}</span>
  745. </div>
  746. <p class="text-gray-600 text-sm">{{ item.content }}</p>
  747. </div>
  748. </div>
  749. </div>
  750. <!-- 3. 面试记录 -->
  751. <div class="mb-8">
  752. <h2 class="text-xl font-bold mb-6">3. 面试记录</h2>
  753. <div class="space-y-6">
  754. <div v-for="(record, index) in candidateInfo.interviewRecord" :key="index" class="border-b pb-4">
  755. <div class="mb-2">
  756. <h3 class="font-semibold">问题:{{ record.question }}</h3>
  757. <p class="text-gray-600 mt-2">答案:{{ record.answer }}</p>
  758. <p class="text-gray-500 mt-2 text-sm" v-if="record.question_form === 0">分析:{{ record.analysis }}</p>
  759. <img v-if="record.question_form === 3" :src="record.question_image_url" alt="图片" class="w-5/12 h-full object-cover m-auto">
  760. <div v-if="record.videoUrl" class="mt-4">
  761. <div class="video-container">
  762. <video
  763. class="w-full h-full object-cover"
  764. controls
  765. :src="record.videoUrl"
  766. preload="metadata"
  767. :poster="fallbackImageBase64Ref"
  768. @loadeddata="handleVideoLoaded"
  769. @error="handleVideoError"
  770. >
  771. 您的浏览器不支持视频播放。
  772. </video>
  773. </div>
  774. </div>
  775. <div class="mt-2 flex justify-end">
  776. <span class="text-blue-500" v-if="record.question_form === 0">得分:{{ record.score }}</span>
  777. <span class="text-gray-500" v-else>
  778. <span class="text-green-500" v-if="record.is_correct === true || record.is_correct === 'true'">正确</span>
  779. <span class="text-red-500" v-else>错误</span>
  780. </span>
  781. </div>
  782. </div>
  783. </div>
  784. </div>
  785. </div>
  786. <!-- 4. 视频记录 -->
  787. <div class="mb-8">
  788. <h2 class="text-xl font-bold mb-6">4. 视频记录</h2>
  789. <div class="space-y-8">
  790. <div v-for="(category, index) in candidateInfo.videoRecords" :key="index">
  791. <h3 class="text-lg font-semibold mb-4">{{ category.category }}</h3>
  792. <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
  793. <div v-for="(video, vIndex) in category.videos" :key="vIndex" class="bg-gray-50 rounded-lg p-4">
  794. <div class="video-container">
  795. <video
  796. class="w-full h-full object-cover"
  797. controls
  798. :src="video.url"
  799. preload="metadata"
  800. :poster="fallbackImageBase64Ref"
  801. @loadeddata="handleVideoLoaded"
  802. @error="handleVideoError"
  803. >
  804. 您的浏览器不支持视频播放。
  805. </video>
  806. </div>
  807. <p class="text-sm text-gray-600 mt-2">{{ video.description }}</p>
  808. </div>
  809. </div>
  810. </div>
  811. </div>
  812. </div>
  813. <!-- 其他信息 -->
  814. <div class="mb-8">
  815. <h2 class="text-xl font-bold mb-6">5. 其他信息</h2>
  816. <div class="space-y-6">
  817. <!-- 验证状态 -->
  818. <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
  819. <div class="flex items-center space-x-2">
  820. <span class="text-gray-600">身份证验证:</span>
  821. <span class="text-green-500">{{ candidateInfo.additionalInfo.idVerification }}</span>
  822. </div>
  823. <div class="flex items-center space-x-2">
  824. <span class="text-gray-600">手机号验证:</span>
  825. <span class="text-green-500">{{ candidateInfo.additionalInfo.phoneVerification }}</span>
  826. </div>
  827. </div>
  828. <!-- 手势验证 -->
  829. <div class="mb-6">
  830. <h3 class="font-semibold mb-4">手势验证</h3>
  831. <div class="grid grid-cols-2 gap-8">
  832. <!-- 左手手势 -->
  833. <div>
  834. <h4 class="text-sm font-medium mb-2">左手手势验证</h4>
  835. <div class="grid grid-cols-3 gap-2">
  836. <div v-for="(image, index) in candidateInfo.additionalInfo.leftHandGestures.images" :key="index"
  837. class="aspect-square rounded-lg overflow-hidden bg-gray-100">
  838. <img :src="image" alt="左手手势" class="w-full h-full object-cover"
  839. @error="handleImageError">
  840. </div>
  841. </div>
  842. </div>
  843. <!-- 右手手势 -->
  844. <div>
  845. <h4 class="text-sm font-medium mb-2">右手手势验证</h4>
  846. <div class="grid grid-cols-3 gap-2">
  847. <div v-for="(image, index) in candidateInfo.additionalInfo.rightHandGestures.images" :key="index"
  848. class="aspect-square rounded-lg overflow-hidden bg-gray-100">
  849. <img :src="image" alt="右手手势" class="w-full h-full object-cover"
  850. @error="handleImageError">
  851. </div>
  852. </div>
  853. </div>
  854. </div>
  855. </div>
  856. <!-- 人脸验证 -->
  857. <div>
  858. <h3 class="font-semibold mb-4">人脸验证</h3>
  859. <div class="grid grid-cols-3 md:grid-cols-6 gap-2">
  860. <div v-for="(image, index) in candidateInfo.additionalInfo.faceVerification.images" :key="index"
  861. class="aspect-square rounded-lg overflow-hidden bg-gray-100">
  862. <img :src="image" alt="人脸验证" class="w-full h-full object-cover"
  863. @error="handleImageError">
  864. </div>
  865. </div>
  866. </div>
  867. </div>
  868. </div>
  869. </div>
  870. </a-spin>
  871. <!-- 悬浮按钮 (Element Plus 版本) -->
  872. <div class="fixed right-8 bottom-24 flex flex-col space-y-4">
  873. <el-button
  874. type="primary"
  875. style="margin-left: 12px;"
  876. circle
  877. @click="handleShare"
  878. >
  879. <el-icon><Share /></el-icon>
  880. </el-button>
  881. <el-button
  882. type="primary"
  883. circle
  884. @click="handleDownload"
  885. >
  886. <el-icon><Download /></el-icon>
  887. </el-button>
  888. <el-button
  889. type="primary"
  890. circle
  891. @click="handlePrint"
  892. >
  893. <el-icon><Printer /></el-icon>
  894. </el-button>
  895. <el-button
  896. type="primary"
  897. circle
  898. @click="scrollToTop"
  899. >
  900. <el-icon><ArrowUp /></el-icon>
  901. </el-button>
  902. </div>
  903. </div>
  904. </template>
  905. <style scoped>
  906. .video-container {
  907. position: relative;
  908. width: 100%;
  909. max-width: 240px;
  910. aspect-ratio: 3/4;
  911. margin: 0 auto;
  912. background: #000;
  913. border-radius: 12px;
  914. overflow: hidden;
  915. }
  916. .ant-form-item {
  917. margin-bottom: 24px;
  918. }
  919. .fixed {
  920. z-index: 100;
  921. }
  922. .ant-btn-circle {
  923. width: 40px;
  924. height: 40px;
  925. padding: 0;
  926. border-radius: 50%;
  927. }
  928. /* 修改滚动相关样式 */
  929. html, body {
  930. height: 100%;
  931. scroll-behavior: smooth;
  932. }
  933. /* 确保主容器可以正常滚动 */
  934. .max-w-4xl {
  935. position: relative;
  936. overflow-y: visible;
  937. min-height: 100%;
  938. }
  939. /* 添加以下样式确保内容可以正常滚动 */
  940. body {
  941. margin: 0;
  942. padding: 0;
  943. overflow-y: auto !important;
  944. }
  945. #app {
  946. height: auto;
  947. min-height: 100vh;
  948. overflow: visible;
  949. }
  950. /* 确保滚动容器不会被阻止滚动 */
  951. .ant-layout,
  952. .ant-layout-content,
  953. .el-scrollbar__wrap,
  954. .el-scrollbar__view {
  955. overflow-y: auto !important;
  956. }
  957. @media (max-width: 768px) {
  958. .max-w-4xl {
  959. padding: 1rem;
  960. }
  961. .grid-cols-3 {
  962. grid-template-columns: 1fr;
  963. }
  964. .fixed {
  965. right: 1rem;
  966. }
  967. }
  968. /* 打印样式 */
  969. @media print {
  970. /* 打印时隐藏悬浮按钮 */
  971. .fixed {
  972. display: none !important;
  973. }
  974. /* 确保内容完整显示 */
  975. .max-w-4xl {
  976. max-width: 100% !important;
  977. padding: 0 !important;
  978. margin: 0 !important;
  979. }
  980. /* 调整页面边距 */
  981. @page {
  982. margin: 1cm;
  983. }
  984. /* 确保背景色和图片打印 */
  985. * {
  986. -webkit-print-color-adjust: exact !important;
  987. color-adjust: exact !important;
  988. print-color-adjust: exact !important;
  989. }
  990. /* 避免视频容器在打印时出现问题 */
  991. .video-container {
  992. page-break-inside: avoid;
  993. break-inside: avoid;
  994. }
  995. /* 确保每个主要部分在新页面开始 */
  996. h2.text-xl {
  997. page-break-before: always;
  998. break-before: always;
  999. }
  1000. /* 第一个标题不需要分页 */
  1001. h2.text-xl:first-of-type {
  1002. page-break-before: avoid;
  1003. break-before: avoid;
  1004. }
  1005. }
  1006. </style>