m-SafetyHazard.vue 102 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670
  1. <template>
  2. <div class="mobile-safety-hazard">
  3. <!-- 移动端安全培训页面 -->
  4. <MobileHeader title="安全培训" @back="goBack" @menu="showHistoryDrawer" />
  5. <div class="mobile-content">
  6. <!-- 通用历史记录抽屉 -->
  7. <MobileHistoryDrawer
  8. :visible="!isGeneratingOutline && !isGeneratingExam && showHistory"
  9. title="历史记录"
  10. :historyData="historyData"
  11. :loading="isLoadingHistory"
  12. @close="showHistory = false"
  13. @createNewTask="createNewTask"
  14. @handleHistoryItem="handleHistoryItem"
  15. @deleteHistoryItem="deleteHistoryItem"
  16. />
  17. <!-- 初始状态:AI助手介绍和功能卡片 -->
  18. <div v-if="!showChat && currentStep === 'step1'" class="initial-content">
  19. <!-- AI助手介绍 -->
  20. <div class="ai-intro">
  21. <div class="ai-avatar">
  22. <img :src="aiAvatarIcon" alt="AI头像" class="ai-avatar-img">
  23. </div>
  24. <div class="ai-greeting">
  25. <h3>快速生成专业安全培训材料</h3>
  26. <p>输入培训主题,一键生成培训大纲与PPT模板</p>
  27. </div>
  28. </div>
  29. <!-- 功能卡片 -->
  30. <div class="function-cards">
  31. <div
  32. v-for="(card, index) in functionCards"
  33. :key="card.id || index"
  34. class="function-card"
  35. @click="handleFunctionCard(card.function_title)"
  36. >
  37. <div class="card-header">
  38. <div class="card-icon">
  39. <img :src="getFunctionCardIcon(card.function_title)" :alt="card.function_title" class="card-icon-img">
  40. </div>
  41. <h4>{{ card.function_title }}</h4>
  42. </div>
  43. <div class="card-description">
  44. <p>{{ card.function_content }}</p>
  45. </div>
  46. </div>
  47. <!-- 如果没有数据,显示默认卡片 -->
  48. <div v-if="functionCards.length === 0" class="function-card" @click="handleFunctionCard('安全培训课程')">
  49. <div class="card-header">
  50. <div class="card-icon">
  51. <img :src="safetyTrainingIcon" alt="安全培训课程" class="card-icon-img">
  52. </div>
  53. <h4>安全培训课程</h4>
  54. </div>
  55. <div class="card-description">
  56. <p>施工安全培训,操作规范学习</p>
  57. </div>
  58. </div>
  59. <div v-if="functionCards.length === 0" class="function-card" @click="handleFunctionCard('安全评估测试')">
  60. <div class="card-header">
  61. <div class="card-icon">
  62. <img :src="safetyAssessmentIcon" alt="安全评估" class="card-icon-img">
  63. </div>
  64. <h4>安全评估测试</h4>
  65. </div>
  66. <div class="card-description">
  67. <p>安全知识测评,能力水平评估</p>
  68. </div>
  69. </div>
  70. <div v-if="functionCards.length === 0" class="function-card" @click="handleFunctionCard('安全法规查询')">
  71. <div class="card-header">
  72. <div class="card-icon">
  73. <img :src="safetyRegulationsIcon" alt="安全法规" class="card-icon-img">
  74. </div>
  75. <h4>安全法规查询</h4>
  76. </div>
  77. <div class="card-description">
  78. <p>安全法律法规,标准规范查询</p>
  79. </div>
  80. </div>
  81. <div v-if="functionCards.length === 0" class="function-card" @click="handleFunctionCard('应急处理程序')">
  82. <div class="card-header">
  83. <div class="card-icon">
  84. <img :src="emergencyProceduresIcon" alt="应急程序" class="card-icon-img">
  85. </div>
  86. <h4>应急处理程序</h4>
  87. </div>
  88. <div class="card-description">
  89. <p>事故应急预案,处理流程指导</p>
  90. </div>
  91. </div>
  92. </div>
  93. </div>
  94. <!-- 聊天对话区域 -->
  95. <div v-else-if="showChat && currentStep === 'step1'" class="chat-messages">
  96. <div
  97. v-for="(message, index) in chatMessages"
  98. :key="index"
  99. :class="['message-item', message.type]"
  100. >
  101. <!-- 用户消息 -->
  102. <div v-if="message.type === 'user'" class="user-message">
  103. <div class="message-content">
  104. <!-- 文本内容 -->
  105. <div v-if="message.content" class="message-text">{{ message.content }}</div>
  106. </div>
  107. <div class="message-actions">
  108. <button class="action-btn copy-btn" @click="copyUserMessage(message)" title="复制">
  109. <img :src="copyIcon" alt="复制" class="action-icon">
  110. </button>
  111. <button class="action-btn edit-btn" @click="editUserMessage(message)" title="编辑">
  112. <img :src="editIcon" alt="编辑" class="action-icon">
  113. </button>
  114. </div>
  115. </div>
  116. <!-- AI消息 -->
  117. <div v-else-if="message.type === 'ai'" class="ai-message">
  118. <div class="ai-avatar-small">
  119. <img :src="aiAvatarIcon" alt="AI" class="ai-icon">
  120. </div>
  121. <div class="message-content">
  122. <div class="ai-text">
  123. <div v-if="message.displayContent.length === 0" class="typing-indicator">
  124. <div class="thinking-animation">
  125. <span class="dot"></span>
  126. <span class="dot"></span>
  127. <span class="dot"></span>
  128. </div>
  129. <span>AI正在思考中...</span>
  130. </div>
  131. <div v-else v-html="message.displayContent" class="ai-content"></div>
  132. </div>
  133. <div v-show="!message.isTyping && message.displayContent.length > 0" class="divider"></div>
  134. <div v-show="!message.isTyping && message.displayContent.length > 0" class="message-actions">
  135. <div class="left-actions">
  136. <button class="action-btn copy-btn" @click="copyAIMessage(message)" title="复制">
  137. <img :src="copyIcon" alt="复制" class="action-icon">
  138. </button>
  139. <button class="action-btn regenerate-btn" @click="regenerateResponse(index)" :disabled="hasTypingMessage" title="重新生成">
  140. <img :src="regenerateIcon" alt="重新生成" class="action-icon">
  141. </button>
  142. <button class="action-btn voice-btn" @click="handleVoiceRead(message)" :title="isSpeaking(message.id) ? '停止朗读' : '语音朗读'">
  143. <img :src="voiceIcon" alt="语音朗读" class="action-icon">
  144. </button>
  145. </div>
  146. <div class="right-actions">
  147. <button
  148. class="action-btn thumbs-up-btn"
  149. :class="{ active: message.userFeedback === 'like' }"
  150. @click="handleThumbsUp(message)"
  151. :title="message.userFeedback === 'like' ? '取消点赞' : '点赞'"
  152. >
  153. <img :src="likeIcon" alt="点赞" class="action-icon">
  154. </button>
  155. <button
  156. class="action-btn thumbs-down-btn"
  157. :class="{ active: message.userFeedback === 'dislike' }"
  158. @click="handleThumbsDown(message)"
  159. :title="message.userFeedback === 'dislike' ? '取消点踩' : '点踩'"
  160. >
  161. <img :src="dislikeIcon" alt="踩" class="action-icon">
  162. </button>
  163. </div>
  164. </div>
  165. </div>
  166. </div>
  167. </div>
  168. </div>
  169. <!-- 步骤2:培训大纲 -->
  170. <div v-else-if="currentStep === 'step2'" class="step2-content">
  171. <!-- 加载状态 -->
  172. <div v-if="isLoadingHistory" class="loading-overlay">
  173. <div class="loading-content">
  174. <div class="loading-spinner"></div>
  175. <div class="loading-text">正在加载培训大纲</div>
  176. <div class="loading-subtitle">请稍候,正在为您准备数据...</div>
  177. </div>
  178. </div>
  179. <div class="outline-container" :class="{ 'disabled': isGeneratingOutline || isGeneratingExam }">
  180. <!-- 生成中遮罩层 - 覆盖整个outline-container -->
  181. <div v-if="isGeneratingOutline" class="generating-overlay-full">
  182. <div class="generating-content">
  183. <div class="loading-spinner-small"></div>
  184. <p>AI正在生成新大纲,请稍候...</p>
  185. </div>
  186. </div>
  187. <div v-if="isGeneratingExam" class="generating-overlay-full">
  188. <div class="generating-content">
  189. <div class="loading-spinner-small"></div>
  190. <p>AI正在生成考试题目,请稍候...</p>
  191. </div>
  192. </div>
  193. <!-- 大纲容器内的按钮布局 -->
  194. <div class="outline-header">
  195. <!-- 右上角:基础操作按钮 -->
  196. <div class="outline-top-right">
  197. <button class="action-btn" @click="copyEntireOutline" :disabled="isGeneratingOutline || isGeneratingExam">
  198. <img :src="copyIcon" alt="复制" class="action-icon">
  199. 复制
  200. </button>
  201. </div>
  202. </div>
  203. <!-- 大纲内容 -->
  204. <div class="outline-content">
  205. <!-- 动态大纲内容 -->
  206. <div v-if="outlineData && outlineData.length > 0" class="outline-content-scrollable">
  207. <template v-for="(chapter, chapterIndex) in outlineData" :key="chapterIndex">
  208. <div v-if="chapter && chapter.sections" class="outline-chapter">
  209. <div class="chapter-header">
  210. <h4 class="chapter-title">{{ getDisplayChapterTitle(chapter.title, chapterIndex) }}</h4>
  211. </div>
  212. <div class="outline-section">
  213. <template v-for="(section, sectionIndex) in chapter.sections" :key="sectionIndex">
  214. <div v-if="section &&
  215. section.title !== '内容要点' &&
  216. section.title !== '概述' &&
  217. section.title !== '内容详情'" class="section-container">
  218. <div class="section-header">
  219. <div class="section-title">{{ section.title }}</div>
  220. </div>
  221. <!-- 子小节 -->
  222. <div v-if="section && section.subsections && section.subsections.length > 0" class="section-subsection">
  223. <template v-for="(subsection, subsectionIndex) in section.subsections" :key="subsectionIndex">
  224. <div v-if="subsection &&
  225. subsection.title !== '内容要点' &&
  226. subsection.title !== '概述' &&
  227. subsection.title !== '内容详情' &&
  228. !subsection.title.includes('总章节数') &&
  229. !subsection.title.includes('总小节数') &&
  230. !subsection.title.includes('预计PPT页数') &&
  231. !subsection.title.includes('预计讲解时长')" class="subsection-container">
  232. <div class="subsection-header">
  233. <div class="subsection-title">{{ subsection.title }}</div>
  234. </div>
  235. <!-- 具体内容要点 -->
  236. <div v-if="subsection.subsubsections && subsection.subsubsections.length > 0" class="subsubsection-container">
  237. <template v-for="(subsubsection, subsubsectionIndex) in subsection.subsubsections" :key="subsubsectionIndex">
  238. <div class="subsubsection-item">
  239. <div class="subsubsection-header">
  240. <div class="subsubsection-title">{{ subsubsection.title }}</div>
  241. </div>
  242. </div>
  243. </template>
  244. </div>
  245. </div>
  246. </template>
  247. </div>
  248. </div>
  249. </template>
  250. </div>
  251. </div>
  252. </template>
  253. <!-- 添加新章节按钮 -->
  254. <div v-if="outlineData.length < 6" class="add-chapter-container">
  255. <button class="add-chapter-btn" @click="addNewItem('chapter', null)">
  256. <img :src="addIcon" alt="添加章节" class="add-icon">
  257. <span>添加新章节</span>
  258. </button>
  259. </div>
  260. </div>
  261. <!-- AI返回的层级内容渲染 -->
  262. <div v-else-if="aiOutlineContent" class="ai-outline-content">
  263. <div class="ai-outline-scrollable" v-html="aiOutlineContent"></div>
  264. </div>
  265. <!-- 默认大纲内容 -->
  266. <div v-else class="default-outline">
  267. <div class="outline-chapter">
  268. <h4>第一章 安全生产基本原则</h4>
  269. <div class="outline-section">
  270. <div class="section-item">1.1 安全生产的重要性</div>
  271. <div class="section-item">1.2 安全生产相关法规</div>
  272. <div class="section-subsection">
  273. <div class="subsection-item">1.2.1 《中华人民共和国安全生产法》解读</div>
  274. <div class="subsection-item">1.2.2 建筑工程安全管理规范</div>
  275. </div>
  276. </div>
  277. </div>
  278. <div class="outline-chapter">
  279. <h4>第二章 施工现场安全管理</h4>
  280. <div class="outline-section">
  281. <div class="section-item">2.1 安全责任制度</div>
  282. <div class="section-item">2.2 安全教育培训</div>
  283. <div class="section-item">2.3 安全检查与隐患排查</div>
  284. </div>
  285. </div>
  286. <div class="outline-chapter">
  287. <h4>第三章 常见安全隐患及防范措施</h4>
  288. <div class="outline-section">
  289. <div class="section-item">3.1 高空作业安全</div>
  290. <div class="section-subsection">
  291. <div class="subsection-item">3.1.1 脚手架搭设及使用安全规范</div>
  292. </div>
  293. <div class="section-item">3.2 用电安全</div>
  294. <div class="section-item">3.3 消防安全</div>
  295. </div>
  296. </div>
  297. <div class="outline-chapter">
  298. <h4>第四章 安全事故案例分析</h4>
  299. <div class="outline-section">
  300. <div class="section-item">4.1 典型事故分析与教训</div>
  301. </div>
  302. </div>
  303. <div class="outline-chapter">
  304. <h4>第五章 总结与展望</h4>
  305. </div>
  306. </div>
  307. </div>
  308. <!-- 左下角:大纲操作按钮 -->
  309. <div class="outline-bottom-left">
  310. <button class="action-btn regenerate-btn" @click="regenerateOutline" :disabled="isGeneratingOutline || isGeneratingExam">
  311. <img :src="regenerateIcon" alt="重新生成" class="action-icon" :class="{ 'rotating': isGeneratingOutline }">
  312. {{ isGeneratingOutline ? '生成中...' : '生成新大纲' }}
  313. </button>
  314. <button class="action-btn" @click="selectPptTemplate" :disabled="isGeneratingOutline || isGeneratingExam">
  315. 选择PPT模版
  316. <img :src="pptIcon" alt="箭头" class="action-icon">
  317. </button>
  318. </div>
  319. <!-- 右下角:评价按钮 -->
  320. <div class="outline-bottom-right">
  321. <button class="action-btn like-btn" @click="handleOutlineThumbsUp" :class="{ active: getEvaluationStatus() === 'like' }" :disabled="isGeneratingOutline || isGeneratingExam">
  322. <img :src="likeIcon" alt="满意" class="action-icon">
  323. <!-- 满意 -->
  324. </button>
  325. <button class="action-btn dislike-btn" @click="handleOutlineThumbsDown" :class="{ active: getEvaluationStatus() === 'dislike' }" :disabled="isGeneratingOutline || isGeneratingExam">
  326. <img :src="dislikeIcon" alt="不满意" class="action-icon">
  327. <!-- 不满意 -->
  328. </button>
  329. </div>
  330. </div>
  331. </div>
  332. <!-- 底部输入区域 -->
  333. <div v-if="currentStep === 'step1'" class="chat-input-section">
  334. <div class="input-container">
  335. <div class="input-box">
  336. <input
  337. type="text"
  338. placeholder="请在此处发送消息"
  339. class="message-input"
  340. v-model="messageText"
  341. @keyup.enter="sendMessage"
  342. :disabled="isSending || hasTypingMessage"
  343. maxlength="2000"
  344. >
  345. <button class="voice-btn" @click="handleVoiceClick" :disabled="isSending || hasTypingMessage" :class="{ 'recording': isListening }">
  346. <div class="icon-container">
  347. <img :src="voiceInputIcon" alt="语音" class="action-icon">
  348. <div v-if="isListening" class="recording-indicator"></div>
  349. </div>
  350. </button>
  351. <div class="divider"></div>
  352. <button class="send-btn" @click="sendMessage" :disabled="isSending || hasTypingMessage || !messageText.trim()">
  353. <img :src="messageText.trim() && !isSending ? sendIconFilled : sendIconEmpty" alt="发送" class="send-icon">
  354. </button>
  355. </div>
  356. </div>
  357. </div>
  358. </div>
  359. <!-- 移动端轻提示 -->
  360. <MobileToast
  361. :visible="showToast"
  362. :message="toastMessage"
  363. :duration="toastDuration"
  364. @close="showToast = false"
  365. />
  366. <!-- 删除确认弹窗 -->
  367. <DeleteConfirmModal
  368. :visible="showDeleteModal"
  369. :title="deleteConfirmTitle"
  370. :message="deleteConfirmMessage"
  371. @confirm="confirmDelete"
  372. @cancel="cancelDelete"
  373. @close="cancelDelete"
  374. />
  375. </div>
  376. </template>
  377. <script setup>
  378. import { useRouter, useRoute } from 'vue-router'
  379. import MobileHeader from '@/components/MobileHeader.vue'
  380. import MobileHistoryDrawer from '@/components/MobileHistoryDrawer.vue'
  381. import DeleteConfirmModal from '@/components/DeleteConfirmModal.vue'
  382. import MobileToast from '@/components/MobileToast.vue'
  383. import { ref, onMounted, watch, nextTick, computed, onBeforeUnmount } from 'vue'
  384. import { apis } from '@/request/apis.js'
  385. import { initNativeNavForSubPage } from '@/utils/nativeBridge.js'
  386. // ===== 已删除:getUserId - 不再需要,改用token =====
  387. // import { getUserId } from '@/utils/userManager.js'
  388. import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
  389. // 导入图片资源 - 安全培训专用
  390. import aiAvatarIcon from '@/assets/Safety/5.png'
  391. import safetyTrainingIcon from '@/assets/Safety/4.png'
  392. import safetyAssessmentIcon from '@/assets/Safety/3.png'
  393. import safetyRegulationsIcon from '@/assets/Safety/2.png'
  394. import emergencyProceduresIcon from '@/assets/Safety/1.png'
  395. import sendIconEmpty from '@/assets/Chat/15.png'
  396. import sendIconFilled from '@/assets/Chat/16.png'
  397. // 对话操作图标
  398. import copyIcon from '@/assets/AIWriting/12.png'
  399. import editIcon from '@/assets/AIWriting/6.png'
  400. import regenerateIcon from '@/assets/Safety/12.png'
  401. import voiceIcon from '@/assets/AIWriting/9.png'
  402. import likeIcon from '@/assets/AIWriting/10.png'
  403. import dislikeIcon from '@/assets/AIWriting/11.png'
  404. // 大纲操作图标
  405. import examIcon from '@/assets/Safety/31.png'
  406. import downloadIcon from '@/assets/AIWriting/13.png'
  407. import addIcon from '@/assets/Safety/8.png'
  408. import evaluateIcon from '@/assets/index/6.png'
  409. import pptIcon from '@/assets/Safety/13.png'
  410. // 语音输入图标
  411. import voiceInputIcon from '@/assets/Chat/18.png'
  412. const router = useRouter()
  413. const route = useRoute()
  414. const goBack = () => {
  415. router.go(-1)
  416. }
  417. // 显示历史记录抽屉的方法
  418. const showHistoryDrawer = () => {
  419. if (!isGeneratingOutline.value && !isGeneratingExam.value) {
  420. showHistory.value = true
  421. }
  422. // AI处理中时不执行任何操作,不记录点击意图
  423. }
  424. const showHistory = ref(false)
  425. // 历史记录相关状态
  426. const historyData = ref([])
  427. const historyTotal = ref(0)
  428. const isLoadingHistory = ref(false)
  429. // 聊天相关状态
  430. const showChat = ref(false)
  431. const chatMessages = ref([])
  432. const messageText = ref('')
  433. const isSending = ref(false)
  434. const ai_conversation_id = ref(0)
  435. // 步骤控制
  436. const currentStep = ref('step1') // step1: 聊天界面, step2: 培训大纲
  437. // 删除确认弹窗状态
  438. const showDeleteModal = ref(false)
  439. const deleteTargetItem = ref(null)
  440. const deleteType = ref('')
  441. // 语音朗读状态
  442. const speakingMessageId = ref(null)
  443. // 语音识别功能
  444. const {
  445. isSupported: speechSupported,
  446. isListening,
  447. transcript,
  448. error: speechError,
  449. startListening,
  450. stopListening,
  451. speakText,
  452. stopSpeaking
  453. } = useSpeechRecognition()
  454. // Toast状态
  455. const showToast = ref(false)
  456. const toastMessage = ref('')
  457. const toastDuration = ref(2000)
  458. // 功能卡片数据
  459. const functionCards = ref([])
  460. // 功能卡片图标计数器
  461. let functionCardIconIndex = 0
  462. // 大纲相关状态
  463. const outlineTitle = ref('')
  464. const outlineData = ref([])
  465. const aiOutlineContent = ref('')
  466. const isGeneratingOutline = ref(false)
  467. const isGeneratingExam = ref(false)
  468. const outlineFeedback = ref(null) // 大纲评价反馈
  469. const evaluation = ref('') // 本地评价状态
  470. const currentAiMessageId = ref(null) // 当前AI消息的ID
  471. // 计算属性 - 是否有正在打字的AI消息
  472. const hasTypingMessage = computed(() => {
  473. return chatMessages.value.some(message => message.type === 'ai' && message.isTyping)
  474. })
  475. // 删除确认消息
  476. const deleteConfirmMessage = computed(() => {
  477. if (deleteType.value === 'history') {
  478. const title = deleteTargetItem.value?.item?.title || ''
  479. return `确定要删除历史记录"${title}"吗?删除后将无法恢复。`
  480. } else if (deleteType.value === 'message') {
  481. return '确定要删除这条消息吗?删除后将无法恢复。'
  482. }
  483. return '确定要删除吗?删除后将无法恢复。'
  484. })
  485. // 删除确认标题
  486. const deleteConfirmTitle = computed(() => {
  487. if (deleteType.value === 'history') {
  488. return '删除历史记录'
  489. } else if (deleteType.value === 'message') {
  490. return '删除消息'
  491. }
  492. return '删除确认'
  493. })
  494. // 从HTML转换为Markdown(适配移动端HTML内容)
  495. const convertOutlineToMarkdownFromHTML = (htmlContent, title) => {
  496. let markdown = `# ${title}\n\n`
  497. // 创建临时容器解析HTML
  498. const tempDiv = document.createElement('div')
  499. tempDiv.innerHTML = htmlContent
  500. // 遍历HTML元素生成markdown
  501. const processElement = (element) => {
  502. const tagName = element.tagName.toLowerCase()
  503. if (tagName === 'h1') {
  504. const content = element.textContent.trim()
  505. if (content && !content.includes('大纲')) {
  506. markdown += `## ${content}\n\n`
  507. }
  508. } else if (tagName === 'h2') {
  509. const content = element.textContent.trim()
  510. markdown += `### ${content}\n\n`
  511. } else if (tagName === 'h3') {
  512. const content = element.textContent.trim()
  513. markdown += `#### ${content}\n\n`
  514. } else if (tagName === 'h4') {
  515. const content = element.textContent.trim()
  516. markdown += `##### ${content}\n\n`
  517. } else if (tagName === 'ul') {
  518. const listItems = element.querySelectorAll('li')
  519. listItems.forEach((li) => {
  520. const content = li.textContent.trim()
  521. if (content) {
  522. markdown += `- ${content}\n`
  523. }
  524. })
  525. markdown += '\n'
  526. } else if (tagName === 'ol') {
  527. const listItems = element.querySelectorAll('li')
  528. listItems.forEach((li, index) => {
  529. const content = li.textContent.trim()
  530. if (content) {
  531. markdown += `${index + 1}. ${content}\n`
  532. }
  533. })
  534. markdown += '\n'
  535. } else if (tagName === 'p') {
  536. const content = element.textContent.trim()
  537. if (content) {
  538. markdown += `${content}\n\n`
  539. }
  540. } else {
  541. // 递归处理子元素
  542. for (const child of element.childNodes) {
  543. if (child.nodeType === Node.ELEMENT_NODE) {
  544. processElement(child)
  545. }
  546. }
  547. }
  548. }
  549. // 处理所有子元素
  550. for (const child of tempDiv.childNodes) {
  551. if (child.nodeType === Node.ELEMENT_NODE) {
  552. processElement(child)
  553. }
  554. }
  555. return markdown
  556. }
  557. // 解析AI考试回复(从PC端移植)
  558. const parseAIExamResponse = (aiReply) => {
  559. try {
  560. // 尝试提取JSON内容
  561. const jsonMatch = aiReply.match(/\{[\s\S]*\}/)
  562. if (jsonMatch) {
  563. const examData = JSON.parse(jsonMatch[0])
  564. // 确保所有题目都有正确的初始值
  565. ensureQuestionInitialValues(examData)
  566. return examData
  567. } else {
  568. throw new Error('未找到有效的JSON数据')
  569. }
  570. } catch (error) {
  571. console.error('解析AI回复失败:', error)
  572. // 返回默认试卷结构
  573. return generateDefaultExam()
  574. }
  575. }
  576. // 确保题目初始值正确(从PC端移植)
  577. const ensureQuestionInitialValues = (examData) => {
  578. // 单选题
  579. if (examData.singleChoice && examData.singleChoice.questions) {
  580. examData.singleChoice.questions.forEach(question => {
  581. if (!question.selectedAnswer) {
  582. question.selectedAnswer = ""
  583. }
  584. if (!question.options || question.options.length === 0) {
  585. question.options = [
  586. { key: "A", text: "选项A" },
  587. { key: "B", text: "选项B" },
  588. { key: "C", text: "选项C" },
  589. { key: "D", text: "选项D" }
  590. ]
  591. }
  592. })
  593. }
  594. // 判断题
  595. if (examData.judge && examData.judge.questions) {
  596. examData.judge.questions.forEach(question => {
  597. if (!question.selectedAnswer) {
  598. question.selectedAnswer = ""
  599. }
  600. })
  601. }
  602. // 多选题
  603. if (examData.multiple && examData.multiple.questions) {
  604. examData.multiple.questions.forEach(question => {
  605. if (!question.selectedAnswers) {
  606. question.selectedAnswers = []
  607. }
  608. if (!question.options || question.options.length === 0) {
  609. question.options = [
  610. { key: "A", text: "选项A" },
  611. { key: "B", text: "选项B" },
  612. { key: "C", text: "选项C" },
  613. { key: "D", text: "选项D" }
  614. ]
  615. }
  616. })
  617. }
  618. // 简答题
  619. if (examData.short && examData.short.questions) {
  620. examData.short.questions.forEach(question => {
  621. if (!question.outline) {
  622. question.outline = { keyFactors: "答题要点、关键因素:示例答案" }
  623. }
  624. })
  625. }
  626. }
  627. // 生成默认考试(从PC端移植)
  628. const generateDefaultExam = () => {
  629. return {
  630. title: "安全培训考试",
  631. totalScore: 100,
  632. totalQuestions: 17,
  633. singleChoice: {
  634. scorePerQuestion: 5,
  635. totalScore: 25,
  636. count: 5,
  637. questions: []
  638. },
  639. judge: {
  640. scorePerQuestion: 3,
  641. totalScore: 15,
  642. count: 5,
  643. questions: []
  644. },
  645. multiple: {
  646. scorePerQuestion: 8,
  647. totalScore: 40,
  648. count: 5,
  649. questions: []
  650. },
  651. short: {
  652. scorePerQuestion: 10,
  653. totalScore: 20,
  654. count: 2,
  655. questions: []
  656. }
  657. }
  658. }
  659. // 导出考试文件(从PC端移植)
  660. const exportExamToFile = (examData) => {
  661. try {
  662. // 创建Word文档内容(使用PC端相同的createExamWordContent函数)
  663. const wordContent = createExamWordContent(examData)
  664. // 创建Blob对象 - 使用Word兼容的MIME类型
  665. const blob = new Blob([wordContent], {
  666. type: 'application/msword'
  667. })
  668. // 下载文件
  669. const url = URL.createObjectURL(blob)
  670. const link = document.createElement('a')
  671. link.setAttribute('href', url)
  672. link.setAttribute('download', `${examData.title}_${new Date().toISOString().split('T')[0]}.doc`)
  673. link.style.visibility = 'hidden'
  674. document.body.appendChild(link)
  675. link.click()
  676. document.body.removeChild(link)
  677. URL.revokeObjectURL(url)
  678. showToastMessage('考试文件已下载!')
  679. } catch (error) {
  680. console.error('导出考试文件失败:', error)
  681. showToastMessage('导出考试文件失败,请重试')
  682. }
  683. }
  684. // 创建Word格式的考试文档内容(从PC端完整移植)
  685. const createExamWordContent = (examData) => {
  686. const currentTime = new Date().toLocaleString('zh-CN')
  687. // HTML文档内容,使用Word兼容的格式
  688. let htmlContent = `<!DOCTYPE html>
  689. <html xmlns:o="urn:schemas-microsoft-com:office:office"
  690. xmlns:w="urn:schemas-microsoft-com:office:word"
  691. xmlns="http://www.w3.org/TR/REC-html40">
  692. <head>
  693. <meta charset="utf-8">
  694. <meta name="ProgId" content="Word.Document">
  695. <meta name="Generator" content="Microsoft Word 15">
  696. <meta name="Originator" content="Microsoft Word 15">
  697. <title>${examData.title || '考试试卷'}</title>
  698. <!--[if gte mso 9]>
  699. <xml>
  700. <w:WordDocument>
  701. <w:View>Print</w:View>
  702. <w:Zoom>100</w:Zoom>
  703. <w:DoNotPromptForConvert/>
  704. <w:DoNotShowRevisions/>
  705. <w:DoNotPrintRevisions/>
  706. <w:DoNotShowComments/>
  707. <w:DoNotShowInsertionsAndDeletions/>
  708. <w:DoNotShowPropertyChanges/>
  709. <w:Compatibility>
  710. <w:BreakWrappedTables/>
  711. <w:SnapToGridInCell/>
  712. <w:WrapTextWithPunct/>
  713. <w:UseAsianBreakRules/>
  714. <w:DontGrowAutofit/>
  715. </w:Compatibility>
  716. </w:WordDocument>
  717. </xml>
  718. <![endif]-->
  719. <style>
  720. body {
  721. font-family: "Microsoft YaHei", "宋体", Arial, sans-serif;
  722. font-size: 14px;
  723. line-height: 1.6;
  724. margin: 24px;
  725. color: #000;
  726. }
  727. .header {
  728. text-align: center;
  729. margin-bottom: 14px;
  730. }
  731. .exam-title {
  732. font-size: 24px;
  733. font-weight: bold;
  734. margin-bottom: 14px;
  735. color: #000;
  736. }
  737. .exam-info {
  738. font-size: 14px;
  739. color: #666;
  740. margin-bottom: 14px;
  741. }
  742. .section {
  743. margin-bottom: 14px;
  744. }
  745. .section-title {
  746. font-size: 18px;
  747. font-weight: bold;
  748. margin-bottom: 14px;
  749. color: #000;
  750. border-bottom: 2px solid #3e7bfa;
  751. padding-bottom: 5px;
  752. }
  753. .question {
  754. margin-bottom: 14px;
  755. padding: 10px;
  756. background-color: #f9f9f9;
  757. border-left: 4px solid #3e7bfa;
  758. }
  759. .question-header {
  760. margin-bottom: 14px;
  761. line-height: 1.6;
  762. }
  763. .question-number {
  764. font-weight: bold;
  765. color: #3e7bfa;
  766. }
  767. .options {
  768. margin-left: 12px;
  769. }
  770. .option {
  771. margin-bottom: 5px;
  772. }
  773. .answer {
  774. margin-top: 10px;
  775. padding: 8px;
  776. background: #e8f4fd;
  777. border-radius: 4px;
  778. font-weight: bold;
  779. color: #2c5aa0;
  780. }
  781. </style>
  782. </head>
  783. <body>
  784. <div class="header">
  785. <div class="exam-title">${examData.title || '考试试卷'}</div>
  786. <div class="exam-info">
  787. 总分:${examData.totalScore || 0}分 | 总题数:${examData.totalQuestions || 0}题 | 生成时间:${currentTime}
  788. </div>
  789. </div>`
  790. // 单选题
  791. if (examData.singleChoice && examData.singleChoice.questions.length > 0) {
  792. htmlContent += `
  793. <div class="section">
  794. <div class="section-title">一、单选题(${examData.singleChoice.count}题,每题${examData.singleChoice.scorePerQuestion}分,共${examData.singleChoice.totalScore}分)</div>`
  795. examData.singleChoice.questions.forEach((question, index) => {
  796. htmlContent += `
  797. <div class="question">
  798. <div class="question-header">
  799. <span class="question-number">${index + 1}.</span> ${question.text}
  800. </div>
  801. <div class="options">`
  802. question.options.forEach(option => {
  803. htmlContent += `
  804. <div class="option">${option.key}. ${option.text}</div>`
  805. })
  806. htmlContent += `
  807. </div>
  808. <div class="answer">正确答案:${question.selectedAnswer} </div>
  809. </div>`
  810. })
  811. htmlContent += `
  812. </div>`
  813. }
  814. // 判断题
  815. if (examData.judge && examData.judge.questions.length > 0) {
  816. htmlContent += `
  817. <div class="section">
  818. <div class="section-title">二、判断题(${examData.judge.count}题,每题${examData.judge.scorePerQuestion}分,共${examData.judge.totalScore}分)</div>`
  819. examData.judge.questions.forEach((question, index) => {
  820. htmlContent += `
  821. <div class="question">
  822. <div class="question-header">
  823. <span class="question-number">${index + 1}.</span> ${question.text}
  824. </div>
  825. <div class="answer">正确答案:${question.selectedAnswer} </div>
  826. </div>`
  827. })
  828. htmlContent += `
  829. </div>`
  830. }
  831. // 多选题
  832. if (examData.multiple && examData.multiple.questions.length > 0) {
  833. htmlContent += `
  834. <div class="section">
  835. <div class="section-title">三、多选题(${examData.multiple.count}题,每题${examData.multiple.scorePerQuestion}分,共${examData.multiple.totalScore}分)</div>`
  836. examData.multiple.questions.forEach((question, index) => {
  837. htmlContent += `
  838. <div class="question">
  839. <div class="question-header">
  840. <span class="question-number">${index + 1}.</span> ${question.text}
  841. </div>
  842. <div class="options">`
  843. question.options.forEach(option => {
  844. htmlContent += `
  845. <div class="option">${option.key}. ${option.text}</div>`
  846. })
  847. htmlContent += `
  848. </div>
  849. <div class="answer">正确答案:${question.selectedAnswers.join(', ')}</div>
  850. </div>`
  851. })
  852. htmlContent += `
  853. </div>`
  854. }
  855. // 简答题
  856. if (examData.short && examData.short.questions.length > 0) {
  857. htmlContent += `
  858. <div class="section">
  859. <div class="section-title">四、简答题(${examData.short.count}题,每题${examData.short.scorePerQuestion}分,共${examData.short.totalScore}分)</div>`
  860. examData.short.questions.forEach((question, index) => {
  861. htmlContent += `
  862. <div class="question">
  863. <div class="question-header">
  864. <span class="question-number">${index + 1}.</span> ${question.text}
  865. </div>
  866. <div class="answer">答题要点:${question.outline.keyFactors} </div>
  867. </div>`
  868. })
  869. htmlContent += `
  870. </div>`
  871. }
  872. htmlContent += `
  873. </body>
  874. </html>`
  875. return htmlContent
  876. }
  877. // 时间解析与格式化(容错更强)
  878. const parseToDate = (input) => {
  879. if (!input) return null
  880. if (typeof input === 'number') {
  881. const ms = input < 1e12 ? input * 1000 : input
  882. return new Date(ms)
  883. }
  884. if (typeof input === 'string') {
  885. let d = new Date(input)
  886. if (!isNaN(d)) return d
  887. const normalized = input.replace(/-/g, '/').replace('T', ' ')
  888. d = new Date(normalized)
  889. if (!isNaN(d)) return d
  890. }
  891. return new Date(input)
  892. }
  893. const formatTime = (timestamp) => {
  894. const date = parseToDate(timestamp)
  895. if (!date || isNaN(date)) return ''
  896. const now = new Date()
  897. const isToday = date.toDateString() === now.toDateString()
  898. const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
  899. const isYesterday = date.toDateString() === yesterday.toDateString()
  900. if (isToday) {
  901. return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
  902. }
  903. if (isYesterday) {
  904. return '昨天 ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
  905. }
  906. const month = date.getMonth() + 1
  907. const day = date.getDate()
  908. const time = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
  909. return `${month}月${day}日 ${time}`
  910. }
  911. // 从AI回复中提取大纲标题(从PC端移植)
  912. const extractOutlineTitleFromAI = (aiReply) => {
  913. try {
  914. let title = '安全培训大纲' // 默认标题
  915. // 尝试从第一行提取标题
  916. const lines = aiReply.split('\n')
  917. for (let line of lines) {
  918. const trimmedLine = line.trim()
  919. // 查找包含"以下是为您准备的PPT大纲"的行,下一行通常是标题
  920. if (trimmedLine.includes('以下是为您准备的PPT大纲')) {
  921. // 查找下一行作为标题
  922. const nextLineIndex = lines.indexOf(line) + 1
  923. if (nextLineIndex < lines.length) {
  924. const nextLine = lines[nextLineIndex].trim()
  925. if (nextLine && nextLine.length > 0 && !nextLine.includes('以下') && !nextLine.includes('大纲统计信息')) {
  926. title = nextLine
  927. break
  928. }
  929. }
  930. }
  931. // 查找#开头的大标题,作为整个大纲的标题
  932. if (trimmedLine.startsWith('# ')) {
  933. title = trimmedLine.replace('# ', '').trim()
  934. break
  935. }
  936. // 查找"## "开头的一级标题
  937. if (trimmedLine.startsWith('## ')) {
  938. const titleText = trimmedLine.replace('## ', '').trim()
  939. if (titleText && !titleText.includes('内容要点') && !titleText.includes('概述') && !titleText.includes('内容详情')) {
  940. title = titleText
  941. break
  942. }
  943. }
  944. // 查找没有#号的大标题行(通常是第一行的重要标题)
  945. if (trimmedLine.length > 10 &&
  946. !trimmedLine.includes('以下是') &&
  947. !trimmedLine.includes('大纲统计') &&
  948. !trimmedLine.includes('##') &&
  949. !trimmedLine.includes('###') &&
  950. trimmedLine.length < 50) {
  951. // 如果这一行看起来像标签
  952. if (!title.includes('#') && title === '安全培训大纲') {
  953. title = trimmedLine
  954. }
  955. }
  956. }
  957. console.log('提取的大纲标题:', title)
  958. return title
  959. } catch (error) {
  960. console.error('提取大纲标题失败:', error)
  961. return '安全培训大纲'
  962. }
  963. }
  964. // 生成对话标题
  965. const generateConversationTitle = (content) => {
  966. if (!content) return '新对话'
  967. // 取前30个字符作为标题
  968. const title = content.replace(/<[^>]*>/g, '').trim()
  969. return title.length > 30 ? title.substring(0, 30) + '...' : title
  970. }
  971. // 获取历史记录列表
  972. const getHistoryRecordList = async () => {
  973. try {
  974. console.log('📋 开始获取移动端安全培训历史记录列表...')
  975. isLoadingHistory.value = true
  976. const startTime = performance.now()
  977. const response = await apis.getHistoryRecord({
  978. // ===== 已删除:user_id - 后端从token解析 =====
  979. ai_conversation_id: 0, // 0表示获取对话列表
  980. business_type: 1 // 安全培训类型
  981. })
  982. const endTime = performance.now()
  983. console.log(`📋 移动端安全培训历史记录API调用耗时: ${(endTime - startTime).toFixed(2)}ms`)
  984. console.log('📋 移动端历史记录列表响应:', response)
  985. if (response.statusCode === 200) {
  986. // 设置历史记录总数
  987. historyTotal.value = response.total || 0
  988. // 转换后端数据为前端格式
  989. historyData.value = response.data.map(conversation => ({
  990. id: conversation.id,
  991. title: generateConversationTitle(conversation.content),
  992. time: formatTime(conversation.updated_at),
  993. businessType: conversation.business_type,
  994. isActive: false,
  995. // 保存原始数据用于后续查询
  996. rawData: conversation
  997. }))
  998. // 高亮当前对话
  999. if (ai_conversation_id.value) {
  1000. historyData.value.forEach(item => { item.isActive = item.id === ai_conversation_id.value })
  1001. }
  1002. console.log(`✅ 移动端安全培训历史记录列表已设置: ${historyData.value.length}条记录,总数: ${historyTotal.value}`)
  1003. } else {
  1004. console.error('❌ 获取移动端历史记录列表失败:', response.statusCode)
  1005. }
  1006. } catch (error) {
  1007. console.error('❌ 获取移动端历史记录列表失败:', error)
  1008. } finally {
  1009. isLoadingHistory.value = false
  1010. }
  1011. }
  1012. // 将Markdown格式转换为HTML格式
  1013. const markdownToHtml = (text) => {
  1014. if (!text) return text
  1015. console.log('开始转换Markdown:', text)
  1016. let html = text
  1017. // 检查是否包含Markdown格式
  1018. const hasMarkdown = /(?:^|<br>)#{1,6}\s*/.test(html) || /\*\*.*?\*\*/.test(html) || /^\s*[-*]\s+/.test(html)
  1019. console.log('Markdown格式检测结果:', hasMarkdown)
  1020. // 检查是否已经包含HTML标签(如<strong>、<em>等)
  1021. const hasHtmlTags = /<[^>]*>/.test(html)
  1022. console.log('HTML标签检测结果:', hasHtmlTags)
  1023. // 如果包含Markdown格式,进行转换
  1024. if (hasMarkdown) {
  1025. console.log('检测到Markdown格式,进行Markdown转换')
  1026. // 转换标题
  1027. html = html.replace(/^#{6}\s*(.+)$/gm, '<h6>$1</h6>')
  1028. html = html.replace(/^#{5}\s*(.+)$/gm, '<h5>$1</h5>')
  1029. html = html.replace(/^#{4}\s*(.+)$/gm, '<h4>$1</h4>')
  1030. html = html.replace(/^#{3}\s*(.+)$/gm, '<h3>$1</h3>')
  1031. html = html.replace(/^#{2}\s*(.+)$/gm, '<h2>$1</h2>')
  1032. html = html.replace(/^#{1}\s*(.+)$/gm, '<h1>$1</h1>')
  1033. // 转换加粗
  1034. html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
  1035. // 转换斜体
  1036. html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
  1037. // 转换列表
  1038. html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>')
  1039. html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
  1040. // 转换代码
  1041. html = html.replace(/`(.*?)`/g, '<code>$1</code>')
  1042. console.log('Markdown转换完成:', html)
  1043. } else if (hasHtmlTags) {
  1044. console.log('检测到HTML标签,跳过Markdown转换')
  1045. } else {
  1046. console.log('未检测到特殊格式,保持原文本')
  1047. }
  1048. // 处理换行
  1049. html = html.replace(/\n/g, '<br>')
  1050. console.log('最终HTML:', html)
  1051. return html
  1052. }
  1053. // 根据功能卡片标题返回对应的图标
  1054. const getFunctionCardIcon = (title) => {
  1055. // 按顺序循环使用4个图标
  1056. const icons = [safetyTrainingIcon, safetyAssessmentIcon, safetyRegulationsIcon, emergencyProceduresIcon]
  1057. const icon = icons[functionCardIconIndex % icons.length]
  1058. functionCardIconIndex++
  1059. return icon
  1060. }
  1061. // 点击功能卡片
  1062. const handleFunctionCard = (cardType) => {
  1063. console.log('点击功能卡片:', cardType)
  1064. // 重置聊天状态,准备创建新的对话
  1065. chatMessages.value = []
  1066. ai_conversation_id.value = 0
  1067. // 显示聊天界面
  1068. showChat.value = true
  1069. // 直接使用卡片类型作为消息内容
  1070. const message = `请详细介绍${cardType}的相关内容`
  1071. messageText.value = message
  1072. // 自动发送消息
  1073. sendMessage()
  1074. }
  1075. // 发送消息
  1076. const sendMessage = async () => {
  1077. if (!messageText.value.trim() || isSending.value) return
  1078. console.log('开始发送消息:', messageText.value)
  1079. isSending.value = true
  1080. // 切换到聊天界面
  1081. showChat.value = true
  1082. // 添加用户消息
  1083. const userMessage = {
  1084. type: 'user',
  1085. content: messageText.value,
  1086. id: Date.now()
  1087. }
  1088. chatMessages.value.push(userMessage)
  1089. // 添加AI消息(初始状态为思考中)
  1090. const aiMessage = {
  1091. type: 'ai',
  1092. content: '',
  1093. displayContent: '',
  1094. isTyping: true,
  1095. id: Date.now() + 1,
  1096. userFeedback: null
  1097. }
  1098. chatMessages.value.push(aiMessage)
  1099. // 清空输入框
  1100. const currentMessage = messageText.value
  1101. messageText.value = ''
  1102. // 发送消息后滚动到底部
  1103. scrollToBottom()
  1104. try {
  1105. // 调用后端DeepSeek接口
  1106. const response = await apis.sendDeepseekMessage({
  1107. // ===== 已删除:user_id - 后端从token解析 =====
  1108. ai_conversation_id: ai_conversation_id.value,
  1109. message: currentMessage,
  1110. business_type: 1 // 安全培训类型
  1111. })
  1112. console.log('AI回复响应:', response)
  1113. if (response.statusCode === 200) {
  1114. // 设置对话ID
  1115. if (response.data && response.data.ai_conversation_id) {
  1116. ai_conversation_id.value = response.data.ai_conversation_id
  1117. }
  1118. // 设置AI消息ID(用于评价功能)
  1119. if (response.data && response.data.ai_message_id) {
  1120. currentAiMessageId.value = response.data.ai_message_id
  1121. console.log('设置AI消息ID:', currentAiMessageId.value)
  1122. }
  1123. // 处理AI回复
  1124. const aiReply = response.data ? response.data.reply : response.reply || ''
  1125. console.log('AI回复内容:', aiReply)
  1126. // 将Markdown格式转换为HTML格式
  1127. const htmlReply = markdownToHtml(aiReply)
  1128. // 开始打字效果 - 按完整HTML标签或文本块显示,避免标签分割
  1129. const textBlocks = []
  1130. let currentBlock = ''
  1131. let inTag = false
  1132. let tagContent = ''
  1133. // 将HTML内容分割成完整的块
  1134. for (let i = 0; i < htmlReply.length; i++) {
  1135. const char = htmlReply[i]
  1136. if (char === '<') {
  1137. // 如果之前有文本内容,先保存
  1138. if (currentBlock && !inTag) {
  1139. textBlocks.push({ type: 'text', content: currentBlock })
  1140. currentBlock = ''
  1141. }
  1142. inTag = true
  1143. tagContent = char
  1144. } else if (char === '>') {
  1145. // 标签结束
  1146. tagContent += char
  1147. textBlocks.push({ type: 'tag', content: tagContent })
  1148. inTag = false
  1149. tagContent = ''
  1150. } else if (inTag) {
  1151. // 在标签内
  1152. tagContent += char
  1153. } else {
  1154. // 普通文本
  1155. currentBlock += char
  1156. }
  1157. }
  1158. // 保存最后一个文本块
  1159. if (currentBlock) {
  1160. textBlocks.push({ type: 'text', content: currentBlock })
  1161. }
  1162. // 检查是否有未闭合的标签
  1163. if (tagContent) {
  1164. textBlocks.push({ type: 'tag', content: tagContent })
  1165. }
  1166. console.log('分割后的文本块:', textBlocks)
  1167. let currentBlockIndex = 0
  1168. let currentCharIndex = 0
  1169. const typeInterval = setInterval(() => {
  1170. if (currentBlockIndex < textBlocks.length) {
  1171. const currentBlock = textBlocks[currentBlockIndex]
  1172. if (currentBlock.type === 'tag') {
  1173. // 标签直接显示,不分字符
  1174. console.log('显示HTML标签:', currentBlock.content)
  1175. aiMessage.displayContent += currentBlock.content
  1176. currentBlockIndex++
  1177. currentCharIndex = 0
  1178. } else {
  1179. // 文本按字符显示
  1180. if (currentCharIndex < currentBlock.content.length) {
  1181. const newContent = aiMessage.displayContent + currentBlock.content[currentCharIndex]
  1182. aiMessage.displayContent = newContent
  1183. currentCharIndex++
  1184. } else {
  1185. // 当前文本块完成,移动到下一个
  1186. currentBlockIndex++
  1187. currentCharIndex = 0
  1188. }
  1189. }
  1190. // 强制触发Vue响应式更新
  1191. chatMessages.value = [...chatMessages.value]
  1192. // 如果用户在底部,自动滚动到底部
  1193. scrollToBottom()
  1194. } else {
  1195. // 所有块都显示完成
  1196. aiMessage.isTyping = false
  1197. aiMessage.content = aiReply // 保存完整内容
  1198. clearInterval(typeInterval)
  1199. console.log('打字完成,最终displayContent:', aiMessage.displayContent)
  1200. // 强制触发Vue响应式更新,确保hasTypingMessage计算属性更新
  1201. chatMessages.value = [...chatMessages.value]
  1202. console.log('打字完成,强制更新响应式数据')
  1203. // AI回复完成后,设置大纲内容并跳转到步骤2
  1204. aiOutlineContent.value = aiMessage.displayContent
  1205. // 提取大纲标题
  1206. outlineTitle.value = extractOutlineTitleFromAI(aiReply) || '安全培训大纲'
  1207. currentStep.value = 'step2'
  1208. // 获取最新的历史记录
  1209. getHistoryRecordList()
  1210. }
  1211. }, 50) // 每50ms显示一个字符
  1212. } else {
  1213. console.error('发送消息失败:', response)
  1214. aiMessage.content = '抱歉,我暂时无法回答您的问题,请稍后再试。'
  1215. aiMessage.displayContent = '抱歉,我暂时无法回答您的问题,请稍后再试。'
  1216. aiMessage.isTyping = false
  1217. chatMessages.value = [...chatMessages.value]
  1218. }
  1219. } catch (error) {
  1220. console.error('发送消息失败:', error)
  1221. aiMessage.content = '抱歉,网络连接出现问题,请检查网络后重试。'
  1222. aiMessage.displayContent = '抱歉,网络连接出现问题,请检查网络后重试。'
  1223. aiMessage.isTyping = false
  1224. chatMessages.value = [...chatMessages.value]
  1225. } finally {
  1226. isSending.value = false
  1227. }
  1228. }
  1229. // 滚动到底部
  1230. const scrollToBottom = () => {
  1231. nextTick(() => {
  1232. const chatContainer = document.querySelector('.chat-messages')
  1233. if (chatContainer) {
  1234. chatContainer.scrollTop = chatContainer.scrollHeight
  1235. }
  1236. })
  1237. }
  1238. // 获取功能卡片数据
  1239. const getFunctionCards = async () => {
  1240. try {
  1241. console.log('开始获取功能卡片...')
  1242. const response = await apis.getFunctionCard({ function_type: 1 }) // 1为安全培训类型
  1243. console.log('功能卡片响应:', response)
  1244. if (response.statusCode === 200) {
  1245. functionCards.value = response.data
  1246. console.log('功能卡片数据已设置:', functionCards.value)
  1247. } else {
  1248. console.error('获取功能卡片失败:', response.statusCode)
  1249. }
  1250. } catch (error) {
  1251. console.error('获取功能卡片失败:', error)
  1252. }
  1253. }
  1254. // Toast显示函数
  1255. const showToastMessage = (message, duration = 2000) => {
  1256. showToast.value = false
  1257. nextTick(() => {
  1258. toastMessage.value = message
  1259. toastDuration.value = duration
  1260. showToast.value = true
  1261. })
  1262. }
  1263. // 复制到剪贴板
  1264. const copyToClipboard = async (text) => {
  1265. try {
  1266. await navigator.clipboard.writeText(text)
  1267. showToastMessage('复制成功')
  1268. } catch (error) {
  1269. console.error('复制失败:', error)
  1270. showToastMessage('复制失败')
  1271. }
  1272. }
  1273. // 复制用户消息
  1274. const copyUserMessage = (message) => {
  1275. copyToClipboard(message.content)
  1276. }
  1277. // 复制AI消息
  1278. const copyAIMessage = (message) => {
  1279. let textToCopy = message.displayContent || message.content
  1280. if (textToCopy && textToCopy.includes('<')) {
  1281. const tempDiv = document.createElement('div')
  1282. tempDiv.innerHTML = textToCopy
  1283. textToCopy = tempDiv.textContent || tempDiv.innerText || textToCopy
  1284. }
  1285. copyToClipboard(textToCopy)
  1286. }
  1287. // 编辑用户消息
  1288. const editUserMessage = (message) => {
  1289. console.log('编辑用户消息:', message.content)
  1290. messageText.value = message.content
  1291. nextTick(() => {
  1292. const inputElement = document.querySelector('.message-input')
  1293. if (inputElement) {
  1294. inputElement.focus()
  1295. inputElement.setSelectionRange(inputElement.value.length, inputElement.value.length)
  1296. }
  1297. })
  1298. }
  1299. // 语音输入相关方法
  1300. const handleVoiceClick = () => {
  1301. console.log('点击语音按钮')
  1302. if (!speechSupported.value) {
  1303. showToastMessage('当前浏览器不支持语音识别')
  1304. return
  1305. }
  1306. if (isListening.value) {
  1307. // 如果正在录音,则停止
  1308. stopVoiceInput()
  1309. } else {
  1310. // 开始语音输入
  1311. startVoiceInput()
  1312. }
  1313. }
  1314. const startVoiceInput = () => {
  1315. console.log('开始语音输入')
  1316. // 开始语音识别
  1317. const success = startListening()
  1318. if (!success) {
  1319. showToastMessage('语音识别启动失败,请检查麦克风权限')
  1320. }
  1321. }
  1322. const stopVoiceInput = () => {
  1323. console.log('停止语音输入')
  1324. stopListening()
  1325. // 语音识别完成后,将结果填入输入框
  1326. if (transcript.value.trim()) {
  1327. messageText.value = transcript.value
  1328. }
  1329. }
  1330. // 语音朗读相关方法
  1331. const handleVoiceRead = (message) => {
  1332. if (speakingMessageId.value === message.id) {
  1333. stopSpeaking()
  1334. speakingMessageId.value = null
  1335. } else {
  1336. if (speakingMessageId.value) {
  1337. stopSpeaking()
  1338. }
  1339. const textToRead = message.displayContent || message.content
  1340. if (textToRead && textToRead.trim()) {
  1341. const cleanText = textToRead.replace(/<[^>]*>/g, '')
  1342. speakingMessageId.value = message.id
  1343. Promise.resolve(speakText(cleanText, { rate: 0.9 }))
  1344. .finally(() => {
  1345. if (speakingMessageId.value === message.id) {
  1346. speakingMessageId.value = null
  1347. }
  1348. })
  1349. }
  1350. }
  1351. }
  1352. // 检查消息是否正在朗读
  1353. const isSpeaking = (messageId) => {
  1354. return speakingMessageId.value === messageId
  1355. }
  1356. // 重新生成AI回复
  1357. const regenerateResponse = async (messageIndex) => {
  1358. console.log('重新生成回复,消息索引:', messageIndex)
  1359. if (messageIndex > 0) {
  1360. const userMessage = chatMessages.value[messageIndex - 1]
  1361. if (userMessage && userMessage.type === 'user') {
  1362. console.log('重新发送用户消息:', userMessage.content)
  1363. // 删除当前AI消息
  1364. chatMessages.value.splice(messageIndex, 1)
  1365. // 添加新的AI消息(初始状态为思考中)
  1366. const aiMessage = {
  1367. type: 'ai',
  1368. content: '',
  1369. displayContent: '',
  1370. isTyping: true,
  1371. id: Date.now() + 1,
  1372. userFeedback: null
  1373. }
  1374. chatMessages.value.push(aiMessage)
  1375. scrollToBottom()
  1376. try {
  1377. const response = await apis.sendDeepseekMessage({
  1378. // ===== 已删除:user_id - 后端从token解析 =====
  1379. ai_conversation_id: ai_conversation_id.value,
  1380. message: userMessage.content,
  1381. business_type: 1 // 安全培训类型
  1382. })
  1383. console.log('重新生成AI回复响应:', response)
  1384. if (response.statusCode === 200) {
  1385. if (response.data && response.data.ai_conversation_id) {
  1386. ai_conversation_id.value = response.data.ai_conversation_id
  1387. }
  1388. // 设置AI消息ID(用于评价功能)
  1389. if (response.data && response.data.ai_message_id) {
  1390. currentAiMessageId.value = response.data.ai_message_id
  1391. console.log('重新生成设置AI消息ID:', currentAiMessageId.value)
  1392. }
  1393. const aiReply = response.data ? response.data.reply : response.reply || ''
  1394. console.log('重新生成AI回复内容:', aiReply)
  1395. // 将Markdown格式转换为HTML格式
  1396. const htmlReply = markdownToHtml(aiReply)
  1397. // 开始打字效果 - 按完整HTML标签或文本块显示,避免标签分割
  1398. const textBlocks = []
  1399. let currentBlock = ''
  1400. let inTag = false
  1401. let tagContent = ''
  1402. // 将HTML内容分割成完整的块
  1403. for (let i = 0; i < htmlReply.length; i++) {
  1404. const char = htmlReply[i]
  1405. if (char === '<') {
  1406. // 如果之前有文本内容,先保存
  1407. if (currentBlock && !inTag) {
  1408. textBlocks.push({ type: 'text', content: currentBlock })
  1409. currentBlock = ''
  1410. }
  1411. inTag = true
  1412. tagContent = char
  1413. } else if (char === '>') {
  1414. // 标签结束
  1415. tagContent += char
  1416. textBlocks.push({ type: 'tag', content: tagContent })
  1417. inTag = false
  1418. tagContent = ''
  1419. } else if (inTag) {
  1420. // 在标签内
  1421. tagContent += char
  1422. } else {
  1423. // 普通文本
  1424. currentBlock += char
  1425. }
  1426. }
  1427. // 保存最后一个文本块
  1428. if (currentBlock) {
  1429. textBlocks.push({ type: 'text', content: currentBlock })
  1430. }
  1431. // 检查是否有未闭合的标签
  1432. if (tagContent) {
  1433. textBlocks.push({ type: 'tag', content: tagContent })
  1434. }
  1435. console.log('重新生成分割后的文本块:', textBlocks)
  1436. let currentBlockIndex = 0
  1437. let currentCharIndex = 0
  1438. const typeInterval = setInterval(() => {
  1439. if (currentBlockIndex < textBlocks.length) {
  1440. const currentBlock = textBlocks[currentBlockIndex]
  1441. if (currentBlock.type === 'tag') {
  1442. // 标签直接显示,不分字符
  1443. console.log('显示HTML标签:', currentBlock.content)
  1444. aiMessage.displayContent += currentBlock.content
  1445. currentBlockIndex++
  1446. currentCharIndex = 0
  1447. } else {
  1448. // 文本按字符显示
  1449. if (currentCharIndex < currentBlock.content.length) {
  1450. const newContent = aiMessage.displayContent + currentBlock.content[currentCharIndex]
  1451. aiMessage.displayContent = newContent
  1452. currentCharIndex++
  1453. } else {
  1454. // 当前文本块完成,移动到下一个
  1455. currentBlockIndex++
  1456. currentCharIndex = 0
  1457. }
  1458. }
  1459. // 强制触发Vue响应式更新
  1460. chatMessages.value = [...chatMessages.value]
  1461. // 如果用户在底部,自动滚动到底部
  1462. scrollToBottom()
  1463. } else {
  1464. // 所有块都显示完成
  1465. aiMessage.isTyping = false
  1466. aiMessage.content = aiReply // 保存完整内容
  1467. clearInterval(typeInterval)
  1468. console.log('重新生成打字完成,最终displayContent:', aiMessage.displayContent)
  1469. // 强制触发Vue响应式更新,确保hasTypingMessage计算属性更新
  1470. chatMessages.value = [...chatMessages.value]
  1471. console.log('重新生成打字完成,强制更新响应式数据')
  1472. // AI回复完成后,设置大纲内容并跳转到步骤2
  1473. aiOutlineContent.value = aiMessage.displayContent
  1474. // 提取大纲标题
  1475. outlineTitle.value = extractOutlineTitleFromAI(aiReply) || '安全培训大纲'
  1476. currentStep.value = 'step2'
  1477. getHistoryRecordList()
  1478. }
  1479. }, 50) // 每50ms显示一个字符
  1480. } else {
  1481. console.error('重新生成失败:', response)
  1482. aiMessage.content = '抱歉,重新生成失败,请稍后再试。'
  1483. aiMessage.displayContent = '抱歉,重新生成失败,请稍后再试。'
  1484. aiMessage.isTyping = false
  1485. chatMessages.value = [...chatMessages.value]
  1486. }
  1487. } catch (error) {
  1488. console.error('重新生成失败:', error)
  1489. aiMessage.content = '抱歉,网络连接出现问题,请检查网络后重试。'
  1490. aiMessage.displayContent = '抱歉,网络连接出现问题,请检查网络后重试。'
  1491. aiMessage.isTyping = false
  1492. chatMessages.value = [...chatMessages.value]
  1493. }
  1494. }
  1495. }
  1496. }
  1497. // 点赞和点踩功能
  1498. const handleThumbsUp = async (message) => {
  1499. console.log('点赞消息:', message.id)
  1500. if (message.userFeedback === 'like') {
  1501. message.userFeedback = null
  1502. showToastMessage('已取消点赞')
  1503. } else {
  1504. message.userFeedback = 'like'
  1505. showToastMessage('点赞成功')
  1506. }
  1507. chatMessages.value = [...chatMessages.value]
  1508. }
  1509. const handleThumbsDown = async (message) => {
  1510. console.log('点踩消息:', message.id)
  1511. if (message.userFeedback === 'dislike') {
  1512. message.userFeedback = null
  1513. showToastMessage('已取消点踩')
  1514. } else {
  1515. message.userFeedback = 'dislike'
  1516. showToastMessage('点踩成功')
  1517. }
  1518. chatMessages.value = [...chatMessages.value]
  1519. }
  1520. // 大纲相关方法
  1521. const getDisplayChapterTitle = (title, index) => {
  1522. if (!title) return `第${index + 1}章`
  1523. return title.includes('第') ? title : `第${index + 1}章 ${title}`
  1524. }
  1525. // 复制整个大纲(完全按PC端实现)
  1526. // 复制整个大纲(按PC端逻辑处理所有层级)
  1527. // 复制整个大纲
  1528. const copyEntireOutline = async () => {
  1529. try {
  1530. if (!aiOutlineContent.value) {
  1531. showToastMessage('暂无大纲内容可复制')
  1532. return
  1533. }
  1534. // 构建大纲文本(纯文本格式,无井号)
  1535. // 将HTML内容转换为带层级格式的文本
  1536. const tempDiv = document.createElement('div')
  1537. tempDiv.innerHTML = aiOutlineContent.value
  1538. let outlineText = ''
  1539. // 遍历所有文本节点,保持层级结构
  1540. const walkTextNodes = (element, level = 0) => {
  1541. for (const node of element.childNodes) {
  1542. if (node.nodeType === Node.TEXT_NODE) {
  1543. const text = node.textContent?.trim()
  1544. if (text) {
  1545. // 添加适当的缩进
  1546. const indent = ' '.repeat(level)
  1547. outlineText += `${indent}${text}\n`
  1548. }
  1549. } else if (node.nodeType === Node.ELEMENT_NODE) {
  1550. const tagName = node.tagName?.toLowerCase()
  1551. // 处理标题标签,添加额外空行
  1552. if (['h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) {
  1553. const content = node.textContent?.trim()
  1554. if (content && !content.includes('安全培训大纲')) { // 避免重复标题
  1555. outlineText += `${' '.repeat(level)}${content}\n\n`
  1556. }
  1557. continue // 不继续处理标题的子节点
  1558. }
  1559. // 递归处理其他元素
  1560. walkTextNodes(node, level + 1)
  1561. }
  1562. }
  1563. }
  1564. // 开始遍历
  1565. walkTextNodes(tempDiv)
  1566. // 清理多余的空行和空白字符
  1567. outlineText = outlineText
  1568. .replace(/\n\s*\n\s*\n/g, '\n\n') // 最多保留两个连续换行
  1569. .replace(/^\s+|\s+$/gm, '') // 移除每行首尾空白
  1570. .replace(/\n\s*\n$/, '') // 移除末尾多余换行
  1571. .trim()
  1572. // 只添加一次标题
  1573. const finalText = `${outlineTitle.value || '安全培训大纲'}\n\n${outlineText}`
  1574. // outlineData处理已替换为HTML处理逻辑
  1575. // 不添加统计信息,只复制纯大纲内容
  1576. // 检查 Clipboard API 是否可用
  1577. if (navigator.clipboard && navigator.clipboard.writeText && window.isSecureContext) {
  1578. try {
  1579. await navigator.clipboard.writeText(finalText)
  1580. showToastMessage('复制成功')
  1581. return
  1582. } catch (clipboardError) {
  1583. console.warn('Clipboard API 失败,使用降级方案:', clipboardError)
  1584. }
  1585. }
  1586. // 降级方案:使用传统复制方法
  1587. const textArea = document.createElement('textarea')
  1588. textArea.value = finalText
  1589. textArea.style.position = 'fixed'
  1590. textArea.style.left = '-999999px'
  1591. textArea.style.top = '-999999px'
  1592. document.body.appendChild(textArea)
  1593. textArea.focus()
  1594. textArea.select()
  1595. try {
  1596. const successful = document.execCommand('copy')
  1597. if (successful) {
  1598. showToastMessage('大纲已复制到剪贴板')
  1599. } else {
  1600. throw new Error('execCommand 复制失败')
  1601. }
  1602. } catch (execError) {
  1603. console.error('传统复制方法也失败:', execError)
  1604. showToastMessage('复制失败,请手动选择文本复制')
  1605. } finally {
  1606. document.body.removeChild(textArea)
  1607. }
  1608. } catch (error) {
  1609. console.error('复制大纲失败:', error)
  1610. ElMessage.error('复制失败,请手动选择文本复制')
  1611. }
  1612. }
  1613. // 下载大纲为Word文档
  1614. const downloadOutlineAsWord = async () => {
  1615. try {
  1616. if (!aiOutlineContent.value) {
  1617. showToastMessage('暂无大纲内容可下载')
  1618. return
  1619. }
  1620. // 构建Word文档内容(HTML格式,兼容Microsoft Office Word)
  1621. let htmlContent = `<!DOCTYPE html>
  1622. <html xmlns:o="urn:schemas-microsoft-com:office:office"
  1623. xmlns:w="urn:schemas-microsoft-com:office:word"
  1624. xmlns="http://www.w3.org/TR/REC-html40">
  1625. <head>
  1626. <meta charset="utf-8">
  1627. <meta name="ProgId" content="Word.Document">
  1628. <meta name="Generator" content="Microsoft Word 15">
  1629. <meta name="Originator" content="Microsoft Word 15">
  1630. <title>${outlineTitle.value || '安全培训大纲'}</title>
  1631. <!--[if gte mso 9]>
  1632. <xml>
  1633. <w:WordDocument>
  1634. <w:View>Print</w:View>
  1635. <w:Zoom>100</w:Zoom>
  1636. <w:DoNotPromptForConvert/>
  1637. <w:DoNotShowRevisions/>
  1638. <w:DoNotPrintRevisions/>
  1639. <w:DoNotShowComments/>
  1640. <w:DoNotShowInsertionsAndDeletions/>
  1641. <w:DoNotShowPropertyChanges/>
  1642. <w:Compatibility>
  1643. <w:BreakWrappedTables/>
  1644. <w:SnapToGridInCell/>
  1645. <w:WrapTextWithPunct/>
  1646. <w:UseAsianBreakRules/>
  1647. <w:DontGrowAutofit/>
  1648. </w:Compatibility>
  1649. </w:WordDocument>
  1650. </xml>
  1651. <![endif]-->
  1652. <style>
  1653. body {
  1654. font-family: "Microsoft YaHei", Arial, sans-serif;
  1655. font-size: 14px;
  1656. line-height: 1.6;
  1657. margin: 24px;
  1658. color: #000;
  1659. }
  1660. .header {
  1661. text-align: center;
  1662. margin-bottom: 14px;
  1663. }
  1664. .outline-title {
  1665. font-size: 24px;
  1666. font-weight: bold;
  1667. margin-bottom: 14px;
  1668. color: #000;
  1669. }
  1670. h1, h2, h3, h4, h5, h6 {
  1671. color: #000;
  1672. font-weight: bold;
  1673. font-family: "Microsoft YaHei", Arial, sans-serif;
  1674. margin-top: 20px;
  1675. margin-bottom: 15px;
  1676. }
  1677. h1 {
  1678. font-size: 20px;
  1679. border-bottom: 2px solid #000;
  1680. padding-bottom: 10px;
  1681. }
  1682. h2 {
  1683. font-size: 18px;
  1684. margin-top: 20px;
  1685. margin-bottom: 12px;
  1686. }
  1687. h3 {
  1688. font-size: 16px;
  1689. margin-top: 15px;
  1690. margin-bottom: 8px;
  1691. }
  1692. h4 {
  1693. font-size: 14px;
  1694. margin-top: 12px;
  1695. margin-bottom: 6px;
  1696. }
  1697. ul, li {
  1698. color: #000;
  1699. font-family: "Microsoft YaHei", Arial, sans-serif;
  1700. }
  1701. li {
  1702. margin-bottom: 4px;
  1703. }
  1704. .stats {
  1705. background: #f8f9fa;
  1706. padding: 20px;
  1707. border-radius: 8px;
  1708. margin-top: 30px;
  1709. border: 1px solid #ddd;
  1710. }
  1711. .stats h3 {
  1712. color: #2c3e50;
  1713. margin-top: 0;
  1714. font-size: 16px;
  1715. }
  1716. .stats p {
  1717. margin: 8px 0;
  1718. color: #555;
  1719. font-size: 14px;
  1720. }
  1721. </style>
  1722. </head>
  1723. <body>
  1724. <div class="header">
  1725. <div class="outline-title">${outlineTitle.value || '安全培训大纲'}</div>
  1726. </div>
  1727. `
  1728. // 清理内容,移除重复的标题
  1729. const tempDiv = document.createElement('div')
  1730. tempDiv.innerHTML = aiOutlineContent.value
  1731. // 移除所有h1标题(通常是主标题)
  1732. const h1Elements = tempDiv.querySelectorAll('h1')
  1733. h1Elements.forEach(h1 => {
  1734. const content = h1.textContent?.trim()
  1735. if (content && content.includes(outlineTitle.value || '安全培训大纲')) {
  1736. h1.remove()
  1737. }
  1738. })
  1739. // 清理空标签和多余的空白
  1740. const cleanContent = tempDiv.innerHTML.replace(/<h[1-6][^>]*>\s*<\/h[1-6]>/gi, '')
  1741. htmlContent += cleanContent
  1742. // 不再添加统计信息,因为不再使用outlineStats
  1743. htmlContent += `
  1744. </body>
  1745. </html>
  1746. `
  1747. // 创建Blob对象 - 使用Word兼容的MIME类型
  1748. const blob = new Blob([htmlContent], { type: 'application/msword' })
  1749. // 创建下载链接
  1750. const url = URL.createObjectURL(blob)
  1751. const link = document.createElement('a')
  1752. link.href = url
  1753. link.download = `${outlineTitle.value || '安全培训大纲'}.doc`
  1754. document.body.appendChild(link)
  1755. link.click()
  1756. document.body.removeChild(link)
  1757. // 清理URL对象
  1758. URL.revokeObjectURL(url)
  1759. showToastMessage('下载成功')
  1760. } catch (error) {
  1761. console.error('下载大纲失败:', error)
  1762. showToastMessage('下载失败,请重试')
  1763. }
  1764. }
  1765. const evaluateOutline = () => {
  1766. console.log('大纲评价')
  1767. showToastMessage('大纲评价功能开发中...')
  1768. }
  1769. const regenerateOutline = async () => {
  1770. try {
  1771. // 检查是否有当前对话ID
  1772. if (!ai_conversation_id.value) {
  1773. showToastMessage('请先开始一个对话')
  1774. return
  1775. }
  1776. // 设置生成状态
  1777. isGeneratingOutline.value = true
  1778. // 获取当前大纲标题作为请求内容
  1779. const currentTitle = outlineTitle.value || '安全培训大纲'
  1780. const requestMessage = `${currentTitle}`
  1781. console.log('开始生成新大纲:', requestMessage)
  1782. console.log('ai_conversation_id:', ai_conversation_id.value)
  1783. // 调用后端DeepSeek接口重新生成大纲
  1784. const response = await apis.sendDeepseekMessage({
  1785. // ===== 已删除:user_id - 后端从token解析 =====
  1786. ai_conversation_id: ai_conversation_id.value,
  1787. message: requestMessage,
  1788. business_type: 1
  1789. })
  1790. console.log('重新生成大纲响应:', response)
  1791. if (response.statusCode === 200) {
  1792. const aiReply = response.data ? response.data.reply : response.reply || ''
  1793. console.log('重新生成大纲内容:', aiReply)
  1794. // 设置AI消息ID(用于评价功能)
  1795. if (response.data && response.data.ai_message_id) {
  1796. currentAiMessageId.value = response.data.ai_message_id
  1797. console.log('重新生成大纲设置AI消息ID:', currentAiMessageId.value)
  1798. }
  1799. // 将Markdown格式转换为HTML格式
  1800. const htmlReply = markdownToHtml(aiReply)
  1801. // 更新大纲内容
  1802. aiOutlineContent.value = htmlReply
  1803. // 更新大纲标题
  1804. const newTitle = extractOutlineTitleFromAI(aiReply) || '安全培训大纲'
  1805. outlineTitle.value = newTitle
  1806. console.log('重新生成大纲标题设置为:', newTitle)
  1807. showToastMessage('新大纲生成成功!')
  1808. // 重置评价状态
  1809. outlineFeedback.value = null
  1810. evaluation.value = ''
  1811. } else {
  1812. console.error('重新生成大纲失败:', response)
  1813. showToastMessage('重新生成大纲失败,请重试')
  1814. }
  1815. } catch (error) {
  1816. console.error('重新生成大纲失败:', error)
  1817. showToastMessage('重新生成大纲失败,请重试')
  1818. } finally {
  1819. isGeneratingOutline.value = false
  1820. }
  1821. }
  1822. const selectPptTemplate = () => {
  1823. showToastMessage('请前往电脑端选择模版')
  1824. }
  1825. // 获取评价状态(从PC端移植)
  1826. const getEvaluationStatus = () => {
  1827. console.log('发出评价状态 - outlineFeedback:', outlineFeedback.value, 'evaluation:', evaluation.value)
  1828. if (outlineFeedback.value !== null) {
  1829. // 根据后端数据判断状态
  1830. switch (outlineFeedback.value) {
  1831. case 2: return 'like' // 满意
  1832. case 3: return 'dislike' // 不满意
  1833. case 0: return '' // 无反馈(取消评价)
  1834. default: return '' // 无反馈
  1835. }
  1836. }
  1837. return evaluation.value // 如果没有后端数据,使用本地状态
  1838. }
  1839. // 设置大纲评价(从PC端移植)
  1840. const setEvaluation = async (value) => {
  1841. try {
  1842. console.log('设置评价:', value)
  1843. // 检查当前状态,如果点击的是当前已激活的状态,则取消评价, 则取消评价
  1844. const currentStatus = getEvaluationStatus()
  1845. let feedbackValue
  1846. if (currentStatus === value) {
  1847. // 如果点击的是当前已激活的状态,取消评价
  1848. feedbackValue = 0
  1849. console.log('取消评价,发送0')
  1850. } else {
  1851. // 否则设置新的评价
  1852. feedbackValue = value === 'like' ? 2 : 3
  1853. console.log('设置新评价:', feedbackValue)
  1854. }
  1855. console.log('currentAiMessageId.value', currentAiMessageId.value)
  1856. // 调用后端API保存评价
  1857. const response = await apis.likeAndDislike({
  1858. id: currentAiMessageId.value, // 使用当前的AI消息ID
  1859. user_feedback: feedbackValue
  1860. })
  1861. if (response.statusCode === 200) {
  1862. console.log('点评成功')
  1863. // 更新本地状态
  1864. if (feedbackValue === 0) {
  1865. // 取消评价
  1866. evaluation.value = ''
  1867. outlineFeedback.value = 0
  1868. showToastMessage('点评已取消')
  1869. } else {
  1870. // 设置新评价
  1871. evaluation.value = value
  1872. outlineFeedback.value = feedbackValue
  1873. showToastMessage('点评成功')
  1874. }
  1875. } else {
  1876. console.error('评价保存失败:', response)
  1877. showToastMessage('评价保存失败,请重试')
  1878. }
  1879. } catch (error) {
  1880. console.error('设置评价失败:', error)
  1881. showToastMessage('评价设置失败,请重试')
  1882. }
  1883. }
  1884. const handleOutlineThumbsUp = () => {
  1885. setEvaluation('like')
  1886. }
  1887. const handleOutlineThumbsDown = () => {
  1888. setEvaluation('dislike')
  1889. }
  1890. const addNewItem = (type, index) => {
  1891. console.log('添加新项目:', type, index)
  1892. showToastMessage('添加功能开发中...')
  1893. }
  1894. // 新建任务
  1895. const createNewTask = () => {
  1896. console.log('新建安全培训任务')
  1897. showHistory.value = false
  1898. // 重置所有状态
  1899. currentStep.value = 'step1'
  1900. showChat.value = false
  1901. chatMessages.value = []
  1902. ai_conversation_id.value = 0
  1903. messageText.value = ''
  1904. outlineTitle.value = ''
  1905. outlineData.value = []
  1906. aiOutlineContent.value = ''
  1907. outlineFeedback.value = null
  1908. evaluation.value = ''
  1909. currentAiMessageId.value = null
  1910. // 清除所有历史记录的选中状态
  1911. historyData.value.forEach((item) => {
  1912. item.isActive = false
  1913. })
  1914. }
  1915. const consumeAutoMessage = async (message) => {
  1916. const normalizedMessage = String(message || '').trim()
  1917. if (!normalizedMessage) return
  1918. createNewTask()
  1919. await nextTick()
  1920. messageText.value = normalizedMessage
  1921. await sendMessage()
  1922. }
  1923. // 处理历史记录点击
  1924. const handleHistoryItem = async (historyItem) => {
  1925. if (historyItem.isActive) return
  1926. console.log("点击移动端安全培训历史记录:", historyItem)
  1927. // 设置当前点击的历史记录为激活状态
  1928. historyData.value.forEach((item) => {
  1929. item.isActive = item.id === historyItem.id
  1930. })
  1931. // 关闭历史记录抽屉
  1932. showHistory.value = false
  1933. // 切换到步骤2(培训大纲)
  1934. showChat.value = true
  1935. currentStep.value = 'step2'
  1936. // 设置当前对话ID
  1937. ai_conversation_id.value = historyItem.id
  1938. // 加载历史对话详情
  1939. console.log('开始加载历史对话详情,conversation_id:', historyItem.id)
  1940. const response = await apis.getHistoryRecord({
  1941. // ===== 已删除:user_id - 后端从token解析 =====
  1942. ai_conversation_id: historyItem.id,
  1943. business_type: 1 // 安全培训类型
  1944. })
  1945. console.log('历史对话详情响应:', response)
  1946. if (response.statusCode === 200 && response.data && response.data.length > 0) {
  1947. // 清空当前聊天消息
  1948. chatMessages.value = []
  1949. console.log('历史对话数据详情:', response.data)
  1950. // 转换历史消息为聊天消息格式
  1951. response.data.forEach((message, index) => {
  1952. console.log(`处理第${index + 1}条消息:`, message)
  1953. const messageType = message.type
  1954. if (messageType === 'user') {
  1955. // 用户消息
  1956. console.log('添加用户消息:', message.content)
  1957. chatMessages.value.push({
  1958. type: 'user',
  1959. content: message.content,
  1960. id: message.id || Date.now() + index,
  1961. timestamp: message.created_at || message.timestamp
  1962. })
  1963. } else if (messageType === 'ai') {
  1964. // AI消息
  1965. console.log('添加AI消息:', message.content)
  1966. const aiMessage = {
  1967. type: 'ai',
  1968. content: message.content,
  1969. displayContent: markdownToHtml(message.content), // 转换为HTML显示
  1970. isTyping: false,
  1971. id: message.id || Date.now() + index + 1000,
  1972. timestamp: message.created_at || message.timestamp,
  1973. userFeedback: null
  1974. }
  1975. chatMessages.value.push(aiMessage)
  1976. // 设置大纲内容(取最后一条AI消息作为大纲内容)
  1977. if (index === response.data.length - 1) {
  1978. aiOutlineContent.value = aiMessage.displayContent
  1979. // 提取大纲标题
  1980. outlineTitle.value = extractOutlineTitleFromAI(message.content) || '安全培训大纲'
  1981. console.log('历史记录大纲标题设置为:', outlineTitle.value)
  1982. // 设置AI消息ID和评价状态(从历史记录中获取)
  1983. if (message.id) {
  1984. currentAiMessageId.value = message.id
  1985. console.log('历史记录设置AI消息ID:', currentAiMessageId.value)
  1986. }
  1987. // 从历史记录中设置评价状态
  1988. if (message.user_feedback !== undefined) {
  1989. outlineFeedback.value = message.user_feedback
  1990. console.log('历史记录设置评价状态:', outlineFeedback.value)
  1991. }
  1992. }
  1993. } else {
  1994. console.log('未知消息类型,跳过:', messageType, message)
  1995. }
  1996. })
  1997. console.log('历史对话加载完成,消息数量:', chatMessages.value.length)
  1998. // 滚动到底部
  1999. nextTick(() => {
  2000. scrollToBottom()
  2001. })
  2002. } else {
  2003. console.error('加载历史对话失败或无数据:', response)
  2004. chatMessages.value = []
  2005. }
  2006. }
  2007. // 删除历史记录
  2008. const deleteHistoryItem = async (historyItem, index) => {
  2009. try {
  2010. console.log('开始删除移动端历史记录:', historyItem)
  2011. const response = await apis.deleteHistoryRecord({
  2012. // ===== 已删除:user_id - 后端从token解析 =====
  2013. ai_conversation_id: historyItem.id
  2014. })
  2015. if (response.statusCode === 200) {
  2016. // 从本地数据中移除
  2017. historyData.value.splice(index, 1)
  2018. historyTotal.value = Math.max(0, historyTotal.value - 1)
  2019. // 如果删除的是当前激活的历史记录,执行新建任务
  2020. if (historyItem.isActive) {
  2021. console.log('删除激活的历史记录,执行新建任务')
  2022. createNewTask()
  2023. }
  2024. console.log('✅ 移动端历史记录删除成功')
  2025. showToastMessage('删除成功')
  2026. } else {
  2027. console.error('❌ 删除移动端历史记录失败:', response)
  2028. }
  2029. } catch (error) {
  2030. console.error('❌ 删除移动端历史记录失败:', error)
  2031. }
  2032. }
  2033. // 统一的确认删除函数
  2034. const confirmDelete = async () => {
  2035. if (!deleteTargetItem.value) return
  2036. if (deleteType.value === 'history') {
  2037. await confirmDeleteHistory()
  2038. } else if (deleteType.value === 'message') {
  2039. await confirmDeleteMessage()
  2040. }
  2041. }
  2042. // 确认删除历史记录
  2043. const confirmDeleteHistory = async () => {
  2044. const { item: historyItem, index } = deleteTargetItem.value
  2045. try {
  2046. const response = await apis.deleteHistoryRecord({
  2047. ai_conversation_id: historyItem.id
  2048. })
  2049. if (response.statusCode === 200) {
  2050. historyData.value.splice(index, 1)
  2051. if (historyItem.isActive) {
  2052. chatMessages.value = []
  2053. ai_conversation_id.value = 0
  2054. showChat.value = false
  2055. currentStep.value = 'step1'
  2056. }
  2057. console.log('历史记录删除成功')
  2058. showToastMessage('删除成功')
  2059. } else {
  2060. console.error('删除历史记录失败:', response.msg)
  2061. showToastMessage(response.msg || '删除失败')
  2062. }
  2063. } catch (error) {
  2064. console.error('删除历史记录失败:', error)
  2065. showToastMessage('删除失败,请稍后重试')
  2066. } finally {
  2067. showDeleteModal.value = false
  2068. deleteTargetItem.value = null
  2069. deleteType.value = ''
  2070. }
  2071. }
  2072. // 确认删除消息
  2073. const confirmDeleteMessage = async () => {
  2074. const { messageIndex } = deleteTargetItem.value
  2075. try {
  2076. const aiMessage = chatMessages.value[messageIndex]
  2077. if (aiMessage && aiMessage.id) {
  2078. try {
  2079. const response = await apis.deleteConversation({
  2080. ai_message_id: aiMessage.id
  2081. })
  2082. if (response.statusCode === 200) {
  2083. chatMessages.value.splice(messageIndex, 1)
  2084. if (messageIndex > 0) {
  2085. chatMessages.value.splice(messageIndex - 1, 1)
  2086. }
  2087. console.log('删除成功')
  2088. showToastMessage('删除成功')
  2089. } else {
  2090. console.error('删除失败:', response.msg)
  2091. showToastMessage('删除失败,请稍后重试')
  2092. }
  2093. } catch (error) {
  2094. console.error('删除接口调用失败:', error)
  2095. showToastMessage('删除失败,请稍后重试')
  2096. }
  2097. } else {
  2098. console.log('没有id,仅从前端删除')
  2099. chatMessages.value.splice(messageIndex, 1)
  2100. showToastMessage('删除成功')
  2101. }
  2102. } catch (error) {
  2103. console.error('删除消息失败:', error)
  2104. showToastMessage('删除失败,请稍后重试')
  2105. } finally {
  2106. showDeleteModal.value = false
  2107. deleteTargetItem.value = null
  2108. deleteType.value = ''
  2109. }
  2110. }
  2111. // 取消删除
  2112. const cancelDelete = () => {
  2113. showDeleteModal.value = false
  2114. deleteTargetItem.value = null
  2115. deleteType.value = ''
  2116. }
  2117. // 页面加载时不再自动加载历史记录,改为点击菜单时加载
  2118. onMounted(async () => {
  2119. try {
  2120. console.log('🚀 移动端安全培训页面初始化,加载功能卡片...')
  2121. // 初始化原生导航栏(子页面模式:返回按钮执行路由后退)
  2122. initNativeNavForSubPage(() => router.back())
  2123. await getFunctionCards()
  2124. const autoMessage = route.query.autoMessage
  2125. if (autoMessage) {
  2126. router.replace({
  2127. path: route.path,
  2128. query: { ...route.query, autoMessage: undefined }
  2129. })
  2130. await consumeAutoMessage(autoMessage)
  2131. }
  2132. console.log('✅ 移动端安全培训页面初始化完成')
  2133. } catch (error) {
  2134. console.error('❌ 移动端安全培训页面初始化失败:', error)
  2135. }
  2136. })
  2137. // 组件销毁前,强制停止任何朗读
  2138. onBeforeUnmount(() => {
  2139. if (speakingMessageId.value) {
  2140. stopSpeaking()
  2141. speakingMessageId.value = null
  2142. }
  2143. })
  2144. // 监听历史记录抽屉显示状态,显示时加载数据
  2145. watch(showHistory, async (newVal) => {
  2146. if (newVal && historyData.value.length === 0) {
  2147. console.log('📋 历史记录抽屉打开,开始加载数据...')
  2148. await getHistoryRecordList()
  2149. }
  2150. })
  2151. // 监听语音识别结果
  2152. watch(transcript, (newVal) => {
  2153. if (!newVal || isListening.value) return
  2154. messageText.value = newVal
  2155. })
  2156. // 监听语音识别错误
  2157. watch(speechError, (newVal) => {
  2158. if (newVal) {
  2159. console.error('语音识别错误:', newVal)
  2160. showToastMessage(newVal)
  2161. }
  2162. })
  2163. </script>
  2164. <style lang="less" scoped>
  2165. .mobile-safety-hazard {
  2166. min-height: 100vh;
  2167. background: #EBF3FF;
  2168. font-family: "Alibaba PuHuiTi 3.0", sans-serif;
  2169. overflow-x: hidden;
  2170. -webkit-overflow-scrolling: touch;
  2171. touch-action: manipulation;
  2172. }
  2173. .mobile-content {
  2174. padding: 20px;
  2175. text-align: center;
  2176. position: relative;
  2177. padding-bottom: 80px;
  2178. }
  2179. /* AI助手介绍 */
  2180. .ai-intro {
  2181. display: flex;
  2182. flex-direction: column;
  2183. align-items: center;
  2184. margin-bottom: 30px;
  2185. .ai-avatar {
  2186. width: 60px;
  2187. height: 60px;
  2188. border-radius: 15px;
  2189. margin-bottom: 12px;
  2190. .ai-avatar-img {
  2191. width: 100%;
  2192. height: 100%;
  2193. object-fit: cover;
  2194. border-radius: 15px;
  2195. }
  2196. }
  2197. .ai-greeting {
  2198. text-align: center;
  2199. h3 {
  2200. font-size: 18px;
  2201. font-weight: 600;
  2202. color: #1f2937;
  2203. margin: 0 0 8px 0;
  2204. }
  2205. p {
  2206. font-size: 14px;
  2207. color: #6b7280;
  2208. margin: 0;
  2209. }
  2210. }
  2211. }
  2212. /* 功能卡片 */
  2213. .function-cards {
  2214. display: grid;
  2215. grid-template-columns: repeat(2, 1fr);
  2216. gap: 12px;
  2217. margin-top: 30px;
  2218. .function-card {
  2219. background: white;
  2220. padding: 16px;
  2221. border-radius: 12px;
  2222. border: 1px solid #E5E8EB;
  2223. cursor: pointer;
  2224. transition: all 0.3s ease;
  2225. display: flex;
  2226. flex-direction: column;
  2227. justify-content: center;
  2228. text-align: left;
  2229. &:hover {
  2230. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  2231. transform: translateY(-2px);
  2232. }
  2233. .card-header {
  2234. display: flex;
  2235. align-items: center;
  2236. margin-bottom: 8px;
  2237. .card-icon {
  2238. width: 32px;
  2239. height: 32px;
  2240. margin-right: 12px;
  2241. .card-icon-img {
  2242. width: 100%;
  2243. height: 100%;
  2244. object-fit: cover;
  2245. }
  2246. }
  2247. h4 {
  2248. font-size: 16px;
  2249. font-weight: 600;
  2250. color: #1f2937;
  2251. margin: 0;
  2252. flex: 1;
  2253. }
  2254. }
  2255. .card-description {
  2256. p {
  2257. font-size: 14px;
  2258. color: #6b7280;
  2259. margin: 0;
  2260. line-height: 1.4;
  2261. display: -webkit-box;
  2262. -webkit-line-clamp: 1;
  2263. line-clamp: 1;
  2264. -webkit-box-orient: vertical;
  2265. overflow: hidden;
  2266. }
  2267. }
  2268. }
  2269. }
  2270. /* 聊天消息区域 */
  2271. .chat-messages {
  2272. max-height: calc(100vh - 180px);
  2273. overflow-y: auto;
  2274. padding: 20px 0;
  2275. margin-bottom: 10px;
  2276. .message-item {
  2277. margin-bottom: 20px;
  2278. &.user {
  2279. display: flex;
  2280. justify-content: flex-end;
  2281. .user-message {
  2282. background: #3e7bfa;
  2283. color: white;
  2284. padding: 12px 16px;
  2285. border-radius: 18px 18px 4px 18px;
  2286. max-width: 80%;
  2287. text-align: left;
  2288. .message-content {
  2289. .message-text {
  2290. font-size: 20px;
  2291. line-height: 1.4;
  2292. word-wrap: break-word;
  2293. }
  2294. }
  2295. .message-actions {
  2296. margin-top: 8px;
  2297. display: flex;
  2298. gap: 8px;
  2299. justify-content: flex-end;
  2300. .action-btn {
  2301. background: none;
  2302. border: none;
  2303. color: white;
  2304. padding: 4px;
  2305. border-radius: 4px;
  2306. cursor: pointer;
  2307. display: flex;
  2308. align-items: center;
  2309. justify-content: center;
  2310. transition: all 0.2s ease;
  2311. .action-icon {
  2312. width: 20px;
  2313. height: 20px;
  2314. object-fit: contain;
  2315. filter: brightness(0) invert(1);
  2316. }
  2317. }
  2318. }
  2319. }
  2320. }
  2321. }
  2322. }
  2323. /* AI消息样式 */
  2324. .ai-message {
  2325. display: flex;
  2326. gap: 12px;
  2327. .ai-avatar-small {
  2328. width: 32px;
  2329. height: 32px;
  2330. flex-shrink: 0;
  2331. .ai-icon {
  2332. width: 100%;
  2333. height: 100%;
  2334. object-fit: contain;
  2335. }
  2336. }
  2337. .message-content {
  2338. background: white;
  2339. color: #374151;
  2340. padding: 12px 16px;
  2341. border-radius: 0px 18px 18px 18px;
  2342. max-width: calc(100vw - 120px);
  2343. width: fit-content;
  2344. min-width: 120px;
  2345. word-wrap: break-word;
  2346. line-height: 1.5;
  2347. font-size: 14px;
  2348. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  2349. white-space: pre-line;
  2350. overflow-wrap: break-word;
  2351. word-break: break-word;
  2352. text-align: left;
  2353. .ai-text {
  2354. min-height: 20px;
  2355. line-height: 1.5;
  2356. .typing-indicator {
  2357. color: #9CA3AF;
  2358. font-style: italic;
  2359. font-size: 14px;
  2360. display: flex;
  2361. align-items: center;
  2362. gap: 8px;
  2363. white-space: normal;
  2364. word-wrap: break-word;
  2365. overflow-wrap: break-word;
  2366. .thinking-animation {
  2367. .dot {
  2368. display: inline-block;
  2369. width: 4px;
  2370. height: 4px;
  2371. border-radius: 50%;
  2372. background: #9CA3AF;
  2373. margin: 0 1px;
  2374. animation: thinking 1.4s infinite ease-in-out;
  2375. &:nth-child(1) { animation-delay: -0.32s; }
  2376. &:nth-child(2) { animation-delay: -0.16s; }
  2377. }
  2378. }
  2379. }
  2380. .ai-content {
  2381. font-size: 14px;
  2382. line-height: 1.5;
  2383. color: #374151;
  2384. word-wrap: break-word;
  2385. overflow-wrap: break-word;
  2386. word-break: break-word;
  2387. text-align: left;
  2388. }
  2389. }
  2390. .divider {
  2391. width: 100%;
  2392. height: 1px;
  2393. background: #E5E7EB;
  2394. margin: 8px 0;
  2395. }
  2396. .message-actions {
  2397. display: flex;
  2398. justify-content: space-between;
  2399. align-items: center;
  2400. margin-top: 8px;
  2401. .left-actions {
  2402. display: flex;
  2403. gap: 8px;
  2404. flex-wrap: wrap;
  2405. }
  2406. .right-actions {
  2407. display: flex;
  2408. gap: 4px;
  2409. }
  2410. .action-btn {
  2411. background: transparent;
  2412. border: none;
  2413. color: #6B7280;
  2414. padding: 6px;
  2415. border-radius: 4px;
  2416. cursor: pointer;
  2417. display: flex;
  2418. align-items: center;
  2419. justify-content: center;
  2420. transition: all 0.3s ease;
  2421. &:disabled {
  2422. opacity: 0.5;
  2423. cursor: not-allowed;
  2424. }
  2425. .action-icon {
  2426. width: 20px;
  2427. height: 20px;
  2428. object-fit: contain;
  2429. }
  2430. &.thumbs-up-btn {
  2431. transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
  2432. &.active {
  2433. .action-icon {
  2434. filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(142deg) brightness(104%) contrast(97%);
  2435. }
  2436. }
  2437. }
  2438. &.thumbs-down-btn {
  2439. transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
  2440. &.active {
  2441. .action-icon {
  2442. filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(0deg) brightness(104%) contrast(97%);
  2443. }
  2444. }
  2445. }
  2446. }
  2447. }
  2448. }
  2449. }
  2450. /* 底部输入区域 */
  2451. .chat-input-section {
  2452. position: fixed;
  2453. bottom: 20px;
  2454. left: 0;
  2455. right: 0;
  2456. background: #EBF3FF;
  2457. padding: 8px 20px;
  2458. z-index: 1;
  2459. transform: translateZ(0);
  2460. -webkit-transform: translateZ(0);
  2461. will-change: transform;
  2462. .input-container {
  2463. max-width: 100%;
  2464. .input-box {
  2465. display: flex;
  2466. align-items: center;
  2467. gap: 12px;
  2468. background: white;
  2469. border-radius: 16px;
  2470. padding: 8px 20px;
  2471. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  2472. transition: box-shadow 0.3s ease;
  2473. border: 1px solid #3E7BFA;
  2474. height: 57px;
  2475. transform: translateZ(0);
  2476. -webkit-transform: translateZ(0);
  2477. will-change: transform;
  2478. &:focus-within {
  2479. box-shadow: 0 2px 12px rgba(62, 123, 250, 0.2);
  2480. }
  2481. .message-input {
  2482. flex: 1;
  2483. border: none;
  2484. background: transparent;
  2485. font-size: 16px !important;
  2486. color: #2C3E50;
  2487. outline: none;
  2488. transition: opacity 0.3s ease;
  2489. line-height: 1.4 !important;
  2490. height: auto !important;
  2491. &::placeholder {
  2492. color: #A0A6B8;
  2493. font-size: 16px !important;
  2494. }
  2495. &:disabled {
  2496. cursor: not-allowed;
  2497. }
  2498. }
  2499. .divider {
  2500. width: 1px;
  2501. height: 31px;
  2502. background-color: #D6D5DE;
  2503. margin: 0 4px;
  2504. }
  2505. .voice-btn {
  2506. background: none;
  2507. border: none;
  2508. cursor: pointer;
  2509. padding: 8px;
  2510. border-radius: 6px;
  2511. transition: all 0.3s ease;
  2512. display: flex;
  2513. align-items: center;
  2514. justify-content: center;
  2515. position: relative;
  2516. &:hover:not(:disabled) {
  2517. background: rgba(102, 126, 234, 0.1);
  2518. }
  2519. &:disabled {
  2520. cursor: not-allowed;
  2521. opacity: 0.5;
  2522. }
  2523. &.recording {
  2524. background: rgba(239, 68, 68, 0.1);
  2525. animation: pulse 1.5s ease-in-out infinite;
  2526. }
  2527. .icon-container {
  2528. width: 20px;
  2529. height: 20px;
  2530. display: flex;
  2531. align-items: center;
  2532. justify-content: center;
  2533. position: relative;
  2534. .action-icon {
  2535. width: 20px;
  2536. height: 20px;
  2537. object-fit: contain;
  2538. }
  2539. .recording-indicator {
  2540. position: absolute;
  2541. top: -2px;
  2542. right: -2px;
  2543. width: 8px;
  2544. height: 8px;
  2545. background: #ef4444;
  2546. border-radius: 50%;
  2547. animation: blink 1s ease-in-out infinite;
  2548. }
  2549. }
  2550. }
  2551. .send-btn {
  2552. background: none;
  2553. border: none;
  2554. cursor: pointer;
  2555. border-radius: 6px;
  2556. transition: background 0.3s ease;
  2557. display: flex;
  2558. align-items: center;
  2559. justify-content: center;
  2560. &:hover:not(:disabled) {
  2561. background: rgba(102, 126, 234, 0.1);
  2562. }
  2563. &:disabled {
  2564. cursor: not-allowed;
  2565. }
  2566. .send-icon {
  2567. width: 90px;
  2568. height: 40px;
  2569. object-fit: contain;
  2570. }
  2571. }
  2572. }
  2573. }
  2574. }
  2575. /* 步骤2:培训大纲样式 */
  2576. .step2-content {
  2577. .loading-overlay {
  2578. position: fixed;
  2579. top: 0;
  2580. left: 0;
  2581. right: 0;
  2582. bottom: 0;
  2583. background: rgba(255, 255, 255, 0.9);
  2584. display: flex;
  2585. align-items: center;
  2586. justify-content: center;
  2587. z-index: 1000;
  2588. .loading-content {
  2589. text-align: center;
  2590. .loading-spinner {
  2591. width: 40px;
  2592. height: 40px;
  2593. border: 4px solid #f3f3f3;
  2594. border-top: 4px solid #3e7bfa;
  2595. border-radius: 50%;
  2596. animation: spin 1s linear infinite;
  2597. margin: 0 auto 16px;
  2598. }
  2599. .loading-text {
  2600. font-size: 16px;
  2601. color: #374151;
  2602. margin-bottom: 8px;
  2603. }
  2604. .loading-subtitle {
  2605. font-size: 14px;
  2606. color: #6b7280;
  2607. }
  2608. }
  2609. }
  2610. .outline-container {
  2611. background: white;
  2612. border-radius: 12px;
  2613. padding: 20px;
  2614. padding-bottom: 80px;
  2615. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  2616. position: relative;
  2617. &.disabled {
  2618. pointer-events: none;
  2619. opacity: 0.6;
  2620. }
  2621. .outline-header {
  2622. position: relative;
  2623. margin-bottom: 20px;
  2624. padding-bottom: 16px;
  2625. border-bottom: 1px solid #E5E7EB;
  2626. .outline-top-right {
  2627. display: flex;
  2628. gap: 8px;
  2629. justify-content: flex-end;
  2630. .action-btn {
  2631. display: flex;
  2632. align-items: center;
  2633. gap: 8px;
  2634. color: #374151;
  2635. font-size: 14px;
  2636. cursor: pointer;
  2637. transition: all 0.3s ease;
  2638. background: none;
  2639. border: none;
  2640. padding: 8px 12px;
  2641. border-radius: 4px;
  2642. &:hover {
  2643. background: #F3F4F6;
  2644. }
  2645. &:disabled {
  2646. opacity: 0.5;
  2647. cursor: not-allowed;
  2648. }
  2649. .action-icon {
  2650. width: 16px;
  2651. height: 16px;
  2652. }
  2653. &.exam-btn {
  2654. color: #22B850;
  2655. font-weight: 500;
  2656. &:hover {
  2657. background: #F3F4F6;
  2658. }
  2659. }
  2660. }
  2661. }
  2662. }
  2663. .outline-bottom-left {
  2664. position: absolute;
  2665. bottom: 20px;
  2666. left: 10px;
  2667. display: flex;
  2668. .action-btn {
  2669. display: flex;
  2670. align-items: center;
  2671. gap: 8px;
  2672. color: #374151;
  2673. font-size: 14px;
  2674. cursor: pointer;
  2675. transition: all 0.3s ease;
  2676. background: none;
  2677. border: none;
  2678. padding: 8px 12px;
  2679. border-radius: 4px;
  2680. &:hover {
  2681. background: #F3F4F6;
  2682. }
  2683. &:disabled {
  2684. opacity: 0.5;
  2685. cursor: not-allowed;
  2686. }
  2687. .action-icon {
  2688. width: 16px;
  2689. height: 16px;
  2690. }
  2691. &.regenerate-btn {
  2692. color: #374151;
  2693. .action-icon.rotating {
  2694. animation: rotate 1s linear infinite;
  2695. }
  2696. }
  2697. }
  2698. }
  2699. .outline-bottom-right {
  2700. position: absolute;
  2701. bottom: 20px;
  2702. right: 10px;
  2703. display: flex;
  2704. // gap: 20px;
  2705. .action-btn {
  2706. display: flex;
  2707. align-items: center;
  2708. gap: 8px;
  2709. color: #374151;
  2710. font-size: 14px;
  2711. cursor: pointer;
  2712. transition: all 0.3s ease;
  2713. background: none;
  2714. border: none;
  2715. padding: 8px 12px;
  2716. border-radius: 4px;
  2717. &.like-btn.active {
  2718. color: #10B981;
  2719. font-weight: 500;
  2720. .action-icon {
  2721. filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(142deg) brightness(104%) contrast(97%);
  2722. }
  2723. }
  2724. &.dislike-btn.active {
  2725. color: #EF4444;
  2726. font-weight: 500;
  2727. .action-icon {
  2728. filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(0deg) brightness(104%) contrast(97%);
  2729. }
  2730. }
  2731. &:hover {
  2732. background: #F3F4F6;
  2733. }
  2734. &:disabled {
  2735. opacity: 0.5;
  2736. cursor: not-allowed;
  2737. }
  2738. .action-icon {
  2739. width: 16px;
  2740. height: 16px;
  2741. }
  2742. }
  2743. }
  2744. .outline-content {
  2745. position: relative;
  2746. &.disabled {
  2747. pointer-events: none;
  2748. opacity: 0.6;
  2749. }
  2750. .generating-overlay {
  2751. position: absolute;
  2752. top: 0;
  2753. left: 0;
  2754. right: 0;
  2755. bottom: 0;
  2756. background: rgba(255, 255, 255, 0.9);
  2757. display: flex;
  2758. align-items: center;
  2759. justify-content: center;
  2760. z-index: 10;
  2761. .generating-content {
  2762. text-align: center;
  2763. p {
  2764. font-size: 14px;
  2765. color: #6b7280;
  2766. margin: 0;
  2767. }
  2768. }
  2769. }
  2770. .outline-content-scrollable {
  2771. max-height: calc(100vh - 300px);
  2772. overflow-y: auto;
  2773. .outline-chapter {
  2774. margin-bottom: 24px;
  2775. .chapter-header {
  2776. margin-bottom: 16px;
  2777. .chapter-title {
  2778. font-size: 16px;
  2779. font-weight: 600;
  2780. color: #1f2937;
  2781. margin: 0;
  2782. padding: 12px 16px;
  2783. background: #f8fafc;
  2784. border-left: 4px solid #3e7bfa;
  2785. border-radius: 0 8px 8px 0;
  2786. }
  2787. }
  2788. .outline-section {
  2789. .section-container {
  2790. margin-bottom: 16px;
  2791. .section-header {
  2792. margin-bottom: 8px;
  2793. .section-title {
  2794. font-size: 14px;
  2795. font-weight: 500;
  2796. color: #374151;
  2797. margin: 0;
  2798. padding: 8px 12px;
  2799. background: #f1f5f9;
  2800. border-radius: 6px;
  2801. }
  2802. }
  2803. .section-subsection {
  2804. margin-left: 16px;
  2805. .subsection-container {
  2806. margin-bottom: 12px;
  2807. .subsection-header {
  2808. margin-bottom: 6px;
  2809. .subsection-title {
  2810. font-size: 13px;
  2811. color: #6b7280;
  2812. margin: 0;
  2813. padding: 6px 10px;
  2814. background: #f9fafb;
  2815. border-radius: 4px;
  2816. }
  2817. }
  2818. .subsubsection-container {
  2819. margin-left: 12px;
  2820. .subsubsection-item {
  2821. margin-bottom: 8px;
  2822. .subsubsection-header {
  2823. .subsubsection-title {
  2824. font-size: 12px;
  2825. color: #9ca3af;
  2826. margin: 0;
  2827. padding: 4px 8px;
  2828. background: #ffffff;
  2829. border: 1px solid #e5e7eb;
  2830. border-radius: 4px;
  2831. }
  2832. }
  2833. }
  2834. }
  2835. }
  2836. }
  2837. }
  2838. }
  2839. }
  2840. .add-chapter-container {
  2841. text-align: center;
  2842. margin-top: 20px;
  2843. .add-chapter-btn {
  2844. display: flex;
  2845. align-items: center;
  2846. justify-content: center;
  2847. gap: 8px;
  2848. padding: 12px 20px;
  2849. background: #f8fafc;
  2850. border: 2px dashed #cbd5e1;
  2851. border-radius: 8px;
  2852. color: #64748b;
  2853. font-size: 14px;
  2854. cursor: pointer;
  2855. transition: all 0.2s ease;
  2856. &:hover {
  2857. background: #e2e8f0;
  2858. border-color: #94a3b8;
  2859. }
  2860. .add-icon {
  2861. width: 16px;
  2862. height: 16px;
  2863. object-fit: contain;
  2864. }
  2865. }
  2866. }
  2867. }
  2868. .ai-outline-content {
  2869. .ai-outline-scrollable {
  2870. max-height: calc(100vh - 280px);
  2871. overflow-y: auto;
  2872. text-align: left;
  2873. line-height: 1.6;
  2874. font-size: 14px;
  2875. color: #374151;
  2876. padding: 8px 0;
  2877. h1, h2, h3, h4, h5, h6 {
  2878. margin: 16px 0 8px 0;
  2879. font-weight: 600;
  2880. color: #1f2937;
  2881. }
  2882. h1 {
  2883. font-size: 18px;
  2884. border-bottom: 2px solid #3e7bfa;
  2885. padding-bottom: 8px;
  2886. }
  2887. h2 {
  2888. font-size: 16px;
  2889. color: #3e7bfa;
  2890. }
  2891. h3 {
  2892. font-size: 15px;
  2893. }
  2894. h4 {
  2895. font-size: 14px;
  2896. }
  2897. p {
  2898. margin: 8px 0;
  2899. line-height: 1.6;
  2900. }
  2901. ul, ol {
  2902. margin: 8px 0;
  2903. padding-left: 20px;
  2904. }
  2905. li {
  2906. margin: 4px 0;
  2907. }
  2908. strong {
  2909. font-weight: 600;
  2910. color: #1f2937;
  2911. }
  2912. em {
  2913. font-style: italic;
  2914. color: #6b7280;
  2915. }
  2916. code {
  2917. background: #f3f4f6;
  2918. padding: 2px 6px;
  2919. border-radius: 4px;
  2920. font-family: 'Courier New', monospace;
  2921. font-size: 13px;
  2922. }
  2923. }
  2924. }
  2925. .default-outline {
  2926. .outline-chapter {
  2927. margin-bottom: 20px;
  2928. h4 {
  2929. font-size: 16px;
  2930. font-weight: 600;
  2931. color: #1f2937;
  2932. margin: 0 0 12px 0;
  2933. padding: 12px 16px;
  2934. background: #f8fafc;
  2935. border-left: 4px solid #3e7bfa;
  2936. border-radius: 0 8px 8px 0;
  2937. }
  2938. .outline-section {
  2939. .section-item {
  2940. font-size: 14px;
  2941. color: #374151;
  2942. margin: 0 0 8px 16px;
  2943. padding: 6px 0;
  2944. }
  2945. .section-subsection {
  2946. margin-left: 16px;
  2947. .subsection-item {
  2948. font-size: 13px;
  2949. color: #6b7280;
  2950. margin: 0 0 6px 16px;
  2951. padding: 4px 0;
  2952. }
  2953. }
  2954. }
  2955. }
  2956. }
  2957. }
  2958. }
  2959. }
  2960. /* 思考动画 */
  2961. @keyframes thinking {
  2962. 0%, 80%, 100% {
  2963. transform: scale(0);
  2964. }
  2965. 40% {
  2966. transform: scale(1);
  2967. }
  2968. }
  2969. @keyframes spin {
  2970. 0% { transform: rotate(0deg); }
  2971. 100% { transform: rotate(360deg); }
  2972. }
  2973. @keyframes rotate {
  2974. 0% { transform: rotate(0deg); }
  2975. 100% { transform: rotate(360deg); }
  2976. }
  2977. /* 语音输入动画 */
  2978. @keyframes pulse {
  2979. 0% {
  2980. transform: scale(1);
  2981. }
  2982. 50% {
  2983. transform: scale(1.05);
  2984. }
  2985. 100% {
  2986. transform: scale(1);
  2987. }
  2988. }
  2989. @keyframes blink {
  2990. 0%, 50% {
  2991. opacity: 1;
  2992. }
  2993. 51%, 100% {
  2994. opacity: 0.3;
  2995. }
  2996. }
  2997. /* 旋转动画 */
  2998. .rotating {
  2999. animation: rotate 1s linear infinite;
  3000. }
  3001. /* 全屏遮罩层样式 */
  3002. .generating-overlay-full {
  3003. position: absolute;
  3004. top: 0;
  3005. left: 0;
  3006. right: 0;
  3007. bottom: 0;
  3008. background: rgba(255, 255, 255, 0.95);
  3009. display: flex;
  3010. align-items: center;
  3011. justify-content: center;
  3012. z-index: 100;
  3013. border-radius: 12px;
  3014. .generating-content {
  3015. text-align: center;
  3016. .loading-spinner-small {
  3017. width: 30px;
  3018. height: 30px;
  3019. border: 3px solid #f3f3f3;
  3020. border-top: 3px solid #3e7bfa;
  3021. border-radius: 50%;
  3022. animation: spin 1s linear infinite;
  3023. margin: 0 auto 12px;
  3024. }
  3025. p {
  3026. font-size: 14px;
  3027. color: #6b7280;
  3028. margin: 0;
  3029. }
  3030. }
  3031. }
  3032. </style>