camera.vue 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348
  1. <template>
  2. <view class="camera-container">
  3. <!-- 顶部标题栏 -->
  4. <!-- <view class="header">
  5. <view class="back-button" @click="back">
  6. <text class="iconfont icon-back">&#xe679;</text>
  7. </view>
  8. <view class="title">中国传统文化测试</view>
  9. <view class="controls">
  10. <text class="iconfont icon-mic">&#xe677;</text>
  11. <text class="iconfont icon-settings" @click="toggleSettings">&#xe678;</text>
  12. </view>
  13. </view> -->
  14. <!-- 主要内容区域 -->
  15. <view class="content">
  16. <!-- 数字人头像 -->
  17. <view class="digital-avatar">
  18. <camera v-if="!digitalHumanUrl" device-position="front" flash="auto" class="camera"
  19. :mode="mode" @error="handleCameraError"></camera>
  20. <web-view v-else-if="digitalHumanUrl" :src="digitalHumanUrl" class="digital-human-webview"></web-view>
  21. <image v-else src="/static/avatar.png" mode="aspectFill" class="avatar-image"></image>
  22. </view>
  23. <!-- 进度指示器 -->
  24. <!-- <view class="progress-container">
  25. <view class="progress-step" v-for="(step, index) in 3" :key="index">
  26. <view class="step-circle" :class="{'active': currentStep >= index+1}">{{index+1}}</view>
  27. <view class="step-text">{{stepTexts[index]}}</view>
  28. </view>
  29. <view class="progress-bar">
  30. <view class="progress-fill" :style="{width: progressWidth + '%'}"></view>
  31. </view>
  32. </view> -->
  33. <!-- 面试内容区域 -->
  34. <view class="interview-content">
  35. <!-- 问题编号和进度 -->
  36. <view class="question-number">
  37. <text>{{currentQuestionIndex + 1}}/{{questions.length}}</text>
  38. </view>
  39. <!-- 问题和选项区域 -->
  40. <view class="question-area">
  41. <view class="question-importance">
  42. <text v-if="currentQuestion.isImportant">重点题</text>
  43. <text class="question-type">[{{currentQuestion.questionTypeName}}]</text>
  44. {{currentQuestion.text}}
  45. </view>
  46. <view class="options">
  47. <!-- 只渲染非开放问题 (questionType: 1 或 2) 'option-wrong': showResult && (
  48. currentQuestion.questionType === 1 ?
  49. (selectedOption === index && index !== currentQuestion.correctAnswer) :
  50. (selectedOptions.includes(index) && !currentQuestion.correctAnswers.includes(index))
  51. )-->
  52. <view
  53. v-if="currentQuestion.questionType !== 0"
  54. v-for="(option, index) in currentQuestion.options"
  55. :key="index"
  56. class="option-item"
  57. :class="{
  58. 'option-selected': currentQuestion.questionType === 1 ? selectedOption === index : selectedOptions.includes(index),
  59. 'option-correct': showResult && (
  60. currentQuestion.questionType === 1 ? index === currentQuestion.correctAnswer : currentQuestion.correctAnswers.includes(index)
  61. ),
  62. }"
  63. @click="selectOption(index)">
  64. <!-- 使用字母标识 A、B、C、D、E 等 -->
  65. <text class="option-prefix">{{ String.fromCharCode(65 + index) }}.</text>
  66. <text class="option-text">{{ option.option_text || (typeof option === 'string' ? option : JSON.stringify(option)) }}</text>
  67. </view>
  68. </view>
  69. <!-- 添加计时器显示 -->
  70. <!-- <view class="timer-container">
  71. <text class="timer-text">本题剩余({{remainingTime}})</text>
  72. </view> -->
  73. <view class="bottom-button-container">
  74. <view class="bottom-button-wrapper">
  75. <button class="next-button" @click="nextQuestion(option)"
  76. :disabled="
  77. (currentQuestion.questionType === 0 && openQuestionAnswer.trim() === '') ||
  78. (currentQuestion.questionType === 1 && selectedOption === null) ||
  79. (currentQuestion.questionType === 2 && selectedOptions.length === 0)
  80. ">
  81. 本题剩余{{remainingTime}}&nbsp;&nbsp;&nbsp;{{"进入下一题"}}<!-- showResult ? '进入下一题' : '提交答案' -->
  82. </button>
  83. </view>
  84. </view>
  85. </view>
  86. </view>
  87. </view>
  88. <!-- 底部控制栏 -->
  89. <!-- <view class="footer">
  90. <view class="timer">本题剩余 {{remainingTime}}</view>
  91. <view class="next-step">进入下一题</view>
  92. </view> -->
  93. <!-- 面试结束弹窗 -->
  94. <view class="interview-end-modal" v-if="showEndModal">
  95. <view class="modal-content">
  96. <view class="modal-title">测试已完成</view>
  97. <!-- <view class="score-display">{{score}}/{{totalQuestions}}</view>
  98. <view class="modal-message">您在本次测试中答了{{score}}道题目,共{{totalQuestions}}道题目。</view> -->
  99. <view class="modal-buttons">
  100. <!-- <button type="default" class="modal-button" @click="restartTest">重新测试</button> -->
  101. <button type="primary" class="modal-button" @click="navigateToInterview">进入面试</button>
  102. </view>
  103. </view>
  104. </view>
  105. <!-- AI面试结束页面 -->
  106. <view class="interview-complete-screen" v-if="interviewCompleted">
  107. <view class="digital-avatar-large">
  108. <image src="/static/avatar.png" mode="aspectFill" class="avatar-image-large"></image>
  109. </view>
  110. <view class="complete-message">AI面试已结束</view>
  111. <view class="complete-description">本次面试的全部环节已结束。非常感谢您的参与。</view>
  112. <button class="complete-button" @click="back">我知道了</button>
  113. </view>
  114. <!-- 在模板中添加一个测试按钮(开发时使用,发布前删除) -->
  115. <!-- <button @click="testEndScreen" style="position: absolute; top: 10px; right: 10px; z-index: 9999;">测试结束页</button> -->
  116. <!-- 添加加载状态 -->
  117. <view class="loading-container" v-if="loading">
  118. <uni-load-more status="loading" :contentText="{contentdown: '加载中...'}" />
  119. <text class="loading-text">正在加载面试题目...</text>
  120. </view>
  121. <!-- 添加加载错误提示 -->
  122. <view class="loading-container" v-if="!loading && loadError">
  123. <view class="error-container">
  124. <text class="error-message">加载面试题目失败</text>
  125. <button class="retry-button" @click="fetchInterviewData">重试</button>
  126. </view>
  127. </view>
  128. </view>
  129. </template>
  130. <script>
  131. import { getInterviewList, getInterviewDetail } from '@/api/user.js';
  132. import { apiBaseUrl } from '@/common/config.js';
  133. export default {
  134. data() {
  135. return {
  136. cameraContext: null,
  137. devicePosition: 'front',
  138. interviewStarted: true,
  139. showEndModal: false,
  140. currentQuestionIndex: 0,
  141. currentStep: 1,
  142. stepTexts: ['测试准备', '回答问题', '测试结束'],
  143. progressWidth: 50,
  144. remainingTime: '00:27',
  145. selectedOption: null,
  146. selectedOptions: [],
  147. showResult: false,
  148. isAnswerCorrect: false,
  149. questions: [], // 改为空数组,将通过API获取
  150. interviewId: null, // 存储当前面试ID
  151. useVideo: false,
  152. timerInterval: null,
  153. score: 0,
  154. totalQuestions: 0,
  155. interviewCompleted: false,
  156. digitalHumanUrl: '', // 数字人URL
  157. loading: true, // 添加加载状态
  158. loadError: false, // 添加加载错误状态
  159. errorMessage: '', // 添加错误消息
  160. answers: [], // 存储用户的所有答案
  161. currentQuestionDetail: null, // 当前题目详情
  162. isSubmitting: false, // 是否正在提交答案
  163. openQuestionAnswer: '', // 存储开放问题的答案
  164. currentAnswer: null, // 存储当前答案以便提交
  165. questionStartTime: null, // 存储问题开始时间
  166. mode: 'normal', // 相机模式
  167. }
  168. },
  169. computed: {
  170. currentQuestion() {
  171. console.log(this.questions[this.currentQuestionIndex]);
  172. return this.questions[this.currentQuestionIndex];
  173. }
  174. },
  175. onLoad(options) {
  176. // 从路由参数中获取面试ID
  177. if (options && options.id) {
  178. this.interviewId = options.id;
  179. this.fetchInterviewData();
  180. } else {
  181. // 如果没有ID,可以获取面试列表并使用第一个
  182. this.fetchInterviewList();
  183. }
  184. },
  185. onReady() {
  186. // 创建相机上下文
  187. this.cameraContext = uni.createCameraContext();
  188. // 创建AI视频上下文
  189. if (this.useVideo) {
  190. this.aiVideoContext = uni.createVideoContext('aiInterviewer');
  191. }
  192. // 初始化数字人
  193. this.initDigitalHuman();
  194. },
  195. methods: {
  196. // 获取面试列表
  197. async fetchInterviewList() {
  198. try {
  199. this.loading = true;
  200. const res = await getInterviewList({job_id:JSON.parse(uni.getStorageSync('selectedJob')).id});
  201. console.log(res);
  202. // 使用第一个面试
  203. this.interviewId = res//[0].id;
  204. this.fetchInterviewData(res);
  205. } catch (error) {
  206. console.error('获取面试列表失败:', error);
  207. this.handleLoadError('获取面试列表失败');
  208. }
  209. },
  210. // 获取面试详情数据
  211. async fetchInterviewData(data) {
  212. try {
  213. this.loading = true;
  214. // const res = await getInterviewDetail({ id: this.interviewId });
  215. // console.log('API返回数据:', res);
  216. // 如果返回的是问题列表
  217. if (data && Array.isArray(data)) {
  218. // 处理多个问题,并过滤掉 question_form 为 0 的问题
  219. this.questions = data
  220. .filter(q => q.question_form !== 0) // 过滤掉开放问题
  221. .map((q, index) => ({
  222. id: q.id || index + 1,
  223. text: q.question || '未知问题',
  224. options: q.options || [],
  225. correctAnswer: q.correctAnswer || 0,
  226. isImportant: q.is_system || false,
  227. explanation: q.explanation || '',
  228. questionType: q.question_form,
  229. questionTypeName: q.question_form_name || '单选题',
  230. correctAnswers: q.correct_answers || [],
  231. difficulty: q.difficulty || 1,
  232. difficultyName: q.difficulty_name || '初级'
  233. }));
  234. } else {
  235. // 处理单个问题
  236. this.processInterviewData(res);
  237. }
  238. console.log(this.questions);
  239. // 设置总题目数
  240. this.totalQuestions = this.questions.length;
  241. // 启动倒计时
  242. if (this.questions.length > 0) {
  243. this.startTimer();
  244. }
  245. } catch (error) {
  246. console.error('获取面试详情失败:', error);
  247. this.handleLoadError('获取面试详情失败');
  248. } finally {
  249. this.loading = false;
  250. }
  251. },
  252. // 处理面试数据
  253. processInterviewData(data) {
  254. // 清空现有问题列表
  255. this.questions = [];
  256. // 检查数据格式并处理
  257. if (data && data.question_form !== 0) { // 只处理非开放问题
  258. // 创建一个格式化的问题对象
  259. const formattedQuestion = {
  260. id: data.id || 1,
  261. text: data.question || '未知问题',
  262. options: data.options || [],
  263. correctAnswer: data.correctAnswer || 0,
  264. isImportant: data.is_system || false,
  265. explanation: data.explanation || '',
  266. questionType: data.question_form, // 1-单选题,2-多选题
  267. questionTypeName: data.question_form_name || '单选题',
  268. correctAnswers: data.correct_answers || [],
  269. difficulty: data.difficulty || 1,
  270. difficultyName: data.difficulty_name || '初级'
  271. };
  272. // 添加到问题列表
  273. this.questions.push(formattedQuestion);
  274. // 设置总题目数
  275. this.totalQuestions = this.questions.length;
  276. // 启动倒计时
  277. this.startTimer();
  278. } else {
  279. this.handleLoadError('没有可用的选择题');
  280. }
  281. },
  282. // 处理加载错误
  283. handleLoadError(message) {
  284. this.loadError = true;
  285. this.loading = false;
  286. this.errorMessage = message || '加载失败';
  287. uni.showToast({
  288. title: message || '加载失败',
  289. icon: 'none',
  290. duration: 2000
  291. });
  292. },
  293. startTimer() {
  294. // 确保问题已加载
  295. if (this.questions.length === 0) return;
  296. // 记录开始时间
  297. this.questionStartTime = new Date();
  298. // 设置倒计时时间(秒)
  299. let seconds = 60; // 每题60秒,可以根据需要调整
  300. this.remainingTime = `01:00`;
  301. this.timerInterval = setInterval(() => {
  302. seconds--;
  303. if (seconds <= 0) {
  304. clearInterval(this.timerInterval);
  305. // 时间到,自动提交答案并进入下一题
  306. if (!this.showResult) {
  307. this.checkAnswer();
  308. // 短暂延迟后自动进入下一题
  309. setTimeout(() => {
  310. if (this.currentQuestionIndex < this.questions.length - 1) {
  311. this.goToNextQuestion();
  312. } else {
  313. // 如果是最后一题,显示结束模态框或跳转
  314. uni.navigateTo({
  315. url: '/pages/interview/interview'
  316. });
  317. }
  318. }, 1500); // 1.5秒后自动进入下一题
  319. }
  320. }
  321. // 格式化显示时间
  322. const min = Math.floor(seconds / 60).toString().padStart(2, '0');
  323. const sec = (seconds % 60).toString().padStart(2, '0');
  324. this.remainingTime = `${min}:${sec}`;
  325. }, 1000);
  326. },
  327. resetTimer() {
  328. clearInterval(this.timerInterval);
  329. this.startTimer();
  330. },
  331. selectOption(index) {
  332. if (this.showResult) return; // 已显示结果时不能再选择
  333. // 判断当前题目类型
  334. if (this.currentQuestion.questionType === 2) { // 多选题
  335. // 如果已经选中,则取消选中
  336. const optionIndex = this.selectedOptions.indexOf(index);
  337. if (optionIndex > -1) {
  338. this.selectedOptions.splice(optionIndex, 1);
  339. } else {
  340. // 否则添加到已选中数组
  341. this.selectedOptions.push(index);
  342. }
  343. } else if (this.currentQuestion.questionType === 1) { // 单选题
  344. this.selectedOption = index;
  345. }
  346. this.playAiSpeaking();
  347. },
  348. checkAnswer() {
  349. clearInterval(this.timerInterval); // 停止计时
  350. // 确保当前问题存在
  351. if (!this.currentQuestion) {
  352. console.error('当前问题不存在');
  353. return;
  354. }
  355. // 根据题目类型检查答案是否正确
  356. if (this.currentQuestion.questionType === 0) { // 开放问题
  357. // 开放问题没有标准答案,可以根据需要设置为正确或待评估
  358. this.isAnswerCorrect = true; // 或者设置为 null 表示待评估
  359. } else if (this.currentQuestion.questionType === 2) { // 多选题
  360. const sortedSelected = [...this.selectedOptions].sort();
  361. const sortedCorrect = [...this.currentQuestion.correctAnswers].sort();
  362. if (sortedSelected.length !== sortedCorrect.length) {
  363. this.isAnswerCorrect = false;
  364. } else {
  365. this.isAnswerCorrect = sortedSelected.every((value, index) => value === sortedCorrect[index]);
  366. }
  367. } else { // 单选题
  368. this.isAnswerCorrect = this.selectedOption === this.currentQuestion.correctAnswer;
  369. }
  370. // 显示结果
  371. this.showResult = true;
  372. // 保存当前题目的答案
  373. this.saveAnswer();
  374. // 提交答案
  375. this.submitCurrentAnswer().catch(error => {
  376. console.error('提交答案失败:', error);
  377. });
  378. },
  379. nextQuestion(data) {
  380. // 如果还没有显示结果,先检查答案并提交
  381. if (!this.showResult) {
  382. this.checkAnswer();
  383. // 保存当前题目的答案
  384. this.saveAnswer();
  385. // 提交答案
  386. this.submitCurrentAnswer().then(() => {
  387. // 提交成功后,短暂延迟后自动进入下一题
  388. setTimeout(() => {
  389. // 如果是最后一题,跳转到面试页面
  390. if (this.currentQuestionIndex >= this.questions.length - 1) {
  391. uni.navigateTo({
  392. url: '/pages/interview/interview'
  393. });
  394. } else {
  395. // 否则进入下一题
  396. this.goToNextQuestion();
  397. }
  398. }, 500); // 1秒后自动进入下一题
  399. }).catch(error => {
  400. console.error('提交答案失败:', error);
  401. // 即使提交失败也继续下一题
  402. setTimeout(() => {
  403. if (this.currentQuestionIndex >= this.questions.length - 1) {
  404. uni.navigateTo({
  405. url: '/pages/interview/interview'
  406. });
  407. } else {
  408. this.goToNextQuestion();
  409. }
  410. }, 1000);
  411. });
  412. return;
  413. }
  414. // 如果已经显示结果,点击"下一题"按钮时执行
  415. // 如果是最后一题,直接跳转到手部照片采集页面
  416. if (this.currentQuestionIndex >= this.questions.length - 1) {
  417. uni.navigateTo({
  418. url: '/pages/interview/interview'
  419. });
  420. return;
  421. }
  422. // 前往下一题
  423. this.goToNextQuestion();
  424. },
  425. // 保存当前题目的答案
  426. saveAnswer() {
  427. let answer;
  428. if (this.currentQuestion.questionType === 0) { // 开放问题
  429. answer = {
  430. questionId: this.currentQuestion.id,
  431. questionType: this.currentQuestion.questionType,
  432. answer: this.openQuestionAnswer,
  433. answerDuration: this.getAnswerDuration() // 添加答题时长
  434. };
  435. } else { // 单选或多选
  436. answer = {
  437. questionId: this.currentQuestion.id,
  438. questionType: this.currentQuestion.questionType,
  439. answer: this.currentQuestion.questionType === 1 ?
  440. this.selectedOption : this.selectedOptions,
  441. answerDuration: this.getAnswerDuration() // 添加答题时长
  442. };
  443. }
  444. // 检查是否已存在该题目的答案,如果存在则更新
  445. const existingIndex = this.answers.findIndex(a => a.questionId === answer.questionId);
  446. if (existingIndex > -1) {
  447. this.answers[existingIndex] = answer;
  448. } else {
  449. this.answers.push(answer);
  450. }
  451. // 保存当前答案以便提交
  452. this.currentAnswer = answer;
  453. console.log('已保存答案:', this.answers);
  454. },
  455. // 获取答题时长(秒)
  456. getAnswerDuration() {
  457. // 这里假设每题默认30秒,根据剩余时间计算用时
  458. // 您可以根据实际情况调整计算逻辑
  459. const remainingTimeArr = this.remainingTime.split(':');
  460. const remainingSeconds = parseInt(remainingTimeArr[0]) * 60 + parseInt(remainingTimeArr[1]);
  461. return 30 - remainingSeconds; // 假设每题30秒
  462. },
  463. // 提交当前答案
  464. async submitCurrentAnswer() {
  465. if (!this.currentAnswer) return;
  466. try {
  467. // 显示提交中提示
  468. uni.showLoading({
  469. title: '正在提交答案...'
  470. });
  471. // 构建提交数据
  472. let answerContent = '';
  473. if (this.currentAnswer.questionType === 0) {
  474. // 开放题直接使用文本答案
  475. answerContent = this.currentAnswer.answer;
  476. } else if (this.currentAnswer.questionType === 1) {
  477. // 单选题,获取选项ID而不是索引
  478. const selectedIndex = this.currentAnswer.answer;
  479. const selectedOption = this.currentQuestion.options[selectedIndex];
  480. // 使用选项的ID或其他唯一标识符
  481. answerContent = selectedOption.id ? selectedOption.id.toString() : selectedIndex.toString();
  482. } else if (this.currentAnswer.questionType === 2) {
  483. // 多选题,将选项ID数组转为逗号分隔的字符串
  484. const selectedIndices = this.currentAnswer.answer;
  485. const selectedOptionIds = selectedIndices.map(index => {
  486. const option = this.currentQuestion.options[index];
  487. // 使用选项的ID或其他唯一标识符
  488. return option.id ? option.id : index;
  489. });
  490. answerContent = selectedOptionIds.join(',');
  491. }
  492. const submitData = {
  493. job_id: JSON.parse(uni.getStorageSync('selectedJob')).id,
  494. applicant_id: JSON.parse(uni.getStorageSync('userInfo')).id,//uni.getStorageSync('appId'), // 或者使用其他合适的ID
  495. question_id: this.currentAnswer.questionId,
  496. answer_content: answerContent,
  497. answer_duration: this.currentAnswer.answerDuration || 0,
  498. // 如果需要tenant_id,请在这里添加
  499. tenant_id: 1
  500. };
  501. console.log('提交数据:', submitData);
  502. // 调用API提交答案
  503. const res = await this.$http.post(`${apiBaseUrl}/api/job/submit_answer`, submitData);
  504. console.log('提交答案响应:', res);
  505. // 隐藏加载提示
  506. uni.hideLoading();
  507. return res;
  508. } catch (error) {
  509. console.error('提交答案失败:', error);
  510. // 隐藏加载提示
  511. uni.hideLoading();
  512. // 显示错误提示
  513. uni.showToast({
  514. title: '提交答案失败,请重试',
  515. icon: 'none'
  516. });
  517. throw error;
  518. }
  519. },
  520. // 修改 goToNextQuestion 方法,添加 async 关键字
  521. async goToNextQuestion() {
  522. // 重置状态
  523. this.showResult = false;
  524. this.selectedOption = null;
  525. this.selectedOptions = [];
  526. this.openQuestionAnswer = ''; // 重置开放问题答案
  527. // 前往下一题
  528. this.currentQuestionIndex++;
  529. // 如果已经有下一题的详情,直接使用
  530. if (this.questions[this.currentQuestionIndex]) {
  531. // 更新进度
  532. this.progressWidth = (this.currentQuestionIndex + 1) / this.questions.length * 100;
  533. // 重置计时器
  534. this.resetTimer();
  535. // 播放AI介绍下一题
  536. this.playAiSpeaking();
  537. setTimeout(() => {
  538. this.pauseAiSpeaking();
  539. }, 2000);
  540. return;
  541. }
  542. // 否则请求下一题详情
  543. try {
  544. this.loading = true;
  545. // 假设您有一个获取单个题目详情的API
  546. // const res = await getQuestionDetail({
  547. // interviewId: this.interviewId,
  548. // questionIndex: this.currentQuestionIndex
  549. // });
  550. // 模拟API调用
  551. await new Promise(resolve => setTimeout(resolve, 1000));
  552. const res = { /* 模拟的题目数据 */ };
  553. // 处理题目数据
  554. if (res) {
  555. const formattedQuestion = {
  556. id: res.id || this.currentQuestionIndex + 1,
  557. text: res.question || '未知问题',
  558. options: res.options || [],
  559. correctAnswer: res.correctAnswer || 0,
  560. isImportant: res.is_system || false,
  561. explanation: res.explanation || '',
  562. questionType: res.question_form || 1,
  563. questionTypeName: res.question_form_name || '单选题',
  564. correctAnswers: res.correct_answers || [],
  565. difficulty: res.difficulty || 1,
  566. difficultyName: res.difficulty_name || '初级'
  567. };
  568. // 添加到问题列表
  569. this.questions.push(formattedQuestion);
  570. }
  571. } catch (error) {
  572. console.error('获取题目详情失败:', error);
  573. uni.showToast({
  574. title: '获取题目失败,请重试',
  575. icon: 'none'
  576. });
  577. // 出错时回到上一题
  578. this.currentQuestionIndex--;
  579. } finally {
  580. this.loading = false;
  581. // 更新进度
  582. this.progressWidth = (this.currentQuestionIndex + 1) / this.questions.length * 100;
  583. // 重置计时器
  584. this.resetTimer();
  585. }
  586. },
  587. toggleSettings() {
  588. // 打开设置面板
  589. uni.showToast({
  590. title: '设置功能开发中',
  591. icon: 'none'
  592. });
  593. },
  594. back(target) {
  595. // 清除计时器
  596. if (this.timerInterval) {
  597. clearInterval(this.timerInterval);
  598. }
  599. // 如果指定了跳转目标
  600. if (target) {
  601. uni.navigateTo({
  602. url: target
  603. });
  604. return;
  605. }
  606. // 否则尝试返回上一页,如果失败则跳转到首页
  607. try {
  608. const pages = getCurrentPages();
  609. if (pages.length > 1) {
  610. uni.reLaunch({
  611. url: '/pages/index/index'
  612. });
  613. } else {
  614. // 如果当前是第一页,则跳转到首页
  615. uni.reLaunch({
  616. url: '/pages/index/index'
  617. });
  618. }
  619. } catch (e) {
  620. console.error('导航错误:', e);
  621. // 出错时也跳转到首页
  622. uni.reLaunch({
  623. url: '/pages/index/index'
  624. });
  625. }
  626. },
  627. error(e) {
  628. // 相机错误事件
  629. console.error(e.detail);
  630. uni.showToast({
  631. title: '相机启动失败,请检查权限设置',
  632. icon: 'none'
  633. });
  634. },
  635. playAiSpeaking() {
  636. if (this.useVideo && this.aiVideoContext) {
  637. this.aiVideoContext.play();
  638. }
  639. // 触发数字人说话动画
  640. if (this.digitalHumanUrl) {
  641. // 假设当前问题的文本作为数字人要说的内容
  642. const speakText = this.currentQuestion ? this.currentQuestion.text : '';
  643. this.interactWithDigitalHuman(speakText);
  644. }
  645. },
  646. pauseAiSpeaking() {
  647. if (this.useVideo && this.aiVideoContext) {
  648. this.aiVideoContext.pause();
  649. }
  650. },
  651. // 修改 restartTest 方法,添加可选的跳转目标
  652. restartTest(target) {
  653. this.currentQuestionIndex = 0;
  654. this.score = 0;
  655. this.showEndModal = false;
  656. this.showResult = false;
  657. this.selectedOption = null;
  658. this.selectedOptions = [];
  659. this.resetTimer();
  660. // 如果指定了跳转目标
  661. if (target) {
  662. uni.navigateTo({
  663. url: target
  664. });
  665. }
  666. },
  667. // 在methods中添加测试方法
  668. testEndScreen() {
  669. this.interviewCompleted = true;
  670. this.showEndModal = false;
  671. },
  672. // 初始化数字人
  673. initDigitalHuman() {
  674. // 移除不可用的示例URL
  675. // this.digitalHumanUrl = 'https://your-digital-human-service.com/avatar?id=123';
  676. // 暂时不使用数字人服务,直接使用本地图片
  677. this.digitalHumanUrl = '';
  678. // 如果您有实际可用的数字人服务,可以在这里设置
  679. // 例如:this.digitalHumanUrl = 'https://your-actual-service.com/avatar';
  680. // 或者使用本地HTML文件(如果有的话)
  681. // this.digitalHumanUrl = '/hybrid/html/digital-human.html';
  682. },
  683. // 与数字人交互的方法
  684. interactWithDigitalHuman(message) {
  685. // 如果数字人是通过web-view加载的,可以使用postMessage与其通信
  686. const webview = this.$mp.page.$getAppWebview().children()[0];
  687. if (webview) {
  688. webview.evalJS(`receiveMessage('${message}')`);
  689. }
  690. },
  691. // 添加 navigateToInterview 方法
  692. navigateToInterview() {
  693. // 关闭模态框
  694. this.showEndModal = false;
  695. // 跳转到interview页面
  696. uni.navigateTo({
  697. url: '/pages/interview/interview',
  698. success: () => {
  699. console.log('成功跳转到interview页面');
  700. },
  701. fail: (err) => {
  702. console.error('跳转失败:', err);
  703. uni.showToast({
  704. title: '跳转失败,请手动返回首页',
  705. icon: 'none'
  706. });
  707. }
  708. });
  709. },
  710. // 添加 handleCameraError 方法
  711. handleCameraError(e) {
  712. console.error('相机错误:', e.detail);
  713. uni.showToast({
  714. title: '相机启动失败,请检查权限设置',
  715. icon: 'none'
  716. });
  717. },
  718. },
  719. // 添加生命周期钩子,确保在组件销毁时清除计时器
  720. beforeDestroy() {
  721. if (this.timerInterval) {
  722. clearInterval(this.timerInterval);
  723. }
  724. }
  725. }
  726. </script>
  727. <style>
  728. .camera-container {
  729. position: relative;
  730. width: 100%;
  731. height: 100vh;
  732. background-color: #ffffff;
  733. display: flex;
  734. flex-direction: column;
  735. }
  736. .header {
  737. height: 90rpx;
  738. /* background-color: #000000; */
  739. display: flex;
  740. align-items: center;
  741. padding: 0 30rpx;
  742. /* color: #ffffff; */
  743. }
  744. .back-button {
  745. width: 60rpx;
  746. height: 60rpx;
  747. display: flex;
  748. align-items: center;
  749. justify-content: center;
  750. }
  751. .title {
  752. flex: 1;
  753. text-align: center;
  754. font-size: 32rpx;
  755. font-weight: bold;
  756. }
  757. .controls {
  758. display: flex;
  759. gap: 30rpx;
  760. }
  761. .iconfont {
  762. font-size: 40rpx;
  763. }
  764. .content {
  765. flex: 1;
  766. display: flex;
  767. flex-direction: column;
  768. position: relative;
  769. padding: 50rpx 20rpx 0;
  770. }
  771. .progress-container {
  772. display: flex;
  773. flex-direction: column;
  774. justify-content: space-between;
  775. margin-bottom: 20rpx;
  776. }
  777. .progress-step {
  778. display: flex;
  779. align-items: center;
  780. margin-bottom: 10rpx;
  781. }
  782. .step-circle {
  783. width: 40rpx;
  784. height: 40rpx;
  785. border-radius: 50%;
  786. background-color: #e0e0e0;
  787. display: flex;
  788. align-items: center;
  789. justify-content: center;
  790. font-size: 24rpx;
  791. margin-right: 10rpx;
  792. }
  793. .step-circle.active {
  794. background-color: #ff9500;
  795. color: #ffffff;
  796. }
  797. .step-text {
  798. font-size: 24rpx;
  799. color: #666666;
  800. }
  801. .progress-bar {
  802. height: 10rpx;
  803. background-color: #e0e0e0;
  804. border-radius: 5rpx;
  805. overflow: hidden;
  806. margin-bottom: 20rpx;
  807. }
  808. .progress-fill {
  809. height: 100%;
  810. background-color: #00c853;
  811. transition: width 0.3s;
  812. }
  813. .interview-content {
  814. flex: 1;
  815. display: flex;
  816. flex-direction: column;
  817. }
  818. .video-area {
  819. display: flex;
  820. /* flex-direction: column; */
  821. justify-content: space-between;
  822. height: auto;
  823. margin-bottom: 20rpx;
  824. }
  825. .video-camera {
  826. display: flex;
  827. flex-direction: row;
  828. align-items: center;
  829. justify-content: space-between;
  830. width: 65%;
  831. }
  832. .interviewer {
  833. background-color: #f5f5f5;
  834. display: flex;
  835. align-items: center;
  836. justify-content: center;
  837. position: relative;
  838. width: 48% !important;
  839. height: 300rpx;
  840. }
  841. .interviewer-video {
  842. width: 100%;
  843. height: 100%;
  844. object-fit: cover;
  845. }
  846. .interviewer-avatar {
  847. width: 80%;
  848. height: 80%;
  849. object-fit: contain;
  850. }
  851. .user-camera-container {
  852. width: 48%;
  853. height: 300rpx;
  854. border-radius: 10rpx;
  855. overflow: hidden;
  856. border: 4rpx solid #ffffff;
  857. box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.2);
  858. }
  859. .question-area {
  860. flex: 1;
  861. display: flex;
  862. flex-direction: column;
  863. }
  864. .question-number {
  865. font-size: 28rpx;
  866. color: #666;
  867. margin-bottom: 20rpx;
  868. }
  869. .question-importance {
  870. background-color: #f9f9f9;
  871. padding: 20rpx;
  872. border-radius: 10rpx;
  873. margin-bottom: 20rpx;
  874. font-size: 28rpx;
  875. color: #333;
  876. }
  877. .question-importance text {
  878. color: #ff0000;
  879. margin-right: 10rpx;
  880. }
  881. .options {
  882. display: flex;
  883. flex-direction: column;
  884. gap: 20rpx;
  885. margin-bottom: 30rpx;
  886. }
  887. .option-item {
  888. padding: 20rpx;
  889. background-color: #f5f5f5;
  890. border-radius: 10rpx;
  891. font-size: 28rpx;
  892. transition: all 0.3s;
  893. border: 1px solid #e0e0e0;
  894. display: flex;
  895. align-items: center;
  896. }
  897. .option-selected {
  898. background-color: #e6f7ff;
  899. border: 1px solid #1890ff;
  900. }
  901. .option-correct {
  902. background-color: #d4f7d4;
  903. border: 1px solid #52c41a;
  904. }
  905. .option-wrong {
  906. background-color: #fff1f0;
  907. border: 1px solid #ff4d4f;
  908. }
  909. .option-text {
  910. color: #333;
  911. }
  912. .timer-container {
  913. text-align: right;
  914. margin: 20rpx 0;
  915. }
  916. .timer-text {
  917. font-size: 24rpx;
  918. color: #666;
  919. background-color: #f0f0f0;
  920. padding: 6rpx 12rpx;
  921. border-radius: 6rpx;
  922. }
  923. .bottom-button-container {
  924. position: fixed;
  925. bottom: 30px;
  926. left:7%;
  927. right: 0;
  928. background-color: #ffffff;
  929. /* border-top: 1px solid #e0e0e0; */
  930. padding: 20rpx;
  931. display: flex;
  932. justify-content: center;
  933. }
  934. .bottom-button-wrapper {
  935. display: flex;
  936. width: 100%;
  937. justify-content: space-between;
  938. align-items: center;
  939. }
  940. .timer-display {
  941. font-size: 28rpx;
  942. color: #666;
  943. padding: 10rpx 20rpx;
  944. border-radius: 6rpx;
  945. }
  946. .next-button {
  947. background-color: #0039b3;
  948. color: #ffffff;
  949. border-radius: 10rpx;
  950. font-size: 30rpx;
  951. height: 80rpx;
  952. line-height: 80rpx;
  953. width: 90%;
  954. text-align: center;
  955. margin: 0;
  956. }
  957. .footer {
  958. height: 100rpx;
  959. background-color: #000000;
  960. display: flex;
  961. justify-content: space-between;
  962. align-items: center;
  963. padding: 0 30rpx;
  964. color: #ffffff;
  965. }
  966. .timer {
  967. font-size: 28rpx;
  968. }
  969. .next-step {
  970. font-size: 28rpx;
  971. }
  972. .interview-end-modal {
  973. position: fixed;
  974. top: 0;
  975. left: 0;
  976. right: 0;
  977. bottom: 0;
  978. background-color: rgba(0, 0, 0, 0.5);
  979. display: flex;
  980. align-items: center;
  981. justify-content: center;
  982. z-index: 999;
  983. }
  984. .modal-content {
  985. width: 80%;
  986. background-color: #ffffff;
  987. border-radius: 20rpx;
  988. padding: 40rpx;
  989. display: flex;
  990. flex-direction: column;
  991. align-items: center;
  992. }
  993. .modal-title {
  994. font-size: 36rpx;
  995. font-weight: bold;
  996. margin-bottom: 30rpx;
  997. }
  998. .score-display {
  999. font-size: 48rpx;
  1000. font-weight: bold;
  1001. color: #0039b3;
  1002. margin: 20rpx 0;
  1003. }
  1004. .modal-message {
  1005. font-size: 28rpx;
  1006. color: #666;
  1007. text-align: center;
  1008. margin-bottom: 40rpx;
  1009. }
  1010. .modal-buttons {
  1011. display: flex;
  1012. justify-content: space-between;
  1013. width: 100%;
  1014. margin-top: 20rpx;
  1015. }
  1016. .modal-button {
  1017. width: 45%;
  1018. }
  1019. @keyframes speaking {
  1020. 0% {
  1021. opacity: 0.8;
  1022. }
  1023. 50% {
  1024. opacity: 1;
  1025. }
  1026. 100% {
  1027. opacity: 0.8;
  1028. }
  1029. }
  1030. .speaking {
  1031. animation: speaking 1.5s infinite;
  1032. }
  1033. .digital-avatar {
  1034. width: 120rpx;
  1035. height: 120rpx;
  1036. z-index: 10;
  1037. overflow: hidden;
  1038. border-radius: 20rpx;
  1039. }
  1040. .camera {
  1041. width: 120rpx;
  1042. height: 120rpx;
  1043. border-radius: 20rpx;
  1044. border: 2rpx solid #e0e0e0;
  1045. }
  1046. .avatar-image {
  1047. width: 120rpx;
  1048. height: 120rpx;
  1049. border-radius: 20rpx;
  1050. border: 2rpx solid #e0e0e0;
  1051. }
  1052. .digital-human-webview {
  1053. width: 120rpx;
  1054. height: 120rpx;
  1055. border-radius: 20rpx;
  1056. border: 2rpx solid #e0e0e0;
  1057. }
  1058. .interview-complete-screen {
  1059. position: fixed;
  1060. top: 0;
  1061. left: 0;
  1062. right: 0;
  1063. bottom: 0;
  1064. background-color: #f0f5ff;
  1065. display: flex;
  1066. flex-direction: column;
  1067. align-items: center;
  1068. justify-content: center;
  1069. z-index: 1000;
  1070. padding: 40rpx;
  1071. }
  1072. .digital-avatar-large {
  1073. width: 200rpx;
  1074. height: 200rpx;
  1075. margin-bottom: 60rpx;
  1076. }
  1077. .avatar-image-large {
  1078. width: 100%;
  1079. height: 100%;
  1080. border-radius: 30rpx;
  1081. }
  1082. .complete-message {
  1083. font-size: 40rpx;
  1084. font-weight: bold;
  1085. color: #333;
  1086. margin-bottom: 30rpx;
  1087. }
  1088. .complete-description {
  1089. font-size: 28rpx;
  1090. color: #666;
  1091. text-align: center;
  1092. margin-bottom: 80rpx;
  1093. line-height: 1.5;
  1094. }
  1095. .complete-button {
  1096. background-color: #0039b3;
  1097. color: #ffffff;
  1098. border-radius: 10rpx;
  1099. font-size: 32rpx;
  1100. padding: 20rpx 60rpx;
  1101. margin-top: 40rpx;
  1102. }
  1103. /* 添加加载状态样式 */
  1104. .loading-container {
  1105. position: absolute;
  1106. top: 0;
  1107. left: 0;
  1108. right: 0;
  1109. bottom: 0;
  1110. display: flex;
  1111. flex-direction: column;
  1112. align-items: center;
  1113. justify-content: center;
  1114. background-color: rgba(255, 255, 255, 0.9);
  1115. z-index: 100;
  1116. }
  1117. .loading-text {
  1118. margin-top: 20rpx;
  1119. font-size: 28rpx;
  1120. color: #666;
  1121. }
  1122. .error-container {
  1123. text-align: center;
  1124. }
  1125. .error-message {
  1126. font-size: 28rpx;
  1127. color: #ff4d4f;
  1128. margin-bottom: 30rpx;
  1129. }
  1130. .retry-button {
  1131. background-color: #0039b3;
  1132. color: #ffffff;
  1133. border-radius: 10rpx;
  1134. font-size: 28rpx;
  1135. padding: 10rpx 30rpx;
  1136. }
  1137. .question-type {
  1138. display: inline-block;
  1139. background-color: #0039b3;
  1140. color: white;
  1141. font-size: 24rpx;
  1142. padding: 4rpx 10rpx;
  1143. border-radius: 6rpx;
  1144. margin-right: 10rpx;
  1145. }
  1146. /* 新增的开放问题样式 */
  1147. .open-question-container {
  1148. display: flex;
  1149. flex-direction: column;
  1150. width: 100%;
  1151. margin-bottom: 20rpx;
  1152. }
  1153. .open-question-input {
  1154. width: 100%;
  1155. height: 300rpx;
  1156. background-color: #f9f9f9;
  1157. border: 1px solid #e0e0e0;
  1158. border-radius: 10rpx;
  1159. padding: 20rpx;
  1160. font-size: 28rpx;
  1161. color: #333;
  1162. }
  1163. .word-count {
  1164. text-align: right;
  1165. font-size: 24rpx;
  1166. color: #999;
  1167. margin-top: 10rpx;
  1168. }
  1169. .option-prefix {
  1170. margin-right: 10rpx;
  1171. font-weight: bold;
  1172. color: #333;
  1173. min-width: 30rpx;
  1174. }
  1175. </style>