feat: bubble组件封装

This commit is contained in:
rd
2025-08-21 10:54:18 +08:00
parent 64621d9add
commit 55166ff580
26 changed files with 668 additions and 416 deletions

View File

@ -0,0 +1,81 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:17:49
* @Description: 气泡上下文管理
* 提供气泡组件间的通信机制,支持全局状态管理和组件间数据传递
*/
import { computed, defineComponent, inject, provide, shallowRef, triggerRef, unref, watch } from "vue";
import type { ComputedRef, InjectionKey } from "vue";
import { objectType } from "@/utils/type";
import type { BubbleContextProps } from "./types";
/**
* 气泡上下文注入键
* 用于Vue的依赖注入系统确保上下文的唯一性
*/
const BubbleContextKey: InjectionKey<ComputedRef<BubbleContextProps>> =
Symbol('BubbleContext');
/**
* 全局气泡上下文API
* 提供全局访问气泡上下文的能力,用于跨组件通信
*/
export const globalBubbleContextApi = shallowRef<BubbleContextProps>();
/**
* 气泡上下文提供者Hook
* 向子组件提供气泡上下文并同步更新全局API
*
* @param value - 要提供的上下文值(计算属性)
*/
export const useBubbleContextProvider = (value: ComputedRef<BubbleContextProps>) => {
// 向子组件提供上下文
provide(BubbleContextKey, value);
// 监听上下文变化同步更新全局API
watch(
value,
() => {
globalBubbleContextApi.value = unref(value);
// 触发响应式更新
triggerRef(globalBubbleContextApi);
},
{ immediate: true, deep: true }, // 立即执行,深度监听
);
};
/**
* 气泡上下文注入Hook
* 从父组件或全局API获取气泡上下文
*
* @returns 气泡上下文(计算属性)
*/
export const useBubbleContextInject = () => {
return inject(
BubbleContextKey,
// 如果没有找到注入的上下文使用全局API作为后备
computed(() => globalBubbleContextApi.value || {}),
);
};
/**
* 气泡上下文提供者组件
* 用于在模板中提供气泡上下文,简化使用方式
*/
export const BubbleContextProvider = defineComponent({
props: {
// 上下文值,支持对象类型验证
value: objectType<BubbleContextProps>(),
},
setup(props, { slots }) {
// 使用计算属性包装props.value确保响应式
useBubbleContextProvider(computed(() => props.value));
// 渲染默认插槽内容
return () => {
return slots.default?.();
};
},
});
export default BubbleContextProvider;

View File

@ -0,0 +1,87 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:12:42
* @Description: 聊天气泡列表数据显示逻辑Hook
* 用于控制气泡列表的渐进式显示,实现打字效果和动态加载
*/
import { computed, unref, watch } from 'vue';
import type { Ref } from 'vue';
import { useEventCallback } from '@/hooks/useEventCallback';
import useState from '@/hooks/useState';
import type { ListItemType } from './useListData';
/**
* Hook返回值类型定义
* @returns [displayList: 当前显示的数据列表, onTypingComplete: 打字完成回调函数]
*/
type UseDisplayDataReturn = [Ref<ListItemType[]>, (value: string | number) => void];
/**
* 数据显示逻辑Hook
* 实现气泡列表的渐进式显示,支持打字效果和动态加载
*
* @param items - 完整的数据列表引用
* @returns [displayList, onTypingComplete] - 当前显示列表和打字完成回调
*/
export default function useDisplayData(items: Ref<ListItemType[]>): UseDisplayDataReturn {
// 当前显示的数据条数,初始值为完整列表的长度
const [displayCount, setDisplayCount] = useState(items.value.length);
// 计算当前应该显示的数据列表从完整列表中截取前displayCount条
const displayList = computed(() => items.value.slice(0, unref(displayCount)));
// 获取当前显示列表中最后一条数据的key用于判断是否继续显示下一条
const displayListLastKey = computed(() => {
const lastItem = unref(displayList)[unref(displayList).length - 1];
return lastItem ? lastItem.key : null;
});
// 监听完整数据列表的变化,智能调整显示数量
watch(
items,
() => {
// 当数据列表变化时,先尝试显示所有数据
setDisplayCount(items.value.length);
// 检查当前显示列表是否与完整列表的前N项完全匹配
// 如果匹配,说明数据没有变化,保持当前显示状态
if (
unref(displayList).length &&
unref(displayList).every((item, index) => item.key === items.value[index]?.key)
) {
return;
}
// 如果当前没有显示任何数据,显示第一条
if (unref(displayList).length === 0) {
setDisplayCount(1);
} else {
// 找到第一个不匹配的位置,将显示数量设置为该位置
// 这样可以保持已显示数据的连续性,避免跳跃
for (let i = 0; i < unref(displayList).length; i += 1) {
if (unref(displayList)[i].key !== items.value[i]?.key) {
setDisplayCount(i);
break;
}
}
}
},
{ immediate: true, deep: true }, // 立即执行,深度监听
);
/**
* 打字完成回调函数
* 当某个气泡的打字效果完成时,显示下一条数据
*
* @param key - 完成打字的项目key
*/
const onTypingComplete = useEventCallback((key: string | number) => {
// 只有当完成打字的是当前显示列表的最后一条时,才显示下一条
// 这确保了显示的顺序性和连续性
if (key === unref(displayListLastKey)) {
setDisplayCount(unref(displayCount) + 1);
}
});
return [displayList, onTypingComplete] as const;
}

