m-HazardDetection.vue 86 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955
  1. <template>
  2. <div class="mobile-hazard-detection">
  3. <!-- 移动端隐患提示页面 -->
  4. <MobileHeader title="隐患提示" @back="goBack" @menu="showHistoryDrawer" />
  5. <div class="mobile-content">
  6. <!-- 通用历史记录抽屉 -->
  7. <MobileHistoryDrawer
  8. :visible="!isIdentifying && showHistory"
  9. title="历史记录"
  10. :historyData="historyData"
  11. :loading="isLoadingHistory"
  12. @close="showHistory = false"
  13. @createNewTask="createNewTask"
  14. @handleHistoryItem="handleHistoryItem"
  15. @deleteHistoryItem="deleteHistoryItem"
  16. />
  17. <!-- 主界面:隐患提示系统 -->
  18. <div v-if="currentView === 'main'" class="main-layout">
  19. <!-- 使用流程选项卡 -->
  20. <div class="process-tabs">
  21. <div class="tab-item" :class="{ active: activeMode === 'detect' }" @click="activeMode = 'detect'">
  22. <span>智能识别</span>
  23. </div>
  24. <div class="tab-item" :class="{ active: activeMode === 'process' }" @click="activeMode = 'process'">
  25. <span>使用流程</span>
  26. </div>
  27. </div>
  28. <!-- 智能识别内容 -->
  29. <div v-if="activeMode === 'detect'" class="hazard-system">
  30. <div class="system-header">
  31. <h3>智能隐患提示系统</h3>
  32. <p>基于AI技术的工程安全智能隐患提示系统,实时检测分析,提供专业评估和预防建议</p>
  33. </div>
  34. <!-- 步骤一:选择场景 -->
  35. <div class="step-section">
  36. <h4>步骤一:选择场景</h4>
  37. <p class="step-description">请先选择您要识别的工程场景</p>
  38. <div class="scenario-tags">
  39. <div
  40. v-for="(scenario, key) in scenarios"
  41. :key="key"
  42. :class="['scenario-tag', {
  43. active: selectedScenario === key,
  44. disabled: key !== 'gas_station' && key !== 'simple_supported_bridge' && key !== 'tunnel' && key !== 'special_equipment' && key !== 'operate_highway',
  45. 'identifying-disabled': isIdentifying,
  46. 'compact': key === 'operate_highway'
  47. }]"
  48. @click="!isIdentifying && (key === 'gas_station' || key === 'simple_supported_bridge' || key === 'tunnel' || key === 'special_equipment' || key === 'operate_highway') ? selectScenario(key) : null"
  49. >
  50. {{ scenario.name }}
  51. </div>
  52. </div>
  53. </div>
  54. <!-- 步骤二:上传图片 -->
  55. <div class="step-section">
  56. <h4>步骤二:上传需要识别的场景图片</h4>
  57. <div
  58. class="upload-area"
  59. @click="triggerFileUpload"
  60. :class="{ 'drag-over': isDragOver }"
  61. >
  62. <!-- 显示上传的图片 -->
  63. <div v-if="uploadedImageUrl" class="uploaded-image-container">
  64. <img :src="uploadedImageUrl" alt="已上传的图片" class="uploaded-image" />
  65. <div class="image-overlay">
  66. <button class="change-image-btn" @click.stop="reselectImage">更换图片</button>
  67. </div>
  68. </div>
  69. <!-- 显示上传区域 -->
  70. <div v-else class="upload-content">
  71. <img src="@/assets/Hazard/5.png" alt="上传图标" class="upload-icon" />
  72. <p class="upload-text">点击上传图片</p>
  73. <p class="upload-format">支持JPG、PNG格式</p>
  74. <button class="select-file-btn" @click.stop="triggerFileUpload">选择图片文件</button>
  75. </div>
  76. <!-- 上传状态指示器 -->
  77. <div v-if="isUploading" class="upload-status">
  78. <div class="loading-spinner"></div>
  79. <p>正在上传...</p>
  80. </div>
  81. <!-- 识别状态指示器 -->
  82. <div v-if="isIdentifying" class="upload-status">
  83. <div class="loading-spinner"></div>
  84. <p>正在识别隐患...</p>
  85. </div>
  86. <input
  87. ref="fileInput"
  88. type="file"
  89. accept="image/*"
  90. @change="handleFileUpload"
  91. style="display: none"
  92. />
  93. </div>
  94. </div>
  95. <!-- 开始识别按钮 -->
  96. <div class="action-section">
  97. <button class="start-identify-btn" @click="startIdentification" :disabled="isIdentifying" :class="{ 'btn-disabled': isIdentifying }">
  98. <img :src="uploadedImageUrl ? startIdentifyActiveImg : startIdentifyImg" alt="开始识别" class="btn-bg" />
  99. </button>
  100. </div>
  101. </div>
  102. <!-- 使用流程内容 -->
  103. <div v-if="activeMode === 'process'" class="process-content">
  104. <div class="process-header">
  105. <h3>使用流程</h3>
  106. <p>了解如何使用智能隐患识别系统</p>
  107. </div>
  108. <!-- 流程概述 -->
  109. <div class="process-steps">
  110. <div class="step-item">
  111. <div class="step-number">1</div>
  112. <div class="step-text">
  113. <h4>选择场景</h4>
  114. <p>从支持的五种工程场景中选择您要检测的场景类型</p>
  115. </div>
  116. </div>
  117. <div class="step-item">
  118. <div class="step-number">2</div>
  119. <div class="step-text">
  120. <h4>上传图片</h4>
  121. <p>上传您要识别的场景图片,支持JPG、PNG格式</p>
  122. </div>
  123. </div>
  124. <div class="step-item">
  125. <div class="step-number">3</div>
  126. <div class="step-text">
  127. <h4>开始识别</h4>
  128. <p>点击开始识别按钮,系统将自动检测场景中的隐患</p>
  129. </div>
  130. </div>
  131. <div class="step-item">
  132. <div class="step-number">4</div>
  133. <div class="step-text">
  134. <h4>查看结果</h4>
  135. <p>识别完成后可查看详细的分析结果和隐患列表</p>
  136. </div>
  137. </div>
  138. </div>
  139. <!-- 流程示意图 -->
  140. <!-- <div class="process-image-section">
  141. <img src="@/assets/Hazard/4.png" alt="使用流程" class="process-image" />
  142. </div> -->
  143. </div>
  144. </div>
  145. <!-- 详情页:隐患提示结果 -->
  146. <div v-if="currentView === 'detail'" class="detail-layout">
  147. <!-- 顶部标题栏 -->
  148. <div class="detail-header">
  149. <div class="header-left">
  150. <img src="@/assets/Hazard/6.png" alt="顶部图标" class="header-icon" />
  151. <div class="header-text">
  152. <span class="main-title">{{ detectionResult?.scene_name ? scenarios[detectionResult.scene_name]?.name : '隐患提示结果' }}</span>
  153. <span :class="['sub-title-tag', getTagClass(detectionResult?.scene_name)]">{{ detectionResult?.scene_name ? scenarios[detectionResult.scene_name]?.name : '未知场景' }}</span>
  154. </div>
  155. </div>
  156. <div class="header-right">
  157. <div class="current-time">{{ selectedHistoryItem?.time || getCurrentTime() }}</div>
  158. </div>
  159. </div>
  160. <!-- 主要内容区域 -->
  161. <div class="detail-content">
  162. <!-- 加载状态 -->
  163. <div v-if="isLoadingDetail || isImageLoading" class="loading-overlay">
  164. <div class="loading-spinner"></div>
  165. <p>{{ isLoadingDetail ? '正在加载详情...' : '正在加载图片...' }}</p>
  166. </div>
  167. <!-- 图片显示区域 -->
  168. <div class="image-section">
  169. <!-- 点评状态 -->
  170. <div class="evaluation-status" @click="openEvaluationModal">
  171. <span :class="['status-badge', selectedHistoryItem?.effect_evaluation > 0 ? 'evaluated' : 'not-evaluated']">
  172. {{ selectedHistoryItem?.effect_evaluation > 0 ? '已点评' : '未点评' }}
  173. </span>
  174. </div>
  175. <div class="image-container">
  176. <!-- 用户上传的原图 -->
  177. <img
  178. v-if="showScanningEffect"
  179. :src="uploadedImageUrl"
  180. alt="用户上传图片"
  181. class="original-image"
  182. />
  183. <!-- 扫描效果 -->
  184. <div v-if="showScanningEffect" class="scanning-overlay">
  185. <div class="scanning-line"></div>
  186. </div>
  187. <!-- 识别结果图片 -->
  188. <img
  189. v-if="!showScanningEffect"
  190. :src="annotatedImageUrl"
  191. alt="隐患提示图片"
  192. class="main-image"
  193. @click="openImagePreview"
  194. style="cursor: pointer; transform: none !important;"
  195. @error="handleMainImageError"
  196. />
  197. </div>
  198. </div>
  199. <!-- 识别结果分析 -->
  200. <div class="analysis-section">
  201. <div class="analysis-header">
  202. <img src="@/assets/Hazard/7.png" alt="警告标志" class="warning-icon" />
  203. <h3>识别结果分析</h3>
  204. </div>
  205. <div class="analysis-content">
  206. <p class="scene-info">
  207. <!-- 扫描期间显示分析提示 -->
  208. <span v-if="showAnalysisPrompt" class="analysis-prompt">
  209. 蜀道安全管理AI智能助手正在为您分析图片,请稍候……
  210. </span>
  211. <!-- 扫描完成后显示分析结果 -->
  212. <template v-else>
  213. <span v-if="isStreamingAnalysis" class="streaming-analysis-text">{{ streamingAnalysis }}</span>
  214. <span v-else-if="!isStreamingAnalysis && detectionResult">
  215. 经过智能分析,发现场景:<span :class="['scene-tags', getTagClass(detectionResult?.scene_name)]">[{{ detectionResult?.scene_name ? scenarios[detectionResult.scene_name]?.name : '未知场景' }}]</span>
  216. <span class="detection-count">[{{ detectionResult.labels }}]</span>
  217. </span>
  218. </template>
  219. </p>
  220. </div>
  221. </div>
  222. <!-- 场景隐患列表 -->
  223. <div class="hazards-section">
  224. <div class="hazards-header">
  225. <h3>该场景常见隐患有:</h3>
  226. </div>
  227. <div class="hazards-content" :class="{ 'scanning-mode': showScanningEffect }">
  228. <!-- 扫描期间的遮罩层 -->
  229. <div v-if="showScanningEffect" class="hazards-loading-overlay">
  230. <div class="loading-spinner"></div>
  231. <p>正在分析场景隐患...</p>
  232. </div>
  233. <!-- 隐患内容 -->
  234. <div v-else class="hazard-item">
  235. <div
  236. v-for="(hazard, index) in detectionResult?.third_scenes || []"
  237. :key="index"
  238. class="hazard-line"
  239. >
  240. <span class="hazard-number">{{ index + 1 }}</span>
  241. <span class="hazard-desc">{{ hazard }}</span>
  242. <button class="example-btn" @click="openExampleModal({number: index + 1, description: hazard})">示例</button>
  243. </div>
  244. </div>
  245. </div>
  246. </div>
  247. </div>
  248. <!-- 图片预览弹窗 -->
  249. <div v-if="showImagePreview" class="image-preview-overlay" @click="closeImagePreview">
  250. <img :src="annotatedImageUrl" alt="预览图片" class="preview-image" />
  251. </div>
  252. <!-- 示例弹窗 -->
  253. <div v-if="showExampleModal" class="example-modal-overlay" @click="closeExampleModal">
  254. <div class="example-modal" @click.stop>
  255. <div class="modal-header">
  256. <span class="modal-title">示例详情</span>
  257. <img src="@/assets/Hazard/11.png" alt="关闭" class="close-icon" @click="closeExampleModal" />
  258. </div>
  259. <div class="modal-hazard-info">
  260. <div class="hazard-number">{{ selectedHazard?.number }}</div>
  261. <span class="hazard-description">{{ selectedHazard?.description }}</span>
  262. </div>
  263. <div class="modal-body">
  264. <!-- 加载状态 -->
  265. <div v-if="isLoadingExample" class="loading-overlay">
  266. <div class="loading-spinner"></div>
  267. <p>正在加载示例图...</p>
  268. </div>
  269. <!-- 示例图内容 -->
  270. <template v-else>
  271. <div class="example-images">
  272. <div class="example-panel correct-panel">
  273. <div class="panel-image">
  274. <!-- 正确示例图加载状态 -->
  275. <div v-if="imageLoadingStates.correct" class="image-loading">
  276. <div class="loading-spinner"></div>
  277. <p>正在加载图片...</p>
  278. </div>
  279. <!-- 有正确示例图时显示图片 -->
  280. <img
  281. v-if="exampleImages.correctImageUrl"
  282. :src="exampleImages.correctImageUrl"
  283. alt="正确示例图片"
  284. :style="{ display: imageLoadingStates.correct ? 'none' : 'block' }"
  285. @error="handleImageError($event, 'correct')"
  286. @load="handleImageLoad($event, 'correct')"
  287. />
  288. <!-- 没有正确示例图时显示提示文字 -->
  289. <div v-if="!exampleImages.correctImageUrl && !imageLoadingStates.correct" class="no-image-placeholder">
  290. <div class="placeholder-text">暂无示例图</div>
  291. </div>
  292. <div class="image-label correct-label">
  293. <img src="@/assets/Hazard/9.png" alt="正确" class="label-icon" />
  294. </div>
  295. </div>
  296. </div>
  297. <div class="example-panel error-panel">
  298. <div class="panel-image">
  299. <!-- 错误示例图加载状态 -->
  300. <div v-if="imageLoadingStates.error" class="image-loading">
  301. <div class="loading-spinner"></div>
  302. <p>正在加载图片...</p>
  303. </div>
  304. <!-- 有错误示例图时显示图片 -->
  305. <img
  306. v-if="exampleImages.errorImageUrl"
  307. :src="exampleImages.errorImageUrl"
  308. alt="错误示例图片"
  309. :style="{ display: imageLoadingStates.error ? 'none' : 'block' }"
  310. @error="handleImageError($event, 'error')"
  311. @load="handleImageLoad($event, 'error')"
  312. />
  313. <!-- 没有错误示例图时显示提示文字 -->
  314. <div v-if="!exampleImages.errorImageUrl && !imageLoadingStates.error" class="no-image-placeholder">
  315. <div class="placeholder-text">暂无示例图</div>
  316. </div>
  317. <div class="image-label error-label">
  318. <img src="@/assets/Hazard/10.png" alt="错误" class="label-icon" />
  319. </div>
  320. </div>
  321. </div>
  322. </div>
  323. </template>
  324. </div>
  325. </div>
  326. </div>
  327. </div>
  328. <!-- 点评弹窗 -->
  329. <div v-if="showEvaluationModal" class="evaluation-modal-overlay" @click="closeEvaluationModal">
  330. <div class="evaluation-modal" @click.stop>
  331. <div class="modal-header">
  332. <span class="modal-title">点评确认</span>
  333. <img src="@/assets/Hazard/11.png" alt="关闭" class="close-icon" @click="closeEvaluationModal" />
  334. </div>
  335. <div class="modal-body">
  336. <!-- 问题1:场景是否匹配 -->
  337. <div class="question-section">
  338. <div class="question-title">1.场景是否匹配?</div>
  339. <div class="answer-buttons">
  340. <button
  341. :class="['answer-btn', { active: evaluationData.sceneMatch === true, disabled: selectedHistoryItem?.effect_evaluation > 0 }]"
  342. @click="selectedHistoryItem?.effect_evaluation > 0 ? null : (evaluationData.sceneMatch = true)"
  343. >
  344. </button>
  345. <button
  346. :class="['answer-btn', { active: evaluationData.sceneMatch === false, disabled: selectedHistoryItem?.effect_evaluation > 0 }]"
  347. @click="selectedHistoryItem?.effect_evaluation > 0 ? null : (evaluationData.sceneMatch = false)"
  348. >
  349. </button>
  350. </div>
  351. </div>
  352. <!-- 问题2:提示是否准确 -->
  353. <div class="question-section">
  354. <div class="question-title">2.提示是否准确?</div>
  355. <div class="answer-buttons">
  356. <button
  357. :class="['answer-btn', { active: evaluationData.promptAccurate === true, disabled: selectedHistoryItem?.effect_evaluation > 0 }]"
  358. @click="selectedHistoryItem?.effect_evaluation > 0 ? null : (evaluationData.promptAccurate = true)"
  359. >
  360. </button>
  361. <button
  362. :class="['answer-btn', { active: evaluationData.promptAccurate === false, disabled: selectedHistoryItem?.effect_evaluation > 0 }]"
  363. @click="selectedHistoryItem?.effect_evaluation > 0 ? null : (evaluationData.promptAccurate = false)"
  364. >
  365. </button>
  366. </div>
  367. </div>
  368. <!-- 问题3:效果评价 -->
  369. <div class="question-section">
  370. <div class="question-title">3.效果评价</div>
  371. <div class="star-rating">
  372. <span
  373. v-for="star in 5"
  374. :key="star"
  375. :class="['star', { active: star <= evaluationData.rating, disabled: selectedHistoryItem?.effect_evaluation > 0 }]"
  376. @click="selectedHistoryItem?.effect_evaluation > 0 ? null : (evaluationData.rating = star)"
  377. >
  378. </span>
  379. </div>
  380. </div>
  381. <!-- 问题4:用户意见 -->
  382. <div v-if="shouldShowRemarkSection" class="question-section">
  383. <div class="question-title">4.您的意见</div>
  384. <div class="remark-input-container">
  385. <textarea
  386. v-model="evaluationData.userRemark"
  387. :disabled="selectedHistoryItem?.effect_evaluation > 0"
  388. :class="['remark-textarea', { disabled: selectedHistoryItem?.effect_evaluation > 0 }]"
  389. placeholder="请输入您的意见或建议..."
  390. maxlength="200"
  391. rows="4"
  392. ></textarea>
  393. <div class="char-count">
  394. <span :class="{ 'over-limit': evaluationData.userRemark.length > 200 }">
  395. {{ evaluationData.userRemark.length }}/200
  396. </span>
  397. </div>
  398. </div>
  399. </div>
  400. </div>
  401. <div class="modal-footer" v-if="!selectedHistoryItem?.effect_evaluation || selectedHistoryItem.effect_evaluation === 0">
  402. <button class="submit-btn" @click="submitEvaluation">
  403. <img src="@/assets/Hazard/13.png" alt="提交反馈" class="submit-icon" />
  404. </button>
  405. </div>
  406. </div>
  407. </div>
  408. </div>
  409. <!-- 移动端Toast提示组件 -->
  410. <MobileToast :visible="toastVisible" :message="toastMessage" @close="closeToast" />
  411. </div>
  412. </template>
  413. <script setup>
  414. import { useRouter } from 'vue-router'
  415. import MobileHeader from '@/components/MobileHeader.vue'
  416. import MobileHistoryDrawer from '@/components/MobileHistoryDrawer.vue'
  417. import DeleteConfirmModal from '@/components/DeleteConfirmModal.vue'
  418. import { ref, onMounted, watch, computed } from 'vue'
  419. import { apis } from '@/request/apis.js'
  420. // ===== 已删除:getUserId - 不再需要,改用token =====
  421. // import { getUserId } from '@/utils/userManager.js'
  422. import MobileToast from '@/components/MobileToast.vue'
  423. import startIdentifyImg from "@/assets/Hazard/2.png"
  424. import startIdentifyActiveImg from "@/assets/Hazard/3.png"
  425. const router = useRouter()
  426. const goBack = () => {
  427. router.go(-1)
  428. }
  429. // 显示历史记录抽屉的方法
  430. const showHistoryDrawer = () => {
  431. if (!isIdentifying.value) {
  432. showHistory.value = true
  433. }
  434. // AI处理中时不执行任何操作,不记录点击意图
  435. }
  436. // Toast状态管理
  437. const toastVisible = ref(false)
  438. const toastMessage = ref('')
  439. // Toast便捷方法
  440. const showToast = (message, duration = 2000) => {
  441. toastMessage.value = message
  442. toastVisible.value = true
  443. if (duration > 0) {
  444. setTimeout(() => {
  445. toastVisible.value = false
  446. }, duration)
  447. }
  448. }
  449. const closeToast = () => {
  450. toastVisible.value = false
  451. }
  452. // 替换ElMessage的工具方法
  453. const showSuccess = (message) => showToast(message, 2000)
  454. const showError = (message) => showToast(message, 3000)
  455. const showWarning = (message) => showToast(message, 2500)
  456. const showHistory = ref(false)
  457. // 选项卡状态
  458. const activeMode = ref("detect") // 'detect' 或 'process'
  459. // 隐患识别相关状态
  460. const selectedScenario = ref("tunnel"); // 默认选择隧道工程
  461. const uploadedImage = ref(null);
  462. const uploadedImageUrl = ref(""); // 存储上传后的图片URL
  463. const fileInput = ref(null);
  464. const currentView = ref("main"); // 当前视图:main-主界面,detail-详情页
  465. const selectedHistoryItem = ref(null); // 选中的历史记录项
  466. const isUploading = ref(false); // 修改:上传状态标识
  467. const isDragOver = ref(false); // 修改:拖拽上传区域是否悬停
  468. const isIdentifying = ref(false); // 修改:识别状态标识
  469. const detectionResult = ref(null); // 存储识别结果
  470. const annotatedImageUrl = ref(""); // 存储标注后的图片URL
  471. // 图片预览相关数据
  472. const showImagePreview = ref(false); // 控制图片预览弹窗显示
  473. const showScanningEffect = ref(false); // 控制扫描效果显示
  474. const isLoadingLabels = ref(false); // 控制标签加载状态
  475. const isStreamingLabels = ref(false); // 控制标签流式输出状态
  476. const streamingLabels = ref(''); // 流式输出的标签内容
  477. const isStreamingAnalysis = ref(false); // 控制整个分析文本流式输出状态
  478. const streamingAnalysis = ref(''); // 流式输出的完整分析文本
  479. const showAnalysisPrompt = ref(false); // 控制分析提示显示
  480. // 历史记录相关状态
  481. const historyData = ref([])
  482. const historyTotal = ref(0)
  483. const isLoadingHistory = ref(false)
  484. const isLoadingDetail = ref(false); // 控制详情加载状态
  485. const isImageLoading = ref(false); // 控制图片加载状态
  486. // 示例弹窗相关数据
  487. const showExampleModal = ref(false); // 控制示例弹窗显示
  488. const selectedHazard = ref(null); // 选中的隐患项目
  489. const exampleImages = ref({}); // 存储示例图数据
  490. const isLoadingExample = ref(false); // 控制示例图加载状态
  491. const imageLoadingStates = ref({
  492. correct: false, // 正确示例图加载状态
  493. error: false // 错误示例图加载状态
  494. }); // 控制每张图片的加载状态
  495. // 点评弹窗相关数据
  496. const showEvaluationModal = ref(false); // 控制点评弹窗显示
  497. const evaluationData = ref({
  498. sceneMatch: null, // 场景是否匹配
  499. promptAccurate: null, // 提示是否准确
  500. rating: 0, // 效果评价评分 1-5
  501. userRemark: '' // 用户意见
  502. });
  503. // 删除相关状态
  504. const showDeleteModal = ref(false); // 控制是否显示删除确认弹窗
  505. const deleteTargetItem = ref(null); // 要删除的目标项
  506. // 时间解析与格式化(容错更强)
  507. const parseToDate = (input) => {
  508. if (!input) return null
  509. if (typeof input === 'number') {
  510. const ms = input < 1e12 ? input * 1000 : input
  511. return new Date(ms)
  512. }
  513. if (typeof input === 'string') {
  514. let d = new Date(input)
  515. if (!isNaN(d)) return d
  516. const normalized = input.replace(/-/g, '/').replace('T', ' ')
  517. d = new Date(normalized)
  518. if (!isNaN(d)) return d
  519. }
  520. return new Date(input)
  521. }
  522. const formatTime = (timestamp) => {
  523. const date = parseToDate(timestamp)
  524. if (!date || isNaN(date)) return ''
  525. const now = new Date()
  526. const isToday = date.toDateString() === now.toDateString()
  527. const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
  528. const isYesterday = date.toDateString() === yesterday.toDateString()
  529. if (isToday) {
  530. return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
  531. }
  532. if (isYesterday) {
  533. return '昨天 ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
  534. }
  535. const month = date.getMonth() + 1
  536. const day = date.getDate()
  537. const time = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
  538. return `${month}月${day}日 ${time}`
  539. }
  540. // 生成对话标题
  541. const generateConversationTitle = (content) => {
  542. if (!content) return '新对话'
  543. // 取前30个字符作为标题
  544. const title = content.replace(/<[^>]*>/g, '').trim()
  545. return title.length > 30 ? title.substring(0, 30) + '...' : title
  546. }
  547. // 获取历史记录列表
  548. const getHistoryRecordList = async () => {
  549. try {
  550. console.log('📋 开始获取移动端隐患提示历史记录列表...')
  551. isLoadingHistory.value = true
  552. const startTime = performance.now()
  553. const response = await apis.getHazardHistory({
  554. // ===== 已删除:user_id - 后端从token解析 =====
  555. })
  556. const endTime = performance.now()
  557. console.log(`📋 移动端隐患提示历史记录API调用耗时: ${(endTime - startTime).toFixed(2)}ms`)
  558. console.log('📋 移动端历史记录列表响应:', response)
  559. if (response.statusCode === 200 || response.code === 200) {
  560. // 设置历史记录总数
  561. historyTotal.value = response.total || 0
  562. // 转换后端数据为前端格式
  563. historyData.value = response.data.map(record => ({
  564. id: record.id,
  565. title: record.title || '隐患提示记录',
  566. time: formatTime(record.created_at),
  567. businessType: 'hazard',
  568. isActive: false,
  569. effect_evaluation: record.effect_evaluation || 0,
  570. // 保存原始数据用于后续查询
  571. rawData: record
  572. }))
  573. // 高亮当前记录
  574. if (selectedHistoryItem.value?.id) {
  575. historyData.value.forEach(item => { item.isActive = item.id === selectedHistoryItem.value.id })
  576. }
  577. console.log(`✅ 移动端隐患提示历史记录列表已设置: ${historyData.value.length}条记录,总数: ${historyTotal.value}`)
  578. } else {
  579. console.error('❌ 获取移动端历史记录列表失败:', response.statusCode)
  580. }
  581. } catch (error) {
  582. console.error('❌ 获取移动端历史记录列表失败:', error)
  583. } finally {
  584. isLoadingHistory.value = false
  585. }
  586. }
  587. // 新建任务
  588. // 配置常量
  589. const scenarios = {
  590. tunnel: { name: "隧道工程", color: "#3366E6" },
  591. simple_supported_bridge: { name: "桥梁工程", color: "#22B850" },
  592. "gas_station": { name: "加油站", color: "#FF4D4F" },
  593. special_equipment: { name: "特种设备", color: "#0080FF" },
  594. operate_highway: { name: "运营高速公路", color: "#722ED1" },
  595. };
  596. // 标签类型配置,便于后端动态管理
  597. const tagTypeConfig = {
  598. tunnel: {
  599. class: "tag-tunnel",
  600. background: "rgba(62, 123, 250, 0.1)",
  601. color: "#3366E6",
  602. text: "隧道",
  603. },
  604. simple_supported_bridge: {
  605. class: "tag-bridge",
  606. background: "rgba(34, 184, 80, 0.1)",
  607. color: "#22B850",
  608. text: "桥梁",
  609. },
  610. special_equipment: {
  611. class: "tag-equipment",
  612. background: "rgba(0, 128, 255, 0.1)",
  613. color: "#0080FF",
  614. text: "特种设备",
  615. },
  616. operate_highway: {
  617. class: "tag-highway",
  618. background: "rgba(114, 46, 209, 0.1)",
  619. color: "#722ED1",
  620. text: "运营高速公路",
  621. },
  622. "gas_station": {
  623. class: "tag-gas-station",
  624. background: "rgba(255, 77, 79, 0.1)",
  625. color: "#FF4D4F",
  626. text: "加油站",
  627. },
  628. };
  629. // 根据标签类型获取样式类名
  630. const getTagClass = (tagType) => {
  631. return tagTypeConfig[tagType]?.class || "tag-tunnel";
  632. };
  633. // 根据标签类型获取显示文字
  634. const getTagText = (tagType) => {
  635. return tagTypeConfig[tagType]?.text || "隧道";
  636. };
  637. // 删除确认消息
  638. const deleteConfirmMessage = computed(() => {
  639. const title = deleteTargetItem.value?.item?.title || ''
  640. return `确定要删除历史记录"${title}"吗?删除后将无法恢复。`
  641. })
  642. // 控制是否显示备注区域
  643. const shouldShowRemarkSection = computed(() => {
  644. // 如果是已点评状态,只有当有备注内容时才显示
  645. if (selectedHistoryItem.value?.effect_evaluation > 0) {
  646. return evaluationData.value.userRemark && evaluationData.value.userRemark.trim() !== ''
  647. }
  648. // 如果是未点评状态,始终显示(让用户可以输入)
  649. return true
  650. })
  651. const createNewTask = () => {
  652. console.log("createNewChat 被调用");
  653. // 重置所有状态
  654. currentView.value = "main";
  655. selectedScenario.value = "tunnel"; // 默认选择隧道工程
  656. uploadedImage.value = null;
  657. uploadedImageUrl.value = "";
  658. selectedHistoryItem.value = null;
  659. showExampleModal.value = false;
  660. selectedHazard.value = null;
  661. isUploading.value = false;
  662. isDragOver.value = false; // 重置拖拽状态
  663. isIdentifying.value = false; // 重置识别状态
  664. annotatedImageUrl.value = "";
  665. exampleImages.value = {}; // 清空示例图数据
  666. isLoadingExample.value = false; // 重置示例图加载状态
  667. imageLoadingStates.value = { correct: false, error: false }; // 重置图片加载状态
  668. isImageLoading.value = false; // 重置图片加载状态
  669. showScanningEffect.value = false; // 重置扫描效果状态
  670. isLoadingLabels.value = false; // 重置标签加载状态
  671. isStreamingLabels.value = false; // 重置标签流式输出状态
  672. streamingLabels.value = ''; // 清空流式输出内容
  673. isStreamingAnalysis.value = false; // 重置分析文本流式输出状态
  674. streamingAnalysis.value = ''; // 清空分析文本流式输出内容
  675. showAnalysisPrompt.value = false; // 重置分析提示状态
  676. // 清空文件输入
  677. if (fileInput.value) {
  678. fileInput.value.value = "";
  679. console.log("文件输入已清空");
  680. }
  681. // 重置所有历史记录的active状态
  682. if (historyData.value.length > 0) {
  683. historyData.value.forEach((item) => {
  684. item.isActive = false;
  685. });
  686. }
  687. showHistory.value = false
  688. console.log("新任务创建完成");
  689. };
  690. // 处理历史记录点击
  691. const handleHistoryItem = async (historyItem) => {
  692. if (historyItem.isActive) return
  693. console.log("点击移动端隐患提示历史记录:", historyItem)
  694. // 关闭历史记录抽屉
  695. showHistory.value = false
  696. // 加载历史记录详情
  697. await handleHistoryDetail(historyItem)
  698. }
  699. // 选择场景
  700. const selectScenario = (scenarioKey) => {
  701. try {
  702. console.log("selectScenario 被调用,场景:", scenarioKey);
  703. selectedScenario.value = scenarioKey;
  704. console.log("选择场景:", scenarios[scenarioKey].name);
  705. } catch (error) {
  706. console.error("选择场景失败:", error);
  707. }
  708. };
  709. // 触发文件上传
  710. const triggerFileUpload = () => {
  711. try {
  712. console.log("triggerFileUpload 被调用");
  713. if (fileInput.value) {
  714. fileInput.value.click();
  715. console.log("已触发文件选择器");
  716. } else {
  717. console.error("fileInput 引用为空");
  718. }
  719. } catch (error) {
  720. console.error("触发文件上传失败:", error);
  721. }
  722. };
  723. // 上传文件到服务器
  724. const uploadFileToServer = async (file) => {
  725. try {
  726. console.log("uploadFileToServer 被调用,文件:", file);
  727. isUploading.value = true;
  728. // 创建FormData对象
  729. const formData = new FormData();
  730. formData.append('image', file);
  731. console.log("FormData 已创建:", formData);
  732. // 调用后端上传接口
  733. console.log("开始调用后端API...");
  734. const response = await apis.uploadImage(formData);
  735. console.log("后端API响应:", response);
  736. if (response.statusCode === 200) {
  737. uploadedImageUrl.value = response.fileUrl || response.fileURL;
  738. console.log("上传成功:", uploadedImageUrl.value);
  739. showSuccess("图片上传成功!");
  740. } else {
  741. throw new Error(response.message || "上传失败");
  742. }
  743. } catch (error) {
  744. console.error("上传失败:", error);
  745. showError("图片上传失败: " + (error.message || "未知错误"));
  746. uploadedImage.value = null;
  747. uploadedImageUrl.value = "";
  748. } finally {
  749. isUploading.value = false;
  750. }
  751. };
  752. // 重新选择图片
  753. const reselectImage = () => {
  754. try {
  755. console.log("reselectImage 被调用");
  756. clearUploadedImage();
  757. triggerFileUpload();
  758. console.log("重新选择图片完成");
  759. } catch (error) {
  760. console.error("重新选择图片失败:", error);
  761. }
  762. };
  763. // 清除上传的图片
  764. const clearUploadedImage = () => {
  765. try {
  766. console.log("clearUploadedImage 被调用");
  767. uploadedImage.value = null;
  768. uploadedImageUrl.value = "";
  769. if (fileInput.value) {
  770. fileInput.value.value = "";
  771. console.log("文件输入已清空");
  772. }
  773. console.log("上传图片已清除");
  774. } catch (error) {
  775. console.error("清除上传图片失败:", error);
  776. }
  777. };
  778. // 处理图片方向,确保保持原始方向
  779. const processImageOrientation = (file) => {
  780. return new Promise((resolve) => {
  781. const canvas = document.createElement('canvas');
  782. const ctx = canvas.getContext('2d');
  783. const img = new Image();
  784. img.onload = () => {
  785. // 设置画布尺寸为图片的原始尺寸
  786. canvas.width = img.naturalWidth;
  787. canvas.height = img.naturalHeight;
  788. // 绘制图片,保持原始方向
  789. ctx.drawImage(img, 0, 0);
  790. // 将画布转换为Blob
  791. canvas.toBlob((blob) => {
  792. // 创建一个新的File对象,保持原始文件名和类型
  793. const processedFile = new File([blob], file.name, {
  794. type: file.type,
  795. lastModified: file.lastModified
  796. });
  797. resolve(processedFile);
  798. }, file.type);
  799. };
  800. img.src = URL.createObjectURL(file);
  801. });
  802. };
  803. // 图片压缩函数
  804. const compressImage = (file, maxWidth = 1920, quality = 0.8) => {
  805. return new Promise((resolve, reject) => {
  806. const maxSize = 5 * 1024 * 1024; // 5MB limit
  807. // 如果文件本身小于5MB,直接返回
  808. if (file.size <= maxSize) {
  809. console.log('文件大小符合要求,无需压缩:', file.size);
  810. resolve(file);
  811. return;
  812. }
  813. const canvas = document.createElement('canvas');
  814. const ctx = canvas.getContext('2d');
  815. const img = new Image();
  816. img.onload = () => {
  817. try {
  818. let currentWidth = img.width;
  819. let currentHeight = img.height;
  820. let currentQuality = quality;
  821. // 初始尺寸调整
  822. if (currentWidth > maxWidth) {
  823. currentHeight = (currentHeight * maxWidth) / currentWidth;
  824. currentWidth = maxWidth;
  825. }
  826. canvas.width = currentWidth;
  827. canvas.height = currentHeight;
  828. ctx.drawImage(img, 0, 0, currentWidth, currentHeight);
  829. // 递归压缩函数
  830. const attemptCompression = (q, w, h) => {
  831. canvas.width = w;
  832. canvas.height = h;
  833. ctx.drawImage(img, 0, 0, w, h);
  834. canvas.toBlob((blob) => {
  835. if (!blob) {
  836. reject(new Error('图片压缩失败: Blob生成失败'));
  837. return;
  838. }
  839. console.log(`尝试压缩: 质量=${q.toFixed(2)}, 尺寸=${w}x${h}, 大小=${(blob.size / 1024 / 1024).toFixed(2)}MB`);
  840. if (blob.size <= maxSize) {
  841. // 压缩成功
  842. const compressedFile = new File([blob], file.name, {
  843. type: 'image/jpeg',
  844. lastModified: Date.now()
  845. });
  846. console.log(`最终压缩结果: ${file.size} -> ${compressedFile.size} bytes`);
  847. resolve(compressedFile);
  848. } else {
  849. // 仍然过大,继续压缩
  850. if (q > 0.2) {
  851. // 优先降低质量
  852. attemptCompression(q - 0.1, w, h);
  853. } else if (w > 800) {
  854. // 质量已很低,开始缩小尺寸
  855. attemptCompression(0.8, w * 0.8, h * 0.8);
  856. } else {
  857. // 无法继续压缩,强制返回当前结果(虽然可能仍略大于5MB,但已尽力)
  858. console.warn('无法进一步压缩,返回当前结果');
  859. const compressedFile = new File([blob], file.name, {
  860. type: 'image/jpeg',
  861. lastModified: Date.now()
  862. });
  863. resolve(compressedFile);
  864. }
  865. }
  866. }, 'image/jpeg', q);
  867. };
  868. // 开始第一次压缩尝试
  869. attemptCompression(currentQuality, currentWidth, currentHeight);
  870. } catch (error) {
  871. reject(error);
  872. }
  873. };
  874. img.onerror = () => reject(new Error('图片加载失败'));
  875. img.src = URL.createObjectURL(file);
  876. });
  877. };
  878. // 通用的文件处理函数
  879. const processFile = async (file) => {
  880. try {
  881. console.log("processFile 被调用,文件:", file);
  882. // 检查文件格式
  883. const allowedTypes = ["image/jpeg", "image/jpg", "image/png"];
  884. console.log("文件类型:", file.type);
  885. if (!allowedTypes.includes(file.type)) {
  886. console.log("不支持的文件类型:", file.type);
  887. showError("只支持JPG、PNG格式的图片");
  888. return;
  889. }
  890. let processedFile = file;
  891. // 如果文件大于5MB,进行压缩
  892. if (file.size > 5 * 1024 * 1024) {
  893. console.log("文件过大,开始压缩:", file.size);
  894. showToast("图片较大,正在压缩...", 2000);
  895. try {
  896. processedFile = await compressImage(file, 1920, 0.8);
  897. console.log("压缩完成:", processedFile.size);
  898. } catch (error) {
  899. console.error("压缩失败:", error);
  900. showError("图片压缩失败,请选择较小的图片");
  901. return;
  902. }
  903. }
  904. // 处理图片方向,确保保持原始方向
  905. const orientedFile = await processImageOrientation(processedFile);
  906. uploadedImage.value = orientedFile;
  907. console.log("选择文件:", orientedFile.name);
  908. // 自动上传文件到后端
  909. console.log("开始上传文件到服务器");
  910. await uploadFileToServer(orientedFile);
  911. } catch (error) {
  912. console.error("处理文件失败:", error);
  913. }
  914. };
  915. // 处理文件上传
  916. const handleFileUpload = async (event) => {
  917. try {
  918. console.log("handleFileUpload 被调用", event);
  919. const file = event.target.files[0];
  920. console.log("选择的文件:", file);
  921. if (file) {
  922. await processFile(file);
  923. } else {
  924. console.log("没有选择文件");
  925. }
  926. } catch (error) {
  927. console.error("文件上传处理失败:", error);
  928. }
  929. };
  930. // 开始识别
  931. const startIdentification = async () => {
  932. try {
  933. console.log("startIdentification 被调用");
  934. // 防抖检查:如果正在识别中,直接返回
  935. if (isIdentifying.value) {
  936. console.log("识别正在进行中,忽略重复点击");
  937. showWarning("识别正在进行中,请勿重复点击");
  938. return;
  939. }
  940. if (!selectedScenario.value) {
  941. console.log("未选择场景");
  942. showWarning("请先选择场景");
  943. return;
  944. }
  945. if (!uploadedImageUrl.value) {
  946. console.log("未上传图片");
  947. showWarning("请先上传图片");
  948. return;
  949. }
  950. // 检查用户最新识别记录是否已点评
  951. try {
  952. console.log("检查最新识别记录是否已点评");
  953. const latestRecordResponse = await apis.getLatestRecognitionRecord({
  954. // ===== 已删除:user_id - 后端从token解析 =====
  955. });
  956. if (latestRecordResponse.statusCode === 200 && latestRecordResponse.data) {
  957. const latestRecord = latestRecordResponse.data;
  958. console.log("最新识别记录:", latestRecord);
  959. // 如果最新记录存在且未点评,提示用户
  960. if (latestRecord.effect_evaluation === 0 || !latestRecord.effect_evaluation) {
  961. showWarning("请先对上一次识别结果进行点评,再进行新的识别");
  962. return;
  963. }
  964. }
  965. } catch (error) {
  966. console.error("检查最新识别记录失败:", error);
  967. // 如果检查失败,继续执行识别流程
  968. }
  969. console.log("开始识别:", {
  970. scenario: scenarios[selectedScenario.value].name,
  971. image: uploadedImageUrl.value,
  972. });
  973. // 开始识别状态
  974. isIdentifying.value = true;
  975. // 调用后端API进行隐患提示
  976. showSuccess("开始进行隐患提示,请稍候...");
  977. // 模拟用户信息(暂时不从后端获取)
  978. const account = '';
  979. const username = '蜀道用户';
  980. // 获取当日日期,格式:2025/10/23
  981. const today = new Date();
  982. const year = today.getFullYear();
  983. const month = String(today.getMonth() + 1).padStart(2, '0');
  984. const day = String(today.getDate()).padStart(2, '0');
  985. const currentDate = `${year}/${month}/${day}`;
  986. // 截取手机号后四位
  987. const accountLastFour = account.length >= 4 ? account.slice(-4) : account;
  988. const requestData = {
  989. // ===== 已删除:user_id - 后端从token解析 =====
  990. scene_name: selectedScenario.value,
  991. image: uploadedImageUrl.value,
  992. account: accountLastFour,
  993. username: username,
  994. date: currentDate
  995. };
  996. console.log("发送隐患提示请求:", requestData);
  997. const response = await apis.hazardDetection(requestData);
  998. console.log("隐患提示响应:", response);
  999. // 检查响应结构,兼容不同的字段名
  1000. const isSuccess = response.code === 200 || response.statusCode === 200;
  1001. if (isSuccess) {
  1002. // showSuccess("隐患提示完成!");
  1003. // 保存识别结果
  1004. detectionResult.value = response.data;
  1005. // 处理标注后的图片
  1006. if (response.data.annotated_image) {
  1007. annotatedImageUrl.value = `${response.data.annotated_image}`;
  1008. }
  1009. // 跳转到详情页
  1010. currentView.value = "detail";
  1011. // 开始扫描效果
  1012. showScanningEffect.value = true;
  1013. showAnalysisPrompt.value = true; // 显示分析提示
  1014. // 延迟6秒后显示识别结果(扫描3圈,每圈2秒)
  1015. setTimeout(() => {
  1016. showScanningEffect.value = false;
  1017. showAnalysisPrompt.value = false; // 隐藏分析提示
  1018. // 开始整个分析文本流式输出效果
  1019. startAnalysisStreaming();
  1020. }, 6000);
  1021. // 刷新历史记录
  1022. await getHistoryRecordList();
  1023. // 自动选中最新创建的历史记录
  1024. if (historyData.value.length > 0) {
  1025. const latestRecord = historyData.value[0]; // 假设最新的记录在数组第一位
  1026. selectedHistoryItem.value = latestRecord;
  1027. // 更新所有记录的active状态
  1028. historyData.value.forEach((item) => {
  1029. item.isActive = item.id === latestRecord.id;
  1030. });
  1031. console.log("自动选中最新记录:", latestRecord);
  1032. }
  1033. console.log("识别结果:", response.data);
  1034. console.log("标注图片URL:", annotatedImageUrl.value);
  1035. } else {
  1036. showError(response.msg || "隐患提示失败");
  1037. }
  1038. } catch (error) {
  1039. console.error("开始识别失败:", error);
  1040. showError("隐患提示失败: " + (error.msg || "未知错误"));
  1041. } finally {
  1042. // 清除识别状态
  1043. isIdentifying.value = false;
  1044. }
  1045. };
  1046. // 开始整个分析文本流式输出效果
  1047. const startAnalysisStreaming = () => {
  1048. try {
  1049. console.log("开始整个分析文本流式输出效果");
  1050. // 重置状态
  1051. isStreamingAnalysis.value = false;
  1052. streamingAnalysis.value = '';
  1053. // 立即开始流式输出,不需要延迟
  1054. // 构建完整的分析文本
  1055. const sceneName = detectionResult.value?.scene_name;
  1056. const sceneText = sceneName ? scenarios[sceneName]?.name : '未知场景';
  1057. const labels = detectionResult.value?.labels || '';
  1058. const labelsText = Array.isArray(labels) ? labels.join('') : labels;
  1059. const fullAnalysisText = `经过智能分析,发现场景:[${sceneText}][${labelsText}]`;
  1060. // 开始流式输出
  1061. isStreamingAnalysis.value = true;
  1062. let currentIndex = 0;
  1063. const streamInterval = setInterval(() => {
  1064. if (currentIndex < fullAnalysisText.length) {
  1065. streamingAnalysis.value = fullAnalysisText.substring(0, currentIndex + 1);
  1066. currentIndex++;
  1067. } else {
  1068. // 输出完成
  1069. clearInterval(streamInterval);
  1070. isStreamingAnalysis.value = false;
  1071. console.log("分析文本流式输出完成");
  1072. }
  1073. }, 100); // 每100ms输出一个字符
  1074. } catch (error) {
  1075. console.error("开始分析文本流式输出失败:", error);
  1076. // 出错时直接显示完整内容
  1077. isStreamingAnalysis.value = false;
  1078. }
  1079. };
  1080. // 处理历史记录点击详情
  1081. const handleHistoryDetail = async (historyItem) => {
  1082. try {
  1083. console.log("handleHistoryDetail 被调用,历史记录:", historyItem);
  1084. selectedHistoryItem.value = historyItem;
  1085. currentView.value = "detail";
  1086. isLoadingDetail.value = true;
  1087. isImageLoading.value = true;
  1088. // 调用详情接口获取完整数据
  1089. console.log("开始获取记录详情,ID:", historyItem.id);
  1090. const detailResponse = await apis.getRecognitionRecordDetail({
  1091. recognition_record_id: historyItem.id
  1092. });
  1093. if (detailResponse.statusCode === 200 || detailResponse.code === 200) {
  1094. const detailData = detailResponse.data;
  1095. console.log("获取详情成功:", detailData);
  1096. // 设置识别结果数据
  1097. detectionResult.value = {
  1098. scene_name: detailData.tag_type || getTagTypeFromLabels(detailData.labels),
  1099. labels: detailData.labels,
  1100. total_detections: detailData.labels ? (Array.isArray(detailData.labels) ? detailData.labels.length : 0) : 0,
  1101. third_scenes: detailData.third_scenes || []
  1102. };
  1103. // 设置图片URL
  1104. const newImageUrl = detailData.recognition_image_url || detailData.original_image_url;
  1105. annotatedImageUrl.value = newImageUrl;
  1106. // 更新历史记录中的标签类型
  1107. const tagType = detailData.tag_type || getTagTypeFromLabels(detailData.labels);
  1108. historyItem.tagType = tagType;
  1109. } else {
  1110. console.error("获取详情失败:", detailResponse.message);
  1111. showError("获取记录详情失败");
  1112. // 使用历史记录中的基础数据作为后备
  1113. detectionResult.value = {
  1114. scene_name: historyItem.tagType || 'simple_supported_bridge',
  1115. labels: historyItem.labels,
  1116. total_detections: 0,
  1117. third_scenes: []
  1118. };
  1119. const fallbackImageUrl = historyItem.recognitionImageUrl || historyItem.originalImageUrl;
  1120. annotatedImageUrl.value = fallbackImageUrl;
  1121. }
  1122. // 更新数据层的active状态
  1123. historyData.value.forEach((item) => {
  1124. item.isActive = item.id === historyItem.id;
  1125. });
  1126. console.log("历史记录状态已更新");
  1127. } catch (error) {
  1128. console.error("处理历史记录失败:", error);
  1129. showError("获取记录详情失败");
  1130. } finally {
  1131. isLoadingDetail.value = false;
  1132. isImageLoading.value = false;
  1133. }
  1134. };
  1135. // 根据标签获取标签类型
  1136. const getTagTypeFromLabels = (labels) => {
  1137. if (!labels) return 'gas_station';
  1138. let labelStr = '';
  1139. if (Array.isArray(labels)) {
  1140. labelStr = labels.join(' ').toLowerCase();
  1141. } else {
  1142. labelStr = String(labels).toLowerCase();
  1143. }
  1144. if (labelStr.includes('隧道')) return 'tunnel';
  1145. if (labelStr.includes('桥梁')) return 'simple_supported_bridge';
  1146. if (labelStr.includes('加油站')) return 'gas_station';
  1147. if (labelStr.includes('设备')) return 'special_equipment';
  1148. if (labelStr.includes('高速')) return 'operate_highway';
  1149. return 'simple_supported_bridge';
  1150. };
  1151. // 获取当前时间
  1152. const getCurrentTime = () => {
  1153. const now = new Date();
  1154. const month = now.getMonth() + 1;
  1155. const day = now.getDate();
  1156. const hours = now.getHours().toString().padStart(2, '0');
  1157. const minutes = now.getMinutes().toString().padStart(2, '0');
  1158. return `${month}月${day}日 ${hours}:${minutes}`;
  1159. };
  1160. // 处理主图片加载错误
  1161. const handleMainImageError = (event) => {
  1162. try {
  1163. console.log("主图片加载失败");
  1164. // 可以在这里添加其他错误处理逻辑
  1165. } catch (error) {
  1166. console.error("处理主图片错误失败:", error);
  1167. }
  1168. };
  1169. // 打开图片预览
  1170. const openImagePreview = () => {
  1171. try {
  1172. console.log("openImagePreview 被调用");
  1173. showImagePreview.value = true;
  1174. console.log("图片预览已打开");
  1175. } catch (error) {
  1176. console.error("打开图片预览失败:", error);
  1177. }
  1178. };
  1179. // 关闭图片预览
  1180. const closeImagePreview = () => {
  1181. try {
  1182. console.log("closeImagePreview 被调用");
  1183. showImagePreview.value = false;
  1184. console.log("图片预览已关闭");
  1185. } catch (error) {
  1186. console.error("关闭图片预览失败:", error);
  1187. }
  1188. };
  1189. // 打开示例弹窗
  1190. const openExampleModal = async (hazardInfo) => {
  1191. try {
  1192. console.log("openExampleModal 被调用,隐患信息:", hazardInfo);
  1193. selectedHazard.value = hazardInfo;
  1194. // 开始加载示例图
  1195. isLoadingExample.value = true;
  1196. // 调用API获取示例图
  1197. const response = await apis.getThirdSceneExampleImage({
  1198. third_scene_name: hazardInfo.description
  1199. });
  1200. console.log("获取示例图响应:", response);
  1201. if (response.statusCode === 200) {
  1202. const exampleData = response.data;
  1203. // 检查是否有示例图数据
  1204. if (exampleData && (exampleData.correct_example_image || exampleData.error_example_image)) {
  1205. exampleImages.value = {
  1206. correctImageUrl: exampleData.correct_example_image || '',
  1207. errorImageUrl: exampleData.error_example_image || ''
  1208. };
  1209. // 设置图片加载状态
  1210. imageLoadingStates.value = {
  1211. correct: !!exampleData.correct_example_image,
  1212. error: !!exampleData.error_example_image
  1213. };
  1214. showExampleModal.value = true;
  1215. console.log("示例弹窗已打开,示例图数据:", exampleImages.value);
  1216. } else {
  1217. showWarning("暂无示例图");
  1218. console.log("没有找到示例图数据");
  1219. }
  1220. } else {
  1221. showError("获取示例图失败: " + (response.msg || "未知错误"));
  1222. console.error("获取示例图失败:", response.msg);
  1223. }
  1224. } catch (error) {
  1225. console.error("打开示例弹窗失败:", error);
  1226. showError("获取示例图失败,请稍后重试");
  1227. } finally {
  1228. isLoadingExample.value = false;
  1229. }
  1230. };
  1231. // 关闭示例弹窗
  1232. const closeExampleModal = () => {
  1233. try {
  1234. console.log("closeExampleModal 被调用");
  1235. showExampleModal.value = false;
  1236. exampleImages.value = {};
  1237. selectedHazard.value = null;
  1238. isLoadingExample.value = false;
  1239. imageLoadingStates.value = { correct: false, error: false };
  1240. console.log("示例弹窗已关闭");
  1241. } catch (error) {
  1242. console.error("关闭示例弹窗失败:", error);
  1243. }
  1244. };
  1245. // 处理图片加载错误
  1246. const handleImageError = (event, type) => {
  1247. console.log(`图片加载失败 (${type}):`, event.target.src);
  1248. if (type === 'correct') {
  1249. exampleImages.value.correctImageUrl = '';
  1250. imageLoadingStates.value.correct = false;
  1251. } else if (type === 'error') {
  1252. exampleImages.value.errorImageUrl = '';
  1253. imageLoadingStates.value.error = false;
  1254. }
  1255. };
  1256. // 处理图片加载完成
  1257. const handleImageLoad = (event, type) => {
  1258. const img = event.target;
  1259. const aspectRatio = img.naturalWidth / img.naturalHeight;
  1260. if (aspectRatio > 1) {
  1261. img.setAttribute('data-orientation', 'landscape');
  1262. console.log(`图片加载完成 (${type}): 横图, 宽高比: ${aspectRatio.toFixed(2)}`);
  1263. } else {
  1264. img.setAttribute('data-orientation', 'portrait');
  1265. console.log(`图片加载完成 (${type}): 竖图, 宽高比: ${aspectRatio.toFixed(2)}`);
  1266. }
  1267. if (type === 'correct') {
  1268. imageLoadingStates.value.correct = false;
  1269. } else if (type === 'error') {
  1270. imageLoadingStates.value.error = false;
  1271. }
  1272. };
  1273. // 打开点评弹窗
  1274. const openEvaluationModal = async () => {
  1275. try {
  1276. console.log("打开点评弹窗");
  1277. showEvaluationModal.value = true;
  1278. // 如果当前记录已点评,则回显后端数据并禁用交互
  1279. if (selectedHistoryItem.value?.effect_evaluation > 0) {
  1280. await loadEvaluationData();
  1281. } else {
  1282. // 未点评则重置数据
  1283. evaluationData.value = {
  1284. sceneMatch: null,
  1285. promptAccurate: null,
  1286. rating: 0,
  1287. userRemark: ''
  1288. };
  1289. }
  1290. } catch (error) {
  1291. console.error("打开点评弹窗失败:", error);
  1292. }
  1293. };
  1294. // 加载点评数据(回显PC端逻辑)
  1295. const loadEvaluationData = async () => {
  1296. try {
  1297. if (!selectedHistoryItem.value?.id) return
  1298. console.log('📝 开始加载点评数据,记录ID:', selectedHistoryItem.value.id)
  1299. try {
  1300. const detailResponse = await apis.getRecognitionRecordDetail({
  1301. recognition_record_id: selectedHistoryItem.value.id
  1302. })
  1303. console.log('📝 详情接口响应:', detailResponse)
  1304. if (detailResponse.statusCode === 200 || detailResponse.code === 200) {
  1305. const detailData = detailResponse.data
  1306. console.log('📝 移动端加载点评数据:', detailData)
  1307. console.log('📝 用户备注字段:', detailData.user_remark)
  1308. evaluationData.value = {
  1309. sceneMatch: detailData.scene_match === 1,
  1310. promptAccurate: detailData.tip_accuracy === 1,
  1311. rating: detailData.effect_evaluation || 0,
  1312. userRemark: detailData.user_remark || ''
  1313. }
  1314. console.log('📝 设置后的evaluationData:', evaluationData.value)
  1315. } else {
  1316. console.warn('📝 详情接口返回错误:', detailResponse)
  1317. // 使用历史记录中的基础数据作为后备
  1318. evaluationData.value = {
  1319. sceneMatch: null,
  1320. promptAccurate: null,
  1321. rating: selectedHistoryItem.value.effect_evaluation || 0,
  1322. userRemark: ''
  1323. }
  1324. }
  1325. } catch (apiError) {
  1326. console.error('📝 详情接口调用失败:', apiError)
  1327. // 如果详情接口调用失败,使用历史记录中的基础数据
  1328. evaluationData.value = {
  1329. sceneMatch: null,
  1330. promptAccurate: null,
  1331. rating: selectedHistoryItem.value.effect_evaluation || 0,
  1332. userRemark: ''
  1333. }
  1334. }
  1335. // 同步本地selectedHistoryItem的状态,保证页内与外层抽屉一致
  1336. if (selectedHistoryItem.value) {
  1337. selectedHistoryItem.value.effect_evaluation = evaluationData.value.rating
  1338. }
  1339. // 同步historyData对应条目
  1340. const historyItem = historyData.value.find(item => item.id === selectedHistoryItem.value?.id)
  1341. if (historyItem) {
  1342. historyItem.effect_evaluation = evaluationData.value.rating
  1343. }
  1344. } catch (error) {
  1345. console.error('📝 加载点评数据失败:', error)
  1346. // 出错时使用默认值
  1347. evaluationData.value = {
  1348. sceneMatch: null,
  1349. promptAccurate: null,
  1350. rating: selectedHistoryItem.value?.effect_evaluation || 0,
  1351. userRemark: ''
  1352. }
  1353. }
  1354. }
  1355. // 关闭点评弹窗
  1356. const closeEvaluationModal = () => {
  1357. try {
  1358. console.log("关闭点评弹窗");
  1359. showEvaluationModal.value = false;
  1360. } catch (error) {
  1361. console.error("关闭点评弹窗失败:", error);
  1362. }
  1363. };
  1364. // 提交评价
  1365. const submitEvaluation = async () => {
  1366. try {
  1367. console.log("提交评价:", evaluationData.value);
  1368. // 验证是否所有问题都已回答
  1369. if (evaluationData.value.sceneMatch === null ||
  1370. evaluationData.value.promptAccurate === null ||
  1371. evaluationData.value.rating === 0) {
  1372. showWarning("请完成所有评价项目");
  1373. return;
  1374. }
  1375. // 调用后端API提交评价
  1376. const response = await apis.submitEvaluation({
  1377. id: selectedHistoryItem.value?.id,
  1378. scene_match: evaluationData.value.sceneMatch ? 1 : 0,
  1379. tip_accuracy: evaluationData.value.promptAccurate ? 1 : 0,
  1380. effect_evaluation: evaluationData.value.rating,
  1381. user_remark: evaluationData.value.userRemark
  1382. });
  1383. if (response.statusCode === 200 || response.code === 200) {
  1384. showSuccess("评价提交成功");
  1385. // 更新当前历史记录的点评状态
  1386. if (selectedHistoryItem.value) {
  1387. selectedHistoryItem.value.effect_evaluation = evaluationData.value.rating;
  1388. }
  1389. // 更新历史记录列表中的对应项
  1390. const historyItem = historyData.value.find(item => item.id === selectedHistoryItem.value?.id);
  1391. if (historyItem) {
  1392. historyItem.effect_evaluation = evaluationData.value.rating;
  1393. }
  1394. // 关闭弹窗
  1395. closeEvaluationModal();
  1396. } else {
  1397. showError("评价提交失败: " + (response.msg || "未知错误"));
  1398. }
  1399. } catch (error) {
  1400. console.error("提交评价失败:", error);
  1401. showError("评价提交失败,请稍后重试");
  1402. }
  1403. };
  1404. // 删除历史记录
  1405. const deleteHistoryItem = async (historyItem, index) => {
  1406. try {
  1407. console.log('开始删除移动端历史记录:', historyItem)
  1408. const response = await apis.deleteRecognitionRecord({
  1409. recognition_record_id: historyItem.id
  1410. })
  1411. if (response.statusCode === 200) {
  1412. // 从本地数据中移除
  1413. historyData.value.splice(index, 1)
  1414. historyTotal.value = Math.max(0, historyTotal.value - 1)
  1415. // 如果删除的是当前激活的历史记录,执行新建任务
  1416. if (historyItem.isActive) {
  1417. console.log('删除激活的历史记录,执行新建任务')
  1418. createNewTask()
  1419. }
  1420. console.log('✅ 移动端历史记录删除成功')
  1421. showSuccess('删除成功')
  1422. } else {
  1423. console.error('❌ 删除移动端历史记录删除失败:', response)
  1424. }
  1425. } catch (error) {
  1426. console.error('❌ 删除移动端历史记录失败:', error)
  1427. }
  1428. }
  1429. // 页面加载时不再自动加载历史记录,改为点击菜单时加载
  1430. onMounted(async () => {
  1431. try {
  1432. console.log('🚀 移动端隐患提示页面初始化完成')
  1433. } catch (error) {
  1434. console.error('❌ 移动端隐患提示页面初始化失败:', error)
  1435. }
  1436. })
  1437. // 监听历史记录抽屉显示状态,显示时加载数据
  1438. watch(showHistory, async (newVal) => {
  1439. if (newVal && historyData.value.length === 0) {
  1440. console.log('📋 历史记录抽屉打开,开始加载数据...')
  1441. await getHistoryRecordList()
  1442. }
  1443. })
  1444. </script>
  1445. <style lang="less" scoped>
  1446. .mobile-hazard-detection {
  1447. height: 100vh;
  1448. background: #EBF3FF;
  1449. font-family: "Alibaba PuHuiTi 3.0", sans-serif;
  1450. overflow-y: auto;
  1451. overflow-x: hidden;
  1452. }
  1453. .mobile-content {
  1454. padding: 16px;
  1455. position: relative;
  1456. min-height: calc(100vh - 60px);
  1457. }
  1458. // 选项卡样式
  1459. .process-tabs {
  1460. display: flex;
  1461. background: white;
  1462. border-radius: 12px;
  1463. padding: 4px;
  1464. margin-bottom: 16px;
  1465. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
  1466. .tab-item {
  1467. flex: 1;
  1468. padding: 12px 16px;
  1469. text-align: center;
  1470. border-radius: 8px;
  1471. cursor: pointer;
  1472. transition: all 0.3s ease;
  1473. font-size: 14px;
  1474. font-weight: 500;
  1475. color: #6b7280;
  1476. &.active {
  1477. background: #3e7bfa;
  1478. color: white;
  1479. box-shadow: 0 2px 4px rgba(62, 123, 250, 0.3);
  1480. }
  1481. &:hover:not(.active) {
  1482. color: #3e7bfa;
  1483. background: rgba(62, 123, 250, 0.05);
  1484. }
  1485. }
  1486. }
  1487. // 主界面布局
  1488. .main-layout {
  1489. .hazard-system {
  1490. background: white;
  1491. border-radius: 16px;
  1492. padding: 20px;
  1493. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
  1494. .system-header {
  1495. text-align: center;
  1496. margin-bottom: 32px;
  1497. h3 {
  1498. font-size: 20px;
  1499. font-weight: 600;
  1500. color: #1f2937;
  1501. margin: 0 0 10px 0;
  1502. }
  1503. p {
  1504. font-size: 14px;
  1505. color: #6b7280;
  1506. margin: 0;
  1507. line-height: 1.4;
  1508. }
  1509. }
  1510. .step-section {
  1511. margin-bottom: 24px;
  1512. h4 {
  1513. font-size: 16px;
  1514. font-weight: 600;
  1515. color: #1f2937;
  1516. margin: 0 0 8px 0;
  1517. }
  1518. .step-description {
  1519. font-size: 14px;
  1520. color: #6b7280;
  1521. margin: 0 0 12px 0;
  1522. line-height: 1.4;
  1523. }
  1524. .scenario-tags {
  1525. display: flex;
  1526. flex-wrap: wrap;
  1527. gap: 12px;
  1528. .scenario-tag {
  1529. padding: 12px 16px;
  1530. border-radius: 8px;
  1531. font-size: 14px;
  1532. font-weight: 500;
  1533. cursor: pointer;
  1534. transition: all 0.3s ease;
  1535. border: 2px solid transparent;
  1536. background: #f8faff;
  1537. color: #1f2937;
  1538. flex: 1;
  1539. min-width: calc(50% - 6px);
  1540. text-align: center;
  1541. &.compact {
  1542. min-width: calc(50% - 6px);
  1543. flex: 0 1 auto;
  1544. }
  1545. &.active {
  1546. background: rgba(62, 123, 250, 0.1);
  1547. color: #3366e6;
  1548. border-color: #3366e6;
  1549. box-shadow: 0 2px 8px rgba(62, 123, 250, 0.2);
  1550. }
  1551. &.disabled {
  1552. background: #f3f4f6;
  1553. color: #9ca3af;
  1554. border-color: #d1d5db;
  1555. cursor: not-allowed;
  1556. opacity: 0.6;
  1557. &:hover {
  1558. transform: none;
  1559. box-shadow: none;
  1560. }
  1561. }
  1562. &.identifying-disabled {
  1563. background: #f3f4f6;
  1564. color: #9ca3af;
  1565. border-color: #d1d5db;
  1566. cursor: not-allowed;
  1567. opacity: 0.6;
  1568. pointer-events: none;
  1569. &:hover {
  1570. transform: none;
  1571. box-shadow: none;
  1572. }
  1573. }
  1574. &:hover:not(.disabled):not(.identifying-disabled) {
  1575. transform: translateY(-1px);
  1576. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  1577. }
  1578. }
  1579. }
  1580. .upload-area {
  1581. position: relative;
  1582. border: 2px dashed #3e7bfa;
  1583. border-radius: 12px;
  1584. margin-top: 12px;
  1585. height: 300px;
  1586. text-align: center;
  1587. cursor: pointer;
  1588. transition: all 0.3s ease;
  1589. &:hover {
  1590. border-color: #3e7bfa;
  1591. background: rgba(62, 123, 250, 0.02);
  1592. }
  1593. &.drag-over {
  1594. border-color: #3e7bfa;
  1595. background: rgba(62, 123, 250, 0.05);
  1596. box-shadow: 0 0 10px rgba(62, 123, 250, 0.2);
  1597. }
  1598. .uploaded-image-container {
  1599. position: relative;
  1600. width: 100%;
  1601. height: 100%;
  1602. display: flex;
  1603. align-items: center;
  1604. justify-content: center;
  1605. background-color: #f1f6ff;
  1606. border-radius: 8px;
  1607. overflow: hidden;
  1608. .uploaded-image {
  1609. width: 100%;
  1610. height: 100%;
  1611. object-fit: contain;
  1612. border-radius: 8px;
  1613. }
  1614. .image-overlay {
  1615. position: absolute;
  1616. top: 0;
  1617. left: 0;
  1618. width: 100%;
  1619. height: 100%;
  1620. background: rgba(0, 0, 0, 0.5);
  1621. display: flex;
  1622. align-items: center;
  1623. justify-content: center;
  1624. border-radius: 8px;
  1625. cursor: pointer;
  1626. opacity: 0;
  1627. transition: opacity 0.3s ease;
  1628. .change-image-btn {
  1629. background: #3e7bfa;
  1630. color: white;
  1631. border: none;
  1632. border-radius: 8px;
  1633. padding: 6px 12px;
  1634. font-size: 12px;
  1635. font-weight: 600;
  1636. cursor: pointer;
  1637. transition: background-color 0.3s ease;
  1638. &:hover {
  1639. background: #3366e6;
  1640. }
  1641. }
  1642. }
  1643. &:hover .image-overlay {
  1644. opacity: 1;
  1645. }
  1646. }
  1647. .upload-content {
  1648. display: flex;
  1649. flex-direction: column;
  1650. align-items: center;
  1651. justify-content: center;
  1652. height: 100%;
  1653. padding: 20px;
  1654. .upload-icon {
  1655. width: 40px;
  1656. height: 40px;
  1657. margin-bottom: 10px;
  1658. }
  1659. .upload-text {
  1660. font-size: 14px;
  1661. font-weight: 600;
  1662. color: #2c3e50;
  1663. margin: 0 0 6px 0;
  1664. }
  1665. .upload-format {
  1666. font-size: 12px;
  1667. color: #6b7280;
  1668. margin: 0 0 20px 0;
  1669. }
  1670. .select-file-btn {
  1671. background: #e5eeff;
  1672. border: 1px solid #3e7bfa;
  1673. border-radius: 8px;
  1674. width: 120px;
  1675. height: 36px;
  1676. font-size: 14px;
  1677. color: #3e7bfa;
  1678. cursor: pointer;
  1679. transition: all 0.3s ease;
  1680. &:hover {
  1681. background: #e5e7eb;
  1682. border-color: #9ca3af;
  1683. }
  1684. }
  1685. }
  1686. }
  1687. .upload-status {
  1688. position: absolute;
  1689. top: 0;
  1690. left: 0;
  1691. width: 100%;
  1692. height: 100%;
  1693. background: rgba(255, 255, 255, 0.7);
  1694. display: flex;
  1695. flex-direction: column;
  1696. align-items: center;
  1697. justify-content: center;
  1698. border-radius: 12px;
  1699. z-index: 10;
  1700. backdrop-filter: blur(1px);
  1701. .loading-spinner {
  1702. border: 4px solid #f8f9fa;
  1703. border-top: 4px solid #3e7bfa;
  1704. border-radius: 50%;
  1705. width: 32px;
  1706. height: 32px;
  1707. animation: spin 1s linear infinite;
  1708. margin-bottom: 8px;
  1709. }
  1710. p {
  1711. font-size: 14px;
  1712. color: #4b5563;
  1713. font-weight: 500;
  1714. }
  1715. }
  1716. }
  1717. .action-section {
  1718. text-align: center;
  1719. margin-top: 24px;
  1720. .start-identify-btn {
  1721. position: relative;
  1722. border: none;
  1723. background: none;
  1724. cursor: pointer;
  1725. padding: 0;
  1726. width: 100%;
  1727. display: flex;
  1728. justify-content: center;
  1729. align-items: center;
  1730. transition: opacity 0.3s ease;
  1731. &.btn-disabled {
  1732. opacity: 0.6;
  1733. cursor: not-allowed;
  1734. pointer-events: none;
  1735. }
  1736. .btn-bg {
  1737. width: 100%;
  1738. border-radius: 12px;
  1739. // max-width: 340px;
  1740. height: 60px;
  1741. object-fit: cover;
  1742. margin: 0 auto;
  1743. display: block;
  1744. }
  1745. }
  1746. }
  1747. }
  1748. // 使用流程页面样式
  1749. .process-content {
  1750. background: white;
  1751. border-radius: 16px;
  1752. padding: 20px;
  1753. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
  1754. .process-header {
  1755. text-align: center;
  1756. margin-bottom: 24px;
  1757. h3 {
  1758. font-size: 20px;
  1759. font-weight: 600;
  1760. color: #1f2937;
  1761. margin: 0 0 8px 0;
  1762. }
  1763. p {
  1764. font-size: 14px;
  1765. color: #6b7280;
  1766. margin: 0;
  1767. }
  1768. }
  1769. .process-steps {
  1770. margin-bottom: 24px;
  1771. .step-item {
  1772. display: flex;
  1773. align-items: center;
  1774. margin-bottom: 16px;
  1775. .step-number {
  1776. width: 32px;
  1777. height: 32px;
  1778. background: #3e7bfa;
  1779. color: white;
  1780. border-radius: 50%;
  1781. display: flex;
  1782. align-items: center;
  1783. justify-content: center;
  1784. font-weight: 600;
  1785. font-size: 14px;
  1786. margin-right: 12px;
  1787. flex-shrink: 0;
  1788. }
  1789. .step-text {
  1790. flex: 1;
  1791. h4 {
  1792. font-size: 18px;
  1793. font-weight: 600;
  1794. color: #1f2937;
  1795. margin: 0 0 4px 0;
  1796. }
  1797. p {
  1798. font-size: 16px;
  1799. color: #6b7280;
  1800. margin: 0;
  1801. line-height: 1.4;
  1802. }
  1803. }
  1804. }
  1805. }
  1806. .process-image-section {
  1807. text-align: center;
  1808. .process-image {
  1809. width: 100%;
  1810. max-width: 300px;
  1811. height: auto;
  1812. border-radius: 8px;
  1813. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  1814. }
  1815. }
  1816. }
  1817. }
  1818. // 详情页布局
  1819. .detail-layout {
  1820. .detail-header {
  1821. display: flex;
  1822. justify-content: space-between;
  1823. align-items: center;
  1824. padding: 12px 0;
  1825. margin-bottom: 16px;
  1826. .header-left {
  1827. display: flex;
  1828. align-items: center;
  1829. gap: 12px;
  1830. .header-icon {
  1831. width: 32px;
  1832. height: 32px;
  1833. }
  1834. .header-text {
  1835. display: flex;
  1836. flex-direction: column;
  1837. gap: 4px;
  1838. .main-title {
  1839. font-size: 16px;
  1840. font-weight: 600;
  1841. color: #1f2937;
  1842. line-height: 1.2;
  1843. }
  1844. .sub-title-tag {
  1845. font-size: 10px;
  1846. line-height: 10px;
  1847. text-align: center;
  1848. padding:4px 0px;
  1849. border-radius: 4px;
  1850. font-weight: 500;
  1851. flex-shrink: 0;
  1852. &.tag-tunnel {
  1853. background: rgba(62, 123, 250, 0.1);
  1854. color: #3366e6;
  1855. }
  1856. &.tag-bridge {
  1857. background: rgba(34, 184, 80, 0.1);
  1858. color: #22b850;
  1859. }
  1860. &.tag-equipment {
  1861. background: rgba(0, 128, 255, 0.1);
  1862. color: #0080ff;
  1863. }
  1864. &.tag-highway {
  1865. background: rgba(114, 46, 209, 0.1);
  1866. color: #722ed1;
  1867. }
  1868. &.tag-gas-station {
  1869. background: rgba(255, 77, 79, 0.1);
  1870. color: #ff4d4f;
  1871. }
  1872. }
  1873. }
  1874. }
  1875. .header-right {
  1876. .current-time {
  1877. font-size: 12px;
  1878. color: #6b7280;
  1879. }
  1880. }
  1881. }
  1882. .detail-content {
  1883. background: white;
  1884. border-radius: 16px;
  1885. padding: 16px;
  1886. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
  1887. position: relative;
  1888. .loading-overlay {
  1889. position: absolute;
  1890. top: 0;
  1891. left: 0;
  1892. width: 100%;
  1893. height: 100%;
  1894. background: rgba(255, 255, 255, 0.9);
  1895. display: flex;
  1896. flex-direction: column;
  1897. align-items: center;
  1898. justify-content: center;
  1899. z-index: 100;
  1900. .loading-spinner {
  1901. border: 4px solid #f3f3f3;
  1902. border-top: 4px solid #3e7bfa;
  1903. border-radius: 50%;
  1904. width: 32px;
  1905. height: 32px;
  1906. animation: spin 1s linear infinite;
  1907. margin-bottom: 12px;
  1908. }
  1909. p {
  1910. font-size: 14px;
  1911. color: #666;
  1912. margin: 0;
  1913. }
  1914. }
  1915. .image-section {
  1916. background-color: #f1f6ff;
  1917. border-radius: 8px;
  1918. padding: 12px 0;
  1919. text-align: center;
  1920. margin-bottom: 16px;
  1921. position: relative;
  1922. .evaluation-status {
  1923. position: absolute;
  1924. top: 8px;
  1925. right: 8px;
  1926. z-index: 10;
  1927. cursor: pointer;
  1928. .status-badge {
  1929. display: inline-block;
  1930. padding: 3px 8px;
  1931. border-radius: 8px;
  1932. font-size: 10px;
  1933. font-weight: 500;
  1934. line-height: 1.2;
  1935. box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
  1936. &.evaluated {
  1937. background: #d1fae5;
  1938. color: #065f46;
  1939. border: 1px solid #a7f3d0;
  1940. }
  1941. &.not-evaluated {
  1942. background: #fef3c7;
  1943. color: #92400e;
  1944. border: 1px solid #fde68a;
  1945. }
  1946. }
  1947. }
  1948. .image-container {
  1949. position: relative;
  1950. display: inline-block;
  1951. max-width: 280px;
  1952. height: 200px;
  1953. border-radius: 8px;
  1954. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  1955. border: 2px dashed #2c8d6980;
  1956. padding: 4px;
  1957. overflow: hidden;
  1958. }
  1959. .original-image, .main-image {
  1960. width: 100%;
  1961. height: 100%;
  1962. border-radius: 8px;
  1963. object-fit: contain;
  1964. transform: none !important;
  1965. }
  1966. .main-image {
  1967. cursor: pointer;
  1968. }
  1969. .scanning-overlay {
  1970. position: absolute;
  1971. top: 0;
  1972. left: 0;
  1973. width: 100%;
  1974. height: 100%;
  1975. pointer-events: none;
  1976. overflow: hidden;
  1977. border-radius: 8px;
  1978. .scanning-line {
  1979. position: absolute;
  1980. top: 0;
  1981. left: 0;
  1982. width: 100%;
  1983. height: 2px;
  1984. background: linear-gradient(90deg,
  1985. transparent 0%,
  1986. #3e7bfa 20%,
  1987. #3e7bfa 80%,
  1988. transparent 100%
  1989. );
  1990. box-shadow: 0 0 8px rgba(62, 123, 250, 0.8);
  1991. animation: scanning 2s ease-in-out 3;
  1992. }
  1993. }
  1994. }
  1995. .analysis-section {
  1996. background-color: #F9FAFB;
  1997. border-left: 3px solid #3B82F6;
  1998. padding: 16px 10px;
  1999. margin-bottom: 16px;
  2000. .analysis-header {
  2001. display: flex;
  2002. align-items: center;
  2003. gap: 8px;
  2004. margin-bottom: 8px;
  2005. .warning-icon {
  2006. width: 24px;
  2007. height: 24px;
  2008. }
  2009. h3 {
  2010. font-size: 16px;
  2011. font-weight: 600;
  2012. color: #1f2937;
  2013. margin: 0;
  2014. }
  2015. }
  2016. .analysis-content {
  2017. .scene-info {
  2018. font-size: 14px;
  2019. color: #374151;
  2020. margin-left: 32px;
  2021. word-wrap: break-word;
  2022. .scene-tags {
  2023. color: #3E7BFA;
  2024. font-weight: 500;
  2025. }
  2026. .detection-count {
  2027. color: #3E7BFA;
  2028. font-weight: 600;
  2029. margin-left: 8px;
  2030. }
  2031. .streaming-text {
  2032. color: #3E7BFA;
  2033. font-weight: 600;
  2034. border-right: 2px solid #3E7BFA;
  2035. animation: blink 1s infinite;
  2036. }
  2037. .streaming-analysis-text {
  2038. color: #374151;
  2039. font-weight: 400;
  2040. border-right: 2px solid #374151;
  2041. animation: blink 1s infinite;
  2042. }
  2043. .analysis-prompt {
  2044. color: #3E7BFA;
  2045. font-weight: 500;
  2046. font-style: italic;
  2047. }
  2048. .loading-labels {
  2049. display: inline-flex;
  2050. align-items: center;
  2051. color: #3E7BFA;
  2052. font-weight: 600;
  2053. margin-left: 8px;
  2054. }
  2055. .loading-dots {
  2056. display: inline-block;
  2057. width: 3px;
  2058. height: 3px;
  2059. border-radius: 50%;
  2060. background-color: #3E7BFA;
  2061. animation: loadingDots 1.4s infinite ease-in-out both;
  2062. margin-left: 1px;
  2063. }
  2064. .loading-dots::before,
  2065. .loading-dots::after {
  2066. content: '';
  2067. position: absolute;
  2068. width: 3px;
  2069. height: 3px;
  2070. border-radius: 50%;
  2071. background-color: #3E7BFA;
  2072. animation: loadingDots 1.4s infinite ease-in-out both;
  2073. }
  2074. .loading-dots::before {
  2075. left: -6px;
  2076. animation-delay: -0.32s;
  2077. }
  2078. .loading-dots::after {
  2079. left: 6px;
  2080. animation-delay: 0.32s;
  2081. }
  2082. }
  2083. }
  2084. }
  2085. .hazards-section {
  2086. background-color: #F9FAFB;
  2087. padding: 12px;
  2088. border-radius: 8px;
  2089. .hazards-header {
  2090. margin-bottom: 12px;
  2091. h3 {
  2092. font-size: 16px;
  2093. font-weight: 600;
  2094. color: #1f2937;
  2095. margin: 0;
  2096. }
  2097. }
  2098. .hazards-content {
  2099. position: relative; /* 为遮罩层提供定位基准 */
  2100. &.scanning-mode {
  2101. min-height: 120px; /* 扫描期间保持最小高度 */
  2102. }
  2103. .hazard-item {
  2104. .hazard-line {
  2105. display: flex;
  2106. align-items: center;
  2107. gap: 8px;
  2108. margin-bottom: 8px;
  2109. .hazard-number {
  2110. width: 20px;
  2111. height: 20px;
  2112. background: #3E7BFA;
  2113. border-radius: 50%;
  2114. display: flex;
  2115. align-items: center;
  2116. justify-content: center;
  2117. font-size: 12px;
  2118. color: #FFFFFF;
  2119. flex-shrink: 0;
  2120. }
  2121. .hazard-desc {
  2122. flex: 1;
  2123. font-size: 14px;
  2124. color: #000000;
  2125. line-height: 1.4;
  2126. word-wrap: break-word;
  2127. }
  2128. .example-btn {
  2129. background: #3468E710;
  2130. color: #3468E7;
  2131. border: none;
  2132. border-radius: 6px;
  2133. width: 40px;
  2134. height: 20px;
  2135. font-size: 12px;
  2136. cursor: pointer;
  2137. transition: background-color 0.3s ease;
  2138. flex-shrink: 0;
  2139. &:hover {
  2140. background: #3468E720;
  2141. }
  2142. }
  2143. }
  2144. }
  2145. /* 隐患区域加载遮罩层样式 */
  2146. .hazards-loading-overlay {
  2147. position: absolute;
  2148. top: 0;
  2149. left: 0;
  2150. width: 100%;
  2151. height: 100%;
  2152. background: rgba(249, 250, 251, 0.9);
  2153. display: flex;
  2154. flex-direction: column;
  2155. align-items: center;
  2156. justify-content: center;
  2157. z-index: 10;
  2158. border-radius: 8px;
  2159. backdrop-filter: blur(2px);
  2160. .loading-spinner {
  2161. border: 3px solid #f3f3f3;
  2162. border-top: 3px solid #3e7bfa;
  2163. border-radius: 50%;
  2164. width: 32px;
  2165. height: 32px;
  2166. animation: spin 1s linear infinite;
  2167. margin-bottom: 12px;
  2168. }
  2169. p {
  2170. font-size: 14px;
  2171. color: #6b7280;
  2172. margin: 0;
  2173. font-weight: 500;
  2174. }
  2175. }
  2176. }
  2177. }
  2178. }
  2179. }
  2180. // 弹窗样式
  2181. .image-preview-overlay {
  2182. position: fixed;
  2183. top: 0;
  2184. left: 0;
  2185. width: 100%;
  2186. height: 100%;
  2187. background: rgba(0, 0, 0, 0.8);
  2188. display: flex;
  2189. align-items: center;
  2190. justify-content: center;
  2191. z-index: 1000;
  2192. cursor: pointer;
  2193. }
  2194. .preview-image {
  2195. max-width: 90%;
  2196. max-height: 90%;
  2197. object-fit: contain;
  2198. border-radius: 8px;
  2199. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
  2200. transform: none !important;
  2201. }
  2202. .example-modal-overlay {
  2203. position: fixed;
  2204. top: 0;
  2205. left: 0;
  2206. width: 100%;
  2207. height: 100%;
  2208. background: rgba(0, 0, 0, 0.5);
  2209. display: flex;
  2210. align-items: center;
  2211. justify-content: center;
  2212. z-index: 1000;
  2213. }
  2214. .example-modal {
  2215. width: 90%;
  2216. max-width: 500px;
  2217. max-height: 80%;
  2218. background: white;
  2219. border-radius: 16px;
  2220. box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
  2221. overflow: hidden;
  2222. }
  2223. .modal-header {
  2224. display: flex;
  2225. align-items: center;
  2226. justify-content: space-between;
  2227. padding: 16px 20px;
  2228. .modal-title {
  2229. font-size: 18px;
  2230. font-weight: 600;
  2231. color: #1f2937;
  2232. text-align: center
  2233. }
  2234. .close-icon {
  2235. width: 24px;
  2236. height: 24px;
  2237. cursor: pointer;
  2238. transition: opacity 0.3s ease;
  2239. &:hover {
  2240. opacity: 0.7;
  2241. }
  2242. }
  2243. }
  2244. .modal-hazard-info {
  2245. display: flex;
  2246. align-items: center;
  2247. justify-content: center;
  2248. // gap: 8px;
  2249. margin-top: 16px;
  2250. margin-bottom: 8px;
  2251. .hazard-number {
  2252. width: 20px;
  2253. height: 20px;
  2254. background: #3E7BFA;
  2255. border-radius: 50%;
  2256. display: flex;
  2257. align-items: center;
  2258. justify-content: center;
  2259. font-size: 12px;
  2260. font-weight: 600;
  2261. color: #FFFFFF;
  2262. flex-shrink: 0;
  2263. }
  2264. .hazard-description {
  2265. font-size: 14px;
  2266. color: #374151;
  2267. line-height: 1.4;
  2268. text-align: center;
  2269. padding: 0 8px;
  2270. }
  2271. }
  2272. .modal-body {
  2273. padding: 0 20px 20px;
  2274. height: calc(100% - 120px);
  2275. .loading-overlay {
  2276. display: flex;
  2277. flex-direction: column;
  2278. align-items: center;
  2279. justify-content: center;
  2280. height: 200px;
  2281. .loading-spinner {
  2282. border: 4px solid #f3f3f3;
  2283. border-top: 4px solid #3e7bfa;
  2284. border-radius: 50%;
  2285. width: 32px;
  2286. height: 32px;
  2287. animation: spin 1s linear infinite;
  2288. margin-bottom: 12px;
  2289. }
  2290. p {
  2291. font-size: 14px;
  2292. color: #666;
  2293. margin: 0;
  2294. }
  2295. }
  2296. .example-images {
  2297. display: flex;
  2298. gap: 12px;
  2299. .example-panel {
  2300. flex: 1;
  2301. .panel-image {
  2302. position: relative;
  2303. height: 150px;
  2304. border-radius: 8px;
  2305. overflow: hidden;
  2306. img {
  2307. width: 100%;
  2308. height: 100%;
  2309. object-fit: cover;
  2310. border-radius: 8px;
  2311. }
  2312. .no-image-placeholder {
  2313. width: 100%;
  2314. height: 100%;
  2315. display: flex;
  2316. align-items: center;
  2317. justify-content: center;
  2318. background-color: #f5f5f5;
  2319. border: 2px dashed #d1d5db;
  2320. border-radius: 8px;
  2321. .placeholder-text {
  2322. color: #6b7280;
  2323. font-size: 12px;
  2324. font-weight: 500;
  2325. text-align: center;
  2326. }
  2327. }
  2328. .image-loading {
  2329. width: 100%;
  2330. height: 100%;
  2331. display: flex;
  2332. flex-direction: column;
  2333. align-items: center;
  2334. justify-content: center;
  2335. background-color: #f9fafb;
  2336. border: 2px dashed #d1d5db;
  2337. border-radius: 8px;
  2338. .loading-spinner {
  2339. border: 3px solid #f3f3f3;
  2340. border-top: 3px solid #3e7bfa;
  2341. border-radius: 50%;
  2342. width: 24px;
  2343. height: 24px;
  2344. animation: spin 1s linear infinite;
  2345. margin-bottom: 8px;
  2346. }
  2347. p {
  2348. color: #6b7280;
  2349. font-size: 12px;
  2350. font-weight: 500;
  2351. margin: 0;
  2352. }
  2353. }
  2354. .image-label {
  2355. position: absolute;
  2356. top: 4px;
  2357. left: 4px;
  2358. z-index: 10;
  2359. .label-icon {
  2360. width: 60px;
  2361. height: 20px;
  2362. }
  2363. }
  2364. }
  2365. }
  2366. }
  2367. }
  2368. .evaluation-modal-overlay {
  2369. position: fixed;
  2370. top: 0;
  2371. left: 0;
  2372. width: 100%;
  2373. height: 100%;
  2374. background: rgba(0, 0, 0, 0.5);
  2375. display: flex;
  2376. align-items: center;
  2377. justify-content: center;
  2378. z-index: 1000;
  2379. }
  2380. .evaluation-modal {
  2381. width: 90%;
  2382. max-width: 400px;
  2383. background: white;
  2384. border-radius: 16px;
  2385. box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
  2386. overflow: hidden;
  2387. }
  2388. .modal-header {
  2389. display: flex;
  2390. align-items: center;
  2391. justify-content: space-between;
  2392. padding: 20px 20px 0;
  2393. .modal-title {
  2394. font-size: 18px;
  2395. font-weight: 600;
  2396. color: #1f2937;
  2397. }
  2398. .close-icon {
  2399. width: 24px;
  2400. height: 24px;
  2401. cursor: pointer;
  2402. transition: opacity 0.3s ease;
  2403. &:hover {
  2404. opacity: 0.7;
  2405. }
  2406. }
  2407. }
  2408. .modal-body {
  2409. padding: 20px;
  2410. .question-section {
  2411. margin-bottom: 24px;
  2412. .question-title {
  2413. font-size: 14px;
  2414. font-weight: 500;
  2415. color: #374151;
  2416. margin-bottom: 12px;
  2417. }
  2418. .answer-buttons {
  2419. display: flex;
  2420. gap: 8px;
  2421. .answer-btn {
  2422. flex: 1;
  2423. padding: 8px 16px;
  2424. border: 2px solid #d1d5db;
  2425. border-radius: 8px;
  2426. background: white;
  2427. color: #6b7280;
  2428. font-size: 14px;
  2429. font-weight: 500;
  2430. cursor: pointer;
  2431. transition: all 0.3s ease;
  2432. &:hover {
  2433. border-color: #3e7bfa;
  2434. color: #3e7bfa;
  2435. }
  2436. &.active {
  2437. border-color: #3e7bfa;
  2438. background: #3e7bfa;
  2439. color: white;
  2440. }
  2441. &.disabled {
  2442. cursor: not-allowed;
  2443. pointer-events: none;
  2444. opacity: 0.6;
  2445. }
  2446. }
  2447. }
  2448. .star-rating {
  2449. display: flex;
  2450. gap: 6px;
  2451. .star {
  2452. font-size: 24px;
  2453. color: #d1d5db;
  2454. cursor: pointer;
  2455. transition: color 0.2s ease;
  2456. &:hover {
  2457. color: #fbbf24;
  2458. }
  2459. &.active {
  2460. color: #fbbf24;
  2461. }
  2462. &.disabled {
  2463. cursor: not-allowed;
  2464. pointer-events: none;
  2465. // 不改变颜色,保留已选中星星的金色显示
  2466. }
  2467. // 禁用且已选中时,保持金色
  2468. &.disabled.active {
  2469. color: #fbbf24;
  2470. }
  2471. }
  2472. }
  2473. .remark-input-container {
  2474. position: relative;
  2475. .remark-textarea {
  2476. width: 100%;
  2477. padding: 12px;
  2478. border: 2px solid #d1d5db;
  2479. border-radius: 8px;
  2480. font-size: 14px;
  2481. font-family: inherit;
  2482. resize: vertical;
  2483. min-height: 80px;
  2484. transition: border-color 0.3s ease;
  2485. background: white;
  2486. &:focus {
  2487. outline: none;
  2488. border-color: #3e7bfa;
  2489. box-shadow: 0 0 0 3px rgba(62, 123, 250, 0.1);
  2490. }
  2491. &::placeholder {
  2492. color: #9ca3af;
  2493. }
  2494. &.disabled {
  2495. background: #f9fafb;
  2496. color: #6b7280;
  2497. cursor: not-allowed;
  2498. border-color: #e5e7eb;
  2499. }
  2500. }
  2501. .char-count {
  2502. position: absolute;
  2503. bottom: 8px;
  2504. right: 12px;
  2505. font-size: 12px;
  2506. color: #9ca3af;
  2507. background: white;
  2508. padding: 2px 4px;
  2509. border-radius: 4px;
  2510. .over-limit {
  2511. color: #ef4444;
  2512. font-weight: 500;
  2513. }
  2514. }
  2515. }
  2516. }
  2517. }
  2518. .modal-footer {
  2519. padding: 0 20px 20px;
  2520. text-align: center;
  2521. .submit-btn {
  2522. background: none;
  2523. border: none;
  2524. cursor: pointer;
  2525. padding: 0;
  2526. .submit-icon {
  2527. width: 100px;
  2528. height: 36px;
  2529. object-fit: contain;
  2530. }
  2531. }
  2532. }
  2533. // 动画
  2534. @keyframes spin {
  2535. 0% { transform: rotate(0deg); }
  2536. 100% { transform: rotate(360deg); }
  2537. }
  2538. @keyframes scanning {
  2539. 0% {
  2540. top: 0;
  2541. opacity: 0;
  2542. }
  2543. 10% {
  2544. opacity: 1;
  2545. }
  2546. 90% {
  2547. opacity: 1;
  2548. }
  2549. 100% {
  2550. top: 100%;
  2551. opacity: 0;
  2552. }
  2553. }
  2554. @keyframes blink {
  2555. 0%, 50% {
  2556. border-color: #3E7BFA;
  2557. }
  2558. 51%, 100% {
  2559. border-color: transparent;
  2560. }
  2561. }
  2562. @keyframes loadingDots {
  2563. 0%, 80%, 100% {
  2564. transform: scale(0);
  2565. opacity: 0.5;
  2566. }
  2567. 40% {
  2568. transform: scale(1);
  2569. opacity: 1;
  2570. }
  2571. }
  2572. </style>