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

165 lines
5.2 KiB
Vue

<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: {},
setup(_, { attrs, slots, expose }) {
const props = attrs as unknown as BubbleListProps & { class?: any; style?: any };
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 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)) {
nextTick(() => {
unref(listRef)!.scrollTo({ top: unref(listRef)!.scrollHeight });
});
}
});
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);
}
}
}
},
);
const onBubbleUpdate = useEventCallback<void>(() => {
if (props.autoScroll ?? true) setUpdateCount(unref(updateCount) + 1);
});
const context = computed(() => ({ onUpdate: onBubbleUpdate }));
// 暴露控制方法
const abortTypingByKey = (key: string | number) => {
bubbleRefs.value[key]?.abortTyping?.();
};
// 对外暴露能力
expose({
nativeElement: listRef,
abortTypingByKey,
scrollTo: (info: any) => {
unref(listRef)?.scrollTo?.(info);
},
});
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>