| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670 |
- <template>
- <div class="mobile-safety-hazard">
- <!-- 移动端安全培训页面 -->
- <MobileHeader title="安全培训" @back="goBack" @menu="showHistoryDrawer" />
-
- <div class="mobile-content">
-
- <!-- 通用历史记录抽屉 -->
- <MobileHistoryDrawer
- :visible="!isGeneratingOutline && !isGeneratingExam && showHistory"
- title="历史记录"
- :historyData="historyData"
- :loading="isLoadingHistory"
- @close="showHistory = false"
- @createNewTask="createNewTask"
- @handleHistoryItem="handleHistoryItem"
- @deleteHistoryItem="deleteHistoryItem"
- />
- <!-- 初始状态:AI助手介绍和功能卡片 -->
- <div v-if="!showChat && currentStep === 'step1'" class="initial-content">
- <!-- AI助手介绍 -->
- <div class="ai-intro">
- <div class="ai-avatar">
- <img :src="aiAvatarIcon" alt="AI头像" class="ai-avatar-img">
- </div>
- <div class="ai-greeting">
- <h3>快速生成专业安全培训材料</h3>
- <p>输入培训主题,一键生成培训大纲与PPT模板</p>
- </div>
- </div>
-
- <!-- 功能卡片 -->
- <div class="function-cards">
- <div
- v-for="(card, index) in functionCards"
- :key="card.id || index"
- class="function-card"
- @click="handleFunctionCard(card.function_title)"
- >
- <div class="card-header">
- <div class="card-icon">
- <img :src="getFunctionCardIcon(card.function_title)" :alt="card.function_title" class="card-icon-img">
- </div>
- <h4>{{ card.function_title }}</h4>
- </div>
- <div class="card-description">
- <p>{{ card.function_content }}</p>
- </div>
- </div>
-
- <!-- 如果没有数据,显示默认卡片 -->
- <div v-if="functionCards.length === 0" class="function-card" @click="handleFunctionCard('安全培训课程')">
- <div class="card-header">
- <div class="card-icon">
- <img :src="safetyTrainingIcon" alt="安全培训课程" class="card-icon-img">
- </div>
- <h4>安全培训课程</h4>
- </div>
- <div class="card-description">
- <p>施工安全培训,操作规范学习</p>
- </div>
- </div>
- <div v-if="functionCards.length === 0" class="function-card" @click="handleFunctionCard('安全评估测试')">
- <div class="card-header">
- <div class="card-icon">
- <img :src="safetyAssessmentIcon" alt="安全评估" class="card-icon-img">
- </div>
- <h4>安全评估测试</h4>
- </div>
- <div class="card-description">
- <p>安全知识测评,能力水平评估</p>
- </div>
- </div>
- <div v-if="functionCards.length === 0" class="function-card" @click="handleFunctionCard('安全法规查询')">
- <div class="card-header">
- <div class="card-icon">
- <img :src="safetyRegulationsIcon" alt="安全法规" class="card-icon-img">
- </div>
- <h4>安全法规查询</h4>
- </div>
- <div class="card-description">
- <p>安全法律法规,标准规范查询</p>
- </div>
- </div>
- <div v-if="functionCards.length === 0" class="function-card" @click="handleFunctionCard('应急处理程序')">
- <div class="card-header">
- <div class="card-icon">
- <img :src="emergencyProceduresIcon" alt="应急程序" class="card-icon-img">
- </div>
- <h4>应急处理程序</h4>
- </div>
- <div class="card-description">
- <p>事故应急预案,处理流程指导</p>
- </div>
- </div>
- </div>
- </div>
- <!-- 聊天对话区域 -->
- <div v-else-if="showChat && currentStep === 'step1'" class="chat-messages">
- <div
- v-for="(message, index) in chatMessages"
- :key="index"
- :class="['message-item', message.type]"
- >
- <!-- 用户消息 -->
- <div v-if="message.type === 'user'" class="user-message">
- <div class="message-content">
- <!-- 文本内容 -->
- <div v-if="message.content" class="message-text">{{ message.content }}</div>
- </div>
- <div class="message-actions">
- <button class="action-btn copy-btn" @click="copyUserMessage(message)" title="复制">
- <img :src="copyIcon" alt="复制" class="action-icon">
- </button>
- <button class="action-btn edit-btn" @click="editUserMessage(message)" title="编辑">
- <img :src="editIcon" alt="编辑" class="action-icon">
- </button>
- </div>
- </div>
-
- <!-- AI消息 -->
- <div v-else-if="message.type === 'ai'" class="ai-message">
- <div class="ai-avatar-small">
- <img :src="aiAvatarIcon" alt="AI" class="ai-icon">
- </div>
- <div class="message-content">
- <div class="ai-text">
- <div v-if="message.displayContent.length === 0" class="typing-indicator">
- <div class="thinking-animation">
- <span class="dot"></span>
- <span class="dot"></span>
- <span class="dot"></span>
- </div>
- <span>AI正在思考中...</span>
- </div>
- <div v-else v-html="message.displayContent" class="ai-content"></div>
- </div>
- <div v-show="!message.isTyping && message.displayContent.length > 0" class="divider"></div>
- <div v-show="!message.isTyping && message.displayContent.length > 0" class="message-actions">
- <div class="left-actions">
- <button class="action-btn copy-btn" @click="copyAIMessage(message)" title="复制">
- <img :src="copyIcon" alt="复制" class="action-icon">
- </button>
- <button class="action-btn regenerate-btn" @click="regenerateResponse(index)" :disabled="hasTypingMessage" title="重新生成">
- <img :src="regenerateIcon" alt="重新生成" class="action-icon">
- </button>
- <button class="action-btn voice-btn" @click="handleVoiceRead(message)" :title="isSpeaking(message.id) ? '停止朗读' : '语音朗读'">
- <img :src="voiceIcon" alt="语音朗读" class="action-icon">
- </button>
- </div>
- <div class="right-actions">
- <button
- class="action-btn thumbs-up-btn"
- :class="{ active: message.userFeedback === 'like' }"
- @click="handleThumbsUp(message)"
- :title="message.userFeedback === 'like' ? '取消点赞' : '点赞'"
- >
- <img :src="likeIcon" alt="点赞" class="action-icon">
- </button>
- <button
- class="action-btn thumbs-down-btn"
- :class="{ active: message.userFeedback === 'dislike' }"
- @click="handleThumbsDown(message)"
- :title="message.userFeedback === 'dislike' ? '取消点踩' : '点踩'"
- >
- <img :src="dislikeIcon" alt="踩" class="action-icon">
- </button>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 步骤2:培训大纲 -->
- <div v-else-if="currentStep === 'step2'" class="step2-content">
- <!-- 加载状态 -->
- <div v-if="isLoadingHistory" class="loading-overlay">
- <div class="loading-content">
- <div class="loading-spinner"></div>
- <div class="loading-text">正在加载培训大纲</div>
- <div class="loading-subtitle">请稍候,正在为您准备数据...</div>
- </div>
- </div>
- <div class="outline-container" :class="{ 'disabled': isGeneratingOutline || isGeneratingExam }">
- <!-- 生成中遮罩层 - 覆盖整个outline-container -->
- <div v-if="isGeneratingOutline" class="generating-overlay-full">
- <div class="generating-content">
- <div class="loading-spinner-small"></div>
- <p>AI正在生成新大纲,请稍候...</p>
- </div>
- </div>
- <div v-if="isGeneratingExam" class="generating-overlay-full">
- <div class="generating-content">
- <div class="loading-spinner-small"></div>
- <p>AI正在生成考试题目,请稍候...</p>
- </div>
- </div>
-
- <!-- 大纲容器内的按钮布局 -->
- <div class="outline-header">
- <!-- 右上角:基础操作按钮 -->
- <div class="outline-top-right">
- <button class="action-btn" @click="copyEntireOutline" :disabled="isGeneratingOutline || isGeneratingExam">
- <img :src="copyIcon" alt="复制" class="action-icon">
- 复制
- </button>
- </div>
- </div>
- <!-- 大纲内容 -->
- <div class="outline-content">
- <!-- 动态大纲内容 -->
- <div v-if="outlineData && outlineData.length > 0" class="outline-content-scrollable">
- <template v-for="(chapter, chapterIndex) in outlineData" :key="chapterIndex">
- <div v-if="chapter && chapter.sections" class="outline-chapter">
- <div class="chapter-header">
- <h4 class="chapter-title">{{ getDisplayChapterTitle(chapter.title, chapterIndex) }}</h4>
- </div>
- <div class="outline-section">
- <template v-for="(section, sectionIndex) in chapter.sections" :key="sectionIndex">
- <div v-if="section &&
- section.title !== '内容要点' &&
- section.title !== '概述' &&
- section.title !== '内容详情'" class="section-container">
- <div class="section-header">
- <div class="section-title">{{ section.title }}</div>
- </div>
- <!-- 子小节 -->
- <div v-if="section && section.subsections && section.subsections.length > 0" class="section-subsection">
- <template v-for="(subsection, subsectionIndex) in section.subsections" :key="subsectionIndex">
- <div v-if="subsection &&
- subsection.title !== '内容要点' &&
- subsection.title !== '概述' &&
- subsection.title !== '内容详情' &&
- !subsection.title.includes('总章节数') &&
- !subsection.title.includes('总小节数') &&
- !subsection.title.includes('预计PPT页数') &&
- !subsection.title.includes('预计讲解时长')" class="subsection-container">
- <div class="subsection-header">
- <div class="subsection-title">{{ subsection.title }}</div>
- </div>
- <!-- 具体内容要点 -->
- <div v-if="subsection.subsubsections && subsection.subsubsections.length > 0" class="subsubsection-container">
- <template v-for="(subsubsection, subsubsectionIndex) in subsection.subsubsections" :key="subsubsectionIndex">
- <div class="subsubsection-item">
- <div class="subsubsection-header">
- <div class="subsubsection-title">{{ subsubsection.title }}</div>
- </div>
- </div>
- </template>
- </div>
- </div>
- </template>
- </div>
- </div>
- </template>
- </div>
- </div>
- </template>
- <!-- 添加新章节按钮 -->
- <div v-if="outlineData.length < 6" class="add-chapter-container">
- <button class="add-chapter-btn" @click="addNewItem('chapter', null)">
- <img :src="addIcon" alt="添加章节" class="add-icon">
- <span>添加新章节</span>
- </button>
- </div>
- </div>
- <!-- AI返回的层级内容渲染 -->
- <div v-else-if="aiOutlineContent" class="ai-outline-content">
- <div class="ai-outline-scrollable" v-html="aiOutlineContent"></div>
- </div>
- <!-- 默认大纲内容 -->
- <div v-else class="default-outline">
- <div class="outline-chapter">
- <h4>第一章 安全生产基本原则</h4>
- <div class="outline-section">
- <div class="section-item">1.1 安全生产的重要性</div>
- <div class="section-item">1.2 安全生产相关法规</div>
- <div class="section-subsection">
- <div class="subsection-item">1.2.1 《中华人民共和国安全生产法》解读</div>
- <div class="subsection-item">1.2.2 建筑工程安全管理规范</div>
- </div>
- </div>
- </div>
- <div class="outline-chapter">
- <h4>第二章 施工现场安全管理</h4>
- <div class="outline-section">
- <div class="section-item">2.1 安全责任制度</div>
- <div class="section-item">2.2 安全教育培训</div>
- <div class="section-item">2.3 安全检查与隐患排查</div>
- </div>
- </div>
- <div class="outline-chapter">
- <h4>第三章 常见安全隐患及防范措施</h4>
- <div class="outline-section">
- <div class="section-item">3.1 高空作业安全</div>
- <div class="section-subsection">
- <div class="subsection-item">3.1.1 脚手架搭设及使用安全规范</div>
- </div>
- <div class="section-item">3.2 用电安全</div>
- <div class="section-item">3.3 消防安全</div>
- </div>
- </div>
- <div class="outline-chapter">
- <h4>第四章 安全事故案例分析</h4>
- <div class="outline-section">
- <div class="section-item">4.1 典型事故分析与教训</div>
- </div>
- </div>
- <div class="outline-chapter">
- <h4>第五章 总结与展望</h4>
- </div>
- </div>
- </div>
-
- <!-- 左下角:大纲操作按钮 -->
- <div class="outline-bottom-left">
- <button class="action-btn regenerate-btn" @click="regenerateOutline" :disabled="isGeneratingOutline || isGeneratingExam">
- <img :src="regenerateIcon" alt="重新生成" class="action-icon" :class="{ 'rotating': isGeneratingOutline }">
- {{ isGeneratingOutline ? '生成中...' : '生成新大纲' }}
- </button>
- <button class="action-btn" @click="selectPptTemplate" :disabled="isGeneratingOutline || isGeneratingExam">
- 选择PPT模版
- <img :src="pptIcon" alt="箭头" class="action-icon">
- </button>
- </div>
-
- <!-- 右下角:评价按钮 -->
- <div class="outline-bottom-right">
- <button class="action-btn like-btn" @click="handleOutlineThumbsUp" :class="{ active: getEvaluationStatus() === 'like' }" :disabled="isGeneratingOutline || isGeneratingExam">
- <img :src="likeIcon" alt="满意" class="action-icon">
- <!-- 满意 -->
- </button>
- <button class="action-btn dislike-btn" @click="handleOutlineThumbsDown" :class="{ active: getEvaluationStatus() === 'dislike' }" :disabled="isGeneratingOutline || isGeneratingExam">
- <img :src="dislikeIcon" alt="不满意" class="action-icon">
- <!-- 不满意 -->
- </button>
- </div>
- </div>
- </div>
- <!-- 底部输入区域 -->
- <div v-if="currentStep === 'step1'" class="chat-input-section">
- <div class="input-container">
- <div class="input-box">
- <input
- type="text"
- placeholder="请在此处发送消息"
- class="message-input"
- v-model="messageText"
- @keyup.enter="sendMessage"
- :disabled="isSending || hasTypingMessage"
- maxlength="2000"
- >
- <button class="voice-btn" @click="handleVoiceClick" :disabled="isSending || hasTypingMessage" :class="{ 'recording': isListening }">
- <div class="icon-container">
- <img :src="voiceInputIcon" alt="语音" class="action-icon">
- <div v-if="isListening" class="recording-indicator"></div>
- </div>
- </button>
- <div class="divider"></div>
- <button class="send-btn" @click="sendMessage" :disabled="isSending || hasTypingMessage || !messageText.trim()">
- <img :src="messageText.trim() && !isSending ? sendIconFilled : sendIconEmpty" alt="发送" class="send-icon">
- </button>
- </div>
- </div>
- </div>
- </div>
-
- <!-- 移动端轻提示 -->
- <MobileToast
- :visible="showToast"
- :message="toastMessage"
- :duration="toastDuration"
- @close="showToast = false"
- />
-
- <!-- 删除确认弹窗 -->
- <DeleteConfirmModal
- :visible="showDeleteModal"
- :title="deleteConfirmTitle"
- :message="deleteConfirmMessage"
- @confirm="confirmDelete"
- @cancel="cancelDelete"
- @close="cancelDelete"
- />
-
- </div>
- </template>
- <script setup>
- import { useRouter, useRoute } from 'vue-router'
- import MobileHeader from '@/components/MobileHeader.vue'
- import MobileHistoryDrawer from '@/components/MobileHistoryDrawer.vue'
- import DeleteConfirmModal from '@/components/DeleteConfirmModal.vue'
- import MobileToast from '@/components/MobileToast.vue'
- import { ref, onMounted, watch, nextTick, computed, onBeforeUnmount } from 'vue'
- import { apis } from '@/request/apis.js'
- import { initNativeNavForSubPage } from '@/utils/nativeBridge.js'
- // ===== 已删除:getUserId - 不再需要,改用token =====
- // import { getUserId } from '@/utils/userManager.js'
- import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
- // 导入图片资源 - 安全培训专用
- import aiAvatarIcon from '@/assets/Safety/5.png'
- import safetyTrainingIcon from '@/assets/Safety/4.png'
- import safetyAssessmentIcon from '@/assets/Safety/3.png'
- import safetyRegulationsIcon from '@/assets/Safety/2.png'
- import emergencyProceduresIcon from '@/assets/Safety/1.png'
- import sendIconEmpty from '@/assets/Chat/15.png'
- import sendIconFilled from '@/assets/Chat/16.png'
- // 对话操作图标
- import copyIcon from '@/assets/AIWriting/12.png'
- import editIcon from '@/assets/AIWriting/6.png'
- import regenerateIcon from '@/assets/Safety/12.png'
- import voiceIcon from '@/assets/AIWriting/9.png'
- import likeIcon from '@/assets/AIWriting/10.png'
- import dislikeIcon from '@/assets/AIWriting/11.png'
- // 大纲操作图标
- import examIcon from '@/assets/Safety/31.png'
- import downloadIcon from '@/assets/AIWriting/13.png'
- import addIcon from '@/assets/Safety/8.png'
- import evaluateIcon from '@/assets/index/6.png'
- import pptIcon from '@/assets/Safety/13.png'
- // 语音输入图标
- import voiceInputIcon from '@/assets/Chat/18.png'
- const router = useRouter()
- const route = useRoute()
- const goBack = () => {
- router.go(-1)
- }
- // 显示历史记录抽屉的方法
- const showHistoryDrawer = () => {
- if (!isGeneratingOutline.value && !isGeneratingExam.value) {
- showHistory.value = true
- }
- // AI处理中时不执行任何操作,不记录点击意图
- }
- const showHistory = ref(false)
- // 历史记录相关状态
- const historyData = ref([])
- const historyTotal = ref(0)
- const isLoadingHistory = ref(false)
- // 聊天相关状态
- const showChat = ref(false)
- const chatMessages = ref([])
- const messageText = ref('')
- const isSending = ref(false)
- const ai_conversation_id = ref(0)
- // 步骤控制
- const currentStep = ref('step1') // step1: 聊天界面, step2: 培训大纲
- // 删除确认弹窗状态
- const showDeleteModal = ref(false)
- const deleteTargetItem = ref(null)
- const deleteType = ref('')
- // 语音朗读状态
- const speakingMessageId = ref(null)
- // 语音识别功能
- const {
- isSupported: speechSupported,
- isListening,
- transcript,
- error: speechError,
- startListening,
- stopListening,
- speakText,
- stopSpeaking
- } = useSpeechRecognition()
- // Toast状态
- const showToast = ref(false)
- const toastMessage = ref('')
- const toastDuration = ref(2000)
- // 功能卡片数据
- const functionCards = ref([])
- // 功能卡片图标计数器
- let functionCardIconIndex = 0
- // 大纲相关状态
- const outlineTitle = ref('')
- const outlineData = ref([])
- const aiOutlineContent = ref('')
- const isGeneratingOutline = ref(false)
- const isGeneratingExam = ref(false)
- const outlineFeedback = ref(null) // 大纲评价反馈
- const evaluation = ref('') // 本地评价状态
- const currentAiMessageId = ref(null) // 当前AI消息的ID
- // 计算属性 - 是否有正在打字的AI消息
- const hasTypingMessage = computed(() => {
- return chatMessages.value.some(message => message.type === 'ai' && message.isTyping)
- })
- // 删除确认消息
- const deleteConfirmMessage = computed(() => {
- if (deleteType.value === 'history') {
- const title = deleteTargetItem.value?.item?.title || ''
- return `确定要删除历史记录"${title}"吗?删除后将无法恢复。`
- } else if (deleteType.value === 'message') {
- return '确定要删除这条消息吗?删除后将无法恢复。'
- }
- return '确定要删除吗?删除后将无法恢复。'
- })
- // 删除确认标题
- const deleteConfirmTitle = computed(() => {
- if (deleteType.value === 'history') {
- return '删除历史记录'
- } else if (deleteType.value === 'message') {
- return '删除消息'
- }
- return '删除确认'
- })
- // 从HTML转换为Markdown(适配移动端HTML内容)
- const convertOutlineToMarkdownFromHTML = (htmlContent, title) => {
- let markdown = `# ${title}\n\n`
-
- // 创建临时容器解析HTML
- const tempDiv = document.createElement('div')
- tempDiv.innerHTML = htmlContent
-
- // 遍历HTML元素生成markdown
- const processElement = (element) => {
- const tagName = element.tagName.toLowerCase()
-
- if (tagName === 'h1') {
- const content = element.textContent.trim()
- if (content && !content.includes('大纲')) {
- markdown += `## ${content}\n\n`
- }
- } else if (tagName === 'h2') {
- const content = element.textContent.trim()
- markdown += `### ${content}\n\n`
- } else if (tagName === 'h3') {
- const content = element.textContent.trim()
- markdown += `#### ${content}\n\n`
- } else if (tagName === 'h4') {
- const content = element.textContent.trim()
- markdown += `##### ${content}\n\n`
- } else if (tagName === 'ul') {
- const listItems = element.querySelectorAll('li')
- listItems.forEach((li) => {
- const content = li.textContent.trim()
- if (content) {
- markdown += `- ${content}\n`
- }
- })
- markdown += '\n'
- } else if (tagName === 'ol') {
- const listItems = element.querySelectorAll('li')
- listItems.forEach((li, index) => {
- const content = li.textContent.trim()
- if (content) {
- markdown += `${index + 1}. ${content}\n`
- }
- })
- markdown += '\n'
- } else if (tagName === 'p') {
- const content = element.textContent.trim()
- if (content) {
- markdown += `${content}\n\n`
- }
- } else {
- // 递归处理子元素
- for (const child of element.childNodes) {
- if (child.nodeType === Node.ELEMENT_NODE) {
- processElement(child)
- }
- }
- }
- }
-
- // 处理所有子元素
- for (const child of tempDiv.childNodes) {
- if (child.nodeType === Node.ELEMENT_NODE) {
- processElement(child)
- }
- }
-
- return markdown
- }
- // 解析AI考试回复(从PC端移植)
- const parseAIExamResponse = (aiReply) => {
- try {
- // 尝试提取JSON内容
- const jsonMatch = aiReply.match(/\{[\s\S]*\}/)
- if (jsonMatch) {
- const examData = JSON.parse(jsonMatch[0])
- // 确保所有题目都有正确的初始值
- ensureQuestionInitialValues(examData)
- return examData
- } else {
- throw new Error('未找到有效的JSON数据')
- }
- } catch (error) {
- console.error('解析AI回复失败:', error)
- // 返回默认试卷结构
- return generateDefaultExam()
- }
- }
- // 确保题目初始值正确(从PC端移植)
- const ensureQuestionInitialValues = (examData) => {
- // 单选题
- if (examData.singleChoice && examData.singleChoice.questions) {
- examData.singleChoice.questions.forEach(question => {
- if (!question.selectedAnswer) {
- question.selectedAnswer = ""
- }
- if (!question.options || question.options.length === 0) {
- question.options = [
- { key: "A", text: "选项A" },
- { key: "B", text: "选项B" },
- { key: "C", text: "选项C" },
- { key: "D", text: "选项D" }
- ]
- }
- })
- }
- // 判断题
- if (examData.judge && examData.judge.questions) {
- examData.judge.questions.forEach(question => {
- if (!question.selectedAnswer) {
- question.selectedAnswer = ""
- }
- })
- }
- // 多选题
- if (examData.multiple && examData.multiple.questions) {
- examData.multiple.questions.forEach(question => {
- if (!question.selectedAnswers) {
- question.selectedAnswers = []
- }
- if (!question.options || question.options.length === 0) {
- question.options = [
- { key: "A", text: "选项A" },
- { key: "B", text: "选项B" },
- { key: "C", text: "选项C" },
- { key: "D", text: "选项D" }
- ]
- }
- })
- }
- // 简答题
- if (examData.short && examData.short.questions) {
- examData.short.questions.forEach(question => {
- if (!question.outline) {
- question.outline = { keyFactors: "答题要点、关键因素:示例答案" }
- }
- })
- }
- }
- // 生成默认考试(从PC端移植)
- const generateDefaultExam = () => {
- return {
- title: "安全培训考试",
- totalScore: 100,
- totalQuestions: 17,
- singleChoice: {
- scorePerQuestion: 5,
- totalScore: 25,
- count: 5,
- questions: []
- },
- judge: {
- scorePerQuestion: 3,
- totalScore: 15,
- count: 5,
- questions: []
- },
- multiple: {
- scorePerQuestion: 8,
- totalScore: 40,
- count: 5,
- questions: []
- },
- short: {
- scorePerQuestion: 10,
- totalScore: 20,
- count: 2,
- questions: []
- }
- }
- }
- // 导出考试文件(从PC端移植)
- const exportExamToFile = (examData) => {
- try {
- // 创建Word文档内容(使用PC端相同的createExamWordContent函数)
- const wordContent = createExamWordContent(examData)
- // 创建Blob对象 - 使用Word兼容的MIME类型
- const blob = new Blob([wordContent], {
- type: 'application/msword'
- })
- // 下载文件
- const url = URL.createObjectURL(blob)
- const link = document.createElement('a')
- link.setAttribute('href', url)
- link.setAttribute('download', `${examData.title}_${new Date().toISOString().split('T')[0]}.doc`)
- link.style.visibility = 'hidden'
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
- URL.revokeObjectURL(url)
- showToastMessage('考试文件已下载!')
- } catch (error) {
- console.error('导出考试文件失败:', error)
- showToastMessage('导出考试文件失败,请重试')
- }
- }
- // 创建Word格式的考试文档内容(从PC端完整移植)
- const createExamWordContent = (examData) => {
- const currentTime = new Date().toLocaleString('zh-CN')
- // HTML文档内容,使用Word兼容的格式
- let htmlContent = `<!DOCTYPE html>
- <html xmlns:o="urn:schemas-microsoft-com:office:office"
- xmlns:w="urn:schemas-microsoft-com:office:word"
- xmlns="http://www.w3.org/TR/REC-html40">
- <head>
- <meta charset="utf-8">
- <meta name="ProgId" content="Word.Document">
- <meta name="Generator" content="Microsoft Word 15">
- <meta name="Originator" content="Microsoft Word 15">
- <title>${examData.title || '考试试卷'}</title>
- <!--[if gte mso 9]>
- <xml>
- <w:WordDocument>
- <w:View>Print</w:View>
- <w:Zoom>100</w:Zoom>
- <w:DoNotPromptForConvert/>
- <w:DoNotShowRevisions/>
- <w:DoNotPrintRevisions/>
- <w:DoNotShowComments/>
- <w:DoNotShowInsertionsAndDeletions/>
- <w:DoNotShowPropertyChanges/>
- <w:Compatibility>
- <w:BreakWrappedTables/>
- <w:SnapToGridInCell/>
- <w:WrapTextWithPunct/>
- <w:UseAsianBreakRules/>
- <w:DontGrowAutofit/>
- </w:Compatibility>
- </w:WordDocument>
- </xml>
- <![endif]-->
- <style>
- body {
- font-family: "Microsoft YaHei", "宋体", Arial, sans-serif;
- font-size: 14px;
- line-height: 1.6;
- margin: 24px;
- color: #000;
- }
- .header {
- text-align: center;
- margin-bottom: 14px;
- }
- .exam-title {
- font-size: 24px;
- font-weight: bold;
- margin-bottom: 14px;
- color: #000;
- }
- .exam-info {
- font-size: 14px;
- color: #666;
- margin-bottom: 14px;
- }
- .section {
- margin-bottom: 14px;
- }
- .section-title {
- font-size: 18px;
- font-weight: bold;
- margin-bottom: 14px;
- color: #000;
- border-bottom: 2px solid #3e7bfa;
- padding-bottom: 5px;
- }
- .question {
- margin-bottom: 14px;
- padding: 10px;
- background-color: #f9f9f9;
- border-left: 4px solid #3e7bfa;
- }
- .question-header {
- margin-bottom: 14px;
- line-height: 1.6;
- }
- .question-number {
- font-weight: bold;
- color: #3e7bfa;
- }
- .options {
- margin-left: 12px;
- }
- .option {
- margin-bottom: 5px;
- }
- .answer {
- margin-top: 10px;
- padding: 8px;
- background: #e8f4fd;
- border-radius: 4px;
- font-weight: bold;
- color: #2c5aa0;
- }
- </style>
- </head>
- <body>
- <div class="header">
- <div class="exam-title">${examData.title || '考试试卷'}</div>
- <div class="exam-info">
- 总分:${examData.totalScore || 0}分 | 总题数:${examData.totalQuestions || 0}题 | 生成时间:${currentTime}
- </div>
- </div>`
- // 单选题
- if (examData.singleChoice && examData.singleChoice.questions.length > 0) {
- htmlContent += `
- <div class="section">
- <div class="section-title">一、单选题(${examData.singleChoice.count}题,每题${examData.singleChoice.scorePerQuestion}分,共${examData.singleChoice.totalScore}分)</div>`
- examData.singleChoice.questions.forEach((question, index) => {
- htmlContent += `
- <div class="question">
- <div class="question-header">
- <span class="question-number">${index + 1}.</span> ${question.text}
- </div>
- <div class="options">`
- question.options.forEach(option => {
- htmlContent += `
- <div class="option">${option.key}. ${option.text}</div>`
- })
- htmlContent += `
- </div>
- <div class="answer">正确答案:${question.selectedAnswer} </div>
- </div>`
- })
- htmlContent += `
- </div>`
- }
- // 判断题
- if (examData.judge && examData.judge.questions.length > 0) {
- htmlContent += `
- <div class="section">
- <div class="section-title">二、判断题(${examData.judge.count}题,每题${examData.judge.scorePerQuestion}分,共${examData.judge.totalScore}分)</div>`
- examData.judge.questions.forEach((question, index) => {
- htmlContent += `
- <div class="question">
- <div class="question-header">
- <span class="question-number">${index + 1}.</span> ${question.text}
- </div>
- <div class="answer">正确答案:${question.selectedAnswer} </div>
- </div>`
- })
- htmlContent += `
- </div>`
- }
- // 多选题
- if (examData.multiple && examData.multiple.questions.length > 0) {
- htmlContent += `
- <div class="section">
- <div class="section-title">三、多选题(${examData.multiple.count}题,每题${examData.multiple.scorePerQuestion}分,共${examData.multiple.totalScore}分)</div>`
- examData.multiple.questions.forEach((question, index) => {
- htmlContent += `
- <div class="question">
- <div class="question-header">
- <span class="question-number">${index + 1}.</span> ${question.text}
- </div>
- <div class="options">`
- question.options.forEach(option => {
- htmlContent += `
- <div class="option">${option.key}. ${option.text}</div>`
- })
- htmlContent += `
- </div>
- <div class="answer">正确答案:${question.selectedAnswers.join(', ')}</div>
- </div>`
- })
- htmlContent += `
- </div>`
- }
- // 简答题
- if (examData.short && examData.short.questions.length > 0) {
- htmlContent += `
- <div class="section">
- <div class="section-title">四、简答题(${examData.short.count}题,每题${examData.short.scorePerQuestion}分,共${examData.short.totalScore}分)</div>`
- examData.short.questions.forEach((question, index) => {
- htmlContent += `
- <div class="question">
- <div class="question-header">
- <span class="question-number">${index + 1}.</span> ${question.text}
- </div>
- <div class="answer">答题要点:${question.outline.keyFactors} </div>
- </div>`
- })
- htmlContent += `
- </div>`
- }
- htmlContent += `
- </body>
- </html>`
- return htmlContent
- }
- // 时间解析与格式化(容错更强)
- const parseToDate = (input) => {
- if (!input) return null
- if (typeof input === 'number') {
- const ms = input < 1e12 ? input * 1000 : input
- return new Date(ms)
- }
- if (typeof input === 'string') {
- let d = new Date(input)
- if (!isNaN(d)) return d
- const normalized = input.replace(/-/g, '/').replace('T', ' ')
- d = new Date(normalized)
- if (!isNaN(d)) return d
- }
- return new Date(input)
- }
- const formatTime = (timestamp) => {
- const date = parseToDate(timestamp)
- if (!date || isNaN(date)) return ''
- const now = new Date()
- const isToday = date.toDateString() === now.toDateString()
- const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
- const isYesterday = date.toDateString() === yesterday.toDateString()
- if (isToday) {
- return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
- }
- if (isYesterday) {
- return '昨天 ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
- }
- const month = date.getMonth() + 1
- const day = date.getDate()
- const time = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
- return `${month}月${day}日 ${time}`
- }
- // 从AI回复中提取大纲标题(从PC端移植)
- const extractOutlineTitleFromAI = (aiReply) => {
- try {
- let title = '安全培训大纲' // 默认标题
-
- // 尝试从第一行提取标题
- const lines = aiReply.split('\n')
-
- for (let line of lines) {
- const trimmedLine = line.trim()
-
- // 查找包含"以下是为您准备的PPT大纲"的行,下一行通常是标题
- if (trimmedLine.includes('以下是为您准备的PPT大纲')) {
- // 查找下一行作为标题
- const nextLineIndex = lines.indexOf(line) + 1
- if (nextLineIndex < lines.length) {
- const nextLine = lines[nextLineIndex].trim()
- if (nextLine && nextLine.length > 0 && !nextLine.includes('以下') && !nextLine.includes('大纲统计信息')) {
- title = nextLine
- break
- }
- }
- }
-
- // 查找#开头的大标题,作为整个大纲的标题
- if (trimmedLine.startsWith('# ')) {
- title = trimmedLine.replace('# ', '').trim()
- break
- }
-
- // 查找"## "开头的一级标题
- if (trimmedLine.startsWith('## ')) {
- const titleText = trimmedLine.replace('## ', '').trim()
- if (titleText && !titleText.includes('内容要点') && !titleText.includes('概述') && !titleText.includes('内容详情')) {
- title = titleText
- break
- }
- }
-
- // 查找没有#号的大标题行(通常是第一行的重要标题)
- if (trimmedLine.length > 10 &&
- !trimmedLine.includes('以下是') &&
- !trimmedLine.includes('大纲统计') &&
- !trimmedLine.includes('##') &&
- !trimmedLine.includes('###') &&
- trimmedLine.length < 50) {
- // 如果这一行看起来像标签
- if (!title.includes('#') && title === '安全培训大纲') {
- title = trimmedLine
- }
- }
- }
-
- console.log('提取的大纲标题:', title)
- return title
-
- } catch (error) {
- console.error('提取大纲标题失败:', error)
- return '安全培训大纲'
- }
- }
- // 生成对话标题
- const generateConversationTitle = (content) => {
- if (!content) return '新对话'
- // 取前30个字符作为标题
- const title = content.replace(/<[^>]*>/g, '').trim()
- return title.length > 30 ? title.substring(0, 30) + '...' : title
- }
- // 获取历史记录列表
- const getHistoryRecordList = async () => {
- try {
- console.log('📋 开始获取移动端安全培训历史记录列表...')
- isLoadingHistory.value = true
- const startTime = performance.now()
-
- const response = await apis.getHistoryRecord({
- // ===== 已删除:user_id - 后端从token解析 =====
- ai_conversation_id: 0, // 0表示获取对话列表
- business_type: 1 // 安全培训类型
- })
-
- const endTime = performance.now()
- console.log(`📋 移动端安全培训历史记录API调用耗时: ${(endTime - startTime).toFixed(2)}ms`)
- console.log('📋 移动端历史记录列表响应:', response)
-
- if (response.statusCode === 200) {
- // 设置历史记录总数
- historyTotal.value = response.total || 0
-
- // 转换后端数据为前端格式
- historyData.value = response.data.map(conversation => ({
- id: conversation.id,
- title: generateConversationTitle(conversation.content),
- time: formatTime(conversation.updated_at),
- businessType: conversation.business_type,
- isActive: false,
- // 保存原始数据用于后续查询
- rawData: conversation
- }))
- // 高亮当前对话
- if (ai_conversation_id.value) {
- historyData.value.forEach(item => { item.isActive = item.id === ai_conversation_id.value })
- }
- console.log(`✅ 移动端安全培训历史记录列表已设置: ${historyData.value.length}条记录,总数: ${historyTotal.value}`)
- } else {
- console.error('❌ 获取移动端历史记录列表失败:', response.statusCode)
- }
- } catch (error) {
- console.error('❌ 获取移动端历史记录列表失败:', error)
- } finally {
- isLoadingHistory.value = false
- }
- }
- // 将Markdown格式转换为HTML格式
- const markdownToHtml = (text) => {
- if (!text) return text
-
- console.log('开始转换Markdown:', text)
-
- let html = text
-
- // 检查是否包含Markdown格式
- const hasMarkdown = /(?:^|<br>)#{1,6}\s*/.test(html) || /\*\*.*?\*\*/.test(html) || /^\s*[-*]\s+/.test(html)
- console.log('Markdown格式检测结果:', hasMarkdown)
-
- // 检查是否已经包含HTML标签(如<strong>、<em>等)
- const hasHtmlTags = /<[^>]*>/.test(html)
- console.log('HTML标签检测结果:', hasHtmlTags)
-
- // 如果包含Markdown格式,进行转换
- if (hasMarkdown) {
- console.log('检测到Markdown格式,进行Markdown转换')
-
- // 转换标题
- html = html.replace(/^#{6}\s*(.+)$/gm, '<h6>$1</h6>')
- html = html.replace(/^#{5}\s*(.+)$/gm, '<h5>$1</h5>')
- html = html.replace(/^#{4}\s*(.+)$/gm, '<h4>$1</h4>')
- html = html.replace(/^#{3}\s*(.+)$/gm, '<h3>$1</h3>')
- html = html.replace(/^#{2}\s*(.+)$/gm, '<h2>$1</h2>')
- html = html.replace(/^#{1}\s*(.+)$/gm, '<h1>$1</h1>')
-
- // 转换加粗
- html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
-
- // 转换斜体
- html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
-
- // 转换列表
- html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>')
- html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
-
- // 转换代码
- html = html.replace(/`(.*?)`/g, '<code>$1</code>')
-
- console.log('Markdown转换完成:', html)
- } else if (hasHtmlTags) {
- console.log('检测到HTML标签,跳过Markdown转换')
- } else {
- console.log('未检测到特殊格式,保持原文本')
- }
-
- // 处理换行
- html = html.replace(/\n/g, '<br>')
-
- console.log('最终HTML:', html)
- return html
- }
- // 根据功能卡片标题返回对应的图标
- const getFunctionCardIcon = (title) => {
- // 按顺序循环使用4个图标
- const icons = [safetyTrainingIcon, safetyAssessmentIcon, safetyRegulationsIcon, emergencyProceduresIcon]
- const icon = icons[functionCardIconIndex % icons.length]
- functionCardIconIndex++
- return icon
- }
- // 点击功能卡片
- const handleFunctionCard = (cardType) => {
- console.log('点击功能卡片:', cardType)
-
- // 重置聊天状态,准备创建新的对话
- chatMessages.value = []
- ai_conversation_id.value = 0
-
- // 显示聊天界面
- showChat.value = true
-
- // 直接使用卡片类型作为消息内容
- const message = `请详细介绍${cardType}的相关内容`
- messageText.value = message
-
- // 自动发送消息
- sendMessage()
- }
- // 发送消息
- const sendMessage = async () => {
- if (!messageText.value.trim() || isSending.value) return
-
- console.log('开始发送消息:', messageText.value)
- isSending.value = true
-
- // 切换到聊天界面
- showChat.value = true
-
- // 添加用户消息
- const userMessage = {
- type: 'user',
- content: messageText.value,
- id: Date.now()
- }
- chatMessages.value.push(userMessage)
-
- // 添加AI消息(初始状态为思考中)
- const aiMessage = {
- type: 'ai',
- content: '',
- displayContent: '',
- isTyping: true,
- id: Date.now() + 1,
- userFeedback: null
- }
- chatMessages.value.push(aiMessage)
-
- // 清空输入框
- const currentMessage = messageText.value
- messageText.value = ''
-
- // 发送消息后滚动到底部
- scrollToBottom()
-
- try {
- // 调用后端DeepSeek接口
- const response = await apis.sendDeepseekMessage({
- // ===== 已删除:user_id - 后端从token解析 =====
- ai_conversation_id: ai_conversation_id.value,
- message: currentMessage,
- business_type: 1 // 安全培训类型
- })
-
- console.log('AI回复响应:', response)
-
- if (response.statusCode === 200) {
- // 设置对话ID
- if (response.data && response.data.ai_conversation_id) {
- ai_conversation_id.value = response.data.ai_conversation_id
- }
-
- // 设置AI消息ID(用于评价功能)
- if (response.data && response.data.ai_message_id) {
- currentAiMessageId.value = response.data.ai_message_id
- console.log('设置AI消息ID:', currentAiMessageId.value)
- }
-
- // 处理AI回复
- const aiReply = response.data ? response.data.reply : response.reply || ''
- console.log('AI回复内容:', aiReply)
-
- // 将Markdown格式转换为HTML格式
- const htmlReply = markdownToHtml(aiReply)
-
- // 开始打字效果 - 按完整HTML标签或文本块显示,避免标签分割
- const textBlocks = []
- let currentBlock = ''
- let inTag = false
- let tagContent = ''
-
- // 将HTML内容分割成完整的块
- for (let i = 0; i < htmlReply.length; i++) {
- const char = htmlReply[i]
-
- if (char === '<') {
- // 如果之前有文本内容,先保存
- if (currentBlock && !inTag) {
- textBlocks.push({ type: 'text', content: currentBlock })
- currentBlock = ''
- }
- inTag = true
- tagContent = char
- } else if (char === '>') {
- // 标签结束
- tagContent += char
- textBlocks.push({ type: 'tag', content: tagContent })
- inTag = false
- tagContent = ''
- } else if (inTag) {
- // 在标签内
- tagContent += char
- } else {
- // 普通文本
- currentBlock += char
- }
- }
-
- // 保存最后一个文本块
- if (currentBlock) {
- textBlocks.push({ type: 'text', content: currentBlock })
- }
-
- // 检查是否有未闭合的标签
- if (tagContent) {
- textBlocks.push({ type: 'tag', content: tagContent })
- }
-
- console.log('分割后的文本块:', textBlocks)
-
- let currentBlockIndex = 0
- let currentCharIndex = 0
-
- const typeInterval = setInterval(() => {
- if (currentBlockIndex < textBlocks.length) {
- const currentBlock = textBlocks[currentBlockIndex]
-
- if (currentBlock.type === 'tag') {
- // 标签直接显示,不分字符
- console.log('显示HTML标签:', currentBlock.content)
- aiMessage.displayContent += currentBlock.content
- currentBlockIndex++
- currentCharIndex = 0
- } else {
- // 文本按字符显示
- if (currentCharIndex < currentBlock.content.length) {
- const newContent = aiMessage.displayContent + currentBlock.content[currentCharIndex]
- aiMessage.displayContent = newContent
- currentCharIndex++
- } else {
- // 当前文本块完成,移动到下一个
- currentBlockIndex++
- currentCharIndex = 0
- }
- }
-
- // 强制触发Vue响应式更新
- chatMessages.value = [...chatMessages.value]
-
- // 如果用户在底部,自动滚动到底部
- scrollToBottom()
- } else {
- // 所有块都显示完成
- aiMessage.isTyping = false
- aiMessage.content = aiReply // 保存完整内容
- clearInterval(typeInterval)
- console.log('打字完成,最终displayContent:', aiMessage.displayContent)
-
- // 强制触发Vue响应式更新,确保hasTypingMessage计算属性更新
- chatMessages.value = [...chatMessages.value]
- console.log('打字完成,强制更新响应式数据')
-
- // AI回复完成后,设置大纲内容并跳转到步骤2
- aiOutlineContent.value = aiMessage.displayContent
- // 提取大纲标题
- outlineTitle.value = extractOutlineTitleFromAI(aiReply) || '安全培训大纲'
- currentStep.value = 'step2'
-
- // 获取最新的历史记录
- getHistoryRecordList()
- }
- }, 50) // 每50ms显示一个字符
-
- } else {
- console.error('发送消息失败:', response)
- aiMessage.content = '抱歉,我暂时无法回答您的问题,请稍后再试。'
- aiMessage.displayContent = '抱歉,我暂时无法回答您的问题,请稍后再试。'
- aiMessage.isTyping = false
- chatMessages.value = [...chatMessages.value]
- }
- } catch (error) {
- console.error('发送消息失败:', error)
- aiMessage.content = '抱歉,网络连接出现问题,请检查网络后重试。'
- aiMessage.displayContent = '抱歉,网络连接出现问题,请检查网络后重试。'
- aiMessage.isTyping = false
- chatMessages.value = [...chatMessages.value]
- } finally {
- isSending.value = false
- }
- }
- // 滚动到底部
- const scrollToBottom = () => {
- nextTick(() => {
- const chatContainer = document.querySelector('.chat-messages')
- if (chatContainer) {
- chatContainer.scrollTop = chatContainer.scrollHeight
- }
- })
- }
- // 获取功能卡片数据
- const getFunctionCards = async () => {
- try {
- console.log('开始获取功能卡片...')
- const response = await apis.getFunctionCard({ function_type: 1 }) // 1为安全培训类型
- console.log('功能卡片响应:', response)
-
- if (response.statusCode === 200) {
- functionCards.value = response.data
- console.log('功能卡片数据已设置:', functionCards.value)
- } else {
- console.error('获取功能卡片失败:', response.statusCode)
- }
- } catch (error) {
- console.error('获取功能卡片失败:', error)
- }
- }
- // Toast显示函数
- const showToastMessage = (message, duration = 2000) => {
- showToast.value = false
- nextTick(() => {
- toastMessage.value = message
- toastDuration.value = duration
- showToast.value = true
- })
- }
- // 复制到剪贴板
- const copyToClipboard = async (text) => {
- try {
- await navigator.clipboard.writeText(text)
- showToastMessage('复制成功')
- } catch (error) {
- console.error('复制失败:', error)
- showToastMessage('复制失败')
- }
- }
- // 复制用户消息
- const copyUserMessage = (message) => {
- copyToClipboard(message.content)
- }
- // 复制AI消息
- const copyAIMessage = (message) => {
- let textToCopy = message.displayContent || message.content
-
- if (textToCopy && textToCopy.includes('<')) {
- const tempDiv = document.createElement('div')
- tempDiv.innerHTML = textToCopy
- textToCopy = tempDiv.textContent || tempDiv.innerText || textToCopy
- }
-
- copyToClipboard(textToCopy)
- }
- // 编辑用户消息
- const editUserMessage = (message) => {
- console.log('编辑用户消息:', message.content)
- messageText.value = message.content
-
- nextTick(() => {
- const inputElement = document.querySelector('.message-input')
- if (inputElement) {
- inputElement.focus()
- inputElement.setSelectionRange(inputElement.value.length, inputElement.value.length)
- }
- })
- }
- // 语音输入相关方法
- const handleVoiceClick = () => {
- console.log('点击语音按钮')
- if (!speechSupported.value) {
- showToastMessage('当前浏览器不支持语音识别')
- return
- }
- if (isListening.value) {
- // 如果正在录音,则停止
- stopVoiceInput()
- } else {
- // 开始语音输入
- startVoiceInput()
- }
- }
- const startVoiceInput = () => {
- console.log('开始语音输入')
-
- // 开始语音识别
- const success = startListening()
- if (!success) {
- showToastMessage('语音识别启动失败,请检查麦克风权限')
- }
- }
- const stopVoiceInput = () => {
- console.log('停止语音输入')
- stopListening()
-
- // 语音识别完成后,将结果填入输入框
- if (transcript.value.trim()) {
- messageText.value = transcript.value
- }
- }
- // 语音朗读相关方法
- const handleVoiceRead = (message) => {
- if (speakingMessageId.value === message.id) {
- stopSpeaking()
- speakingMessageId.value = null
- } else {
- if (speakingMessageId.value) {
- stopSpeaking()
- }
-
- const textToRead = message.displayContent || message.content
- if (textToRead && textToRead.trim()) {
- const cleanText = textToRead.replace(/<[^>]*>/g, '')
- speakingMessageId.value = message.id
- Promise.resolve(speakText(cleanText, { rate: 0.9 }))
- .finally(() => {
- if (speakingMessageId.value === message.id) {
- speakingMessageId.value = null
- }
- })
- }
- }
- }
- // 检查消息是否正在朗读
- const isSpeaking = (messageId) => {
- return speakingMessageId.value === messageId
- }
- // 重新生成AI回复
- const regenerateResponse = async (messageIndex) => {
- console.log('重新生成回复,消息索引:', messageIndex)
-
- if (messageIndex > 0) {
- const userMessage = chatMessages.value[messageIndex - 1]
- if (userMessage && userMessage.type === 'user') {
- console.log('重新发送用户消息:', userMessage.content)
-
- // 删除当前AI消息
- chatMessages.value.splice(messageIndex, 1)
-
- // 添加新的AI消息(初始状态为思考中)
- const aiMessage = {
- type: 'ai',
- content: '',
- displayContent: '',
- isTyping: true,
- id: Date.now() + 1,
- userFeedback: null
- }
- chatMessages.value.push(aiMessage)
-
- scrollToBottom()
-
- try {
- const response = await apis.sendDeepseekMessage({
- // ===== 已删除:user_id - 后端从token解析 =====
- ai_conversation_id: ai_conversation_id.value,
- message: userMessage.content,
- business_type: 1 // 安全培训类型
- })
-
- console.log('重新生成AI回复响应:', response)
-
- if (response.statusCode === 200) {
- if (response.data && response.data.ai_conversation_id) {
- ai_conversation_id.value = response.data.ai_conversation_id
- }
-
- // 设置AI消息ID(用于评价功能)
- if (response.data && response.data.ai_message_id) {
- currentAiMessageId.value = response.data.ai_message_id
- console.log('重新生成设置AI消息ID:', currentAiMessageId.value)
- }
-
- const aiReply = response.data ? response.data.reply : response.reply || ''
- console.log('重新生成AI回复内容:', aiReply)
-
- // 将Markdown格式转换为HTML格式
- const htmlReply = markdownToHtml(aiReply)
-
- // 开始打字效果 - 按完整HTML标签或文本块显示,避免标签分割
- const textBlocks = []
- let currentBlock = ''
- let inTag = false
- let tagContent = ''
-
- // 将HTML内容分割成完整的块
- for (let i = 0; i < htmlReply.length; i++) {
- const char = htmlReply[i]
-
- if (char === '<') {
- // 如果之前有文本内容,先保存
- if (currentBlock && !inTag) {
- textBlocks.push({ type: 'text', content: currentBlock })
- currentBlock = ''
- }
- inTag = true
- tagContent = char
- } else if (char === '>') {
- // 标签结束
- tagContent += char
- textBlocks.push({ type: 'tag', content: tagContent })
- inTag = false
- tagContent = ''
- } else if (inTag) {
- // 在标签内
- tagContent += char
- } else {
- // 普通文本
- currentBlock += char
- }
- }
-
- // 保存最后一个文本块
- if (currentBlock) {
- textBlocks.push({ type: 'text', content: currentBlock })
- }
-
- // 检查是否有未闭合的标签
- if (tagContent) {
- textBlocks.push({ type: 'tag', content: tagContent })
- }
-
- console.log('重新生成分割后的文本块:', textBlocks)
-
- let currentBlockIndex = 0
- let currentCharIndex = 0
-
- const typeInterval = setInterval(() => {
- if (currentBlockIndex < textBlocks.length) {
- const currentBlock = textBlocks[currentBlockIndex]
-
- if (currentBlock.type === 'tag') {
- // 标签直接显示,不分字符
- console.log('显示HTML标签:', currentBlock.content)
- aiMessage.displayContent += currentBlock.content
- currentBlockIndex++
- currentCharIndex = 0
- } else {
- // 文本按字符显示
- if (currentCharIndex < currentBlock.content.length) {
- const newContent = aiMessage.displayContent + currentBlock.content[currentCharIndex]
- aiMessage.displayContent = newContent
- currentCharIndex++
- } else {
- // 当前文本块完成,移动到下一个
- currentBlockIndex++
- currentCharIndex = 0
- }
- }
-
- // 强制触发Vue响应式更新
- chatMessages.value = [...chatMessages.value]
-
- // 如果用户在底部,自动滚动到底部
- scrollToBottom()
- } else {
- // 所有块都显示完成
- aiMessage.isTyping = false
- aiMessage.content = aiReply // 保存完整内容
- clearInterval(typeInterval)
- console.log('重新生成打字完成,最终displayContent:', aiMessage.displayContent)
-
- // 强制触发Vue响应式更新,确保hasTypingMessage计算属性更新
- chatMessages.value = [...chatMessages.value]
- console.log('重新生成打字完成,强制更新响应式数据')
-
- // AI回复完成后,设置大纲内容并跳转到步骤2
- aiOutlineContent.value = aiMessage.displayContent
- // 提取大纲标题
- outlineTitle.value = extractOutlineTitleFromAI(aiReply) || '安全培训大纲'
- currentStep.value = 'step2'
-
- getHistoryRecordList()
- }
- }, 50) // 每50ms显示一个字符
-
- } else {
- console.error('重新生成失败:', response)
- aiMessage.content = '抱歉,重新生成失败,请稍后再试。'
- aiMessage.displayContent = '抱歉,重新生成失败,请稍后再试。'
- aiMessage.isTyping = false
- chatMessages.value = [...chatMessages.value]
- }
- } catch (error) {
- console.error('重新生成失败:', error)
- aiMessage.content = '抱歉,网络连接出现问题,请检查网络后重试。'
- aiMessage.displayContent = '抱歉,网络连接出现问题,请检查网络后重试。'
- aiMessage.isTyping = false
- chatMessages.value = [...chatMessages.value]
- }
- }
- }
- }
- // 点赞和点踩功能
- const handleThumbsUp = async (message) => {
- console.log('点赞消息:', message.id)
-
- if (message.userFeedback === 'like') {
- message.userFeedback = null
- showToastMessage('已取消点赞')
- } else {
- message.userFeedback = 'like'
- showToastMessage('点赞成功')
- }
-
- chatMessages.value = [...chatMessages.value]
- }
- const handleThumbsDown = async (message) => {
- console.log('点踩消息:', message.id)
-
- if (message.userFeedback === 'dislike') {
- message.userFeedback = null
- showToastMessage('已取消点踩')
- } else {
- message.userFeedback = 'dislike'
- showToastMessage('点踩成功')
- }
-
- chatMessages.value = [...chatMessages.value]
- }
- // 大纲相关方法
- const getDisplayChapterTitle = (title, index) => {
- if (!title) return `第${index + 1}章`
- return title.includes('第') ? title : `第${index + 1}章 ${title}`
- }
- // 复制整个大纲(完全按PC端实现)
- // 复制整个大纲(按PC端逻辑处理所有层级)
- // 复制整个大纲
- const copyEntireOutline = async () => {
- try {
- if (!aiOutlineContent.value) {
- showToastMessage('暂无大纲内容可复制')
- return
- }
- // 构建大纲文本(纯文本格式,无井号)
- // 将HTML内容转换为带层级格式的文本
- const tempDiv = document.createElement('div')
- tempDiv.innerHTML = aiOutlineContent.value
-
- let outlineText = ''
-
- // 遍历所有文本节点,保持层级结构
- const walkTextNodes = (element, level = 0) => {
- for (const node of element.childNodes) {
- if (node.nodeType === Node.TEXT_NODE) {
- const text = node.textContent?.trim()
- if (text) {
- // 添加适当的缩进
- const indent = ' '.repeat(level)
- outlineText += `${indent}${text}\n`
- }
- } else if (node.nodeType === Node.ELEMENT_NODE) {
- const tagName = node.tagName?.toLowerCase()
-
- // 处理标题标签,添加额外空行
- if (['h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) {
- const content = node.textContent?.trim()
- if (content && !content.includes('安全培训大纲')) { // 避免重复标题
- outlineText += `${' '.repeat(level)}${content}\n\n`
- }
- continue // 不继续处理标题的子节点
- }
-
- // 递归处理其他元素
- walkTextNodes(node, level + 1)
- }
- }
- }
-
- // 开始遍历
- walkTextNodes(tempDiv)
-
- // 清理多余的空行和空白字符
- outlineText = outlineText
- .replace(/\n\s*\n\s*\n/g, '\n\n') // 最多保留两个连续换行
- .replace(/^\s+|\s+$/gm, '') // 移除每行首尾空白
- .replace(/\n\s*\n$/, '') // 移除末尾多余换行
- .trim()
-
- // 只添加一次标题
- const finalText = `${outlineTitle.value || '安全培训大纲'}\n\n${outlineText}`
- // outlineData处理已替换为HTML处理逻辑
- // 不添加统计信息,只复制纯大纲内容
- // 检查 Clipboard API 是否可用
- if (navigator.clipboard && navigator.clipboard.writeText && window.isSecureContext) {
- try {
- await navigator.clipboard.writeText(finalText)
- showToastMessage('复制成功')
- return
- } catch (clipboardError) {
- console.warn('Clipboard API 失败,使用降级方案:', clipboardError)
- }
- }
- // 降级方案:使用传统复制方法
- const textArea = document.createElement('textarea')
- textArea.value = finalText
- textArea.style.position = 'fixed'
- textArea.style.left = '-999999px'
- textArea.style.top = '-999999px'
- document.body.appendChild(textArea)
- textArea.focus()
- textArea.select()
- try {
- const successful = document.execCommand('copy')
- if (successful) {
- showToastMessage('大纲已复制到剪贴板')
- } else {
- throw new Error('execCommand 复制失败')
- }
- } catch (execError) {
- console.error('传统复制方法也失败:', execError)
- showToastMessage('复制失败,请手动选择文本复制')
- } finally {
- document.body.removeChild(textArea)
- }
- } catch (error) {
- console.error('复制大纲失败:', error)
- ElMessage.error('复制失败,请手动选择文本复制')
- }
- }
- // 下载大纲为Word文档
- const downloadOutlineAsWord = async () => {
- try {
- if (!aiOutlineContent.value) {
- showToastMessage('暂无大纲内容可下载')
- return
- }
- // 构建Word文档内容(HTML格式,兼容Microsoft Office Word)
- let htmlContent = `<!DOCTYPE html>
- <html xmlns:o="urn:schemas-microsoft-com:office:office"
- xmlns:w="urn:schemas-microsoft-com:office:word"
- xmlns="http://www.w3.org/TR/REC-html40">
- <head>
- <meta charset="utf-8">
- <meta name="ProgId" content="Word.Document">
- <meta name="Generator" content="Microsoft Word 15">
- <meta name="Originator" content="Microsoft Word 15">
- <title>${outlineTitle.value || '安全培训大纲'}</title>
- <!--[if gte mso 9]>
- <xml>
- <w:WordDocument>
- <w:View>Print</w:View>
- <w:Zoom>100</w:Zoom>
- <w:DoNotPromptForConvert/>
- <w:DoNotShowRevisions/>
- <w:DoNotPrintRevisions/>
- <w:DoNotShowComments/>
- <w:DoNotShowInsertionsAndDeletions/>
- <w:DoNotShowPropertyChanges/>
- <w:Compatibility>
- <w:BreakWrappedTables/>
- <w:SnapToGridInCell/>
- <w:WrapTextWithPunct/>
- <w:UseAsianBreakRules/>
- <w:DontGrowAutofit/>
- </w:Compatibility>
- </w:WordDocument>
- </xml>
- <![endif]-->
- <style>
- body {
- font-family: "Microsoft YaHei", Arial, sans-serif;
- font-size: 14px;
- line-height: 1.6;
- margin: 24px;
- color: #000;
- }
- .header {
- text-align: center;
- margin-bottom: 14px;
- }
- .outline-title {
- font-size: 24px;
- font-weight: bold;
- margin-bottom: 14px;
- color: #000;
- }
- h1, h2, h3, h4, h5, h6 {
- color: #000;
- font-weight: bold;
- font-family: "Microsoft YaHei", Arial, sans-serif;
- margin-top: 20px;
- margin-bottom: 15px;
- }
- h1 {
- font-size: 20px;
- border-bottom: 2px solid #000;
- padding-bottom: 10px;
- }
- h2 {
- font-size: 18px;
- margin-top: 20px;
- margin-bottom: 12px;
- }
- h3 {
- font-size: 16px;
- margin-top: 15px;
- margin-bottom: 8px;
- }
- h4 {
- font-size: 14px;
- margin-top: 12px;
- margin-bottom: 6px;
- }
- ul, li {
- color: #000;
- font-family: "Microsoft YaHei", Arial, sans-serif;
- }
- li {
- margin-bottom: 4px;
- }
- .stats {
- background: #f8f9fa;
- padding: 20px;
- border-radius: 8px;
- margin-top: 30px;
- border: 1px solid #ddd;
- }
- .stats h3 {
- color: #2c3e50;
- margin-top: 0;
- font-size: 16px;
- }
- .stats p {
- margin: 8px 0;
- color: #555;
- font-size: 14px;
- }
- </style>
- </head>
- <body>
- <div class="header">
- <div class="outline-title">${outlineTitle.value || '安全培训大纲'}</div>
- </div>
- `
- // 清理内容,移除重复的标题
- const tempDiv = document.createElement('div')
- tempDiv.innerHTML = aiOutlineContent.value
-
- // 移除所有h1标题(通常是主标题)
- const h1Elements = tempDiv.querySelectorAll('h1')
- h1Elements.forEach(h1 => {
- const content = h1.textContent?.trim()
- if (content && content.includes(outlineTitle.value || '安全培训大纲')) {
- h1.remove()
- }
- })
-
- // 清理空标签和多余的空白
- const cleanContent = tempDiv.innerHTML.replace(/<h[1-6][^>]*>\s*<\/h[1-6]>/gi, '')
-
- htmlContent += cleanContent
- // 不再添加统计信息,因为不再使用outlineStats
- htmlContent += `
- </body>
- </html>
- `
- // 创建Blob对象 - 使用Word兼容的MIME类型
- const blob = new Blob([htmlContent], { type: 'application/msword' })
- // 创建下载链接
- const url = URL.createObjectURL(blob)
- const link = document.createElement('a')
- link.href = url
- link.download = `${outlineTitle.value || '安全培训大纲'}.doc`
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
- // 清理URL对象
- URL.revokeObjectURL(url)
- showToastMessage('下载成功')
- } catch (error) {
- console.error('下载大纲失败:', error)
- showToastMessage('下载失败,请重试')
- }
- }
- const evaluateOutline = () => {
- console.log('大纲评价')
- showToastMessage('大纲评价功能开发中...')
- }
- const regenerateOutline = async () => {
- try {
- // 检查是否有当前对话ID
- if (!ai_conversation_id.value) {
- showToastMessage('请先开始一个对话')
- return
- }
- // 设置生成状态
- isGeneratingOutline.value = true
- // 获取当前大纲标题作为请求内容
- const currentTitle = outlineTitle.value || '安全培训大纲'
- const requestMessage = `${currentTitle}`
- console.log('开始生成新大纲:', requestMessage)
- console.log('ai_conversation_id:', ai_conversation_id.value)
- // 调用后端DeepSeek接口重新生成大纲
- const response = await apis.sendDeepseekMessage({
- // ===== 已删除:user_id - 后端从token解析 =====
- ai_conversation_id: ai_conversation_id.value,
- message: requestMessage,
- business_type: 1
- })
- console.log('重新生成大纲响应:', response)
- if (response.statusCode === 200) {
- const aiReply = response.data ? response.data.reply : response.reply || ''
- console.log('重新生成大纲内容:', aiReply)
-
- // 设置AI消息ID(用于评价功能)
- if (response.data && response.data.ai_message_id) {
- currentAiMessageId.value = response.data.ai_message_id
- console.log('重新生成大纲设置AI消息ID:', currentAiMessageId.value)
- }
-
- // 将Markdown格式转换为HTML格式
- const htmlReply = markdownToHtml(aiReply)
-
- // 更新大纲内容
- aiOutlineContent.value = htmlReply
-
- // 更新大纲标题
- const newTitle = extractOutlineTitleFromAI(aiReply) || '安全培训大纲'
- outlineTitle.value = newTitle
- console.log('重新生成大纲标题设置为:', newTitle)
-
- showToastMessage('新大纲生成成功!')
-
- // 重置评价状态
- outlineFeedback.value = null
- evaluation.value = ''
- } else {
- console.error('重新生成大纲失败:', response)
- showToastMessage('重新生成大纲失败,请重试')
- }
- } catch (error) {
- console.error('重新生成大纲失败:', error)
- showToastMessage('重新生成大纲失败,请重试')
- } finally {
- isGeneratingOutline.value = false
- }
- }
- const selectPptTemplate = () => {
- showToastMessage('请前往电脑端选择模版')
- }
- // 获取评价状态(从PC端移植)
- const getEvaluationStatus = () => {
- console.log('发出评价状态 - outlineFeedback:', outlineFeedback.value, 'evaluation:', evaluation.value)
-
- if (outlineFeedback.value !== null) {
- // 根据后端数据判断状态
- switch (outlineFeedback.value) {
- case 2: return 'like' // 满意
- case 3: return 'dislike' // 不满意
- case 0: return '' // 无反馈(取消评价)
- default: return '' // 无反馈
- }
- }
-
- return evaluation.value // 如果没有后端数据,使用本地状态
- }
- // 设置大纲评价(从PC端移植)
- const setEvaluation = async (value) => {
- try {
- console.log('设置评价:', value)
- // 检查当前状态,如果点击的是当前已激活的状态,则取消评价, 则取消评价
- const currentStatus = getEvaluationStatus()
- let feedbackValue
- if (currentStatus === value) {
- // 如果点击的是当前已激活的状态,取消评价
- feedbackValue = 0
- console.log('取消评价,发送0')
- } else {
- // 否则设置新的评价
- feedbackValue = value === 'like' ? 2 : 3
- console.log('设置新评价:', feedbackValue)
- }
- console.log('currentAiMessageId.value', currentAiMessageId.value)
-
- // 调用后端API保存评价
- const response = await apis.likeAndDislike({
- id: currentAiMessageId.value, // 使用当前的AI消息ID
- user_feedback: feedbackValue
- })
- if (response.statusCode === 200) {
- console.log('点评成功')
-
- // 更新本地状态
- if (feedbackValue === 0) {
- // 取消评价
- evaluation.value = ''
- outlineFeedback.value = 0
- showToastMessage('点评已取消')
- } else {
- // 设置新评价
- evaluation.value = value
- outlineFeedback.value = feedbackValue
- showToastMessage('点评成功')
- }
- } else {
- console.error('评价保存失败:', response)
- showToastMessage('评价保存失败,请重试')
- }
- } catch (error) {
- console.error('设置评价失败:', error)
- showToastMessage('评价设置失败,请重试')
- }
- }
- const handleOutlineThumbsUp = () => {
- setEvaluation('like')
- }
- const handleOutlineThumbsDown = () => {
- setEvaluation('dislike')
- }
- const addNewItem = (type, index) => {
- console.log('添加新项目:', type, index)
- showToastMessage('添加功能开发中...')
- }
- // 新建任务
- const createNewTask = () => {
- console.log('新建安全培训任务')
- showHistory.value = false
-
- // 重置所有状态
- currentStep.value = 'step1'
- showChat.value = false
- chatMessages.value = []
- ai_conversation_id.value = 0
- messageText.value = ''
- outlineTitle.value = ''
- outlineData.value = []
- aiOutlineContent.value = ''
- outlineFeedback.value = null
- evaluation.value = ''
- currentAiMessageId.value = null
-
- // 清除所有历史记录的选中状态
- historyData.value.forEach((item) => {
- item.isActive = false
- })
- }
- const consumeAutoMessage = async (message) => {
- const normalizedMessage = String(message || '').trim()
- if (!normalizedMessage) return
- createNewTask()
- await nextTick()
- messageText.value = normalizedMessage
- await sendMessage()
- }
- // 处理历史记录点击
- const handleHistoryItem = async (historyItem) => {
- if (historyItem.isActive) return
-
- console.log("点击移动端安全培训历史记录:", historyItem)
-
- // 设置当前点击的历史记录为激活状态
- historyData.value.forEach((item) => {
- item.isActive = item.id === historyItem.id
- })
-
- // 关闭历史记录抽屉
- showHistory.value = false
-
- // 切换到步骤2(培训大纲)
- showChat.value = true
- currentStep.value = 'step2'
-
- // 设置当前对话ID
- ai_conversation_id.value = historyItem.id
-
- // 加载历史对话详情
- console.log('开始加载历史对话详情,conversation_id:', historyItem.id)
- const response = await apis.getHistoryRecord({
- // ===== 已删除:user_id - 后端从token解析 =====
- ai_conversation_id: historyItem.id,
- business_type: 1 // 安全培训类型
- })
-
- console.log('历史对话详情响应:', response)
-
- if (response.statusCode === 200 && response.data && response.data.length > 0) {
- // 清空当前聊天消息
- chatMessages.value = []
-
- console.log('历史对话数据详情:', response.data)
-
- // 转换历史消息为聊天消息格式
- response.data.forEach((message, index) => {
- console.log(`处理第${index + 1}条消息:`, message)
-
- const messageType = message.type
-
- if (messageType === 'user') {
- // 用户消息
- console.log('添加用户消息:', message.content)
- chatMessages.value.push({
- type: 'user',
- content: message.content,
- id: message.id || Date.now() + index,
- timestamp: message.created_at || message.timestamp
- })
- } else if (messageType === 'ai') {
- // AI消息
- console.log('添加AI消息:', message.content)
- const aiMessage = {
- type: 'ai',
- content: message.content,
- displayContent: markdownToHtml(message.content), // 转换为HTML显示
- isTyping: false,
- id: message.id || Date.now() + index + 1000,
- timestamp: message.created_at || message.timestamp,
- userFeedback: null
- }
- chatMessages.value.push(aiMessage)
-
- // 设置大纲内容(取最后一条AI消息作为大纲内容)
- if (index === response.data.length - 1) {
- aiOutlineContent.value = aiMessage.displayContent
- // 提取大纲标题
- outlineTitle.value = extractOutlineTitleFromAI(message.content) || '安全培训大纲'
- console.log('历史记录大纲标题设置为:', outlineTitle.value)
-
- // 设置AI消息ID和评价状态(从历史记录中获取)
- if (message.id) {
- currentAiMessageId.value = message.id
- console.log('历史记录设置AI消息ID:', currentAiMessageId.value)
- }
-
- // 从历史记录中设置评价状态
- if (message.user_feedback !== undefined) {
- outlineFeedback.value = message.user_feedback
- console.log('历史记录设置评价状态:', outlineFeedback.value)
- }
- }
- } else {
- console.log('未知消息类型,跳过:', messageType, message)
- }
- })
-
- console.log('历史对话加载完成,消息数量:', chatMessages.value.length)
-
- // 滚动到底部
- nextTick(() => {
- scrollToBottom()
- })
- } else {
- console.error('加载历史对话失败或无数据:', response)
- chatMessages.value = []
- }
- }
- // 删除历史记录
- const deleteHistoryItem = async (historyItem, index) => {
- try {
- console.log('开始删除移动端历史记录:', historyItem)
-
- const response = await apis.deleteHistoryRecord({
- // ===== 已删除:user_id - 后端从token解析 =====
- ai_conversation_id: historyItem.id
- })
-
- if (response.statusCode === 200) {
- // 从本地数据中移除
- historyData.value.splice(index, 1)
- historyTotal.value = Math.max(0, historyTotal.value - 1)
-
- // 如果删除的是当前激活的历史记录,执行新建任务
- if (historyItem.isActive) {
- console.log('删除激活的历史记录,执行新建任务')
- createNewTask()
- }
-
- console.log('✅ 移动端历史记录删除成功')
- showToastMessage('删除成功')
- } else {
- console.error('❌ 删除移动端历史记录失败:', response)
- }
- } catch (error) {
- console.error('❌ 删除移动端历史记录失败:', error)
- }
- }
- // 统一的确认删除函数
- const confirmDelete = async () => {
- if (!deleteTargetItem.value) return
-
- if (deleteType.value === 'history') {
- await confirmDeleteHistory()
- } else if (deleteType.value === 'message') {
- await confirmDeleteMessage()
- }
- }
- // 确认删除历史记录
- const confirmDeleteHistory = async () => {
- const { item: historyItem, index } = deleteTargetItem.value
-
- try {
- const response = await apis.deleteHistoryRecord({
- ai_conversation_id: historyItem.id
- })
-
- if (response.statusCode === 200) {
- historyData.value.splice(index, 1)
-
- if (historyItem.isActive) {
- chatMessages.value = []
- ai_conversation_id.value = 0
- showChat.value = false
- currentStep.value = 'step1'
- }
-
- console.log('历史记录删除成功')
- showToastMessage('删除成功')
- } else {
- console.error('删除历史记录失败:', response.msg)
- showToastMessage(response.msg || '删除失败')
- }
- } catch (error) {
- console.error('删除历史记录失败:', error)
- showToastMessage('删除失败,请稍后重试')
- } finally {
- showDeleteModal.value = false
- deleteTargetItem.value = null
- deleteType.value = ''
- }
- }
- // 确认删除消息
- const confirmDeleteMessage = async () => {
- const { messageIndex } = deleteTargetItem.value
-
- try {
- const aiMessage = chatMessages.value[messageIndex]
-
- if (aiMessage && aiMessage.id) {
- try {
- const response = await apis.deleteConversation({
- ai_message_id: aiMessage.id
- })
-
- if (response.statusCode === 200) {
- chatMessages.value.splice(messageIndex, 1)
- if (messageIndex > 0) {
- chatMessages.value.splice(messageIndex - 1, 1)
- }
-
- console.log('删除成功')
- showToastMessage('删除成功')
- } else {
- console.error('删除失败:', response.msg)
- showToastMessage('删除失败,请稍后重试')
- }
- } catch (error) {
- console.error('删除接口调用失败:', error)
- showToastMessage('删除失败,请稍后重试')
- }
- } else {
- console.log('没有id,仅从前端删除')
- chatMessages.value.splice(messageIndex, 1)
- showToastMessage('删除成功')
- }
- } catch (error) {
- console.error('删除消息失败:', error)
- showToastMessage('删除失败,请稍后重试')
- } finally {
- showDeleteModal.value = false
- deleteTargetItem.value = null
- deleteType.value = ''
- }
- }
- // 取消删除
- const cancelDelete = () => {
- showDeleteModal.value = false
- deleteTargetItem.value = null
- deleteType.value = ''
- }
- // 页面加载时不再自动加载历史记录,改为点击菜单时加载
- onMounted(async () => {
- try {
- console.log('🚀 移动端安全培训页面初始化,加载功能卡片...')
-
- // 初始化原生导航栏(子页面模式:返回按钮执行路由后退)
- initNativeNavForSubPage(() => router.back())
-
- await getFunctionCards()
- const autoMessage = route.query.autoMessage
- if (autoMessage) {
- router.replace({
- path: route.path,
- query: { ...route.query, autoMessage: undefined }
- })
- await consumeAutoMessage(autoMessage)
- }
- console.log('✅ 移动端安全培训页面初始化完成')
- } catch (error) {
- console.error('❌ 移动端安全培训页面初始化失败:', error)
- }
- })
- // 组件销毁前,强制停止任何朗读
- onBeforeUnmount(() => {
- if (speakingMessageId.value) {
- stopSpeaking()
- speakingMessageId.value = null
- }
- })
- // 监听历史记录抽屉显示状态,显示时加载数据
- watch(showHistory, async (newVal) => {
- if (newVal && historyData.value.length === 0) {
- console.log('📋 历史记录抽屉打开,开始加载数据...')
- await getHistoryRecordList()
- }
- })
- // 监听语音识别结果
- watch(transcript, (newVal) => {
- if (!newVal || isListening.value) return
- messageText.value = newVal
- })
- // 监听语音识别错误
- watch(speechError, (newVal) => {
- if (newVal) {
- console.error('语音识别错误:', newVal)
- showToastMessage(newVal)
- }
- })
- </script>
- <style lang="less" scoped>
- .mobile-safety-hazard {
- min-height: 100vh;
- background: #EBF3FF;
- font-family: "Alibaba PuHuiTi 3.0", sans-serif;
- overflow-x: hidden;
- -webkit-overflow-scrolling: touch;
- touch-action: manipulation;
- }
- .mobile-content {
- padding: 20px;
- text-align: center;
- position: relative;
- padding-bottom: 80px;
- }
- /* AI助手介绍 */
- .ai-intro {
- display: flex;
- flex-direction: column;
- align-items: center;
- margin-bottom: 30px;
-
- .ai-avatar {
- width: 60px;
- height: 60px;
- border-radius: 15px;
- margin-bottom: 12px;
-
- .ai-avatar-img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- border-radius: 15px;
- }
- }
-
- .ai-greeting {
- text-align: center;
-
- h3 {
- font-size: 18px;
- font-weight: 600;
- color: #1f2937;
- margin: 0 0 8px 0;
- }
-
- p {
- font-size: 14px;
- color: #6b7280;
- margin: 0;
- }
- }
- }
- /* 功能卡片 */
- .function-cards {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 12px;
- margin-top: 30px;
-
- .function-card {
- background: white;
- padding: 16px;
- border-radius: 12px;
- border: 1px solid #E5E8EB;
- cursor: pointer;
- transition: all 0.3s ease;
- display: flex;
- flex-direction: column;
- justify-content: center;
- text-align: left;
-
- &:hover {
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
- transform: translateY(-2px);
- }
-
- .card-header {
- display: flex;
- align-items: center;
- margin-bottom: 8px;
-
- .card-icon {
- width: 32px;
- height: 32px;
- margin-right: 12px;
-
- .card-icon-img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
- }
-
- h4 {
- font-size: 16px;
- font-weight: 600;
- color: #1f2937;
- margin: 0;
- flex: 1;
- }
- }
-
- .card-description {
- p {
- font-size: 14px;
- color: #6b7280;
- margin: 0;
- line-height: 1.4;
- display: -webkit-box;
- -webkit-line-clamp: 1;
- line-clamp: 1;
- -webkit-box-orient: vertical;
- overflow: hidden;
- }
- }
- }
- }
- /* 聊天消息区域 */
- .chat-messages {
- max-height: calc(100vh - 180px);
- overflow-y: auto;
- padding: 20px 0;
- margin-bottom: 10px;
-
- .message-item {
- margin-bottom: 20px;
-
- &.user {
- display: flex;
- justify-content: flex-end;
-
- .user-message {
- background: #3e7bfa;
- color: white;
- padding: 12px 16px;
- border-radius: 18px 18px 4px 18px;
- max-width: 80%;
- text-align: left;
-
- .message-content {
- .message-text {
- font-size: 20px;
- line-height: 1.4;
- word-wrap: break-word;
- }
- }
-
- .message-actions {
- margin-top: 8px;
- display: flex;
- gap: 8px;
- justify-content: flex-end;
-
- .action-btn {
- background: none;
- border: none;
- color: white;
- padding: 4px;
- border-radius: 4px;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.2s ease;
-
- .action-icon {
- width: 20px;
- height: 20px;
- object-fit: contain;
- filter: brightness(0) invert(1);
- }
- }
- }
- }
- }
- }
- }
- /* AI消息样式 */
- .ai-message {
- display: flex;
- gap: 12px;
-
- .ai-avatar-small {
- width: 32px;
- height: 32px;
- flex-shrink: 0;
-
- .ai-icon {
- width: 100%;
- height: 100%;
- object-fit: contain;
- }
- }
-
- .message-content {
- background: white;
- color: #374151;
- padding: 12px 16px;
- border-radius: 0px 18px 18px 18px;
- max-width: calc(100vw - 120px);
- width: fit-content;
- min-width: 120px;
- word-wrap: break-word;
- line-height: 1.5;
- font-size: 14px;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
- white-space: pre-line;
- overflow-wrap: break-word;
- word-break: break-word;
- text-align: left;
-
- .ai-text {
- min-height: 20px;
- line-height: 1.5;
-
- .typing-indicator {
- color: #9CA3AF;
- font-style: italic;
- font-size: 14px;
- display: flex;
- align-items: center;
- gap: 8px;
- white-space: normal;
- word-wrap: break-word;
- overflow-wrap: break-word;
-
- .thinking-animation {
- .dot {
- display: inline-block;
- width: 4px;
- height: 4px;
- border-radius: 50%;
- background: #9CA3AF;
- margin: 0 1px;
- animation: thinking 1.4s infinite ease-in-out;
-
- &:nth-child(1) { animation-delay: -0.32s; }
- &:nth-child(2) { animation-delay: -0.16s; }
- }
- }
- }
-
- .ai-content {
- font-size: 14px;
- line-height: 1.5;
- color: #374151;
- word-wrap: break-word;
- overflow-wrap: break-word;
- word-break: break-word;
- text-align: left;
- }
- }
-
- .divider {
- width: 100%;
- height: 1px;
- background: #E5E7EB;
- margin: 8px 0;
- }
-
- .message-actions {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-top: 8px;
-
- .left-actions {
- display: flex;
- gap: 8px;
- flex-wrap: wrap;
- }
-
- .right-actions {
- display: flex;
- gap: 4px;
- }
-
- .action-btn {
- background: transparent;
- border: none;
- color: #6B7280;
- padding: 6px;
- border-radius: 4px;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.3s ease;
-
- &:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
-
- .action-icon {
- width: 20px;
- height: 20px;
- object-fit: contain;
- }
-
- &.thumbs-up-btn {
- transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
-
- &.active {
- .action-icon {
- filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(142deg) brightness(104%) contrast(97%);
- }
- }
- }
-
- &.thumbs-down-btn {
- transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
-
- &.active {
- .action-icon {
- filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(0deg) brightness(104%) contrast(97%);
- }
- }
- }
- }
- }
- }
- }
- /* 底部输入区域 */
- .chat-input-section {
- position: fixed;
- bottom: 20px;
- left: 0;
- right: 0;
- background: #EBF3FF;
- padding: 8px 20px;
- z-index: 1;
- transform: translateZ(0);
- -webkit-transform: translateZ(0);
- will-change: transform;
-
- .input-container {
- max-width: 100%;
-
- .input-box {
- display: flex;
- align-items: center;
- gap: 12px;
- background: white;
- border-radius: 16px;
- padding: 8px 20px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- transition: box-shadow 0.3s ease;
- border: 1px solid #3E7BFA;
- height: 57px;
- transform: translateZ(0);
- -webkit-transform: translateZ(0);
- will-change: transform;
-
- &:focus-within {
- box-shadow: 0 2px 12px rgba(62, 123, 250, 0.2);
- }
-
- .message-input {
- flex: 1;
- border: none;
- background: transparent;
- font-size: 16px !important;
- color: #2C3E50;
- outline: none;
- transition: opacity 0.3s ease;
- line-height: 1.4 !important;
- height: auto !important;
-
- &::placeholder {
- color: #A0A6B8;
- font-size: 16px !important;
- }
-
- &:disabled {
- cursor: not-allowed;
- }
- }
-
- .divider {
- width: 1px;
- height: 31px;
- background-color: #D6D5DE;
- margin: 0 4px;
- }
-
- .voice-btn {
- background: none;
- border: none;
- cursor: pointer;
- padding: 8px;
- border-radius: 6px;
- transition: all 0.3s ease;
- display: flex;
- align-items: center;
- justify-content: center;
- position: relative;
-
- &:hover:not(:disabled) {
- background: rgba(102, 126, 234, 0.1);
- }
-
- &:disabled {
- cursor: not-allowed;
- opacity: 0.5;
- }
-
- &.recording {
- background: rgba(239, 68, 68, 0.1);
- animation: pulse 1.5s ease-in-out infinite;
- }
-
- .icon-container {
- width: 20px;
- height: 20px;
- display: flex;
- align-items: center;
- justify-content: center;
- position: relative;
-
- .action-icon {
- width: 20px;
- height: 20px;
- object-fit: contain;
- }
-
- .recording-indicator {
- position: absolute;
- top: -2px;
- right: -2px;
- width: 8px;
- height: 8px;
- background: #ef4444;
- border-radius: 50%;
- animation: blink 1s ease-in-out infinite;
- }
- }
- }
-
- .send-btn {
- background: none;
- border: none;
- cursor: pointer;
- border-radius: 6px;
- transition: background 0.3s ease;
- display: flex;
- align-items: center;
- justify-content: center;
-
- &:hover:not(:disabled) {
- background: rgba(102, 126, 234, 0.1);
- }
-
- &:disabled {
- cursor: not-allowed;
- }
-
- .send-icon {
- width: 90px;
- height: 40px;
- object-fit: contain;
- }
- }
- }
- }
- }
- /* 步骤2:培训大纲样式 */
- .step2-content {
- .loading-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(255, 255, 255, 0.9);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 1000;
-
- .loading-content {
- text-align: center;
-
- .loading-spinner {
- width: 40px;
- height: 40px;
- border: 4px solid #f3f3f3;
- border-top: 4px solid #3e7bfa;
- border-radius: 50%;
- animation: spin 1s linear infinite;
- margin: 0 auto 16px;
- }
-
- .loading-text {
- font-size: 16px;
- color: #374151;
- margin-bottom: 8px;
- }
-
- .loading-subtitle {
- font-size: 14px;
- color: #6b7280;
- }
- }
- }
-
- .outline-container {
- background: white;
- border-radius: 12px;
- padding: 20px;
- padding-bottom: 80px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- position: relative;
-
- &.disabled {
- pointer-events: none;
- opacity: 0.6;
- }
-
- .outline-header {
- position: relative;
- margin-bottom: 20px;
- padding-bottom: 16px;
- border-bottom: 1px solid #E5E7EB;
-
- .outline-top-right {
- display: flex;
- gap: 8px;
- justify-content: flex-end;
-
- .action-btn {
- display: flex;
- align-items: center;
- gap: 8px;
- color: #374151;
- font-size: 14px;
- cursor: pointer;
- transition: all 0.3s ease;
- background: none;
- border: none;
- padding: 8px 12px;
- border-radius: 4px;
-
- &:hover {
- background: #F3F4F6;
- }
-
- &:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
-
- .action-icon {
- width: 16px;
- height: 16px;
- }
-
- &.exam-btn {
- color: #22B850;
- font-weight: 500;
-
- &:hover {
- background: #F3F4F6;
- }
- }
- }
- }
- }
-
- .outline-bottom-left {
- position: absolute;
- bottom: 20px;
- left: 10px;
- display: flex;
-
-
- .action-btn {
- display: flex;
- align-items: center;
- gap: 8px;
- color: #374151;
- font-size: 14px;
- cursor: pointer;
- transition: all 0.3s ease;
- background: none;
- border: none;
- padding: 8px 12px;
- border-radius: 4px;
-
- &:hover {
- background: #F3F4F6;
- }
-
- &:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
-
- .action-icon {
- width: 16px;
- height: 16px;
- }
-
- &.regenerate-btn {
- color: #374151;
-
- .action-icon.rotating {
- animation: rotate 1s linear infinite;
- }
- }
- }
- }
-
- .outline-bottom-right {
- position: absolute;
- bottom: 20px;
- right: 10px;
- display: flex;
- // gap: 20px;
-
- .action-btn {
- display: flex;
- align-items: center;
- gap: 8px;
- color: #374151;
- font-size: 14px;
- cursor: pointer;
- transition: all 0.3s ease;
- background: none;
- border: none;
- padding: 8px 12px;
- border-radius: 4px;
-
- &.like-btn.active {
- color: #10B981;
- font-weight: 500;
-
- .action-icon {
- filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(142deg) brightness(104%) contrast(97%);
- }
- }
-
- &.dislike-btn.active {
- color: #EF4444;
- font-weight: 500;
-
- .action-icon {
- filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(0deg) brightness(104%) contrast(97%);
- }
- }
-
- &:hover {
- background: #F3F4F6;
- }
-
- &:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
-
- .action-icon {
- width: 16px;
- height: 16px;
- }
- }
- }
-
- .outline-content {
- position: relative;
-
- &.disabled {
- pointer-events: none;
- opacity: 0.6;
- }
-
- .generating-overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(255, 255, 255, 0.9);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 10;
-
- .generating-content {
- text-align: center;
-
- p {
- font-size: 14px;
- color: #6b7280;
- margin: 0;
- }
- }
- }
-
- .outline-content-scrollable {
- max-height: calc(100vh - 300px);
- overflow-y: auto;
-
- .outline-chapter {
- margin-bottom: 24px;
-
- .chapter-header {
- margin-bottom: 16px;
-
- .chapter-title {
- font-size: 16px;
- font-weight: 600;
- color: #1f2937;
- margin: 0;
- padding: 12px 16px;
- background: #f8fafc;
- border-left: 4px solid #3e7bfa;
- border-radius: 0 8px 8px 0;
- }
- }
-
- .outline-section {
- .section-container {
- margin-bottom: 16px;
-
- .section-header {
- margin-bottom: 8px;
-
- .section-title {
- font-size: 14px;
- font-weight: 500;
- color: #374151;
- margin: 0;
- padding: 8px 12px;
- background: #f1f5f9;
- border-radius: 6px;
- }
- }
-
- .section-subsection {
- margin-left: 16px;
-
- .subsection-container {
- margin-bottom: 12px;
-
- .subsection-header {
- margin-bottom: 6px;
-
- .subsection-title {
- font-size: 13px;
- color: #6b7280;
- margin: 0;
- padding: 6px 10px;
- background: #f9fafb;
- border-radius: 4px;
- }
- }
-
- .subsubsection-container {
- margin-left: 12px;
-
- .subsubsection-item {
- margin-bottom: 8px;
-
- .subsubsection-header {
- .subsubsection-title {
- font-size: 12px;
- color: #9ca3af;
- margin: 0;
- padding: 4px 8px;
- background: #ffffff;
- border: 1px solid #e5e7eb;
- border-radius: 4px;
- }
- }
- }
- }
- }
- }
- }
- }
- }
-
- .add-chapter-container {
- text-align: center;
- margin-top: 20px;
-
- .add-chapter-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- padding: 12px 20px;
- background: #f8fafc;
- border: 2px dashed #cbd5e1;
- border-radius: 8px;
- color: #64748b;
- font-size: 14px;
- cursor: pointer;
- transition: all 0.2s ease;
-
- &:hover {
- background: #e2e8f0;
- border-color: #94a3b8;
- }
-
- .add-icon {
- width: 16px;
- height: 16px;
- object-fit: contain;
- }
- }
- }
- }
-
- .ai-outline-content {
- .ai-outline-scrollable {
- max-height: calc(100vh - 280px);
- overflow-y: auto;
- text-align: left;
- line-height: 1.6;
- font-size: 14px;
- color: #374151;
- padding: 8px 0;
-
- h1, h2, h3, h4, h5, h6 {
- margin: 16px 0 8px 0;
- font-weight: 600;
- color: #1f2937;
- }
-
- h1 {
- font-size: 18px;
- border-bottom: 2px solid #3e7bfa;
- padding-bottom: 8px;
- }
-
- h2 {
- font-size: 16px;
- color: #3e7bfa;
- }
-
- h3 {
- font-size: 15px;
- }
-
- h4 {
- font-size: 14px;
- }
-
- p {
- margin: 8px 0;
- line-height: 1.6;
- }
-
- ul, ol {
- margin: 8px 0;
- padding-left: 20px;
- }
-
- li {
- margin: 4px 0;
- }
-
- strong {
- font-weight: 600;
- color: #1f2937;
- }
-
- em {
- font-style: italic;
- color: #6b7280;
- }
-
- code {
- background: #f3f4f6;
- padding: 2px 6px;
- border-radius: 4px;
- font-family: 'Courier New', monospace;
- font-size: 13px;
- }
- }
- }
-
- .default-outline {
- .outline-chapter {
- margin-bottom: 20px;
-
- h4 {
- font-size: 16px;
- font-weight: 600;
- color: #1f2937;
- margin: 0 0 12px 0;
- padding: 12px 16px;
- background: #f8fafc;
- border-left: 4px solid #3e7bfa;
- border-radius: 0 8px 8px 0;
- }
-
- .outline-section {
- .section-item {
- font-size: 14px;
- color: #374151;
- margin: 0 0 8px 16px;
- padding: 6px 0;
- }
-
- .section-subsection {
- margin-left: 16px;
-
- .subsection-item {
- font-size: 13px;
- color: #6b7280;
- margin: 0 0 6px 16px;
- padding: 4px 0;
- }
- }
- }
- }
- }
- }
- }
- }
- /* 思考动画 */
- @keyframes thinking {
- 0%, 80%, 100% {
- transform: scale(0);
- }
- 40% {
- transform: scale(1);
- }
- }
- @keyframes spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
- }
- @keyframes rotate {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
- }
- /* 语音输入动画 */
- @keyframes pulse {
- 0% {
- transform: scale(1);
- }
- 50% {
- transform: scale(1.05);
- }
- 100% {
- transform: scale(1);
- }
- }
- @keyframes blink {
- 0%, 50% {
- opacity: 1;
- }
- 51%, 100% {
- opacity: 0.3;
- }
- }
- /* 旋转动画 */
- .rotating {
- animation: rotate 1s linear infinite;
- }
- /* 全屏遮罩层样式 */
- .generating-overlay-full {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(255, 255, 255, 0.95);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 100;
- border-radius: 12px;
-
- .generating-content {
- text-align: center;
-
- .loading-spinner-small {
- width: 30px;
- height: 30px;
- border: 3px solid #f3f3f3;
- border-top: 3px solid #3e7bfa;
- border-radius: 50%;
- animation: spin 1s linear infinite;
- margin: 0 auto 12px;
- }
-
- p {
- font-size: 14px;
- color: #6b7280;
- margin: 0;
- }
- }
- }
- </style>
|