| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733 |
- <template>
- <div class="ba-mobile-container">
- <!-- 全局提示(按会话并行生成后,发送冲突/后端 409 等都要给用户可见反馈) -->
- <!-- [已停用] 全局提示改由 Ant Design 的 message 弹出(见 useBusinessAssistantChat 的 showToast)
- —— 原来的 <Toast> 是全屏黑遮罩 + 居中白框,用户说"太丑了" -->
- <header v-if="currentPage === 'home' || currentPage === 'chat'" class="mobile-header"
- :class="{ 'chat-header': currentPage === 'chat' }">
- <button v-if="currentPage === 'home'" type="button" class="icon-btn menu-btn" aria-label="打开会话菜单"
- @click="isDrawerOpen = true">
- <svg width="18" height="12" viewBox="0 0 18 12" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M0 12v-2h18v2H0Zm0-5V5h18v2H0Zm0-5V0h18v2H0Z" fill="currentColor" />
- </svg>
- </button>
- <div class="header-copy">
- <button v-if="currentPage === 'chat'" type="button" class="icon-btn menu-btn header-copy-menu"
- aria-label="打开会话菜单" @click="isDrawerOpen = true">
- <svg width="18" height="12" viewBox="0 0 18 12" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M0 12v-2h18v2H0Zm0-5V5h18v2H0Zm0-5V0h18v2H0Z" fill="currentColor" />
- </svg>
- </button>
- <div class="header-content">
- <div class="chat-header-title">青浦区营商助手</div>
- <div v-if="currentPage === 'chat'" class="header-subtitle">{{ currentSessionTitle }}</div>
- </div>
- </div>
- </header>
- <main ref="scrollHostRef" class="mobile-main" :class="`page-${currentPage}`">
- <!-- <ParticleBackground
- v-if="showParticleBackground"
- mouse-target-selector=".ba-mobile-container"
- /> -->
- <template v-if="currentPage === 'home'">
- <div class="home-content-shell">
- <section class="welcome-section">
- <div class="overlay-icon" aria-hidden="true">
- <img src="@/assets/qingpu_logo_2_3x.png" alt="logo"
- style="width: 100%; height: 100%; object-fit: cover; border-radius: 999px;" />
- </div>
- <h1 class="heading">你好,有什么我可以帮您的?</h1>
- <p class="desc">我是您的青浦区营商智能助手,可以为您提供惠企政策匹配以及出海、人才、科创、金融、法治方面的办事指引,请问有什么可以帮助您?</p>
- </section>
- <section class="suggestion-chips" aria-label="推荐问题">
- <button v-for="(item, index) in suggestions" :key="item" type="button" class="chip"
- @click="sendSuggestion(item)">
- <span class="chip-icon" aria-hidden="true">
- <svg v-if="index === 0" width="10" height="12" viewBox="0 0 10 12" fill="none">
- <path
- 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"
- fill="currentColor" />
- </svg>
- <svg v-else-if="index === 1" width="13" height="13" viewBox="0 0 13 13" fill="none">
- <path
- 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"
- fill="currentColor" />
- </svg>
- <svg v-else width="12" height="12" viewBox="0 0 12 12" fill="none">
- <path
- 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"
- fill="currentColor" />
- </svg>
- </span>
- {{ item }}
- </button>
- </section>
- </div>
- <div class="home-fixed-input" v-show="!isFreeformInputActive">
- <BusinessAssistantComposer
- v-model="inputText"
- :uploaded-files="uploadedFiles"
- :is-generating="currentSessionGenerating"
- :is-deep-think="isDeepThink"
- :is-voice-input-mode="isVoiceInputMode"
- :is-voice-input-starting="isVoiceInputStarting"
- :suggestions="[]"
- variant="mobile"
- :show-mock-stream-test-button="isMockStreamTestButtonVisible"
- @update:is-deep-think="isDeepThink = $event"
- @send="sendMobileMessage"
- @stop="handleStopGenerate"
- @image-upload="triggerImageUpload"
- @file-upload="triggerFileUpload"
- @voice-toggle="toggleVoiceInput"
- @remove-file="removeUploadedFile"
- @test-mock-stream="sendChatStreamReplayTestMessage"
- @test-replay="sendChatStreamReplayTestMessage"
- @test-typewriter="sendTypewriterTestMessage"
- @test-scope-merge="sendScopeMergeStreamTestMessage"
- @test-input="sendDocumentLineStreamTestMessage"
- @test-ref-links="sendRefLinksStreamTestMessage"
- @test-question-card="sendQuestionCardTestMessage"
- />
- </div>
- </template>
- <template v-else-if="currentPage === 'chat'">
- <!-- <div class="chat-ambient" aria-hidden="true">
- <span></span>
- <span></span>
- </div> -->
- <section class="chat-stage">
- <!-- <div class="date-indicator">{{ todayLabel }}</div> -->
- <ScrollList v-if="currentSession" ref="scrollListRef" :data="currentSession.messages" :height="listHeight"
- :maxHeight="listHeight" :topPadding="8" :bottomPadding="chatSuggestionsBottomPadding" :native-scroll-mode="true"
- @scrollPositionChange="handleBusinessScrollPositionChange">
- <template #item="{ data, isLast }">
- <div class="message-row" :ref="(el) => setMessageRowRef(data.id, el)"
- :class="[data.role, { 'is-last': isLast }]" :style="getMessageRowStyle(data.id, isLast, data.role)">
- <BusinessRecord :record="data" :state="data.state" :content="data.content" :isLastRecord="isLast"
- :sessionId="currentSession?.id" :sessionViewedBefore="currentSessionVisitedBefore"
- enableFeedback
- @refreshScroll="handleBusinessRecordRefreshScroll"
- @scopeHeightLockChange="handleScopeHeightLockChange"
- @typewriterStateChange="handleBusinessRecordTypewriterStateChange" />
- <Transition name="chat-suggestions-fade" appear>
- <div v-if="isLast && data.role === 'ai' && currentSessionSuggestions.length > 0"
- ref="chatSuggestionsContainerRef" class="chat-suggestions-container">
- <div class="chat-suggestions-gradient" aria-hidden="true"></div>
- <section class="chat-suggestion-chips" aria-label="跟进问题">
- <button v-for="(item, index) in currentSessionSuggestions.slice(0, 2)"
- :key="`${item.text}-${index}`" type="button" class="chip" @click="handleSuggestionClick(item)">
- {{ item.text }}
- </button>
- </section>
- </div>
- </Transition>
- </div>
- </template>
- </ScrollList>
- <div v-else class="empty-chat">打开一次咨询,助手会在这里继续服务。</div>
- </section>
- <div class="fixed-input" v-show="!isFreeformInputActive">
- <BusinessAssistantComposer
- v-model="inputText"
- :uploaded-files="uploadedFiles"
- :is-generating="currentSessionGenerating"
- :is-deep-think="isDeepThink"
- :is-voice-input-mode="isVoiceInputMode"
- :is-voice-input-starting="isVoiceInputStarting"
- :suggestions="[]"
- variant="mobile"
- :show-mock-stream-test-button="isMockStreamTestButtonVisible"
- @update:is-deep-think="isDeepThink = $event"
- @send="sendMobileMessage"
- @stop="handleStopGenerate"
- @image-upload="triggerImageUpload"
- @file-upload="triggerFileUpload"
- @voice-toggle="toggleVoiceInput"
- @remove-file="removeUploadedFile"
- @test-mock-stream="sendChatStreamReplayTestMessage"
- @test-replay="sendChatStreamReplayTestMessage"
- @test-typewriter="sendTypewriterTestMessage"
- @test-scope-merge="sendScopeMergeStreamTestMessage"
- @test-input="sendDocumentLineStreamTestMessage"
- @test-ref-links="sendRefLinksStreamTestMessage"
- @test-question-card="sendQuestionCardTestMessage"
- />
- <div class="ai-disclaimer">所有内容均由 AI 生成,仅供参考</div>
- </div>
- </template>
- <template v-else-if="currentPage === 'policy-list'">
- <section class="policy-page">
- <button type="button" class="page-close-btn" aria-label="关闭政策列表" @click="goHome">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
- <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
- </svg>
- </button>
- <div class="policy-title-block">
- <h1>政策事项列表</h1>
- <p>浏览、搜索及筛选青浦最新的惠企政策</p>
- </div>
- <div class="policy-filter">
- <label class="policy-search">
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
- <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"
- stroke-width="2" stroke-linecap="round" />
- </svg>
- <input v-model="policyKeyword" type="search" placeholder="输入政策关键词" />
- </label>
- <div class="policy-filter-row">
- <select v-model="policyCategory" class="policy-select" aria-label="政策分类">
- <option value="">所有分类</option>
- <option value="科技创新">科技创新</option>
- <option value="金融扶持">金融扶持</option>
- <option value="人才服务">人才服务</option>
- <option value="税费优惠">税费优惠</option>
- </select>
- <button type="button" class="policy-filter-btn">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
- <path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
- </svg>
- 筛选
- </button>
- </div>
- </div>
- <div class="policy-list">
- <article v-for="policy in filteredPolicies" :key="policy.id" class="policy-card"
- @click="openPolicyDetail(policy)">
- <div class="policy-card-main">
- <div class="policy-meta">
- <span>{{ policy.category }}</span>
- <time>{{ policy.date }}</time>
- </div>
- <h2>{{ policy.title }}</h2>
- <p>{{ policy.summary }}</p>
- </div>
- <div class="match-panel" aria-hidden="true">
- <div class="match-ring">{{ policy.match }}%</div>
- <div class="match-label">匹配度</div>
- <button type="button">查看</button>
- </div>
- </article>
- </div>
- <div class="pagination-row" aria-label="分页">
- <button type="button" disabled>‹</button>
- <button type="button" class="active">1</button>
- <button type="button">2</button>
- <button type="button">3</button>
- <span>...</span>
- <button type="button">12</button>
- <button type="button">›</button>
- </div>
- </section>
- </template>
- <template v-else-if="currentPage === 'policy-detail' && selectedPolicy">
- <section class="policy-detail">
- <button type="button" class="page-close-btn" aria-label="关闭政策详情" @click="goHome">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
- <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
- </svg>
- </button>
- <button type="button" class="back-policy-btn" @click="openPolicyList">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
- <path d="m15 18-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"
- stroke-linejoin="round" />
- </svg>
- 返回政策列表
- </button>
- <div class="policy-detail-hero">
- <h1>{{ selectedPolicy.title }}</h1>
- <div class="detail-tags">
- <span>{{ selectedPolicy.category }}</span>
- <span>{{ selectedPolicy.date }}</span>
- <span>匹配度 {{ selectedPolicy.match }}%</span>
- </div>
- </div>
- <div class="detail-grid">
- <section class="detail-box">
- <h2>支持对象</h2>
- <ul>
- <li v-for="item in selectedPolicy.targets" :key="item">{{ item }}</li>
- </ul>
- </section>
- <section class="detail-box">
- <h2>支持方式</h2>
- <p>{{ selectedPolicy.method }}</p>
- </section>
- </div>
- <section class="conditions-section">
- <h2>申报条件</h2>
- <div class="condition-lead">申报条件:{{ selectedPolicy.condition }}</div>
- <div class="condition-card">
- <div class="condition-status">符合</div>
- <p>{{ selectedPolicy.condition }}</p>
- </div>
- </section>
- </section>
- </template>
- </main>
- <Transition name="drawer-fade">
- <div v-if="isDrawerOpen" class="drawer-layer" @click.self="closeDrawer">
- <aside class="session-drawer">
- <div class="drawer-head">
- <button type="button" class="new-chat-btn" @click="startNewChat">
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
- <path d="M6 8H0V6h6V0h2v6h6v2H8v6H6V8Z" fill="currentColor" />
- </svg>
- 开启新的咨询
- </button>
- <button type="button" class="icon-btn" aria-label="关闭会话菜单" @click="closeDrawer">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
- <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
- </svg>
- </button>
- </div>
- <label class="drawer-search">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
- <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"
- stroke-width="2" stroke-linecap="round" />
- </svg>
- <input v-model="sessionKeyword" type="search" placeholder="搜索历史咨询" />
- </label>
- <section class="drawer-list">
- <h2>历史咨询</h2>
- <div v-if="filteredChatHistory.length === 0" class="drawer-empty">还没有历史咨询,先发起一次咨询吧。</div>
- <div v-for="session in filteredChatHistory" :key="session.id" class="drawer-session" role="button"
- tabindex="0" :class="{
- active: currentPage === 'chat' && currentSession?.id === session.id,
- 'is-pressing': longPressSessionId === session.id,
- }" @click="handleSessionTap(session.id)" @keydown.enter.prevent="switchSessionById(session.id)"
- @keydown.space.prevent="switchSessionById(session.id)"
- @pointerdown="handleSessionPressStart(session.id, $event)" @pointerup="handleSessionPressEnd"
- @pointerleave="handleSessionPressEnd" @pointercancel="handleSessionPressEnd" @contextmenu.prevent>
- <span>{{ session.title || '新对话' }}</span>
- </div>
- </section>
- <AccountInfo @logout="handleAccountLogout" />
- <!-- <div class="drawer-footer">
- <span>青浦区营商助手</span>
- <button type="button" class="icon-btn" aria-label="打开政策列表" @click="openPolicyListFromDrawer">
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
- <path d="M7 3h7l5 5v13H7V3Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
- </svg>
- </button>
- </div> -->
- </aside>
- </div>
- </Transition>
- <Teleport to="body">
- <Transition name="session-menu-fade">
- <div v-if="activeSessionMenuId && sessionMenuAnchor" class="session-menu-portal">
- <div class="session-menu-overlay" aria-hidden="true" @click="closeSessionMenu"></div>
- <div class="session-popover" :style="sessionMenuPopoverStyle" @click.stop @pointerdown.stop>
- <button type="button" @click="handleRenameFromSheet">重命名</button>
- <button type="button" class="danger" @click="handleDeleteFromSheet">删除</button>
- </div>
- </div>
- </Transition>
- </Teleport>
- <Teleport to="body">
- <div v-if="isRenameModalOpen" class="modal-overlay">
- <div class="mobile-modal">
- <div class="modal-header">
- <h2>编辑对话名称</h2>
- <button type="button" class="icon-btn" aria-label="关闭" @click="closeRenameModal">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
- <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
- </svg>
- </button>
- </div>
- <input v-model="renameInput" class="modal-input" type="text" placeholder="输入名称" />
- <div class="modal-actions">
- <button type="button" @click="closeRenameModal">取消</button>
- <button type="button" class="primary" @click="confirmRename">确定</button>
- </div>
- </div>
- </div>
- <div v-if="isDeleteModalOpen" class="modal-overlay">
- <div class="mobile-modal">
- <div class="modal-header">
- <h2>提示</h2>
- <button type="button" class="icon-btn" aria-label="关闭" @click="closeDeleteModal">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
- <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
- </svg>
- </button>
- </div>
- <p class="delete-copy">确认删除该会话记录吗?</p>
- <div class="modal-actions">
- <button type="button" @click="closeDeleteModal">取消</button>
- <button type="button" class="danger" @click="confirmDeleteSession">删除</button>
- </div>
- </div>
- </div>
- </Teleport>
- <input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden
- @change="handleImageSelected" />
- <input ref="fileInputRef" type="file" accept=".xls,.xlsx,.docx,.pdf" multiple hidden
- @change="handleFileSelected" />
- </div>
- </template>
- <script setup lang="ts">
- import { computed, nextTick, onBeforeUnmount, onMounted, provide, reactive, ref, watch } from 'vue';
- import BusinessRecord from "./Chat/BusinessRecord.vue";
- // [已停用] import Toast from "./Common/Toast.vue";
- import ScrollList from './Common/ScrollList.vue';
- import BusinessAssistantComposer from './business-assistant/BusinessAssistantComposer.vue';
- import { useBusinessAssistantChat } from './business-assistant/useBusinessAssistantChat';
- import { useBusinessAssistantUpload } from './business-assistant/useBusinessAssistantUpload';
- import {
- maybeStopAutoScrollAtLastAiRowTop,
- } from './business-assistant/auto-scroll-top-stop';
- import { scrollBusinessAssistantListToBottom } from './business-assistant/scroll-list-bottom';
- // 实时语音识别:**阿里云 NLS 直连**(2026-09-23 换)。
- // 对外契约与旧厂商 SDK(`@/three-libs/asr/index`)一致,所以这里只有这一行变了;
- // 切回旧实现也只改这一行。详见 harness/docs/reference/asr-nls.md
- import ASR from '@/asr/nls';
- import AccountInfo from "./Chat/AccountInfo.vue";
- import { useEnterpriseAuth } from '@/components/useEnterpriseAuth';
- import { createModalMutex, MODAL_MUTEX_KEY } from './business-assistant/modal-mutex';
- import {
- createFreeformInputFocusControl,
- FREEFORM_INPUT_FOCUS_KEY,
- } from './business-assistant/freeform-focus-control';
- import {
- calculateMobileListHeight,
- } from './business-assistant/mobile-viewport-height';
- provide(MODAL_MUTEX_KEY, createModalMutex());
- provide('formOptionsControl', {
- setFormOptions: () => { },
- });
- provide('speechControl', {
- stopSpeech: () => { },
- handleStopSpeech: () => { },
- handleEndSpeech: () => { },
- });
- const freeformInputFocusControl = createFreeformInputFocusControl();
- const isFreeformInputActive = freeformInputFocusControl.active;
- provide(FREEFORM_INPUT_FOCUS_KEY, freeformInputFocusControl);
- interface PolicyItem {
- id: string;
- title: string;
- category: string;
- date: string;
- summary: string;
- match: number;
- targets: string[];
- method: string;
- condition: string;
- }
- const currentPage = ref<'home' | 'chat' | 'policy-list' | 'policy-detail'>('home');
- const isDrawerOpen = ref(false);
- const activeSessionMenuId = ref<string | null>(null);
- const sessionKeyword = ref('');
- const renameInput = ref('');
- const renameSessionId = ref<string | null>(null);
- const deleteSessionId = ref<string | null>(null);
- const isRenameModalOpen = ref(false);
- const isDeleteModalOpen = ref(false);
- const scrollHostRef = ref<HTMLElement | null>(null);
- const scrollListRef = ref<any>(null);
- const listHeight = ref(520);
- const messageRowRefs = reactive<Record<string, HTMLElement | null>>({});
- const messageRowMinHeights = reactive<Record<string, number>>({});
- const chatSuggestionsContainerRef = ref<HTMLElement | null>(null);
- const chatSuggestionsBottomPadding = ref(0);
- const BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE = 24;
- const BUSINESS_RECORD_LARGE_HEIGHT_GROWTH = 80;
- /**
- * 「一整块到达」的高度阈值:超过它就按**块**处理 —— 把块的**顶部**对到视口顶部,
- * 而不是贴到底部(用户 2026-09-21 选择的做法)。与 PC 端同一口径(说明见 BusinessAssistantPC.vue)。
- */
- const BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH = 240;
- /** 块对顶时留的空隙(别把块的顶部贴死在视口最上沿) */
- const BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET = 8;
- const hasStoppedAtLastAiRowTop = ref(false);
- const lastAiRowTopConsumedRecordId = ref<string | null>(null);
- const lastAiRowTopSnapshot = ref<{ recordId: string; rowOffsetTop: number } | null>(null);
- const lastAiRowTypewriterStates = ref<Record<string, boolean>>({});
- let lastAiRowTypingReleaseTimer: number | null = null;
- const policyKeyword = ref('');
- const policyCategory = ref('');
- const selectedPolicy = ref<PolicyItem | null>(null);
- const isVoiceInputMode = ref(false);
- const isVoiceInputStarting = ref(false);
- const longPressSessionId = ref<string | null>(null);
- const sessionMenuAnchor = ref<{ left: number; bottom: number } | null>(null);
- let asrInstance: ASR | null = null;
- const mobileScrollAnimationDurationMs = 300;
- let mobileScrollAnimationFrameId = 0;
- let mobileScrollAnimationRevision = 0;
- let mobileScrollAnimationResolve: ((value?: unknown) => void) | null = null;
- const cancelMobileScrollAnimation = () => {
- if (mobileScrollAnimationFrameId) {
- cancelAnimationFrame(mobileScrollAnimationFrameId);
- mobileScrollAnimationFrameId = 0;
- }
- if (mobileScrollAnimationResolve) {
- mobileScrollAnimationResolve();
- mobileScrollAnimationResolve = null;
- }
- mobileScrollAnimationRevision += 1;
- };
- let sessionPressTimer: number | null = null;
- let sessionPressTriggeredId: string | null = null;
- let sessionPressPointerId: number | null = null;
- const isMockStreamTestButtonVisible = import.meta.env.MODE === 'development'
- // const todayLabel = computed(() => {
- // const now = new Date();
- // const hours = String(now.getHours()).padStart(2, '0');
- // const minutes = String(now.getMinutes()).padStart(2, '0');
- // return `今天 ${hours}:${minutes}`;
- // });
- const imageInputRef = ref<HTMLInputElement | null>(null);
- const fileInputRef = ref<HTMLInputElement | null>(null);
- const {
- uploadedFiles,
- triggerImageUpload,
- triggerFileUpload,
- handleImageSelected,
- handleFileSelected,
- removeUploadedFile,
- consumeSuccessfulFileTransmission,
- setUploadDraftKey,
- clearUploadDraft,
- } = useBusinessAssistantUpload({ imageInputRef, fileInputRef, notify: (message) => notifyUpload(message) });
- /**
- * 上传校验失败时的提示通道。
- * 创建顺序上 `useBusinessAssistantChat`(它提供 showToast)在后面,而且它依赖上传模块的
- * `consumeSuccessfulFileTransmission`,没法调换顺序 —— 所以这里先用占位,拿到 showToast 再接通。
- */
- let notifyUpload: (message: string) => void = (message) => console.warn('[upload]', message);
- const {
- suggestions,
- chatHistory,
- currentSessionVisitedBefore,
- currentSession,
- currentSessionTitle,
- currentSessionSuggestions,
- inputText,
- isGenerating,
- currentSessionGenerating,
- showToast,
- stopAllTasks,
- isDeepThink,
- loadHistory,
- sendMessage,
- sendChatStreamReplayTestMessage,
- sendTypewriterTestMessage,
- sendScopeMergeStreamTestMessage,
- sendDocumentLineStreamTestMessage,
- sendRefLinksStreamTestMessage,
- sendQuestionCardTestMessage,
- handleStopGenerate,
- startNewChat: startSharedNewChat,
- switchSessionById: switchSharedSessionById,
- renameSessionById,
- deleteSessionById,
- mergeRemoteSessions,
- loadRemoteSessionRecords,
- } = useBusinessAssistantChat({
- // 新协调器自行读取 VITE_CHAT_API,此参数仅保留签名兼容
- apiBaseUrl: import.meta.env.VITE_API || "",
- beforeSend: async () => {
- if (isVoiceInputMode.value) {
- await stopVoiceInput();
- }
- },
- afterSend: async () => {
- currentPage.value = 'chat';
- await scrollToBottom(true);
- },
- onMessageUpdated: () => scrollToBottom(),
- getInputFileTransmission: consumeSuccessfulFileTransmission,
- });
- // 上传校验的提示通道接通(见上面 notifyUpload 的说明)
- notifyUpload = showToast;
- const { isLoggedIn, creditCode, initAuth } = useEnterpriseAuth();
- const handleAccountLogout = () => {
- // ⚠️ 必须先停掉全部生成任务:退出登录会切换 localStorage key,
- // 旧账户的任务继续写会把内容落到新身份下
- stopAllTasks();
- closeDrawer();
- currentPage.value = 'home';
- loadHistory();
- startSharedNewChat();
- };
- const policies: PolicyItem[] = [
- {
- id: 'policy-1',
- title: '关于支持青浦自贸区科技创新企业发展的若干意见',
- category: '科技创新',
- date: '2026-06-12',
- summary: '围绕研发投入、成果转化、创新平台建设给予综合支持。',
- match: 92,
- targets: ['青浦区注册科技型企业', '拥有研发活动或知识产权成果的企业'],
- method: '对符合条件的企业给予研发补贴、平台建设资助和成果转化奖励。',
- condition: '企业需在青浦区依法登记纳税,具备持续研发能力,项目资料真实完整。',
- },
- {
- id: 'policy-2',
- title: '青浦自贸区中小微企业贷款贴息申报指南',
- category: '金融扶持',
- date: '2026-05-28',
- summary: '降低中小微企业融资成本,支持稳经营、扩产能。',
- match: 88,
- targets: ['青浦区中小微企业', '已获得银行经营性贷款的企业'],
- method: '按照实际贷款利息的一定比例给予贴息支持。',
- condition: '贷款用途须用于生产经营,企业信用记录良好,申报材料与贷款凭证一致。',
- },
- {
- id: 'policy-3',
- title: '2026年度“青峰”人才计划领军人物评选通知',
- category: '人才服务',
- date: '2026-05-14',
- summary: '聚焦高层次人才引育,为重点团队提供人才服务与项目支持。',
- match: 84,
- targets: ['重点产业领域领军人才', '承担核心研发或经营管理职责的人才'],
- method: '提供人才补贴、项目资助、服务保障和资源对接。',
- condition: '申报人需符合青浦区重点产业方向,并由所在单位提交推荐材料。',
- },
- {
- id: 'policy-4',
- title: '高新技术企业所得税优惠政策延续执行说明',
- category: '税费优惠',
- date: '2026-04-30',
- summary: '明确高新技术企业税收优惠延续执行口径和申报注意事项。',
- match: 79,
- targets: ['有效期内高新技术企业', '拟申报高新技术企业认定的企业'],
- method: '对符合条件的高新技术企业按规定享受企业所得税优惠。',
- condition: '企业需保持高新技术企业资格有效,研发费用、收入结构等指标满足要求。',
- },
- ];
- const filteredChatHistory = computed(() => {
- const keyword = sessionKeyword.value.trim().toLowerCase();
- if (!keyword) {
- return chatHistory.value;
- }
- return chatHistory.value.filter((session) => (session.title || '').toLowerCase().includes(keyword));
- });
- const filteredPolicies = computed(() => {
- const keyword = policyKeyword.value.trim();
- return policies.filter((policy) => {
- const matchedKeyword = !keyword || `${policy.title}${policy.summary}`.includes(keyword);
- const matchedCategory = !policyCategory.value || policy.category === policyCategory.value;
- return matchedKeyword && matchedCategory;
- });
- });
- const setMessageRowRef = (id: string | number, el: HTMLElement | null) => {
- const key = String(id);
- if (el) {
- messageRowRefs[key] = el;
- } else {
- delete messageRowRefs[key];
- }
- };
- const setMessageRowMinHeight = (recordId: string | number, height: number | null | undefined) => {
- const key = String(recordId);
- const nextHeight = Number(height);
- if (!Number.isFinite(nextHeight) || nextHeight <= 0) {
- delete messageRowMinHeights[key];
- return;
- }
- messageRowMinHeights[key] = Math.ceil(nextHeight);
- };
- const getMessageRowMinHeight = (recordId: string | number) => {
- const height = messageRowMinHeights[String(recordId)];
- return Number.isFinite(height) && height > 0 ? height : null;
- };
- const getMessageRowStyle = (recordId: string | number, isLast: boolean, role: string) => {
- if (!isLast || role !== 'ai') {
- return undefined;
- }
- const minHeight = getMessageRowMinHeight(recordId);
- if (!minHeight) {
- return undefined;
- }
- return {
- minHeight: `${minHeight}px`,
- };
- };
- const getLastAiMessage = () => {
- const messages = currentSession.value?.messages || [];
- for (let index = messages.length - 1; index >= 0; index -= 1) {
- const message = messages[index];
- if (message?.role === 'ai') {
- return message;
- }
- }
- return null;
- };
- const getRowScrollTop = (scrollElement: HTMLElement, rowElement: HTMLElement) => {
- const scrollRect = scrollElement.getBoundingClientRect();
- const rowRect = rowElement.getBoundingClientRect();
- return rowRect.top - scrollRect.top + scrollElement.scrollTop;
- };
- const isLastAiRowTyping = () => {
- const lastAiMessage = getLastAiMessage();
- if (!lastAiMessage) {
- return false;
- }
- const isTyping = Boolean(lastAiRowTypewriterStates.value[String(lastAiMessage.id)]);
- return isTyping;
- };
- const clearLastAiRowTopSnapshot = () => {
- lastAiRowTopSnapshot.value = null;
- };
- const consumeLastAiRowTopForRecord = (recordId: string | number | null | undefined) => {
- if (recordId === undefined || recordId === null) {
- return;
- }
- lastAiRowTopConsumedRecordId.value = String(recordId);
- hasStoppedAtLastAiRowTop.value = true;
- };
- const clearLastAiRowTypingReleaseTimer = () => {
- if (lastAiRowTypingReleaseTimer !== null) {
- window.clearTimeout(lastAiRowTypingReleaseTimer);
- lastAiRowTypingReleaseTimer = null;
- }
- };
- const setLastAiRowTopPinState = (active: boolean) => {
- const scrollList = scrollListRef.value;
- if (scrollList && typeof scrollList.setAutoScrollTopPinned === "function") {
- scrollList.setAutoScrollTopPinned(active);
- }
- };
- const isLastAiRowTopPinned = () => {
- const scrollList = scrollListRef.value;
- if (!scrollList || typeof scrollList.isAutoScrollTopPinned !== "function") {
- return false;
- }
- return scrollList.isAutoScrollTopPinned();
- };
- const releaseLastAiRowTopPin = (reason: "typewriter-finished" | "session-switch" | "user-scroll") => {
- clearLastAiRowTypingReleaseTimer();
- setLastAiRowTopPinState(false);
- clearLastAiRowTopSnapshot();
- if (reason === "user-scroll") {
- return;
- }
- };
- const getOrCreateLastAiRowTopSnapshot = (recordId: string, rowOffsetTop: number) => {
- if (
- lastAiRowTopSnapshot.value?.recordId !== recordId ||
- !Number.isFinite(lastAiRowTopSnapshot.value?.rowOffsetTop)
- ) {
- lastAiRowTopSnapshot.value = {
- recordId,
- rowOffsetTop,
- };
- }
- return lastAiRowTopSnapshot.value;
- };
- const stopAutoScrollAtLastAiRowTopIfNeeded = () => {
- const scrollList = scrollListRef.value;
- if (scrollList && typeof scrollList.isAutoScrollTopPinSuppressedByUser === "function" && scrollList.isAutoScrollTopPinSuppressedByUser()) {
- return false;
- }
- if (!isLastAiRowTyping()) {
- clearLastAiRowTopSnapshot();
- if (isLastAiRowTopPinned()) {
- releaseLastAiRowTopPin("typewriter-finished");
- }
- return false;
- }
- if (!scrollList || typeof scrollList.getScrollElement !== "function") {
- return false;
- }
- const scrollElement = scrollList.getScrollElement();
- const lastAiMessage = getLastAiMessage();
- if (!scrollElement || !lastAiMessage) {
- return false;
- }
- const lastAiMessageId = String(lastAiMessage.id);
- if (lastAiRowTopConsumedRecordId.value === lastAiMessageId) {
- return false;
- }
- const rowElement = messageRowRefs[String(lastAiMessage.id)];
- if (!rowElement) {
- return false;
- }
- if (typeof scrollList.enableAutoScroll === "function" && !isLastAiRowTopPinned()) {
- scrollList.enableAutoScroll();
- }
- const isAutoScrollActive =
- typeof scrollList.isAutoScrollEnabled === "function" ? scrollList.isAutoScrollEnabled() : true;
- const rowScrollTop = getRowScrollTop(scrollElement, rowElement);
- const snapshot = getOrCreateLastAiRowTopSnapshot(String(lastAiMessage.id), rowScrollTop);
- const result = maybeStopAutoScrollAtLastAiRowTop({
- isLastAiRowTyping: true,
- isAutoScrollActive,
- hasStoppedAtLastAiRowTop: hasStoppedAtLastAiRowTop.value,
- scrollTop: scrollElement.scrollTop,
- rowOffsetTop: snapshot.rowOffsetTop,
- disableAutoScroll: () => {
- if (typeof scrollList.disableAutoScroll === "function") {
- scrollList.disableAutoScroll();
- }
- },
- });
- hasStoppedAtLastAiRowTop.value = result.hasStoppedAtLastAiRowTop;
- if (result.didStop) {
- consumeLastAiRowTopForRecord(lastAiMessage.id);
- setLastAiRowTopPinState(true);
- }
- if (result.didStop && result.nextScrollTop !== null) {
- if (typeof scrollList.scrollToPositionInstant === "function") {
- scrollList.scrollToPositionInstant(result.nextScrollTop);
- } else {
- scrollElement.scrollTop = result.nextScrollTop;
- }
- }
- return result.didStop;
- };
- const syncAutoScrollFollowState = () => {
- const scrollList = scrollListRef.value;
- if (!scrollList || typeof scrollList.getScrollElement !== "function") {
- return;
- }
- const scrollElement = scrollList.getScrollElement();
- const lastAiMessage = getLastAiMessage();
- if (!scrollElement || !lastAiMessage) {
- return;
- }
- const rowElement = messageRowRefs[String(lastAiMessage.id)];
- if (!rowElement) {
- return;
- }
- const distanceToBottom =
- typeof scrollList.getDistanceToBottom === "function" ? scrollList.getDistanceToBottom() : Number.POSITIVE_INFINITY;
- const isUserScrollSuppressed =
- Boolean(
- typeof scrollList.isAutoScrollTopPinSuppressedByUser === "function" &&
- scrollList.isAutoScrollTopPinSuppressedByUser()
- );
- if (isUserScrollSuppressed) {
- if (distanceToBottom < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE && typeof scrollList.enableAutoScroll === "function") {
- scrollList.enableAutoScroll();
- }
- return;
- }
- if (stopAutoScrollAtLastAiRowTopIfNeeded()) {
- return;
- }
- if (distanceToBottom < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE && typeof scrollList.enableAutoScroll === "function") {
- scrollList.enableAutoScroll();
- }
- };
- let chatSuggestionsResizeObserver: ResizeObserver | null = null;
- let listHeightRecoveryFrame = 0;
- let listHeightRecoveryFallbackTimer: ReturnType<typeof setTimeout> | undefined;
- const listHeightRecoveryMaxDuration = 2000;
- const listHeightRecoveryFallbackDelay = 3000;
- const listHeightStableFramesRequired = 4;
- const stopListHeightRecovery = () => {
- if (listHeightRecoveryFrame) {
- cancelAnimationFrame(listHeightRecoveryFrame);
- listHeightRecoveryFrame = 0;
- }
- if (listHeightRecoveryFallbackTimer) {
- clearTimeout(listHeightRecoveryFallbackTimer);
- listHeightRecoveryFallbackTimer = undefined;
- }
- };
- const startListHeightRecovery = () => {
- stopListHeightRecovery();
- const startedAt = performance.now();
- const initialViewportHeight = Math.floor(window.innerHeight);
- let lastViewportHeight = initialViewportHeight;
- let stableFrameCount = 0;
- let viewportRecoveryObserved = false;
- const recover = (timestamp: number) => {
- const viewportHeight = Math.floor(window.innerHeight);
- updateListHeight();
- if (viewportHeight > initialViewportHeight + 1) {
- viewportRecoveryObserved = true;
- }
- if (Math.abs(viewportHeight - lastViewportHeight) <= 1) {
- stableFrameCount += 1;
- } else {
- stableFrameCount = 0;
- lastViewportHeight = viewportHeight;
- }
- const recoveredAndStable = viewportRecoveryObserved && stableFrameCount >= listHeightStableFramesRequired;
- if (!recoveredAndStable && timestamp - startedAt < listHeightRecoveryMaxDuration) {
- listHeightRecoveryFrame = requestAnimationFrame(recover);
- return;
- }
- listHeightRecoveryFrame = 0;
- };
- listHeightRecoveryFallbackTimer = setTimeout(() => {
- listHeightRecoveryFallbackTimer = undefined;
- updateListHeight();
- }, listHeightRecoveryFallbackDelay);
- listHeightRecoveryFrame = requestAnimationFrame(recover);
- };
- onMounted(() => {
- loadHistory();
- if ((currentSession.value?.messages.length || 0) > 0) {
- currentPage.value = 'chat';
- }
- updateListHeight();
- void observeChatSuggestionsHeight();
- window.addEventListener('resize', updateListHeight);
- window.visualViewport?.addEventListener('resize', updateListHeight);
- window.visualViewport?.addEventListener('scroll', updateListHeight);
- const fetchAndMergeRemoteSessions = async () => {
- const { fetchRemoteSessions } = await import('@/network/api/chat-sessions');
- const remoteSessions = await fetchRemoteSessions(creditCode.value);
- if (remoteSessions.length > 0) {
- mergeRemoteSessions(remoteSessions);
- }
- };
- initAuth().then(() => {
- if (isLoggedIn.value) {
- loadHistory();
- void fetchAndMergeRemoteSessions();
- }
- });
- });
- onBeforeUnmount(() => {
- window.removeEventListener('resize', updateListHeight);
- window.visualViewport?.removeEventListener('resize', updateListHeight);
- window.visualViewport?.removeEventListener('scroll', updateListHeight);
- stopListHeightRecovery();
- chatSuggestionsResizeObserver?.disconnect();
- void stopVoiceInput();
- clearSessionPressTimer();
- clearLastAiRowTypingReleaseTimer();
- releaseLastAiRowTopPin("session-switch");
- cancelMobileScrollAnimation();
- });
- const updateListHeight = () => {
- listHeight.value = calculateMobileListHeight(window.innerHeight, isFreeformInputActive.value);
- updateChatSuggestionsBottomPadding();
- };
- const updateChatSuggestionsBottomPadding = () => {
- if (currentPage.value !== 'chat' || currentSessionSuggestions.value.length === 0) {
- chatSuggestionsBottomPadding.value = 0;
- return;
- }
- chatSuggestionsBottomPadding.value = Math.ceil(
- chatSuggestionsContainerRef.value?.getBoundingClientRect().height || 0,
- ) + 16;
- };
- const observeChatSuggestionsHeight = async () => {
- await nextTick();
- chatSuggestionsResizeObserver?.disconnect();
- chatSuggestionsResizeObserver = null;
- if (currentPage.value === 'chat' && chatSuggestionsContainerRef.value) {
- chatSuggestionsResizeObserver = new ResizeObserver(updateChatSuggestionsBottomPadding);
- chatSuggestionsResizeObserver.observe(chatSuggestionsContainerRef.value);
- }
- updateChatSuggestionsBottomPadding();
- };
- watch(
- [currentPage, () => currentSessionSuggestions.value.length],
- () => {
- void observeChatSuggestionsHeight();
- },
- { flush: 'post' },
- );
- watch(isFreeformInputActive, (isFocused) => {
- updateListHeight();
- if (isFocused) {
- stopListHeightRecovery();
- return;
- }
- startListHeightRecovery();
- }, { flush: 'post' });
- watch(
- [currentPage, () => currentSession.value?.id || null],
- ([page, sessionId]) => {
- setUploadDraftKey(page === 'chat' ? sessionId : null);
- },
- { immediate: true },
- );
- const sendSuggestion = (text: string) => {
- inputText.value = text;
- void sendMobileMessage();
- };
- const handleSuggestionClick = (item: { text: string }) => {
- inputText.value = item.text;
- void sendMessage();
- };
- const sendMobileMessage = async () => {
- if (currentPage.value === 'home' && currentSession.value) {
- const pendingText = inputText.value;
- startSharedNewChat();
- inputText.value = pendingText;
- }
- await sendMessage();
- };
- const releaseVoiceInputInstance = () => {
- if (!asrInstance) {
- return;
- }
- asrInstance.close();
- asrInstance.clear();
- asrInstance = null;
- };
- const stopVoiceInput = async () => {
- const wasVoiceInputActive = isVoiceInputMode.value;
- isVoiceInputMode.value = false;
- isVoiceInputStarting.value = false;
- if (!asrInstance) {
- return;
- }
- if (wasVoiceInputActive) {
- try {
- const result = await asrInstance.transform();
- if (result?.text) {
- inputText.value = result.text;
- }
- } catch (error) {
- console.error("Mobile voice input transform error:", error);
- }
- }
- releaseVoiceInputInstance();
- };
- const toggleVoiceInput = async () => {
- if (isVoiceInputStarting.value) {
- return;
- }
- if (isVoiceInputMode.value) {
- await stopVoiceInput();
- return;
- }
- if (inputText.value.trim().length > 0) {
- const confirmClear = window.confirm("可能会清空之前的文字,确认要清空吗?");
- if (!confirmClear) {
- return;
- }
- inputText.value = "";
- }
- isVoiceInputStarting.value = true;
- try {
- // 不传 appKey:NLS 的 appkey 由服务端端点下发(前端不再硬编码厂商/云凭据)
- asrInstance = new ASR(undefined, {
- audio: true,
- video: false,
- });
- let success = false;
- try {
- success = await asrInstance.openMicroPhone();
- } catch (error) {
- console.error("Mobile voice input microphone error:", error);
- // 提示统一走 showToast(Ant Design 的 message)—— window.alert 会阻塞页面、
- // 样式也和全站其他提示不一致(用户 2026-09-23 要求语音这几处一并换掉)
- showToast("未获取到麦克风权限,请在系统或浏览器设置中启用麦克风");
- releaseVoiceInputInstance();
- return;
- }
- if (!success) {
- showToast("未获取到麦克风权限,请在系统或浏览器设置中启用麦克风");
- releaseVoiceInputInstance();
- return;
- }
- asrInstance.addEventListener("transform", (arg: any) => {
- inputText.value = arg.transformText;
- });
- asrInstance.startRecord();
- await asrInstance.openSocket();
- isVoiceInputMode.value = true;
- } catch (error) {
- console.error("Mobile voice input error:", error);
- showToast(error instanceof Error ? error.message : "语音输入初始化失败,请稍后重试");
- isVoiceInputMode.value = false;
- releaseVoiceInputInstance();
- } finally {
- isVoiceInputStarting.value = false;
- }
- };
- const scrollToBottom = async (force = false) => {
- await nextTick();
- if (scrollListRef.value) {
- if (force && typeof scrollListRef.value.enableAutoScroll === 'function') {
- scrollListRef.value.enableAutoScroll();
- releaseLastAiRowTopPin("session-switch");
- }
- scrollListRef.value.scrollToBottom();
- }
- if (scrollHostRef.value) {
- scrollHostRef.value.scrollTop = scrollHostRef.value.scrollHeight;
- }
- };
- const scrollToBottomAfterSessionSwitch = async () => {
- await scrollToBottom(true);
- await nextTick();
- scrollBusinessAssistantListToBottom(scrollListRef.value, 'instant');
- if (scrollHostRef.value) {
- scrollHostRef.value.scrollTop = scrollHostRef.value.scrollHeight;
- }
- };
- const animateScrollElementBy = (element: HTMLElement | null, delta: number, duration = mobileScrollAnimationDurationMs) => {
- if (!element || !Number.isFinite(delta) || delta === 0) {
- return Promise.resolve();
- }
- cancelMobileScrollAnimation();
- const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
- const startTop = element.scrollTop;
- const targetTop = Math.max(0, Math.min(maxScrollTop, startTop + delta));
- if (targetTop === startTop) {
- return Promise.resolve();
- }
- return new Promise((resolve) => {
- const startTime = performance.now();
- const revision = ++mobileScrollAnimationRevision;
- const finish = () => {
- if (mobileScrollAnimationResolve) {
- mobileScrollAnimationResolve();
- mobileScrollAnimationResolve = null;
- }
- mobileScrollAnimationFrameId = 0;
- };
- mobileScrollAnimationResolve = resolve;
- const step = (now: number) => {
- if (revision !== mobileScrollAnimationRevision) {
- finish();
- return;
- }
- const progress = Math.min(1, (now - startTime) / duration);
- const eased = 1 - Math.pow(1 - progress, 3);
- element.scrollTop = startTop + (targetTop - startTop) * eased;
- if (progress < 1) {
- mobileScrollAnimationFrameId = requestAnimationFrame(step);
- } else {
- finish();
- }
- };
- mobileScrollAnimationFrameId = requestAnimationFrame(step);
- });
- };
- const getLastAiRowBodyHeight = () => {
- const lastAiMessage = getLastAiMessage();
- if (!lastAiMessage) {
- return 0;
- }
- const rowElement = messageRowRefs[String(lastAiMessage.id)];
- const bodyElement = rowElement?.querySelector(".business-record-body") as HTMLElement | null;
- return bodyElement ? Math.ceil(bodyElement.getBoundingClientRect().height) : 0;
- };
- const releaseScopeHeightLockIfReady = () => {
- const lastAiMessage = getLastAiMessage();
- if (!lastAiMessage) {
- return false;
- }
- const key = String(lastAiMessage.id);
- const lockHeight = messageRowMinHeights[key];
- if (!Number.isFinite(lockHeight) || lockHeight <= 0) {
- return false;
- }
- const bodyHeight = getLastAiRowBodyHeight();
- if (bodyHeight < lockHeight) {
- return false;
- }
- delete messageRowMinHeights[key];
- return true;
- };
- /**
- * 大块内容(卡片 / 参考资料)到达时,把**它自己的顶部**对到视口顶部。
- * 与 PC 端同实现(说明见 BusinessAssistantPC.vue 里同名函数)。
- */
- const anchorLargeBlockTop = (heightGrowth: number): boolean => {
- const scrollList = scrollListRef.value;
- const lastAiMessage = getLastAiMessage();
- if (!scrollList || typeof scrollList.getScrollElement !== 'function' || !lastAiMessage) {
- return false;
- }
- const scrollElement = scrollList.getScrollElement();
- const rowElement = messageRowRefs[String(lastAiMessage.id)];
- if (!scrollElement || !rowElement) {
- return false;
- }
- const rowTop = getRowScrollTop(scrollElement, rowElement);
- const rowHeightAfter = rowElement.getBoundingClientRect().height;
- const blockTop = rowTop + Math.max(0, rowHeightAfter - heightGrowth) - BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET;
- if (typeof scrollList.scrollToPositionInstant === 'function') {
- scrollList.scrollToPositionInstant(blockTop);
- } else {
- scrollElement.scrollTop = blockTop;
- }
- return true;
- };
- const handleBusinessRecordRefreshScroll = async (payload?: { heightGrowth?: number; scrollDelta?: number; onComplete?: () => void }) => {
- const scrollList = scrollListRef.value;
- if (scrollList && typeof scrollList.isAutoScrollTopPinned === "function" && scrollList.isAutoScrollTopPinned()) {
- payload?.onComplete?.();
- return;
- }
- if (stopAutoScrollAtLastAiRowTopIfNeeded()) {
- syncAutoScrollFollowState();
- payload?.onComplete?.();
- return;
- }
- const isUserScrollSuppressed =
- Boolean(
- scrollList &&
- typeof scrollList.isAutoScrollTopPinSuppressedByUser === "function" &&
- scrollList.isAutoScrollTopPinSuppressedByUser()
- );
- if (scrollList && typeof scrollList.enableAutoScroll === "function" && !isLastAiRowTopPinned()) {
- if (isUserScrollSuppressed) {
- const distanceToBottom =
- typeof scrollList.getDistanceToBottom === "function"
- ? scrollList.getDistanceToBottom()
- : Number.POSITIVE_INFINITY;
- if (distanceToBottom < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE) {
- scrollList.enableAutoScroll();
- }
- payload?.onComplete?.();
- return;
- }
- scrollList.enableAutoScroll();
- payload?.onComplete?.();
- return;
- }
- const releasedLock = releaseScopeHeightLockIfReady();
- if (releasedLock) {
- syncAutoScrollFollowState();
- }
- const scrollDelta = Number(payload?.scrollDelta || 0);
- if (Number.isFinite(scrollDelta) && scrollDelta !== 0) {
- const scrollTasks: Promise<unknown>[] = [];
- if (scrollListRef.value && typeof scrollListRef.value.scrollByAnimated === "function") {
- scrollTasks.push(scrollListRef.value.scrollByAnimated(scrollDelta, mobileScrollAnimationDurationMs));
- } else if (scrollListRef.value && typeof scrollListRef.value.scrollBy === "function") {
- scrollListRef.value.scrollBy(scrollDelta);
- }
- scrollTasks.push(animateScrollElementBy(scrollHostRef.value, scrollDelta));
- await Promise.all(scrollTasks);
- syncAutoScrollFollowState();
- payload?.onComplete?.();
- return;
- }
- await nextTick();
- if (!scrollListRef.value) {
- return;
- }
- if (!releasedLock) {
- const lastAiMessage = getLastAiMessage();
- if (lastAiMessage) {
- const lockHeight = messageRowMinHeights[String(lastAiMessage.id)];
- if (
- Number.isFinite(lockHeight) &&
- lockHeight > 0 &&
- getLastAiRowBodyHeight() < lockHeight &&
- !isLastAiRowTyping()
- ) {
- return;
- }
- }
- }
- const heightGrowth = Number(payload?.heightGrowth || 0);
- const distanceAfterGrowth =
- typeof scrollListRef.value.getDistanceToBottom === 'function'
- ? scrollListRef.value.getDistanceToBottom()
- : Number.POSITIVE_INFINITY;
- const distanceBeforeGrowth = Math.max(0, distanceAfterGrowth - heightGrowth);
- if (!Number.isFinite(heightGrowth) || heightGrowth <= 0) {
- if (distanceAfterGrowth <= 24) {
- scrollBusinessAssistantListToBottom(scrollListRef.value, 'instant');
- } else {
- scrollBusinessAssistantListToBottom(scrollListRef.value);
- }
- return;
- }
- if (
- distanceBeforeGrowth < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE ||
- heightGrowth > BUSINESS_RECORD_LARGE_HEIGHT_GROWTH
- ) {
- // 一整块到达(卡片 / 参考资料):把块的**顶部**对到视口顶,而不是滚过它
- // (用户 2026-09-21 选定;说明见 BusinessAssistantPC.vue 的 BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH)
- if (heightGrowth >= BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH && anchorLargeBlockTop(heightGrowth)) {
- payload?.onComplete?.();
- return;
- }
- if (distanceAfterGrowth <= 24) {
- scrollBusinessAssistantListToBottom(scrollListRef.value, 'instant');
- } else {
- scrollBusinessAssistantListToBottom(scrollListRef.value, 'once');
- }
- }
- syncAutoScrollFollowState();
- };
- const handleBusinessScrollPositionChange = (payload?: { isTrusted?: boolean; isProgrammaticScroll?: boolean }) => {
- if (payload?.isTrusted && !payload?.isProgrammaticScroll) {
- cancelMobileScrollAnimation();
- consumeLastAiRowTopForRecord(getLastAiMessage()?.id ?? null);
- releaseLastAiRowTopPin("user-scroll");
- }
- if (stopAutoScrollAtLastAiRowTopIfNeeded()) {
- syncAutoScrollFollowState();
- return;
- }
- syncAutoScrollFollowState();
- };
- const handleScopeHeightLockChange = (payload?: { recordId?: string | number; active?: boolean; height?: number }) => {
- const recordId = payload?.recordId;
- if (recordId === undefined || recordId === null) {
- return;
- }
- if (payload?.active) {
- Object.keys(messageRowMinHeights).forEach((key) => {
- delete messageRowMinHeights[key];
- });
- setMessageRowMinHeight(recordId, payload.height);
- return;
- }
- setMessageRowMinHeight(recordId, null);
- };
- const handleBusinessRecordTypewriterStateChange = (payload?: { recordId?: string | number; isTypingActive?: boolean }) => {
- const recordId = payload?.recordId;
- if (recordId === undefined || recordId === null) {
- return;
- }
- const key = String(recordId);
- if (payload?.isTypingActive) {
- if (lastAiRowTopConsumedRecordId.value && lastAiRowTopConsumedRecordId.value !== key) {
- lastAiRowTopConsumedRecordId.value = null;
- hasStoppedAtLastAiRowTop.value = false;
- clearLastAiRowTopSnapshot();
- }
- clearLastAiRowTypingReleaseTimer();
- lastAiRowTypewriterStates.value[key] = true;
- syncAutoScrollFollowState();
- return;
- }
- clearLastAiRowTypingReleaseTimer();
- lastAiRowTypingReleaseTimer = window.setTimeout(() => {
- if (lastAiRowTypewriterStates.value[key]) {
- return;
- }
- delete lastAiRowTypewriterStates.value[key];
- releaseLastAiRowTopPin("typewriter-finished");
- syncAutoScrollFollowState();
- }, 180);
- };
- const goHome = () => {
- currentPage.value = 'home';
- };
- const openPolicyList = () => {
- currentPage.value = 'policy-list';
- selectedPolicy.value = null;
- };
- const openPolicyDetail = (policy: PolicyItem) => {
- selectedPolicy.value = policy;
- currentPage.value = 'policy-detail';
- };
- const closeDrawer = () => {
- isDrawerOpen.value = false;
- activeSessionMenuId.value = null;
- longPressSessionId.value = null;
- };
- const startNewChat = () => {
- setUploadDraftKey(null);
- clearUploadDraft(null);
- startSharedNewChat();
- currentPage.value = 'home';
- closeDrawer();
- };
- const switchSessionById = (id: string) => {
- const session = chatHistory.value.find((s) => s.id === id);
- const doSwitch = () => {
- if (switchSharedSessionById(id)) {
- currentPage.value = 'chat';
- closeDrawer();
- void scrollToBottomAfterSessionSwitch();
- }
- };
- if (session && session.messages.length === 0 && isLoggedIn.value) {
- void loadRemoteSessionRecords(id).then(() => {
- doSwitch();
- });
- return;
- }
- doSwitch();
- };
- const clearSessionPressTimer = () => {
- if (sessionPressTimer === null) {
- return;
- }
- window.clearTimeout(sessionPressTimer);
- sessionPressTimer = null;
- };
- const closeSessionMenu = () => {
- activeSessionMenuId.value = null;
- longPressSessionId.value = null;
- sessionPressTriggeredId = null;
- sessionPressPointerId = null;
- sessionMenuAnchor.value = null;
- };
- const handleSessionTap = (id: string) => {
- if (activeSessionMenuId.value === id) {
- return;
- }
- switchSessionById(id);
- };
- const handleSessionPressStart = (id: string, event: PointerEvent) => {
- if (event.pointerType === 'mouse' && event.button !== 0) {
- return;
- }
- clearSessionPressTimer();
- sessionPressTriggeredId = null;
- sessionPressPointerId = event.pointerId;
- longPressSessionId.value = id;
- const target = event.currentTarget as HTMLElement | null;
- const targetRect = target?.getBoundingClientRect() || null;
- sessionPressTimer = window.setTimeout(() => {
- sessionPressTimer = null;
- sessionPressTriggeredId = id;
- activeSessionMenuId.value = id;
- if (targetRect) {
- sessionMenuAnchor.value = {
- left: targetRect.left + 8,
- bottom: targetRect.bottom + 6,
- };
- }
- }, 420);
- };
- const handleSessionPressEnd = (event?: PointerEvent) => {
- if (event && sessionPressPointerId !== null && event.pointerId !== sessionPressPointerId) {
- return;
- }
- clearSessionPressTimer();
- sessionPressPointerId = null;
- if (sessionPressTriggeredId !== longPressSessionId.value) {
- longPressSessionId.value = null;
- }
- };
- const handleRenameFromSheet = () => {
- const id = activeSessionMenuId.value;
- if (!id) {
- return;
- }
- closeSessionMenu();
- openRenameModal(id);
- };
- const handleDeleteFromSheet = () => {
- const id = activeSessionMenuId.value;
- if (!id) {
- return;
- }
- closeSessionMenu();
- openDeleteModal(id);
- };
- const sessionMenuPopoverStyle = computed(() => {
- const anchor = sessionMenuAnchor.value;
- if (!anchor) {
- return {};
- }
- return {
- left: `${Math.max(8, anchor.left)}px`,
- top: `${anchor.bottom}px`,
- };
- });
- const openRenameModal = (id: string) => {
- const session = chatHistory.value.find((item) => item.id === id);
- if (!session) {
- return;
- }
- renameSessionId.value = id;
- renameInput.value = session.title || '';
- closeSessionMenu();
- isRenameModalOpen.value = true;
- };
- const closeRenameModal = () => {
- isRenameModalOpen.value = false;
- renameSessionId.value = null;
- };
- const confirmRename = () => {
- const title = renameInput.value.trim();
- if (!renameSessionId.value || !title) {
- return;
- }
- renameSessionById(renameSessionId.value, title);
- closeRenameModal();
- };
- const openDeleteModal = (id: string) => {
- deleteSessionId.value = id;
- closeSessionMenu();
- isDeleteModalOpen.value = true;
- };
- const closeDeleteModal = () => {
- isDeleteModalOpen.value = false;
- deleteSessionId.value = null;
- };
- const confirmDeleteSession = () => {
- if (!deleteSessionId.value) {
- return;
- }
- deleteSessionById(deleteSessionId.value);
- if (!currentSession.value) {
- currentPage.value = 'home';
- }
- closeDeleteModal();
- };
- </script>
- <style scoped>
- .ba-mobile-container {
- position: relative;
- width: 100vw;
- height: 100dvh;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- background: linear-gradient(180deg, #ffffff 0%, #f9f9ff 100%);
- color: #141b2b;
- font-family: "HarmonyOS Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
- }
- .mobile-header {
- height: 52px;
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 56px 0 16px;
- background: #f9f9ff;
- z-index: 10;
- }
- .mobile-header.chat-header {
- justify-content: center;
- padding: 0 16px;
- }
- .icon-btn {
- width: 40px;
- height: 40px;
- border: 0;
- border-radius: 999px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- background: transparent;
- color: #464555;
- cursor: pointer;
- }
- .icon-btn:active {
- background: rgba(79, 70, 229, 0.1);
- color: #3525cd;
- }
- .header-copy {
- position: relative;
- min-width: 0;
- flex: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- text-align: center;
- }
- .chat-header .header-copy {
- width: 100%;
- }
- .header-copy-menu {
- position: absolute;
- left: 0;
- top: calc(50% - 0.5px);
- transform: translateY(-50%);
- }
- .header-content {
- width: 100%;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- }
- :deep(.record--business.ba-record-me) {
- background-color: rgba(65, 45, 252, 1);
- color: #ffffff;
- width: fit-content;
- max-width: 300px;
- opacity: 1;
- padding: 16px 22.3px 16px 16px;
- border-radius: 12px 4px 12px 12px;
- box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05);
- }
- :deep(.record--business.ba-record-me .text-content) {
- color: #ffffff;
- }
- :deep(.business-record .content a) {
- color: #3525cd;
- }
- .chat-header-title {
- height: 26px;
- color: #141b2b;
- font-family: "HarmonyOS Sans SC", sans-serif;
- font-size: 20px;
- font-weight: 500;
- line-height: 1.3;
- letter-spacing: -0.2px;
- display: flex;
- align-items: center;
- }
- .header-subtitle {
- color: #3525cd;
- font-size: 12px;
- font-weight: 500;
- line-height: 1.4;
- letter-spacing: 0.12px;
- opacity: 0.8;
- padding-bottom: 0.8px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .mobile-main {
- flex: 1;
- /* overflow-y: auto; */
- position: relative;
- padding: 24px 8px 28px;
- }
- .page-chat {
- padding: 0 16px 198px;
- background: #ffffff;
- }
- .page-policy-list,
- .page-policy-detail {
- padding: 32px 16px 64px;
- background: #ffffff;
- }
- .bg-image-container {
- position: absolute;
- top: 76px;
- left: 50%;
- z-index: 0;
- width: 580px;
- height: 382px;
- opacity: 0.1;
- transform: translateX(-50%);
- pointer-events: none;
- }
- .bg-image {
- width: 100%;
- max-width: none;
- height: 100%;
- object-fit: fill;
- }
- .particle-bg {
- position: absolute;
- top: 76px;
- left: 50%;
- z-index: 1;
- width: 580px;
- height: 382px;
- transform: translateX(-50%);
- pointer-events: none;
- }
- .welcome-section {
- position: relative;
- z-index: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 6px;
- margin: 0;
- /* 两侧留白:标题与介绍文案都不贴屏幕边(原先几乎顶满,观感很挤) */
- padding: 0 28px;
- box-sizing: border-box;
- width: 100%;
- text-align: center;
- }
- .overlay-icon {
- width: 96px;
- height: 65px;
- border-radius: 999px;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-bottom: 20px;
- }
- .heading {
- margin: 0;
- color: #141b2b;
- /* 先给老内核一个固定值(= 上限),再用 clamp 让更窄的屏继续收一点。
- 目的:这行字**恒定单行**不换行。
- ⚠️ 两行必须同进同退:clamp 这行在后面、会覆盖上一行,只改基础值等于没改。
- 2026-09-18 演进:20px → 16px(用户反馈「太小了」)→ 20px → **22px(当前)**。
- clamp 取 5.7vw:≥386px 的屏恒为 22px,更窄的屏按比例收缩以免标题被顶出屏幕。 */
- font-size: 22px;
- font-size: clamp(17px, 5.7vw, 22px);
- font-weight: 500;
- line-height: 1.2;
- letter-spacing: -0.64px;
- margin-bottom: 12px;
- white-space: nowrap;
- }
- .desc {
- margin: 0;
- color: #464555;
- /* 加了两侧留白后可用宽度变窄,字号同步收一点,避免这段介绍多折一行 */
- font-size: 13px;
- font-weight: 500;
- line-height: 1.5;
- }
- .chat-input-area {
- width: 100%;
- padding: 0 16px 16px;
- overflow: hidden;
- }
- .mobile-main>.chat-input-area {
- width: calc(100% + 48px);
- margin-left: -24px;
- margin-right: -24px;
- }
- .page-home>.chat-input-area {
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 2;
- width: 100%;
- margin: 0;
- }
- .fixed-input .chat-input-area {
- padding-bottom: 0;
- }
- .home-content-shell {
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- width: 100%;
- height: 100%;
- gap: 0;
- padding-bottom: 180px;
- }
- .suggestion-chips {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 10px;
- width: 100%;
- padding: 18px 32px 0;
- }
- .chip {
- min-height: 38px;
- border: 1px solid rgba(199, 196, 216, 0.3);
- border-radius: 12px;
- padding: 9px 16px;
- background: #ffffff;
- color: #464555;
- font-size: 12px;
- font-weight: 500;
- line-height: 1.4;
- letter-spacing: 0.12px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- cursor: pointer;
- }
- .chip-icon {
- width: 16px;
- height: 16px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .chat-stage {
- position: relative;
- z-index: 1;
- padding-top: 12px;
- }
- .chat-ambient {
- position: fixed;
- inset: 64px 0 0;
- z-index: 0;
- pointer-events: none;
- overflow: hidden;
- opacity: 0.3;
- }
- .chat-ambient span {
- position: absolute;
- display: block;
- border-radius: 999px;
- filter: blur(70px);
- }
- .chat-ambient span:first-child {
- top: 0;
- left: 90px;
- width: 300px;
- height: 300px;
- background: #3525cd;
- }
- .chat-ambient span:last-child {
- left: 0;
- bottom: -80px;
- width: 250px;
- height: 250px;
- background: #4648d4;
- }
- .date-indicator {
- display: flex;
- justify-content: center;
- padding: 12px 0;
- color: #777587;
- font-size: 12px;
- font-weight: 500;
- line-height: 1.4;
- }
- .message-row {
- width: 100%;
- display: flex;
- margin-bottom: 16px;
- }
- .message-row.user {
- justify-content: flex-end;
- }
- .message-row.ai {
- justify-content: flex-start;
- }
- .message-row.ai.is-last {
- position: relative;
- /* padding-bottom: 32px; */
- }
- :deep(.chat-record-line) {
- /* max-width: min(322px, 100%) !important; */
- min-width: 80px !important;
- min-height: 55px !important;
- font-size: 15px !important;
- line-height: 1.6 !important;
- }
- :deep(.policy-item) {
- width: 100% !important;
- box-sizing: border-box;
- }
- :deep(.text-right) {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- }
- :deep(.text-left) {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- }
- .empty-chat {
- margin-top: 120px;
- text-align: center;
- color: #777587;
- font-size: 14px;
- }
- .chat-suggestion-chips {
- display: flex;
- gap: 8px;
- flex-wrap: wrap;
- width: 100%;
- justify-content: flex-start;
- position: relative;
- z-index: 1;
- pointer-events: auto;
- }
- .fixed-input {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 8;
- padding: 12px 0 16px;
- background: linear-gradient(180deg, rgba(249, 249, 255, 0) 0%, #f9f9ff 26%, #f9f9ff 100%);
- }
- .home-fixed-input {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 9;
- padding: 12px 0 calc(16px + env(safe-area-inset-bottom));
- background: linear-gradient(180deg, rgba(249, 249, 255, 0) 0%, #f9f9ff 26%, #f9f9ff 100%);
- }
- .chat-suggestions-container {
- position: absolute;
- top: calc(100% + 12px);
- left: 0;
- right: 0;
- width: 100%;
- display: flex;
- justify-content: center;
- pointer-events: none;
- z-index: 3;
- }
- .chat-suggestions-gradient {
- display: none;
- }
- .ai-disclaimer {
- color: #4f46e5;
- font-size: 12px;
- text-align: center;
- }
- .policy-page,
- .policy-detail {
- position: relative;
- display: flex;
- flex-direction: column;
- }
- .policy-page {
- gap: 16px;
- }
- .policy-detail {
- gap: 32px;
- }
- .page-close-btn {
- position: absolute;
- top: -5px;
- right: 0;
- z-index: 2;
- width: 40px;
- height: 40px;
- border: 0;
- border-radius: 999px;
- background: transparent;
- color: #464555;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .policy-title-block {
- padding-right: 16px;
- }
- .policy-title-block h1,
- .policy-detail-hero h1 {
- margin: 0;
- color: #141b2b;
- font-size: 24px;
- font-weight: 500;
- line-height: 1.35;
- letter-spacing: -0.24px;
- }
- .policy-title-block p {
- margin: 7px 0 0;
- color: #464555;
- font-size: 16px;
- font-weight: 500;
- line-height: 1.6;
- }
- .policy-filter {
- display: flex;
- flex-direction: column;
- gap: 16px;
- padding: 16px;
- border: 1px solid #e9edff;
- border-radius: 12px;
- background: #ffffff;
- box-shadow: 0 1px 1.75px rgba(0, 0, 0, 0.05);
- }
- .policy-search,
- .drawer-search {
- height: 48px;
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 0 16px;
- border: 1px solid #c7c4d8;
- border-radius: 8px;
- background: #f9f9ff;
- color: #777587;
- }
- .policy-filter-row {
- display: grid;
- grid-template-columns: 1fr 112px;
- gap: 16px;
- }
- .policy-search input,
- .drawer-search input {
- min-width: 0;
- flex: 1;
- border: 0;
- outline: 0;
- background: transparent;
- color: #141b2b;
- font: inherit;
- font-size: 14px;
- }
- .policy-select {
- height: 48px;
- border: 1px solid #c7c4d8;
- border-radius: 8px;
- background: #f9f9ff;
- color: #141b2b;
- padding: 0 16px;
- font: inherit;
- font-size: 14px;
- min-width: 0;
- }
- .policy-filter-btn {
- height: 48px;
- border: 0;
- border-radius: 8px;
- background: #3525cd;
- color: #ffffff;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- padding: 0 18px;
- font: inherit;
- font-size: 14px;
- font-weight: 500;
- }
- .policy-list {
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
- .policy-card {
- display: grid;
- grid-template-columns: 1fr 98px;
- gap: 12px;
- min-height: 186px;
- padding: 16px;
- border: 1px solid #e9edff;
- border-radius: 12px;
- background: #ffffff;
- cursor: pointer;
- }
- .policy-meta,
- .detail-tags {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- color: #464555;
- font-size: 12px;
- }
- .policy-meta span,
- .detail-tags span {
- padding: 2px 8px;
- border-radius: 4px;
- background: #e2dfff;
- }
- .policy-card h2 {
- margin: 8px 0 0;
- color: #141b2b;
- font-size: 20px;
- font-weight: 500;
- line-height: 1.3;
- letter-spacing: -0.2px;
- }
- .policy-card p {
- margin: 8px 0 0;
- color: #464555;
- font-size: 14px;
- font-weight: 500;
- line-height: 1.6;
- }
- .match-panel {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 8px;
- }
- .match-ring {
- width: 64px;
- height: 64px;
- border-radius: 50%;
- border: 6px solid #dce2f7;
- color: #3525cd;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 15px;
- font-weight: 700;
- }
- .match-label {
- color: #464555;
- font-size: 12px;
- }
- .match-panel button,
- .back-policy-btn {
- height: 34px;
- border: 1px solid #3525cd;
- border-radius: 8px;
- background: #ffffff;
- color: #3525cd;
- padding: 0 16px;
- font: inherit;
- font-size: 12px;
- }
- .pagination-row {
- display: grid;
- grid-template-columns: repeat(7, minmax(0, 1fr));
- gap: 8px;
- align-items: center;
- text-align: center;
- }
- .pagination-row button {
- height: 40px;
- border: 1px solid #c7c4d8;
- border-radius: 8px;
- background: transparent;
- color: #464555;
- font: inherit;
- }
- .pagination-row button.active {
- border-color: #3525cd;
- background: #3525cd;
- color: #ffffff;
- }
- .back-policy-btn {
- align-self: flex-start;
- display: inline-flex;
- align-items: center;
- gap: 4px;
- border: 0;
- padding: 0;
- height: auto;
- color: #3525cd;
- }
- .policy-detail-hero {
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
- .detail-grid,
- .conditions-section {
- display: flex;
- flex-direction: column;
- gap: 16px;
- }
- .detail-box,
- .condition-card {
- padding: 16px;
- border: 1px solid rgba(199, 196, 216, 0.3);
- border-radius: 12px;
- background: #ffffff;
- }
- .detail-box h2,
- .conditions-section h2 {
- margin: 0 0 12px;
- color: #141b2b;
- font-size: 16px;
- font-weight: 600;
- }
- .detail-box p,
- .detail-box li,
- .condition-card p,
- .condition-lead {
- color: #464555;
- font-size: 14px;
- font-weight: 500;
- line-height: 1.6;
- }
- .detail-box ul {
- margin: 0;
- padding-left: 18px;
- }
- .condition-lead {
- padding-left: 12px;
- border-left: 1px solid #a1a1a1;
- opacity: 0.65;
- }
- .condition-status {
- align-self: flex-end;
- display: inline-flex;
- padding: 4px 10px;
- border-radius: 999px;
- background: rgba(53, 37, 205, 0.1);
- color: #3525cd;
- font-size: 12px;
- font-weight: 600;
- }
- .drawer-layer {
- position: fixed;
- inset: 0;
- z-index: 30;
- background: rgba(0, 0, 0, 0.5);
- }
- .session-drawer {
- width: min(331.5px, 86vw);
- height: 100%;
- display: flex;
- flex-direction: column;
- background: #ffffff;
- box-shadow: 0 25px 43.75px -12px rgba(0, 0, 0, 0.25);
- }
- .drawer-head {
- min-height: 73px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- padding: 16px;
- border-bottom: 1px solid #c7c4d8;
- }
- .new-chat-btn {
- height: 40px;
- border: 0;
- border-radius: 999px;
- background: #3525cd;
- color: #ffffff;
- display: inline-flex;
- align-items: center;
- gap: 8px;
- padding: 0 16px;
- font: inherit;
- font-size: 16px;
- font-weight: 500;
- }
- .drawer-search {
- margin: 16px;
- height: 40px;
- border: 0;
- }
- .drawer-list {
- position: relative;
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding: 7px 8px 16px;
- -webkit-touch-callout: none;
- }
- .drawer-list h2 {
- margin: 0;
- padding: 0 8px 8px;
- color: #777587;
- font-size: 12px;
- font-weight: 500;
- letter-spacing: 0.12px;
- }
- .drawer-empty {
- padding: 16px;
- color: #777587;
- font-size: 13px;
- }
- .drawer-session {
- position: relative;
- width: 100%;
- min-height: 48px;
- border: 0;
- border-radius: 12px;
- background: transparent;
- color: #464555;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- padding: 12px;
- font: inherit;
- font-size: 14px;
- text-align: left;
- user-select: none;
- -webkit-user-select: none;
- -webkit-touch-callout: none;
- -webkit-tap-highlight-color: transparent;
- transition:
- background 0.18s ease,
- color 0.18s ease,
- transform 0.18s ease;
- }
- .drawer-session.active {
- background: rgba(79, 70, 229, 0.1);
- color: #3525cd;
- }
- .drawer-session.is-pressing {
- background: rgba(53, 37, 205, 0.08);
- transform: scale(0.99);
- }
- .drawer-session span {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .session-popover {
- position: absolute;
- left: 8px;
- top: calc(100% + 6px);
- z-index: 50;
- width: 108px;
- overflow: hidden;
- border-radius: 10px;
- background: rgba(250, 251, 255, 0.98);
- box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
- user-select: none;
- -webkit-user-select: none;
- -webkit-touch-callout: none;
- -webkit-tap-highlight-color: transparent;
- }
- .session-popover button {
- width: 100%;
- height: 38px;
- border: 0;
- background: transparent;
- color: #141b2b;
- font: inherit;
- font-size: 14px;
- font-weight: 500;
- text-align: left;
- padding: 0 12px;
- user-select: none;
- -webkit-user-select: none;
- -webkit-touch-callout: none;
- -webkit-tap-highlight-color: transparent;
- }
- .session-popover button:active {
- background: rgba(53, 37, 205, 0.08);
- }
- .session-popover .danger {
- color: #e82437;
- }
- .session-menu-overlay {
- position: fixed;
- inset: 0;
- z-index: 40;
- background: rgba(14, 18, 38, 0.18);
- backdrop-filter: blur(3px);
- pointer-events: auto;
- }
- .modal-overlay {
- position: fixed;
- inset: 0;
- z-index: 60;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 24px;
- background: rgba(0, 0, 0, 0.3);
- }
- .mobile-modal {
- width: min(100%, 360px);
- border-radius: 12px;
- background: #ffffff;
- box-shadow: 4px 4px 14px rgba(0, 0, 0, 0.12);
- padding: 16px;
- }
- .modal-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 16px;
- }
- .modal-header h2 {
- margin: 0;
- color: #000;
- font-size: 16px;
- font-weight: 700;
- }
- .modal-input {
- width: 100%;
- height: 42px;
- border: 1px solid #cecece;
- border-radius: 8px;
- padding: 0 12px;
- color: #000;
- font: inherit;
- outline: none;
- }
- .delete-copy {
- margin: 0 0 24px;
- color: #464555;
- font-size: 15px;
- }
- .modal-actions {
- display: flex;
- justify-content: flex-end;
- gap: 12px;
- margin-top: 18px;
- }
- .modal-actions button {
- min-width: 84px;
- height: 36px;
- border: 0;
- border-radius: 8px;
- background: #cecece;
- color: #ffffff;
- font: inherit;
- }
- .modal-actions .primary {
- background: #3525cd;
- }
- .modal-actions .danger {
- background: #f97c7c;
- }
- .chat-suggestions-fade-enter-active,
- .chat-suggestions-fade-leave-active,
- .drawer-fade-enter-active,
- .drawer-fade-leave-active,
- .session-popover-fade-enter-active,
- .session-popover-fade-leave-active,
- .session-overlay-fade-enter-active,
- .session-overlay-fade-leave-active {
- transition: opacity 0.22s ease, transform 0.22s ease;
- }
- .chat-suggestions-fade-enter-from,
- .chat-suggestions-fade-leave-to {
- opacity: 0;
- transform: translateY(10px);
- }
- .drawer-fade-enter-from,
- .drawer-fade-leave-to {
- opacity: 0;
- }
- .session-popover-fade-enter-from,
- .session-popover-fade-leave-to {
- opacity: 0;
- transform: translateY(6px) scale(0.96);
- }
- .session-overlay-fade-enter-from,
- .session-overlay-fade-leave-to {
- opacity: 0;
- }
- @media (max-width: 360px) {
- .mobile-main {
- padding-left: 16px;
- padding-right: 16px;
- }
- /* [已移除 2026-09-18] 此处原为 .heading { font-size: 28px }。
- 它最初与基础值同值(那时基础值是 28px),是个空操作;
- 2026-09-18 基础值改小后它反而变成**窄屏上把标题放大回 28px** 的覆盖——
- 而标题是 white-space: nowrap,13 个字 × 28px ≈ 356px,远超窄屏可用宽度
- (360px 屏约 272px)—— 会把标题顶出屏幕约 84px。
- 窄屏的收缩已由 .heading 自己的 clamp(13px, 4.2vw, 16px) 负责,故移除。
- .heading {
- font-size: 28px;
- }
- */
- .suggestion-chips {
- padding-left: 12px;
- padding-right: 12px;
- }
- .policy-card {
- grid-template-columns: 1fr;
- }
- .match-panel {
- flex-direction: row;
- justify-content: flex-start;
- }
- }
- </style>
|