diff --git a/pages/deepMate/deepMate.vue b/pages/deepMate/deepMate.vue index abeef37..aea697c 100644 --- a/pages/deepMate/deepMate.vue +++ b/pages/deepMate/deepMate.vue @@ -77,13 +77,11 @@ --> - - - - Hi, 我是您的股市随身顾问~ - 个股诊断、市场情绪解读,都可以找我。 - - + + + Hi, 我是您的股市随身顾问~ 个股诊断、市场情绪解读,都可以找我。 + + @@ -156,6 +158,7 @@ const { safeAreaInsets } = uni.getSystemInfoSync(); import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue"; import footerBar from '../../components/footerBar-cn' +import { onPageScroll } from '@dcloudio/uni-app' const type = ref('member') @@ -185,60 +188,23 @@ const hotTopics = ref([ icon: "https://d31zlh4on95l9h.cloudfront.net/images/7ed58be0f4b81aeb398d9ba2534a624b.svg", }, ]); - -// 新增:tabs 列表用于渲染与轮换 -const tabsList = ref(['个股诊断', '市场情绪温度计', '买卖时机提示', '个股']); - -// 用于无缝滚动的引用与状态(兼容小程序/App) -const tabsTrack = ref(null); -const marqueeOffset = ref(0); -let tabsTickId = 0; -const marqueeSpeed = 0.6; // 像素/帧 -let currentThreshold = 0; // 当前第一个标签的宽度(含右外边距) - // 统一 raf/caf(小程序端可能没有 rAF) -const hasRAF = typeof requestAnimationFrame === 'function'; -const startFrame = (fn) => hasRAF ? requestAnimationFrame(fn) : setTimeout(fn, 16); -const stopFrame = (id) => hasRAF ? cancelAnimationFrame(id) : clearTimeout(id); - -const measureFirstThreshold = (cb) => { - if (!tabsList.value.length) { currentThreshold = 0; if (cb) cb(0); return; } - const q = uni.createSelectorQuery(); - q.selectAll('.function-tabs .tab-item').boundingClientRect(); - q.exec(res => { - if (res && res[0] && res[0].length) { - const firstRect = res[0][0]; - const marginRightPx = uni.upx2px(20); - currentThreshold = (firstRect?.width || 0) + marginRightPx; - if (typeof cb === 'function') cb(currentThreshold); - } else { - // 若暂时无法获取,稍后重试一次 - setTimeout(() => measureFirstThreshold(cb), 16); - } - }); -}; - -const startTabsMarquee = () => { - stopFrame(tabsTickId); - if (tabsList.value.length <= 1) return; // 单个标签不滚动 - marqueeOffset.value = 0; - measureFirstThreshold(() => { - const step = () => { - marqueeOffset.value += marqueeSpeed; - if (currentThreshold > 0 && marqueeOffset.value >= currentThreshold) { - const first = tabsList.value.shift(); - if (first !== undefined) tabsList.value.push(first); - marqueeOffset.value -= currentThreshold; - measureFirstThreshold(); - } - tabsTickId = startFrame(step); - }; - nextTick(step); - }); -}; +// const hasRAF = typeof requestAnimationFrame === 'function'; +// const startFrame = (fn) => hasRAF ? requestAnimationFrame(fn) : setTimeout(fn, 16); +// const stopFrame = (id) => hasRAF ? cancelAnimationFrame(id) : clearTimeout(id); // 初始化 onMounted(() => { + const sys = uni.getSystemInfoSync(); + const iconSize = uni.upx2px(100); // 和样式保持一致 + const reserveBottom = uni.upx2px(260); // 预留底部输入区域空间 + const initX = Math.max(0, sys.windowWidth - iconSize - uni.upx2px(30)); + const initY = Math.max(0, Math.floor(sys.windowHeight * 0.65) - iconSize); + backTopTargetX.value = initX; + backTopTargetY.value = initY; + backTopX.value = initX; + backTopY.value = initY; + initUUID(); if (messages.value.length === 0) { nextTick(startTabsMarquee); @@ -248,17 +214,6 @@ onMounted(() => { } }); -// 当消息数量变化,控制滚动是否运行 -watch(messages, (val) => { - if (val.length === 0) { - nextTick(startTabsMarquee); - } else { - stopFrame(tabsTickId); - } -}); - -onUnmounted(() => { stopFrame(tabsTickId); }); - // 初始化 UUID const initUUID = () => { let storedUUID = uni.getStorageSync("user_uuid"); @@ -306,33 +261,9 @@ const sendMessage = () => { messages.value.push(userMessage); inputMessage.value = ""; - // 滚动到底部 - nextTick(() => { - scrollToBottom(); - }); - - // 模拟机器人回复 - simulateBotResponse(userMessage.content); -}; - -// 发送消息 -const sendMessageList = (listMessage) => { - console.log(listMessage); - - const userMessage = { - content: listMessage, - isUser: true, - isThinking: false, - isTyping: false, - }; - - messages.value.push(userMessage); - inputMessage.value = ""; - - // 滚动到底部 - nextTick(() => { - scrollToBottom(); - }); + // 发送后强制恢复并滚到底部 + shouldAutoScroll.value = true; + nextTick(() => { forceScrollToBottom(); }); // 模拟机器人回复 simulateBotResponse(userMessage.content); @@ -358,45 +289,156 @@ const simulateBotResponse = (userMessage) => { }); // 模拟流式响应 - let responseText = `我已经收到您的消息: "${userMessage}"。作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?`; + let responseText = `我已经收到您的消息: "${userMessage}"。作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?作为您的股市顾问,我可以为您提供专业的投资建议。请问您想了解哪方面的信息?`; let index = 0; + const botIndex = messages.value.length - 1; + const baseDelay = 165; // 普通字符基础延迟(毫秒) + const slowPunct = /[。!?!?;;]/; // 句号、感叹号、分号等较长停顿 + const midPunct = /[,、,::]/; // 逗号、顿号、冒号等中等停顿 + const typeWriter = () => { if (index < responseText.length) { - botMsg.content += responseText.charAt(index); + const ch = responseText.charAt(index); + const current = messages.value[botIndex]; + // 通过数组替换触发渲染,避免部分平台对子项属性变更不响应 + messages.value.splice( + botIndex, + 1, + { ...current, content: current.content + ch, isTyping: true } + ); index++; - - // 滚动到底部 scrollToBottom(); - setTimeout(typeWriter, 30); + const delay = slowPunct.test(ch) ? 220 : midPunct.test(ch) ? 120 : baseDelay; + setTimeout(typeWriter, delay); } else { - botMsg.isTyping = false; + const current = messages.value[botIndex]; + messages.value.splice( + botIndex, + 1, + { ...current, isTyping: false } + ); isSending.value = false; + nextTick(() => { scrollToBottom(); }); } }; + // 启动前稍作停顿,避免过快开始 setTimeout(typeWriter, 500); }; // 滚动到底部 const scrollToBottom = () => { + if (!shouldAutoScroll.value) return; // 暂停自动滚动 const query = uni.createSelectorQuery(); query.select("#messageList").boundingClientRect(); query.selectViewport().scrollOffset(); - query.exec((res) => { if (res[0] && res[1]) { - uni.pageScrollTo({ - scrollTop: res[0].height, - duration: 100, - }); + latestContentHeight.value = res[0].height; + uni.pageScrollTo({ scrollTop: res[0].height, duration: 100 }); } }); }; const scrollToTop = () => { uni.pageScrollTo({ scrollTop: 0, duration: 200 }); }; +// 自动滚动控制:用户向上滚动时暂停自动滚到底部 +const shouldAutoScroll = ref(true); +const latestContentHeight = ref(0); +const lastScrollTop = ref(0); +const windowHeight = uni.getSystemInfoSync().windowHeight; +const AUTO_SCROLL_REENABLE_THRESHOLD = 1; // px,接近底部时恢复自动滚动 + +onPageScroll((e) => { + const st = e.scrollTop; + const delta = st - lastScrollTop.value; + lastScrollTop.value = st; + + if (delta < 0) { + shouldAutoScroll.value = false; + return; + } + + const distanceToBottom = latestContentHeight.value - st - windowHeight; + if (distanceToBottom <= AUTO_SCROLL_REENABLE_THRESHOLD) { + shouldAutoScroll.value = true; + } +}); + +// 回到顶部图标拖拽状态 +const backTopX = ref(0); +const backTopY = ref(0); +const backTopDragging = ref(false); +const backTopDragOffset = ref({ x: 0, y: 0 }); +const backTopTargetX = ref(0); +const backTopTargetY = ref(0); +let backTopRAF = 0; +const backTopSmoothing = 0.15; // 越大越跟手,越小越顺滑 +const backTopEpsilon = 0.5; // 收敛阈值 +const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (fn) => setTimeout(fn, 16); +const caf = typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : (id) => clearTimeout(id); + +function stepBackTop() { + const dx = backTopTargetX.value - backTopX.value; + const dy = backTopTargetY.value - backTopY.value; + // 插值缓动,避免每帧重排 + backTopX.value += dx * backTopSmoothing; + backTopY.value += dy * backTopSmoothing; + if (Math.abs(dx) > backTopEpsilon || Math.abs(dy) > backTopEpsilon) { + backTopRAF = raf(stepBackTop); + } else { + backTopX.value = backTopTargetX.value; + backTopY.value = backTopTargetY.value; + backTopRAF = 0; + } +} +function ensureBackTopRAF() { + if (!backTopRAF) { + backTopRAF = raf(stepBackTop); + } +} + +const clamp = (val, min, max) => Math.max(min, Math.min(val, max)); + +const onBackTopTouchStart = (e) => { + const t = e.touches && e.touches[0]; + if (!t) return; + backTopDragging.value = false; + backTopDragOffset.value = { + x: t.pageX - backTopX.value, + y: t.pageY - backTopY.value, + }; +}; + +const onBackTopTouchMove = (e) => { + const t = e.touches && e.touches[0]; + if (!t) return; + const sys = uni.getSystemInfoSync(); + const iconSize = uni.upx2px(100); + const reserveBottom = uni.upx2px(260); + const nx = t.pageX - backTopDragOffset.value.x; + const ny = t.pageY - backTopDragOffset.value.y; + const clampedX = clamp(nx, 0, sys.windowWidth - iconSize); + const clampedY = clamp(ny, 0, sys.windowHeight - reserveBottom - iconSize); + if (Math.abs(clampedX - backTopX.value) + Math.abs(clampedY - backTopY.value) > 3) { + backTopDragging.value = true; + } + backTopTargetX.value = clampedX; + backTopTargetY.value = clampedY; + ensureBackTopRAF(); +}; + +const onBackTopTouchEnd = () => { + // 结束拖拽即可 +}; + +const onBackTopClick = () => { + if (backTopDragging.value) return; // 拖拽时不触发点击回到顶部 + scrollToTop(); +}; +