Files
lingji-work-fe/src/components/xt-chat/xt-bubble/xt-bubbleList.vue
2025-08-29 12:03:04 +08:00

205 lines
6.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="tsx">
import Bubble from './xt-bubble.vue';
import BubbleContextProvider from './context';
import useDisplayData from './hooks/useDisplayData';
import useListData from './hooks/useListData';
import useState from '@/hooks/useState';
import pickAttrs from '@/utils/pick-attrs';
import { useEventCallback } from '@/hooks/useEventCallback';
import type { BubbleListProps, BubbleRef, RolesType } from './types';
import {
computed,
defineComponent,
mergeProps,
nextTick,
onWatcherCleanup,
ref,
unref,
useAttrs,
watch,
watchPostEffect,
} from 'vue';
export default defineComponent({
name: 'BubbleList',
inheritAttrs: false,
// 正确声明 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<string, any>,
default: () => ({}),
},
class: {
type: [String, Array, Object] as unknown as () => any,
default: '',
},
},
setup(props, { attrs, slots, expose }) {
const passThroughAttrs = useAttrs();
const TOLERANCE = 1;
const domProps = computed(() => pickAttrs(mergeProps(props as any, passThroughAttrs)));
const items = ref(props.items);
const roles = ref(props.roles as RolesType);
watch(
() => props.items,
(val) => (items.value = val),
{ immediate: true },
);
watch(
() => props.roles,
(val) => (roles.value = val as RolesType),
{ immediate: true },
);
const listRef = ref<HTMLDivElement | null>(null);
const bubbleRefs = ref<Record<string | number, BubbleRef>>({});
const listPrefixCls = 'xt-bubble-list';
const [initialized, setInitialized] = useState(false);
watchPostEffect(() => {
setInitialized(true);
onWatcherCleanup(() => setInitialized(false));
});
// data
const mergedData = useListData(items as any, roles as any);
const [displayData, onTypingComplete] = useDisplayData(mergedData);
// 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, scrollReachEnd, listRef], () => {
if (props.autoScroll && unref(listRef) && unref(scrollReachEnd)) {
nextTick(() => {
console.log('自然滚动')
unref(listRef)!.scrollTo({ top: unref(listRef)!.scrollHeight });
});
}
});
watch(
() => unref(displayData).length,
(newLen, oldLen) => {
if (!props.autoScroll) return;
// 首次渲染:当有内容时滚到底部一次
if (!didInitialAutoScroll.value && newLen > 0) {
console.log('首次渲染滚动到底部-----')
scrollToBottom('auto');
didInitialAutoScroll.value = true;
return;
}
// 新增内容且当前在底部:继续粘底
if (oldLen !== undefined && newLen > (oldLen ?? 0) && unref(scrollReachEnd)) {
scrollToBottom();
}
},
{ immediate: true },
);
const onBubbleUpdate = useEventCallback<void>(() => {
if (props.autoScroll) setUpdateCount(unref(updateCount) + 1);
});
const context = computed(() => ({ onUpdate: onBubbleUpdate }));
// 暴露控制方法
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,
abortTypingByKey,
scrollTo: (info: any) => {
unref(listRef)?.scrollTo?.(info);
},
scrollToBottom,
});
return () => (
<BubbleContextProvider value={context.value}>
<div
{...domProps.value}
class={[
listPrefixCls,
props.rootClassName,
props.class,
{ [`${listPrefixCls}-reach-end`]: unref(scrollReachEnd) },
]}
style={props.style}
ref={listRef}
onScroll={onInternalScroll}
>
{unref(displayData).map(({ key, onTypingComplete: onTypingCompleteBubble, ...bubble }) => (
<Bubble
{...bubble}
avatar={slots.avatar ? () => slots.avatar?.({ item: { key, ...bubble } }) : bubble.avatar}
header={slots.header?.({ item: { key, ...bubble } }) ?? bubble.header}
footer={slots.footer?.({ item: { key, ...bubble } }) ?? bubble.footer}
loadingRender={slots.loading ? () => slots.loading({ item: { key, ...bubble } }) : bubble.loadingRender}
content={slots.message?.({ item: { key, ...bubble } }) ?? bubble.content}
key={key}
ref={(node: any) => {
if (node) {
bubbleRefs.value[key] = node as BubbleRef;
} else {
delete bubbleRefs.value[key];
}
}}
typing={unref(initialized) ? bubble.typing : false}
onTypingComplete={() => {
onTypingCompleteBubble?.();
onTypingComplete(key);
}}
/>
))}
</div>
</BubbleContextProvider>
);
},
});
</script>
<style scoped lang="scss">
@import './style.scss';
</style>