identity-verify.vue 74 KB

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