diff --git a/src/components/xt-chat/chat-view/index.vue b/src/components/xt-chat/chat-view/index.vue index e894339..58558dd 100644 --- a/src/components/xt-chat/chat-view/index.vue +++ b/src/components/xt-chat/chat-view/index.vue @@ -31,15 +31,6 @@ export default { const sseController = ref(null); const searchValue = ref(''); - // 强制滚动到底部:用户主动发送消息时,无视是否在底部 - const forceScrollToBottom = () => { - requestAnimationFrame(() => { - try { - bubbleListRef.value?.scrollTo?.({ top: Number.MAX_SAFE_INTEGER, behavior: 'smooth' }); - } catch {} - }); - }; - const handleSubmit = (message: string) => { if (generateLoading.value) { antdMessage.warning('停止生成后可发送'); @@ -94,9 +85,6 @@ export default { }), }); - nextTick(() => { - forceScrollToBottom(); - }); } catch (error) { console.error('Failed to initialize SSE:', error); antdMessage.error('初始化连接失败'); diff --git a/src/components/xt-chat/xt-bubble/types.ts b/src/components/xt-chat/xt-bubble/types.ts index 333d7f4..83c872e 100644 --- a/src/components/xt-chat/xt-bubble/types.ts +++ b/src/components/xt-chat/xt-bubble/types.ts @@ -118,7 +118,7 @@ export interface BubbleProps */ export interface BubbleRef { /** 气泡组件的原生DOM元素 */ - nativeElement: HTMLElement; + bubbleElement: HTMLElement; /** 中止当前打字效果并立即展示完整内容 */ abortTyping: VoidFunction; } @@ -138,7 +138,7 @@ export interface BubbleContextProps { */ export interface BubbleListRef { /** 气泡列表的原生DOM元素 */ - nativeElement: HTMLDivElement; + bubbleElement: HTMLDivElement; /** 滚动到指定位置的方法 */ scrollTo: (info: { /** 滚动偏移量 */ @@ -180,8 +180,6 @@ export type RolesType = Record | ((bubbleDataP: BubbleDataType * 定义气泡列表组件的所有可配置属性 */ export interface BubbleListProps extends /* @vue-ignore */ HTMLAttributes { - /** 组件前缀类名 */ - prefixCls?: string; /** 根元素的自定义类名 */ rootClassName?: string; /** 气泡数据数组 */ diff --git a/src/components/xt-chat/xt-bubble/xt-bubble.vue b/src/components/xt-chat/xt-bubble/xt-bubble.vue index 80ab0e2..2fecee2 100644 --- a/src/components/xt-chat/xt-bubble/xt-bubble.vue +++ b/src/components/xt-chat/xt-bubble/xt-bubble.vue @@ -15,6 +15,8 @@ export default defineComponent({ const props = attrs as unknown as BubbleProps & { style?: any; class?: any }; const content = ref(props.content ?? ''); + const bubbleElement = ref(null); + watch( () => props.content, (val) => { @@ -103,10 +105,11 @@ export default defineComponent({ expose({ abortTyping, + bubbleElement, }); return () => ( -
+
{(slots.avatar || props.avatar) && (
{avatarNode.value} diff --git a/src/components/xt-chat/xt-bubble/xt-bubbleList.vue b/src/components/xt-chat/xt-bubble/xt-bubbleList.vue index 89026b3..56fde0b 100644 --- a/src/components/xt-chat/xt-bubble/xt-bubbleList.vue +++ b/src/components/xt-chat/xt-bubble/xt-bubbleList.vue @@ -23,9 +23,34 @@ import { export default defineComponent({ name: 'BubbleList', inheritAttrs: false, - props: {}, - setup(_, { attrs, slots, expose }) { - const props = attrs as unknown as BubbleListProps & { class?: any; style?: any }; + // 正确声明 props,提供默认值,确保 TSX 下默认值生效 + props: { + autoScroll: { + type: Boolean, + default: true, + }, + items: { + type: Array as () => BubbleListProps['items'], + required: true, + }, + roles: { + type: Object as () => RolesType, + default: () => ({} as RolesType), + }, + rootClassName: { + type: String, + default: '', + }, + style: { + type: Object as () => Record, + default: () => ({}), + }, + class: { + type: [String, Array, Object] as unknown as () => any, + default: '', + }, + }, + setup(props, { attrs, slots, expose }) { const passThroughAttrs = useAttrs(); const TOLERANCE = 1; @@ -63,14 +88,16 @@ export default defineComponent({ // scroll const [scrollReachEnd, setScrollReachEnd] = useState(true); const [updateCount, setUpdateCount] = useState(0); + // 首次挂载后仅自动滚动一次 + const didInitialAutoScroll = ref(false); const onInternalScroll = (e: Event) => { const target = e.target as HTMLElement; setScrollReachEnd(target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight <= TOLERANCE); }; - watch(updateCount, () => { - if ((props.autoScroll ?? true) && unref(listRef) && unref(scrollReachEnd)) { + watch([updateCount, scrollReachEnd, listRef], () => { + if (props.autoScroll && unref(listRef) && unref(scrollReachEnd)) { nextTick(() => { unref(listRef)!.scrollTo({ top: unref(listRef)!.scrollHeight }); }); @@ -79,26 +106,24 @@ export default defineComponent({ watch( () => unref(displayData).length, - () => { - if (props.autoScroll ?? true) { - const lastItemKey = unref(displayData)[unref(displayData).length - 2]?.key; - const bubbleInst = unref(bubbleRefs)[lastItemKey!]; - if (bubbleInst) { - const { nativeElement } = bubbleInst; - const { top = 0, bottom = 0 } = nativeElement?.getBoundingClientRect() ?? {}; - const { top: listTop, bottom: listBottom } = unref(listRef)!.getBoundingClientRect(); - const isVisible = top < listBottom && bottom > listTop; - if (isVisible) { - setUpdateCount(unref(updateCount) + 1); - setScrollReachEnd(true); - } - } + (newLen, oldLen) => { + if (!props.autoScroll) return; + // 首次渲染:当有内容时滚到底部一次 + if (!didInitialAutoScroll.value && newLen > 0) { + scrollToBottom('auto'); + didInitialAutoScroll.value = true; + return; + } + // 新增内容且当前在底部:继续粘底 + if (oldLen !== undefined && newLen > (oldLen ?? 0) && unref(scrollReachEnd)) { + scrollToBottom(); } }, + { immediate: true }, ); const onBubbleUpdate = useEventCallback(() => { - if (props.autoScroll ?? true) setUpdateCount(unref(updateCount) + 1); + if (props.autoScroll) setUpdateCount(unref(updateCount) + 1); }); const context = computed(() => ({ onUpdate: onBubbleUpdate })); @@ -106,6 +131,18 @@ export default defineComponent({ const abortTypingByKey = (key: string | number) => { bubbleRefs.value[key]?.abortTyping?.(); }; + // 通用:滚动到底部 + const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { + nextTick(() => { + requestAnimationFrame(() => { + const el = unref(listRef); + if (el) { + el.scrollTo({ top: el.scrollHeight, behavior }); + setScrollReachEnd(true); + } + }) + }); + }; // 对外暴露能力 expose({ nativeElement: listRef, @@ -113,6 +150,7 @@ export default defineComponent({ scrollTo: (info: any) => { unref(listRef)?.scrollTo?.(info); }, + scrollToBottom, }); return () => (