mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(chat): floating back-to-bottom button with End-key shortcut
Add a floating back-to-bottom control to the chat message list that appears when the user scrolls up away from the live bottom. The button auto-docks to the right edge after 15s of inactivity (with a subtle breathing pulse) and un-docks on mouseenter, keeping it unobtrusive while reading history. - End key jumps to the bottom, ignored when focus is in an input, textarea, or contentEditable field. - Explicit jump (button click or End) forces past the stick-to-bottom escape lock and clears it so sticky auto-scroll resumes following new content; automatic scrolls still respect the escape lock so they do not fight the user reading history. - Larger thumb-reach hit area and lower placement on mobile. - New i18n key chat.scrollToBottom (zh-CN / en-US).
This commit is contained in:
parent
1d1c35aadf
commit
f7f1c30557
@ -88,13 +88,33 @@
|
|||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating "back to bottom" button: appears when the user scrolls up to
|
||||||
|
read history, auto-docks to the edge after 15s of inactivity. -->
|
||||||
|
<Transition name="fade-up">
|
||||||
|
<div
|
||||||
|
v-if="!isAtBottom"
|
||||||
|
class="scroll-to-bottom-wrapper"
|
||||||
|
:class="{ docked: isButtonDocked }"
|
||||||
|
@mouseenter="undockButton"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="scroll-to-bottom"
|
||||||
|
type="button"
|
||||||
|
:title="$t('chat.scrollToBottom')"
|
||||||
|
@click="goToBottom"
|
||||||
|
>
|
||||||
|
<el-icon><ArrowDown /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch, nextTick } from 'vue'
|
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ChatDotRound, DataLine, EditPen, Monitor, Right } from '@element-plus/icons-vue'
|
import { ArrowDown, ChatDotRound, DataLine, EditPen, Monitor, Right } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
import MessageBubble from './MessageBubble.vue'
|
import MessageBubble from './MessageBubble.vue'
|
||||||
@ -166,7 +186,7 @@ const isCronHeader = (msg: Message) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 智能滚动
|
// 智能滚动
|
||||||
const { scrollRef, contentRef, isAtBottom, scrollToBottom } = useStickToBottom({
|
const { scrollRef, contentRef, isAtBottom, escapedFromLock, scrollToBottom } = useStickToBottom({
|
||||||
enabled: props.autoScroll,
|
enabled: props.autoScroll,
|
||||||
offset: 70,
|
offset: 70,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
@ -185,6 +205,8 @@ watch(
|
|||||||
() => props.messages,
|
() => props.messages,
|
||||||
async () => {
|
async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
// Don't auto-scroll while the user has scrolled up to read history.
|
||||||
|
if (escapedFromLock.value) return
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
@ -200,6 +222,63 @@ watch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Explicit "jump to bottom" request (button click / End key): force past the
|
||||||
|
// escape lock and clear it so sticky auto-scroll resumes following new content.
|
||||||
|
function goToBottom() {
|
||||||
|
escapedFromLock.value = false
|
||||||
|
scrollToBottom({ force: true, smooth: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Back-to-bottom button: auto-dock to the edge after 15s idle ==========
|
||||||
|
const DOCK_DELAY_MS = 15000
|
||||||
|
let dockTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const isButtonDocked = ref(false)
|
||||||
|
|
||||||
|
function clearDockTimer() {
|
||||||
|
if (dockTimer) { clearTimeout(dockTimer); dockTimer = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDockTimer() {
|
||||||
|
isButtonDocked.value = false
|
||||||
|
if (!isAtBottom.value) {
|
||||||
|
clearDockTimer()
|
||||||
|
dockTimer = setTimeout(() => { isButtonDocked.value = true }, DOCK_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function undockButton() {
|
||||||
|
isButtonDocked.value = false
|
||||||
|
resetDockTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isAtBottom, (atBottom) => {
|
||||||
|
if (atBottom) { clearDockTimer(); isButtonDocked.value = false }
|
||||||
|
else resetDockTimer()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(scrollRef, (el, _, onCleanup) => {
|
||||||
|
if (!el) return
|
||||||
|
el.addEventListener('scroll', resetDockTimer, { passive: true })
|
||||||
|
onCleanup(() => el.removeEventListener('scroll', resetDockTimer))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========== End key jumps to the bottom ==========
|
||||||
|
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key !== 'End') return
|
||||||
|
// Only respond when focus is not in an editable field (textarea / input / contentEditable).
|
||||||
|
const el = document.activeElement
|
||||||
|
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || (el as HTMLElement).isContentEditable)) return
|
||||||
|
if (isAtBottom.value) return
|
||||||
|
e.preventDefault()
|
||||||
|
goToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('keydown', handleGlobalKeydown))
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearDockTimer()
|
||||||
|
document.removeEventListener('keydown', handleGlobalKeydown)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -396,10 +475,88 @@ watch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动提示 */
|
/* Back-to-bottom floating button */
|
||||||
.is-scrolling .scroll-to-bottom {
|
.scroll-to-bottom-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20%;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-to-bottom {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--mc-border, #e2e8f0);
|
||||||
|
background: var(--mc-bg-elevated, rgba(255, 255, 255, 0.92));
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
color: var(--mc-text-secondary, #64748b);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 0.35s ease,
|
||||||
|
box-shadow 0.2s ease,
|
||||||
|
background 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-to-bottom:hover {
|
||||||
|
background: var(--mc-panel-raised, #f1f5f9);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
transform: scale(1.08);
|
||||||
|
color: var(--mc-primary, #D97757);
|
||||||
|
border-color: var(--mc-primary, #D97757);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Click feedback: shrink slightly on press, then spring back. */
|
||||||
|
.scroll-to-bottom:active {
|
||||||
|
transform: scale(0.88);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Docked to the edge: only ~8px stays visible. */
|
||||||
|
.scroll-to-bottom-wrapper.docked .scroll-to-bottom {
|
||||||
|
transform: translateX(28px);
|
||||||
|
opacity: 0.45;
|
||||||
|
/* Subtle breathing pulse to remind the user the button is still there. */
|
||||||
|
animation: dock-breathe 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dock-breathe {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(217, 119, 87, 0); }
|
||||||
|
50% { box-shadow: 0 0 8px 2px rgba(217, 119, 87, 0.18); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restore as soon as the pointer approaches. */
|
||||||
|
.scroll-to-bottom-wrapper.docked:hover .scroll-to-bottom {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition: fade + slide up */
|
||||||
|
.fade-up-enter-active,
|
||||||
|
.fade-up-leave-active {
|
||||||
|
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||||
|
}
|
||||||
|
.fade-up-enter-from,
|
||||||
|
.fade-up-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 移动端适配 ===== */
|
/* ===== 移动端适配 ===== */
|
||||||
@ -416,6 +573,21 @@ watch(
|
|||||||
min-height: 240px;
|
min-height: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile: larger button positioned lower for the thumb reach zone. */
|
||||||
|
.scroll-to-bottom-wrapper {
|
||||||
|
bottom: 12%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-to-bottom {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-to-bottom-wrapper.docked .scroll-to-bottom {
|
||||||
|
transform: translateX(36px); /* 44px - 8px = 36px */
|
||||||
|
}
|
||||||
|
|
||||||
.welcome-screen {
|
.welcome-screen {
|
||||||
padding: 16px 10px;
|
padding: 16px 10px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -224,6 +224,7 @@ export default {
|
|||||||
modelSaveFailed: 'Model switch was not saved — the next message may still use the previous model',
|
modelSaveFailed: 'Model switch was not saved — the next message may still use the previous model',
|
||||||
openSessions: 'Session Admin',
|
openSessions: 'Session Admin',
|
||||||
clearMessages: 'Clear Messages',
|
clearMessages: 'Clear Messages',
|
||||||
|
scrollToBottom: 'Back to bottom',
|
||||||
goToModelSettings: 'Go to Model Settings',
|
goToModelSettings: 'Go to Model Settings',
|
||||||
configModelFirst: 'Please configure a model first',
|
configModelFirst: 'Please configure a model first',
|
||||||
modelUnavailable: 'Current model is unavailable',
|
modelUnavailable: 'Current model is unavailable',
|
||||||
|
|||||||
@ -224,6 +224,7 @@ export default {
|
|||||||
modelSaveFailed: '模型切换未保存,下条消息可能仍走原模型',
|
modelSaveFailed: '模型切换未保存,下条消息可能仍走原模型',
|
||||||
openSessions: '会话管理',
|
openSessions: '会话管理',
|
||||||
clearMessages: '清空消息',
|
clearMessages: '清空消息',
|
||||||
|
scrollToBottom: '回到底部',
|
||||||
goToModelSettings: '前往模型设置',
|
goToModelSettings: '前往模型设置',
|
||||||
configModelFirst: '请先配置模型',
|
configModelFirst: '请先配置模型',
|
||||||
modelUnavailable: '当前模型不可用',
|
modelUnavailable: '当前模型不可用',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user