View File

@ -0,0 +1,79 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:14:55
* @Description: 气泡列表数据处理Hook
* 用于处理气泡数据列表,支持角色配置和属性合并
*/
import { computed, type Ref } from 'vue';
import type { BubbleDataType, BubbleListProps } from '../types';
import type { BubbleProps } from '../types';
/**
* 类型工具从Ref类型中提取原始类型
* @template T - Ref类型
* @returns 原始类型
*/
export type UnRef<T extends Ref<any>> = T extends Ref<infer R> ? R : never;
/**
* 列表项类型定义
* 从useListData返回值中提取单个列表项的类型
*/
export type ListItemType = UnRef<ReturnType<typeof useListData>>[number];
/**
* 气泡列表数据处理Hook
* 处理原始气泡数据,应用角色配置,生成最终的气泡属性
*
* @param items - 原始气泡数据列表
* @param roles - 角色配置,可以是对象或函数
* @returns 处理后的气泡数据列表
*/
export default function useListData(
items: Ref<BubbleListProps['items']>,
roles?: Ref<BubbleListProps['roles']>,
) {
/**
* 获取角色对应的气泡属性配置
* 根据气泡的角色类型,返回对应的样式和属性配置
*
* @param bubble - 气泡数据
* @param index - 气泡在列表中的索引
* @returns 角色对应的气泡属性
*/
const getRoleBubbleProps = (bubble: BubbleDataType, index: number): Partial<BubbleProps> => {
// 如果roles是函数调用函数获取配置
if (typeof roles.value === 'function') {
return roles.value(bubble, index);
}
// 如果roles是对象根据bubble.role获取对应配置
if (roles) {
return roles.value?.[bubble.role!] || {};
}
// 如果没有角色配置,返回空对象
return {};
}
/**
* 计算处理后的列表数据
* 合并角色配置和原始数据,生成最终的气泡属性
*/
const listData = computed(() =>
(items.value || []).map((bubbleData, i) => {
// 生成唯一key如果没有提供key则使用预设格式
const mergedKey = bubbleData.key ?? `preset_${i}`;
return {
// 先应用角色配置(作为默认值)
...getRoleBubbleProps(bubbleData, i),
// 再应用原始数据(会覆盖角色配置中的相同属性)
...bubbleData,
// 最后设置key确保唯一性
key: mergedKey,
};
}));
return listData as Ref<any[]>;
}

View File

