From 12843f09a5436596eae6a41745ace8525e412ac1 Mon Sep 17 00:00:00 2001 From: Metona Date: Sat, 4 Apr 2026 21:41:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=99=BA=E8=83=BD=E6=BB=9A=E5=8A=A8=20?= =?UTF-8?q?-=20=E6=B5=81=E5=BC=8F=E5=9B=9E=E5=A4=8D=E6=97=B6=E5=8F=AF?= =?UTF-8?q?=E5=90=91=E4=B8=8A=E6=9F=A5=E7=9C=8B=E5=8E=86=E5=8F=B2=EF=BC=8C?= =?UTF-8?q?=E6=B5=AE=E5=8A=A8=E6=8C=89=E9=92=AE=E5=9B=9E=E5=88=B0=E5=BA=95?= =?UTF-8?q?=E9=83=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 流式输出时不再强制锁定底部,用户可自由滚动查看历史 - 滚动离开底部时显示「回到底部」浮动按钮(sticky 定位) - 点击按钮平滑滚动并恢复自动跟踪 - 用户手动滚回底部时自动恢复跟踪 - 发送消息/新建会话时自动重置为跟踪状态 - 首次渲染(加载历史)强制滚到底部 --- css/style.css | 52 +++++++++++++++++++++++++++++++++++++ index.html | 6 +++++ js/app.js | 3 ++- js/components/chat-area.js | 45 ++++++++++++++++++++++++++++++-- js/components/input-area.js | 3 ++- 5 files changed, 105 insertions(+), 4 deletions(-) diff --git a/css/style.css b/css/style.css index c549faa..3873808 100644 --- a/css/style.css +++ b/css/style.css @@ -498,6 +498,58 @@ body::before { border-radius: 3px; } +/* ── 回到底部浮动按钮 ── */ +.scroll-to-bottom { + position: sticky; + bottom: 16px; + float: right; + margin-right: 4px; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: 1px solid rgba(0, 245, 212, 0.3); + background: rgba(10, 10, 26, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-radius: 24px; + color: var(--accent-cyan); + font-size: 12px; + font-weight: 600; + cursor: pointer; + z-index: 5; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4), 0 0 12px rgba(0, 245, 212, 0.15); + transition: all 0.25s ease; + animation: scrollBtnIn 0.25s ease; +} + +.scroll-to-bottom:hover { + background: rgba(0, 245, 212, 0.12); + border-color: var(--accent-cyan); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 20px rgba(0, 245, 212, 0.25); + transform: translateY(-2px); +} + +.scroll-to-bottom:active { + transform: translateY(0); +} + +.scroll-to-bottom svg { + width: 16px; + height: 16px; + animation: bounceDown 1.5s ease-in-out infinite; +} + +@keyframes scrollBtnIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes bounceDown { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(3px); } +} + /* ── 空状态 ── */ .empty-state { display: flex; diff --git a/index.html b/index.html index 717b958..5683fee 100644 --- a/index.html +++ b/index.html @@ -84,6 +84,12 @@

选择一个模型,输入消息开始聊天

+ diff --git a/js/app.js b/js/app.js index 73e9068..59917d1 100644 --- a/js/app.js +++ b/js/app.js @@ -13,7 +13,7 @@ import { initToast, showToast } from './components/toast.js'; import { initLightbox, closeLightbox } from './components/lightbox.js'; import { initHeader, checkConnection } from './components/header.js'; import { initModelBar, loadModels, setSelectedModel } from './components/model-bar.js'; -import { initChatArea, renderMessages, clearMessages } from './components/chat-area.js'; +import { initChatArea, renderMessages, clearMessages, enableAutoScroll } from './components/chat-area.js'; import { initInputArea } from './components/input-area.js'; import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js'; import { initHistoryModal, closeHistoryModal } from './components/history-modal.js'; @@ -50,6 +50,7 @@ async function startNewSession() { document.querySelector('#inputArea').style.display = ''; clearMessages(); + enableAutoScroll(); renderMessages(); document.querySelector('#chatInput')?.focus(); } diff --git a/js/components/chat-area.js b/js/components/chat-area.js index a23ecc6..7d23c3f 100644 --- a/js/components/chat-area.js +++ b/js/components/chat-area.js @@ -39,10 +39,38 @@ function getFileIcon(filename) { return map[ext] || '📄'; } +let chatAreaEl, messagesContainerEl, emptyStateEl, scrollBtnEl; +let autoScroll = true; // 是否自动滚动到底部 + export function initChatArea() { chatAreaEl = document.querySelector('#chatArea'); messagesContainerEl = document.querySelector('#messagesContainer'); emptyStateEl = document.querySelector('#emptyState'); + scrollBtnEl = document.querySelector('#scrollToBottom'); + + // 监听用户手动滚动 + chatAreaEl.addEventListener('scroll', () => { + const atBottom = isAtBottom(); + if (atBottom) { + autoScroll = true; + scrollBtnEl.style.display = 'none'; + } else { + autoScroll = false; + scrollBtnEl.style.display = ''; + } + }); + + // 点击滚动到底部按钮 + scrollBtnEl.addEventListener('click', () => { + autoScroll = true; + scrollBtnEl.style.display = 'none'; + chatAreaEl.scrollTo({ top: chatAreaEl.scrollHeight, behavior: 'smooth' }); + }); +} + +/** 判断是否在底部附近(60px 容差) */ +function isAtBottom() { + return chatAreaEl.scrollHeight - chatAreaEl.scrollTop - chatAreaEl.clientHeight < 60; } /** 安全地将 Markdown 转为 HTML */ @@ -58,8 +86,9 @@ export function safeMarkdown(text) { } } -/** 滚动到底部 */ +/** 滚动到底部(仅当 autoScroll 开启时) */ export function scrollToBottom() { + if (!autoScroll) return; requestAnimationFrame(() => { chatAreaEl.scrollTop = chatAreaEl.scrollHeight; }); @@ -80,11 +109,17 @@ export function renderMessages() { messagesContainerEl.style.display = ''; const existingCount = messagesContainerEl.children.length; + const isNewMessage = existingCount > 0 && existingCount < msgs.length; for (let i = existingCount; i < msgs.length; i++) { appendMessageDOM(msgs[i], i); } - scrollToBottom(); + // 新消息按 autoScroll 决定;首次渲染(加载历史)强制滚到底部 + if (isNewMessage) { + scrollToBottom(); + } else if (msgs.length > 0) { + chatAreaEl.scrollTop = chatAreaEl.scrollHeight; + } } /** 追加单条消息 DOM */ @@ -274,6 +309,12 @@ export function clearMessages() { messagesContainerEl.innerHTML = ''; } +/** 重置自动滚动(用户发送消息、新建会话时调用) */ +export function enableAutoScroll() { + autoScroll = true; + if (scrollBtnEl) scrollBtnEl.style.display = 'none'; +} + // ── 导出功能 ── /** 导出为 Markdown */ diff --git a/js/components/input-area.js b/js/components/input-area.js index da3ffbf..f125975 100644 --- a/js/components/input-area.js +++ b/js/components/input-area.js @@ -8,7 +8,7 @@ import { fileToBase64, fileToText, detectLanguage, debounce, truncate, escapeHtm import { getSelectedModel, isThinkEnabled, isVisionAvailable } from './model-bar.js'; import { renderMessages, appendAssistantPlaceholder, appendSystemMessage, - updateLastAssistantMessage, clearMessages, safeMarkdown + updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll } from './chat-area.js'; import { showToast } from './toast.js'; import { checkConnection } from './header.js'; @@ -353,6 +353,7 @@ export async function sendMessage() { } msgsToAdd.forEach(m => currentSession.messages.push(m)); + enableAutoScroll(); renderMessages(); // 立即保存