identity-verify.vue 157 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950
  1. <template>
  2. <div class="identity-verify-container" :class="{ 'page-warning': showPageWarning }">
  3. <div class="digital-human-container">
  4. <!-- 添加题号显示 -->
  5. <!-- <div class="question-counter" v-if="currentVideoIndex > 0">
  6. <span class="question-number">{{ currentVideoIndex }}/{{ totalQuestions }}</span>
  7. </div> -->
  8. <!-- AI数字人视频/图像显示区域 -->
  9. <div class="digital-human-video">
  10. <!-- 添加GIF图片显示区域 -->
  11. <!-- <img v-if="showGif && gifUrl" :src="gifUrl" class="gif-player" alt="面试官GIF" /> -->
  12. <!-- <video
  13. v-if="showGif && gifUrl"
  14. :src="gifUrl"
  15. id="mpVideo"
  16. autoplay
  17. class="video-player"
  18. :controls="false"
  19. loop
  20. muted
  21. object-fit="contain"
  22. ></video> v-if="!showGif"-->
  23. <!-- 视频播放区域,当showGif为false时显示 -->
  24. <video
  25. :src="videoUrl"
  26. id="myVideo"
  27. ref="videoPlayer"
  28. autoplay
  29. playsinline
  30. disablePictureInPicture
  31. controlsList="nodownload nofullscreen noremoteplayback"
  32. class="video-player"
  33. :controls="false"
  34. @error="handleVideoError"
  35. @ended="handleVideoEnded"
  36. @timeupdate="handleTimeUpdate"
  37. @click.prevent="preventVideoControl"
  38. @touchstart.prevent="preventVideoControl">
  39. </video>
  40. <!-- 添加透明蒙层 -->
  41. <div class="video-overlay"></div>
  42. <div class="answer-button-container" v-if="showAnswerButton">
  43. <button class="answer-button" @click="handleAnswerButtonClick">
  44. 开始回答
  45. </button>
  46. </div>
  47. <!-- 添加字幕覆盖层 -->
  48. <div class="subtitle-overlay" v-if="currentSubtitle || (showSubtitleText && subtitleText)">
  49. <!-- 添加题号显示 -->
  50. <div class="subtitle-header" v-if="currentVideoIndex > 0">
  51. <span class="question-tag">{{ currentVideoIndex }}/{{ totalQuestions }}</span>
  52. </div>
  53. <!-- 显示父问题 -->
  54. <div class="subtitle-main" v-if="!isFollowUpQuestion">{{ currentSubtitle }}</div>
  55. <!-- 当是追问时显示父问题和追问问题 -->
  56. <template v-else>
  57. <div class="parent-question">{{ parentQuestion }}</div>
  58. <div class="followup-container">
  59. <div class="followup-content">
  60. <span class="followup-label">追问:</span>
  61. <span class="followup-text">{{ subtitleText || currentSubtitle }}</span>
  62. </div>
  63. </div>
  64. </template>
  65. </div>
  66. <!-- 添加答题按钮 -->
  67. </div>
  68. <!-- 用户摄像头视频显示区域 -->
  69. <div class="user-camera-container" :class="{ 'camera-warning': showPageWarning }">
  70. <!-- 在小程序环境中使用camera组件 -->
  71. <camera v-if="useMiniProgramCameraComponent"
  72. device-position="front"
  73. flash="off"
  74. class="user-camera-video"
  75. @error="handleCameraError"
  76. :record-audio="true"
  77. frame-size="small"
  78. resolution="low"
  79. :disable-zoom="true"
  80. :enable-camera="true"
  81. :enable-record="true"
  82. mode="normal">
  83. </camera>
  84. <!-- 在H5/App环境中使用video元素 -->
  85. <video v-else
  86. id="userCamera"
  87. ref="userCameraVideo"
  88. autoplay
  89. playsinline
  90. muted
  91. class="user-camera-video"
  92. :controls="false">
  93. </video>
  94. </div>
  95. <!-- 字幕/文本覆盖区域 -->
  96. <!-- <div class="subtitle-overlay" v-if="assistantResponse">
  97. {{ assistantResponse }}
  98. </div> -->
  99. </div>
  100. <!-- 加载状态 -->
  101. <div v-if="loading" class="loading">加载中...</div>
  102. <!-- 控制面板(可选,可以隐藏) -->
  103. <!-- 响应数据(可以设为隐藏,仅用于调试) -->
  104. <div v-if="showDebugInfo" class="response-container">
  105. <div v-if="assistantResponse" class="response-item">
  106. <div class="response-content">
  107. <span>助手回复: {{ assistantResponse }}</span>
  108. </div>
  109. </div>
  110. <div v-if="audioTranscript" class="response-item">
  111. <div class="response-content">
  112. <span>音频转写: {{ audioTranscript }}</span>
  113. </div>
  114. </div>
  115. <div v-for="(item, index) in processedResponses" :key="index" class="response-item">
  116. <div class="response-content">
  117. <span v-if="item.role">角色: {{ item.role }}</span>
  118. <span v-if="item.transcript">文本: {{ item.transcript }}</span>
  119. </div>
  120. </div>
  121. </div>
  122. <!-- 添加停止录制按钮 -->
  123. <div class="stop-recording-button-container" v-if="showStopRecordingButton">
  124. <button class="stop-recording-button" @click="stopRecordingAnswer">
  125. 完成作答
  126. </button>
  127. </div>
  128. <!-- 添加录制指示器 -->
  129. <div class="recording-indicator" v-if="isRecording">
  130. <!-- 替换原有的progress组件为自定义环形进度条 -->
  131. <div class="circle-progress-container">
  132. <div class="circle-progress" :style="{
  133. background: `conic-gradient(${progressColor} ${progressPercent}%, ${progressBgColor} 0%)`
  134. }">
  135. <div class="circle-progress-inner">
  136. <span class="progress-text">{{ recordingTimeDisplay }}</span><!-- -->
  137. </div>
  138. </div>
  139. </div>
  140. <!-- <span class="timer-text">{{ recordingTimeDisplay || '00:00 / 05:00' }}</span> -->
  141. </div>
  142. <!-- 添加开始回答按钮 -->
  143. <div class="start-recording-button-container" v-if="showStartRecordingButton">
  144. <button class="start-recording-button" @click="handleStartRecordingClick">
  145. 开始作答
  146. </button>
  147. </div>
  148. <!-- 添加重试上传按钮 -->
  149. <div class="retry-button-container" v-if="showRetryButton">
  150. <button class="retry-button" @click="retryVideoUpload">
  151. 重新上传
  152. </button>
  153. </div>
  154. <!-- 在模板部分添加录制时长显示 -->
  155. <!-- <div class="recording-timer" v-if="isRecording">
  156. <span class="timer-text">{{ recordingTimeDisplay || '00:00' }}</span>
  157. </div> -->
  158. <!-- 添加上传状态指示器 -->
  159. <!-- <div class="upload-status-indicator" v-if="showUploadStatus && uploadStatusText">
  160. <div class="upload-status-content">
  161. <div class="upload-status-icon" :class="{'uploading': isUploading}"></div>
  162. <span class="upload-status-text">{{ uploadStatusText }}</span>
  163. </div>
  164. </div> -->
  165. <!-- 修改录制时长显示 -->
  166. <!-- <div class="recording-timer" v-if="isRecording">
  167. <span class="timer-text">{{ recordingTimeDisplay || '00:00 / 05:00' }}</span>
  168. <span class="remaining-time" :class="{'warning': remainingTime <= 10}">
  169. 剩余: {{ formatTime(remainingTime) }}
  170. </span>
  171. </div> -->
  172. <!-- 在模板部分添加倒计时蒙层 -->
  173. <div class="countdown-overlay" v-if="showCountdown">
  174. <div class="countdown-content">
  175. <div class="countdown-number">{{ countdownValue }}</div>
  176. <div class="countdown-text">准备开始回答</div>
  177. </div>
  178. </div>
  179. <!-- 添加重新录制按钮 -->
  180. <div class="rerecord-button-container" v-if="showRerecordButton">
  181. <button class="rerecord-button" @click="handleRerecordButtonClick">
  182. 重新作答
  183. </button>
  184. </div>
  185. </div>
  186. </template>
  187. <script>
  188. import { apiBaseUrl, personDetectionWsUrl } from '@/common/config.js';
  189. export default {
  190. name: 'IdentityVerify',
  191. data() {
  192. return {
  193. followUpQuestion: '', // 追问问题
  194. followUpAudioUrl: '', // 追问音频URL
  195. audioContext: null, // 音频上下文
  196. followUpQuestions: [], // 追问问题列表
  197. isWaitingForAnswer: false, // 添加新的状态来控制是否在等待用户回答
  198. currentFollowUpIndex: -1, // 当前追问问题索引
  199. showSubtitleText: false, // 是否显示字幕
  200. subtitleText: '', // 字幕文本
  201. parentQuestion: '', // 父问题文本
  202. isFollowUpQuestion: false, // 是否是追问问题
  203. loading: false,
  204. responses: [],
  205. processedResponses: [],
  206. assistantResponse: '',
  207. audioTranscript: '',
  208. videoPlaying: false,
  209. showDebugInfo: false, // 设置为true可以显示调试信息
  210. videoUrl: '', // 修改为空字符串,将通过API获取
  211. showReplayButton: false,
  212. cameraStream: null, // 存储摄像头流
  213. cameraError: null, // 存储摄像头错误信息
  214. useMiniProgramCameraComponent: false, // 添加小程序相机组件标志
  215. cameraContext: null, // 添加相机上下文
  216. currentSubtitle: '',
  217. currentTranslation: '', // 添加翻译文本
  218. subtitles: [], // 修改为空数组,将通过API获取
  219. // 移除硬编码的字幕数组
  220. // secondVideoSubtitles: [...],
  221. // thirdVideoSubtitles: [...],
  222. // 等等...
  223. showAnswerButton: false, // 控制答题按钮显示
  224. currentVideoIndex: 0, // 当前播放的视频索引
  225. videoList: [], // 修改为空数组,将通过API获取
  226. // 其他属性保持不变...
  227. questions: [], // 添加新属性存储API返回的问题数据
  228. introVideoUrl: (() => {
  229. try {
  230. const configStr = uni.getStorageSync('configData');
  231. if (configStr && configStr.trim()) {
  232. return JSON.parse(configStr).digital_human_opening_video_url || 'https://data.qicai321.com/minlong/ee4d9cce-c3d5-4350-8c6e-684283827897.mp4';
  233. }
  234. return 'https://data.qicai321.com/minlong/ee4d9cce-c3d5-4350-8c6e-684283827897.mp4';
  235. } catch (error) {
  236. console.warn('解析配置数据失败:', error);
  237. return {};
  238. }
  239. })(),//'https://data.qicai321.com/minlong/ee4d9cce-c3d5-4350-8c6e-684283827897.mp4', // 保留介绍视频
  240. isRecording: false,
  241. recordingTimer: null,
  242. showStopRecordingButton: false,
  243. mediaRecorder: null,
  244. recordedChunks: [],
  245. recorder: null,
  246. lastUploadedVideoUrl: '',
  247. showStartRecordingButton: false,
  248. showRetryButton: false, // 控制重试按钮显示
  249. lastVideoToRetry: null, // 存储上次失败的视频URL,用于重试
  250. recordingStartTime: null, // 录制开始时间
  251. recordingTimerCount: 0, // 录制计时器计数
  252. recordingTimeDisplay: '00:00', // 格式化的录制时间显示
  253. // 添加上传队列相关数据
  254. uploadQueue: [], // 存储待上传的视频
  255. isUploading: false, // 标记是否正在上传
  256. uploadProgress: {}, // 存储每个视频的上传进度
  257. uploadStatus: {}, // 存储每个视频的上传状态
  258. showUploadStatus: false, // 是否显示上传状态指示器
  259. uploadStatusText: '', // 上传状态文本
  260. mediaRecorderTimeout: null, // 用于存储MediaRecorder的超时机制
  261. maxRecordingTime: 300, // 最大录制时间(秒)- 从60秒改为300秒(5分钟)
  262. remainingTime: 300, // 剩余录制时间(秒)- 从60秒改为300秒
  263. countdownValue: 10, // 倒计时数值
  264. showCountdown: false, // 是否显示倒计时蒙层
  265. countdownTimer: null,
  266. showGif: false, // 控制是否显示GIF
  267. gifUrl: '', // GIF图片的URL
  268. globalSocketTask: null, // 添加全局 WebSocket 连接对象
  269. lowScoreVideoUrl: 'https://data.qicai321.com/minlong/latentsync/0530e7f5-1957-422d-8f34-ba4a92608081_result.mp4', // 低分提示视频URL
  270. showRerecordButton: false, // 控制重新录制按钮显示
  271. isPlayingLowScoreVideo: false, // 标记是否正在播放低分提示视频
  272. lowScoreVideoSubtitles: [
  273. {
  274. startTime: 0,
  275. endTime: 10, // 假设视频长度为10秒,可根据实际情况调整
  276. text: '我未听清楚您在说什么,请您再说一遍!'
  277. }
  278. ], // 用于存储低分提示视频的字幕
  279. retryCount: 0, // 添加重试次数计数器
  280. maxRetryAttempts: 2, // 最大重试次数限制
  281. needPlayLowScoreVideo: false, // 是否需要播放低分视频
  282. finalRecordingDuration: 0, // 最终录制时长
  283. historyTime: 0, // 添加历史时间记录
  284. followUpQuestions: [], // 存储追问问题数据
  285. currentParentQuestionId: null, // 当前父问题ID
  286. hasPlayedFollowUp: {}, // 记录已播放的追问,格式: {questionId: true}
  287. isFollowUpQuestion: false, // 标记当前是否正在播放追问问题
  288. currentFollowUpQuestion: null, // 添加当前追问问题的完整信息
  289. lastQuestionWasFollowUp: false,
  290. lastFollowUpQuestionId: null,
  291. lastUpdateTime: Date.now(), // 添加最后更新时间戳
  292. subtitleMap: {}, // 用于存储字幕和翻译的映射
  293. progressPercent: 0, // 录制进度百分比
  294. progressColor: '#05dc8b', // 进度条颜色
  295. progressBgColor: 'rgba(0, 0, 0,0.3)', // 进度条背景色
  296. parentQuestion: '', // 添加父问题存储
  297. screenCaptureCount: 0, // 添加截屏次数记录
  298. personDetectionSocket: null, // WebSocket对象
  299. personDetectionInterval: null, // 定时器对象
  300. showCameraWarning: false, // 添加新的数据属性
  301. showPageWarning: false, // 添加新的数据属性
  302. followUpQuestion: '', // 追问问题
  303. followUpAudioUrl: '', // 追问音频URL
  304. audioContext: null, // 音频上下文
  305. followUpQuestions: [], // 追问问题列表
  306. currentFollowUpIndex: -1, // 当前追问问题索引
  307. showSubtitleText: false, // 是否显示字幕
  308. subtitleText: '', // 字幕文本
  309. isAudioPlaying: false,
  310. isWaitingForAnswer: false, // 添加新的状态来控制是否在等待用户回答
  311. }
  312. },
  313. mounted() {
  314. this.fetchQuestions();
  315. this.fetchFollowUpQuestions();
  316. this.checkAudioPermission();
  317. this.initCamera();
  318. this.checkIOSCameraRecordPermission();
  319. this.checkAndFixRenderingIssues();
  320. setTimeout(() => {
  321. if (this.cameraStream && !this.useMiniProgramCameraComponent) {
  322. this.testAudioInput();
  323. }
  324. }, 3e3);
  325. this.historyTime = 0;
  326. uni.setKeepScreenOn({
  327. keepScreenOn: true
  328. });
  329. // 添加截屏监听
  330. uni.onUserCaptureScreen(() => {
  331. console.log('User captured screen');
  332. this.screenCaptureCount++;
  333. if (this.screenCaptureCount === 1) {
  334. uni.showModal({
  335. title: 'Warning',
  336. content: 'Screen capture detected. If you capture the screen again, your interview will be invalid.',
  337. showCancel: false,
  338. confirmText: 'OK'
  339. });
  340. } else if (this.screenCaptureCount >= 2) {
  341. uni.showModal({
  342. title: 'Interview Invalid',
  343. content: 'Your interview has been invalidated due to multiple screen captures. Please contact the recruiter for assistance.',
  344. showCancel: false,
  345. confirmText: 'OK',
  346. success: () => {
  347. this.invalidateInterview();
  348. }
  349. });
  350. }
  351. });
  352. const systemInfo = uni.getSystemInfoSync();
  353. const isMiniProgram = systemInfo.uniPlatform && systemInfo.uniPlatform.startsWith('mp-');
  354. if (isMiniProgram) {
  355. this.initPersonDetectionWebSocket();
  356. }
  357. },
  358. beforeDestroy() {
  359. // 移除截屏监听
  360. uni.offUserCaptureScreen();
  361. this.cleanupPersonDetectionWebSocket();
  362. },
  363. methods: {
  364. // 处理音频播放完成
  365. handleAudioEnd() {
  366. console.log('音频播放完成');
  367. // 设置等待回答状态
  368. this.isWaitingForAnswer = true;
  369. // 显示开始作答按钮
  370. this.showStartRecordingButton = true;
  371. // 延迟一下再开始倒计时,避免重复触发
  372. setTimeout(() => {
  373. // 开始倒计时
  374. this.startCountdown();
  375. }, 100);
  376. },
  377. // 开始倒计时
  378. startCountdown() {
  379. this.showCountdown = true;
  380. this.countdownValue = 5; // 重置倒计时值
  381. this.countdownTimer = setInterval(() => {
  382. this.countdownValue--;
  383. if (this.countdownValue <= 0) {
  384. // 倒计时结束,清除定时器
  385. this.clearCountdown();
  386. // 隐藏开始作答按钮
  387. this.showStartRecordingButton = false;
  388. // 开始录制
  389. this.startRecordingAnswer();
  390. }
  391. }, 1000);
  392. },
  393. // 清除倒计时
  394. clearCountdown() {
  395. if (this.countdownTimer) {
  396. clearInterval(this.countdownTimer);
  397. this.countdownTimer = null;
  398. }
  399. this.showCountdown = false;
  400. },
  401. // 处理追问问题
  402. async handleFollowUpQuestion(questionData) {
  403. console.log('处理追问问题数据:', questionData);
  404. // 检查数据完整性
  405. if (!questionData || !questionData.follow_up_voice_url || !questionData.follow_up_question) {
  406. console.error('追问数据不完整:', questionData);
  407. uni.showToast({
  408. title: '获取追问数据失败',
  409. icon: 'none'
  410. });
  411. return;
  412. }
  413. // 保存父问题
  414. this.parentQuestion = questionData.original_question || '';
  415. // 确保音频URL正确
  416. let audioUrl = '';
  417. if (questionData.follow_up_voice_url.direct_url) {
  418. audioUrl = questionData.follow_up_voice_url.direct_url;
  419. } else if (questionData.follow_up_voice_url.file_url) {
  420. audioUrl = questionData.follow_up_voice_url.file_url;
  421. }
  422. // 确保音频URL是完整的
  423. if (!audioUrl.startsWith('http')) {
  424. audioUrl = apiBaseUrl + audioUrl;
  425. }
  426. console.log('处理后的音频URL:', audioUrl);
  427. // 创建追问问题对象
  428. const followUpQuestionObj = {
  429. id: Date.now(),
  430. question: questionData.follow_up_question.trim(), // 去除可能的空白字符
  431. audioUrl: audioUrl,
  432. originalQuestion: questionData.original_question,
  433. sessionId: questionData.session_id
  434. };
  435. console.log('创建的追问问题对象:', followUpQuestionObj);
  436. // 添加到追问问题列表
  437. this.followUpQuestions.push(followUpQuestionObj);
  438. this.currentFollowUpIndex = this.followUpQuestions.length - 1;
  439. // 更新当前追问问题
  440. this.followUpQuestion = followUpQuestionObj.question;
  441. this.followUpAudioUrl = followUpQuestionObj.audioUrl;
  442. // 标记当前是追问问题
  443. this.isFollowUpQuestion = true;
  444. this.currentFollowUpQuestion = followUpQuestionObj;
  445. // 显示字幕
  446. this.showSubtitle(followUpQuestionObj.question, true);
  447. // 等待音频播放完成
  448. try {
  449. await this.playFollowUpAudio();
  450. console.log('音频播放完成,准备录制回答');
  451. // 开始准备录制回答
  452. this.prepareToAnswer(true, followUpQuestionObj);
  453. } catch (error) {
  454. console.error('音频播放失败:', error);
  455. // 即使音频播放失败,也继续准备录制回答
  456. this.prepareToAnswer(true, followUpQuestionObj);
  457. }
  458. },
  459. // 准备回答问题
  460. prepareToAnswer(isFollowUp = false, questionData = null) {
  461. console.log('准备回答问题:', { isFollowUp, questionData });
  462. // 重置录制状态
  463. this.isRecording = false;
  464. this.recordedTime = 0;
  465. this.showStopRecordingButton = false;
  466. // 设置当前问题状态
  467. if (isFollowUp && questionData) {
  468. this.currentFollowUpQuestion = questionData;
  469. this.isFollowUpQuestion = true;
  470. }
  471. // 设置等待回答状态
  472. this.isWaitingForAnswer = true;
  473. // 显示开始回答按钮和倒计时
  474. this.handleAudioEnd();
  475. },
  476. // 调用面试互动接口
  477. async callInterviewInteraction(questionId) {
  478. const userInfo = JSON.parse(uni.getStorageSync('userInfo'));
  479. const appId = uni.getStorageSync('appId');
  480. try {
  481. console.log('开始调用面试互动接口', { questionId, appId });
  482. const res = await uni.request({
  483. url: `${apiBaseUrl}/api/voice_interview_interaction/`,
  484. method: 'POST',
  485. data: {
  486. tenant_id: userInfo.tenant_id || 1,
  487. question_id: questionId,
  488. position_config_id: 6,
  489. application_id: appId
  490. },
  491. header: {
  492. 'content-type': 'application/json'
  493. }
  494. });
  495. console.log('面试互动接口返回数据:', res);
  496. if (res.data.success) {
  497. // 等待处理追问问题完成
  498. await this.handleFollowUpQuestion(res.data);
  499. } else {
  500. console.error('面试互动接口返回错误:', res.data);
  501. uni.showToast({
  502. title: '获取追问失败',
  503. icon: 'none'
  504. });
  505. }
  506. } catch (error) {
  507. console.error('调用面试互动接口失败:', error);
  508. }
  509. },
  510. // 播放追问音频
  511. async playFollowUpAudio() {
  512. return new Promise((resolve, reject) => {
  513. console.log('开始播放追问音频, URL:', this.followUpAudioUrl);
  514. // 检查音频URL
  515. if (!this.followUpAudioUrl) {
  516. console.error('没有音频URL');
  517. uni.showToast({
  518. title: '音频URL无效',
  519. icon: 'none'
  520. });
  521. reject(new Error('没有音频URL'));
  522. return;
  523. }
  524. // 显示加载提示
  525. uni.showLoading({
  526. title: '加载音频中...'
  527. });
  528. // 停止并销毁之前的音频实例
  529. this.stopAndDestroyAudio();
  530. try {
  531. // 创建新的音频上下文
  532. const innerAudioContext = uni.createInnerAudioContext();
  533. this.audioContext = innerAudioContext;
  534. // 设置音频属性
  535. innerAudioContext.autoplay = true;
  536. innerAudioContext.obeyMuteSwitch = false;
  537. innerAudioContext.volume = 1.0;
  538. // 监听音频加载状态
  539. innerAudioContext.onCanplay(() => {
  540. console.log('音频可以播放');
  541. uni.hideLoading();
  542. this.isAudioPlaying = true;
  543. uni.showToast({
  544. title: '正在播放追问',
  545. icon: 'none',
  546. duration: 2000
  547. });
  548. });
  549. // 监听音频播放完成
  550. innerAudioContext.onEnded(() => {
  551. console.log('追问音频播放完成');
  552. this.isAudioPlaying = false;
  553. resolve();
  554. // 延迟清理资源,确保不会影响播放完成的回调
  555. setTimeout(() => {
  556. this.stopAndDestroyAudio();
  557. // 音频播放完成后,自动准备录制回答
  558. this.prepareToAnswer(true, this.currentFollowUpQuestion);
  559. }, 100);
  560. });
  561. // 监听音频播放错误
  562. innerAudioContext.onError((res) => {
  563. console.error('音频播放错误:', res);
  564. this.isAudioPlaying = false;
  565. uni.hideLoading();
  566. uni.showToast({
  567. title: '音频播放失败',
  568. icon: 'none'
  569. });
  570. reject(res);
  571. this.stopAndDestroyAudio();
  572. });
  573. // 设置音频源
  574. console.log('设置音频源:', this.followUpAudioUrl);
  575. innerAudioContext.src = this.followUpAudioUrl;
  576. } catch (error) {
  577. console.error('创建或播放音频失败:', error);
  578. this.isAudioPlaying = false;
  579. uni.hideLoading();
  580. uni.showToast({
  581. title: '音频播放失败',
  582. icon: 'none'
  583. });
  584. this.stopAndDestroyAudio();
  585. reject(error);
  586. }
  587. });
  588. },
  589. // 停止并销毁音频
  590. stopAndDestroyAudio() {
  591. if (!this.audioContext) {
  592. return;
  593. }
  594. const ctx = this.audioContext;
  595. this.audioContext = null; // 先清除引用
  596. try {
  597. if (this.isAudioPlaying) {
  598. ctx.stop();
  599. }
  600. } catch (error) {
  601. console.error('停止音频播放失败:', error);
  602. }
  603. try {
  604. ctx.destroy();
  605. } catch (error) {
  606. console.error('销毁音频实例失败:', error);
  607. }
  608. this.isAudioPlaying = false;
  609. },
  610. // 绑定音频事件
  611. bindAudioEvents(audioContext, resolve, reject) {
  612. // 监听音频加载状态
  613. audioContext.onCanplay(() => {
  614. console.log('音频可以播放');
  615. uni.showToast({
  616. title: '正在播放追问',
  617. icon: 'none',
  618. duration: 2000
  619. });
  620. });
  621. // 监听播放进度
  622. audioContext.onTimeUpdate(() => {
  623. console.log('音频播放进度:', audioContext.currentTime);
  624. });
  625. // 监听音频播放完成
  626. audioContext.onEnded(() => {
  627. console.log('追问音频播放完成');
  628. this.cleanupAudioContext();
  629. resolve();
  630. });
  631. // 监听音频播放错误
  632. audioContext.onError((res) => {
  633. console.error('音频播放错误:', res);
  634. uni.showToast({
  635. title: '音频播放失败',
  636. icon: 'none'
  637. });
  638. this.cleanupAudioContext();
  639. reject(res);
  640. });
  641. },
  642. // 修改准备回答方法
  643. prepareToAnswer(isFollowUp = false, questionData = null) {
  644. // 重置录制状态
  645. this.isRecording = false;
  646. this.recordedTime = 0;
  647. this.showStopRecordingButton = false;
  648. // 设置当前问题状态
  649. if (isFollowUp && questionData) {
  650. this.currentFollowUpQuestion = questionData;
  651. this.isFollowUpQuestion = true;
  652. }
  653. // 设置等待回答状态
  654. this.isWaitingForAnswer = true;
  655. // 显示开始作答按钮和倒计时
  656. this.handleAudioEnd();
  657. },
  658. // 初始化相机
  659. async initCamera() {
  660. // 检查平台
  661. const systemInfo = uni.getSystemInfoSync();
  662. // 判断是否在小程序环境中
  663. const isMiniProgram = systemInfo.uniPlatform === 'mp-weixin' ||
  664. systemInfo.uniPlatform === 'mp-alipay' ||
  665. systemInfo.uniPlatform === 'mp-baidu';
  666. if (isMiniProgram) {
  667. // 小程序环境使用小程序的相机API
  668. this.useMiniProgramCameraComponent = true;
  669. // 创建相机上下文
  670. this.cameraContext = uni.createCameraContext();
  671. // 确保已获取录音和相机权限
  672. uni.getSetting({
  673. success: (res) => {
  674. // 检查录音权限
  675. if (!res.authSetting['scope.record']) {
  676. uni.authorize({
  677. scope: 'scope.record',
  678. success: () => {
  679. console.log('录音权限已获取');
  680. },
  681. fail: (err) => {
  682. console.error('录音权限获取失败:', err);
  683. this.showPermissionDialog('录音');
  684. }
  685. });
  686. }
  687. // 检查相机权限
  688. if (!res.authSetting['scope.camera']) {
  689. uni.authorize({
  690. scope: 'scope.camera',
  691. success: () => {
  692. console.log('相机权限已获取');
  693. },
  694. fail: (err) => {
  695. console.error('相机权限获取失败:', err);
  696. this.showPermissionDialog('相机');
  697. }
  698. });
  699. }
  700. // 在iOS上,可能需要额外的权限检查
  701. const systemInfo = uni.getSystemInfoSync();
  702. if (systemInfo.platform === 'ios') {
  703. // 在iOS上,不要使用无效的权限范围
  704. // 移除以下代码块
  705. /*
  706. if (!res.authSetting['scope.camera.record']) {
  707. uni.authorize({
  708. scope: 'scope.camera.record',
  709. success: () => {
  710. console.log('相机录制权限已获取');
  711. },
  712. fail: (err) => {
  713. console.error('相机录制权限获取失败:', err);
  714. this.showPermissionDialog('相机录制');
  715. }
  716. });
  717. }
  718. */
  719. // 确保同时获取了相机和录音权限
  720. if (!res.authSetting['scope.camera'] || !res.authSetting['scope.record']) {
  721. console.log('iOS需要同时获取相机和录音权限');
  722. }
  723. }
  724. }
  725. });
  726. } else {
  727. // H5/App环境使用浏览器的API
  728. try {
  729. // 请求摄像头和麦克风权限并获取媒体流,添加更多约束
  730. const constraints = {
  731. audio: {
  732. echoCancellation: true,
  733. noiseSuppression: true,
  734. autoGainControl: true
  735. },
  736. video: {
  737. width: { ideal: 640, max: 1280 }, // 控制视频宽度
  738. height: { ideal: 480, max: 720 }, // 控制视频高度
  739. frameRate: { ideal: 15, max: 24 }, // 控制帧率
  740. facingMode: 'user'
  741. }
  742. };
  743. const stream = await navigator.mediaDevices.getUserMedia(constraints);
  744. // 保存流以便后续使用
  745. this.cameraStream = stream;
  746. // 检查音频轨道
  747. const audioTracks = stream.getAudioTracks();
  748. console.log('音频轨道数量:', audioTracks.length);
  749. if (audioTracks.length > 0) {
  750. console.log('音频轨道已获取:', audioTracks[0].label);
  751. // 确保音频轨道已启用
  752. audioTracks[0].enabled = true;
  753. } else {
  754. console.warn('未检测到音频轨道,尝试单独获取音频');
  755. this.tryGetAudioOnly();
  756. }
  757. // 将流设置到视频元素
  758. const videoElement = this.$refs.userCameraVideo;
  759. if (videoElement) {
  760. videoElement.srcObject = stream;
  761. videoElement.muted = true; // 避免回声,但仍然录制声音
  762. }
  763. } catch (error) {
  764. console.error('获取摄像头失败:', error);
  765. this.cameraError = error.message || '无法访问摄像头';
  766. // 显示错误提示
  767. uni.showToast({
  768. title: '无法访问摄像头,请检查权限设置',
  769. icon: 'none'
  770. });
  771. }
  772. }
  773. },
  774. // 停止用户摄像头
  775. stopUserCamera() {
  776. if (this.cameraStream) {
  777. // 停止所有轨道
  778. this.cameraStream.getTracks().forEach(track => {
  779. track.stop();
  780. });
  781. this.cameraStream = null;
  782. }
  783. },
  784. async fetchData() {
  785. this.loading = true
  786. this.assistantResponse = ''
  787. this.audioTranscript = ''
  788. this.processedResponses = []
  789. try {
  790. // 使用uni.request代替fetch
  791. const requestTask = uni.request({
  792. url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
  793. method: 'POST',
  794. header: {
  795. 'Content-Type': 'application/json',
  796. 'Authorization': 'Bearer sk-9e1ec73a7d97493b8613c63f06b6110c'
  797. },
  798. data: {
  799. "model": "qwen-omni-turbo",
  800. "messages": [
  801. {
  802. "role": "user",
  803. "content": [
  804. {
  805. "type": "input_audio",
  806. "input_audio": {
  807. "data": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250211/tixcef/cherry.wav",
  808. "format": "wav"
  809. }
  810. },
  811. {
  812. "type": "text",
  813. "text": "这段音频在说什么"
  814. }
  815. ]
  816. }
  817. ],
  818. "stream":true,
  819. "stream_options":{
  820. "include_usage":true
  821. },
  822. "modalities":["text","audio"],
  823. "audio":{"voice":"Cherry","format":"wav"}
  824. },
  825. success: (res) => {
  826. console.log('请求成功,响应数据:', res.data);
  827. // 检查响应数据是否包含多个JSON对象
  828. if (typeof res.data === 'string' && res.data.includes('data: {')) {
  829. // 处理包含多个JSON对象的情况
  830. const chunks = res.data.split('data: ').filter(chunk => chunk.trim() !== '');
  831. chunks.forEach(chunk => {
  832. this.handleStreamResponse(chunk);
  833. });
  834. } else {
  835. // 处理单个响应对象的情况
  836. this.handleStreamResponse(res.data);
  837. }
  838. // 模拟获取到数字人视频并播放
  839. this.playDigitalHumanVideo();
  840. },
  841. fail: (err) => {
  842. console.error('请求失败:', err);
  843. },
  844. complete: () => {
  845. this.loading = false;
  846. }
  847. });
  848. } catch (error) {
  849. console.error('获取数据失败:', error);
  850. this.loading = false;
  851. }
  852. },
  853. handleStreamResponse(data) {
  854. // 处理流式响应数据
  855. if (typeof data === 'string') {
  856. // 处理字符串格式的响应
  857. if (data === '[DONE]') return;
  858. try {
  859. // 移除可能存在的换行符和多余空格
  860. const cleanData = data.trim();
  861. // 检查是否是有效的JSON字符串
  862. if (cleanData.startsWith('{') && cleanData.endsWith('}')) {
  863. const jsonData = JSON.parse(cleanData);
  864. this.processStreamChunk(jsonData);
  865. }
  866. } catch (e) {
  867. console.error('解析JSON失败:', e, '原始数据:', data);
  868. }
  869. } else {
  870. // 处理对象格式的响应
  871. this.processStreamChunk(data);
  872. }
  873. },
  874. processStreamChunk(chunk) {
  875. if (chunk.choices && chunk.choices.length > 0) {
  876. const choice = chunk.choices[0];
  877. // 处理助手回复内容
  878. if (choice.delta && choice.delta.content) {
  879. this.assistantResponse += choice.delta.content;
  880. }
  881. // 处理音频转写内容
  882. if (choice.delta && choice.delta.audio && choice.delta.audio.transcript) {
  883. this.audioTranscript += choice.delta.audio.transcript;
  884. }
  885. // 处理角色和音频转写
  886. if (choice.delta) {
  887. const result = {};
  888. if (choice.delta.role) {
  889. result.role = choice.delta.role;
  890. }
  891. if (choice.delta.audio && choice.delta.audio.transcript) {
  892. result.transcript = choice.delta.audio.transcript;
  893. }
  894. if (Object.keys(result).length > 0) {
  895. this.processedResponses.push(result);
  896. }
  897. }
  898. }
  899. },
  900. processResponseData() {
  901. // 处理返回的数据
  902. this.processedResponses = this.responses.map(item => {
  903. const result = {}
  904. // 处理角色信息
  905. if (item.delta && item.delta.role) {
  906. result.role = item.delta.role
  907. }
  908. // 处理音频转写文本
  909. if (item.delta && item.delta.audio && item.delta.audio.transcript) {
  910. result.transcript = item.delta.audio.transcript
  911. }
  912. return result
  913. }).filter(item => Object.keys(item).length > 0)
  914. },
  915. // 播放数字人视频
  916. playDigitalHumanVideo() {
  917. // 如果还没有加载问题数据,先获取问题数据
  918. if (this.videoList.length === 0) {
  919. this.fetchQuestions();
  920. return;
  921. }
  922. // 设置当前视频
  923. this.videoUrl = this.videoList[this.currentVideoIndex];
  924. this.videoPlaying = true;
  925. // 使用 uni.createVideoContext 来控制视频
  926. this.$nextTick(() => {
  927. const videoContext = uni.createVideoContext('myVideo', this);
  928. if (videoContext) {
  929. videoContext.play();
  930. // 设置超时检查,确认视频是否真的在播放
  931. setTimeout(() => {
  932. if (this.videoPlaying && this.$refs.videoPlayer) {
  933. console.log('视频应该正在播放');
  934. } else {
  935. console.log('视频可能未成功播放,尝试替代方案');
  936. this.tryAlternativeVideoPath();
  937. }
  938. }, 1000);
  939. } else {
  940. console.error('无法创建视频上下文');
  941. this.tryAlternativeVideoPath();
  942. }
  943. });
  944. },
  945. // 修改 tryAlternativeVideoPath 方法
  946. tryAlternativeVideoPath() {
  947. console.log('尝试使用替代路径');
  948. // 尝试不同的路径格式
  949. const alternativePaths = [
  950. './static/demo.mp4',
  951. '../static/demo.mp4',
  952. 'static/demo.mp4',
  953. '/static/demo.mp4',
  954. // 添加绝对路径
  955. `${window.location.origin}/static/demo.mp4`
  956. ];
  957. // 获取当前路径索引
  958. const currentPathIndex = alternativePaths.indexOf(this.videoUrl);
  959. const nextPathIndex = (currentPathIndex + 1) % alternativePaths.length;
  960. // 设置下一个路径
  961. this.videoUrl = alternativePaths[nextPathIndex];
  962. console.log('尝试新路径:', this.videoUrl);
  963. this.$nextTick(() => {
  964. const videoContext = uni.createVideoContext('myVideo', this);
  965. if (videoContext) {
  966. videoContext.stop();
  967. videoContext.play();
  968. // 检查是否成功播放
  969. setTimeout(() => {
  970. if (nextPathIndex === alternativePaths.length - 1 && !this.videoPlaying) {
  971. console.log('所有路径均失败,尝试使用uni.getVideoInfo检查视频');
  972. this.checkVideoWithAPI();
  973. }
  974. }, 1000);
  975. }
  976. });
  977. },
  978. // 添加新方法:使用uni API检查视频
  979. checkVideoWithAPI() {
  980. // 尝试使用uni.getVideoInfo API检查视频是否可用
  981. uni.getVideoInfo({
  982. src: '/static/demo.mp4',
  983. success: (res) => {
  984. console.log('视频信息获取成功:', res);
  985. // 如果能获取到视频信息,再次尝试播放
  986. this.videoUrl = '/static/demo.mp4';
  987. this.$nextTick(() => {
  988. const videoContext = uni.createVideoContext('myVideo', this);
  989. if (videoContext) {
  990. videoContext.play();
  991. }
  992. });
  993. },
  994. fail: (err) => {
  995. console.error('视频信息获取失败:', err);
  996. // 最后尝试使用uni.chooseVideo API
  997. this.fallbackToLocalVideo();
  998. }
  999. });
  1000. },
  1001. // 添加新方法:回退到本地视频
  1002. fallbackToLocalVideo() {
  1003. console.log('尝试使用本地视频资源');
  1004. // 检查平台
  1005. const platform = uni.getSystemInfoSync().platform;
  1006. if (platform === 'android' || platform === 'ios') {
  1007. // 移动端可以尝试使用本地资源
  1008. this.videoUrl = platform === 'android' ? 'android.resource://package_name/raw/demo' : 'file:///assets/demo.mp4';
  1009. this.$nextTick(() => {
  1010. const videoContext = uni.createVideoContext('myVideo', this);
  1011. if (videoContext) {
  1012. videoContext.play();
  1013. }
  1014. });
  1015. } else {
  1016. // 最终回退到静态图片
  1017. this.videoPlaying = false;
  1018. uni.showToast({
  1019. title: '视频加载失败,显示静态图片',
  1020. icon: 'none'
  1021. });
  1022. }
  1023. },
  1024. // 修改 handleVideoError 方法
  1025. handleVideoError(e) {
  1026. console.error('视频加载错误:', e);
  1027. // 记录更详细的错误信息
  1028. if (e && e.detail) {
  1029. console.error('详细错误信息:', e.detail);
  1030. }
  1031. // 检查视频文件是否存在
  1032. uni.getFileInfo({
  1033. filePath: this.videoUrl.startsWith('/') ? this.videoUrl.substring(1) : this.videoUrl,
  1034. success: (res) => {
  1035. console.log('文件存在,大小:', res.size);
  1036. // 文件存在但播放失败,可能是格式问题
  1037. this.tryDifferentFormat();
  1038. },
  1039. fail: (err) => {
  1040. console.error('文件不存在或无法访问:', err);
  1041. // 尝试不同路径
  1042. this.tryAlternativeVideoPath();
  1043. }
  1044. });
  1045. // 如果多次尝试后仍然失败,显示错误信息
  1046. uni.showToast({
  1047. title: '视频加载失败,请检查文件是否存在',
  1048. icon: 'none',
  1049. duration: 2000
  1050. });
  1051. },
  1052. // 添加新方法:尝试不同格式
  1053. tryDifferentFormat() {
  1054. console.log('尝试不同的视频格式');
  1055. // 尝试不同的视频格式
  1056. const formats = [
  1057. { ext: 'mp4', mime: 'video/mp4' },
  1058. { ext: 'webm', mime: 'video/webm' },
  1059. { ext: 'ogg', mime: 'video/ogg' },
  1060. { ext: 'mov', mime: 'video/quicktime' }
  1061. ];
  1062. // 获取当前文件名(不含扩展名)
  1063. const currentPath = this.videoUrl;
  1064. const basePath = currentPath.substring(0, currentPath.lastIndexOf('.')) || '/static/demo';
  1065. // 尝试下一个格式
  1066. let nextFormat = formats.find(f => !currentPath.endsWith(f.ext));
  1067. if (nextFormat) {
  1068. this.videoUrl = `${basePath}.${nextFormat.ext}`;
  1069. console.log('尝试新格式:', this.videoUrl);
  1070. this.$nextTick(() => {
  1071. const videoContext = uni.createVideoContext('myVideo', this);
  1072. if (videoContext) {
  1073. videoContext.stop();
  1074. videoContext.play();
  1075. }
  1076. });
  1077. } else {
  1078. // 所有格式都尝试过了,使用内置资源
  1079. this.useBuiltInResource();
  1080. }
  1081. },
  1082. // 添加新方法:使用内置资源
  1083. useBuiltInResource() {
  1084. console.log('尝试使用内置资源');
  1085. // 检查平台
  1086. const platform = uni.getSystemInfoSync().platform;
  1087. // 根据平台选择合适的视频源
  1088. if (platform === 'windows') {
  1089. // Windows平台特定处理
  1090. // 尝试使用相对于应用根目录的路径
  1091. const appRoot = process.env.UNI_INPUT_DIR || '';
  1092. this.videoUrl = `./static/demo.mp4`;
  1093. // 或者尝试使用file://协议
  1094. // this.videoUrl = `file:///${appRoot.replace(/\\/g, '/')}/static/demo.mp4`;
  1095. console.log('Windows平台尝试路径:', this.videoUrl);
  1096. } else if (platform === 'android' || platform === 'ios') {
  1097. // 移动端
  1098. this.useNativeVideo();
  1099. } else {
  1100. // Web平台
  1101. // 尝试使用完整URL
  1102. const baseUrl = window.location.origin;
  1103. this.videoUrl = `${baseUrl}/static/demo.mp4`;
  1104. console.log('Web平台尝试URL:', this.videoUrl);
  1105. }
  1106. this.$nextTick(() => {
  1107. const videoContext = uni.createVideoContext('myVideo', this);
  1108. if (videoContext) {
  1109. videoContext.play();
  1110. }
  1111. });
  1112. },
  1113. // 添加新方法:使用原生视频能力
  1114. useNativeVideo() {
  1115. console.log('尝试使用原生视频能力');
  1116. // 在移动端,可以尝试使用原生视频播放器
  1117. uni.chooseVideo({
  1118. sourceType: ['album'],
  1119. success: (res) => {
  1120. this.videoUrl = res.tempFilePath;
  1121. this.$nextTick(() => {
  1122. const videoContext = uni.createVideoContext('myVideo', this);
  1123. if (videoContext) {
  1124. videoContext.play();
  1125. }
  1126. });
  1127. },
  1128. fail: () => {
  1129. // 如果用户取消选择,回退到静态图片
  1130. this.videoPlaying = false;
  1131. uni.showToast({
  1132. title: '无法加载视频,显示静态图片',
  1133. icon: 'none'
  1134. });
  1135. }
  1136. });
  1137. },
  1138. // 处理视频结束事件
  1139. handleVideoEnded() {
  1140. console.log('视频播放结束事件触发');
  1141. // 获取视频元素和当前时间
  1142. const videoElement = this.$refs.videoPlayer;
  1143. if (videoElement) {
  1144. const currentTime = videoElement.currentTime || 0;
  1145. const duration = videoElement.duration || 0;
  1146. // 更严格的检查:只有当前时间非常接近视频总时长时才认为是真正结束
  1147. if (duration > 0 && (duration - currentTime > 0.5)) {
  1148. console.log('检测到可能的误触发结束事件,继续播放', currentTime, duration);
  1149. // 继续播放视频
  1150. setTimeout(() => {
  1151. const videoContext = uni.createVideoContext('myVideo', this);
  1152. if (videoContext) {
  1153. // 如果距离结束还很远,回到历史位置
  1154. if (duration - currentTime > 2) {
  1155. videoContext.seek(this.historyTime || 0);
  1156. } else {
  1157. // 如果接近结束但还没完全结束,继续播放
  1158. videoContext.play();
  1159. }
  1160. }
  1161. }, 100);
  1162. return;
  1163. }
  1164. console.log('视频确认结束,当前时间:', currentTime, '总时长:', duration);
  1165. }
  1166. this.videoPlaying = false;
  1167. // 检查是否是低分提示视频
  1168. if (this.isPlayingLowScoreVideo) {
  1169. console.log('低分提示视频播放完成');
  1170. // 检查重试次数
  1171. if (this.retryCount < this.maxRetryAttempts) {
  1172. console.log(`显示重新录制按钮 (重试次数: ${this.retryCount + 1}/${this.maxRetryAttempts})`);
  1173. // 显示重新录制按钮
  1174. this.showRerecordButton = true;
  1175. // 如果是追问问题,保持显示追问问题的字幕
  1176. if (this.isFollowUpQuestion && this.currentFollowUpQuestion) {
  1177. this.currentSubtitle = this.currentFollowUpQuestion.digital_human_video_subtitle || this.currentFollowUpQuestion.question;
  1178. }
  1179. } else {
  1180. console.log('已达到最大重试次数,继续下一题');
  1181. // 重置重试次数
  1182. this.retryCount = 0;
  1183. // 继续下一个问题
  1184. this.proceedToNextQuestion();
  1185. }
  1186. // 重置标记
  1187. this.isPlayingLowScoreVideo = false;
  1188. return;
  1189. }
  1190. // 检查是否是追问问题视频
  1191. if (this.isFollowUpQuestion) {
  1192. console.log('追问问题视频播放完成');
  1193. // 不重置追问状态,保持显示
  1194. // this.isFollowUpQuestion = false;
  1195. // this.currentFollowUpQuestion = null;
  1196. // 显示"开始回答"按钮,准备回答追问
  1197. this.showStartRecordingButton = true;
  1198. this.startCountdown();
  1199. return;
  1200. }
  1201. // 恢复原有的视频结束处理逻辑
  1202. // 对于所有视频,都显示相应的按钮
  1203. if (this.currentVideoIndex >= 1) {
  1204. // 对于问题视频,显示"开始回答"按钮
  1205. this.showStartRecordingButton = true;
  1206. this.startCountdown();
  1207. } else {
  1208. // 对于介绍视频(第一个视频),显示"开始面试"按钮
  1209. this.showAnswerButton = true;
  1210. }
  1211. // 重置追问状态
  1212. if (this.isFollowUpQuestion) {
  1213. this.isFollowUpQuestion = false;
  1214. this.currentFollowUpQuestion = null;
  1215. }
  1216. },
  1217. // 添加新方法:开始倒计时
  1218. startCountdown() {
  1219. // 先清除可能存在的倒计时
  1220. this.clearCountdown();
  1221. // 显示倒计时蒙层
  1222. this.showCountdown = true;
  1223. this.countdownValue = 10;
  1224. // 开始倒计时
  1225. this.countdownTimer = setInterval(() => {
  1226. if (!this.showStartRecordingButton) {
  1227. // 如果开始回答按钮已经被点击,清除倒计时
  1228. this.clearCountdown();
  1229. return;
  1230. }
  1231. this.countdownValue--;
  1232. if (this.countdownValue <= 0) {
  1233. // 倒计时结束,清除定时器
  1234. this.clearCountdown();
  1235. // 自动开始录制
  1236. this.showStartRecordingButton = false;
  1237. this.startRecordingAnswer();
  1238. }
  1239. }, 1000);
  1240. },
  1241. // 添加新方法:清除倒计时
  1242. clearCountdown() {
  1243. if (this.countdownTimer) {
  1244. clearInterval(this.countdownTimer);
  1245. this.countdownTimer = null;
  1246. }
  1247. this.showCountdown = false;
  1248. },
  1249. // 修改 handleStartRecordingClick 方法,使其可以终止倒计时
  1250. handleStartRecordingClick() {
  1251. // 隐藏"开始回答"按钮
  1252. this.showStartRecordingButton = false;
  1253. // 如果倒计时正在进行,清除倒计时
  1254. this.clearCountdown();
  1255. // 重置等待回答状态
  1256. this.isWaitingForAnswer = false;
  1257. // 直接开始录制
  1258. this.startRecordingAnswer();
  1259. },
  1260. // 修改 stopRecordingAnswer 方法,添加录制时长检查
  1261. stopRecordingAnswer() {
  1262. // 如果倒计时正在进行,先清除倒计时
  1263. this.clearCountdown();
  1264. // 重置等待回答状态
  1265. this.isWaitingForAnswer = false;
  1266. // 重置等待回答状态
  1267. this.isWaitingForAnswer = false;
  1268. // 检查录制时长
  1269. const recordingDuration = this.getRecordingDuration();
  1270. const minimumDuration = 3; // 最小录制时长(秒),改为3秒
  1271. const lowScoreDuration = 7; // 低分阈值时长(秒),少于7秒视为低分
  1272. if (recordingDuration < minimumDuration) {
  1273. // 录制时间过短,显示提示
  1274. uni.showToast({
  1275. title: '录制时间过短,请至少录制3秒',
  1276. icon: 'none',
  1277. duration: 2000
  1278. });
  1279. // 不执行停止录制逻辑,继续录制
  1280. return;
  1281. }
  1282. // 录制时长足够,停止录制
  1283. this.completeRecordingStop();
  1284. // 检查是否需要播放低分视频(录制时间少于7秒)
  1285. if (recordingDuration < lowScoreDuration) {
  1286. console.log(`录制时间 ${recordingDuration} 秒,少于 ${lowScoreDuration} 秒,将播放低分提示视频`);
  1287. // 设置标记,在上传完成后播放低分视频
  1288. this.needPlayLowScoreVideo = true;
  1289. } else {
  1290. this.needPlayLowScoreVideo = false;
  1291. }
  1292. },
  1293. // 添加新方法:开始录制用户回答
  1294. startRecordingAnswer() {
  1295. console.log('开始录制用户回答');
  1296. this.isRecording = true;
  1297. // 记录录制开始时间
  1298. this.recordingStartTime = Date.now();
  1299. this.recordingTimerCount = 0;
  1300. this.remainingTime = this.maxRecordingTime;
  1301. // 启动计时器,每秒更新计时
  1302. this.recordingTimer = setInterval(() => {
  1303. this.recordingTimerCount++;
  1304. this.remainingTime = Math.max(0, this.maxRecordingTime - this.recordingTimerCount);
  1305. // 更新UI显示录制时长和剩余时间
  1306. this.recordingTimeDisplay = this.formatTime(this.recordingTimerCount)/* +
  1307. ' / ' + this.formatTime(this.maxRecordingTime) */;
  1308. // 更新进度百分比 - 确保值在0-100之间
  1309. this.progressPercent = (this.recordingTimerCount / this.maxRecordingTime) * 100;
  1310. // 检查是否达到最大录制时间
  1311. if (this.recordingTimerCount >= this.maxRecordingTime) {
  1312. console.log('已达到最大录制时间(5分钟),自动停止录制');
  1313. this.stopRecordingAnswer();
  1314. }
  1315. }, 1000);
  1316. // 显示录制中的提示
  1317. // uni.showLoading({
  1318. // title: '正在录制...',
  1319. // mask: true
  1320. // });
  1321. // 根据平台选择不同的录制方法
  1322. const systemInfo = uni.getSystemInfoSync();
  1323. const isMiniProgram = systemInfo.uniPlatform && systemInfo.uniPlatform.startsWith('mp-');
  1324. if (isMiniProgram) {
  1325. // 小程序环境使用小程序的录制API
  1326. this.startMiniProgramRecording();
  1327. } else {
  1328. // H5/App环境使用MediaRecorder API
  1329. this.startBrowserRecording();
  1330. }
  1331. // 显示停止录制按钮
  1332. this.showStopRecordingButton = true;
  1333. },
  1334. // 添加一个新方法:重置相机组件
  1335. resetCamera() {
  1336. console.log('重置相机组件');
  1337. // 先完全移除相机组件
  1338. this.useMiniProgramCameraComponent = false;
  1339. // 释放相机上下文
  1340. if (this.cameraContext) {
  1341. this.cameraContext = null;
  1342. }
  1343. // 延迟后重新创建相机组件
  1344. setTimeout(() => {
  1345. this.useMiniProgramCameraComponent = true;
  1346. // 再次延迟创建相机上下文,确保组件已完全渲染
  1347. setTimeout(() => {
  1348. this.cameraContext = uni.createCameraContext();
  1349. console.log('相机组件已重置');
  1350. }, 500);
  1351. }, 500);
  1352. },
  1353. // 修改 startMiniProgramRecording 方法
  1354. startMiniProgramRecording() {
  1355. console.log('开始小程序录制方法');
  1356. // 获取平台信息
  1357. const systemInfo = uni.getSystemInfoSync();
  1358. const isIOS = systemInfo.platform === 'ios';
  1359. // 在iOS上,先重置相机
  1360. if (isIOS) {
  1361. // 先重置相机组件
  1362. this.resetCamera();
  1363. // 延迟执行录制,给相机组件足够的初始化时间
  1364. setTimeout(() => {
  1365. this.actualStartRecording(isIOS);
  1366. }, 1000);
  1367. } else {
  1368. // Android直接开始
  1369. this.actualStartRecording(isIOS);
  1370. }
  1371. },
  1372. // 添加新方法:实际开始录制
  1373. actualStartRecording(isIOS) {
  1374. if (!this.cameraContext) {
  1375. this.cameraContext = uni.createCameraContext();
  1376. console.log('创建新的相机上下文');
  1377. }
  1378. // 确保有录音权限
  1379. uni.getSetting({
  1380. success: (res) => {
  1381. const hasRecordAuth = res.authSetting['scope.record'];
  1382. const hasCameraAuth = res.authSetting['scope.camera'];
  1383. if (!hasRecordAuth || !hasCameraAuth) {
  1384. console.warn('缺少必要权限,请求权限');
  1385. this.requestMiniProgramPermissions();
  1386. return;
  1387. }
  1388. // 在iOS上,先检查相机状态
  1389. if (isIOS) {
  1390. console.log('iOS: 检查相机状态');
  1391. // 使用最简单的选项,设置最大录制时间为5分钟
  1392. const options = {
  1393. timeout: 300000, // 300秒超时 (5分钟)
  1394. quality: 'low', // 降低质量
  1395. compressed: true,
  1396. success: () => {
  1397. console.log('iOS录制开始成功');
  1398. },
  1399. fail: (err) => {
  1400. console.error('iOS录制失败:', err);
  1401. // 如果失败,尝试使用不同的方法
  1402. this.useAlternativeRecordingMethod();
  1403. }
  1404. };
  1405. try {
  1406. console.log('尝试开始录制');
  1407. this.recorder = this.cameraContext.startRecord(options);
  1408. } catch (e) {
  1409. console.error('开始录制异常:', e);
  1410. this.useAlternativeRecordingMethod();
  1411. }
  1412. } else {
  1413. // Android使用标准选项,设置最大录制时间为5分钟
  1414. const options = {
  1415. timeout: 300000, // 300秒超时 (5分钟)
  1416. quality: 'medium',
  1417. compressed: true,
  1418. success: () => {
  1419. console.log('Android录制开始成功');
  1420. },
  1421. fail: (err) => {
  1422. console.error('Android录制失败:', err);
  1423. uni.showToast({
  1424. title: '录制失败,请检查相机权限',
  1425. icon: 'none'
  1426. });
  1427. this.proceedToNextQuestion();
  1428. }
  1429. };
  1430. this.recorder = this.cameraContext.startRecord(options);
  1431. }
  1432. }
  1433. });
  1434. },
  1435. // 添加新方法:使用替代录制方法
  1436. useAlternativeRecordingMethod() {
  1437. console.log('使用替代录制方法');
  1438. // 在iOS上,可以尝试使用chooseVideo API作为备选
  1439. uni.showActionSheet({
  1440. itemList: ['使用相册中的视频', '跳过此问题'],
  1441. success: (res) => {
  1442. if (res.tapIndex === 0) {
  1443. // 选择相册中的视频
  1444. uni.chooseVideo({
  1445. sourceType: ['album'],
  1446. maxDuration: 300, // 从60秒改为300秒
  1447. camera: 'front',
  1448. success: (res) => {
  1449. console.log('选择视频成功:', res.tempFilePath);
  1450. // 停止录制状态
  1451. this.isRecording = false;
  1452. this.showStopRecordingButton = false;
  1453. // 上传选择的视频
  1454. this.uploadRecordedVideo(res.tempFilePath);
  1455. },
  1456. fail: () => {
  1457. console.log('用户取消选择视频');
  1458. this.proceedToNextQuestion();
  1459. }
  1460. });
  1461. } else {
  1462. // 跳过此问题
  1463. console.log('用户选择跳过问题');
  1464. this.proceedToNextQuestion();
  1465. }
  1466. },
  1467. fail: () => {
  1468. console.log('操作取消');
  1469. this.proceedToNextQuestion();
  1470. }
  1471. });
  1472. },
  1473. // 添加新方法:请求小程序权限
  1474. requestMiniProgramPermissions() {
  1475. // 请求录音权限
  1476. uni.authorize({
  1477. scope: 'scope.record',
  1478. success: () => {
  1479. console.log('录音权限已获取');
  1480. // 请求相机权限
  1481. uni.authorize({
  1482. scope: 'scope.camera',
  1483. success: () => {
  1484. console.log('相机权限已获取');
  1485. // 权限都获取到后,开始录制
  1486. this.startMiniProgramRecording();
  1487. },
  1488. fail: (err) => {
  1489. console.error('相机权限获取失败:', err);
  1490. this.showPermissionDialog('相机');
  1491. }
  1492. });
  1493. },
  1494. fail: (err) => {
  1495. console.error('录音权限获取失败:', err);
  1496. this.showPermissionDialog('录音');
  1497. }
  1498. });
  1499. },
  1500. // 修改浏览器环境下的录制方法
  1501. startBrowserRecording() {
  1502. if (!this.cameraStream) {
  1503. console.error('没有可用的摄像头流');
  1504. uni.showToast({
  1505. title: '录制失败,摄像头未就绪',
  1506. icon: 'none'
  1507. });
  1508. this.proceedToNextQuestion();
  1509. return;
  1510. }
  1511. try {
  1512. // 检查流中是否包含音频轨道
  1513. const hasAudio = this.cameraStream.getAudioTracks().length > 0;
  1514. if (!hasAudio) {
  1515. console.warn('警告:媒体流中没有音频轨道,尝试重新获取带音频的媒体流');
  1516. // 尝试重新获取带音频的媒体流
  1517. navigator.mediaDevices.getUserMedia({
  1518. audio: {
  1519. echoCancellation: true,
  1520. noiseSuppression: true,
  1521. autoGainControl: true
  1522. },
  1523. video: true
  1524. })
  1525. .then(newStream => {
  1526. // 检查新流是否包含音频轨道
  1527. const audioTracks = newStream.getAudioTracks();
  1528. if (audioTracks.length > 0) {
  1529. console.log('成功获取音频轨道:', audioTracks[0].label);
  1530. // 合并视频和音频轨道
  1531. const videoTrack = this.cameraStream.getVideoTracks()[0];
  1532. const audioTrack = newStream.getAudioTracks()[0];
  1533. // 创建新的媒体流,包含视频和音频轨道
  1534. const combinedStream = new MediaStream();
  1535. if (videoTrack) combinedStream.addTrack(videoTrack);
  1536. if (audioTrack) combinedStream.addTrack(audioTrack);
  1537. this.cameraStream = combinedStream;
  1538. // 更新视频元素的源
  1539. const videoElement = this.$refs.userCameraVideo;
  1540. if (videoElement) {
  1541. videoElement.srcObject = combinedStream;
  1542. videoElement.muted = true; // 避免回声,但仍然录制声音
  1543. }
  1544. // 继续录制过程
  1545. this.setupMediaRecorder(combinedStream);
  1546. } else {
  1547. console.warn('仍然无法获取音频轨道');
  1548. // 使用现有流继续
  1549. this.setupMediaRecorder(this.cameraStream);
  1550. }
  1551. })
  1552. .catch(err => {
  1553. console.error('获取音频失败:', err);
  1554. // 使用现有流继续
  1555. this.setupMediaRecorder(this.cameraStream);
  1556. });
  1557. } else {
  1558. console.log('检测到音频轨道,直接使用');
  1559. // 已有音频轨道,直接使用
  1560. this.setupMediaRecorder(this.cameraStream);
  1561. }
  1562. } catch (error) {
  1563. console.error('浏览器录制失败:', error);
  1564. uni.showToast({
  1565. title: '录制失败,浏览器可能不支持此功能',
  1566. icon: 'none'
  1567. });
  1568. this.proceedToNextQuestion();
  1569. }
  1570. },
  1571. // 修改 setupMediaRecorder 方法
  1572. setupMediaRecorder(stream) {
  1573. // 检查流中的轨道
  1574. const videoTracks = stream.getVideoTracks();
  1575. const audioTracks = stream.getAudioTracks();
  1576. console.log('设置MediaRecorder - 视频轨道:', videoTracks.length, '音频轨道:', audioTracks.length);
  1577. // 尝试使用支持度更广的编码格式
  1578. let mimeType = '';
  1579. const supportedTypes = [
  1580. 'video/webm;codecs=vp9,opus',
  1581. 'video/webm;codecs=vp8,opus',
  1582. 'video/webm;codecs=h264,opus',
  1583. 'video/mp4;codecs=h264,aac',
  1584. 'video/webm',
  1585. 'video/mp4'
  1586. ];
  1587. for (const type of supportedTypes) {
  1588. if (MediaRecorder.isTypeSupported(type)) {
  1589. mimeType = type;
  1590. console.log('使用支持的MIME类型:', mimeType);
  1591. break;
  1592. }
  1593. }
  1594. // 创建MediaRecorder实例,设置较低的比特率以减小文件大小
  1595. const options = {
  1596. mimeType: mimeType || '',
  1597. audioBitsPerSecond: 64000, // 降低音频比特率
  1598. videoBitsPerSecond: 1000000, // 降低视频比特率到1Mbps
  1599. };
  1600. try {
  1601. this.mediaRecorder = new MediaRecorder(stream, options);
  1602. console.log('MediaRecorder创建成功,使用选项:', options);
  1603. } catch (e) {
  1604. console.warn('使用指定选项创建MediaRecorder失败,尝试使用默认选项');
  1605. this.mediaRecorder = new MediaRecorder(stream);
  1606. }
  1607. // 存储录制的数据块
  1608. this.recordedChunks = [];
  1609. // 监听数据可用事件
  1610. this.mediaRecorder.ondataavailable = (event) => {
  1611. if (event.data && event.data.size > 0) {
  1612. this.recordedChunks.push(event.data);
  1613. console.log(`收到数据块: ${event.data.size} 字节`);
  1614. }
  1615. };
  1616. // 监听录制停止事件
  1617. this.mediaRecorder.onstop = async () => {
  1618. console.log('MediaRecorder停止,数据块数量:', this.recordedChunks.length);
  1619. if (this.recordedChunks.length === 0) {
  1620. console.error('没有录制到数据');
  1621. uni.showToast({
  1622. title: '录制失败,未捕获到数据',
  1623. icon: 'none'
  1624. });
  1625. this.proceedToNextQuestion();
  1626. return;
  1627. }
  1628. // 创建Blob对象
  1629. const mimeType = this.mediaRecorder.mimeType || 'video/webm';
  1630. const blob = new Blob(this.recordedChunks, { type: mimeType });
  1631. console.log('创建Blob,原始大小:', blob.size, '类型:', mimeType);
  1632. // 显示压缩中提示
  1633. uni.showLoading({
  1634. title: '正在处理视频...',
  1635. mask: true
  1636. });
  1637. try {
  1638. // 压缩视频
  1639. const compressedBlob = await this.compressVideo(blob);
  1640. // 将Blob转换为File对象
  1641. const fileName = `answer_${this.currentVideoIndex}_${Date.now()}.webm`;
  1642. const file = new File([compressedBlob], fileName, { type: mimeType });
  1643. // 隐藏压缩提示
  1644. uni.hideLoading();
  1645. // 上传文件
  1646. this.uploadRecordedVideo(file);
  1647. } catch (error) {
  1648. console.error('视频处理失败:', error);
  1649. uni.hideLoading();
  1650. // 如果压缩失败,使用原始视频
  1651. const fileName = `answer_${this.currentVideoIndex}_${Date.now()}.webm`;
  1652. const file = new File([blob], fileName, { type: mimeType });
  1653. this.uploadRecordedVideo(file);
  1654. }
  1655. };
  1656. // 监听错误
  1657. this.mediaRecorder.onerror = (event) => {
  1658. console.error('MediaRecorder错误:', event.error);
  1659. };
  1660. // 开始录制
  1661. try {
  1662. this.mediaRecorder.start(1000); // 每秒触发一次dataavailable事件
  1663. console.log('MediaRecorder开始录制');
  1664. // 设置最大录制时间为60秒
  1665. // 注意:我们已经在startRecordingAnswer方法中设置了全局计时器
  1666. // 这里作为备份机制,确保即使全局计时器失效,也能停止录制
  1667. this.mediaRecorderTimeout = setTimeout(() => {
  1668. if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
  1669. console.log('MediaRecorder备份超时机制触发,停止录制');
  1670. this.mediaRecorder.stop();
  1671. }
  1672. }, 300000); // 300秒 (5分钟)
  1673. } catch (e) {
  1674. console.error('开始录制失败:', e);
  1675. }
  1676. },
  1677. // 添加新方法:停止录制用户回答
  1678. stopRecordingAnswer() {
  1679. console.log('停止录制用户回答');
  1680. // 如果倒计时正在进行,先清除倒计时
  1681. if (this.countdownTimer) {
  1682. clearInterval(this.countdownTimer);
  1683. this.countdownTimer = null;
  1684. this.showCountdown = false;
  1685. }
  1686. // 重置等待回答状态
  1687. this.isWaitingForAnswer = false;
  1688. // 检查录制时长
  1689. const recordingDuration = this.getRecordingDuration();
  1690. const minimumDuration = 3; // 最小录制时长(秒),改为3秒
  1691. const lowScoreDuration = 7; // 低分阈值时长(秒),少于7秒视为低分
  1692. if (recordingDuration < minimumDuration) {
  1693. // 录制时间过短,显示提示
  1694. uni.showToast({
  1695. title: '录制时间过短,请至少录制3秒',
  1696. icon: 'none',
  1697. duration: 2000
  1698. });
  1699. // 不执行停止录制逻辑,继续录制
  1700. return;
  1701. }
  1702. // 录制时长足够,停止录制
  1703. this.completeRecordingStop();
  1704. // 检查是否需要播放低分视频(录制时间少于7秒)
  1705. if (recordingDuration < lowScoreDuration) {
  1706. console.log(`录制时间 ${recordingDuration} 秒,少于 ${lowScoreDuration} 秒,将播放低分提示视频`);
  1707. // 设置标记,在上传完成后播放低分视频
  1708. this.needPlayLowScoreVideo = true;
  1709. } else {
  1710. this.needPlayLowScoreVideo = false;
  1711. }
  1712. },
  1713. // 添加新方法:获取录制时长
  1714. getRecordingDuration() {
  1715. // 如果有明确的录制开始时间,计算实际录制时长
  1716. if (this.recordingStartTime) {
  1717. return (Date.now() - this.recordingStartTime) / 1000;
  1718. }
  1719. // 如果没有明确记录开始时间,尝试从视频元素获取
  1720. if (this.mediaRecorder && this.$refs.userCameraVideo) {
  1721. return this.$refs.userCameraVideo.currentTime || 0;
  1722. }
  1723. // 如果无法获取准确时长,返回估计值(如果有计时器)
  1724. if (this.recordingTimerCount) {
  1725. return this.recordingTimerCount;
  1726. }
  1727. // 默认返回0
  1728. return 0;
  1729. },
  1730. // 添加新方法:重置录制
  1731. resetRecording() {
  1732. // 保持录制状态,但重置计时器
  1733. if (this.recordingTimer) {
  1734. clearTimeout(this.recordingTimer);
  1735. }
  1736. // 重置录制开始时间
  1737. this.recordingStartTime = Date.now();
  1738. this.recordingTimerCount = 0;
  1739. // 如果是浏览器环境,需要重置MediaRecorder
  1740. if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
  1741. // 停止当前录制
  1742. this.mediaRecorder.stop();
  1743. // 清空已录制的数据块
  1744. this.recordedChunks = [];
  1745. // 短暂延迟后重新开始录制
  1746. setTimeout(() => {
  1747. this.startBrowserRecording();
  1748. }, 500);
  1749. }
  1750. // 如果是小程序环境,需要重置相机录制
  1751. else if (this.cameraContext) {
  1752. // 停止当前录制
  1753. this.cameraContext.stopRecord({
  1754. success: () => {
  1755. console.log('相机录制已停止');
  1756. },
  1757. fail: (err) => {
  1758. console.error('停止相机录制失败:', err);
  1759. }
  1760. });
  1761. }
  1762. // 显示提示
  1763. uni.showToast({
  1764. title: '请重新开始回答',
  1765. icon: 'none',
  1766. duration: 2000
  1767. });
  1768. },
  1769. // 修改 completeRecordingStop 方法,确保正确处理追问问题ID
  1770. completeRecordingStop() {
  1771. console.log('完成录制停止');
  1772. // 停止录制计时器
  1773. if (this.recordingTimer) {
  1774. clearInterval(this.recordingTimer);
  1775. this.recordingTimer = null;
  1776. }
  1777. // 记录最终录制时长
  1778. this.finalRecordingDuration = this.recordingTimerCount;
  1779. // 隐藏停止录制按钮
  1780. this.showStopRecordingButton = false;
  1781. // 隐藏录制指示器
  1782. this.isRecording = false;
  1783. // 根据平台选择不同的停止录制方法
  1784. const systemInfo = uni.getSystemInfoSync();
  1785. const isMiniProgram = systemInfo.uniPlatform && systemInfo.uniPlatform.startsWith('mp-');
  1786. if (isMiniProgram) {
  1787. // 小程序环境停止录制
  1788. this.stopMiniProgramRecording();
  1789. } else {
  1790. // H5/App环境停止录制
  1791. this.stopBrowserRecording();
  1792. }
  1793. // 记录当前问题ID和类型,确保追问问题使用正确的ID
  1794. if (this.isFollowUpQuestion && this.currentFollowUpQuestion) {
  1795. console.log('当前是追问问题,记录追问问题ID:', this.currentFollowUpQuestion.id);
  1796. // 可以在这里添加额外的标记,确保后续处理使用正确的ID
  1797. this.lastQuestionWasFollowUp = true;
  1798. this.lastFollowUpQuestionId = this.currentFollowUpQuestion.id;
  1799. } else {
  1800. this.lastQuestionWasFollowUp = false;
  1801. this.lastFollowUpQuestionId = null;
  1802. }
  1803. },
  1804. // 修改 stopMiniProgramRecording 方法,确保正确传递追问问题ID
  1805. stopMiniProgramRecording() {
  1806. console.log('停止小程序录制');
  1807. // 检查是否有录制上下文
  1808. if (!this.cameraContext) {
  1809. console.error('相机上下文不存在');
  1810. this.proceedToNextQuestion();
  1811. return;
  1812. }
  1813. // 停止录制
  1814. this.cameraContext.stopRecord({
  1815. success: async (res) => {
  1816. console.log('小程序录制停止成功:', res);
  1817. // 获取录制的视频文件路径
  1818. const videoPath = res.tempVideoPath;
  1819. // 上传视频文件
  1820. if (videoPath) {
  1821. // 等待上传完成后再继续
  1822. await this.uploadRecordedVideo(videoPath);
  1823. } else {
  1824. console.error('未获取到录制视频路径');
  1825. this.proceedToNextQuestion();
  1826. }
  1827. },
  1828. fail: (err) => {
  1829. console.error('小程序录制停止失败:', err);
  1830. this.proceedToNextQuestion();
  1831. }
  1832. });
  1833. },
  1834. // 添加新方法:停止浏览器录制
  1835. stopBrowserRecording() {
  1836. if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
  1837. this.mediaRecorder.stop();
  1838. console.log('浏览器录制停止成功');
  1839. } else {
  1840. console.error('MediaRecorder不存在或已经停止');
  1841. this.proceedToNextQuestion();
  1842. }
  1843. },
  1844. // 修改 uploadRecordedVideo 方法,添加记录父问题ID的逻辑
  1845. uploadRecordedVideo(fileOrPath) {
  1846. console.log('准备上传视频:', typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.name);
  1847. console.log('当前问题ID:', this.currentParentQuestionId);
  1848. console.log('当前问题类型:', this.isFollowUpQuestion);
  1849. console.log('当前追问问题:', this.currentFollowUpQuestion);
  1850. // 获取当前问题ID和相关信息
  1851. let questionId;
  1852. let isFollowUpQuestionUpload = false;
  1853. let questionText = '';
  1854. let questionForm = 0;
  1855. // 检查当前是否是追问问题
  1856. if (this.currentFollowUpQuestion &&
  1857. (this.currentFollowUpQuestion.question_form === 5 || this.isFollowUpQuestion)) {
  1858. // 使用追问问题的信息
  1859. questionId = this.currentFollowUpQuestion.id;
  1860. isFollowUpQuestionUpload = true;
  1861. questionText = this.currentFollowUpQuestion.question || '';
  1862. questionForm = 5; // 追问问题类型
  1863. console.log('正在上传追问问题的回答:', {
  1864. questionId,
  1865. questionText,
  1866. questionForm,
  1867. isFollowUp: true
  1868. });
  1869. } else {
  1870. // 使用常规问题的信息
  1871. const currentQuestion = this.getCurrentQuestionByIndex(this.currentVideoIndex);
  1872. if (currentQuestion && currentQuestion.id) {
  1873. questionId = currentQuestion.id;
  1874. questionText = currentQuestion.question || '';
  1875. console.log('正在上传常规问题的回答:', {
  1876. questionId,
  1877. questionText,
  1878. questionForm: 0
  1879. });
  1880. } else {
  1881. questionId = this.getDefaultQuestionId(this.currentVideoIndex);
  1882. console.log('使用默认问题ID:', questionId);
  1883. }
  1884. }
  1885. // 创建上传任务对象
  1886. const uploadTask = {
  1887. id: Date.now().toString(),
  1888. file: fileOrPath,
  1889. questionId: questionId,
  1890. isFollowUp: isFollowUpQuestionUpload,
  1891. questionText: questionText,
  1892. questionForm: questionForm,
  1893. attempts: 0,
  1894. maxAttempts: 3,
  1895. parentQuestionId: this.currentParentQuestionId
  1896. };
  1897. // 如果不是追问问题,记录当前问题ID作为父问题ID
  1898. if (!isFollowUpQuestionUpload) {
  1899. this.currentParentQuestionId = questionId;
  1900. }
  1901. // 添加到上传队列
  1902. this.uploadQueue.push(uploadTask);
  1903. // 初始化上传进度和状态
  1904. this.uploadProgress[uploadTask.id] = 0;
  1905. this.uploadStatus[uploadTask.id] = 'pending';
  1906. // 显示上传状态提示
  1907. uni.showToast({
  1908. title: '正在上传回答...',
  1909. icon: 'loading',
  1910. duration: 1500
  1911. });
  1912. // 更新上传状态文本
  1913. this.updateUploadStatusText();
  1914. // 等待上传完成后再执行后续操作
  1915. return new Promise((resolve) => {
  1916. const checkUploadStatus = () => {
  1917. if (!this.isUploading && this.uploadQueue.length === 0) {
  1918. // 上传完成后执行后续操作
  1919. this.handlePostUploadActions(uploadTask);
  1920. resolve();
  1921. } else {
  1922. // 如果还在上传,继续检查
  1923. setTimeout(checkUploadStatus, 500);
  1924. }
  1925. };
  1926. // 如果当前没有上传任务在进行,开始处理队列
  1927. if (!this.isUploading) {
  1928. this.processUploadQueue();
  1929. }
  1930. // 开始检查上传状态
  1931. checkUploadStatus();
  1932. });
  1933. },
  1934. // 添加新方法:处理上传后的逻辑
  1935. handlePostUploadActions(task) {
  1936. // 检查是否需要播放低分视频
  1937. if (this.needPlayLowScoreVideo && this.retryCount < 1) {
  1938. this.playLowScoreVideo();
  1939. this.needPlayLowScoreVideo = false;
  1940. } else {
  1941. // 如果是追问问题的回答,直接进入下一个问题
  1942. if (task.isFollowUp) {
  1943. console.log('追问问题回答完成,进入下一个问题');
  1944. this.retryCount = 0; // 重置重试次数
  1945. this.isFollowUpQuestion = false; // 重置追问标记
  1946. this.currentFollowUpQuestion = null; // 清除当前追问问题信息
  1947. this.proceedToNextQuestion();
  1948. } else {
  1949. // 如果是常规问题,记录父问题ID并检查追问问题
  1950. this.currentParentQuestionId = task.questionId;
  1951. console.log('检查常规问题的追问,父问题ID:', this.currentParentQuestionId);
  1952. this.checkAndPlayFollowUpQuestion(this.currentParentQuestionId);
  1953. }
  1954. }
  1955. },
  1956. // 修改 processUploadQueue 方法
  1957. processUploadQueue() {
  1958. // 如果队列为空,结束处理
  1959. if (this.uploadQueue.length === 0) {
  1960. this.isUploading = false;
  1961. this.showUploadStatus = false;
  1962. return;
  1963. }
  1964. // 标记为正在上传
  1965. this.isUploading = true;
  1966. this.showUploadStatus = true;
  1967. // 获取队列中的第一个任务
  1968. const task = this.uploadQueue[0];
  1969. // 更新任务状态
  1970. this.uploadStatus[task.id] = 'uploading';
  1971. this.updateUploadStatusText();
  1972. // 增加尝试次数
  1973. task.attempts++;
  1974. // 根据文件类型选择上传方法
  1975. if (typeof task.file === 'string') {
  1976. // 小程序环境,使用uni.uploadFile上传
  1977. this.uploadFileWithUni(task);
  1978. } else {
  1979. // 浏览器环境,使用XMLHttpRequest上传
  1980. this.uploadFileWithXHR(task);
  1981. }
  1982. },
  1983. // 添加新方法:使用XMLHttpRequest上传文件
  1984. uploadFileWithXHR(task) {
  1985. // 获取用户信息
  1986. const userInfo = uni.getStorageSync('userInfo');
  1987. const openid = userInfo ? (JSON.parse(userInfo).openid || '') : '';
  1988. const tenant_id = JSON.parse(uni.getStorageSync('userInfo')).tenant_id || '1';
  1989. // 创建FormData
  1990. const formData = new FormData();
  1991. formData.append('file', task.file);
  1992. formData.append('openid', openid);
  1993. formData.append('tenant_id', tenant_id);
  1994. formData.append('application_id', uni.getStorageSync('appId'));
  1995. formData.append('question_id', task.questionId);
  1996. formData.append('video_duration', 0);
  1997. formData.append('has_audio', 'true');
  1998. // 创建XMLHttpRequest
  1999. const xhr = new XMLHttpRequest();
  2000. xhr.open('POST', `${apiBaseUrl}/api/upload/`, true);
  2001. xhr.timeout = 120000; // 2分钟超时
  2002. // 监听上传进度
  2003. xhr.upload.onprogress = (event) => {
  2004. if (event.lengthComputable) {
  2005. const percentComplete = Math.round((event.loaded / event.total) * 100);
  2006. this.uploadProgress[task.id] = percentComplete;
  2007. this.updateUploadStatusText();
  2008. }
  2009. };
  2010. // 监听请求完成
  2011. xhr.onload = () => {
  2012. if (xhr.status === 200) {
  2013. try {
  2014. const res = JSON.parse(xhr.responseText);
  2015. console.log('上传响应:', res);
  2016. if (res.code === 2000) {
  2017. // 获取上传后的视频URL
  2018. const videoUrl = res.data.url || res.data.photoUrl || '';
  2019. if (videoUrl) {
  2020. // 上传成功,更新状态
  2021. this.uploadStatus[task.id] = 'success';
  2022. this.updateUploadStatusText();
  2023. // 提交到面试接口
  2024. this.submitVideoToInterview(videoUrl, task);
  2025. } else {
  2026. this.handleUploadFailure(task, '视频URL获取失败');
  2027. }
  2028. } else {
  2029. this.handleUploadFailure(task, res.msg || '上传失败');
  2030. }
  2031. } catch (e) {
  2032. this.handleUploadFailure(task, '解析响应失败');
  2033. }
  2034. } else {
  2035. this.handleUploadFailure(task, 'HTTP状态: ' + xhr.status);
  2036. }
  2037. };
  2038. // 监听错误
  2039. xhr.onerror = () => {
  2040. this.handleUploadFailure(task, '网络错误');
  2041. };
  2042. // 监听超时
  2043. xhr.ontimeout = () => {
  2044. this.handleUploadFailure(task, '上传超时');
  2045. };
  2046. // 发送请求
  2047. xhr.send(formData);
  2048. },
  2049. // 添加新方法:使用uni.uploadFile上传文件
  2050. uploadFileWithUni(task) {
  2051. // 获取用户信息
  2052. const userInfo = uni.getStorageSync('userInfo');
  2053. const openid = userInfo ? (JSON.parse(userInfo).openid || '') : '';
  2054. const tenant_id = JSON.parse(uni.getStorageSync('userInfo')).tenant_id || '1';
  2055. // 创建上传任务
  2056. const uploadTask = uni.uploadFile({
  2057. url: `${apiBaseUrl}/api/upload/`,
  2058. filePath: task.file,
  2059. name: 'file',
  2060. formData: {
  2061. openid: openid,
  2062. tenant_id: tenant_id,
  2063. application_id: uni.getStorageSync('appId'),
  2064. question_id: task.questionId,
  2065. video_duration: 0,
  2066. has_audio: 'true'
  2067. },
  2068. success: (uploadRes) => {
  2069. try {
  2070. const res = JSON.parse(uploadRes.data);
  2071. console.log('上传响应:', res);
  2072. if (res.code === 2000) {
  2073. // 获取上传后的视频URL
  2074. const videoUrl = res.data.permanent_link || res.data.url || '';
  2075. if (videoUrl) {
  2076. // 上传成功,更新状态
  2077. this.uploadStatus[task.id] = 'success';
  2078. this.updateUploadStatusText();
  2079. // 提交到面试接口
  2080. this.submitVideoToInterview(videoUrl, task);
  2081. } else {
  2082. this.handleUploadFailure(task, '视频URL获取失败');
  2083. }
  2084. } else {
  2085. this.handleUploadFailure(task, res.msg || '上传失败');
  2086. }
  2087. } catch (e) {
  2088. this.handleUploadFailure(task, '解析响应失败');
  2089. }
  2090. },
  2091. fail: (err) => {
  2092. this.handleUploadFailure(task, err.errMsg || '上传失败');
  2093. }
  2094. });
  2095. // 监听上传进度
  2096. uploadTask.onProgressUpdate((res) => {
  2097. this.uploadProgress[task.id] = res.progress;
  2098. this.updateUploadStatusText();
  2099. });
  2100. },
  2101. // 添加新方法:处理上传失败
  2102. handleUploadFailure(task, errorMsg) {
  2103. console.error('上传失败:', errorMsg);
  2104. // 更新任务状态
  2105. this.uploadStatus[task.id] = 'failed';
  2106. this.updateUploadStatusText();
  2107. // 检查是否还有重试机会
  2108. if (task.attempts < task.maxAttempts) {
  2109. console.log(`将在5秒后重试上传,当前尝试次数: ${task.attempts}/${task.maxAttempts}`);
  2110. // 5秒后重试
  2111. setTimeout(() => {
  2112. // 重置进度
  2113. this.uploadProgress[task.id] = 0;
  2114. // 重新开始上传
  2115. if (typeof task.file !== 'string') {
  2116. this.uploadFileWithXHR(task);
  2117. } else {
  2118. this.uploadFileWithUni(task);
  2119. }
  2120. }, 5000);
  2121. } else {
  2122. // 超过最大重试次数,移除任务并继续处理队列
  2123. console.log('超过最大重试次数,放弃上传');
  2124. // 显示错误提示
  2125. uni.showToast({
  2126. title: '视频上传失败,请稍后重试',
  2127. icon: 'none',
  2128. duration: 2000
  2129. });
  2130. // 从队列中移除当前任务
  2131. this.uploadQueue.shift();
  2132. // 继续处理队列中的下一个任务
  2133. this.processUploadQueue();
  2134. }
  2135. },
  2136. // 修改 submitVideoToInterview 方法
  2137. submitVideoToInterview(videoUrl, task) {
  2138. // 准备请求参数
  2139. const requestData = {
  2140. application_id: uni.getStorageSync('appId'),
  2141. question_id: task.questionId,
  2142. video_url: videoUrl,
  2143. tenant_id: JSON.parse(uni.getStorageSync('userInfo')).tenant_id || '1',
  2144. is_follow_up: task.isFollowUp ? 1 : 0,
  2145. question_form: task.questionForm,
  2146. question_text: task.questionText
  2147. };
  2148. console.log('提交视频到面试接口', requestData);
  2149. // 发送请求到面试接口
  2150. uni.request({
  2151. url: `${apiBaseUrl}/api/job/upload_video`,
  2152. method: 'POST',
  2153. data: requestData,
  2154. header: {
  2155. 'content-type': 'application/x-www-form-urlencoded'
  2156. },
  2157. success: async (res) => {
  2158. if (res.data.code === 200 || res.data.code === 2000) {
  2159. console.log('视频提交成功,准备调用面试互动接口');
  2160. // 如果不是追问回答,才调用面试互动接口获取追问
  2161. if (!task.isFollowUp) {
  2162. try {
  2163. // 调用面试互动接口
  2164. await this.callInterviewInteraction(res.data.data.job_position_question_id);
  2165. console.log('面试互动接口调用成功');
  2166. } catch (error) {
  2167. console.error('面试互动接口调用失败:', error);
  2168. // 如果获取追问失败,继续处理下一个问题
  2169. this.proceedToNextQuestion();
  2170. }
  2171. } else {
  2172. // 如果是追问回答,直接继续下一个问题
  2173. console.log('追问回答上传完成,继续下一个问题');
  2174. this.proceedToNextQuestion();
  2175. }
  2176. // 显示成功提示
  2177. uni.showToast({
  2178. title: '回答已提交',
  2179. icon: 'success',
  2180. duration: 1500
  2181. });
  2182. // 从队列中移除已完成的任务
  2183. this.uploadQueue.shift();
  2184. // 继续处理队列中的下一个任务
  2185. this.processUploadQueue();
  2186. } else {
  2187. this.handleUploadError(task, '提交失败: ' + (res.data.msg || '未知错误'));
  2188. }
  2189. },
  2190. fail: (err) => {
  2191. console.error('提交视频失败:', err);
  2192. this.handleUploadError(task, '提交失败: ' + err.errMsg);
  2193. }
  2194. });
  2195. },
  2196. // 添加新方法:处理提交失败
  2197. handleSubmitFailure(task, errorMsg) {
  2198. console.error('提交失败:', errorMsg);
  2199. // 更新任务状态
  2200. this.uploadStatus[task.id] = 'failed';
  2201. this.updateUploadStatusText();
  2202. // 检查是否还有重试机会
  2203. if (task.attempts < task.maxAttempts) {
  2204. console.log(`将在5秒后重试提交,当前尝试次数: ${task.attempts}/${task.maxAttempts}`);
  2205. // 5秒后重试
  2206. setTimeout(() => {
  2207. // 重新提交
  2208. this.submitVideoToInterview(task.videoUrl, task);
  2209. }, 5000);
  2210. } else {
  2211. // 超过最大重试次数,移除任务并继续处理队列
  2212. console.log('超过最大重试次数,放弃提交');
  2213. // 显示错误提示
  2214. uni.showToast({
  2215. title: '视频提交失败,请稍后重试',
  2216. icon: 'none',
  2217. duration: 2000
  2218. });
  2219. // 从队列中移除当前任务
  2220. this.uploadQueue.shift();
  2221. // 继续处理队列中的下一个任务
  2222. this.processUploadQueue();
  2223. }
  2224. },
  2225. // 添加新方法:更新上传状态文本
  2226. updateUploadStatusText() {
  2227. if (this.uploadQueue.length === 0) {
  2228. this.uploadStatusText = '';
  2229. return;
  2230. }
  2231. const currentTask = this.uploadQueue[0];
  2232. const progress = this.uploadProgress[currentTask.id] || 0;
  2233. const status = this.uploadStatus[currentTask.id] || 'pending';
  2234. let statusText = '';
  2235. switch (status) {
  2236. case 'pending':
  2237. statusText = '等待上传';
  2238. break;
  2239. case 'uploading':
  2240. statusText = `上传中 ${progress}%`;
  2241. break;
  2242. case 'success':
  2243. statusText = '上传成功,提交中...';
  2244. break;
  2245. case 'failed':
  2246. statusText = `上传失败,${currentTask.attempts < currentTask.maxAttempts ? '即将重试' : '已放弃'}`;
  2247. break;
  2248. }
  2249. // 添加问题类型和文本的中文释义
  2250. const questionTypeText = currentTask.isFollowUp ? '追问' : '问题';
  2251. const questionShortText = currentTask.questionText ?
  2252. (currentTask.questionText.length > 10 ? currentTask.questionText.substring(0, 10) + '...' : currentTask.questionText) :
  2253. `${questionTypeText}${currentTask.questionId}`;
  2254. this.uploadStatusText = `${questionTypeText}「${questionShortText}」:${statusText}`;
  2255. // 如果队列中有多个任务,显示总数
  2256. if (this.uploadQueue.length > 1) {
  2257. this.uploadStatusText += ` (${this.uploadQueue.length}个视频待处理)`;
  2258. }
  2259. },
  2260. // 修改 proceedToNextQuestion 方法,确保在切换视频时重置历史时间
  2261. proceedToNextQuestion() {
  2262. console.log('继续下一个问题');
  2263. // 增加当前视频索引,切换到下一个视频
  2264. this.currentVideoIndex++;
  2265. // 重置历史时间,避免触发异常跳转检测
  2266. this.historyTime = 0;
  2267. // 如果还有视频,播放下一个视频
  2268. if (this.currentVideoIndex < this.videoList.length) {
  2269. this.videoUrl = this.videoList[this.currentVideoIndex];
  2270. this.videoPlaying = true;
  2271. this.$nextTick(() => {
  2272. const videoContext = uni.createVideoContext('myVideo', this);
  2273. if (videoContext) {
  2274. videoContext.play();
  2275. }
  2276. });
  2277. } else {
  2278. // 所有视频都播放完毕,显示完成页面或返回
  2279. console.log('所有视频已播放完毕');
  2280. // 清理资源
  2281. this.stopUserCamera();
  2282. this.clearCountdown();
  2283. // 获取当前职位ID
  2284. const currentJobDetail = JSON.parse(uni.getStorageSync('selectedJob'));
  2285. console.log('当前职位ID:', currentJobDetail);
  2286. const jobId = currentJobDetail ? currentJobDetail.id : null;
  2287. console.log('当前职位ID:', jobId);
  2288. // 延迟后根据职位ID跳转到不同页面
  2289. setTimeout(() => {
  2290. // 如果职位ID为9,跳转到interview-question页面
  2291. if (jobId === 9) {
  2292. uni.navigateTo({
  2293. url: '/pages/interview-question/interview-question',
  2294. success: () => {
  2295. console.log('成功跳转到interview-question页面');
  2296. },
  2297. fail: (err) => {
  2298. console.error('跳转到interview-question页面失败:', err);
  2299. // 如果跳转失败,尝试使用redirectTo
  2300. uni.redirectTo({
  2301. url: '/pages/interview-question/interview-question',
  2302. fail: (redirectErr) => {
  2303. console.error('重定向到interview-question页面也失败:', redirectErr);
  2304. // 如果所有跳转方式都失败,尝试返回上一页
  2305. uni.navigateBack({
  2306. delta: 1
  2307. });
  2308. }
  2309. });
  2310. }
  2311. });
  2312. } else {
  2313. // 其他职位ID跳转到camera页面
  2314. uni.navigateTo({
  2315. url: '/pages/camera/camera',
  2316. success: () => {
  2317. console.log('成功跳转到camera页面');
  2318. },
  2319. fail: (err) => {
  2320. console.error('跳转到camera页面失败:', err);
  2321. // 如果跳转失败,尝试使用redirectTo
  2322. uni.redirectTo({
  2323. url: '/pages/camera/camera',
  2324. fail: (redirectErr) => {
  2325. console.error('重定向到camera页面也失败:', redirectErr);
  2326. // 最后尝试使用switchTab(如果camera是tabBar页面)
  2327. uni.switchTab({
  2328. url: '/pages/camera/camera',
  2329. fail: (switchErr) => {
  2330. console.error('所有跳转方式都失败:', switchErr);
  2331. // 如果所有跳转方式都失败,尝试返回上一页
  2332. uni.navigateBack({
  2333. delta: 1
  2334. });
  2335. }
  2336. });
  2337. }
  2338. });
  2339. }
  2340. });
  2341. }
  2342. }, 2000);
  2343. }
  2344. },
  2345. // 修改 handleAnswerButtonClick 方法,确保在切换视频时重置历史时间
  2346. handleAnswerButtonClick() {
  2347. console.log('点击开始回答按钮');
  2348. // 隐藏开始回答按钮
  2349. this.showAnswerButton = false;
  2350. // 增加当前视频索引,切换到下一个视频
  2351. this.currentVideoIndex++;
  2352. // 重置历史时间,避免触发异常跳转检测
  2353. this.historyTime = 0;
  2354. // 如果还有视频,播放下一个视频
  2355. if (this.currentVideoIndex < this.videoList.length) {
  2356. this.videoUrl = this.videoList[this.currentVideoIndex];
  2357. this.videoPlaying = true;
  2358. this.$nextTick(() => {
  2359. const videoContext = uni.createVideoContext('myVideo', this);
  2360. if (videoContext) {
  2361. videoContext.play();
  2362. }
  2363. });
  2364. }
  2365. },
  2366. // 处理相机错误
  2367. handleCameraError(e) {
  2368. console.error('相机错误:', e);
  2369. // 获取平台信息
  2370. const systemInfo = uni.getSystemInfoSync();
  2371. const isIOS = systemInfo.platform === 'ios';
  2372. if (isIOS) {
  2373. console.log('iOS相机错误,尝试重新初始化');
  2374. // 显示提示
  2375. uni.showToast({
  2376. title: '相机初始化中...',
  2377. icon: 'loading',
  2378. duration: 2000
  2379. });
  2380. // 重置相机
  2381. this.resetCamera();
  2382. // 如果正在录制,停止录制
  2383. if (this.isRecording) {
  2384. this.isRecording = false;
  2385. this.showStopRecordingButton = false;
  2386. // 提供替代选项
  2387. setTimeout(() => {
  2388. this.useAlternativeRecordingMethod();
  2389. }, 1000);
  2390. }
  2391. } else {
  2392. // 显示错误提示
  2393. uni.showToast({
  2394. title: '相机初始化失败,请检查权限设置',
  2395. icon: 'none'
  2396. });
  2397. // 尝试备用选项
  2398. this.tryFallbackOptions();
  2399. }
  2400. },
  2401. // 添加新方法:尝试备用选项
  2402. tryFallbackOptions() {
  2403. // 检查环境
  2404. const systemInfo = uni.getSystemInfoSync();
  2405. // 在小程序环境中使用小程序API
  2406. if (systemInfo.uniPlatform === 'mp-weixin' || systemInfo.uniPlatform === 'mp-alipay') {
  2407. this.useMiniProgramCamera();
  2408. }
  2409. // 在H5环境中显示静态图像
  2410. else {
  2411. this.showStaticCameraPlaceholder();
  2412. }
  2413. },
  2414. // 添加新方法:使用小程序相机API
  2415. useMiniProgramCamera() {
  2416. console.log('尝试使用小程序相机组件');
  2417. // 这里需要在模板中添加小程序相机组件
  2418. // 并设置一个标志来控制显示
  2419. this.useMiniProgramCameraComponent = true;
  2420. },
  2421. // 添加新方法:显示静态图像
  2422. showStaticCameraPlaceholder() {
  2423. console.log('显示静态摄像头占位图');
  2424. // 创建一个图像元素
  2425. const img = document.createElement('img');
  2426. img.src = '/static/images/camera-placeholder.png'; // 确保有这个图片资源
  2427. img.className = 'static-camera-image';
  2428. img.style.width = '100%';
  2429. img.style.height = '100%';
  2430. img.style.objectFit = 'cover';
  2431. // 获取容器并添加图像
  2432. const container = this.$refs.userCameraVideo.parentNode;
  2433. container.appendChild(img);
  2434. },
  2435. // 修改 handleTimeUpdate 方法
  2436. handleTimeUpdate(e) {
  2437. // 获取当前视频播放时间
  2438. const currentTime = e.detail.currentTime || e.target.currentTime;
  2439. // 如果正在录制,更新录制时间显示
  2440. if (this.isRecording && this.recordingTimerCount) {
  2441. this.recordingTimeDisplay = this.formatTime(this.recordingTimerCount);
  2442. }
  2443. // 如果正在播放低分提示视频,使用专门的字幕数组
  2444. if (this.isPlayingLowScoreVideo) {
  2445. if (this.lowScoreVideoSubtitles && this.lowScoreVideoSubtitles.length > 0) {
  2446. const subtitle = this.lowScoreVideoSubtitles[0]; // 使用第一个字幕
  2447. this.currentSubtitle = subtitle.text;
  2448. }
  2449. return;
  2450. }
  2451. // 获取当前视频对应的字幕数组
  2452. let currentSubtitles = null;
  2453. if (this.isFollowUpQuestion && this.currentFollowUpQuestion) {
  2454. // 追问问题字幕处理
  2455. const subtitleKey = `followUpSubtitles_${this.currentFollowUpQuestion.id}`;
  2456. currentSubtitles = this[subtitleKey] || [{
  2457. startTime: 0,
  2458. endTime: 30,
  2459. text: this.currentFollowUpQuestion.digital_human_video_subtitle ||
  2460. this.currentFollowUpQuestion.question
  2461. }];
  2462. } else {
  2463. // 常规问题字幕处理
  2464. if (this.currentVideoIndex === 0) {
  2465. currentSubtitles = this.subtitles;
  2466. } else {
  2467. const subtitleArrayName = `question${this.currentVideoIndex}Subtitles`;
  2468. currentSubtitles = this[subtitleArrayName];
  2469. }
  2470. }
  2471. // 确保字幕持续显示
  2472. if (currentSubtitles && currentSubtitles.length > 0) {
  2473. // 找到当前时间对应的字幕
  2474. const subtitle = currentSubtitles.find(sub =>
  2475. currentTime >= sub.startTime && currentTime <= sub.endTime
  2476. ) || currentSubtitles[0]; // 如果找不到对应时间的字幕,使用第一个字幕
  2477. // 只显示字幕文本
  2478. if (subtitle) {
  2479. this.currentSubtitle = subtitle.text;
  2480. }
  2481. }
  2482. // 更新历史时间和时间戳
  2483. this.historyTime = currentTime;
  2484. this.lastUpdateTime = Date.now();
  2485. },
  2486. // Add a new method to handle the "Start Recording" button click
  2487. handleStartRecordingClick() {
  2488. // 隐藏"开始回答"按钮
  2489. this.showStartRecordingButton = false;
  2490. // 如果倒计时正在进行,清除倒计时
  2491. this.clearCountdown();
  2492. // 重置等待回答状态
  2493. this.isWaitingForAnswer = false;
  2494. // 直接开始录制
  2495. this.startRecordingAnswer();
  2496. },
  2497. // 修改 checkAudioPermission 方法,确保在录制前获取音频权限
  2498. checkAudioPermission() {
  2499. // 在小程序环境中
  2500. const systemInfo = uni.getSystemInfoSync();
  2501. const isMiniProgram = systemInfo.uniPlatform && systemInfo.uniPlatform.startsWith('mp-');
  2502. if (isMiniProgram) {
  2503. // 小程序环境下的权限请求
  2504. uni.getSetting({
  2505. success: (res) => {
  2506. if (!res.authSetting['scope.record']) {
  2507. uni.authorize({
  2508. scope: 'scope.record',
  2509. success: () => {
  2510. console.log('录音权限已获取');
  2511. },
  2512. fail: (err) => {
  2513. console.error('录音权限获取失败:', err);
  2514. this.showPermissionDialog('录音');
  2515. }
  2516. });
  2517. }
  2518. if (!res.authSetting['scope.camera']) {
  2519. uni.authorize({
  2520. scope: 'scope.camera',
  2521. success: () => {
  2522. console.log('相机权限已获取');
  2523. },
  2524. fail: (err) => {
  2525. console.error('相机权限获取失败:', err);
  2526. this.showPermissionDialog('相机');
  2527. }
  2528. });
  2529. }
  2530. }
  2531. });
  2532. } else {
  2533. // 浏览器环境下的权限请求
  2534. // ... 保持原有代码不变
  2535. }
  2536. },
  2537. // 添加新方法:测试音频输入
  2538. testAudioInput() {
  2539. if (!this.cameraStream) {
  2540. console.warn('没有可用的媒体流,无法测试音频');
  2541. return;
  2542. }
  2543. const audioTracks = this.cameraStream.getAudioTracks();
  2544. if (audioTracks.length === 0) {
  2545. console.warn('没有检测到音频轨道,尝试重新获取');
  2546. this.tryGetAudioOnly();
  2547. return;
  2548. }
  2549. console.log('音频轨道信息:', audioTracks[0].getSettings());
  2550. // 创建音频上下文和分析器
  2551. try {
  2552. const AudioContext = window.AudioContext || window.webkitAudioContext;
  2553. if (!AudioContext) {
  2554. console.warn('浏览器不支持AudioContext');
  2555. return;
  2556. }
  2557. const audioContext = new AudioContext();
  2558. const analyser = audioContext.createAnalyser();
  2559. const microphone = audioContext.createMediaStreamSource(this.cameraStream);
  2560. microphone.connect(analyser);
  2561. // 设置分析器
  2562. analyser.fftSize = 256;
  2563. const bufferLength = analyser.frequencyBinCount;
  2564. const dataArray = new Uint8Array(bufferLength);
  2565. // 检测音频输入
  2566. let silenceCounter = 0;
  2567. const checkAudio = () => {
  2568. if (this.isRecording) return; // 如果正在录制,停止检测
  2569. analyser.getByteFrequencyData(dataArray);
  2570. // 计算平均音量
  2571. let sum = 0;
  2572. for (let i = 0; i < bufferLength; i++) {
  2573. sum += dataArray[i];
  2574. }
  2575. const average = sum / bufferLength;
  2576. if (average > 10) {
  2577. console.log('检测到音频输入,音量:', average);
  2578. silenceCounter = 0;
  2579. } else {
  2580. silenceCounter++;
  2581. if (silenceCounter > 10) {
  2582. console.warn('持续检测不到音频输入,可能麦克风未正常工作');
  2583. silenceCounter = 0;
  2584. }
  2585. }
  2586. // 继续检测
  2587. requestAnimationFrame(checkAudio);
  2588. };
  2589. // 开始检测
  2590. checkAudio();
  2591. } catch (e) {
  2592. console.error('音频测试失败:', e);
  2593. }
  2594. },
  2595. // 添加新方法:尝试单独获取音频
  2596. tryGetAudioOnly() {
  2597. navigator.mediaDevices.getUserMedia({ audio: true })
  2598. .then(audioStream => {
  2599. // 如果已有视频流,合并音频和视频轨道
  2600. if (this.cameraStream) {
  2601. const videoTrack = this.cameraStream.getVideoTracks()[0];
  2602. const audioTrack = audioStream.getAudioTracks()[0];
  2603. // 创建新的媒体流,包含视频和音频轨道
  2604. const combinedStream = new MediaStream();
  2605. if (videoTrack) combinedStream.addTrack(videoTrack);
  2606. if (audioTrack) combinedStream.addTrack(audioTrack);
  2607. // 更新摄像头流
  2608. this.cameraStream = combinedStream;
  2609. // 更新视频元素的源
  2610. const videoElement = this.$refs.userCameraVideo;
  2611. if (videoElement) {
  2612. videoElement.srcObject = combinedStream;
  2613. videoElement.muted = true; // 避免回声,但仍然录制声音
  2614. }
  2615. console.log('成功合并音频和视频轨道');
  2616. } else {
  2617. console.warn('没有视频流可合并');
  2618. }
  2619. })
  2620. .catch(err => {
  2621. console.error('单独获取音频失败:', err);
  2622. });
  2623. },
  2624. // 添加新方法:显示权限对话框
  2625. showPermissionDialog(permissionType) {
  2626. uni.showModal({
  2627. title: '需要权限',
  2628. content: `请允许使用${permissionType}权限,否则可能影响面试功能`,
  2629. confirmText: '去设置',
  2630. success: (res) => {
  2631. if (res.confirm) {
  2632. uni.openSetting({
  2633. success: (settingRes) => {
  2634. console.log('设置页面打开成功', settingRes);
  2635. }
  2636. });
  2637. }
  2638. }
  2639. });
  2640. },
  2641. // 添加重试上传方法
  2642. retryVideoUpload() {
  2643. if (this.lastVideoToRetry) {
  2644. // 隐藏重试按钮
  2645. this.showRetryButton = false;
  2646. // 显示加载提示
  2647. uni.showLoading({
  2648. title: '正在重新提交...',
  2649. mask: true
  2650. });
  2651. // 重新提交视频
  2652. this.submitVideoToInterview(this.lastVideoToRetry);
  2653. } else {
  2654. uni.showToast({
  2655. title: '没有可重试的视频',
  2656. icon: 'none'
  2657. });
  2658. }
  2659. },
  2660. // 添加一个新方法用于压缩视频
  2661. async compressVideo(videoBlob) {
  2662. // 如果视频大小小于某个阈值,直接返回原视频
  2663. if (videoBlob.size < 5 * 1024 * 1024) { // 小于5MB
  2664. return videoBlob;
  2665. }
  2666. console.log('开始压缩视频,原始大小:', videoBlob.size);
  2667. // 创建一个视频元素来加载视频
  2668. const videoElement = document.createElement('video');
  2669. videoElement.muted = true;
  2670. videoElement.autoplay = false;
  2671. // 创建一个canvas元素用于绘制视频帧
  2672. const canvas = document.createElement('canvas');
  2673. const ctx = canvas.getContext('2d');
  2674. // 设置视频源
  2675. videoElement.src = URL.createObjectURL(videoBlob);
  2676. return new Promise((resolve) => {
  2677. videoElement.onloadedmetadata = () => {
  2678. // 设置canvas尺寸为视频的一半
  2679. const width = Math.floor(videoElement.videoWidth / 2);
  2680. const height = Math.floor(videoElement.videoHeight / 2);
  2681. canvas.width = width;
  2682. canvas.height = height;
  2683. // 创建MediaRecorder来录制canvas
  2684. const stream = canvas.captureStream(15); // 15fps
  2685. // 如果原视频有音轨,添加到新流中
  2686. if (videoElement.captureStream) {
  2687. const originalStream = videoElement.captureStream();
  2688. const audioTracks = originalStream.getAudioTracks();
  2689. if (audioTracks.length > 0) {
  2690. stream.addTrack(audioTracks[0]);
  2691. }
  2692. }
  2693. // 设置较低的比特率
  2694. const options = {
  2695. mimeType: 'video/webm;codecs=vp8,opus',
  2696. audioBitsPerSecond: 64000,
  2697. videoBitsPerSecond: 800000 // 800kbps
  2698. };
  2699. const mediaRecorder = new MediaRecorder(stream, options);
  2700. const chunks = [];
  2701. mediaRecorder.ondataavailable = (e) => {
  2702. if (e.data.size > 0) {
  2703. chunks.push(e.data);
  2704. }
  2705. };
  2706. mediaRecorder.onstop = () => {
  2707. const compressedBlob = new Blob(chunks, { type: 'video/webm' });
  2708. console.log('视频压缩完成,压缩后大小:', compressedBlob.size);
  2709. resolve(compressedBlob);
  2710. };
  2711. // 开始播放视频并录制
  2712. videoElement.onplay = () => {
  2713. mediaRecorder.start(10);
  2714. // 绘制视频帧到canvas
  2715. const drawFrame = () => {
  2716. if (videoElement.paused || videoElement.ended) {
  2717. mediaRecorder.stop();
  2718. return;
  2719. }
  2720. ctx.drawImage(videoElement, 0, 0, width, height);
  2721. requestAnimationFrame(drawFrame);
  2722. };
  2723. drawFrame();
  2724. };
  2725. videoElement.play();
  2726. };
  2727. });
  2728. },
  2729. // 修改 checkIOSCameraRecordPermission 方法
  2730. checkIOSCameraRecordPermission() {
  2731. const systemInfo = uni.getSystemInfoSync();
  2732. if (systemInfo.platform !== 'ios') return;
  2733. // 在iOS上,需要同时请求相机和录音权限
  2734. uni.getSetting({
  2735. success: (res) => {
  2736. // 检查相机权限
  2737. if (!res.authSetting['scope.camera']) {
  2738. uni.authorize({
  2739. scope: 'scope.camera',
  2740. success: () => {
  2741. console.log('iOS相机权限已获取');
  2742. },
  2743. fail: (err) => {
  2744. console.error('iOS相机权限获取失败:', err);
  2745. this.showPermissionDialog('相机');
  2746. }
  2747. });
  2748. }
  2749. // 检查录音权限
  2750. if (!res.authSetting['scope.record']) {
  2751. uni.authorize({
  2752. scope: 'scope.record',
  2753. success: () => {
  2754. console.log('iOS录音权限已获取');
  2755. },
  2756. fail: (err) => {
  2757. console.error('iOS录音权限获取失败:', err);
  2758. this.showPermissionDialog('录音');
  2759. }
  2760. });
  2761. }
  2762. }
  2763. });
  2764. },
  2765. // 添加新方法:检查并修复渲染问题
  2766. checkAndFixRenderingIssues() {
  2767. // 检查全局对象,防止渲染错误
  2768. try {
  2769. // 检查是否有全局变量u
  2770. if (typeof u !== 'undefined' && u) {
  2771. // 检查currentQuestion属性
  2772. if (!u.currentQuestion) {
  2773. console.log('修复: 创建缺失的currentQuestion对象');
  2774. u.currentQuestion = {};
  2775. }
  2776. // 确保isImportant属性存在
  2777. if (u.currentQuestion && typeof u.currentQuestion.isImportant === 'undefined') {
  2778. console.log('修复: 设置缺失的isImportant属性');
  2779. u.currentQuestion.isImportant = false;
  2780. }
  2781. }
  2782. } catch (e) {
  2783. console.log('防御性检查异常:', e);
  2784. }
  2785. },
  2786. // 添加格式化时间的辅助方法
  2787. formatTime(seconds) {
  2788. if (!seconds && seconds !== 0) return '03:30'; // 默认显示03:30
  2789. const minutes = Math.floor(seconds / 60);
  2790. const remainingSeconds = seconds % 60;
  2791. return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
  2792. },
  2793. // 添加获取问题数据的方法
  2794. fetchQuestions() {
  2795. this.loading = true;
  2796. // 获取tenant_id,如果没有则使用默认值1
  2797. const tenant_id = JSON.parse(uni.getStorageSync('userInfo')).tenant_id || '1';
  2798. // 获取当前选择的职位ID
  2799. let position_id = 1; // 默认值
  2800. try {
  2801. const selectedJob = JSON.parse(uni.getStorageSync('selectedJob') || '{}');
  2802. if (selectedJob && selectedJob.id) {
  2803. position_id = selectedJob.id;
  2804. console.log('使用选择的职位ID获取问题:', position_id);
  2805. } else {
  2806. console.warn('未找到选择的职位信息,使用默认职位ID:', position_id);
  2807. }
  2808. } catch (e) {
  2809. console.error('解析职位信息失败:', e);
  2810. }
  2811. // 构建完整的请求URL,便于调试
  2812. const timestamp = Date.now();
  2813. const requestUrl = `${apiBaseUrl}/api/wechat/open_questions/?position_id=${position_id}&tenant_id=${tenant_id}&question_form=0&_t=${timestamp}`;
  2814. console.log('完整的请求URL:', requestUrl);
  2815. uni.request({
  2816. url: requestUrl,
  2817. method: 'GET',
  2818. success: (res) => {
  2819. if (res.data && res.data.code === 2000 && res.data.data && res.data.data.items) {
  2820. console.log('获取问题数据成功:', res.data.data.items);
  2821. // 存储问题数据
  2822. this.questions = res.data.data.items;
  2823. // 处理视频列表和字幕
  2824. this.processQuestionData();
  2825. // 播放介绍视频
  2826. this.playIntroVideo();
  2827. } else {
  2828. console.error('获取问题数据失败:', res.data);
  2829. uni.showToast({
  2830. title: '获取问题数据失败',
  2831. icon: 'none'
  2832. });
  2833. /* // 使用默认视频和字幕
  2834. this.useDefaultVideosAndSubtitles(); */
  2835. }
  2836. },
  2837. fail: (err) => {
  2838. console.error('请求问题数据失败:', err);
  2839. uni.showToast({
  2840. title: '网络错误,请检查网络连接',
  2841. icon: 'none'
  2842. });
  2843. /* // 使用默认视频和字幕
  2844. this.useDefaultVideosAndSubtitles(); */
  2845. },
  2846. complete: () => {
  2847. this.loading = false;
  2848. }
  2849. });
  2850. },
  2851. // 处理问题数据,提取视频URL和字幕
  2852. processQuestionData() {
  2853. // 清空视频列表和字幕
  2854. this.videoList = [];
  2855. // 首先添加介绍视频
  2856. this.videoList.push(this.introVideoUrl);
  2857. // 修改介绍视频的字幕,移除翻译
  2858. this.subtitles = [
  2859. {
  2860. startTime: 0,
  2861. endTime: 5,
  2862. text: '你好,我是本次面试的面试官,欢迎参加本公司的线上面试!'
  2863. },
  2864. {
  2865. startTime: 5,
  2866. endTime: 13,
  2867. text: '面试预计需要15分钟,请你提前安排在网络良好、光线亮度合适、且相对安静的环境参加这次面试'
  2868. },
  2869. {
  2870. startTime: 13,
  2871. endTime: 20,
  2872. text: '以免影响本次面试的结果。如果你在面试过程中遇到问题,请与我们的招聘人员联系。'
  2873. }
  2874. ];
  2875. // 按照sequence_number排序问题
  2876. const sortedQuestions = [...this.questions].sort((a, b) => a.sequence_number - b.sequence_number);
  2877. // 只处理前5个问题(或者更少,如果问题总数不足5个)
  2878. const questionsToUse = sortedQuestions.slice(0, 5);
  2879. // 为每个问题添加视频URL和字幕
  2880. questionsToUse.forEach((question, index) => {
  2881. if (question.digital_human_video_url) {
  2882. this.videoList.push(question.digital_human_video_url);
  2883. // 创建字幕映射,只保留文本
  2884. const subtitleArray = [{
  2885. startTime: 0,
  2886. endTime: 30, // 延长字幕显示时间到30秒
  2887. text: question.digital_human_video_subtitle || question.question
  2888. }];
  2889. const videoIndex = this.videoList.length - 1;
  2890. this[`question${videoIndex}Subtitles`] = subtitleArray;
  2891. // 存储字幕映射
  2892. this.subtitleMap[question.digital_human_video_url] = subtitleArray;
  2893. }
  2894. });
  2895. // 如果没有获取到任何问题视频,使用默认视频
  2896. if (this.videoList.length <= 1) {
  2897. /* this.useDefaultVideosAndSubtitles(); */
  2898. } else {
  2899. console.log('处理后的视频列表:', this.videoList);
  2900. console.log('处理后的字幕:', this.subtitles);
  2901. }
  2902. },
  2903. // 使用默认视频和字幕(作为备用)
  2904. useDefaultVideosAndSubtitles() {
  2905. console.log('使用默认视频和字幕');
  2906. // 设置默认视频列表
  2907. /* this.videoList = [
  2908. this.introVideoUrl, // 介绍视频
  2909. 'http://121.36.251.245:9000/minlong/tenant_1/general_uploads/e465e23d377b4456bbb3b755d3ad9500.mp4',
  2910. 'http://121.36.251.245:9000/minlong/tenant_1/general_uploads/9bc84230d2a14978b4ea0a97e4102a15.mp4',
  2911. 'http://121.36.251.245:9000/minlong/tenant_1/general_uploads/9b48e824432f451d9e27e12b884d9074.mp4',
  2912. 'http://121.36.251.245:9000/minlong/tenant_1/general_uploads/52f1445b400345e1a673b3c7f05e5cc1.mp4',
  2913. 'http://121.36.251.245:9000/minlong/tenant_1/general_uploads/abdaa6fda8494e3a8613304743ed0433.mp4'
  2914. ]; */
  2915. // 设置默认字幕
  2916. this.subtitles = [
  2917. {
  2918. startTime: 0,
  2919. endTime: 5,
  2920. text: '你好,我是本次面试的面试官,欢迎参加本公司的线上面试!'
  2921. },
  2922. {
  2923. startTime: 5,
  2924. endTime: 13,
  2925. text: '面试预计需要15分钟,请你提前安排在网络良好、光线亮度合适、且相对安静的环境参加这次面试'
  2926. },
  2927. {
  2928. startTime: 13,
  2929. endTime: 20,
  2930. text: '以免影响本次面试的结果。如果你在面试过程中遇到问题,请与我们的招聘人员联系。'
  2931. }
  2932. ];
  2933. // 设置默认问题字幕
  2934. this.question1Subtitles = [{
  2935. startTime: 0,
  2936. endTime: 10,
  2937. text: '请结合您的基本信息与过往履历进行简单的自我介绍,并讲一讲您有哪些优势胜任本岗位:'
  2938. }];
  2939. this.question2Subtitles = [{
  2940. startTime: 0,
  2941. endTime: 4,
  2942. text: '在工作中,你如何确保个人防护装备的正确使用?'
  2943. }];
  2944. this.question3Subtitles = [{
  2945. startTime: 0,
  2946. endTime: 4,
  2947. text: '描述一次你与团队合作改善生产流程的经历。'
  2948. }];
  2949. this.question4Subtitles = [{
  2950. startTime: 0,
  2951. endTime: 6,
  2952. text: '你在团队合作中曾遇到过哪些挑战?如何解决团队内部的分歧?'
  2953. }];
  2954. this.question5Subtitles = [{
  2955. startTime: 0,
  2956. endTime: 5,
  2957. text: '您已完成本次面试全部题目,请问您对于这个岗位还有什么想要了解的吗?'
  2958. }];
  2959. },
  2960. // 播放介绍视频
  2961. playIntroVideo() {
  2962. // 设置第一个视频(介绍视频)
  2963. this.videoUrl = this.videoList[0];
  2964. this.videoPlaying = true;
  2965. this.$nextTick(() => {
  2966. const videoContext = uni.createVideoContext('myVideo', this);
  2967. if (videoContext) {
  2968. videoContext.play();
  2969. }
  2970. });
  2971. },
  2972. // 添加新方法:获取默认问题ID
  2973. getDefaultQuestionId(videoIndex) {
  2974. // 根据视频索引映射到默认问题ID
  2975. switch(videoIndex) {
  2976. case 1: // 第一个问题
  2977. return 10;
  2978. case 2: // 第二个问题
  2979. return 11;
  2980. case 3: // 第三个问题
  2981. return 12;
  2982. case 4: // 第四个问题
  2983. return 13;
  2984. default:
  2985. return 10; // 默认值
  2986. }
  2987. },
  2988. // 添加新方法:根据索引获取当前问题
  2989. getCurrentQuestionByIndex(videoIndex) {
  2990. // 视频索引减1,因为第一个视频是介绍视频
  2991. const questionIndex = videoIndex - 1;
  2992. // 按照sequence_number排序问题
  2993. const sortedQuestions = [...this.questions].sort((a, b) => a.sequence_number - b.sequence_number);
  2994. // 检查索引是否有效
  2995. if (questionIndex >= 0 && questionIndex < sortedQuestions.length) {
  2996. return sortedQuestions[questionIndex];
  2997. }
  2998. return null;
  2999. },
  3000. // 添加新方法:重置录制状态,准备重新回答
  3001. resetForRerecording() {
  3002. console.log('重置录制状态,准备重新回答');
  3003. // 重置录制状态
  3004. this.isRecording = false;
  3005. this.showStopRecordingButton = false;
  3006. // 显示"开始回答"按钮
  3007. this.showStartRecordingButton = true;
  3008. // 清除倒计时
  3009. this.clearCountdown();
  3010. // 重置录制计时器
  3011. if (this.recordingTimer) {
  3012. clearInterval(this.recordingTimer);
  3013. this.recordingTimer = null;
  3014. }
  3015. // 重置录制时间显示
  3016. this.recordingTimerCount = 0;
  3017. this.recordingTimeDisplay = '00:00 ';/* / 05:00 */
  3018. this.remainingTime = this.maxRecordingTime;
  3019. // 如果是浏览器环境,停止MediaRecorder
  3020. if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
  3021. this.mediaRecorder.stop();
  3022. this.recordedChunks = [];
  3023. }
  3024. // 如果是小程序环境,停止相机录制
  3025. if (this.cameraContext) {
  3026. this.cameraContext.stopRecord({
  3027. success: () => {
  3028. console.log('相机录制已停止');
  3029. },
  3030. fail: (err) => {
  3031. console.error('停止相机录制失败:', err);
  3032. }
  3033. });
  3034. }
  3035. },
  3036. // 修改 playLowScoreVideo 方法,优化字幕显示
  3037. playLowScoreVideo() {
  3038. console.log('播放低分提示视频');
  3039. // 标记正在播放低分提示视频
  3040. this.isPlayingLowScoreVideo = true;
  3041. // 隐藏其他按钮
  3042. this.showStartRecordingButton = false;
  3043. this.showStopRecordingButton = false;
  3044. this.showRerecordButton = false;
  3045. // 设置视频URL
  3046. this.videoUrl = this.lowScoreVideoUrl;
  3047. this.videoPlaying = true;
  3048. // 清除现有字幕前先保存
  3049. const originalSubtitle = this.currentSubtitle;
  3050. this.currentSubtitle = '';
  3051. // 使用 nextTick 确保 DOM 更新后再播放视频
  3052. this.$nextTick(() => {
  3053. const videoContext = uni.createVideoContext('myVideo', this);
  3054. if (videoContext) {
  3055. videoContext.play();
  3056. // 视频开始播放后短暂延迟显示字幕,确保用户注意到
  3057. setTimeout(() => {
  3058. // 设置低分提示视频的字幕
  3059. this.currentSubtitle = '我未听清楚您在说什么,请您再说一遍!';
  3060. // 创建专门的字幕数组用于低分提示视频
  3061. this.lowScoreVideoSubtitles = [
  3062. {
  3063. startTime: 0,
  3064. endTime: 30,
  3065. text: '我未听清楚您在说什么,请您再说一遍!',
  3066. translation: 'I didn\'t quite catch what you said, could you please repeat it?'
  3067. }
  3068. ];
  3069. // 在提示播放完成后恢复原来的字幕
  3070. setTimeout(() => {
  3071. // 如果是追问问题,保持显示追问问题的字幕
  3072. if (this.isFollowUpQuestion && this.currentFollowUpQuestion) {
  3073. this.currentSubtitle = this.currentFollowUpQuestion.digital_human_video_subtitle || this.currentFollowUpQuestion.question;
  3074. } else {
  3075. this.currentSubtitle = originalSubtitle;
  3076. }
  3077. }, 3000); // 3秒后恢复原问题
  3078. }, 500);
  3079. }
  3080. });
  3081. },
  3082. // 添加新方法:处理重新录制按钮点击
  3083. handleRerecordButtonClick() {
  3084. console.log('点击重新录制按钮');
  3085. // 增加重试计数
  3086. this.retryCount++;
  3087. console.log(`当前重试次数: ${this.retryCount}/${this.maxRetryAttempts}`);
  3088. // 隐藏重新录制按钮
  3089. this.showRerecordButton = false;
  3090. // 直接调用开始录制的方法
  3091. this.handleStartRecordingClick();
  3092. },
  3093. // 页面卸载时关闭WebSocket连接
  3094. onUnload() {
  3095. console.log('页面卸载,关闭WebSocket连接');
  3096. this.cleanupPersonDetectionWebSocket();
  3097. // 清理其他资源
  3098. this.stopUserCamera();
  3099. this.clearCountdown();
  3100. // 清理录制相关定时器
  3101. if (this.recordingTimer) {
  3102. clearInterval(this.recordingTimer);
  3103. this.recordingTimer = null;
  3104. }
  3105. // 停止MediaRecorder
  3106. if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
  3107. this.mediaRecorder.stop();
  3108. }
  3109. // 关闭屏幕常亮
  3110. uni.setKeepScreenOn({
  3111. keepScreenOn: false
  3112. });
  3113. },
  3114. // 页面隐藏时关闭WebSocket连接
  3115. onHide() {
  3116. console.log('页面隐藏,关闭WebSocket连接');
  3117. // 完全关闭WebSocket连接和相关定时器
  3118. this.cleanupPersonDetectionWebSocket();
  3119. // 如果正在录制,给用户提示
  3120. if (this.isRecording) {
  3121. console.log('页面隐藏时正在录制,录制将继续');
  3122. // 可以选择暂停录制或者给用户提示
  3123. uni.showToast({
  3124. title: '请不要离开面试页面',
  3125. icon: 'none',
  3126. duration: 2000
  3127. });
  3128. }
  3129. },
  3130. // 页面显示时重新初始化WebSocket连接
  3131. onShow() {
  3132. console.log('identity-verify页面onShow');
  3133. console.log('当前本地存储中的职位信息:', uni.getStorageSync('selectedJob'));
  3134. // 每次页面显示时重新获取问题
  3135. this.fetchQuestions();
  3136. this.fetchFollowUpQuestions(); // 添加获取追问问题的调用
  3137. // 重新初始化WebSocket连接(仅在小程序环境中)
  3138. const systemInfo = uni.getSystemInfoSync();
  3139. const isMiniProgram = systemInfo.uniPlatform && systemInfo.uniPlatform.startsWith('mp-');
  3140. if (isMiniProgram) {
  3141. // 如果WebSocket未连接,重新初始化
  3142. if (!this.personDetectionSocket) {
  3143. console.log('重新初始化WebSocket连接');
  3144. this.initPersonDetectionWebSocket();
  3145. }
  3146. // 如果WebSocket已连接但检测定时器被停止,重新启动检测
  3147. else if (!this.personDetectionInterval) {
  3148. console.log('重新启动人脸检测定时器');
  3149. this.startPersonDetectionInterval();
  3150. }
  3151. }
  3152. },
  3153. // 添加新方法:阻止视频控制
  3154. preventVideoControl(e) {
  3155. // 阻止默认行为和事件冒泡
  3156. e.preventDefault();
  3157. e.stopPropagation();
  3158. console.log('阻止视频控制操作');
  3159. return false;
  3160. },
  3161. // 添加获取追问问题的方法
  3162. fetchFollowUpQuestions() {
  3163. // 获取tenant_id,如果没有则使用默认值1
  3164. const tenant_id = JSON.parse(uni.getStorageSync('userInfo')).tenant_id || '1';
  3165. // 获取当前选择的职位ID
  3166. let position_id = 1; // 默认值
  3167. try {
  3168. const selectedJob = JSON.parse(uni.getStorageSync('selectedJob') || '{}');
  3169. if (selectedJob && selectedJob.id) {
  3170. position_id = selectedJob.id;
  3171. console.log('使用选择的职位ID获取追问问题:', position_id);
  3172. } else {
  3173. console.warn('未找到选择的职位信息,使用默认职位ID:', position_id);
  3174. }
  3175. } catch (e) {
  3176. console.error('解析职位信息失败:', e);
  3177. }
  3178. // 构建完整的请求URL,便于调试
  3179. const timestamp = Date.now();
  3180. const requestUrl = `${apiBaseUrl}/api/wechat/open_questions/?position_id=${position_id}&tenant_id=${tenant_id}&question_form=5&_t=${timestamp}`;
  3181. console.log('获取追问问题的请求URL:', requestUrl);
  3182. uni.request({
  3183. url: requestUrl,
  3184. method: 'GET',
  3185. success: (res) => {
  3186. if (res.data && res.data.code === 2000 && res.data.data && res.data.data.items) {
  3187. console.log('获取追问问题数据成功:', res.data.data.items);
  3188. // 存储追问问题数据,并确保每个问题都有question_form=5标记
  3189. this.followUpQuestions = res.data.data.items.map(item => ({
  3190. ...item,
  3191. question_form: 5 // 确保每个追问问题都有正确的question_form值
  3192. }));
  3193. // 处理追问问题数据,建立与父问题的关联
  3194. this.processFollowUpQuestions();
  3195. } else {
  3196. console.error('获取追问问题数据失败:', res.data);
  3197. // 初始化为空数组,避免undefined错误
  3198. this.followUpQuestions = [];
  3199. }
  3200. },
  3201. fail: (err) => {
  3202. console.error('请求追问问题数据失败:', err);
  3203. // 初始化为空数组,避免undefined错误
  3204. this.followUpQuestions = [];
  3205. }
  3206. });
  3207. },
  3208. // 改进处理追问问题数据的方法
  3209. processFollowUpQuestions() {
  3210. if (!this.followUpQuestions || this.followUpQuestions.length === 0) {
  3211. console.log('没有追问问题数据');
  3212. this.followUpQuestions = []; // 确保是数组
  3213. return;
  3214. }
  3215. console.log('处理追问问题数据,总数:', this.followUpQuestions.length);
  3216. // 过滤掉没有parent_question_id的追问问题
  3217. this.followUpQuestions = this.followUpQuestions.filter(q => q.parent_question_id);
  3218. // 可以在这里进行额外的数据处理,如按照parent_question_id分组等
  3219. const parentQuestionIds = new Set();
  3220. this.followUpQuestions.forEach(question => {
  3221. if (question.parent_question_id) {
  3222. parentQuestionIds.add(question.parent_question_id);
  3223. }
  3224. });
  3225. console.log('有追问的父问题ID:', Array.from(parentQuestionIds));
  3226. // 初始化已播放追问记录对象
  3227. this.hasPlayedFollowUp = {};
  3228. },
  3229. // 修改检查并播放追问问题的方法
  3230. checkAndPlayFollowUpQuestion(parentQuestionId) {
  3231. console.log('检查是否有追问问题,父问题ID:', parentQuestionId);
  3232. // 如果正在等待用户回答,不进行任何操作
  3233. if (this.isWaitingForAnswer) {
  3234. console.log('正在等待用户回答,暂不处理追问');
  3235. return;
  3236. }
  3237. if (!parentQuestionId) {
  3238. console.warn('没有父问题ID,无法检查追问问题');
  3239. this.proceedToNextQuestion();
  3240. return;
  3241. }
  3242. // 如果已经播放过这个问题的追问,则直接进入下一个问题
  3243. if (this.hasPlayedFollowUp[parentQuestionId]) {
  3244. console.log('已经播放过此问题的追问,直接进入下一个问题');
  3245. this.retryCount = 0;
  3246. this.proceedToNextQuestion();
  3247. return;
  3248. }
  3249. // 查找对应的追问问题
  3250. const followUpQuestion = this.followUpQuestions.find(q =>
  3251. q.parent_question_id === parentQuestionId &&
  3252. q.digital_human_video_url &&
  3253. q.question_form === 5
  3254. );
  3255. if (followUpQuestion) {
  3256. console.log('找到追问问题:', followUpQuestion);
  3257. // 检查录制时长
  3258. const recordingDuration = this.getRecordingDuration();
  3259. const lowScoreDuration = 7; // 低分阈值时长(秒),少于7秒视为低分
  3260. // 如果录制时间少于7秒,播放"未听清楚"提示视频
  3261. if (recordingDuration < lowScoreDuration) {
  3262. console.log(`录制时间 ${recordingDuration} 秒,少于 ${lowScoreDuration} 秒,将播放低分提示视频`);
  3263. // 增加重试次数
  3264. this.retryCount++;
  3265. // 设置需要播放低分提示视频
  3266. this.needPlayLowScoreVideo = true;
  3267. // 播放低分提示视频
  3268. this.playLowScoreVideo();
  3269. return;
  3270. }
  3271. // 如果录制时长足够,重置重试次数
  3272. this.retryCount = 0;
  3273. // 标记此追问已播放
  3274. this.hasPlayedFollowUp[parentQuestionId] = true;
  3275. // 标记当前是追问问题
  3276. this.isFollowUpQuestion = true;
  3277. // 记录当前父问题ID
  3278. this.currentParentQuestionId = parentQuestionId;
  3279. // 保存当前追问问题的完整信息
  3280. this.currentFollowUpQuestion = followUpQuestion;
  3281. // 播放追问问题视频
  3282. this.playFollowUpQuestionVideo(followUpQuestion);
  3283. } else {
  3284. console.log('没有找到对应的追问问题,继续下一个问题');
  3285. this.retryCount = 0;
  3286. this.proceedToNextQuestion();
  3287. }
  3288. },
  3289. // 修改播放追问问题视频的方法
  3290. playFollowUpQuestionVideo(followUpQuestion) {
  3291. console.log('播放追问问题视频:', followUpQuestion.digital_human_video_url);
  3292. // 保存父问题内容
  3293. this.parentQuestion = this.currentSubtitle;
  3294. this.videoUrl = followUpQuestion.digital_human_video_url;
  3295. this.videoPlaying = true;
  3296. this.isFollowUpQuestion = true;
  3297. this.currentFollowUpQuestion = followUpQuestion;
  3298. // 设置追问问题的字幕
  3299. const followUpSubtitles = [{
  3300. startTime: 0,
  3301. endTime: 60, // 延长显示时间到60秒
  3302. text: followUpQuestion.digital_human_video_subtitle || followUpQuestion.question
  3303. }];
  3304. const subtitleKey = `followUpSubtitles_${followUpQuestion.id}`;
  3305. this[subtitleKey] = followUpSubtitles;
  3306. this.$nextTick(() => {
  3307. const videoContext = uni.createVideoContext('myVideo', this);
  3308. if (videoContext) {
  3309. videoContext.play();
  3310. // 更新当前字幕为追问内容
  3311. this.currentSubtitle = followUpSubtitles[0].text;
  3312. }
  3313. });
  3314. },
  3315. // 添加作废面试的方法
  3316. invalidateInterview() {
  3317. // 停止录制(如果正在录制)
  3318. if (this.isRecording) {
  3319. this.stopRecordingAnswer();
  3320. }
  3321. // 清除所有计时器
  3322. if (this.recordingTimer) {
  3323. clearInterval(this.recordingTimer);
  3324. }
  3325. // 重置状态
  3326. this.isRecording = false;
  3327. this.showStopRecordingButton = false;
  3328. this.showStartRecordingButton = false;
  3329. // 显示作废提示
  3330. // uni.showToast({
  3331. // title: 'Interview invalidated',
  3332. // icon: 'none',
  3333. // duration: 2000
  3334. // });
  3335. // 延迟后返回上一页
  3336. setTimeout(() => {
  3337. uni.switchTab({
  3338. url: '/pages/index/index'
  3339. });
  3340. }, 500);
  3341. },
  3342. initPersonDetectionWebSocket() {
  3343. if (this.personDetectionSocket) {
  3344. this.cleanupPersonDetectionWebSocket();
  3345. }
  3346. try {
  3347. this.personDetectionSocket = uni.connectSocket({
  3348. url: `${personDetectionWsUrl}/ws/interview-room/room_${uni.getStorageSync('appId')}/${uni.getStorageSync('appId')}/`,
  3349. success: () => {
  3350. console.log('WebSocket connection initiated');
  3351. },
  3352. fail: (error) => {
  3353. console.error('WebSocket connection failed:', error);
  3354. }
  3355. });
  3356. this.personDetectionSocket.onOpen(() => {
  3357. console.log('WebSocket connection opened');
  3358. this.startPersonDetectionInterval();
  3359. });
  3360. this.personDetectionSocket.onError((error) => {
  3361. console.error('WebSocket error:', error);
  3362. this.cleanupPersonDetectionWebSocket();
  3363. });
  3364. this.personDetectionSocket.onClose(() => {
  3365. console.log('WebSocket connection closed');
  3366. this.cleanupPersonDetectionWebSocket();
  3367. });
  3368. this.personDetectionSocket.onMessage((res) => {
  3369. try {
  3370. const data = JSON.parse(res.data);
  3371. if (data.type === 'person_detection_result') {
  3372. this.handlePersonDetectionResult(data);
  3373. }
  3374. } catch (error) {
  3375. console.error('Error parsing WebSocket message:', error);
  3376. }
  3377. });
  3378. } catch (error) {
  3379. console.error('Error initializing WebSocket:', error);
  3380. this.cleanupPersonDetectionWebSocket();
  3381. }
  3382. },
  3383. startPersonDetectionInterval() {
  3384. if (this.personDetectionInterval) {
  3385. clearInterval(this.personDetectionInterval);
  3386. }
  3387. this.personDetectionInterval = setInterval(() => {
  3388. if (this.personDetectionSocket && this.cameraContext) {
  3389. this.cameraContext.takePhoto({
  3390. quality: 'low',
  3391. success: (res) => {
  3392. const tempFilePath = res.tempImagePath;
  3393. uni.getFileSystemManager().readFile({
  3394. filePath: tempFilePath,
  3395. encoding: 'base64',
  3396. success: (res) => {
  3397. const base64Image = res.data;
  3398. this.personDetectionSocket.send({
  3399. data: JSON.stringify({
  3400. type: 'person_detection',
  3401. image_data: base64Image
  3402. })
  3403. });
  3404. },
  3405. fail: (error) => {
  3406. console.error('Error reading image file:', error);
  3407. }
  3408. });
  3409. },
  3410. fail: (error) => {
  3411. console.error('Error taking photo:', error);
  3412. }
  3413. });
  3414. }
  3415. }, 3000);
  3416. },
  3417. cleanupPersonDetectionWebSocket() {
  3418. if (this.personDetectionInterval) {
  3419. clearInterval(this.personDetectionInterval);
  3420. this.personDetectionInterval = null;
  3421. }
  3422. if (this.personDetectionSocket) {
  3423. try {
  3424. this.personDetectionSocket.close();
  3425. } catch (error) {
  3426. console.error('Error closing WebSocket:', error);
  3427. }
  3428. this.personDetectionSocket = null;
  3429. }
  3430. },
  3431. handlePersonDetectionResult(data) {
  3432. /* console.log(data.data.detection.has_person);
  3433. console.log(data.data.identity.status);//identity_verified */
  3434. /* // 首先检查是否有人
  3435. if (!data.data.detection.has_person) {
  3436. this.showPageWarning = true;
  3437. uni.showToast({
  3438. title: '请勿离开摄像头',
  3439. icon: 'none',
  3440. duration: 3000
  3441. });
  3442. // 添加震动反馈
  3443. uni.vibrateLong({
  3444. success: function () {
  3445. console.log('Vibration successful');
  3446. },
  3447. fail: function (err) {
  3448. console.error('Vibration failed:', err);
  3449. }
  3450. });
  3451. setTimeout(() => {
  3452. this.showPageWarning = false;
  3453. }, 3000);
  3454. }
  3455. // 然后检查身份验证状态
  3456. else */ if (data.data.identity.status !== "identity_verified") {
  3457. /*this.showPageWarning = true;
  3458. uni.showToast({
  3459. title: data.data.identity.message,
  3460. icon: 'none',
  3461. duration: 3000
  3462. });
  3463. uni.vibrateLong({
  3464. success: function () {
  3465. console.log('Vibration successful');
  3466. },
  3467. fail: function (err) {
  3468. console.error('Vibration failed:', err);
  3469. }
  3470. }); */
  3471. setTimeout(() => {
  3472. this.showPageWarning = false;
  3473. }, 3000);
  3474. }
  3475. },
  3476. // 添加新方法:处理追问问题
  3477. async handleFollowUpQuestion(questionData) {
  3478. console.log('处理追问问题数据:', questionData);
  3479. // 检查数据完整性
  3480. if (!questionData || !questionData.follow_up_voice_url || !questionData.follow_up_question) {
  3481. console.error('追问数据不完整:', questionData);
  3482. uni.showToast({
  3483. title: '获取追问数据失败',
  3484. icon: 'none'
  3485. });
  3486. return;
  3487. }
  3488. // 保存父问题
  3489. this.parentQuestion = questionData.original_question || '';
  3490. // 确保音频URL正确
  3491. let audioUrl = '';
  3492. if (questionData.follow_up_voice_url.direct_url) {
  3493. audioUrl = questionData.follow_up_voice_url.direct_url;
  3494. } else if (questionData.follow_up_voice_url.file_url) {
  3495. audioUrl = questionData.follow_up_voice_url.file_url;
  3496. }
  3497. // 确保音频URL是完整的
  3498. if (!audioUrl.startsWith('http')) {
  3499. audioUrl = apiBaseUrl + audioUrl;
  3500. }
  3501. console.log('处理后的音频URL:', audioUrl);
  3502. // 创建追问问题对象
  3503. const followUpQuestionObj = {
  3504. id: Date.now(),
  3505. question: questionData.follow_up_question.trim(), // 去除可能的空白字符
  3506. audioUrl: audioUrl,
  3507. originalQuestion: questionData.original_question,
  3508. sessionId: questionData.session_id
  3509. };
  3510. console.log('创建的追问问题对象:', followUpQuestionObj);
  3511. // 添加到追问问题列表
  3512. this.followUpQuestions.push(followUpQuestionObj);
  3513. this.currentFollowUpIndex = this.followUpQuestions.length - 1;
  3514. // 更新当前追问问题
  3515. this.followUpQuestion = followUpQuestionObj.question;
  3516. this.followUpAudioUrl = followUpQuestionObj.audioUrl;
  3517. // 标记当前是追问问题
  3518. this.isFollowUpQuestion = true;
  3519. this.currentFollowUpQuestion = followUpQuestionObj;
  3520. // 显示字幕
  3521. this.showSubtitle(followUpQuestionObj.question, true);
  3522. // 等待音频播放完成
  3523. try {
  3524. await this.playFollowUpAudio();
  3525. console.log('音频播放完成,准备录制回答');
  3526. // 开始准备录制回答
  3527. this.prepareToAnswer(true, followUpQuestionObj);
  3528. } catch (error) {
  3529. console.error('音频播放失败:', error);
  3530. // 即使音频播放失败,也继续准备录制回答
  3531. this.prepareToAnswer(true, followUpQuestionObj);
  3532. }
  3533. },
  3534. // 播放追问音频
  3535. async playFollowUpAudio() {
  3536. return new Promise((resolve, reject) => {
  3537. console.log('开始播放追问音频, URL:', this.followUpAudioUrl);
  3538. // 检查音频URL
  3539. if (!this.followUpAudioUrl) {
  3540. console.error('没有音频URL');
  3541. reject(new Error('没有音频URL'));
  3542. return;
  3543. }
  3544. // 显示加载提示
  3545. uni.showLoading({
  3546. title: '加载音频中...'
  3547. });
  3548. // 停止并销毁之前的音频实例
  3549. this.stopAndDestroyAudio();
  3550. try {
  3551. // 创建新的音频上下文
  3552. const innerAudioContext = uni.createInnerAudioContext();
  3553. this.audioContext = innerAudioContext;
  3554. // 设置音频属性
  3555. innerAudioContext.autoplay = true;
  3556. innerAudioContext.obeyMuteSwitch = false;
  3557. innerAudioContext.volume = 1.0;
  3558. // 监听音频加载状态
  3559. innerAudioContext.onCanplay(() => {
  3560. console.log('音频可以播放');
  3561. uni.hideLoading();
  3562. this.isAudioPlaying = true;
  3563. uni.showToast({
  3564. title: '正在播放追问',
  3565. icon: 'none',
  3566. duration: 2000
  3567. });
  3568. });
  3569. // 监听音频播放完成
  3570. innerAudioContext.onEnded(() => {
  3571. console.log('追问音频播放完成');
  3572. this.isAudioPlaying = false;
  3573. resolve();
  3574. // 延迟清理资源,确保不会影响播放完成的回调
  3575. setTimeout(() => {
  3576. this.stopAndDestroyAudio();
  3577. // 音频播放完成后,自动准备录制回答
  3578. this.prepareToAnswer(true, this.currentFollowUpQuestion);
  3579. }, 100);
  3580. });
  3581. // 监听音频播放错误
  3582. innerAudioContext.onError((res) => {
  3583. console.error('音频播放错误:', res);
  3584. this.isAudioPlaying = false;
  3585. uni.hideLoading();
  3586. uni.showToast({
  3587. title: '音频播放失败',
  3588. icon: 'none'
  3589. });
  3590. reject(res);
  3591. this.stopAndDestroyAudio();
  3592. });
  3593. // 设置音频源
  3594. console.log('设置音频源:', this.followUpAudioUrl);
  3595. innerAudioContext.src = this.followUpAudioUrl;
  3596. } catch (error) {
  3597. console.error('创建或播放音频失败:', error);
  3598. this.isAudioPlaying = false;
  3599. uni.hideLoading();
  3600. uni.showToast({
  3601. title: '音频播放失败',
  3602. icon: 'none'
  3603. });
  3604. this.stopAndDestroyAudio();
  3605. reject(error);
  3606. }
  3607. });
  3608. },
  3609. // 停止并销毁音频
  3610. stopAndDestroyAudio() {
  3611. if (!this.audioContext) {
  3612. return;
  3613. }
  3614. const ctx = this.audioContext;
  3615. this.audioContext = null; // 先清除引用
  3616. try {
  3617. if (this.isAudioPlaying) {
  3618. ctx.stop();
  3619. }
  3620. } catch (error) {
  3621. console.error('停止音频播放失败:', error);
  3622. }
  3623. try {
  3624. ctx.destroy();
  3625. } catch (error) {
  3626. console.error('销毁音频实例失败:', error);
  3627. }
  3628. this.isAudioPlaying = false;
  3629. },
  3630. // 显示字幕
  3631. showSubtitle(text, isFollowUp = true) {
  3632. console.log('显示字幕:', text);
  3633. this.subtitleText = text;
  3634. this.showSubtitleText = true;
  3635. this.isFollowUpQuestion = isFollowUp;
  3636. // 确保字幕显示在界面上
  3637. this.$nextTick(() => {
  3638. // 如果需要,可以添加字幕动画效果
  3639. setTimeout(() => {
  3640. this.showSubtitleText = false;
  3641. }, 8000); // 8秒后隐藏字幕
  3642. });
  3643. },
  3644. // 显示问题对话框
  3645. showQuestionDialog() {
  3646. if (this.followUpQuestion) {
  3647. uni.showModal({
  3648. title: '追问问题',
  3649. content: this.followUpQuestion,
  3650. showCancel: false,
  3651. success: () => {
  3652. // 准备开始回答
  3653. this.prepareToAnswer(true, this.followUpQuestions[this.currentFollowUpIndex]);
  3654. }
  3655. });
  3656. }
  3657. },
  3658. },
  3659. computed: {
  3660. // 计算进度比例
  3661. progressRatio() {
  3662. return this.recordingTimerCount / this.maxRecordingTime;
  3663. },
  3664. // 右半圆旋转角度
  3665. half1Turn() {
  3666. // 初始状态为0度,小于50%时,右半圆按比例旋转
  3667. if (this.progressRatio <= 0.5) {
  3668. return this.progressRatio * 2 * 180;
  3669. } else {
  3670. // 大于50%时,右半圆固定在180度
  3671. return 180;
  3672. }
  3673. },
  3674. // 左半圆旋转角度
  3675. half2Turn() {
  3676. // 小于50%时,左半圆固定在0度
  3677. if (this.progressRatio <= 0.5) {
  3678. return 0;
  3679. } else {
  3680. // 大于50%时,左半圆按超出50%的比例旋转
  3681. return (this.progressRatio - 0.5) * 2 * 180;
  3682. }
  3683. },
  3684. // 修改进度百分比的计算方式
  3685. progressPercent() {
  3686. // 将时间进度转换为百分比(0-100)
  3687. return Math.min(Math.round((this.recordingTimerCount / this.maxRecordingTime) * 100), 100);
  3688. },
  3689. // 添加总题数计算属性
  3690. totalQuestions() {
  3691. // 获取问题总数,不包括介绍视频
  3692. return this.questions.length || 5; // 如果没有问题数据,默认显示5
  3693. }
  3694. }
  3695. }
  3696. </script>
  3697. <style scoped>
  3698. .subtitle-container {
  3699. position: fixed;
  3700. bottom: 20%;
  3701. left: 0;
  3702. right: 0;
  3703. display: flex;
  3704. justify-content: center;
  3705. align-items: center;
  3706. z-index: 1000;
  3707. }
  3708. .subtitle-text {
  3709. background-color: rgba(0, 0, 0, 0.7);
  3710. color: white;
  3711. padding: 10px 20px;
  3712. border-radius: 5px;
  3713. font-size: 16px;
  3714. max-width: 80%;
  3715. text-align: center;
  3716. animation: fadeIn 0.5s ease-in-out;
  3717. }
  3718. @keyframes fadeIn {
  3719. from {
  3720. opacity: 0;
  3721. transform: translateY(20px);
  3722. }
  3723. to {
  3724. opacity: 1;
  3725. transform: translateY(0);
  3726. }
  3727. }
  3728. .identity-verify-container {
  3729. padding: 0;
  3730. max-width: 100%;
  3731. margin: 0 auto;
  3732. height: 100vh;
  3733. display: flex;
  3734. flex-direction: column;
  3735. background-color: #f5f5f5;
  3736. transition: all 0.3s ease;
  3737. position: relative;
  3738. }
  3739. .page-warning {
  3740. animation: page-warning-flash 1s ease-in-out infinite;
  3741. }
  3742. @keyframes page-warning-flash {
  3743. 0% {
  3744. background-color: rgba(255, 0, 0, 0.2);
  3745. box-shadow: 0 0 20px rgba(255, 0, 0, 0.3);
  3746. }
  3747. 50% {
  3748. background-color: rgba(255, 0, 0, 0.1);
  3749. box-shadow: 0 0 10px rgba(255, 0, 0, 0.2);
  3750. }
  3751. 100% {
  3752. background-color: rgba(255, 0, 0, 0.2);
  3753. box-shadow: 0 0 20px rgba(255, 0, 0, 0.3);
  3754. }
  3755. }
  3756. .digital-human-container {
  3757. position: relative;
  3758. width: 100%;
  3759. height: 100vh;
  3760. overflow: hidden;
  3761. background-color: #f0f0f0;
  3762. }
  3763. .digital-human-video {
  3764. width: 100%;
  3765. height: 100%;
  3766. display: flex;
  3767. justify-content: center;
  3768. align-items: center;
  3769. }
  3770. /* 用户摄像头容器样式 */
  3771. .user-camera-container {
  3772. position: absolute;
  3773. top: 50px;
  3774. right: 5px;
  3775. width: 110px;
  3776. height: 160px;
  3777. border-radius: 4px;
  3778. overflow: hidden;
  3779. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
  3780. z-index: 20;
  3781. transition: all 0.3s ease;
  3782. }
  3783. .camera-warning {
  3784. animation: camera-warning-flash 1s ease-in-out infinite;
  3785. border: 4px solid #ff0000;
  3786. }
  3787. @keyframes camera-warning-flash {
  3788. 0% {
  3789. border-color: #ff0000;
  3790. box-shadow: 0 0 15px rgba(255, 0, 0, 0.8);
  3791. }
  3792. 50% {
  3793. border-color: rgba(255, 0, 0, 0.3);
  3794. box-shadow: 0 0 5px rgba(255, 0, 0, 0.4);
  3795. }
  3796. 100% {
  3797. border-color: #ff0000;
  3798. box-shadow: 0 0 15px rgba(255, 0, 0, 0.8);
  3799. }
  3800. }
  3801. /* 用户摄像头视频样式 */
  3802. .user-camera-video {
  3803. width: 100%;
  3804. height: 100%;
  3805. object-fit: cover;
  3806. background-color: #333;
  3807. }
  3808. .video-player {
  3809. width: 100%;
  3810. height: 100%;
  3811. object-fit: cover;
  3812. outline: none; /* 移除视频获得焦点时的轮廓 */
  3813. -webkit-tap-highlight-color: transparent; /* 移除移动设备上的点击高亮 */
  3814. }
  3815. /* 隐藏视频控制条 */
  3816. video::-webkit-media-controls {
  3817. display: none !important;
  3818. }
  3819. video::-webkit-media-controls-enclosure {
  3820. display: none !important;
  3821. }
  3822. video::-webkit-media-controls-panel {
  3823. display: none !important;
  3824. }
  3825. video::-webkit-media-controls-play-button {
  3826. display: none !important;
  3827. }
  3828. video::-webkit-media-controls-timeline {
  3829. display: none !important;
  3830. }
  3831. video::-webkit-media-controls-current-time-display {
  3832. display: none !important;
  3833. }
  3834. video::-webkit-media-controls-time-remaining-display {
  3835. display: none !important;
  3836. }
  3837. video::-webkit-media-controls-mute-button {
  3838. display: none !important;
  3839. }
  3840. video::-webkit-media-controls-volume-slider {
  3841. display: none !important;
  3842. }
  3843. video::-webkit-media-controls-fullscreen-button {
  3844. display: none !important;
  3845. }
  3846. /* 修改字幕覆盖层样式,使其与图片中的样式一致 */
  3847. .subtitle-overlay {
  3848. position: absolute;
  3849. bottom: 180px;
  3850. left: 10%;
  3851. width: 80%;
  3852. padding: 0; /* 移除内边距,由内部元素控制 */
  3853. border-radius: 15px;
  3854. background-color: rgba(255, 255, 255, 0.9);
  3855. color: #333;
  3856. text-align: left;
  3857. font-size: 16px;
  3858. line-height: 1.5;
  3859. z-index: 10;
  3860. margin: 0 auto;
  3861. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  3862. display: flex;
  3863. flex-direction: column;
  3864. overflow: hidden; /* 确保圆角效果 */
  3865. }
  3866. /* 添加题号header样式 */
  3867. .subtitle-header {
  3868. /* background-color: rgba(0, 0, 0, 0.05); */
  3869. padding: 8px 20px;
  3870. /* border-bottom: 1px solid rgba(0, 0, 0, 0.05); */
  3871. }
  3872. .question-tag {
  3873. font-size: 14px;
  3874. color: #666;
  3875. font-weight: 500;
  3876. }
  3877. /* 调整主字幕样式 */
  3878. .subtitle-main {
  3879. padding: 15px 20px ;
  3880. font-weight: 500;
  3881. }
  3882. /* 移除之前的题号显示器 */
  3883. .question-counter {
  3884. display: none;
  3885. }
  3886. .control-panel {
  3887. padding: 15px;
  3888. display: flex;
  3889. justify-content: center;
  3890. }
  3891. .control-button {
  3892. padding: 10px 20px;
  3893. background-color: #4CAF50;
  3894. color: white;
  3895. border: none;
  3896. border-radius: 4px;
  3897. cursor: pointer;
  3898. }
  3899. .loading {
  3900. text-align: center;
  3901. margin: 20px 0;
  3902. font-size: 16px;
  3903. }
  3904. .response-container {
  3905. margin-top: 20px;
  3906. padding: 0 20px;
  3907. display: none; /* 默认隐藏调试信息 */
  3908. }
  3909. /* 当showDebugInfo为true时显示 */
  3910. .showDebugInfo .response-container {
  3911. display: block;
  3912. }
  3913. .response-item {
  3914. padding: 10px;
  3915. border: 1px solid #eee;
  3916. border-radius: 4px;
  3917. margin-bottom: 10px;
  3918. background-color: #f9f9f9;
  3919. }
  3920. .response-content {
  3921. display: flex;
  3922. flex-direction: column;
  3923. }
  3924. .answer-button-container {
  3925. position: absolute;
  3926. bottom: 50px; /* 将按钮放在底部而不是75%的位置 */
  3927. left: 50%;
  3928. transform: translateX(-50%); /* 只在X轴上平移,保持水平居中 */
  3929. z-index: 20;
  3930. }
  3931. /* 修改回答按钮样式 */
  3932. .answer-button {
  3933. width: 120px;
  3934. height: 40px; /* 改为矩形按钮 */
  3935. border-radius: 20px; /* 圆角矩形 */
  3936. background-color: #ffffff; /* 白色背景 */
  3937. color: #333; /* 深色文字 */
  3938. font-size: 16px;
  3939. border: none;
  3940. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  3941. display: flex;
  3942. justify-content: center;
  3943. align-items: center;
  3944. cursor: pointer;
  3945. animation: none; /* 移除脉动动画 */
  3946. }
  3947. .stop-recording-button-container {
  3948. position: absolute;
  3949. bottom: 50px; /* 统一与其他按钮的位置 */
  3950. left: 50%;
  3951. transform: translateX(-50%);
  3952. z-index: 20;
  3953. }
  3954. .stop-recording-button {
  3955. width: 120px;
  3956. height: 40px; /* 改为矩形按钮 */
  3957. border-radius: 20px; /* 圆角矩形 */
  3958. background-color: #0039b3; /* 白色背景 */
  3959. color: #fff;/* 深色文字 */
  3960. font-size: 16px;
  3961. border: none;
  3962. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  3963. display: flex;
  3964. justify-content: center;
  3965. align-items: center;
  3966. cursor: pointer;
  3967. animation: none; /* 移除脉动动画 */
  3968. }
  3969. .recording-indicator {
  3970. position: fixed;
  3971. bottom: 20px;
  3972. left: 17%;
  3973. transform: translateX(-50%);
  3974. display: flex;
  3975. flex-direction: column;
  3976. align-items: center;
  3977. /* background-color: rgba(0, 0, 0, 0.6); */
  3978. padding: 10px 15px;
  3979. border-radius: 10px;
  3980. z-index: 100;
  3981. }
  3982. .timer-text {
  3983. color: #05dc8b;
  3984. font-size: 14px;
  3985. margin-top: 5px;
  3986. }
  3987. .start-recording-button-container {
  3988. position: absolute;
  3989. bottom: 50px; /* 统一与其他按钮的位置 */
  3990. left: 50%;
  3991. transform: translateX(-50%);
  3992. z-index: 101;
  3993. }
  3994. .start-recording-button {
  3995. width: 120px;
  3996. height: 40px; /* 改为矩形按钮 */
  3997. border-radius: 20px; /* 圆角矩形 */
  3998. background-color: #ffffff; /* 白色背景 */
  3999. color: #333; /* 深色文字 */
  4000. font-size: 16px;
  4001. border: none;
  4002. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  4003. display: flex;
  4004. justify-content: center;
  4005. align-items: center;
  4006. cursor: pointer;
  4007. animation: none; /* 移除脉动动画 */
  4008. }
  4009. .retry-button-container {
  4010. position: absolute;
  4011. bottom: 30px; /* 统一与其他按钮的位置 */
  4012. left: 50%;
  4013. transform: translateX(-50%);
  4014. z-index: 20;
  4015. }
  4016. .retry-button {
  4017. padding: 10px 20px;
  4018. background-color: #ffffff; /* 白色背景 */
  4019. color: #333; /* 深色文字 */
  4020. font-size: 16px;
  4021. border: none;
  4022. border-radius: 20px; /* 圆角矩形 */
  4023. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  4024. cursor: pointer;
  4025. }
  4026. .retry-button:hover {
  4027. background-color: #2980b9;
  4028. }
  4029. /* 录制时长显示 */
  4030. .recording-timer {
  4031. position: absolute;
  4032. top: 20px;
  4033. left: 130px; /* 放在录制指示器旁边 */
  4034. background-color: rgba(0, 0, 0, 0.6);
  4035. padding: 5px 10px;
  4036. border-radius: 15px;
  4037. z-index: 20;
  4038. display: flex;
  4039. flex-direction: column;
  4040. }
  4041. .timer-text {
  4042. color: #000;
  4043. font-size: 14px;
  4044. font-family: monospace; /* 使用等宽字体,使时间显示更稳定 */
  4045. }
  4046. .remaining-time {
  4047. color: white;
  4048. font-size: 12px;
  4049. margin-top: 2px;
  4050. }
  4051. .remaining-time.warning {
  4052. color: #ff6b6b; /* 剩余时间少时显示红色 */
  4053. animation: blink 1s infinite; /* 使用闪烁动画提醒用户 */
  4054. }
  4055. /* 添加上传状态指示器 */
  4056. .upload-status-indicator {
  4057. position: absolute;
  4058. top: 20px;
  4059. left: 130px; /* 放在录制指示器旁边 */
  4060. background-color: rgba(0, 0, 0, 0.6);
  4061. padding: 5px 10px;
  4062. border-radius: 15px;
  4063. z-index: 20;
  4064. }
  4065. .upload-status-content {
  4066. display: flex;
  4067. align-items: center;
  4068. }
  4069. .upload-status-icon {
  4070. width: 12px;
  4071. height: 12px;
  4072. border-radius: 50%;
  4073. margin-right: 8px;
  4074. }
  4075. .upload-status-text {
  4076. color: white;
  4077. font-size: 14px;
  4078. }
  4079. .uploading {
  4080. background-color: #e74c3c;
  4081. }
  4082. /* 添加倒计时蒙层样式 */
  4083. .countdown-overlay {
  4084. position: fixed;
  4085. top: 0;
  4086. left: 0;
  4087. width: 100%;
  4088. height: 100%;
  4089. background-color: rgba(0, 0, 0, 0.7);
  4090. z-index: 100;
  4091. display: flex;
  4092. justify-content: center;
  4093. align-items: center;
  4094. }
  4095. .countdown-content {
  4096. display: flex;
  4097. flex-direction: column;
  4098. align-items: center;
  4099. }
  4100. .countdown-number {
  4101. font-size: 80px;
  4102. color: #ffffff;
  4103. font-weight: bold;
  4104. margin-bottom: 20px;
  4105. }
  4106. .countdown-text {
  4107. font-size: 24px;
  4108. color: #ffffff;
  4109. }
  4110. /* 添加GIF播放器样式 */
  4111. .gif-player {
  4112. width: 100%;
  4113. height: 100%;
  4114. object-fit: cover;
  4115. position: absolute;
  4116. top: 0;
  4117. left: 0;
  4118. z-index: 5; /* 确保GIF在视频上方但在字幕和按钮下方 */
  4119. }
  4120. /* 重新录制按钮容器样式 */
  4121. .rerecord-button-container {
  4122. position: absolute;
  4123. bottom: 50px; /* 统一与其他按钮的位置 */
  4124. left: 50%;
  4125. transform: translateX(-50%);
  4126. z-index: 20;
  4127. }
  4128. /* 重新录制按钮样式 */
  4129. .rerecord-button {
  4130. width: 120px;
  4131. height: 40px;
  4132. border-radius: 20px;
  4133. background-color: #ffffff;
  4134. color: #333;
  4135. font-size: 16px;
  4136. border: none;
  4137. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  4138. display: flex;
  4139. justify-content: center;
  4140. align-items: center;
  4141. cursor: pointer;
  4142. }
  4143. .rerecord-button:hover {
  4144. background-color: #f0f0f0;
  4145. }
  4146. /* 添加视频蒙层样式 */
  4147. .video-overlay {
  4148. position: absolute;
  4149. top: 0;
  4150. left: 0;
  4151. width: 100%;
  4152. height: 100%;
  4153. background-color: transparent; /* 完全透明 */
  4154. z-index: 6; /* 确保在视频之上,但在按钮和字幕之下 */
  4155. pointer-events: auto; /* 允许捕获点击事件 */
  4156. }
  4157. /* 调整其他元素的z-index以确保正确的层级关系 */
  4158. .subtitle-overlay {
  4159. /* ... 现有样式 ... */
  4160. z-index: 10; /* 确保字幕在蒙层之上 */
  4161. }
  4162. .answer-button-container,
  4163. .stop-recording-button-container,
  4164. .start-recording-button-container,
  4165. .retry-button-container,
  4166. .rerecord-button-container {
  4167. /* ... 现有样式 ... */
  4168. z-index: 999; /* 确保按钮在蒙层之上 */
  4169. }
  4170. .user-camera-container {
  4171. /* ... 现有样式 ... */
  4172. z-index: 20; /* 确保摄像头窗口在蒙层之上 */
  4173. }
  4174. .recording-indicator {
  4175. /* ... 现有样式 ... */
  4176. z-index: 20; /* 确保录制指示器在蒙层之上 */
  4177. }
  4178. /* 环形进度条样式 */
  4179. .circle-progress-container {
  4180. width: 60px;
  4181. height: 60px;
  4182. position: relative;
  4183. margin: 0 auto 10px;
  4184. }
  4185. .circle-progress {
  4186. width: 100%;
  4187. height: 100%;
  4188. border-radius: 50%;
  4189. display: flex;
  4190. align-items: center;
  4191. justify-content: center;
  4192. position: relative;
  4193. transform: rotate(-90deg); /* 从顶部开始进度 */
  4194. }
  4195. .circle-progress-inner {
  4196. width: 80%;
  4197. height: 80%;
  4198. background-color: rgba(255, 255, 255);
  4199. border-radius: 50%;
  4200. display: flex;
  4201. align-items: center;
  4202. justify-content: center;
  4203. transform: rotate(90deg); /* 修正文本方向 */
  4204. }
  4205. .progress-text {
  4206. font-size: 14px;
  4207. font-weight: bold;
  4208. color: #05dc8b;
  4209. }
  4210. /* 添加题号显示 */
  4211. .question-counter {
  4212. position: absolute;
  4213. top: 20px;
  4214. right: 130px; /* 调整位置,避免与用户摄像头重叠 */
  4215. background-color: rgba(255, 255, 255, 0.9);
  4216. padding: 6px 12px;
  4217. border-radius: 15px;
  4218. z-index: 20;
  4219. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
  4220. display: flex;
  4221. align-items: center;
  4222. }
  4223. .question-number {
  4224. color: #333;
  4225. font-size: 14px;
  4226. font-weight: 500;
  4227. }
  4228. /* 追问问题容器样式 */
  4229. .followup-container {
  4230. width: 100%;
  4231. }
  4232. /* 追问分隔线 */
  4233. .followup-divider {
  4234. height: 1px;
  4235. background-color: rgba(0, 0, 0, 0.05);
  4236. margin: 0;
  4237. }
  4238. /* 追问内容样式 */
  4239. .followup-content {
  4240. padding: 12px 20px;
  4241. display: flex;
  4242. align-items: flex-start;
  4243. gap: 8px;
  4244. }
  4245. /* 追问标签样式 */
  4246. .followup-label {
  4247. color: #fb752f;
  4248. font-size: 14px;
  4249. font-weight: 500;
  4250. }
  4251. /* 追问文本样式 */
  4252. .followup-text {
  4253. color: #333;
  4254. font-size: 14px;
  4255. line-height: 1.5;
  4256. flex: 1;
  4257. }
  4258. /* 调整主字幕样式,确保有足够空间 */
  4259. .subtitle-main {
  4260. padding:15px 20px ;
  4261. font-weight: 500;
  4262. border-bottom: none; /* 移除底部边框,由追问分隔线处理 */
  4263. }
  4264. /* 父问题样式 */
  4265. .parent-question {
  4266. padding: 15px 20px;
  4267. font-weight: 500;
  4268. color: #333;
  4269. }
  4270. /* 追问容器样式调整 */
  4271. .followup-container {
  4272. width: 100%;
  4273. /* background-color: rgba(0, 0, 0, 0.02); *//* 轻微的背景色区分 */
  4274. }
  4275. /* 分隔线样式 */
  4276. .followup-divider {
  4277. height: 1px;
  4278. background-color: rgba(0, 0, 0, 0.05);
  4279. margin: 0;
  4280. }
  4281. /* 追问内容样式 */
  4282. .followup-content {
  4283. padding: 12px 20px;
  4284. display: flex;
  4285. align-items: flex-start;
  4286. gap: 8px;
  4287. }
  4288. /* 追问标签样式 */
  4289. .followup-label {
  4290. color: #fb752f;
  4291. font-size: 14px;
  4292. font-weight: 500;
  4293. }
  4294. /* 追问文本样式 */
  4295. .followup-text {
  4296. color: #333;
  4297. font-size: 14px;
  4298. line-height: 1.5;
  4299. flex: 1;
  4300. }
  4301. </style>