@ -0,0 +1,102 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:26:23
* @Description: 打字效果Hook
* 实现文本内容的渐进式显示,模拟打字机效果
*/
import useState from '@/hooks/useState';
import { computed, onWatcherCleanup, unref, watch } from 'vue';
import type { Ref } from 'vue';
import type { BubbleContentType } from '../types';
/**
* 类型守卫:检查值是否为字符串类型
* @param str - 待检查的值
* @returns 是否为字符串
*/
function isString(str: any): str is string {
return typeof str === 'string';
}
/**
* 打字效果Hook
* 当启用打字效果时,返回渐进式显示的内容和打字状态
* 否则直接返回原始内容
*
* @param content - 原始内容
* @param typingEnabled - 是否启用打字效果
* @param typingStep - 每次显示的字符数
* @param typingInterval - 打字间隔时间(毫秒)
* @returns [typedContent: 当前显示的内容, isTyping: 是否正在打字]
*/
const useTypedEffect = (
content: Ref<BubbleContentType>,
typingEnabled: Ref<boolean>,
typingStep: Ref<number>,
typingInterval: Ref<number>,
): [typedContent: Ref<BubbleContentType>, isTyping: Ref<boolean>] => {
// 记录上一次的内容,用于检测内容变化
const [prevContent, setPrevContent] = useState<BubbleContentType>('');
// 当前打字位置(已显示的字符数)
const [typingIndex, setTypingIndex] = useState<number>(1);
// 合并打字启用状态:只有在启用打字且内容为字符串时才生效
const mergedTypingEnabled = computed(() => typingEnabled.value && isString(content.value));
// 监听内容变化,重置打字索引
watch(
content,
() => {
const prevContentValue = unref(prevContent);
// 更新上一次的内容记录
setPrevContent(content.value);
// 如果未启用打字效果且内容为字符串,直接显示全部内容
if (!mergedTypingEnabled.value && isString(content.value)) {
setTypingIndex(content.value.length);
} else if (
// 如果内容为字符串,且新内容不是以旧内容开头,重置打字索引
isString(content.value) &&
isString(prevContentValue) &&
content.value.indexOf(prevContentValue) !== 0
) {
setTypingIndex(1);
}
},
{ immediate: true },
);
// 启动打字效果
watch(
[typingIndex, typingEnabled, content],
() => {
// 只有在启用打字、内容为字符串且未显示完所有内容时才执行
if (mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length) {
// 设置定时器,逐步增加显示字符数
const id = setTimeout(() => {
setTypingIndex(unref(typingIndex) + typingStep.value);
}, typingInterval.value);
// 清理定时器,避免内存泄漏
onWatcherCleanup(() => {
clearTimeout(id);
});
}
},
{ immediate: true },
);
// 计算当前应该显示的内容
const mergedTypingContent = computed(() =>
// 如果启用打字且内容为字符串,显示部分内容;否则显示全部内容
mergedTypingEnabled.value && isString(content.value) ? content.value.slice(0, unref(typingIndex)) : content.value,
);
return [
mergedTypingContent,
// 计算是否正在打字:启用打字 + 内容为字符串 + 未显示完所有内容
computed(() => mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length),
];
};
export default useTypedEffect;

View File

@ -0,0 +1,61 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:27:05
* @Description: 打字配置Hook
* 处理打字效果的配置参数,提供默认值和配置合并功能
*/
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import type { BubbleProps, TypingOption } from '../types';
/**
* 打字配置Hook
* 解析打字配置参数,提供默认值,返回配置化的打字参数
*
* @param typing - 打字配置可以是布尔值、配置对象或null
* @returns [typingEnabled, step, interval, suffix] - 打字启用状态、步长、间隔、后缀
*/
function useTypingConfig(typing: MaybeRefOrGetter<BubbleProps['typing']>) {
/**
* 计算是否启用打字效果
* 只有当typing为真值时才启用打字效果
*/
const typingEnabled = computed(() => {
if (!toValue(typing)) {
return false;
}
return true;
});
/**
* 基础配置:提供默认的打字参数
* 当用户没有提供完整配置时,使用这些默认值
*/
const baseConfig: Required<TypingOption> = {
step: 1, // 每次显示的字符数
interval: 50, // 打字间隔时间(毫秒)
suffix: null, // 打字时的后缀(默认为空)
};
/**
* 合并配置:将用户配置与默认配置合并
* 用户配置会覆盖默认配置中的相同属性
*/
const config = computed(() => {
const typingRaw = toValue(typing);
return {
...baseConfig,
// 如果typing是对象则合并对象属性否则使用默认配置
...(typeof typingRaw === 'object' ? typingRaw : {})
}
});
// 返回解构的配置参数,方便使用
return [
typingEnabled, // 是否启用打字效果
computed(() => config.value.step), // 每次显示的字符数
computed(() => config.value.interval), // 打字间隔时间
computed(() => config.value.suffix) // 打字后缀
] as const;
}
export default useTypingConfig;

View File

@ -0,0 +1,9 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 22:16:14
*/
export { default as Bubble } from './xt-bubble.vue';
export { default as BubbleList } from './xt-bubbleList.vue';
export * from './types';

View File

