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(); // 立即保存