BusinessAssistantMobile.vue 78 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733
  1. <template>
  2. <div class="ba-mobile-container">
  3. <!-- 全局提示(按会话并行生成后,发送冲突/后端 409 等都要给用户可见反馈) -->
  4. <!-- [已停用] 全局提示改由 Ant Design 的 message 弹出(见 useBusinessAssistantChat 的 showToast)
  5. —— 原来的 <Toast> 是全屏黑遮罩 + 居中白框,用户说"太丑了" -->
  6. <header v-if="currentPage === 'home' || currentPage === 'chat'" class="mobile-header"
  7. :class="{ 'chat-header': currentPage === 'chat' }">
  8. <button v-if="currentPage === 'home'" type="button" class="icon-btn menu-btn" aria-label="打开会话菜单"
  9. @click="isDrawerOpen = true">
  10. <svg width="18" height="12" viewBox="0 0 18 12" fill="none" xmlns="http://www.w3.org/2000/svg">
  11. <path d="M0 12v-2h18v2H0Zm0-5V5h18v2H0Zm0-5V0h18v2H0Z" fill="currentColor" />
  12. </svg>
  13. </button>
  14. <div class="header-copy">
  15. <button v-if="currentPage === 'chat'" type="button" class="icon-btn menu-btn header-copy-menu"
  16. aria-label="打开会话菜单" @click="isDrawerOpen = true">
  17. <svg width="18" height="12" viewBox="0 0 18 12" fill="none" xmlns="http://www.w3.org/2000/svg">
  18. <path d="M0 12v-2h18v2H0Zm0-5V5h18v2H0Zm0-5V0h18v2H0Z" fill="currentColor" />
  19. </svg>
  20. </button>
  21. <div class="header-content">
  22. <div class="chat-header-title">青浦区营商助手</div>
  23. <div v-if="currentPage === 'chat'" class="header-subtitle">{{ currentSessionTitle }}</div>
  24. </div>
  25. </div>
  26. </header>
  27. <main ref="scrollHostRef" class="mobile-main" :class="`page-${currentPage}`">
  28. <!-- <ParticleBackground
  29. v-if="showParticleBackground"
  30. mouse-target-selector=".ba-mobile-container"
  31. /> -->
  32. <template v-if="currentPage === 'home'">
  33. <div class="home-content-shell">
  34. <section class="welcome-section">
  35. <div class="overlay-icon" aria-hidden="true">
  36. <img src="@/assets/qingpu_logo_2_3x.png" alt="logo"
  37. style="width: 100%; height: 100%; object-fit: cover; border-radius: 999px;" />
  38. </div>
  39. <h1 class="heading">你好,有什么我可以帮您的?</h1>
  40. <p class="desc">我是您的青浦区营商智能助手,可以为您提供惠企政策匹配以及出海、人才、科创、金融、法治方面的办事指引,请问有什么可以帮助您?</p>
  41. </section>
  42. <section class="suggestion-chips" aria-label="推荐问题">
  43. <button v-for="(item, index) in suggestions" :key="item" type="button" class="chip"
  44. @click="sendSuggestion(item)">
  45. <span class="chip-icon" aria-hidden="true">
  46. <svg v-if="index === 0" width="10" height="12" viewBox="0 0 10 12" fill="none">
  47. <path
  48. d="M4.66667 11.66667c-1.35139-0.34028-2.46701-1.11562-3.34688-2.32605-0.87986-1.21042-1.31979-2.55451-1.31979-4.03229l0-3.55833 4.66667-1.75 4.66666 1.75 0 3.55833c0 0.82639-0.14097 1.62118-0.42291 2.38438-0.28194 0.76319-0.68542 1.43646-1.21042 2.01979l-1.86667-1.86667c-0.175 0.10694-0.36215 0.18715-0.56146 0.24063-0.19931 0.05347-0.40104 0.08021-0.6052 0.08021-0.64167 0-1.19097-0.22847-1.64792-0.68542-0.45694-0.45694-0.68542-1.00625-0.68542-1.64792 0-0.64167 0.22847-1.19097 0.68542-1.64791 0.45694-0.45694 1.00625-0.68542 1.64792-0.68542 0.64167 0 1.19097 0.22847 1.64791 0.68542 0.45694 0.45694 0.68542 1.00625 0.68542 1.64791 0 0.21389-0.02674 0.42049-0.08021 0.6198-0.05347 0.19931-0.13368 0.39132-0.24062 0.57604l0.875 0.875c0.19444-0.39861 0.34514-0.81667 0.45208-1.25417 0.10694-0.4375 0.16042-0.88472 0.16042-1.34167l0-2.75625-3.5-1.3125-3.5 1.3125 0 2.75625c0 1.17639 0.33056 2.24583 0.99166 3.20834 0.66111 0.9625 1.49722 1.60417 2.50834 1.925 0.25278-0.07778 0.4934-0.17743 0.72187-0.29896 0.22847-0.12153 0.45451-0.26493 0.67813-0.43021l0.81666 0.81667c-0.32083 0.2625-0.6684 0.49097-1.04271 0.68541-0.37431 0.19444-0.76563 0.34514-1.17395 0.45209l0 0m0-4.66667c0.32083 0 0.59549-0.11424 0.82395-0.34271 0.22847-0.22847 0.34271-0.50313 0.34271-0.82396 0-0.32083-0.11424-0.59549-0.34271-0.82395-0.22847-0.22847-0.50313-0.34271-0.82395-0.34271-0.32083 0-0.59549 0.11424-0.82396 0.34271-0.22847 0.22847-0.34271 0.50313-0.34271 0.82395 0 0.32083 0.11424 0.59549 0.34271 0.82396 0.22847 0.22847 0.50312 0.34271 0.82396 0.34271l0 0m0.11666-1.12292l0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"
  49. fill="currentColor" />
  50. </svg>
  51. <svg v-else-if="index === 1" width="13" height="13" viewBox="0 0 13 13" fill="none">
  52. <path
  53. d="M4.43333 12.25l-1.10833-1.86667-2.1-0.46666 0.20417-2.15834-1.42917-1.63333 1.42917-1.63333-0.20417-2.15834 2.1-0.46666 1.10833-1.86667 1.98334 0.84583 1.98333-0.84583 1.10833 1.86667 2.1 0.46666-0.20416 2.15834 1.42916 1.63333-1.42916 1.63333 0.20416 2.15834-2.1 0.46666-1.10833 1.86667-1.98333-0.84583-1.98334 0.84583 0 0m0.49584-1.4875l1.4875-0.64167 1.51666 0.64167 0.81667-1.4 1.60417-0.37917-0.14584-1.63333 1.07917-1.225-1.07917-1.25417 0.14584-1.63333-1.60417-0.35-0.84583-1.4-1.4875 0.64167-1.51667-0.64167-0.81667 1.4-1.60416 0.35 0.14583 1.63333-1.07917 1.25417 1.07917 1.225-0.14583 1.6625 1.60416 0.35 0.84584 1.4 0 0m1.4875-4.6375l0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0m-0.6125 2.07083l3.29583-3.29583-0.81667-0.84583-2.47916 2.47916-1.25417-1.225-0.81667 0.81667 2.07084 2.07083 0 0"
  54. fill="currentColor" />
  55. </svg>
  56. <svg v-else width="12" height="12" viewBox="0 0 12 12" fill="none">
  57. <path
  58. d="M1.16667 6.41667l0 1.16666 0 0 0 0 9.33333 0 0 0 0 0 0-1.16666-9.33333 0 0 0m0-6.41667l9.33333 0c0.32083 0 0.59549 0.11424 0.82396 0.34271 0.22847 0.22847 0.34271 0.50312 0.34271 0.82396l0 6.41666c0 0.32083-0.11424 0.59549-0.34271 0.82396-0.22847 0.22847-0.50312 0.34271-0.82396 0.34271l-2.33333 0 0 2.91667-2.33334-1.16667-2.33333 1.16667 0-2.91667-2.33333 0c-0.32083 0-0.59549-0.11424-0.82396-0.34271-0.22847-0.22847-0.34271-0.50312-0.34271-0.82396l0-6.41666c0-0.32083 0.11424-0.59549 0.34271-0.82396 0.22847-0.22847 0.50312-0.34271 0.82396-0.34271l0 0m0 4.66667l9.33333 0 0-3.5 0 0 0 0-9.33333 0 0 0 0 0 0 3.5 0 0m0 2.91666l0-6.41666 0 0 0 0 0 0 0 0 0 0 0 6.41666 0 0 0 0 0 0 0 0 0 0 0 0"
  59. fill="currentColor" />
  60. </svg>
  61. </span>
  62. {{ item }}
  63. </button>
  64. </section>
  65. </div>
  66. <div class="home-fixed-input" v-show="!isFreeformInputActive">
  67. <BusinessAssistantComposer
  68. v-model="inputText"
  69. :uploaded-files="uploadedFiles"
  70. :is-generating="currentSessionGenerating"
  71. :is-deep-think="isDeepThink"
  72. :is-voice-input-mode="isVoiceInputMode"
  73. :is-voice-input-starting="isVoiceInputStarting"
  74. :suggestions="[]"
  75. variant="mobile"
  76. :show-mock-stream-test-button="isMockStreamTestButtonVisible"
  77. @update:is-deep-think="isDeepThink = $event"
  78. @send="sendMobileMessage"
  79. @stop="handleStopGenerate"
  80. @image-upload="triggerImageUpload"
  81. @file-upload="triggerFileUpload"
  82. @voice-toggle="toggleVoiceInput"
  83. @remove-file="removeUploadedFile"
  84. @test-mock-stream="sendChatStreamReplayTestMessage"
  85. @test-replay="sendChatStreamReplayTestMessage"
  86. @test-typewriter="sendTypewriterTestMessage"
  87. @test-scope-merge="sendScopeMergeStreamTestMessage"
  88. @test-input="sendDocumentLineStreamTestMessage"
  89. @test-ref-links="sendRefLinksStreamTestMessage"
  90. @test-question-card="sendQuestionCardTestMessage"
  91. />
  92. </div>
  93. </template>
  94. <template v-else-if="currentPage === 'chat'">
  95. <!-- <div class="chat-ambient" aria-hidden="true">
  96. <span></span>
  97. <span></span>
  98. </div> -->
  99. <section class="chat-stage">
  100. <!-- <div class="date-indicator">{{ todayLabel }}</div> -->
  101. <ScrollList v-if="currentSession" ref="scrollListRef" :data="currentSession.messages" :height="listHeight"
  102. :maxHeight="listHeight" :topPadding="8" :bottomPadding="chatSuggestionsBottomPadding" :native-scroll-mode="true"
  103. @scrollPositionChange="handleBusinessScrollPositionChange">
  104. <template #item="{ data, isLast }">
  105. <div class="message-row" :ref="(el) => setMessageRowRef(data.id, el)"
  106. :class="[data.role, { 'is-last': isLast }]" :style="getMessageRowStyle(data.id, isLast, data.role)">
  107. <BusinessRecord :record="data" :state="data.state" :content="data.content" :isLastRecord="isLast"
  108. :sessionId="currentSession?.id" :sessionViewedBefore="currentSessionVisitedBefore"
  109. enableFeedback
  110. @refreshScroll="handleBusinessRecordRefreshScroll"
  111. @scopeHeightLockChange="handleScopeHeightLockChange"
  112. @typewriterStateChange="handleBusinessRecordTypewriterStateChange" />
  113. <Transition name="chat-suggestions-fade" appear>
  114. <div v-if="isLast && data.role === 'ai' && currentSessionSuggestions.length > 0"
  115. ref="chatSuggestionsContainerRef" class="chat-suggestions-container">
  116. <div class="chat-suggestions-gradient" aria-hidden="true"></div>
  117. <section class="chat-suggestion-chips" aria-label="跟进问题">
  118. <button v-for="(item, index) in currentSessionSuggestions.slice(0, 2)"
  119. :key="`${item.text}-${index}`" type="button" class="chip" @click="handleSuggestionClick(item)">
  120. {{ item.text }}
  121. </button>
  122. </section>
  123. </div>
  124. </Transition>
  125. </div>
  126. </template>
  127. </ScrollList>
  128. <div v-else class="empty-chat">打开一次咨询,助手会在这里继续服务。</div>
  129. </section>
  130. <div class="fixed-input" v-show="!isFreeformInputActive">
  131. <BusinessAssistantComposer
  132. v-model="inputText"
  133. :uploaded-files="uploadedFiles"
  134. :is-generating="currentSessionGenerating"
  135. :is-deep-think="isDeepThink"
  136. :is-voice-input-mode="isVoiceInputMode"
  137. :is-voice-input-starting="isVoiceInputStarting"
  138. :suggestions="[]"
  139. variant="mobile"
  140. :show-mock-stream-test-button="isMockStreamTestButtonVisible"
  141. @update:is-deep-think="isDeepThink = $event"
  142. @send="sendMobileMessage"
  143. @stop="handleStopGenerate"
  144. @image-upload="triggerImageUpload"
  145. @file-upload="triggerFileUpload"
  146. @voice-toggle="toggleVoiceInput"
  147. @remove-file="removeUploadedFile"
  148. @test-mock-stream="sendChatStreamReplayTestMessage"
  149. @test-replay="sendChatStreamReplayTestMessage"
  150. @test-typewriter="sendTypewriterTestMessage"
  151. @test-scope-merge="sendScopeMergeStreamTestMessage"
  152. @test-input="sendDocumentLineStreamTestMessage"
  153. @test-ref-links="sendRefLinksStreamTestMessage"
  154. @test-question-card="sendQuestionCardTestMessage"
  155. />
  156. <div class="ai-disclaimer">所有内容均由 AI 生成,仅供参考</div>
  157. </div>
  158. </template>
  159. <template v-else-if="currentPage === 'policy-list'">
  160. <section class="policy-page">
  161. <button type="button" class="page-close-btn" aria-label="关闭政策列表" @click="goHome">
  162. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
  163. <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
  164. </svg>
  165. </button>
  166. <div class="policy-title-block">
  167. <h1>政策事项列表</h1>
  168. <p>浏览、搜索及筛选青浦最新的惠企政策</p>
  169. </div>
  170. <div class="policy-filter">
  171. <label class="policy-search">
  172. <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
  173. <path d="m21 21-4.35-4.35M10.8 18a7.2 7.2 0 1 1 0-14.4 7.2 7.2 0 0 1 0 14.4Z" stroke="currentColor"
  174. stroke-width="2" stroke-linecap="round" />
  175. </svg>
  176. <input v-model="policyKeyword" type="search" placeholder="输入政策关键词" />
  177. </label>
  178. <div class="policy-filter-row">
  179. <select v-model="policyCategory" class="policy-select" aria-label="政策分类">
  180. <option value="">所有分类</option>
  181. <option value="科技创新">科技创新</option>
  182. <option value="金融扶持">金融扶持</option>
  183. <option value="人才服务">人才服务</option>
  184. <option value="税费优惠">税费优惠</option>
  185. </select>
  186. <button type="button" class="policy-filter-btn">
  187. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
  188. <path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
  189. </svg>
  190. 筛选
  191. </button>
  192. </div>
  193. </div>
  194. <div class="policy-list">
  195. <article v-for="policy in filteredPolicies" :key="policy.id" class="policy-card"
  196. @click="openPolicyDetail(policy)">
  197. <div class="policy-card-main">
  198. <div class="policy-meta">
  199. <span>{{ policy.category }}</span>
  200. <time>{{ policy.date }}</time>
  201. </div>
  202. <h2>{{ policy.title }}</h2>
  203. <p>{{ policy.summary }}</p>
  204. </div>
  205. <div class="match-panel" aria-hidden="true">
  206. <div class="match-ring">{{ policy.match }}%</div>
  207. <div class="match-label">匹配度</div>
  208. <button type="button">查看</button>
  209. </div>
  210. </article>
  211. </div>
  212. <div class="pagination-row" aria-label="分页">
  213. <button type="button" disabled>‹</button>
  214. <button type="button" class="active">1</button>
  215. <button type="button">2</button>
  216. <button type="button">3</button>
  217. <span>...</span>
  218. <button type="button">12</button>
  219. <button type="button">›</button>
  220. </div>
  221. </section>
  222. </template>
  223. <template v-else-if="currentPage === 'policy-detail' && selectedPolicy">
  224. <section class="policy-detail">
  225. <button type="button" class="page-close-btn" aria-label="关闭政策详情" @click="goHome">
  226. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
  227. <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
  228. </svg>
  229. </button>
  230. <button type="button" class="back-policy-btn" @click="openPolicyList">
  231. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
  232. <path d="m15 18-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"
  233. stroke-linejoin="round" />
  234. </svg>
  235. 返回政策列表
  236. </button>
  237. <div class="policy-detail-hero">
  238. <h1>{{ selectedPolicy.title }}</h1>
  239. <div class="detail-tags">
  240. <span>{{ selectedPolicy.category }}</span>
  241. <span>{{ selectedPolicy.date }}</span>
  242. <span>匹配度 {{ selectedPolicy.match }}%</span>
  243. </div>
  244. </div>
  245. <div class="detail-grid">
  246. <section class="detail-box">
  247. <h2>支持对象</h2>
  248. <ul>
  249. <li v-for="item in selectedPolicy.targets" :key="item">{{ item }}</li>
  250. </ul>
  251. </section>
  252. <section class="detail-box">
  253. <h2>支持方式</h2>
  254. <p>{{ selectedPolicy.method }}</p>
  255. </section>
  256. </div>
  257. <section class="conditions-section">
  258. <h2>申报条件</h2>
  259. <div class="condition-lead">申报条件:{{ selectedPolicy.condition }}</div>
  260. <div class="condition-card">
  261. <div class="condition-status">符合</div>
  262. <p>{{ selectedPolicy.condition }}</p>
  263. </div>
  264. </section>
  265. </section>
  266. </template>
  267. </main>
  268. <Transition name="drawer-fade">
  269. <div v-if="isDrawerOpen" class="drawer-layer" @click.self="closeDrawer">
  270. <aside class="session-drawer">
  271. <div class="drawer-head">
  272. <button type="button" class="new-chat-btn" @click="startNewChat">
  273. <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
  274. <path d="M6 8H0V6h6V0h2v6h6v2H8v6H6V8Z" fill="currentColor" />
  275. </svg>
  276. 开启新的咨询
  277. </button>
  278. <button type="button" class="icon-btn" aria-label="关闭会话菜单" @click="closeDrawer">
  279. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
  280. <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
  281. </svg>
  282. </button>
  283. </div>
  284. <label class="drawer-search">
  285. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
  286. <path d="m21 21-4.35-4.35M10.8 18a7.2 7.2 0 1 1 0-14.4 7.2 7.2 0 0 1 0 14.4Z" stroke="currentColor"
  287. stroke-width="2" stroke-linecap="round" />
  288. </svg>
  289. <input v-model="sessionKeyword" type="search" placeholder="搜索历史咨询" />
  290. </label>
  291. <section class="drawer-list">
  292. <h2>历史咨询</h2>
  293. <div v-if="filteredChatHistory.length === 0" class="drawer-empty">还没有历史咨询,先发起一次咨询吧。</div>
  294. <div v-for="session in filteredChatHistory" :key="session.id" class="drawer-session" role="button"
  295. tabindex="0" :class="{
  296. active: currentPage === 'chat' && currentSession?.id === session.id,
  297. 'is-pressing': longPressSessionId === session.id,
  298. }" @click="handleSessionTap(session.id)" @keydown.enter.prevent="switchSessionById(session.id)"
  299. @keydown.space.prevent="switchSessionById(session.id)"
  300. @pointerdown="handleSessionPressStart(session.id, $event)" @pointerup="handleSessionPressEnd"
  301. @pointerleave="handleSessionPressEnd" @pointercancel="handleSessionPressEnd" @contextmenu.prevent>
  302. <span>{{ session.title || '新对话' }}</span>
  303. </div>
  304. </section>
  305. <AccountInfo @logout="handleAccountLogout" />
  306. <!-- <div class="drawer-footer">
  307. <span>青浦区营商助手</span>
  308. <button type="button" class="icon-btn" aria-label="打开政策列表" @click="openPolicyListFromDrawer">
  309. <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
  310. <path d="M7 3h7l5 5v13H7V3Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
  311. </svg>
  312. </button>
  313. </div> -->
  314. </aside>
  315. </div>
  316. </Transition>
  317. <Teleport to="body">
  318. <Transition name="session-menu-fade">
  319. <div v-if="activeSessionMenuId && sessionMenuAnchor" class="session-menu-portal">
  320. <div class="session-menu-overlay" aria-hidden="true" @click="closeSessionMenu"></div>
  321. <div class="session-popover" :style="sessionMenuPopoverStyle" @click.stop @pointerdown.stop>
  322. <button type="button" @click="handleRenameFromSheet">重命名</button>
  323. <button type="button" class="danger" @click="handleDeleteFromSheet">删除</button>
  324. </div>
  325. </div>
  326. </Transition>
  327. </Teleport>
  328. <Teleport to="body">
  329. <div v-if="isRenameModalOpen" class="modal-overlay">
  330. <div class="mobile-modal">
  331. <div class="modal-header">
  332. <h2>编辑对话名称</h2>
  333. <button type="button" class="icon-btn" aria-label="关闭" @click="closeRenameModal">
  334. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
  335. <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
  336. </svg>
  337. </button>
  338. </div>
  339. <input v-model="renameInput" class="modal-input" type="text" placeholder="输入名称" />
  340. <div class="modal-actions">
  341. <button type="button" @click="closeRenameModal">取消</button>
  342. <button type="button" class="primary" @click="confirmRename">确定</button>
  343. </div>
  344. </div>
  345. </div>
  346. <div v-if="isDeleteModalOpen" class="modal-overlay">
  347. <div class="mobile-modal">
  348. <div class="modal-header">
  349. <h2>提示</h2>
  350. <button type="button" class="icon-btn" aria-label="关闭" @click="closeDeleteModal">
  351. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
  352. <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
  353. </svg>
  354. </button>
  355. </div>
  356. <p class="delete-copy">确认删除该会话记录吗?</p>
  357. <div class="modal-actions">
  358. <button type="button" @click="closeDeleteModal">取消</button>
  359. <button type="button" class="danger" @click="confirmDeleteSession">删除</button>
  360. </div>
  361. </div>
  362. </div>
  363. </Teleport>
  364. <input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden
  365. @change="handleImageSelected" />
  366. <input ref="fileInputRef" type="file" accept=".xls,.xlsx,.docx,.pdf" multiple hidden
  367. @change="handleFileSelected" />
  368. </div>
  369. </template>
  370. <script setup lang="ts">
  371. import { computed, nextTick, onBeforeUnmount, onMounted, provide, reactive, ref, watch } from 'vue';
  372. import BusinessRecord from "./Chat/BusinessRecord.vue";
  373. // [已停用] import Toast from "./Common/Toast.vue";
  374. import ScrollList from './Common/ScrollList.vue';
  375. import BusinessAssistantComposer from './business-assistant/BusinessAssistantComposer.vue';
  376. import { useBusinessAssistantChat } from './business-assistant/useBusinessAssistantChat';
  377. import { useBusinessAssistantUpload } from './business-assistant/useBusinessAssistantUpload';
  378. import {
  379. maybeStopAutoScrollAtLastAiRowTop,
  380. } from './business-assistant/auto-scroll-top-stop';
  381. import { scrollBusinessAssistantListToBottom } from './business-assistant/scroll-list-bottom';
  382. // 实时语音识别:**阿里云 NLS 直连**(2026-09-23 换)。
  383. // 对外契约与旧厂商 SDK(`@/three-libs/asr/index`)一致,所以这里只有这一行变了;
  384. // 切回旧实现也只改这一行。详见 harness/docs/reference/asr-nls.md
  385. import ASR from '@/asr/nls';
  386. import AccountInfo from "./Chat/AccountInfo.vue";
  387. import { useEnterpriseAuth } from '@/components/useEnterpriseAuth';
  388. import { createModalMutex, MODAL_MUTEX_KEY } from './business-assistant/modal-mutex';
  389. import {
  390. createFreeformInputFocusControl,
  391. FREEFORM_INPUT_FOCUS_KEY,
  392. } from './business-assistant/freeform-focus-control';
  393. import {
  394. calculateMobileListHeight,
  395. } from './business-assistant/mobile-viewport-height';
  396. provide(MODAL_MUTEX_KEY, createModalMutex());
  397. provide('formOptionsControl', {
  398. setFormOptions: () => { },
  399. });
  400. provide('speechControl', {
  401. stopSpeech: () => { },
  402. handleStopSpeech: () => { },
  403. handleEndSpeech: () => { },
  404. });
  405. const freeformInputFocusControl = createFreeformInputFocusControl();
  406. const isFreeformInputActive = freeformInputFocusControl.active;
  407. provide(FREEFORM_INPUT_FOCUS_KEY, freeformInputFocusControl);
  408. interface PolicyItem {
  409. id: string;
  410. title: string;
  411. category: string;
  412. date: string;
  413. summary: string;
  414. match: number;
  415. targets: string[];
  416. method: string;
  417. condition: string;
  418. }
  419. const currentPage = ref<'home' | 'chat' | 'policy-list' | 'policy-detail'>('home');
  420. const isDrawerOpen = ref(false);
  421. const activeSessionMenuId = ref<string | null>(null);
  422. const sessionKeyword = ref('');
  423. const renameInput = ref('');
  424. const renameSessionId = ref<string | null>(null);
  425. const deleteSessionId = ref<string | null>(null);
  426. const isRenameModalOpen = ref(false);
  427. const isDeleteModalOpen = ref(false);
  428. const scrollHostRef = ref<HTMLElement | null>(null);
  429. const scrollListRef = ref<any>(null);
  430. const listHeight = ref(520);
  431. const messageRowRefs = reactive<Record<string, HTMLElement | null>>({});
  432. const messageRowMinHeights = reactive<Record<string, number>>({});
  433. const chatSuggestionsContainerRef = ref<HTMLElement | null>(null);
  434. const chatSuggestionsBottomPadding = ref(0);
  435. const BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE = 24;
  436. const BUSINESS_RECORD_LARGE_HEIGHT_GROWTH = 80;
  437. /**
  438. * 「一整块到达」的高度阈值:超过它就按**块**处理 —— 把块的**顶部**对到视口顶部,
  439. * 而不是贴到底部(用户 2026-09-21 选择的做法)。与 PC 端同一口径(说明见 BusinessAssistantPC.vue)。
  440. */
  441. const BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH = 240;
  442. /** 块对顶时留的空隙(别把块的顶部贴死在视口最上沿) */
  443. const BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET = 8;
  444. const hasStoppedAtLastAiRowTop = ref(false);
  445. const lastAiRowTopConsumedRecordId = ref<string | null>(null);
  446. const lastAiRowTopSnapshot = ref<{ recordId: string; rowOffsetTop: number } | null>(null);
  447. const lastAiRowTypewriterStates = ref<Record<string, boolean>>({});
  448. let lastAiRowTypingReleaseTimer: number | null = null;
  449. const policyKeyword = ref('');
  450. const policyCategory = ref('');
  451. const selectedPolicy = ref<PolicyItem | null>(null);
  452. const isVoiceInputMode = ref(false);
  453. const isVoiceInputStarting = ref(false);
  454. const longPressSessionId = ref<string | null>(null);
  455. const sessionMenuAnchor = ref<{ left: number; bottom: number } | null>(null);
  456. let asrInstance: ASR | null = null;
  457. const mobileScrollAnimationDurationMs = 300;
  458. let mobileScrollAnimationFrameId = 0;
  459. let mobileScrollAnimationRevision = 0;
  460. let mobileScrollAnimationResolve: ((value?: unknown) => void) | null = null;
  461. const cancelMobileScrollAnimation = () => {
  462. if (mobileScrollAnimationFrameId) {
  463. cancelAnimationFrame(mobileScrollAnimationFrameId);
  464. mobileScrollAnimationFrameId = 0;
  465. }
  466. if (mobileScrollAnimationResolve) {
  467. mobileScrollAnimationResolve();
  468. mobileScrollAnimationResolve = null;
  469. }
  470. mobileScrollAnimationRevision += 1;
  471. };
  472. let sessionPressTimer: number | null = null;
  473. let sessionPressTriggeredId: string | null = null;
  474. let sessionPressPointerId: number | null = null;
  475. const isMockStreamTestButtonVisible = import.meta.env.MODE === 'development'
  476. // const todayLabel = computed(() => {
  477. // const now = new Date();
  478. // const hours = String(now.getHours()).padStart(2, '0');
  479. // const minutes = String(now.getMinutes()).padStart(2, '0');
  480. // return `今天 ${hours}:${minutes}`;
  481. // });
  482. const imageInputRef = ref<HTMLInputElement | null>(null);
  483. const fileInputRef = ref<HTMLInputElement | null>(null);
  484. const {
  485. uploadedFiles,
  486. triggerImageUpload,
  487. triggerFileUpload,
  488. handleImageSelected,
  489. handleFileSelected,
  490. removeUploadedFile,
  491. consumeSuccessfulFileTransmission,
  492. setUploadDraftKey,
  493. clearUploadDraft,
  494. } = useBusinessAssistantUpload({ imageInputRef, fileInputRef, notify: (message) => notifyUpload(message) });
  495. /**
  496. * 上传校验失败时的提示通道。
  497. * 创建顺序上 `useBusinessAssistantChat`(它提供 showToast)在后面,而且它依赖上传模块的
  498. * `consumeSuccessfulFileTransmission`,没法调换顺序 —— 所以这里先用占位,拿到 showToast 再接通。
  499. */
  500. let notifyUpload: (message: string) => void = (message) => console.warn('[upload]', message);
  501. const {
  502. suggestions,
  503. chatHistory,
  504. currentSessionVisitedBefore,
  505. currentSession,
  506. currentSessionTitle,
  507. currentSessionSuggestions,
  508. inputText,
  509. isGenerating,
  510. currentSessionGenerating,
  511. showToast,
  512. stopAllTasks,
  513. isDeepThink,
  514. loadHistory,
  515. sendMessage,
  516. sendChatStreamReplayTestMessage,
  517. sendTypewriterTestMessage,
  518. sendScopeMergeStreamTestMessage,
  519. sendDocumentLineStreamTestMessage,
  520. sendRefLinksStreamTestMessage,
  521. sendQuestionCardTestMessage,
  522. handleStopGenerate,
  523. startNewChat: startSharedNewChat,
  524. switchSessionById: switchSharedSessionById,
  525. renameSessionById,
  526. deleteSessionById,
  527. mergeRemoteSessions,
  528. loadRemoteSessionRecords,
  529. } = useBusinessAssistantChat({
  530. // 新协调器自行读取 VITE_CHAT_API,此参数仅保留签名兼容
  531. apiBaseUrl: import.meta.env.VITE_API || "",
  532. beforeSend: async () => {
  533. if (isVoiceInputMode.value) {
  534. await stopVoiceInput();
  535. }
  536. },
  537. afterSend: async () => {
  538. currentPage.value = 'chat';
  539. await scrollToBottom(true);
  540. },
  541. onMessageUpdated: () => scrollToBottom(),
  542. getInputFileTransmission: consumeSuccessfulFileTransmission,
  543. });
  544. // 上传校验的提示通道接通(见上面 notifyUpload 的说明)
  545. notifyUpload = showToast;
  546. const { isLoggedIn, creditCode, initAuth } = useEnterpriseAuth();
  547. const handleAccountLogout = () => {
  548. // ⚠️ 必须先停掉全部生成任务:退出登录会切换 localStorage key,
  549. // 旧账户的任务继续写会把内容落到新身份下
  550. stopAllTasks();
  551. closeDrawer();
  552. currentPage.value = 'home';
  553. loadHistory();
  554. startSharedNewChat();
  555. };
  556. const policies: PolicyItem[] = [
  557. {
  558. id: 'policy-1',
  559. title: '关于支持青浦自贸区科技创新企业发展的若干意见',
  560. category: '科技创新',
  561. date: '2026-06-12',
  562. summary: '围绕研发投入、成果转化、创新平台建设给予综合支持。',
  563. match: 92,
  564. targets: ['青浦区注册科技型企业', '拥有研发活动或知识产权成果的企业'],
  565. method: '对符合条件的企业给予研发补贴、平台建设资助和成果转化奖励。',
  566. condition: '企业需在青浦区依法登记纳税,具备持续研发能力,项目资料真实完整。',
  567. },
  568. {
  569. id: 'policy-2',
  570. title: '青浦自贸区中小微企业贷款贴息申报指南',
  571. category: '金融扶持',
  572. date: '2026-05-28',
  573. summary: '降低中小微企业融资成本,支持稳经营、扩产能。',
  574. match: 88,
  575. targets: ['青浦区中小微企业', '已获得银行经营性贷款的企业'],
  576. method: '按照实际贷款利息的一定比例给予贴息支持。',
  577. condition: '贷款用途须用于生产经营,企业信用记录良好,申报材料与贷款凭证一致。',
  578. },
  579. {
  580. id: 'policy-3',
  581. title: '2026年度“青峰”人才计划领军人物评选通知',
  582. category: '人才服务',
  583. date: '2026-05-14',
  584. summary: '聚焦高层次人才引育,为重点团队提供人才服务与项目支持。',
  585. match: 84,
  586. targets: ['重点产业领域领军人才', '承担核心研发或经营管理职责的人才'],
  587. method: '提供人才补贴、项目资助、服务保障和资源对接。',
  588. condition: '申报人需符合青浦区重点产业方向,并由所在单位提交推荐材料。',
  589. },
  590. {
  591. id: 'policy-4',
  592. title: '高新技术企业所得税优惠政策延续执行说明',
  593. category: '税费优惠',
  594. date: '2026-04-30',
  595. summary: '明确高新技术企业税收优惠延续执行口径和申报注意事项。',
  596. match: 79,
  597. targets: ['有效期内高新技术企业', '拟申报高新技术企业认定的企业'],
  598. method: '对符合条件的高新技术企业按规定享受企业所得税优惠。',
  599. condition: '企业需保持高新技术企业资格有效,研发费用、收入结构等指标满足要求。',
  600. },
  601. ];
  602. const filteredChatHistory = computed(() => {
  603. const keyword = sessionKeyword.value.trim().toLowerCase();
  604. if (!keyword) {
  605. return chatHistory.value;
  606. }
  607. return chatHistory.value.filter((session) => (session.title || '').toLowerCase().includes(keyword));
  608. });
  609. const filteredPolicies = computed(() => {
  610. const keyword = policyKeyword.value.trim();
  611. return policies.filter((policy) => {
  612. const matchedKeyword = !keyword || `${policy.title}${policy.summary}`.includes(keyword);
  613. const matchedCategory = !policyCategory.value || policy.category === policyCategory.value;
  614. return matchedKeyword && matchedCategory;
  615. });
  616. });
  617. const setMessageRowRef = (id: string | number, el: HTMLElement | null) => {
  618. const key = String(id);
  619. if (el) {
  620. messageRowRefs[key] = el;
  621. } else {
  622. delete messageRowRefs[key];
  623. }
  624. };
  625. const setMessageRowMinHeight = (recordId: string | number, height: number | null | undefined) => {
  626. const key = String(recordId);
  627. const nextHeight = Number(height);
  628. if (!Number.isFinite(nextHeight) || nextHeight <= 0) {
  629. delete messageRowMinHeights[key];
  630. return;
  631. }
  632. messageRowMinHeights[key] = Math.ceil(nextHeight);
  633. };
  634. const getMessageRowMinHeight = (recordId: string | number) => {
  635. const height = messageRowMinHeights[String(recordId)];
  636. return Number.isFinite(height) && height > 0 ? height : null;
  637. };
  638. const getMessageRowStyle = (recordId: string | number, isLast: boolean, role: string) => {
  639. if (!isLast || role !== 'ai') {
  640. return undefined;
  641. }
  642. const minHeight = getMessageRowMinHeight(recordId);
  643. if (!minHeight) {
  644. return undefined;
  645. }
  646. return {
  647. minHeight: `${minHeight}px`,
  648. };
  649. };
  650. const getLastAiMessage = () => {
  651. const messages = currentSession.value?.messages || [];
  652. for (let index = messages.length - 1; index >= 0; index -= 1) {
  653. const message = messages[index];
  654. if (message?.role === 'ai') {
  655. return message;
  656. }
  657. }
  658. return null;
  659. };
  660. const getRowScrollTop = (scrollElement: HTMLElement, rowElement: HTMLElement) => {
  661. const scrollRect = scrollElement.getBoundingClientRect();
  662. const rowRect = rowElement.getBoundingClientRect();
  663. return rowRect.top - scrollRect.top + scrollElement.scrollTop;
  664. };
  665. const isLastAiRowTyping = () => {
  666. const lastAiMessage = getLastAiMessage();
  667. if (!lastAiMessage) {
  668. return false;
  669. }
  670. const isTyping = Boolean(lastAiRowTypewriterStates.value[String(lastAiMessage.id)]);
  671. return isTyping;
  672. };
  673. const clearLastAiRowTopSnapshot = () => {
  674. lastAiRowTopSnapshot.value = null;
  675. };
  676. const consumeLastAiRowTopForRecord = (recordId: string | number | null | undefined) => {
  677. if (recordId === undefined || recordId === null) {
  678. return;
  679. }
  680. lastAiRowTopConsumedRecordId.value = String(recordId);
  681. hasStoppedAtLastAiRowTop.value = true;
  682. };
  683. const clearLastAiRowTypingReleaseTimer = () => {
  684. if (lastAiRowTypingReleaseTimer !== null) {
  685. window.clearTimeout(lastAiRowTypingReleaseTimer);
  686. lastAiRowTypingReleaseTimer = null;
  687. }
  688. };
  689. const setLastAiRowTopPinState = (active: boolean) => {
  690. const scrollList = scrollListRef.value;
  691. if (scrollList && typeof scrollList.setAutoScrollTopPinned === "function") {
  692. scrollList.setAutoScrollTopPinned(active);
  693. }
  694. };
  695. const isLastAiRowTopPinned = () => {
  696. const scrollList = scrollListRef.value;
  697. if (!scrollList || typeof scrollList.isAutoScrollTopPinned !== "function") {
  698. return false;
  699. }
  700. return scrollList.isAutoScrollTopPinned();
  701. };
  702. const releaseLastAiRowTopPin = (reason: "typewriter-finished" | "session-switch" | "user-scroll") => {
  703. clearLastAiRowTypingReleaseTimer();
  704. setLastAiRowTopPinState(false);
  705. clearLastAiRowTopSnapshot();
  706. if (reason === "user-scroll") {
  707. return;
  708. }
  709. };
  710. const getOrCreateLastAiRowTopSnapshot = (recordId: string, rowOffsetTop: number) => {
  711. if (
  712. lastAiRowTopSnapshot.value?.recordId !== recordId ||
  713. !Number.isFinite(lastAiRowTopSnapshot.value?.rowOffsetTop)
  714. ) {
  715. lastAiRowTopSnapshot.value = {
  716. recordId,
  717. rowOffsetTop,
  718. };
  719. }
  720. return lastAiRowTopSnapshot.value;
  721. };
  722. const stopAutoScrollAtLastAiRowTopIfNeeded = () => {
  723. const scrollList = scrollListRef.value;
  724. if (scrollList && typeof scrollList.isAutoScrollTopPinSuppressedByUser === "function" && scrollList.isAutoScrollTopPinSuppressedByUser()) {
  725. return false;
  726. }
  727. if (!isLastAiRowTyping()) {
  728. clearLastAiRowTopSnapshot();
  729. if (isLastAiRowTopPinned()) {
  730. releaseLastAiRowTopPin("typewriter-finished");
  731. }
  732. return false;
  733. }
  734. if (!scrollList || typeof scrollList.getScrollElement !== "function") {
  735. return false;
  736. }
  737. const scrollElement = scrollList.getScrollElement();
  738. const lastAiMessage = getLastAiMessage();
  739. if (!scrollElement || !lastAiMessage) {
  740. return false;
  741. }
  742. const lastAiMessageId = String(lastAiMessage.id);
  743. if (lastAiRowTopConsumedRecordId.value === lastAiMessageId) {
  744. return false;
  745. }
  746. const rowElement = messageRowRefs[String(lastAiMessage.id)];
  747. if (!rowElement) {
  748. return false;
  749. }
  750. if (typeof scrollList.enableAutoScroll === "function" && !isLastAiRowTopPinned()) {
  751. scrollList.enableAutoScroll();
  752. }
  753. const isAutoScrollActive =
  754. typeof scrollList.isAutoScrollEnabled === "function" ? scrollList.isAutoScrollEnabled() : true;
  755. const rowScrollTop = getRowScrollTop(scrollElement, rowElement);
  756. const snapshot = getOrCreateLastAiRowTopSnapshot(String(lastAiMessage.id), rowScrollTop);
  757. const result = maybeStopAutoScrollAtLastAiRowTop({
  758. isLastAiRowTyping: true,
  759. isAutoScrollActive,
  760. hasStoppedAtLastAiRowTop: hasStoppedAtLastAiRowTop.value,
  761. scrollTop: scrollElement.scrollTop,
  762. rowOffsetTop: snapshot.rowOffsetTop,
  763. disableAutoScroll: () => {
  764. if (typeof scrollList.disableAutoScroll === "function") {
  765. scrollList.disableAutoScroll();
  766. }
  767. },
  768. });
  769. hasStoppedAtLastAiRowTop.value = result.hasStoppedAtLastAiRowTop;
  770. if (result.didStop) {
  771. consumeLastAiRowTopForRecord(lastAiMessage.id);
  772. setLastAiRowTopPinState(true);
  773. }
  774. if (result.didStop && result.nextScrollTop !== null) {
  775. if (typeof scrollList.scrollToPositionInstant === "function") {
  776. scrollList.scrollToPositionInstant(result.nextScrollTop);
  777. } else {
  778. scrollElement.scrollTop = result.nextScrollTop;
  779. }
  780. }
  781. return result.didStop;
  782. };
  783. const syncAutoScrollFollowState = () => {
  784. const scrollList = scrollListRef.value;
  785. if (!scrollList || typeof scrollList.getScrollElement !== "function") {
  786. return;
  787. }
  788. const scrollElement = scrollList.getScrollElement();
  789. const lastAiMessage = getLastAiMessage();
  790. if (!scrollElement || !lastAiMessage) {
  791. return;
  792. }
  793. const rowElement = messageRowRefs[String(lastAiMessage.id)];
  794. if (!rowElement) {
  795. return;
  796. }
  797. const distanceToBottom =
  798. typeof scrollList.getDistanceToBottom === "function" ? scrollList.getDistanceToBottom() : Number.POSITIVE_INFINITY;
  799. const isUserScrollSuppressed =
  800. Boolean(
  801. typeof scrollList.isAutoScrollTopPinSuppressedByUser === "function" &&
  802. scrollList.isAutoScrollTopPinSuppressedByUser()
  803. );
  804. if (isUserScrollSuppressed) {
  805. if (distanceToBottom < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE && typeof scrollList.enableAutoScroll === "function") {
  806. scrollList.enableAutoScroll();
  807. }
  808. return;
  809. }
  810. if (stopAutoScrollAtLastAiRowTopIfNeeded()) {
  811. return;
  812. }
  813. if (distanceToBottom < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE && typeof scrollList.enableAutoScroll === "function") {
  814. scrollList.enableAutoScroll();
  815. }
  816. };
  817. let chatSuggestionsResizeObserver: ResizeObserver | null = null;
  818. let listHeightRecoveryFrame = 0;
  819. let listHeightRecoveryFallbackTimer: ReturnType<typeof setTimeout> | undefined;
  820. const listHeightRecoveryMaxDuration = 2000;
  821. const listHeightRecoveryFallbackDelay = 3000;
  822. const listHeightStableFramesRequired = 4;
  823. const stopListHeightRecovery = () => {
  824. if (listHeightRecoveryFrame) {
  825. cancelAnimationFrame(listHeightRecoveryFrame);
  826. listHeightRecoveryFrame = 0;
  827. }
  828. if (listHeightRecoveryFallbackTimer) {
  829. clearTimeout(listHeightRecoveryFallbackTimer);
  830. listHeightRecoveryFallbackTimer = undefined;
  831. }
  832. };
  833. const startListHeightRecovery = () => {
  834. stopListHeightRecovery();
  835. const startedAt = performance.now();
  836. const initialViewportHeight = Math.floor(window.innerHeight);
  837. let lastViewportHeight = initialViewportHeight;
  838. let stableFrameCount = 0;
  839. let viewportRecoveryObserved = false;
  840. const recover = (timestamp: number) => {
  841. const viewportHeight = Math.floor(window.innerHeight);
  842. updateListHeight();
  843. if (viewportHeight > initialViewportHeight + 1) {
  844. viewportRecoveryObserved = true;
  845. }
  846. if (Math.abs(viewportHeight - lastViewportHeight) <= 1) {
  847. stableFrameCount += 1;
  848. } else {
  849. stableFrameCount = 0;
  850. lastViewportHeight = viewportHeight;
  851. }
  852. const recoveredAndStable = viewportRecoveryObserved && stableFrameCount >= listHeightStableFramesRequired;
  853. if (!recoveredAndStable && timestamp - startedAt < listHeightRecoveryMaxDuration) {
  854. listHeightRecoveryFrame = requestAnimationFrame(recover);
  855. return;
  856. }
  857. listHeightRecoveryFrame = 0;
  858. };
  859. listHeightRecoveryFallbackTimer = setTimeout(() => {
  860. listHeightRecoveryFallbackTimer = undefined;
  861. updateListHeight();
  862. }, listHeightRecoveryFallbackDelay);
  863. listHeightRecoveryFrame = requestAnimationFrame(recover);
  864. };
  865. onMounted(() => {
  866. loadHistory();
  867. if ((currentSession.value?.messages.length || 0) > 0) {
  868. currentPage.value = 'chat';
  869. }
  870. updateListHeight();
  871. void observeChatSuggestionsHeight();
  872. window.addEventListener('resize', updateListHeight);
  873. window.visualViewport?.addEventListener('resize', updateListHeight);
  874. window.visualViewport?.addEventListener('scroll', updateListHeight);
  875. const fetchAndMergeRemoteSessions = async () => {
  876. const { fetchRemoteSessions } = await import('@/network/api/chat-sessions');
  877. const remoteSessions = await fetchRemoteSessions(creditCode.value);
  878. if (remoteSessions.length > 0) {
  879. mergeRemoteSessions(remoteSessions);
  880. }
  881. };
  882. initAuth().then(() => {
  883. if (isLoggedIn.value) {
  884. loadHistory();
  885. void fetchAndMergeRemoteSessions();
  886. }
  887. });
  888. });
  889. onBeforeUnmount(() => {
  890. window.removeEventListener('resize', updateListHeight);
  891. window.visualViewport?.removeEventListener('resize', updateListHeight);
  892. window.visualViewport?.removeEventListener('scroll', updateListHeight);
  893. stopListHeightRecovery();
  894. chatSuggestionsResizeObserver?.disconnect();
  895. void stopVoiceInput();
  896. clearSessionPressTimer();
  897. clearLastAiRowTypingReleaseTimer();
  898. releaseLastAiRowTopPin("session-switch");
  899. cancelMobileScrollAnimation();
  900. });
  901. const updateListHeight = () => {
  902. listHeight.value = calculateMobileListHeight(window.innerHeight, isFreeformInputActive.value);
  903. updateChatSuggestionsBottomPadding();
  904. };
  905. const updateChatSuggestionsBottomPadding = () => {
  906. if (currentPage.value !== 'chat' || currentSessionSuggestions.value.length === 0) {
  907. chatSuggestionsBottomPadding.value = 0;
  908. return;
  909. }
  910. chatSuggestionsBottomPadding.value = Math.ceil(
  911. chatSuggestionsContainerRef.value?.getBoundingClientRect().height || 0,
  912. ) + 16;
  913. };
  914. const observeChatSuggestionsHeight = async () => {
  915. await nextTick();
  916. chatSuggestionsResizeObserver?.disconnect();
  917. chatSuggestionsResizeObserver = null;
  918. if (currentPage.value === 'chat' && chatSuggestionsContainerRef.value) {
  919. chatSuggestionsResizeObserver = new ResizeObserver(updateChatSuggestionsBottomPadding);
  920. chatSuggestionsResizeObserver.observe(chatSuggestionsContainerRef.value);
  921. }
  922. updateChatSuggestionsBottomPadding();
  923. };
  924. watch(
  925. [currentPage, () => currentSessionSuggestions.value.length],
  926. () => {
  927. void observeChatSuggestionsHeight();
  928. },
  929. { flush: 'post' },
  930. );
  931. watch(isFreeformInputActive, (isFocused) => {
  932. updateListHeight();
  933. if (isFocused) {
  934. stopListHeightRecovery();
  935. return;
  936. }
  937. startListHeightRecovery();
  938. }, { flush: 'post' });
  939. watch(
  940. [currentPage, () => currentSession.value?.id || null],
  941. ([page, sessionId]) => {
  942. setUploadDraftKey(page === 'chat' ? sessionId : null);
  943. },
  944. { immediate: true },
  945. );
  946. const sendSuggestion = (text: string) => {
  947. inputText.value = text;
  948. void sendMobileMessage();
  949. };
  950. const handleSuggestionClick = (item: { text: string }) => {
  951. inputText.value = item.text;
  952. void sendMessage();
  953. };
  954. const sendMobileMessage = async () => {
  955. if (currentPage.value === 'home' && currentSession.value) {
  956. const pendingText = inputText.value;
  957. startSharedNewChat();
  958. inputText.value = pendingText;
  959. }
  960. await sendMessage();
  961. };
  962. const releaseVoiceInputInstance = () => {
  963. if (!asrInstance) {
  964. return;
  965. }
  966. asrInstance.close();
  967. asrInstance.clear();
  968. asrInstance = null;
  969. };
  970. const stopVoiceInput = async () => {
  971. const wasVoiceInputActive = isVoiceInputMode.value;
  972. isVoiceInputMode.value = false;
  973. isVoiceInputStarting.value = false;
  974. if (!asrInstance) {
  975. return;
  976. }
  977. if (wasVoiceInputActive) {
  978. try {
  979. const result = await asrInstance.transform();
  980. if (result?.text) {
  981. inputText.value = result.text;
  982. }
  983. } catch (error) {
  984. console.error("Mobile voice input transform error:", error);
  985. }
  986. }
  987. releaseVoiceInputInstance();
  988. };
  989. const toggleVoiceInput = async () => {
  990. if (isVoiceInputStarting.value) {
  991. return;
  992. }
  993. if (isVoiceInputMode.value) {
  994. await stopVoiceInput();
  995. return;
  996. }
  997. if (inputText.value.trim().length > 0) {
  998. const confirmClear = window.confirm("可能会清空之前的文字,确认要清空吗?");
  999. if (!confirmClear) {
  1000. return;
  1001. }
  1002. inputText.value = "";
  1003. }
  1004. isVoiceInputStarting.value = true;
  1005. try {
  1006. // 不传 appKey:NLS 的 appkey 由服务端端点下发(前端不再硬编码厂商/云凭据)
  1007. asrInstance = new ASR(undefined, {
  1008. audio: true,
  1009. video: false,
  1010. });
  1011. let success = false;
  1012. try {
  1013. success = await asrInstance.openMicroPhone();
  1014. } catch (error) {
  1015. console.error("Mobile voice input microphone error:", error);
  1016. // 提示统一走 showToast(Ant Design 的 message)—— window.alert 会阻塞页面、
  1017. // 样式也和全站其他提示不一致(用户 2026-09-23 要求语音这几处一并换掉)
  1018. showToast("未获取到麦克风权限,请在系统或浏览器设置中启用麦克风");
  1019. releaseVoiceInputInstance();
  1020. return;
  1021. }
  1022. if (!success) {
  1023. showToast("未获取到麦克风权限,请在系统或浏览器设置中启用麦克风");
  1024. releaseVoiceInputInstance();
  1025. return;
  1026. }
  1027. asrInstance.addEventListener("transform", (arg: any) => {
  1028. inputText.value = arg.transformText;
  1029. });
  1030. asrInstance.startRecord();
  1031. await asrInstance.openSocket();
  1032. isVoiceInputMode.value = true;
  1033. } catch (error) {
  1034. console.error("Mobile voice input error:", error);
  1035. showToast(error instanceof Error ? error.message : "语音输入初始化失败,请稍后重试");
  1036. isVoiceInputMode.value = false;
  1037. releaseVoiceInputInstance();
  1038. } finally {
  1039. isVoiceInputStarting.value = false;
  1040. }
  1041. };
  1042. const scrollToBottom = async (force = false) => {
  1043. await nextTick();
  1044. if (scrollListRef.value) {
  1045. if (force && typeof scrollListRef.value.enableAutoScroll === 'function') {
  1046. scrollListRef.value.enableAutoScroll();
  1047. releaseLastAiRowTopPin("session-switch");
  1048. }
  1049. scrollListRef.value.scrollToBottom();
  1050. }
  1051. if (scrollHostRef.value) {
  1052. scrollHostRef.value.scrollTop = scrollHostRef.value.scrollHeight;
  1053. }
  1054. };
  1055. const scrollToBottomAfterSessionSwitch = async () => {
  1056. await scrollToBottom(true);
  1057. await nextTick();
  1058. scrollBusinessAssistantListToBottom(scrollListRef.value, 'instant');
  1059. if (scrollHostRef.value) {
  1060. scrollHostRef.value.scrollTop = scrollHostRef.value.scrollHeight;
  1061. }
  1062. };
  1063. const animateScrollElementBy = (element: HTMLElement | null, delta: number, duration = mobileScrollAnimationDurationMs) => {
  1064. if (!element || !Number.isFinite(delta) || delta === 0) {
  1065. return Promise.resolve();
  1066. }
  1067. cancelMobileScrollAnimation();
  1068. const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
  1069. const startTop = element.scrollTop;
  1070. const targetTop = Math.max(0, Math.min(maxScrollTop, startTop + delta));
  1071. if (targetTop === startTop) {
  1072. return Promise.resolve();
  1073. }
  1074. return new Promise((resolve) => {
  1075. const startTime = performance.now();
  1076. const revision = ++mobileScrollAnimationRevision;
  1077. const finish = () => {
  1078. if (mobileScrollAnimationResolve) {
  1079. mobileScrollAnimationResolve();
  1080. mobileScrollAnimationResolve = null;
  1081. }
  1082. mobileScrollAnimationFrameId = 0;
  1083. };
  1084. mobileScrollAnimationResolve = resolve;
  1085. const step = (now: number) => {
  1086. if (revision !== mobileScrollAnimationRevision) {
  1087. finish();
  1088. return;
  1089. }
  1090. const progress = Math.min(1, (now - startTime) / duration);
  1091. const eased = 1 - Math.pow(1 - progress, 3);
  1092. element.scrollTop = startTop + (targetTop - startTop) * eased;
  1093. if (progress < 1) {
  1094. mobileScrollAnimationFrameId = requestAnimationFrame(step);
  1095. } else {
  1096. finish();
  1097. }
  1098. };
  1099. mobileScrollAnimationFrameId = requestAnimationFrame(step);
  1100. });
  1101. };
  1102. const getLastAiRowBodyHeight = () => {
  1103. const lastAiMessage = getLastAiMessage();
  1104. if (!lastAiMessage) {
  1105. return 0;
  1106. }
  1107. const rowElement = messageRowRefs[String(lastAiMessage.id)];
  1108. const bodyElement = rowElement?.querySelector(".business-record-body") as HTMLElement | null;
  1109. return bodyElement ? Math.ceil(bodyElement.getBoundingClientRect().height) : 0;
  1110. };
  1111. const releaseScopeHeightLockIfReady = () => {
  1112. const lastAiMessage = getLastAiMessage();
  1113. if (!lastAiMessage) {
  1114. return false;
  1115. }
  1116. const key = String(lastAiMessage.id);
  1117. const lockHeight = messageRowMinHeights[key];
  1118. if (!Number.isFinite(lockHeight) || lockHeight <= 0) {
  1119. return false;
  1120. }
  1121. const bodyHeight = getLastAiRowBodyHeight();
  1122. if (bodyHeight < lockHeight) {
  1123. return false;
  1124. }
  1125. delete messageRowMinHeights[key];
  1126. return true;
  1127. };
  1128. /**
  1129. * 大块内容(卡片 / 参考资料)到达时,把**它自己的顶部**对到视口顶部。
  1130. * 与 PC 端同实现(说明见 BusinessAssistantPC.vue 里同名函数)。
  1131. */
  1132. const anchorLargeBlockTop = (heightGrowth: number): boolean => {
  1133. const scrollList = scrollListRef.value;
  1134. const lastAiMessage = getLastAiMessage();
  1135. if (!scrollList || typeof scrollList.getScrollElement !== 'function' || !lastAiMessage) {
  1136. return false;
  1137. }
  1138. const scrollElement = scrollList.getScrollElement();
  1139. const rowElement = messageRowRefs[String(lastAiMessage.id)];
  1140. if (!scrollElement || !rowElement) {
  1141. return false;
  1142. }
  1143. const rowTop = getRowScrollTop(scrollElement, rowElement);
  1144. const rowHeightAfter = rowElement.getBoundingClientRect().height;
  1145. const blockTop = rowTop + Math.max(0, rowHeightAfter - heightGrowth) - BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET;
  1146. if (typeof scrollList.scrollToPositionInstant === 'function') {
  1147. scrollList.scrollToPositionInstant(blockTop);
  1148. } else {
  1149. scrollElement.scrollTop = blockTop;
  1150. }
  1151. return true;
  1152. };
  1153. const handleBusinessRecordRefreshScroll = async (payload?: { heightGrowth?: number; scrollDelta?: number; onComplete?: () => void }) => {
  1154. const scrollList = scrollListRef.value;
  1155. if (scrollList && typeof scrollList.isAutoScrollTopPinned === "function" && scrollList.isAutoScrollTopPinned()) {
  1156. payload?.onComplete?.();
  1157. return;
  1158. }
  1159. if (stopAutoScrollAtLastAiRowTopIfNeeded()) {
  1160. syncAutoScrollFollowState();
  1161. payload?.onComplete?.();
  1162. return;
  1163. }
  1164. const isUserScrollSuppressed =
  1165. Boolean(
  1166. scrollList &&
  1167. typeof scrollList.isAutoScrollTopPinSuppressedByUser === "function" &&
  1168. scrollList.isAutoScrollTopPinSuppressedByUser()
  1169. );
  1170. if (scrollList && typeof scrollList.enableAutoScroll === "function" && !isLastAiRowTopPinned()) {
  1171. if (isUserScrollSuppressed) {
  1172. const distanceToBottom =
  1173. typeof scrollList.getDistanceToBottom === "function"
  1174. ? scrollList.getDistanceToBottom()
  1175. : Number.POSITIVE_INFINITY;
  1176. if (distanceToBottom < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE) {
  1177. scrollList.enableAutoScroll();
  1178. }
  1179. payload?.onComplete?.();
  1180. return;
  1181. }
  1182. scrollList.enableAutoScroll();
  1183. payload?.onComplete?.();
  1184. return;
  1185. }
  1186. const releasedLock = releaseScopeHeightLockIfReady();
  1187. if (releasedLock) {
  1188. syncAutoScrollFollowState();
  1189. }
  1190. const scrollDelta = Number(payload?.scrollDelta || 0);
  1191. if (Number.isFinite(scrollDelta) && scrollDelta !== 0) {
  1192. const scrollTasks: Promise<unknown>[] = [];
  1193. if (scrollListRef.value && typeof scrollListRef.value.scrollByAnimated === "function") {
  1194. scrollTasks.push(scrollListRef.value.scrollByAnimated(scrollDelta, mobileScrollAnimationDurationMs));
  1195. } else if (scrollListRef.value && typeof scrollListRef.value.scrollBy === "function") {
  1196. scrollListRef.value.scrollBy(scrollDelta);
  1197. }
  1198. scrollTasks.push(animateScrollElementBy(scrollHostRef.value, scrollDelta));
  1199. await Promise.all(scrollTasks);
  1200. syncAutoScrollFollowState();
  1201. payload?.onComplete?.();
  1202. return;
  1203. }
  1204. await nextTick();
  1205. if (!scrollListRef.value) {
  1206. return;
  1207. }
  1208. if (!releasedLock) {
  1209. const lastAiMessage = getLastAiMessage();
  1210. if (lastAiMessage) {
  1211. const lockHeight = messageRowMinHeights[String(lastAiMessage.id)];
  1212. if (
  1213. Number.isFinite(lockHeight) &&
  1214. lockHeight > 0 &&
  1215. getLastAiRowBodyHeight() < lockHeight &&
  1216. !isLastAiRowTyping()
  1217. ) {
  1218. return;
  1219. }
  1220. }
  1221. }
  1222. const heightGrowth = Number(payload?.heightGrowth || 0);
  1223. const distanceAfterGrowth =
  1224. typeof scrollListRef.value.getDistanceToBottom === 'function'
  1225. ? scrollListRef.value.getDistanceToBottom()
  1226. : Number.POSITIVE_INFINITY;
  1227. const distanceBeforeGrowth = Math.max(0, distanceAfterGrowth - heightGrowth);
  1228. if (!Number.isFinite(heightGrowth) || heightGrowth <= 0) {
  1229. if (distanceAfterGrowth <= 24) {
  1230. scrollBusinessAssistantListToBottom(scrollListRef.value, 'instant');
  1231. } else {
  1232. scrollBusinessAssistantListToBottom(scrollListRef.value);
  1233. }
  1234. return;
  1235. }
  1236. if (
  1237. distanceBeforeGrowth < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE ||
  1238. heightGrowth > BUSINESS_RECORD_LARGE_HEIGHT_GROWTH
  1239. ) {
  1240. // 一整块到达(卡片 / 参考资料):把块的**顶部**对到视口顶,而不是滚过它
  1241. // (用户 2026-09-21 选定;说明见 BusinessAssistantPC.vue 的 BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH)
  1242. if (heightGrowth >= BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH && anchorLargeBlockTop(heightGrowth)) {
  1243. payload?.onComplete?.();
  1244. return;
  1245. }
  1246. if (distanceAfterGrowth <= 24) {
  1247. scrollBusinessAssistantListToBottom(scrollListRef.value, 'instant');
  1248. } else {
  1249. scrollBusinessAssistantListToBottom(scrollListRef.value, 'once');
  1250. }
  1251. }
  1252. syncAutoScrollFollowState();
  1253. };
  1254. const handleBusinessScrollPositionChange = (payload?: { isTrusted?: boolean; isProgrammaticScroll?: boolean }) => {
  1255. if (payload?.isTrusted && !payload?.isProgrammaticScroll) {
  1256. cancelMobileScrollAnimation();
  1257. consumeLastAiRowTopForRecord(getLastAiMessage()?.id ?? null);
  1258. releaseLastAiRowTopPin("user-scroll");
  1259. }
  1260. if (stopAutoScrollAtLastAiRowTopIfNeeded()) {
  1261. syncAutoScrollFollowState();
  1262. return;
  1263. }
  1264. syncAutoScrollFollowState();
  1265. };
  1266. const handleScopeHeightLockChange = (payload?: { recordId?: string | number; active?: boolean; height?: number }) => {
  1267. const recordId = payload?.recordId;
  1268. if (recordId === undefined || recordId === null) {
  1269. return;
  1270. }
  1271. if (payload?.active) {
  1272. Object.keys(messageRowMinHeights).forEach((key) => {
  1273. delete messageRowMinHeights[key];
  1274. });
  1275. setMessageRowMinHeight(recordId, payload.height);
  1276. return;
  1277. }
  1278. setMessageRowMinHeight(recordId, null);
  1279. };
  1280. const handleBusinessRecordTypewriterStateChange = (payload?: { recordId?: string | number; isTypingActive?: boolean }) => {
  1281. const recordId = payload?.recordId;
  1282. if (recordId === undefined || recordId === null) {
  1283. return;
  1284. }
  1285. const key = String(recordId);
  1286. if (payload?.isTypingActive) {
  1287. if (lastAiRowTopConsumedRecordId.value && lastAiRowTopConsumedRecordId.value !== key) {
  1288. lastAiRowTopConsumedRecordId.value = null;
  1289. hasStoppedAtLastAiRowTop.value = false;
  1290. clearLastAiRowTopSnapshot();
  1291. }
  1292. clearLastAiRowTypingReleaseTimer();
  1293. lastAiRowTypewriterStates.value[key] = true;
  1294. syncAutoScrollFollowState();
  1295. return;
  1296. }
  1297. clearLastAiRowTypingReleaseTimer();
  1298. lastAiRowTypingReleaseTimer = window.setTimeout(() => {
  1299. if (lastAiRowTypewriterStates.value[key]) {
  1300. return;
  1301. }
  1302. delete lastAiRowTypewriterStates.value[key];
  1303. releaseLastAiRowTopPin("typewriter-finished");
  1304. syncAutoScrollFollowState();
  1305. }, 180);
  1306. };
  1307. const goHome = () => {
  1308. currentPage.value = 'home';
  1309. };
  1310. const openPolicyList = () => {
  1311. currentPage.value = 'policy-list';
  1312. selectedPolicy.value = null;
  1313. };
  1314. const openPolicyDetail = (policy: PolicyItem) => {
  1315. selectedPolicy.value = policy;
  1316. currentPage.value = 'policy-detail';
  1317. };
  1318. const closeDrawer = () => {
  1319. isDrawerOpen.value = false;
  1320. activeSessionMenuId.value = null;
  1321. longPressSessionId.value = null;
  1322. };
  1323. const startNewChat = () => {
  1324. setUploadDraftKey(null);
  1325. clearUploadDraft(null);
  1326. startSharedNewChat();
  1327. currentPage.value = 'home';
  1328. closeDrawer();
  1329. };
  1330. const switchSessionById = (id: string) => {
  1331. const session = chatHistory.value.find((s) => s.id === id);
  1332. const doSwitch = () => {
  1333. if (switchSharedSessionById(id)) {
  1334. currentPage.value = 'chat';
  1335. closeDrawer();
  1336. void scrollToBottomAfterSessionSwitch();
  1337. }
  1338. };
  1339. if (session && session.messages.length === 0 && isLoggedIn.value) {
  1340. void loadRemoteSessionRecords(id).then(() => {
  1341. doSwitch();
  1342. });
  1343. return;
  1344. }
  1345. doSwitch();
  1346. };
  1347. const clearSessionPressTimer = () => {
  1348. if (sessionPressTimer === null) {
  1349. return;
  1350. }
  1351. window.clearTimeout(sessionPressTimer);
  1352. sessionPressTimer = null;
  1353. };
  1354. const closeSessionMenu = () => {
  1355. activeSessionMenuId.value = null;
  1356. longPressSessionId.value = null;
  1357. sessionPressTriggeredId = null;
  1358. sessionPressPointerId = null;
  1359. sessionMenuAnchor.value = null;
  1360. };
  1361. const handleSessionTap = (id: string) => {
  1362. if (activeSessionMenuId.value === id) {
  1363. return;
  1364. }
  1365. switchSessionById(id);
  1366. };
  1367. const handleSessionPressStart = (id: string, event: PointerEvent) => {
  1368. if (event.pointerType === 'mouse' && event.button !== 0) {
  1369. return;
  1370. }
  1371. clearSessionPressTimer();
  1372. sessionPressTriggeredId = null;
  1373. sessionPressPointerId = event.pointerId;
  1374. longPressSessionId.value = id;
  1375. const target = event.currentTarget as HTMLElement | null;
  1376. const targetRect = target?.getBoundingClientRect() || null;
  1377. sessionPressTimer = window.setTimeout(() => {
  1378. sessionPressTimer = null;
  1379. sessionPressTriggeredId = id;
  1380. activeSessionMenuId.value = id;
  1381. if (targetRect) {
  1382. sessionMenuAnchor.value = {
  1383. left: targetRect.left + 8,
  1384. bottom: targetRect.bottom + 6,
  1385. };
  1386. }
  1387. }, 420);
  1388. };
  1389. const handleSessionPressEnd = (event?: PointerEvent) => {
  1390. if (event && sessionPressPointerId !== null && event.pointerId !== sessionPressPointerId) {
  1391. return;
  1392. }
  1393. clearSessionPressTimer();
  1394. sessionPressPointerId = null;
  1395. if (sessionPressTriggeredId !== longPressSessionId.value) {
  1396. longPressSessionId.value = null;
  1397. }
  1398. };
  1399. const handleRenameFromSheet = () => {
  1400. const id = activeSessionMenuId.value;
  1401. if (!id) {
  1402. return;
  1403. }
  1404. closeSessionMenu();
  1405. openRenameModal(id);
  1406. };
  1407. const handleDeleteFromSheet = () => {
  1408. const id = activeSessionMenuId.value;
  1409. if (!id) {
  1410. return;
  1411. }
  1412. closeSessionMenu();
  1413. openDeleteModal(id);
  1414. };
  1415. const sessionMenuPopoverStyle = computed(() => {
  1416. const anchor = sessionMenuAnchor.value;
  1417. if (!anchor) {
  1418. return {};
  1419. }
  1420. return {
  1421. left: `${Math.max(8, anchor.left)}px`,
  1422. top: `${anchor.bottom}px`,
  1423. };
  1424. });
  1425. const openRenameModal = (id: string) => {
  1426. const session = chatHistory.value.find((item) => item.id === id);
  1427. if (!session) {
  1428. return;
  1429. }
  1430. renameSessionId.value = id;
  1431. renameInput.value = session.title || '';
  1432. closeSessionMenu();
  1433. isRenameModalOpen.value = true;
  1434. };
  1435. const closeRenameModal = () => {
  1436. isRenameModalOpen.value = false;
  1437. renameSessionId.value = null;
  1438. };
  1439. const confirmRename = () => {
  1440. const title = renameInput.value.trim();
  1441. if (!renameSessionId.value || !title) {
  1442. return;
  1443. }
  1444. renameSessionById(renameSessionId.value, title);
  1445. closeRenameModal();
  1446. };
  1447. const openDeleteModal = (id: string) => {
  1448. deleteSessionId.value = id;
  1449. closeSessionMenu();
  1450. isDeleteModalOpen.value = true;
  1451. };
  1452. const closeDeleteModal = () => {
  1453. isDeleteModalOpen.value = false;
  1454. deleteSessionId.value = null;
  1455. };
  1456. const confirmDeleteSession = () => {
  1457. if (!deleteSessionId.value) {
  1458. return;
  1459. }
  1460. deleteSessionById(deleteSessionId.value);
  1461. if (!currentSession.value) {
  1462. currentPage.value = 'home';
  1463. }
  1464. closeDeleteModal();
  1465. };
  1466. </script>
  1467. <style scoped>
  1468. .ba-mobile-container {
  1469. position: relative;
  1470. width: 100vw;
  1471. height: 100dvh;
  1472. overflow: hidden;
  1473. display: flex;
  1474. flex-direction: column;
  1475. background: linear-gradient(180deg, #ffffff 0%, #f9f9ff 100%);
  1476. color: #141b2b;
  1477. font-family: "HarmonyOS Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
  1478. }
  1479. .mobile-header {
  1480. height: 52px;
  1481. flex-shrink: 0;
  1482. display: flex;
  1483. align-items: center;
  1484. justify-content: space-between;
  1485. padding: 0 56px 0 16px;
  1486. background: #f9f9ff;
  1487. z-index: 10;
  1488. }
  1489. .mobile-header.chat-header {
  1490. justify-content: center;
  1491. padding: 0 16px;
  1492. }
  1493. .icon-btn {
  1494. width: 40px;
  1495. height: 40px;
  1496. border: 0;
  1497. border-radius: 999px;
  1498. display: inline-flex;
  1499. align-items: center;
  1500. justify-content: center;
  1501. background: transparent;
  1502. color: #464555;
  1503. cursor: pointer;
  1504. }
  1505. .icon-btn:active {
  1506. background: rgba(79, 70, 229, 0.1);
  1507. color: #3525cd;
  1508. }
  1509. .header-copy {
  1510. position: relative;
  1511. min-width: 0;
  1512. flex: 1;
  1513. display: flex;
  1514. flex-direction: column;
  1515. align-items: center;
  1516. justify-content: center;
  1517. text-align: center;
  1518. }
  1519. .chat-header .header-copy {
  1520. width: 100%;
  1521. }
  1522. .header-copy-menu {
  1523. position: absolute;
  1524. left: 0;
  1525. top: calc(50% - 0.5px);
  1526. transform: translateY(-50%);
  1527. }
  1528. .header-content {
  1529. width: 100%;
  1530. display: flex;
  1531. flex-direction: column;
  1532. align-items: center;
  1533. justify-content: center;
  1534. }
  1535. :deep(.record--business.ba-record-me) {
  1536. background-color: rgba(65, 45, 252, 1);
  1537. color: #ffffff;
  1538. width: fit-content;
  1539. max-width: 300px;
  1540. opacity: 1;
  1541. padding: 16px 22.3px 16px 16px;
  1542. border-radius: 12px 4px 12px 12px;
  1543. box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05);
  1544. }
  1545. :deep(.record--business.ba-record-me .text-content) {
  1546. color: #ffffff;
  1547. }
  1548. :deep(.business-record .content a) {
  1549. color: #3525cd;
  1550. }
  1551. .chat-header-title {
  1552. height: 26px;
  1553. color: #141b2b;
  1554. font-family: "HarmonyOS Sans SC", sans-serif;
  1555. font-size: 20px;
  1556. font-weight: 500;
  1557. line-height: 1.3;
  1558. letter-spacing: -0.2px;
  1559. display: flex;
  1560. align-items: center;
  1561. }
  1562. .header-subtitle {
  1563. color: #3525cd;
  1564. font-size: 12px;
  1565. font-weight: 500;
  1566. line-height: 1.4;
  1567. letter-spacing: 0.12px;
  1568. opacity: 0.8;
  1569. padding-bottom: 0.8px;
  1570. overflow: hidden;
  1571. text-overflow: ellipsis;
  1572. white-space: nowrap;
  1573. }
  1574. .mobile-main {
  1575. flex: 1;
  1576. /* overflow-y: auto; */
  1577. position: relative;
  1578. padding: 24px 8px 28px;
  1579. }
  1580. .page-chat {
  1581. padding: 0 16px 198px;
  1582. background: #ffffff;
  1583. }
  1584. .page-policy-list,
  1585. .page-policy-detail {
  1586. padding: 32px 16px 64px;
  1587. background: #ffffff;
  1588. }
  1589. .bg-image-container {
  1590. position: absolute;
  1591. top: 76px;
  1592. left: 50%;
  1593. z-index: 0;
  1594. width: 580px;
  1595. height: 382px;
  1596. opacity: 0.1;
  1597. transform: translateX(-50%);
  1598. pointer-events: none;
  1599. }
  1600. .bg-image {
  1601. width: 100%;
  1602. max-width: none;
  1603. height: 100%;
  1604. object-fit: fill;
  1605. }
  1606. .particle-bg {
  1607. position: absolute;
  1608. top: 76px;
  1609. left: 50%;
  1610. z-index: 1;
  1611. width: 580px;
  1612. height: 382px;
  1613. transform: translateX(-50%);
  1614. pointer-events: none;
  1615. }
  1616. .welcome-section {
  1617. position: relative;
  1618. z-index: 1;
  1619. display: flex;
  1620. flex-direction: column;
  1621. align-items: center;
  1622. gap: 6px;
  1623. margin: 0;
  1624. /* 两侧留白:标题与介绍文案都不贴屏幕边(原先几乎顶满,观感很挤) */
  1625. padding: 0 28px;
  1626. box-sizing: border-box;
  1627. width: 100%;
  1628. text-align: center;
  1629. }
  1630. .overlay-icon {
  1631. width: 96px;
  1632. height: 65px;
  1633. border-radius: 999px;
  1634. display: flex;
  1635. align-items: center;
  1636. justify-content: center;
  1637. margin-bottom: 20px;
  1638. }
  1639. .heading {
  1640. margin: 0;
  1641. color: #141b2b;
  1642. /* 先给老内核一个固定值(= 上限),再用 clamp 让更窄的屏继续收一点。
  1643. 目的:这行字**恒定单行**不换行。
  1644. ⚠️ 两行必须同进同退:clamp 这行在后面、会覆盖上一行,只改基础值等于没改。
  1645. 2026-09-18 演进:20px → 16px(用户反馈「太小了」)→ 20px → **22px(当前)**。
  1646. clamp 取 5.7vw:≥386px 的屏恒为 22px,更窄的屏按比例收缩以免标题被顶出屏幕。 */
  1647. font-size: 22px;
  1648. font-size: clamp(17px, 5.7vw, 22px);
  1649. font-weight: 500;
  1650. line-height: 1.2;
  1651. letter-spacing: -0.64px;
  1652. margin-bottom: 12px;
  1653. white-space: nowrap;
  1654. }
  1655. .desc {
  1656. margin: 0;
  1657. color: #464555;
  1658. /* 加了两侧留白后可用宽度变窄,字号同步收一点,避免这段介绍多折一行 */
  1659. font-size: 13px;
  1660. font-weight: 500;
  1661. line-height: 1.5;
  1662. }
  1663. .chat-input-area {
  1664. width: 100%;
  1665. padding: 0 16px 16px;
  1666. overflow: hidden;
  1667. }
  1668. .mobile-main>.chat-input-area {
  1669. width: calc(100% + 48px);
  1670. margin-left: -24px;
  1671. margin-right: -24px;
  1672. }
  1673. .page-home>.chat-input-area {
  1674. position: absolute;
  1675. left: 0;
  1676. right: 0;
  1677. bottom: 0;
  1678. z-index: 2;
  1679. width: 100%;
  1680. margin: 0;
  1681. }
  1682. .fixed-input .chat-input-area {
  1683. padding-bottom: 0;
  1684. }
  1685. .home-content-shell {
  1686. flex: 1;
  1687. min-height: 0;
  1688. display: flex;
  1689. flex-direction: column;
  1690. justify-content: center;
  1691. align-items: center;
  1692. width: 100%;
  1693. height: 100%;
  1694. gap: 0;
  1695. padding-bottom: 180px;
  1696. }
  1697. .suggestion-chips {
  1698. display: flex;
  1699. flex-direction: column;
  1700. align-items: center;
  1701. gap: 10px;
  1702. width: 100%;
  1703. padding: 18px 32px 0;
  1704. }
  1705. .chip {
  1706. min-height: 38px;
  1707. border: 1px solid rgba(199, 196, 216, 0.3);
  1708. border-radius: 12px;
  1709. padding: 9px 16px;
  1710. background: #ffffff;
  1711. color: #464555;
  1712. font-size: 12px;
  1713. font-weight: 500;
  1714. line-height: 1.4;
  1715. letter-spacing: 0.12px;
  1716. display: inline-flex;
  1717. align-items: center;
  1718. justify-content: center;
  1719. gap: 8px;
  1720. cursor: pointer;
  1721. }
  1722. .chip-icon {
  1723. width: 16px;
  1724. height: 16px;
  1725. display: flex;
  1726. align-items: center;
  1727. justify-content: center;
  1728. }
  1729. .chat-stage {
  1730. position: relative;
  1731. z-index: 1;
  1732. padding-top: 12px;
  1733. }
  1734. .chat-ambient {
  1735. position: fixed;
  1736. inset: 64px 0 0;
  1737. z-index: 0;
  1738. pointer-events: none;
  1739. overflow: hidden;
  1740. opacity: 0.3;
  1741. }
  1742. .chat-ambient span {
  1743. position: absolute;
  1744. display: block;
  1745. border-radius: 999px;
  1746. filter: blur(70px);
  1747. }
  1748. .chat-ambient span:first-child {
  1749. top: 0;
  1750. left: 90px;
  1751. width: 300px;
  1752. height: 300px;
  1753. background: #3525cd;
  1754. }
  1755. .chat-ambient span:last-child {
  1756. left: 0;
  1757. bottom: -80px;
  1758. width: 250px;
  1759. height: 250px;
  1760. background: #4648d4;
  1761. }
  1762. .date-indicator {
  1763. display: flex;
  1764. justify-content: center;
  1765. padding: 12px 0;
  1766. color: #777587;
  1767. font-size: 12px;
  1768. font-weight: 500;
  1769. line-height: 1.4;
  1770. }
  1771. .message-row {
  1772. width: 100%;
  1773. display: flex;
  1774. margin-bottom: 16px;
  1775. }
  1776. .message-row.user {
  1777. justify-content: flex-end;
  1778. }
  1779. .message-row.ai {
  1780. justify-content: flex-start;
  1781. }
  1782. .message-row.ai.is-last {
  1783. position: relative;
  1784. /* padding-bottom: 32px; */
  1785. }
  1786. :deep(.chat-record-line) {
  1787. /* max-width: min(322px, 100%) !important; */
  1788. min-width: 80px !important;
  1789. min-height: 55px !important;
  1790. font-size: 15px !important;
  1791. line-height: 1.6 !important;
  1792. }
  1793. :deep(.policy-item) {
  1794. width: 100% !important;
  1795. box-sizing: border-box;
  1796. }
  1797. :deep(.text-right) {
  1798. display: flex;
  1799. flex-direction: column;
  1800. align-items: flex-end;
  1801. }
  1802. :deep(.text-left) {
  1803. display: flex;
  1804. flex-direction: column;
  1805. align-items: flex-start;
  1806. }
  1807. .empty-chat {
  1808. margin-top: 120px;
  1809. text-align: center;
  1810. color: #777587;
  1811. font-size: 14px;
  1812. }
  1813. .chat-suggestion-chips {
  1814. display: flex;
  1815. gap: 8px;
  1816. flex-wrap: wrap;
  1817. width: 100%;
  1818. justify-content: flex-start;
  1819. position: relative;
  1820. z-index: 1;
  1821. pointer-events: auto;
  1822. }
  1823. .fixed-input {
  1824. position: fixed;
  1825. left: 0;
  1826. right: 0;
  1827. bottom: 0;
  1828. z-index: 8;
  1829. padding: 12px 0 16px;
  1830. background: linear-gradient(180deg, rgba(249, 249, 255, 0) 0%, #f9f9ff 26%, #f9f9ff 100%);
  1831. }
  1832. .home-fixed-input {
  1833. position: fixed;
  1834. left: 0;
  1835. right: 0;
  1836. bottom: 0;
  1837. z-index: 9;
  1838. padding: 12px 0 calc(16px + env(safe-area-inset-bottom));
  1839. background: linear-gradient(180deg, rgba(249, 249, 255, 0) 0%, #f9f9ff 26%, #f9f9ff 100%);
  1840. }
  1841. .chat-suggestions-container {
  1842. position: absolute;
  1843. top: calc(100% + 12px);
  1844. left: 0;
  1845. right: 0;
  1846. width: 100%;
  1847. display: flex;
  1848. justify-content: center;
  1849. pointer-events: none;
  1850. z-index: 3;
  1851. }
  1852. .chat-suggestions-gradient {
  1853. display: none;
  1854. }
  1855. .ai-disclaimer {
  1856. color: #4f46e5;
  1857. font-size: 12px;
  1858. text-align: center;
  1859. }
  1860. .policy-page,
  1861. .policy-detail {
  1862. position: relative;
  1863. display: flex;
  1864. flex-direction: column;
  1865. }
  1866. .policy-page {
  1867. gap: 16px;
  1868. }
  1869. .policy-detail {
  1870. gap: 32px;
  1871. }
  1872. .page-close-btn {
  1873. position: absolute;
  1874. top: -5px;
  1875. right: 0;
  1876. z-index: 2;
  1877. width: 40px;
  1878. height: 40px;
  1879. border: 0;
  1880. border-radius: 999px;
  1881. background: transparent;
  1882. color: #464555;
  1883. display: flex;
  1884. align-items: center;
  1885. justify-content: center;
  1886. }
  1887. .policy-title-block {
  1888. padding-right: 16px;
  1889. }
  1890. .policy-title-block h1,
  1891. .policy-detail-hero h1 {
  1892. margin: 0;
  1893. color: #141b2b;
  1894. font-size: 24px;
  1895. font-weight: 500;
  1896. line-height: 1.35;
  1897. letter-spacing: -0.24px;
  1898. }
  1899. .policy-title-block p {
  1900. margin: 7px 0 0;
  1901. color: #464555;
  1902. font-size: 16px;
  1903. font-weight: 500;
  1904. line-height: 1.6;
  1905. }
  1906. .policy-filter {
  1907. display: flex;
  1908. flex-direction: column;
  1909. gap: 16px;
  1910. padding: 16px;
  1911. border: 1px solid #e9edff;
  1912. border-radius: 12px;
  1913. background: #ffffff;
  1914. box-shadow: 0 1px 1.75px rgba(0, 0, 0, 0.05);
  1915. }
  1916. .policy-search,
  1917. .drawer-search {
  1918. height: 48px;
  1919. display: flex;
  1920. align-items: center;
  1921. gap: 8px;
  1922. padding: 0 16px;
  1923. border: 1px solid #c7c4d8;
  1924. border-radius: 8px;
  1925. background: #f9f9ff;
  1926. color: #777587;
  1927. }
  1928. .policy-filter-row {
  1929. display: grid;
  1930. grid-template-columns: 1fr 112px;
  1931. gap: 16px;
  1932. }
  1933. .policy-search input,
  1934. .drawer-search input {
  1935. min-width: 0;
  1936. flex: 1;
  1937. border: 0;
  1938. outline: 0;
  1939. background: transparent;
  1940. color: #141b2b;
  1941. font: inherit;
  1942. font-size: 14px;
  1943. }
  1944. .policy-select {
  1945. height: 48px;
  1946. border: 1px solid #c7c4d8;
  1947. border-radius: 8px;
  1948. background: #f9f9ff;
  1949. color: #141b2b;
  1950. padding: 0 16px;
  1951. font: inherit;
  1952. font-size: 14px;
  1953. min-width: 0;
  1954. }
  1955. .policy-filter-btn {
  1956. height: 48px;
  1957. border: 0;
  1958. border-radius: 8px;
  1959. background: #3525cd;
  1960. color: #ffffff;
  1961. display: inline-flex;
  1962. align-items: center;
  1963. justify-content: center;
  1964. gap: 8px;
  1965. padding: 0 18px;
  1966. font: inherit;
  1967. font-size: 14px;
  1968. font-weight: 500;
  1969. }
  1970. .policy-list {
  1971. display: flex;
  1972. flex-direction: column;
  1973. gap: 12px;
  1974. }
  1975. .policy-card {
  1976. display: grid;
  1977. grid-template-columns: 1fr 98px;
  1978. gap: 12px;
  1979. min-height: 186px;
  1980. padding: 16px;
  1981. border: 1px solid #e9edff;
  1982. border-radius: 12px;
  1983. background: #ffffff;
  1984. cursor: pointer;
  1985. }
  1986. .policy-meta,
  1987. .detail-tags {
  1988. display: flex;
  1989. flex-wrap: wrap;
  1990. gap: 8px;
  1991. color: #464555;
  1992. font-size: 12px;
  1993. }
  1994. .policy-meta span,
  1995. .detail-tags span {
  1996. padding: 2px 8px;
  1997. border-radius: 4px;
  1998. background: #e2dfff;
  1999. }
  2000. .policy-card h2 {
  2001. margin: 8px 0 0;
  2002. color: #141b2b;
  2003. font-size: 20px;
  2004. font-weight: 500;
  2005. line-height: 1.3;
  2006. letter-spacing: -0.2px;
  2007. }
  2008. .policy-card p {
  2009. margin: 8px 0 0;
  2010. color: #464555;
  2011. font-size: 14px;
  2012. font-weight: 500;
  2013. line-height: 1.6;
  2014. }
  2015. .match-panel {
  2016. display: flex;
  2017. flex-direction: column;
  2018. align-items: center;
  2019. justify-content: center;
  2020. gap: 8px;
  2021. }
  2022. .match-ring {
  2023. width: 64px;
  2024. height: 64px;
  2025. border-radius: 50%;
  2026. border: 6px solid #dce2f7;
  2027. color: #3525cd;
  2028. display: flex;
  2029. align-items: center;
  2030. justify-content: center;
  2031. font-size: 15px;
  2032. font-weight: 700;
  2033. }
  2034. .match-label {
  2035. color: #464555;
  2036. font-size: 12px;
  2037. }
  2038. .match-panel button,
  2039. .back-policy-btn {
  2040. height: 34px;
  2041. border: 1px solid #3525cd;
  2042. border-radius: 8px;
  2043. background: #ffffff;
  2044. color: #3525cd;
  2045. padding: 0 16px;
  2046. font: inherit;
  2047. font-size: 12px;
  2048. }
  2049. .pagination-row {
  2050. display: grid;
  2051. grid-template-columns: repeat(7, minmax(0, 1fr));
  2052. gap: 8px;
  2053. align-items: center;
  2054. text-align: center;
  2055. }
  2056. .pagination-row button {
  2057. height: 40px;
  2058. border: 1px solid #c7c4d8;
  2059. border-radius: 8px;
  2060. background: transparent;
  2061. color: #464555;
  2062. font: inherit;
  2063. }
  2064. .pagination-row button.active {
  2065. border-color: #3525cd;
  2066. background: #3525cd;
  2067. color: #ffffff;
  2068. }
  2069. .back-policy-btn {
  2070. align-self: flex-start;
  2071. display: inline-flex;
  2072. align-items: center;
  2073. gap: 4px;
  2074. border: 0;
  2075. padding: 0;
  2076. height: auto;
  2077. color: #3525cd;
  2078. }
  2079. .policy-detail-hero {
  2080. display: flex;
  2081. flex-direction: column;
  2082. gap: 12px;
  2083. }
  2084. .detail-grid,
  2085. .conditions-section {
  2086. display: flex;
  2087. flex-direction: column;
  2088. gap: 16px;
  2089. }
  2090. .detail-box,
  2091. .condition-card {
  2092. padding: 16px;
  2093. border: 1px solid rgba(199, 196, 216, 0.3);
  2094. border-radius: 12px;
  2095. background: #ffffff;
  2096. }
  2097. .detail-box h2,
  2098. .conditions-section h2 {
  2099. margin: 0 0 12px;
  2100. color: #141b2b;
  2101. font-size: 16px;
  2102. font-weight: 600;
  2103. }
  2104. .detail-box p,
  2105. .detail-box li,
  2106. .condition-card p,
  2107. .condition-lead {
  2108. color: #464555;
  2109. font-size: 14px;
  2110. font-weight: 500;
  2111. line-height: 1.6;
  2112. }
  2113. .detail-box ul {
  2114. margin: 0;
  2115. padding-left: 18px;
  2116. }
  2117. .condition-lead {
  2118. padding-left: 12px;
  2119. border-left: 1px solid #a1a1a1;
  2120. opacity: 0.65;
  2121. }
  2122. .condition-status {
  2123. align-self: flex-end;
  2124. display: inline-flex;
  2125. padding: 4px 10px;
  2126. border-radius: 999px;
  2127. background: rgba(53, 37, 205, 0.1);
  2128. color: #3525cd;
  2129. font-size: 12px;
  2130. font-weight: 600;
  2131. }
  2132. .drawer-layer {
  2133. position: fixed;
  2134. inset: 0;
  2135. z-index: 30;
  2136. background: rgba(0, 0, 0, 0.5);
  2137. }
  2138. .session-drawer {
  2139. width: min(331.5px, 86vw);
  2140. height: 100%;
  2141. display: flex;
  2142. flex-direction: column;
  2143. background: #ffffff;
  2144. box-shadow: 0 25px 43.75px -12px rgba(0, 0, 0, 0.25);
  2145. }
  2146. .drawer-head {
  2147. min-height: 73px;
  2148. display: flex;
  2149. align-items: center;
  2150. justify-content: space-between;
  2151. gap: 16px;
  2152. padding: 16px;
  2153. border-bottom: 1px solid #c7c4d8;
  2154. }
  2155. .new-chat-btn {
  2156. height: 40px;
  2157. border: 0;
  2158. border-radius: 999px;
  2159. background: #3525cd;
  2160. color: #ffffff;
  2161. display: inline-flex;
  2162. align-items: center;
  2163. gap: 8px;
  2164. padding: 0 16px;
  2165. font: inherit;
  2166. font-size: 16px;
  2167. font-weight: 500;
  2168. }
  2169. .drawer-search {
  2170. margin: 16px;
  2171. height: 40px;
  2172. border: 0;
  2173. }
  2174. .drawer-list {
  2175. position: relative;
  2176. flex: 1;
  2177. min-height: 0;
  2178. overflow-y: auto;
  2179. padding: 7px 8px 16px;
  2180. -webkit-touch-callout: none;
  2181. }
  2182. .drawer-list h2 {
  2183. margin: 0;
  2184. padding: 0 8px 8px;
  2185. color: #777587;
  2186. font-size: 12px;
  2187. font-weight: 500;
  2188. letter-spacing: 0.12px;
  2189. }
  2190. .drawer-empty {
  2191. padding: 16px;
  2192. color: #777587;
  2193. font-size: 13px;
  2194. }
  2195. .drawer-session {
  2196. position: relative;
  2197. width: 100%;
  2198. min-height: 48px;
  2199. border: 0;
  2200. border-radius: 12px;
  2201. background: transparent;
  2202. color: #464555;
  2203. display: flex;
  2204. align-items: center;
  2205. justify-content: space-between;
  2206. gap: 10px;
  2207. padding: 12px;
  2208. font: inherit;
  2209. font-size: 14px;
  2210. text-align: left;
  2211. user-select: none;
  2212. -webkit-user-select: none;
  2213. -webkit-touch-callout: none;
  2214. -webkit-tap-highlight-color: transparent;
  2215. transition:
  2216. background 0.18s ease,
  2217. color 0.18s ease,
  2218. transform 0.18s ease;
  2219. }
  2220. .drawer-session.active {
  2221. background: rgba(79, 70, 229, 0.1);
  2222. color: #3525cd;
  2223. }
  2224. .drawer-session.is-pressing {
  2225. background: rgba(53, 37, 205, 0.08);
  2226. transform: scale(0.99);
  2227. }
  2228. .drawer-session span {
  2229. min-width: 0;
  2230. overflow: hidden;
  2231. text-overflow: ellipsis;
  2232. white-space: nowrap;
  2233. }
  2234. .session-popover {
  2235. position: absolute;
  2236. left: 8px;
  2237. top: calc(100% + 6px);
  2238. z-index: 50;
  2239. width: 108px;
  2240. overflow: hidden;
  2241. border-radius: 10px;
  2242. background: rgba(250, 251, 255, 0.98);
  2243. box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
  2244. user-select: none;
  2245. -webkit-user-select: none;
  2246. -webkit-touch-callout: none;
  2247. -webkit-tap-highlight-color: transparent;
  2248. }
  2249. .session-popover button {
  2250. width: 100%;
  2251. height: 38px;
  2252. border: 0;
  2253. background: transparent;
  2254. color: #141b2b;
  2255. font: inherit;
  2256. font-size: 14px;
  2257. font-weight: 500;
  2258. text-align: left;
  2259. padding: 0 12px;
  2260. user-select: none;
  2261. -webkit-user-select: none;
  2262. -webkit-touch-callout: none;
  2263. -webkit-tap-highlight-color: transparent;
  2264. }
  2265. .session-popover button:active {
  2266. background: rgba(53, 37, 205, 0.08);
  2267. }
  2268. .session-popover .danger {
  2269. color: #e82437;
  2270. }
  2271. .session-menu-overlay {
  2272. position: fixed;
  2273. inset: 0;
  2274. z-index: 40;
  2275. background: rgba(14, 18, 38, 0.18);
  2276. backdrop-filter: blur(3px);
  2277. pointer-events: auto;
  2278. }
  2279. .modal-overlay {
  2280. position: fixed;
  2281. inset: 0;
  2282. z-index: 60;
  2283. display: flex;
  2284. align-items: center;
  2285. justify-content: center;
  2286. padding: 24px;
  2287. background: rgba(0, 0, 0, 0.3);
  2288. }
  2289. .mobile-modal {
  2290. width: min(100%, 360px);
  2291. border-radius: 12px;
  2292. background: #ffffff;
  2293. box-shadow: 4px 4px 14px rgba(0, 0, 0, 0.12);
  2294. padding: 16px;
  2295. }
  2296. .modal-header {
  2297. display: flex;
  2298. align-items: center;
  2299. justify-content: space-between;
  2300. margin-bottom: 16px;
  2301. }
  2302. .modal-header h2 {
  2303. margin: 0;
  2304. color: #000;
  2305. font-size: 16px;
  2306. font-weight: 700;
  2307. }
  2308. .modal-input {
  2309. width: 100%;
  2310. height: 42px;
  2311. border: 1px solid #cecece;
  2312. border-radius: 8px;
  2313. padding: 0 12px;
  2314. color: #000;
  2315. font: inherit;
  2316. outline: none;
  2317. }
  2318. .delete-copy {
  2319. margin: 0 0 24px;
  2320. color: #464555;
  2321. font-size: 15px;
  2322. }
  2323. .modal-actions {
  2324. display: flex;
  2325. justify-content: flex-end;
  2326. gap: 12px;
  2327. margin-top: 18px;
  2328. }
  2329. .modal-actions button {
  2330. min-width: 84px;
  2331. height: 36px;
  2332. border: 0;
  2333. border-radius: 8px;
  2334. background: #cecece;
  2335. color: #ffffff;
  2336. font: inherit;
  2337. }
  2338. .modal-actions .primary {
  2339. background: #3525cd;
  2340. }
  2341. .modal-actions .danger {
  2342. background: #f97c7c;
  2343. }
  2344. .chat-suggestions-fade-enter-active,
  2345. .chat-suggestions-fade-leave-active,
  2346. .drawer-fade-enter-active,
  2347. .drawer-fade-leave-active,
  2348. .session-popover-fade-enter-active,
  2349. .session-popover-fade-leave-active,
  2350. .session-overlay-fade-enter-active,
  2351. .session-overlay-fade-leave-active {
  2352. transition: opacity 0.22s ease, transform 0.22s ease;
  2353. }
  2354. .chat-suggestions-fade-enter-from,
  2355. .chat-suggestions-fade-leave-to {
  2356. opacity: 0;
  2357. transform: translateY(10px);
  2358. }
  2359. .drawer-fade-enter-from,
  2360. .drawer-fade-leave-to {
  2361. opacity: 0;
  2362. }
  2363. .session-popover-fade-enter-from,
  2364. .session-popover-fade-leave-to {
  2365. opacity: 0;
  2366. transform: translateY(6px) scale(0.96);
  2367. }
  2368. .session-overlay-fade-enter-from,
  2369. .session-overlay-fade-leave-to {
  2370. opacity: 0;
  2371. }
  2372. @media (max-width: 360px) {
  2373. .mobile-main {
  2374. padding-left: 16px;
  2375. padding-right: 16px;
  2376. }
  2377. /* [已移除 2026-09-18] 此处原为 .heading { font-size: 28px }。
  2378. 它最初与基础值同值(那时基础值是 28px),是个空操作;
  2379. 2026-09-18 基础值改小后它反而变成**窄屏上把标题放大回 28px** 的覆盖——
  2380. 而标题是 white-space: nowrap,13 个字 × 28px ≈ 356px,远超窄屏可用宽度
  2381. (360px 屏约 272px)—— 会把标题顶出屏幕约 84px。
  2382. 窄屏的收缩已由 .heading 自己的 clamp(13px, 4.2vw, 16px) 负责,故移除。
  2383. .heading {
  2384. font-size: 28px;
  2385. }
  2386. */
  2387. .suggestion-chips {
  2388. padding-left: 12px;
  2389. padding-right: 12px;
  2390. }
  2391. .policy-card {
  2392. grid-template-columns: 1fr;
  2393. }
  2394. .match-panel {
  2395. flex-direction: row;
  2396. justify-content: flex-start;
  2397. }
  2398. }
  2399. </style>