@ -0,0 +1,66 @@
<!--
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:20:21
-->
<script lang="tsx">
import { defineComponent } from 'vue';
export default defineComponent({
setup(props, { emit, expose }) {
return () => (
<span class="xt-bubble-dot">
<i class="dot-item w-2px h-2px" key="item-1" />
<i class="dot-item w-3px h-3px" key="item-2" />
<i class="dot-item w-5px h-5px" key="item-3" />
</span>
);
},
});
</script>
<style lang="scss" scoped>
.xt-bubble-dot {
display: flex;
align-items: center;
column-gap: 4px;
@keyframes loadingMove {
0% {
transform: translateY(0);
}
10% {
transform: translateY(4px);
}
20% {
transform: translateY(0);
}
30% {
transform: translateY(4px);
}
40% {
transform: translateY(0);
}
}
.dot-item {
border-radius: 50%;
background: var(--Brand-6, #6d4cfe);
animation-name: loadingMove;
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: linear;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
</style>

View File

@ -0,0 +1,94 @@
.xt-bubble-list {
gap: 8px;
display: flex;
flex-direction: column;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.xt-bubble {
display: flex;
justify-content: start;
column-gap: 12px;
@mixin cts {
color: var(--Text-1, #211f24);
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
}
&.xt-bubble-start {
flex-direction: row;
}
&.xt-bubble-end {
justify-content: end;
flex-direction: row-reverse;
}
.xt-bubble-avatar {
}
.xt-bubble-content {
display: flex;
align-items: center;
@include cts;
&-filled {
background-color: #f2f3f5;
padding: 6px 12px;
border-radius: 10px;
}
&-outlined {
background-color: #fff;
border: 1px solid #e5e6eb;
padding: 6px 12px;
border-radius: 10px;
}
&-shadow {
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
padding: 6px 12px;
border-radius: 10px;
}
&-borderless {
background-color: transparent !important;
}
// 形状样式
&-default {
border-radius: 10px;
}
&-round {
padding: 6px 12px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50px;
background-color: var(--BG-200, #f2f3f5);
}
&-corner {
border-radius: 4px;
}
}
.xt-bubble-header {
@include cts;
margin-bottom: 6px;
}
.xt-bubble-footer {
@include cts;
margin-top: 6px;
}
&.xt-bubble-typing {
display: inline-block;
min-width: 36px;
}
}

View File

@ -0,0 +1,186 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 22:04:54
* @Description: 聊天气泡组件的类型定义
*/
import type { AvatarProps } from 'ant-design-vue';
import type { CSSProperties, HTMLAttributes, VNode } from 'vue';
/**
* 避免类型验证的包装类型
* 用于包装可能引起TypeScript严格检查问题的类型
*/
export type AvoidValidation<T> = T;
/**
* 打字效果配置选项
* 控制消息的打字动画效果
*/
export interface TypingOption {
/**
* 每次打字的字符数
* @default 1
*/
step?: number;
/**
* 打字间隔时间(毫秒)
* @default 50
*/
interval?: number;
/**
* 打字时的后缀显示内容
* @default null
*/
suffix?: VNode | string;
}
/**
* 语义化类型
* 定义气泡组件中各个部分的语义化标识
*/
export type SemanticType = 'avatar' | 'content' | 'header' | 'footer';
/**
* 气泡内容类型
* 支持多种内容格式VNode、字符串、对象、数字等
*/
export type BubbleContentType = VNode | string | Record<PropertyKey, any> | number;
/**
* 插槽信息类型
* 传递给插槽函数的额外信息
*/
export type SlotInfoType = {
/** 气泡的唯一标识键 */
key?: string | number;
};
/**
* 头像属性扩展接口
* 继承自ant-design-vue的AvatarProps添加了class和style属性
*/
export interface _AvatarProps extends AvatarProps {
/** 自定义CSS类名 */
class: string;
/** 自定义内联样式 */
style: CSSProperties;
}
/**
* 气泡组件属性接口
* 定义气泡组件的所有可配置属性
*/
export interface BubbleProps<ContentType extends BubbleContentType = string> extends /* @vue-ignore */ Omit<HTMLAttributes, 'content'> {
/** 组件前缀类名 */
prefixCls?: string;
/** 根元素的自定义类名 */
rootClassName?: string;
/** 各部分的样式配置 */
styles?: Partial<Record<SemanticType, CSSProperties>>;
/** 各部分的类名配置 */
classNames?: Partial<Record<SemanticType, string>>;
/** 头像配置可以是属性对象、VNode或渲染函数 */
avatar?: Partial<_AvatarProps> | VNode | (() => VNode);
/** 气泡位置start(左侧) | end(右侧) */
placement?: 'start' | 'end';
/** 是否显示加载状态 */
loading?: boolean;
/** 打字效果配置:可以是配置对象或布尔值 */
typing?: AvoidValidation<TypingOption | boolean>;
/** 气泡内容 */
content?: ContentType;
/** 自定义消息渲染函数 */
messageRender?: (content: ContentType) => VNode | string;
/** 自定义加载状态渲染函数 */
loadingRender?: () => VNode;
/** 气泡样式变体filled(填充) | borderless(无边框) | outlined(轮廓) | shadow(阴影) */
variant?: 'filled' | 'borderless' | 'outlined' | 'shadow';
/** 气泡形状round(圆角) | corner(直角) */
shape?: 'round' | 'corner';
/** 内部使用的唯一标识键 */
_key?: number | string;
/** 打字完成时的回调函数 */
onTypingComplete?: VoidFunction;
/** 头部内容可以是VNode、字符串或渲染函数 */
header?: AvoidValidation<VNode | string | ((content: ContentType, info: SlotInfoType) => VNode | string)>;
/** 底部内容可以是VNode、字符串或渲染函数 */
footer?: AvoidValidation<VNode | string | ((content: ContentType, info: SlotInfoType) => VNode | string)>;
}
/**
* 气泡组件引用接口
* 提供对气泡组件DOM元素的访问
*/
export interface BubbleRef {
/** 气泡组件的原生DOM元素 */
nativeElement: HTMLElement;
}
/**
* 气泡上下文属性接口
* 用于气泡组件间的通信
*/
export interface BubbleContextProps {
/** 更新回调函数 */
onUpdate?: VoidFunction;
}
/**
* 气泡列表引用接口
* 提供对气泡列表组件的访问和控制方法
*/
export interface BubbleListRef {
/** 气泡列表的原生DOM元素 */
nativeElement: HTMLDivElement;
/** 滚动到指定位置的方法 */
scrollTo: (info: {
/** 滚动偏移量 */
offset?: number;
/** 目标气泡的键值 */
key?: string | number;
/** 滚动行为smooth(平滑) | auto(自动) */
behavior?: ScrollBehavior;
/** 滚动位置start | center | end | nearest */
block?: ScrollLogicalPosition;
}) => void;
}
/**
* 气泡数据类型
* 扩展了BubbleProps添加了key和role属性
*/
export type BubbleDataType = BubbleProps<any> & {
/** 气泡的唯一标识键 */
key?: string | number;
/** 气泡的角色类型 */
role?: string;
};
/**
* 角色类型
* 定义不同角色的气泡样式配置
*/
export type RoleType = Partial<Omit<BubbleProps<any>, 'content'>>;
/**
* 角色配置类型
* 可以是角色配置对象或根据气泡数据和索引返回角色配置的函数
*/
export type RolesType = Record<string, RoleType> | ((bubbleDataP: BubbleDataType, index: number) => RoleType);
/**
* 气泡列表属性接口
* 定义气泡列表组件的所有可配置属性
*/
export interface BubbleListProps extends /* @vue-ignore */ HTMLAttributes {
/** 组件前缀类名 */
prefixCls?: string;
/** 根元素的自定义类名 */
rootClassName?: string;
/** 气泡数据数组 */
items?: BubbleDataType[];
/** 是否自动滚动到最新消息 */
autoScroll?: boolean;
/** 角色配置:定义不同角色的气泡样式 */
roles?: AvoidValidation<RolesType>;
}

View File

@ -0,0 +1,137 @@
<script lang="tsx">
import { Avatar } from 'ant-design-vue';
import { computed, defineComponent, ref, toRef, unref, watch, watchEffect } from 'vue';
import Loading from './loading.vue';
import useTypingConfig from './hooks/useTypingConfig';
import useTypedEffect from './hooks/useTypedEffect';
import { useBubbleContextInject } from './context';
import type { BubbleProps, BubbleContentType, SlotInfoType } from './types';
export default defineComponent({
name: 'Bubble',
inheritAttrs: false,
props: {} as any,
setup(_, { attrs, slots }) {
const props = attrs as unknown as BubbleProps<BubbleContentType> & { style?: any; class?: any };
const content = ref<BubbleContentType>(props.content ?? '');
watch(
() => props.content,
(val) => {
content.value = val as any;
},
{ immediate: true },
);
const { onUpdate } = unref(useBubbleContextInject());
const [typingEnabled, typingStep, typingInterval, typingSuffix] = useTypingConfig(() => props.typing);
const [typedContent, isTyping] = useTypedEffect(content as any, typingEnabled, typingStep, typingInterval);
const triggerTypingCompleteRef = ref(false);
watch(typedContent, () => {
onUpdate?.();
});
watchEffect(() => {
if (!isTyping.value && !props.loading) {
if (!triggerTypingCompleteRef.value) {
triggerTypingCompleteRef.value = true;
props.onTypingComplete?.();
}
} else {
triggerTypingCompleteRef.value = false;
}
});
const prefixCls = 'xt-bubble';
const mergedCls = computed(() => [
prefixCls,
`${prefixCls}-${props.placement ?? 'start'}`,
props.class,
{
[`${prefixCls}-typing`]:
isTyping.value && !props.loading && !props.messageRender && !slots.message && !typingSuffix.value,
},
]);
const avatarNode = computed(() => {
if (slots.avatar) return slots.avatar();
const avatar = props.avatar as any;
return typeof avatar === 'function' ? avatar() : avatar && avatar.src ? <Avatar {...avatar} /> : null;
});
const mergedContent = computed(() => {
if (slots.message) return slots.message({ content: typedContent.value as any }) as any;
return props.messageRender
? (props.messageRender(typedContent.value as any) as any)
: (typedContent.value as any);
});
const contentNode = computed(() => {
if (props.loading) {
if (slots.loading) {
return slots.loading();
}
return props.loadingRender ? props.loadingRender() : <Loading />;
}
return (
<>
{mergedContent.value as any}
{isTyping.value && unref(typingSuffix)}
</>
);
});
const renderHeader = () => {
const info: SlotInfoType = { key: props._key };
if (slots.header) return slots.header({ content: typedContent.value as any, info }) as any;
const h = props.header as any;
return typeof h === 'function' ? h(typedContent.value as any, info) : h;
};
const renderFooter = () => {
const info: SlotInfoType = { key: props._key };
if (slots.footer) return slots.footer({ content: typedContent.value as any, info }) as any;
const f = props.footer as any;
return typeof f === 'function' ? f(typedContent.value as any, info) : f;
};
return () => (
<div class={mergedCls.value} style={{ ...(props.style || {}) }}>
{(slots.avatar || props.avatar) && (
<div class={[`${prefixCls}-avatar`, props.classNames?.avatar]} style={props.styles?.avatar}>
{avatarNode.value as any}
</div>
)}
<div
class={[
`${prefixCls}-content`,
`${prefixCls}-content-${props.variant ?? 'filled'}`,
{ [`${prefixCls}-content-${props.shape}`]: props.shape },
props.classNames?.content,
]}
style={props.styles?.content}
>
{renderHeader() ? (
<div class={[`${prefixCls}-header`, props.classNames?.header]} style={props.styles?.header}>
{renderHeader()}
</div>
) : null}
{contentNode.value as any}
{renderFooter() ? (
<div class={[`${prefixCls}-footer`, props.classNames?.footer]} style={props.styles?.footer}>
{renderFooter()}
</div>
) : null}
</div>
</div>
);
},
});
</script>
<style scoped lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,153 @@
<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: {} as any,
setup(_, { attrs, slots }) {
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)) as any);
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 }));
return () => (
<BubbleContextProvider value={context.value}>
<div
{...domProps.value}
class={[
listPrefixCls,
props.rootClassName,
props.class,
{ [`${listPrefixCls}-reach-end`]: unref(scrollReachEnd) },
]}
style={props.style as any}
ref={listRef}
onScroll={onInternalScroll}
>
{unref(displayData).map(({ key, onTypingComplete: onTypingCompleteBubble, ...bubble }: any) => (
<Bubble
{...bubble}
avatar={slots.avatar ? () => slots.avatar?.({ item: { key, ...bubble } }) : (bubble as any).avatar}
header={slots.header?.({ item: { key, ...bubble } }) ?? (bubble as any).header}
footer={slots.footer?.({ item: { key, ...bubble } }) ?? (bubble as any).footer}
loadingRender={
slots.loading ? () => slots.loading({ item: { key, ...bubble } }) : (bubble as any).loadingRender
}
content={slots.message?.({ item: { key, ...bubble } }) ?? (bubble as any).content}
key={key}
ref={(node: any) => {
if (node) {
bubbleRefs.value[key] = node as BubbleRef;
} else {
delete bubbleRefs.value[key];
}
}}
typing={unref(initialized) ? (bubble as any).typing : false}
onTypingComplete={() => {
onTypingCompleteBubble?.();
onTypingComplete(key);
}}
/>
))}
</div>
</BubbleContextProvider>
);
},
});
</script>
<style scoped lang="scss">
@import './style.scss';
</style>