identity-verify.vue 65 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133
  1. <template>
  2. <div class="identity-verify-container">
  3. <div class="digital-human-container">
  4. <!-- AI数字人视频/图像显示区域 -->
  5. <div class="digital-human-video">
  6. <!-- <image v-if="!videoPlaying" src="/static/images/digital-human-placeholder.jpg" mode="aspectFit"></image> -->
  7. <video
  8. :src="videoUrl"
  9. id="myVideo"
  10. ref="videoPlayer"
  11. autoplay
  12. playsinline
  13. disablePictureInPicture
  14. controlsList="nodownload nofullscreen noremoteplayback"
  15. class="video-player"
  16. :controls="false"
  17. @error="handleVideoError"
  18. @ended="handleVideoEnded"
  19. @timeupdate="handleTimeUpdate">
  20. </video>
  21. <!-- 添加字幕覆盖层 -->
  22. <div class="subtitle-overlay" v-if="currentSubtitle">
  23. {{ currentSubtitle }}
  24. </div>
  25. <!-- 添加答题按钮 -->
  26. <div class="answer-button-container" v-if="showAnswerButton">
  27. <button class="answer-button" @click="handleAnswerButtonClick">
  28. 开始面试
  29. </button>
  30. </div>
  31. </div>
  32. <!-- 用户摄像头视频显示区域 -->
  33. <div class="user-camera-container">
  34. <!-- 在小程序环境中使用camera组件 -->
  35. <camera v-if="useMiniProgramCameraComponent"
  36. device-position="front"
  37. flash="off"
  38. class="user-camera-video"
  39. @error="handleCameraError"
  40. :record-audio="true"
  41. frame-size="medium"
  42. resolution="medium">
  43. </camera>
  44. <!-- 在H5/App环境中使用video元素 -->
  45. <video v-else
  46. id="userCamera"
  47. ref="userCameraVideo"
  48. autoplay
  49. playsinline
  50. muted
  51. class="user-camera-video"
  52. :controls="false">
  53. </video>
  54. </div>
  55. <!-- 字幕/文本覆盖区域 -->
  56. <!-- <div class="subtitle-overlay" v-if="assistantResponse">
  57. {{ assistantResponse }}
  58. </div> -->
  59. </div>
  60. <!-- 加载状态 -->
  61. <div v-if="loading" class="loading">加载中...</div>
  62. <!-- 控制面板(可选,可以隐藏) -->
  63. <!-- 响应数据(可以设为隐藏,仅用于调试) -->
  64. <div v-if="showDebugInfo" class="response-container">
  65. <div v-if="assistantResponse" class="response-item">
  66. <div class="response-content">
  67. <span>助手回复: {{ assistantResponse }}</span>
  68. </div>
  69. </div>
  70. <div v-if="audioTranscript" class="response-item">
  71. <div class="response-content">
  72. <span>音频转写: {{ audioTranscript }}</span>
  73. </div>
  74. </div>
  75. <div v-for="(item, index) in processedResponses" :key="index" class="response-item">
  76. <div class="response-content">
  77. <span v-if="item.role">角色: {{ item.role }}</span>
  78. <span v-if="item.transcript">文本: {{ item.transcript }}</span>
  79. </div>
  80. </div>
  81. </div>
  82. <!-- 添加停止录制按钮 -->
  83. <div class="stop-recording-button-container" v-if="showStopRecordingButton">
  84. <button class="stop-recording-button" @click="stopRecordingAnswer">
  85. 完成作答
  86. </button>
  87. </div>
  88. <!-- 添加录制指示器 -->
  89. <div class="recording-indicator" v-if="isRecording">
  90. <div class="recording-dot"></div>
  91. <span class="recording-text">正在录制...</span>
  92. </div>
  93. <!-- 添加开始回答按钮 -->
  94. <div class="start-recording-button-container" v-if="showStartRecordingButton">
  95. <button class="start-recording-button" @click="handleStartRecordingClick">
  96. 开始作答
  97. </button>
  98. </div>
  99. <!-- 添加重试上传按钮 -->
  100. <div class="retry-button-container" v-if="showRetryButton">
  101. <button class="retry-button" @click="retryVideoUpload">
  102. 重新上传
  103. </button>
  104. </div>
  105. </div>
  106. </template>
  107. <script>
  108. import { apiBaseUrl } from '@/common/config.js';
  109. export default {
  110. name: 'IdentityVerify',
  111. data() {
  112. return {
  113. loading: false,
  114. responses: [],
  115. processedResponses: [],
  116. assistantResponse: '',
  117. audioTranscript: '',
  118. videoPlaying: false,
  119. showDebugInfo: false, // 设置为true可以显示调试信息
  120. videoUrl: 'http://121.36.251.245:9000/minlong/9c139c99-a613-49f2-986b-8a3ac20a9b1b.mp4', // 用于存储AI数字人视频URL
  121. showReplayButton: false,
  122. cameraStream: null, // 存储摄像头流
  123. cameraError: null, // 存储摄像头错误信息
  124. useMiniProgramCameraComponent: false, // 添加小程序相机组件标志
  125. cameraContext: null, // 添加相机上下文
  126. currentSubtitle: '',
  127. subtitles: [
  128. {
  129. startTime: 0, // 开始时间(秒)
  130. endTime: 5, // 结束时间(秒)
  131. text: '你好,我是本次面试的面试官,欢迎参加本公司的线上面试!'
  132. },
  133. {
  134. startTime: 5,
  135. endTime: 13,
  136. text: '面试预计需要15分钟,请你提前安排在网络良好、光线亮度合适、且相对安静的环境参加这次面试'
  137. },
  138. {
  139. startTime: 13,
  140. endTime: 20,
  141. text: '以免影响本次面试的结果。如果你在面试过程中遇到问题,请与我们的招聘人员联系。'
  142. }
  143. ],
  144. secondVideoSubtitles: [
  145. {
  146. startTime: 0,
  147. endTime: 10,
  148. text: '请结合您的基本信息与过往履历进行简单的自我介绍,并讲一讲您有哪些优势胜任本岗位:'
  149. }
  150. ],
  151. thirdVideoSubtitles: [
  152. {
  153. startTime: 0,
  154. endTime: 3,
  155. text: '在工作中,你如何确保个人防护装备的正确使用?'
  156. }
  157. ],
  158. fourthVideoSubtitles: [
  159. {
  160. startTime: 0,
  161. endTime: 3,
  162. text: '描述一次你与团队合作改善生产流程的经历。'
  163. }
  164. ],
  165. fifthVideoSubtitles: [
  166. {
  167. startTime: 0,
  168. endTime: 6,
  169. text: '你在团队合作中曾遇到过哪些挑战?如何解决团队内部的分歧?'
  170. }
  171. ],
  172. sixthVideoSubtitles: [
  173. {
  174. startTime: 0,
  175. endTime: 5,
  176. text: '您已完成本次面试全部题目,请问您对于这个岗位还有什么想要了解的吗?'
  177. }
  178. ],
  179. showAnswerButton: false, // 控制答题按钮显示
  180. currentVideoIndex: 0, // 当前播放的视频索引
  181. videoList: [
  182. 'http://121.36.251.245:9000/minlong/9c139c99-a613-49f2-986b-8a3ac20a9b1b.mp4', // 第一段视频
  183. 'http://121.36.251.245:9000/minlong/15467aca-2c1e-4d40-bebc-11fc984f0a24.mp4', // 第二段视频
  184. 'http://121.36.251.245:9000/minlong/a96f295c-ecba-4b08-b627-0ed0dc72d727.mp4', // 第三段视频
  185. 'http://121.36.251.245:9000/minlong/e152eb38-7e03-4bb0-8b8d-73d51342d2aa.mp4',
  186. 'http://121.36.251.245:9000/minlong/6381eec3-1373-400f-aa8c-e229b60522ea.mp4',
  187. 'http://121.36.251.245:9000/minlong/2a11fe78-38d5-4af3-91c8-a080feb122e0.mp4'//结束
  188. ],
  189. isRecording: false,
  190. recordingTimer: null,
  191. showStopRecordingButton: false,
  192. mediaRecorder: null,
  193. recordedChunks: [],
  194. recorder: null,
  195. lastUploadedVideoUrl: '',
  196. showStartRecordingButton: false,
  197. showRetryButton: false, // 控制重试按钮显示
  198. lastVideoToRetry: null, // 存储上次失败的视频URL,用于重试
  199. }
  200. },
  201. mounted() {
  202. /* this.fetchData() */
  203. this.playDigitalHumanVideo();
  204. this.checkAudioPermission();
  205. this.initCamera();
  206. // 添加音频测试
  207. setTimeout(() => {
  208. if (this.cameraStream && !this.useMiniProgramCameraComponent) {
  209. this.testAudioInput();
  210. }
  211. }, 3000);
  212. },
  213. beforeDestroy() {
  214. // 组件销毁前停止摄像头
  215. this.stopUserCamera();
  216. },
  217. methods: {
  218. // 初始化相机
  219. async initCamera() {
  220. // 检查平台
  221. const systemInfo = uni.getSystemInfoSync();
  222. // 判断是否在小程序环境中
  223. const isMiniProgram = systemInfo.uniPlatform === 'mp-weixin' ||
  224. systemInfo.uniPlatform === 'mp-alipay' ||
  225. systemInfo.uniPlatform === 'mp-baidu';
  226. if (isMiniProgram) {
  227. // 小程序环境使用小程序的相机API
  228. this.useMiniProgramCameraComponent = true;
  229. // 创建相机上下文
  230. this.cameraContext = uni.createCameraContext();
  231. // 确保已获取录音权限
  232. uni.getSetting({
  233. success: (res) => {
  234. if (!res.authSetting['scope.record']) {
  235. uni.authorize({
  236. scope: 'scope.record',
  237. success: () => {
  238. console.log('录音权限已获取');
  239. },
  240. fail: (err) => {
  241. console.error('录音权限获取失败:', err);
  242. this.showPermissionDialog('录音');
  243. }
  244. });
  245. }
  246. if (!res.authSetting['scope.camera']) {
  247. uni.authorize({
  248. scope: 'scope.camera',
  249. success: () => {
  250. console.log('相机权限已获取');
  251. },
  252. fail: (err) => {
  253. console.error('相机权限获取失败:', err);
  254. this.showPermissionDialog('相机');
  255. }
  256. });
  257. }
  258. }
  259. });
  260. } else {
  261. // H5/App环境使用浏览器的API
  262. try {
  263. // 请求摄像头和麦克风权限并获取媒体流,添加更多约束
  264. const constraints = {
  265. audio: {
  266. echoCancellation: true,
  267. noiseSuppression: true,
  268. autoGainControl: true
  269. },
  270. video: {
  271. width: { ideal: 640, max: 1280 }, // 控制视频宽度
  272. height: { ideal: 480, max: 720 }, // 控制视频高度
  273. frameRate: { ideal: 15, max: 24 }, // 控制帧率
  274. facingMode: 'user'
  275. }
  276. };
  277. const stream = await navigator.mediaDevices.getUserMedia(constraints);
  278. // 保存流以便后续使用
  279. this.cameraStream = stream;
  280. // 检查音频轨道
  281. const audioTracks = stream.getAudioTracks();
  282. console.log('音频轨道数量:', audioTracks.length);
  283. if (audioTracks.length > 0) {
  284. console.log('音频轨道已获取:', audioTracks[0].label);
  285. // 确保音频轨道已启用
  286. audioTracks[0].enabled = true;
  287. } else {
  288. console.warn('未检测到音频轨道,尝试单独获取音频');
  289. this.tryGetAudioOnly();
  290. }
  291. // 将流设置到视频元素
  292. const videoElement = this.$refs.userCameraVideo;
  293. if (videoElement) {
  294. videoElement.srcObject = stream;
  295. videoElement.muted = true; // 避免回声,但仍然录制声音
  296. }
  297. } catch (error) {
  298. console.error('获取摄像头失败:', error);
  299. this.cameraError = error.message || '无法访问摄像头';
  300. // 显示错误提示
  301. uni.showToast({
  302. title: '无法访问摄像头,请检查权限设置',
  303. icon: 'none'
  304. });
  305. }
  306. }
  307. },
  308. // 停止用户摄像头
  309. stopUserCamera() {
  310. if (this.cameraStream) {
  311. // 停止所有轨道
  312. this.cameraStream.getTracks().forEach(track => {
  313. track.stop();
  314. });
  315. this.cameraStream = null;
  316. }
  317. },
  318. async fetchData() {
  319. this.loading = true
  320. this.assistantResponse = ''
  321. this.audioTranscript = ''
  322. this.processedResponses = []
  323. try {
  324. // 使用uni.request代替fetch
  325. const requestTask = uni.request({
  326. url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
  327. method: 'POST',
  328. header: {
  329. 'Content-Type': 'application/json',
  330. 'Authorization': 'Bearer sk-9e1ec73a7d97493b8613c63f06b6110c'
  331. },
  332. data: {
  333. "model": "qwen-omni-turbo",
  334. "messages": [
  335. {
  336. "role": "user",
  337. "content": [
  338. {
  339. "type": "input_audio",
  340. "input_audio": {
  341. "data": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250211/tixcef/cherry.wav",
  342. "format": "wav"
  343. }
  344. },
  345. {
  346. "type": "text",
  347. "text": "这段音频在说什么"
  348. }
  349. ]
  350. }
  351. ],
  352. "stream":true,
  353. "stream_options":{
  354. "include_usage":true
  355. },
  356. "modalities":["text","audio"],
  357. "audio":{"voice":"Cherry","format":"wav"}
  358. },
  359. success: (res) => {
  360. console.log('请求成功,响应数据:', res.data);
  361. // 检查响应数据是否包含多个JSON对象
  362. if (typeof res.data === 'string' && res.data.includes('data: {')) {
  363. // 处理包含多个JSON对象的情况
  364. const chunks = res.data.split('data: ').filter(chunk => chunk.trim() !== '');
  365. chunks.forEach(chunk => {
  366. this.handleStreamResponse(chunk);
  367. });
  368. } else {
  369. // 处理单个响应对象的情况
  370. this.handleStreamResponse(res.data);
  371. }
  372. // 模拟获取到数字人视频并播放
  373. this.playDigitalHumanVideo();
  374. },
  375. fail: (err) => {
  376. console.error('请求失败:', err);
  377. },
  378. complete: () => {
  379. this.loading = false;
  380. }
  381. });
  382. } catch (error) {
  383. console.error('获取数据失败:', error);
  384. this.loading = false;
  385. }
  386. },
  387. handleStreamResponse(data) {
  388. // 处理流式响应数据
  389. if (typeof data === 'string') {
  390. // 处理字符串格式的响应
  391. if (data === '[DONE]') return;
  392. try {
  393. // 移除可能存在的换行符和多余空格
  394. const cleanData = data.trim();
  395. // 检查是否是有效的JSON字符串
  396. if (cleanData.startsWith('{') && cleanData.endsWith('}')) {
  397. const jsonData = JSON.parse(cleanData);
  398. this.processStreamChunk(jsonData);
  399. }
  400. } catch (e) {
  401. console.error('解析JSON失败:', e, '原始数据:', data);
  402. }
  403. } else {
  404. // 处理对象格式的响应
  405. this.processStreamChunk(data);
  406. }
  407. },
  408. processStreamChunk(chunk) {
  409. if (chunk.choices && chunk.choices.length > 0) {
  410. const choice = chunk.choices[0];
  411. // 处理助手回复内容
  412. if (choice.delta && choice.delta.content) {
  413. this.assistantResponse += choice.delta.content;
  414. }
  415. // 处理音频转写内容
  416. if (choice.delta && choice.delta.audio && choice.delta.audio.transcript) {
  417. this.audioTranscript += choice.delta.audio.transcript;
  418. }
  419. // 处理角色和音频转写
  420. if (choice.delta) {
  421. const result = {};
  422. if (choice.delta.role) {
  423. result.role = choice.delta.role;
  424. }
  425. if (choice.delta.audio && choice.delta.audio.transcript) {
  426. result.transcript = choice.delta.audio.transcript;
  427. }
  428. if (Object.keys(result).length > 0) {
  429. this.processedResponses.push(result);
  430. }
  431. }
  432. }
  433. },
  434. processResponseData() {
  435. // 处理返回的数据
  436. this.processedResponses = this.responses.map(item => {
  437. const result = {}
  438. // 处理角色信息
  439. if (item.delta && item.delta.role) {
  440. result.role = item.delta.role
  441. }
  442. // 处理音频转写文本
  443. if (item.delta && item.delta.audio && item.delta.audio.transcript) {
  444. result.transcript = item.delta.audio.transcript
  445. }
  446. return result
  447. }).filter(item => Object.keys(item).length > 0)
  448. },
  449. // 播放数字人视频
  450. playDigitalHumanVideo() {
  451. // 设置第一个视频
  452. this.videoUrl = this.videoList[this.currentVideoIndex];
  453. this.videoPlaying = true;
  454. // 使用 uni.createVideoContext 来控制视频
  455. this.$nextTick(() => {
  456. const videoContext = uni.createVideoContext('myVideo', this);
  457. if (videoContext) {
  458. videoContext.play();
  459. // 设置超时检查,确认视频是否真的在播放
  460. setTimeout(() => {
  461. if (this.videoPlaying && this.$refs.videoPlayer) {
  462. console.log('视频应该正在播放');
  463. } else {
  464. console.log('视频可能未成功播放,尝试替代方案');
  465. this.tryAlternativeVideoPath();
  466. }
  467. }, 1000);
  468. } else {
  469. console.error('无法创建视频上下文');
  470. this.tryAlternativeVideoPath();
  471. }
  472. });
  473. },
  474. // 修改 tryAlternativeVideoPath 方法
  475. tryAlternativeVideoPath() {
  476. console.log('尝试使用替代路径');
  477. // 尝试不同的路径格式
  478. const alternativePaths = [
  479. './static/demo.mp4',
  480. '../static/demo.mp4',
  481. 'static/demo.mp4',
  482. '/static/demo.mp4',
  483. // 添加绝对路径
  484. `${window.location.origin}/static/demo.mp4`
  485. ];
  486. // 获取当前路径索引
  487. const currentPathIndex = alternativePaths.indexOf(this.videoUrl);
  488. const nextPathIndex = (currentPathIndex + 1) % alternativePaths.length;
  489. // 设置下一个路径
  490. this.videoUrl = alternativePaths[nextPathIndex];
  491. console.log('尝试新路径:', this.videoUrl);
  492. this.$nextTick(() => {
  493. const videoContext = uni.createVideoContext('myVideo', this);
  494. if (videoContext) {
  495. videoContext.stop();
  496. videoContext.play();
  497. // 检查是否成功播放
  498. setTimeout(() => {
  499. if (nextPathIndex === alternativePaths.length - 1 && !this.videoPlaying) {
  500. console.log('所有路径均失败,尝试使用uni.getVideoInfo检查视频');
  501. this.checkVideoWithAPI();
  502. }
  503. }, 1000);
  504. }
  505. });
  506. },
  507. // 添加新方法:使用uni API检查视频
  508. checkVideoWithAPI() {
  509. // 尝试使用uni.getVideoInfo API检查视频是否可用
  510. uni.getVideoInfo({
  511. src: '/static/demo.mp4',
  512. success: (res) => {
  513. console.log('视频信息获取成功:', res);
  514. // 如果能获取到视频信息,再次尝试播放
  515. this.videoUrl = '/static/demo.mp4';
  516. this.$nextTick(() => {
  517. const videoContext = uni.createVideoContext('myVideo', this);
  518. if (videoContext) {
  519. videoContext.play();
  520. }
  521. });
  522. },
  523. fail: (err) => {
  524. console.error('视频信息获取失败:', err);
  525. // 最后尝试使用uni.chooseVideo API
  526. this.fallbackToLocalVideo();
  527. }
  528. });
  529. },
  530. // 添加新方法:回退到本地视频
  531. fallbackToLocalVideo() {
  532. console.log('尝试使用本地视频资源');
  533. // 检查平台
  534. const platform = uni.getSystemInfoSync().platform;
  535. if (platform === 'android' || platform === 'ios') {
  536. // 移动端可以尝试使用本地资源
  537. this.videoUrl = platform === 'android' ? 'android.resource://package_name/raw/demo' : 'file:///assets/demo.mp4';
  538. this.$nextTick(() => {
  539. const videoContext = uni.createVideoContext('myVideo', this);
  540. if (videoContext) {
  541. videoContext.play();
  542. }
  543. });
  544. } else {
  545. // 最终回退到静态图片
  546. this.videoPlaying = false;
  547. uni.showToast({
  548. title: '视频加载失败,显示静态图片',
  549. icon: 'none'
  550. });
  551. }
  552. },
  553. // 修改 handleVideoError 方法
  554. handleVideoError(e) {
  555. console.error('视频加载错误:', e);
  556. // 记录更详细的错误信息
  557. if (e && e.detail) {
  558. console.error('详细错误信息:', e.detail);
  559. }
  560. // 检查视频文件是否存在
  561. uni.getFileInfo({
  562. filePath: this.videoUrl.startsWith('/') ? this.videoUrl.substring(1) : this.videoUrl,
  563. success: (res) => {
  564. console.log('文件存在,大小:', res.size);
  565. // 文件存在但播放失败,可能是格式问题
  566. this.tryDifferentFormat();
  567. },
  568. fail: (err) => {
  569. console.error('文件不存在或无法访问:', err);
  570. // 尝试不同路径
  571. this.tryAlternativeVideoPath();
  572. }
  573. });
  574. // 如果多次尝试后仍然失败,显示错误信息
  575. uni.showToast({
  576. title: '视频加载失败,请检查文件是否存在',
  577. icon: 'none',
  578. duration: 2000
  579. });
  580. },
  581. // 添加新方法:尝试不同格式
  582. tryDifferentFormat() {
  583. console.log('尝试不同的视频格式');
  584. // 尝试不同的视频格式
  585. const formats = [
  586. { ext: 'mp4', mime: 'video/mp4' },
  587. { ext: 'webm', mime: 'video/webm' },
  588. { ext: 'ogg', mime: 'video/ogg' },
  589. { ext: 'mov', mime: 'video/quicktime' }
  590. ];
  591. // 获取当前文件名(不含扩展名)
  592. const currentPath = this.videoUrl;
  593. const basePath = currentPath.substring(0, currentPath.lastIndexOf('.')) || '/static/demo';
  594. // 尝试下一个格式
  595. let nextFormat = formats.find(f => !currentPath.endsWith(f.ext));
  596. if (nextFormat) {
  597. this.videoUrl = `${basePath}.${nextFormat.ext}`;
  598. console.log('尝试新格式:', this.videoUrl);
  599. this.$nextTick(() => {
  600. const videoContext = uni.createVideoContext('myVideo', this);
  601. if (videoContext) {
  602. videoContext.stop();
  603. videoContext.play();
  604. }
  605. });
  606. } else {
  607. // 所有格式都尝试过了,使用内置资源
  608. this.useBuiltInResource();
  609. }
  610. },
  611. // 添加新方法:使用内置资源
  612. useBuiltInResource() {
  613. console.log('尝试使用内置资源');
  614. // 检查平台
  615. const platform = uni.getSystemInfoSync().platform;
  616. // 根据平台选择合适的视频源
  617. if (platform === 'windows') {
  618. // Windows平台特定处理
  619. // 尝试使用相对于应用根目录的路径
  620. const appRoot = process.env.UNI_INPUT_DIR || '';
  621. this.videoUrl = `./static/demo.mp4`;
  622. // 或者尝试使用file://协议
  623. // this.videoUrl = `file:///${appRoot.replace(/\\/g, '/')}/static/demo.mp4`;
  624. console.log('Windows平台尝试路径:', this.videoUrl);
  625. } else if (platform === 'android' || platform === 'ios') {
  626. // 移动端
  627. this.useNativeVideo();
  628. } else {
  629. // Web平台
  630. // 尝试使用完整URL
  631. const baseUrl = window.location.origin;
  632. this.videoUrl = `${baseUrl}/static/demo.mp4`;
  633. console.log('Web平台尝试URL:', this.videoUrl);
  634. }
  635. this.$nextTick(() => {
  636. const videoContext = uni.createVideoContext('myVideo', this);
  637. if (videoContext) {
  638. videoContext.play();
  639. }
  640. });
  641. },
  642. // 添加新方法:使用原生视频能力
  643. useNativeVideo() {
  644. console.log('尝试使用原生视频能力');
  645. // 在移动端,可以尝试使用原生视频播放器
  646. uni.chooseVideo({
  647. sourceType: ['album'],
  648. success: (res) => {
  649. this.videoUrl = res.tempFilePath;
  650. this.$nextTick(() => {
  651. const videoContext = uni.createVideoContext('myVideo', this);
  652. if (videoContext) {
  653. videoContext.play();
  654. }
  655. });
  656. },
  657. fail: () => {
  658. // 如果用户取消选择,回退到静态图片
  659. this.videoPlaying = false;
  660. uni.showToast({
  661. title: '无法加载视频,显示静态图片',
  662. icon: 'none'
  663. });
  664. }
  665. });
  666. },
  667. // 处理视频结束事件
  668. handleVideoEnded() {
  669. console.log('视频播放结束');
  670. this.videoPlaying = false;
  671. // 对于所有视频,都显示相应的按钮
  672. if (this.currentVideoIndex >= 1) {
  673. // 对于问题视频,显示"开始回答"按钮
  674. this.showStartRecordingButton = true;
  675. } else {
  676. // 对于介绍视频(第一个视频),显示"开始面试"按钮
  677. this.showAnswerButton = true;
  678. }
  679. },
  680. // 添加新方法:开始录制用户回答
  681. startRecordingAnswer() {
  682. console.log('开始录制用户回答');
  683. this.isRecording = true;
  684. // 显示录制中的提示
  685. // uni.showLoading({
  686. // title: '正在录制...',
  687. // mask: true
  688. // });
  689. // 根据平台选择不同的录制方法
  690. const systemInfo = uni.getSystemInfoSync();
  691. const isMiniProgram = systemInfo.uniPlatform && systemInfo.uniPlatform.startsWith('mp-');
  692. if (isMiniProgram) {
  693. // 小程序环境使用小程序的录制API
  694. this.startMiniProgramRecording();
  695. } else {
  696. // H5/App环境使用MediaRecorder API
  697. this.startBrowserRecording();
  698. }
  699. // 显示停止录制按钮
  700. this.showStopRecordingButton = true;
  701. },
  702. // 修改小程序环境下的录制方法
  703. startMiniProgramRecording() {
  704. if (!this.cameraContext) {
  705. this.cameraContext = uni.createCameraContext();
  706. }
  707. // 确保有录音权限
  708. uni.getSetting({
  709. success: (res) => {
  710. const hasRecordAuth = res.authSetting['scope.record'];
  711. const hasCameraAuth = res.authSetting['scope.camera'];
  712. if (!hasRecordAuth || !hasCameraAuth) {
  713. console.warn('缺少必要权限,请求权限');
  714. this.requestMiniProgramPermissions();
  715. return;
  716. }
  717. // 开始录像,添加质量控制参数
  718. const options = {
  719. timeout: 60000, // 最长录制60秒
  720. quality: 'medium', // 可选值:'low', 'medium', 'high'
  721. compressed: true, // 是否压缩视频
  722. success: () => {
  723. console.log('小程序录像开始成功');
  724. },
  725. fail: (err) => {
  726. console.error('小程序录像开始失败:', err);
  727. uni.showToast({
  728. title: '录制失败,请检查相机权限',
  729. icon: 'none'
  730. });
  731. this.proceedToNextQuestion();
  732. },
  733. timeoutCallback: () => {
  734. console.log('录制超时自动停止');
  735. this.stopRecordingAnswer();
  736. }
  737. };
  738. this.recorder = this.cameraContext.startRecord(options);
  739. }
  740. });
  741. },
  742. // 添加新方法:请求小程序权限
  743. requestMiniProgramPermissions() {
  744. // 请求录音权限
  745. uni.authorize({
  746. scope: 'scope.record',
  747. success: () => {
  748. console.log('录音权限已获取');
  749. // 请求相机权限
  750. uni.authorize({
  751. scope: 'scope.camera',
  752. success: () => {
  753. console.log('相机权限已获取');
  754. // 权限都获取到后,开始录制
  755. this.startMiniProgramRecording();
  756. },
  757. fail: (err) => {
  758. console.error('相机权限获取失败:', err);
  759. this.showPermissionDialog('相机');
  760. }
  761. });
  762. },
  763. fail: (err) => {
  764. console.error('录音权限获取失败:', err);
  765. this.showPermissionDialog('录音');
  766. }
  767. });
  768. },
  769. // 修改浏览器环境下的录制方法
  770. startBrowserRecording() {
  771. if (!this.cameraStream) {
  772. console.error('没有可用的摄像头流');
  773. uni.showToast({
  774. title: '录制失败,摄像头未就绪',
  775. icon: 'none'
  776. });
  777. this.proceedToNextQuestion();
  778. return;
  779. }
  780. try {
  781. // 检查流中是否包含音频轨道
  782. const hasAudio = this.cameraStream.getAudioTracks().length > 0;
  783. if (!hasAudio) {
  784. console.warn('警告:媒体流中没有音频轨道,尝试重新获取带音频的媒体流');
  785. // 尝试重新获取带音频的媒体流
  786. navigator.mediaDevices.getUserMedia({
  787. audio: {
  788. echoCancellation: true,
  789. noiseSuppression: true,
  790. autoGainControl: true
  791. },
  792. video: true
  793. })
  794. .then(newStream => {
  795. // 检查新流是否包含音频轨道
  796. const audioTracks = newStream.getAudioTracks();
  797. if (audioTracks.length > 0) {
  798. console.log('成功获取音频轨道:', audioTracks[0].label);
  799. // 合并视频和音频轨道
  800. const videoTrack = this.cameraStream.getVideoTracks()[0];
  801. const audioTrack = newStream.getAudioTracks()[0];
  802. // 创建新的媒体流,包含视频和音频轨道
  803. const combinedStream = new MediaStream();
  804. if (videoTrack) combinedStream.addTrack(videoTrack);
  805. if (audioTrack) combinedStream.addTrack(audioTrack);
  806. this.cameraStream = combinedStream;
  807. // 更新视频元素的源
  808. const videoElement = this.$refs.userCameraVideo;
  809. if (videoElement) {
  810. videoElement.srcObject = combinedStream;
  811. videoElement.muted = true; // 避免回声,但仍然录制声音
  812. }
  813. // 继续录制过程
  814. this.setupMediaRecorder(combinedStream);
  815. } else {
  816. console.warn('仍然无法获取音频轨道');
  817. // 使用现有流继续
  818. this.setupMediaRecorder(this.cameraStream);
  819. }
  820. })
  821. .catch(err => {
  822. console.error('获取音频失败:', err);
  823. // 使用现有流继续
  824. this.setupMediaRecorder(this.cameraStream);
  825. });
  826. } else {
  827. console.log('检测到音频轨道,直接使用');
  828. // 已有音频轨道,直接使用
  829. this.setupMediaRecorder(this.cameraStream);
  830. }
  831. } catch (error) {
  832. console.error('浏览器录制失败:', error);
  833. uni.showToast({
  834. title: '录制失败,浏览器可能不支持此功能',
  835. icon: 'none'
  836. });
  837. this.proceedToNextQuestion();
  838. }
  839. },
  840. // 修改 setupMediaRecorder 方法
  841. setupMediaRecorder(stream) {
  842. // 检查流中的轨道
  843. const videoTracks = stream.getVideoTracks();
  844. const audioTracks = stream.getAudioTracks();
  845. console.log('设置MediaRecorder - 视频轨道:', videoTracks.length, '音频轨道:', audioTracks.length);
  846. // 尝试使用支持度更广的编码格式
  847. let mimeType = '';
  848. const supportedTypes = [
  849. 'video/webm;codecs=vp9,opus',
  850. 'video/webm;codecs=vp8,opus',
  851. 'video/webm;codecs=h264,opus',
  852. 'video/mp4;codecs=h264,aac',
  853. 'video/webm',
  854. 'video/mp4'
  855. ];
  856. for (const type of supportedTypes) {
  857. if (MediaRecorder.isTypeSupported(type)) {
  858. mimeType = type;
  859. console.log('使用支持的MIME类型:', mimeType);
  860. break;
  861. }
  862. }
  863. // 创建MediaRecorder实例,设置较低的比特率以减小文件大小
  864. const options = {
  865. mimeType: mimeType || '',
  866. audioBitsPerSecond: 64000, // 降低音频比特率
  867. videoBitsPerSecond: 1000000, // 降低视频比特率到1Mbps
  868. };
  869. try {
  870. this.mediaRecorder = new MediaRecorder(stream, options);
  871. console.log('MediaRecorder创建成功,使用选项:', options);
  872. } catch (e) {
  873. console.warn('使用指定选项创建MediaRecorder失败,尝试使用默认选项');
  874. this.mediaRecorder = new MediaRecorder(stream);
  875. }
  876. // 存储录制的数据块
  877. this.recordedChunks = [];
  878. // 监听数据可用事件
  879. this.mediaRecorder.ondataavailable = (event) => {
  880. if (event.data && event.data.size > 0) {
  881. this.recordedChunks.push(event.data);
  882. console.log(`收到数据块: ${event.data.size} 字节`);
  883. }
  884. };
  885. // 监听录制停止事件
  886. this.mediaRecorder.onstop = async () => {
  887. console.log('MediaRecorder停止,数据块数量:', this.recordedChunks.length);
  888. if (this.recordedChunks.length === 0) {
  889. console.error('没有录制到数据');
  890. uni.showToast({
  891. title: '录制失败,未捕获到数据',
  892. icon: 'none'
  893. });
  894. this.proceedToNextQuestion();
  895. return;
  896. }
  897. // 创建Blob对象
  898. const mimeType = this.mediaRecorder.mimeType || 'video/webm';
  899. const blob = new Blob(this.recordedChunks, { type: mimeType });
  900. console.log('创建Blob,原始大小:', blob.size, '类型:', mimeType);
  901. // 显示压缩中提示
  902. uni.showLoading({
  903. title: '正在处理视频...',
  904. mask: true
  905. });
  906. try {
  907. // 压缩视频
  908. const compressedBlob = await this.compressVideo(blob);
  909. // 将Blob转换为File对象
  910. const fileName = `answer_${this.currentVideoIndex}_${Date.now()}.webm`;
  911. const file = new File([compressedBlob], fileName, { type: mimeType });
  912. // 隐藏压缩提示
  913. uni.hideLoading();
  914. // 上传文件
  915. this.uploadRecordedVideo(file);
  916. } catch (error) {
  917. console.error('视频处理失败:', error);
  918. uni.hideLoading();
  919. // 如果压缩失败,使用原始视频
  920. const fileName = `answer_${this.currentVideoIndex}_${Date.now()}.webm`;
  921. const file = new File([blob], fileName, { type: mimeType });
  922. this.uploadRecordedVideo(file);
  923. }
  924. };
  925. // 监听错误
  926. this.mediaRecorder.onerror = (event) => {
  927. console.error('MediaRecorder错误:', event.error);
  928. };
  929. // 开始录制
  930. try {
  931. this.mediaRecorder.start(1000); // 每秒触发一次dataavailable事件
  932. console.log('MediaRecorder开始录制');
  933. } catch (e) {
  934. console.error('开始录制失败:', e);
  935. }
  936. },
  937. // 添加新方法:停止录制用户回答
  938. stopRecordingAnswer() {
  939. console.log('停止录制用户回答');
  940. this.isRecording = false;
  941. // 清除定时器
  942. if (this.recordingTimer) {
  943. clearTimeout(this.recordingTimer);
  944. this.recordingTimer = null;
  945. }
  946. // 隐藏录制中提示
  947. uni.hideLoading();
  948. // 隐藏停止录制按钮
  949. this.showStopRecordingButton = false;
  950. // 根据平台选择不同的停止录制方法
  951. const systemInfo = uni.getSystemInfoSync();
  952. const isMiniProgram = systemInfo.uniPlatform && systemInfo.uniPlatform.startsWith('mp-');
  953. if (isMiniProgram) {
  954. // 小程序环境停止录制
  955. this.stopMiniProgramRecording();
  956. } else {
  957. // H5/App环境停止录制
  958. this.stopBrowserRecording();
  959. }
  960. },
  961. // 添加新方法:停止小程序录制
  962. stopMiniProgramRecording() {
  963. if (!this.cameraContext) {
  964. console.error('相机上下文不存在');
  965. this.proceedToNextQuestion();
  966. return;
  967. }
  968. this.cameraContext.stopRecord({
  969. success: (res) => {
  970. console.log('小程序录像停止成功:', res);
  971. // 获取临时文件路径
  972. const tempFilePath = res.tempVideoPath;
  973. // 上传视频
  974. this.uploadRecordedVideo(tempFilePath);
  975. },
  976. fail: (err) => {
  977. console.error('小程序录像停止失败:', err);
  978. uni.showToast({
  979. title: '录制失败',
  980. icon: 'none'
  981. });
  982. this.proceedToNextQuestion();
  983. }
  984. });
  985. },
  986. // 添加新方法:停止浏览器录制
  987. stopBrowserRecording() {
  988. if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
  989. this.mediaRecorder.stop();
  990. console.log('浏览器录制停止成功');
  991. } else {
  992. console.error('MediaRecorder不存在或已经停止');
  993. this.proceedToNextQuestion();
  994. }
  995. },
  996. // 修改上传录制的视频方法
  997. uploadRecordedVideo(fileOrPath) {
  998. console.log('准备上传视频:', typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.name);
  999. // 显示上传中提示
  1000. uni.showLoading({
  1001. title: '正在上传...',
  1002. mask: true
  1003. });
  1004. // 获取用户信息
  1005. const userInfo = uni.getStorageSync('userInfo');
  1006. const openid = userInfo ? (JSON.parse(userInfo).openid || '') : '';
  1007. const tenant_id = uni.getStorageSync('tenant_id') || '1';
  1008. // 根据当前视频索引映射到正确的问题ID
  1009. let questionId;
  1010. switch(this.currentVideoIndex) {
  1011. case 1: // 第一个问题
  1012. questionId = 10;
  1013. break;
  1014. case 2: // 第二个问题
  1015. questionId = 11;
  1016. break;
  1017. case 3: // 第三个问题
  1018. questionId = 12;
  1019. break;
  1020. case 4: // 第四个问题
  1021. questionId = 13;
  1022. break;
  1023. default:
  1024. questionId = 10; // 默认值
  1025. }
  1026. // 检查是否在浏览器环境中使用File对象
  1027. if (typeof fileOrPath !== 'string') {
  1028. // 在浏览器环境中,我们需要将File对象转换为FormData
  1029. const formData = new FormData();
  1030. formData.append('file', fileOrPath);
  1031. formData.append('openid', openid);
  1032. formData.append('tenant_id', tenant_id);
  1033. formData.append('application_id', uni.getStorageSync('appId'));
  1034. formData.append('question_id', questionId);
  1035. formData.append('video_duration', 0);
  1036. formData.append('has_audio', 'true'); // 明确标记视频包含音频
  1037. // 使用XMLHttpRequest直接上传
  1038. const xhr = new XMLHttpRequest();
  1039. xhr.open('POST', `${apiBaseUrl}/api/system/upload/`, true);
  1040. xhr.timeout = 120000; // 30秒超时
  1041. xhr.onload = () => {
  1042. uni.hideLoading();
  1043. if (xhr.status === 200) {
  1044. try {
  1045. const res = JSON.parse(xhr.responseText);
  1046. console.log('上传响应:', res);
  1047. if (res.code === 2000) {
  1048. // 获取上传后的视频URL
  1049. const videoUrl = res.data.url || res.data.photoUrl || '';
  1050. if (videoUrl) {
  1051. // 提交到面试接口
  1052. this.submitVideoToInterview(videoUrl);
  1053. } else {
  1054. uni.showToast({
  1055. title: '视频URL获取失败',
  1056. icon: 'none'
  1057. });
  1058. }
  1059. } else {
  1060. uni.showToast({
  1061. title: res.msg || '上传失败,请重试',
  1062. icon: 'none'
  1063. });
  1064. }
  1065. } catch (e) {
  1066. console.error('解析响应失败:', e);
  1067. uni.showToast({
  1068. title: '解析响应失败',
  1069. icon: 'none'
  1070. });
  1071. }
  1072. } else {
  1073. uni.showToast({
  1074. title: '上传失败,HTTP状态: ' + xhr.status,
  1075. icon: 'none'
  1076. });
  1077. }
  1078. };
  1079. xhr.onerror = () => {
  1080. uni.hideLoading();
  1081. console.error('上传网络错误');
  1082. uni.showToast({
  1083. title: '网络错误,请重试',
  1084. icon: 'none'
  1085. });
  1086. };
  1087. xhr.upload.onprogress = (event) => {
  1088. if (event.lengthComputable) {
  1089. const percentComplete = Math.round((event.loaded / event.total) * 100);
  1090. console.log('上传进度:', percentComplete + '%');
  1091. }
  1092. };
  1093. xhr.send(formData);
  1094. return;
  1095. }
  1096. // 对于小程序环境,使用uni.uploadFile
  1097. uni.uploadFile({
  1098. url: `${apiBaseUrl}/api/system/upload/`,
  1099. filePath: fileOrPath,
  1100. name: 'file',
  1101. formData: {
  1102. openid: openid,
  1103. tenant_id: tenant_id,
  1104. application_id: uni.getStorageSync('appId'),
  1105. question_id: questionId,
  1106. video_duration: 0,
  1107. has_audio: 'true' // 明确标记视频包含音频
  1108. },
  1109. success: (uploadRes) => {
  1110. try {
  1111. const res = JSON.parse(uploadRes.data);
  1112. console.log('上传响应:', res);
  1113. if (res.code === 2000) {
  1114. // 获取上传后的视频URL
  1115. const videoUrl = res.data.permanent_link || res.data.url || '';
  1116. if (videoUrl) {
  1117. // 提交到面试接口
  1118. this.submitVideoToInterview(videoUrl);
  1119. } else {
  1120. uni.showToast({
  1121. title: '视频URL获取失败',
  1122. icon: 'none'
  1123. });
  1124. }
  1125. } else {
  1126. uni.showToast({
  1127. title: res.msg || '上传失败,请重试',
  1128. icon: 'none'
  1129. });
  1130. }
  1131. } catch (e) {
  1132. console.error('解析响应失败:', e);
  1133. uni.showToast({
  1134. title: '解析响应失败',
  1135. icon: 'none'
  1136. });
  1137. }
  1138. },
  1139. fail: (err) => {
  1140. console.error('上传失败:', err);
  1141. uni.showToast({
  1142. title: '上传失败,请重试',
  1143. icon: 'none'
  1144. });
  1145. },
  1146. complete: () => {
  1147. uni.hideLoading();
  1148. }
  1149. });
  1150. },
  1151. // 修改 submitVideoToInterview 方法
  1152. submitVideoToInterview(videoUrl) {
  1153. console.log('提交视频URL到面试接口:', videoUrl);
  1154. // 显示加载提示
  1155. uni.showLoading({
  1156. title: '正在提交...',
  1157. mask: true
  1158. });
  1159. // 根据当前视频索引映射到正确的问题ID
  1160. let questionId;
  1161. switch(this.currentVideoIndex) {
  1162. case 1: // 第一个问题(索引1是第二个视频,第一个是介绍视频)
  1163. questionId = 10;
  1164. break;
  1165. case 2: // 第二个问题
  1166. questionId = 11;
  1167. break;
  1168. case 3: // 第三个问题
  1169. questionId = 12;
  1170. break;
  1171. case 4: // 第四个问题
  1172. questionId = 13;
  1173. break;
  1174. default:
  1175. questionId = 10; // 默认值,以防万一
  1176. }
  1177. // 准备请求参数,使用映射后的问题ID
  1178. const requestData = {
  1179. application_id: uni.getStorageSync('appId'),
  1180. question_id: questionId,
  1181. video_url: videoUrl,
  1182. tenant_id: uni.getStorageSync('tenant_id') || '1'
  1183. };
  1184. // 发送请求到面试接口,使用form-data格式
  1185. uni.request({
  1186. url: `${apiBaseUrl}/api/job/upload_question_video`,
  1187. method: 'POST',
  1188. data: requestData, // 使用data而不是formData
  1189. header: {
  1190. 'content-type': 'application/x-www-form-urlencoded' // 修改为form-data格式的content-type
  1191. },
  1192. success: (res) => {
  1193. console.log('面试接口提交成功:', res);
  1194. uni.hideLoading();
  1195. if (res.data.code === 0 || res.data.code === 2000) {
  1196. // 提交成功
  1197. uni.showToast({
  1198. title: '回答已提交',
  1199. icon: 'success'
  1200. });
  1201. // 保存最后上传的视频URL
  1202. this.lastUploadedVideoUrl = videoUrl;
  1203. // 隐藏重试按钮(如果之前显示了)
  1204. this.showRetryButton = false;
  1205. this.lastVideoToRetry = null;
  1206. // 延迟一下再进入下一题,让用户看到成功提示
  1207. setTimeout(() => {
  1208. // 检查是否完成了第五个视频问题(索引为4)
  1209. if (this.currentVideoIndex === 4) {
  1210. // 完成第五个问题后,跳转到相机页面
  1211. uni.navigateTo({
  1212. url: '/pages/camera/camera'
  1213. });
  1214. } else {
  1215. // 否则继续正常流程
  1216. this.proceedToNextQuestion();
  1217. }
  1218. }, 1500);
  1219. } else {
  1220. // 提交失败
  1221. uni.showToast({
  1222. title: res.data.msg || '提交失败,请重试',
  1223. icon: 'none'
  1224. });
  1225. // 保存失败的视频URL,用于重试
  1226. this.lastVideoToRetry = videoUrl;
  1227. // 显示重试按钮
  1228. this.showRetryButton = true;
  1229. }
  1230. },
  1231. fail: (err) => {
  1232. console.error('面试接口提交失败:', err);
  1233. uni.hideLoading();
  1234. uni.showToast({
  1235. title: '网络错误,请重试',
  1236. icon: 'none'
  1237. });
  1238. // 保存失败的视频URL,用于重试
  1239. this.lastVideoToRetry = videoUrl;
  1240. // 显示重试按钮
  1241. this.showRetryButton = true;
  1242. }
  1243. });
  1244. },
  1245. // 修改 proceedToNextQuestion 方法
  1246. proceedToNextQuestion() {
  1247. // 检查是否完成了第五个视频问题(索引为4)
  1248. if (this.currentVideoIndex === 4) {
  1249. // 完成第五个问题后,跳转到相机页面
  1250. uni.navigateTo({
  1251. url: '/pages/camera/camera'
  1252. });
  1253. return;
  1254. }
  1255. // 切换到下一个视频
  1256. this.currentVideoIndex++;
  1257. if (this.currentVideoIndex < this.videoList.length) {
  1258. // 还有下一段视频,播放它
  1259. this.videoUrl = this.videoList[this.currentVideoIndex];
  1260. this.videoPlaying = true;
  1261. // 重置当前字幕
  1262. this.currentSubtitle = '';
  1263. // 使用 nextTick 确保 DOM 更新后再播放视频
  1264. this.$nextTick(() => {
  1265. const videoContext = uni.createVideoContext('myVideo', this);
  1266. if (videoContext) {
  1267. videoContext.play();
  1268. }
  1269. });
  1270. } else {
  1271. // 所有视频都播放完毕,可以进行下一步操作
  1272. uni.showToast({
  1273. title: '面试完成',
  1274. icon: 'success',
  1275. duration: 2000
  1276. });
  1277. // 可以在这里添加面试完成后的逻辑,比如跳转到下一个页面
  1278. setTimeout(() => {
  1279. uni.navigateTo({
  1280. url: '/pages/interview-result/interview-result'
  1281. });
  1282. }, 2000);
  1283. }
  1284. },
  1285. // 修改 handleAnswerButtonClick 方法
  1286. handleAnswerButtonClick() {
  1287. // 隐藏答题按钮
  1288. this.showAnswerButton = false;
  1289. // 直接进入下一个问题
  1290. this.proceedToNextQuestion();
  1291. },
  1292. // 处理相机错误
  1293. handleCameraError(e) {
  1294. console.error('相机错误:', e);
  1295. // 显示错误提示
  1296. uni.showToast({
  1297. title: '相机初始化失败,请检查权限设置',
  1298. icon: 'none'
  1299. });
  1300. // 尝试备用选项
  1301. this.tryFallbackOptions();
  1302. },
  1303. // 添加新方法:尝试备用选项
  1304. tryFallbackOptions() {
  1305. // 检查环境
  1306. const systemInfo = uni.getSystemInfoSync();
  1307. // 在小程序环境中使用小程序API
  1308. if (systemInfo.uniPlatform === 'mp-weixin' || systemInfo.uniPlatform === 'mp-alipay') {
  1309. this.useMiniProgramCamera();
  1310. }
  1311. // 在H5环境中显示静态图像
  1312. else {
  1313. this.showStaticCameraPlaceholder();
  1314. }
  1315. },
  1316. // 添加新方法:使用小程序相机API
  1317. useMiniProgramCamera() {
  1318. console.log('尝试使用小程序相机组件');
  1319. // 这里需要在模板中添加小程序相机组件
  1320. // 并设置一个标志来控制显示
  1321. this.useMiniProgramCameraComponent = true;
  1322. },
  1323. // 添加新方法:显示静态图像
  1324. showStaticCameraPlaceholder() {
  1325. console.log('显示静态摄像头占位图');
  1326. // 创建一个图像元素
  1327. const img = document.createElement('img');
  1328. img.src = '/static/images/camera-placeholder.png'; // 确保有这个图片资源
  1329. img.className = 'static-camera-image';
  1330. img.style.width = '100%';
  1331. img.style.height = '100%';
  1332. img.style.objectFit = 'cover';
  1333. // 获取容器并添加图像
  1334. const container = this.$refs.userCameraVideo.parentNode;
  1335. container.appendChild(img);
  1336. },
  1337. // 处理视频时间更新事件
  1338. handleTimeUpdate(e) {
  1339. // 获取当前视频播放时间
  1340. const currentTime = e.target.currentTime;
  1341. // 根据当前播放的视频索引选择对应的字幕数组
  1342. let currentSubtitles;
  1343. if (this.currentVideoIndex === 0) {
  1344. currentSubtitles = this.subtitles;
  1345. } else if (this.currentVideoIndex === 1) {
  1346. currentSubtitles = this.secondVideoSubtitles;
  1347. }else if (this.currentVideoIndex === 2) {
  1348. currentSubtitles = this.thirdVideoSubtitles;
  1349. }else if (this.currentVideoIndex === 3) {
  1350. currentSubtitles = this.fourthVideoSubtitles;
  1351. }else if (this.currentVideoIndex === 4) {
  1352. currentSubtitles = this.fifthVideoSubtitles;
  1353. }else if (this.currentVideoIndex === 5) {
  1354. currentSubtitles = this.sixthVideoSubtitles;
  1355. }else {
  1356. // 如果有更多视频,可以继续添加条件
  1357. currentSubtitles = [];
  1358. }
  1359. // 查找当前时间应该显示的字幕
  1360. const subtitle = currentSubtitles.find(
  1361. sub => currentTime >= sub.startTime && currentTime < sub.endTime
  1362. );
  1363. // 更新当前字幕
  1364. this.currentSubtitle = subtitle ? subtitle.text : '';
  1365. },
  1366. // Add a new method to handle the "Start Recording" button click
  1367. handleStartRecordingClick() {
  1368. // 隐藏"开始回答"按钮
  1369. this.showStartRecordingButton = false;
  1370. // 开始录制
  1371. this.startRecordingAnswer();
  1372. },
  1373. // 修改 checkAudioPermission 方法,确保在录制前获取音频权限
  1374. checkAudioPermission() {
  1375. // 在小程序环境中
  1376. const systemInfo = uni.getSystemInfoSync();
  1377. const isMiniProgram = systemInfo.uniPlatform && systemInfo.uniPlatform.startsWith('mp-');
  1378. if (isMiniProgram) {
  1379. // 小程序环境下的权限请求
  1380. uni.getSetting({
  1381. success: (res) => {
  1382. if (!res.authSetting['scope.record']) {
  1383. uni.authorize({
  1384. scope: 'scope.record',
  1385. success: () => {
  1386. console.log('录音权限已获取');
  1387. },
  1388. fail: (err) => {
  1389. console.error('录音权限获取失败:', err);
  1390. this.showPermissionDialog('录音');
  1391. }
  1392. });
  1393. }
  1394. if (!res.authSetting['scope.camera']) {
  1395. uni.authorize({
  1396. scope: 'scope.camera',
  1397. success: () => {
  1398. console.log('相机权限已获取');
  1399. },
  1400. fail: (err) => {
  1401. console.error('相机权限获取失败:', err);
  1402. this.showPermissionDialog('相机');
  1403. }
  1404. });
  1405. }
  1406. }
  1407. });
  1408. } else {
  1409. // 浏览器环境下的权限请求
  1410. // ... 保持原有代码不变
  1411. }
  1412. },
  1413. // 添加新方法:测试音频输入
  1414. testAudioInput() {
  1415. if (!this.cameraStream) {
  1416. console.warn('没有可用的媒体流,无法测试音频');
  1417. return;
  1418. }
  1419. const audioTracks = this.cameraStream.getAudioTracks();
  1420. if (audioTracks.length === 0) {
  1421. console.warn('没有检测到音频轨道,尝试重新获取');
  1422. this.tryGetAudioOnly();
  1423. return;
  1424. }
  1425. console.log('音频轨道信息:', audioTracks[0].getSettings());
  1426. // 创建音频上下文和分析器
  1427. try {
  1428. const AudioContext = window.AudioContext || window.webkitAudioContext;
  1429. if (!AudioContext) {
  1430. console.warn('浏览器不支持AudioContext');
  1431. return;
  1432. }
  1433. const audioContext = new AudioContext();
  1434. const analyser = audioContext.createAnalyser();
  1435. const microphone = audioContext.createMediaStreamSource(this.cameraStream);
  1436. microphone.connect(analyser);
  1437. // 设置分析器
  1438. analyser.fftSize = 256;
  1439. const bufferLength = analyser.frequencyBinCount;
  1440. const dataArray = new Uint8Array(bufferLength);
  1441. // 检测音频输入
  1442. let silenceCounter = 0;
  1443. const checkAudio = () => {
  1444. if (this.isRecording) return; // 如果正在录制,停止检测
  1445. analyser.getByteFrequencyData(dataArray);
  1446. // 计算平均音量
  1447. let sum = 0;
  1448. for (let i = 0; i < bufferLength; i++) {
  1449. sum += dataArray[i];
  1450. }
  1451. const average = sum / bufferLength;
  1452. if (average > 10) {
  1453. console.log('检测到音频输入,音量:', average);
  1454. silenceCounter = 0;
  1455. } else {
  1456. silenceCounter++;
  1457. if (silenceCounter > 10) {
  1458. console.warn('持续检测不到音频输入,可能麦克风未正常工作');
  1459. silenceCounter = 0;
  1460. }
  1461. }
  1462. // 继续检测
  1463. requestAnimationFrame(checkAudio);
  1464. };
  1465. // 开始检测
  1466. checkAudio();
  1467. } catch (e) {
  1468. console.error('音频测试失败:', e);
  1469. }
  1470. },
  1471. // 添加新方法:尝试单独获取音频
  1472. tryGetAudioOnly() {
  1473. navigator.mediaDevices.getUserMedia({ audio: true })
  1474. .then(audioStream => {
  1475. // 如果已有视频流,合并音频和视频轨道
  1476. if (this.cameraStream) {
  1477. const videoTrack = this.cameraStream.getVideoTracks()[0];
  1478. const audioTrack = audioStream.getAudioTracks()[0];
  1479. // 创建新的媒体流,包含视频和音频轨道
  1480. const combinedStream = new MediaStream();
  1481. if (videoTrack) combinedStream.addTrack(videoTrack);
  1482. if (audioTrack) combinedStream.addTrack(audioTrack);
  1483. // 更新摄像头流
  1484. this.cameraStream = combinedStream;
  1485. // 更新视频元素的源
  1486. const videoElement = this.$refs.userCameraVideo;
  1487. if (videoElement) {
  1488. videoElement.srcObject = combinedStream;
  1489. videoElement.muted = true; // 避免回声,但仍然录制声音
  1490. }
  1491. console.log('成功合并音频和视频轨道');
  1492. } else {
  1493. console.warn('没有视频流可合并');
  1494. }
  1495. })
  1496. .catch(err => {
  1497. console.error('单独获取音频失败:', err);
  1498. });
  1499. },
  1500. // 添加新方法:显示权限对话框
  1501. showPermissionDialog(permissionType) {
  1502. uni.showModal({
  1503. title: '需要权限',
  1504. content: `请允许使用${permissionType}权限,否则可能影响面试功能`,
  1505. confirmText: '去设置',
  1506. success: (res) => {
  1507. if (res.confirm) {
  1508. uni.openSetting({
  1509. success: (settingRes) => {
  1510. console.log('设置页面打开成功', settingRes);
  1511. }
  1512. });
  1513. }
  1514. }
  1515. });
  1516. },
  1517. // 添加重试上传方法
  1518. retryVideoUpload() {
  1519. if (this.lastVideoToRetry) {
  1520. // 隐藏重试按钮
  1521. this.showRetryButton = false;
  1522. // 显示加载提示
  1523. uni.showLoading({
  1524. title: '正在重新提交...',
  1525. mask: true
  1526. });
  1527. // 重新提交视频
  1528. this.submitVideoToInterview(this.lastVideoToRetry);
  1529. } else {
  1530. uni.showToast({
  1531. title: '没有可重试的视频',
  1532. icon: 'none'
  1533. });
  1534. }
  1535. },
  1536. // 添加一个新方法用于压缩视频
  1537. async compressVideo(videoBlob) {
  1538. // 如果视频大小小于某个阈值,直接返回原视频
  1539. if (videoBlob.size < 5 * 1024 * 1024) { // 小于5MB
  1540. return videoBlob;
  1541. }
  1542. console.log('开始压缩视频,原始大小:', videoBlob.size);
  1543. // 创建一个视频元素来加载视频
  1544. const videoElement = document.createElement('video');
  1545. videoElement.muted = true;
  1546. videoElement.autoplay = false;
  1547. // 创建一个canvas元素用于绘制视频帧
  1548. const canvas = document.createElement('canvas');
  1549. const ctx = canvas.getContext('2d');
  1550. // 设置视频源
  1551. videoElement.src = URL.createObjectURL(videoBlob);
  1552. return new Promise((resolve) => {
  1553. videoElement.onloadedmetadata = () => {
  1554. // 设置canvas尺寸为视频的一半
  1555. const width = Math.floor(videoElement.videoWidth / 2);
  1556. const height = Math.floor(videoElement.videoHeight / 2);
  1557. canvas.width = width;
  1558. canvas.height = height;
  1559. // 创建MediaRecorder来录制canvas
  1560. const stream = canvas.captureStream(15); // 15fps
  1561. // 如果原视频有音轨,添加到新流中
  1562. if (videoElement.captureStream) {
  1563. const originalStream = videoElement.captureStream();
  1564. const audioTracks = originalStream.getAudioTracks();
  1565. if (audioTracks.length > 0) {
  1566. stream.addTrack(audioTracks[0]);
  1567. }
  1568. }
  1569. // 设置较低的比特率
  1570. const options = {
  1571. mimeType: 'video/webm;codecs=vp8,opus',
  1572. audioBitsPerSecond: 64000,
  1573. videoBitsPerSecond: 800000 // 800kbps
  1574. };
  1575. const mediaRecorder = new MediaRecorder(stream, options);
  1576. const chunks = [];
  1577. mediaRecorder.ondataavailable = (e) => {
  1578. if (e.data.size > 0) {
  1579. chunks.push(e.data);
  1580. }
  1581. };
  1582. mediaRecorder.onstop = () => {
  1583. const compressedBlob = new Blob(chunks, { type: 'video/webm' });
  1584. console.log('视频压缩完成,压缩后大小:', compressedBlob.size);
  1585. resolve(compressedBlob);
  1586. };
  1587. // 开始播放视频并录制
  1588. videoElement.onplay = () => {
  1589. mediaRecorder.start(100);
  1590. // 绘制视频帧到canvas
  1591. const drawFrame = () => {
  1592. if (videoElement.paused || videoElement.ended) {
  1593. mediaRecorder.stop();
  1594. return;
  1595. }
  1596. ctx.drawImage(videoElement, 0, 0, width, height);
  1597. requestAnimationFrame(drawFrame);
  1598. };
  1599. drawFrame();
  1600. };
  1601. videoElement.play();
  1602. };
  1603. });
  1604. }
  1605. }
  1606. }
  1607. </script>
  1608. <style scoped>
  1609. .identity-verify-container {
  1610. padding: 0;
  1611. max-width: 100%;
  1612. margin: 0 auto;
  1613. height: 100vh;
  1614. display: flex;
  1615. flex-direction: column;
  1616. background-color: #f5f5f5;
  1617. }
  1618. .digital-human-container {
  1619. position: relative;
  1620. width: 100%;
  1621. height: 100vh;
  1622. overflow: hidden;
  1623. background-color: #f0f0f0;
  1624. }
  1625. .digital-human-video {
  1626. width: 100%;
  1627. height: 100%;
  1628. display: flex;
  1629. justify-content: center;
  1630. align-items: center;
  1631. }
  1632. /* 用户摄像头容器样式 */
  1633. .user-camera-container {
  1634. position: absolute;
  1635. top: 20px;
  1636. right: 20px;
  1637. width: 100px;
  1638. height: 150px;
  1639. border-radius: 8px;
  1640. overflow: hidden;
  1641. box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
  1642. z-index: 20;
  1643. border: 1px solid #fff;
  1644. }
  1645. /* 用户摄像头视频样式 */
  1646. .user-camera-video {
  1647. width: 100%;
  1648. height: 100%;
  1649. object-fit: cover;
  1650. background-color: #333;
  1651. }
  1652. .video-player {
  1653. width: 100%;
  1654. height: 100%;
  1655. object-fit: cover;
  1656. outline: none; /* 移除视频获得焦点时的轮廓 */
  1657. -webkit-tap-highlight-color: transparent; /* 移除移动设备上的点击高亮 */
  1658. }
  1659. /* 隐藏视频控制条 */
  1660. video::-webkit-media-controls {
  1661. display: none !important;
  1662. }
  1663. video::-webkit-media-controls-enclosure {
  1664. display: none !important;
  1665. }
  1666. video::-webkit-media-controls-panel {
  1667. display: none !important;
  1668. }
  1669. video::-webkit-media-controls-play-button {
  1670. display: none !important;
  1671. }
  1672. video::-webkit-media-controls-timeline {
  1673. display: none !important;
  1674. }
  1675. video::-webkit-media-controls-current-time-display {
  1676. display: none !important;
  1677. }
  1678. video::-webkit-media-controls-time-remaining-display {
  1679. display: none !important;
  1680. }
  1681. video::-webkit-media-controls-mute-button {
  1682. display: none !important;
  1683. }
  1684. video::-webkit-media-controls-volume-slider {
  1685. display: none !important;
  1686. }
  1687. video::-webkit-media-controls-fullscreen-button {
  1688. display: none !important;
  1689. }
  1690. .subtitle-overlay {
  1691. position: absolute;
  1692. bottom: 50px;
  1693. left: 0;
  1694. width: 100%;
  1695. padding: 15px;
  1696. background-color: rgba(0, 0, 0, 0.7);
  1697. color: white;
  1698. text-align: center;
  1699. font-size: 16px;
  1700. line-height: 1.5;
  1701. z-index: 10;
  1702. border-radius: 4px;
  1703. max-width: 90%;
  1704. margin: 0 auto;
  1705. left: 5%;
  1706. right: 5%;
  1707. }
  1708. .control-panel {
  1709. padding: 15px;
  1710. display: flex;
  1711. justify-content: center;
  1712. }
  1713. .control-button {
  1714. padding: 10px 20px;
  1715. background-color: #4CAF50;
  1716. color: white;
  1717. border: none;
  1718. border-radius: 4px;
  1719. cursor: pointer;
  1720. }
  1721. .loading {
  1722. text-align: center;
  1723. margin: 20px 0;
  1724. font-size: 16px;
  1725. }
  1726. .response-container {
  1727. margin-top: 20px;
  1728. padding: 0 20px;
  1729. display: none; /* 默认隐藏调试信息 */
  1730. }
  1731. /* 当showDebugInfo为true时显示 */
  1732. .showDebugInfo .response-container {
  1733. display: block;
  1734. }
  1735. .response-item {
  1736. padding: 10px;
  1737. border: 1px solid #eee;
  1738. border-radius: 4px;
  1739. margin-bottom: 10px;
  1740. background-color: #f9f9f9;
  1741. }
  1742. .response-content {
  1743. display: flex;
  1744. flex-direction: column;
  1745. }
  1746. .answer-button-container {
  1747. position: absolute;
  1748. top: 75%;
  1749. left: 50%;
  1750. transform: translate(-50%, -50%);
  1751. z-index: 20;
  1752. }
  1753. .answer-button {
  1754. width: 120px;
  1755. height: 120px;
  1756. border-radius: 50%;
  1757. background-color: #6c5ce7; /* 绿色背景,与图片中的颜色类似 */
  1758. color: white;
  1759. font-size: 18px;
  1760. border: none;
  1761. box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
  1762. display: flex;
  1763. justify-content: center;
  1764. align-items: center;
  1765. cursor: pointer;
  1766. animation: pulse 2s infinite;
  1767. }
  1768. @keyframes pulse {
  1769. 0% {
  1770. transform: scale(1);
  1771. box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
  1772. }
  1773. 50% {
  1774. transform: scale(1.05);
  1775. box-shadow: 0 8px 15px rgba(0, 0, 0, 0.4);
  1776. }
  1777. 100% {
  1778. transform: scale(1);
  1779. box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
  1780. }
  1781. }
  1782. .stop-recording-button-container {
  1783. position: absolute;
  1784. top: 75%;
  1785. left: 50%;
  1786. transform: translate(-50%, -50%);
  1787. z-index: 20;
  1788. }
  1789. .stop-recording-button {
  1790. width: 120px;
  1791. height: 120px;
  1792. border-radius: 50%;
  1793. background-color: #e74c3c; /* 红色背景 */
  1794. color: white;
  1795. font-size: 18px;
  1796. border: none;
  1797. box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
  1798. display: flex;
  1799. justify-content: center;
  1800. align-items: center;
  1801. cursor: pointer;
  1802. animation: pulse 2s infinite;
  1803. }
  1804. @keyframes pulse {
  1805. 0% {
  1806. transform: scale(1);
  1807. box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
  1808. }
  1809. 50% {
  1810. transform: scale(1.05);
  1811. box-shadow: 0 8px 15px rgba(0, 0, 0, 0.4);
  1812. }
  1813. 100% {
  1814. transform: scale(1);
  1815. box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
  1816. }
  1817. }
  1818. /* 录制中的指示器 */
  1819. .recording-indicator {
  1820. position: absolute;
  1821. top: 20px;
  1822. left: 20px;
  1823. display: flex;
  1824. align-items: center;
  1825. background-color: rgba(0, 0, 0, 0.6);
  1826. padding: 5px 10px;
  1827. border-radius: 15px;
  1828. z-index: 20;
  1829. }
  1830. .recording-dot {
  1831. width: 12px;
  1832. height: 12px;
  1833. background-color: #e74c3c;
  1834. border-radius: 50%;
  1835. margin-right: 8px;
  1836. animation: blink 1s infinite;
  1837. }
  1838. .recording-text {
  1839. color: white;
  1840. font-size: 14px;
  1841. }
  1842. @keyframes blink {
  1843. 0% { opacity: 1; }
  1844. 50% { opacity: 0.3; }
  1845. 100% { opacity: 1; }
  1846. }
  1847. .start-recording-button-container {
  1848. position: absolute;
  1849. top: 75%;
  1850. left: 50%;
  1851. transform: translate(-50%, -50%);
  1852. z-index: 20;
  1853. }
  1854. .start-recording-button {
  1855. width: 120px;
  1856. height: 120px;
  1857. border-radius: 50%;
  1858. background-color: #6c5ce7; /* 与开始面试按钮颜色保持一致 */
  1859. color: white;
  1860. font-size: 18px;
  1861. border: none;
  1862. box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
  1863. display: flex;
  1864. justify-content: center;
  1865. align-items: center;
  1866. cursor: pointer;
  1867. animation: pulse 2s infinite; /* 使用相同的动画 */
  1868. }
  1869. /* 可以删除pulse-green动画,直接使用与开始面试按钮相同的pulse动画 */
  1870. .retry-button-container {
  1871. position: absolute;
  1872. bottom: 50px;
  1873. left: 50%;
  1874. transform: translateX(-50%);
  1875. z-index: 20;
  1876. }
  1877. .retry-button {
  1878. padding: 10px 20px;
  1879. background-color: #3498db; /* 蓝色背景 */
  1880. color: white;
  1881. font-size: 16px;
  1882. border: none;
  1883. border-radius: 4px;
  1884. box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
  1885. cursor: pointer;
  1886. }
  1887. .retry-button:hover {
  1888. background-color: #2980b9;
  1889. }
  1890. </style>