|
|
@@ -1,2727 +1,2729 @@
|
|
|
-<template>
|
|
|
- <div class="ba-mobile-container">
|
|
|
- <!-- 全局提示(按会话并行生成后,发送冲突/后端 409 等都要给用户可见反馈) -->
|
|
|
+<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';
|
|
|
-import ASR from '@/three-libs/asr/index';
|
|
|
-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 {
|
|
|
- asrInstance = new ASR("ce6cb2020037ed20f8a09a4822e869f380a0de96", {
|
|
|
- audio: true,
|
|
|
- video: false,
|
|
|
- });
|
|
|
-
|
|
|
- let success = false;
|
|
|
- try {
|
|
|
- success = await asrInstance.openMicroPhone();
|
|
|
- } catch (error) {
|
|
|
- console.error("Mobile voice input microphone error:", error);
|
|
|
- window.alert("未获取到麦克风权限,请在系统或浏览器设置中启用麦克风");
|
|
|
- releaseVoiceInputInstance();
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (!success) {
|
|
|
- window.alert("未获取到麦克风权限,请在系统或浏览器设置中启用麦克风");
|
|
|
- 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);
|
|
|
- window.alert(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>
|
|
|
+
|
|
|
+ —— 原来的 <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';
|
|
|
+import ASR from '@/three-libs/asr/index';
|
|
|
+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 {
|
|
|
+ asrInstance = new ASR("ce6cb2020037ed20f8a09a4822e869f380a0de96", {
|
|
|
+ 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>
|