feat: 智能滚动 - 流式回复时可向上查看历史,浮动按钮回到底部
- 流式输出时不再强制锁定底部,用户可自由滚动查看历史 - 滚动离开底部时显示「回到底部」浮动按钮(sticky 定位) - 点击按钮平滑滚动并恢复自动跟踪 - 用户手动滚回底部时自动恢复跟踪 - 发送消息/新建会话时自动重置为跟踪状态 - 首次渲染(加载历史)强制滚到底部
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- ═══════════════ 历史只读提示栏 ═══════════════ -->
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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();
|
||||
|
||||
// 立即保存
|
||||
|
||||
Reference in New Issue
Block a user