feat: 智能滚动 - 流式回复时可向上查看历史,浮动按钮回到底部

- 流式输出时不再强制锁定底部,用户可自由滚动查看历史
- 滚动离开底部时显示「回到底部」浮动按钮(sticky 定位)
- 点击按钮平滑滚动并恢复自动跟踪
- 用户手动滚回底部时自动恢复跟踪
- 发送消息/新建会话时自动重置为跟踪状态
- 首次渲染(加载历史)强制滚到底部
This commit is contained in:
Metona
2026-04-04 21:41:26 +08:00
parent d61764a5e9
commit 12843f09a5
5 changed files with 105 additions and 4 deletions
+52
View File
@@ -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;
+6
View File
@@ -84,6 +84,12 @@
<p>选择一个模型,输入消息开始聊天</p>
</div>
<div class="messages" id="messagesContainer" style="display:none;"></div>
<button class="scroll-to-bottom" id="scrollToBottom" style="display:none;" title="回到最新">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
<span>回到底部</span>
</button>
</main>
<!-- ═══════════════ 历史只读提示栏 ═══════════════ -->
+2 -1
View File
@@ -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();
}
+42 -1
View File
@@ -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);
}
// 新消息按 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 */
+2 -1
View File
@@ -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();
// 立即保存