Files
lingji-work-fe/src/components/xt-chat/xt-bubble/xt-bubbleList.vue

205 lines
6.1 KiB
Vue
Raw Normal View History

2025-08-21 00:24:36 +08:00
<script lang="tsx">
2025-08-21 10:54:18 +08:00
import Bubble from './xt-bubble.vue';
2025-08-21 00:24:36 +08:00
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,
2025-08-29 09:58:09 +08:00
// 正确声明 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 }) {
2025-08-21 00:24:36 +08:00
const passThroughAttrs = useAttrs();
const TOLERANCE = 1;
2025-08-21 16:47:34 +08:00
const domProps = computed(() => pickAttrs(mergeProps(props as any, passThroughAttrs)));
2025-08-21 00:24:36 +08:00
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>>({});
2025-08-21 16:26:57 +08:00
const listPrefixCls = 'xt-bubble-list';
2025-08-21 00:24:36 +08:00
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);
2025-08-29 09:58:09 +08:00
// 首次挂载后仅自动滚动一次
const didInitialAutoScroll = ref(false);
2025-08-21 00:24:36 +08:00
const onInternalScroll = (e: Event) => {
const target = e.target as HTMLElement;
setScrollReachEnd(target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight <= TOLERANCE);
};
2025-08-29 09:58:09 +08:00
watch([updateCount, scrollReachEnd, listRef], () => {
if (props.autoScroll && unref(listRef) && unref(scrollReachEnd)) {
2025-08-21 00:24:36 +08:00
nextTick(() => {
2025-08-29 12:03:04 +08:00
console.log('自然滚动')
2025-08-21 00:24:36 +08:00
unref(listRef)!.scrollTo({ top: unref(listRef)!.scrollHeight });
});
}
});
watch(
() => unref(displayData).length,
2025-08-29 09:58:09 +08:00
(newLen, oldLen) => {
if (!props.autoScroll) return;
// 首次渲染:当有内容时滚到底部一次
if (!didInitialAutoScroll.value && newLen > 0) {
2025-08-29 12:03:04 +08:00
console.log('首次渲染滚动到底部-----')
2025-08-29 09:58:09 +08:00
scrollToBottom('auto');
didInitialAutoScroll.value = true;
return;
}
// 新增内容且当前在底部:继续粘底
if (oldLen !== undefined && newLen > (oldLen ?? 0) && unref(scrollReachEnd)) {
2025-08-29 12:03:04 +08:00
scrollToBottom();
2025-08-21 00:24:36 +08:00
}
},
2025-08-29 09:58:09 +08:00
{ immediate: true },
2025-08-21 00:24:36 +08:00
);
const onBubbleUpdate = useEventCallback<void>(() => {
2025-08-29 09:58:09 +08:00
if (props.autoScroll) setUpdateCount(unref(updateCount) + 1);
2025-08-21 00:24:36 +08:00
});
const context = computed(() => ({ onUpdate: onBubbleUpdate }));
2025-08-21 16:26:57 +08:00
// 暴露控制方法
const abortTypingByKey = (key: string | number) => {
bubbleRefs.value[key]?.abortTyping?.();
};
2025-08-29 09:58:09 +08:00
// 通用:滚动到底部
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
nextTick(() => {
requestAnimationFrame(() => {
const el = unref(listRef);
if (el) {
el.scrollTo({ top: el.scrollHeight, behavior });
setScrollReachEnd(true);
}
})
});
};
2025-08-21 16:26:57 +08:00
// 对外暴露能力
expose({
2025-08-21 16:47:34 +08:00
nativeElement: listRef,
2025-08-21 16:26:57 +08:00
abortTypingByKey,
scrollTo: (info: any) => {
unref(listRef)?.scrollTo?.(info);
},
2025-08-29 09:58:09 +08:00
scrollToBottom,
2025-08-21 16:26:57 +08:00
});
2025-08-21 00:24:36 +08:00
return () => (
<BubbleContextProvider value={context.value}>
<div
{...domProps.value}
class={[
listPrefixCls,
props.rootClassName,
props.class,
{ [`${listPrefixCls}-reach-end`]: unref(scrollReachEnd) },
]}
2025-08-21 16:47:34 +08:00
style={props.style}
2025-08-21 00:24:36 +08:00
ref={listRef}
onScroll={onInternalScroll}
>
2025-08-21 16:47:34 +08:00
{unref(displayData).map(({ key, onTypingComplete: onTypingCompleteBubble, ...bubble }) => (
2025-08-21 00:24:36 +08:00
<Bubble
{...bubble}
2025-08-21 16:47:34 +08:00
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}
2025-08-21 00:24:36 +08:00
key={key}
ref={(node: any) => {
if (node) {
bubbleRefs.value[key] = node as BubbleRef;
} else {
delete bubbleRefs.value[key];
}
}}
2025-08-21 16:47:34 +08:00
typing={unref(initialized) ? bubble.typing : false}
2025-08-21 00:24:36 +08:00
onTypingComplete={() => {
onTypingCompleteBubble?.();
onTypingComplete(key);
}}
/>
))}
</div>
</BubbleContextProvider>
);
},
});
</script>
<style scoped lang="scss">
@import './style.scss';
</style>