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

@ -1,58 +0,0 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:12:42
*/
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';
type UseDisplayDataReturn = [Ref<ListItemType[]>, (value: string | number) => void];
export default function useDisplayData(items: Ref<ListItemType[]>): UseDisplayDataReturn {
const [displayCount, setDisplayCount] = useState(items.value.length);
const displayList = computed(() => items.value.slice(0, unref(displayCount)));
const displayListLastKey = computed(() => {
const lastItem = unref(displayList)[unref(displayList).length - 1];
return lastItem ? lastItem.key : null;
});
// When `items` changed, we replaced with latest one
watch(
items,
() => {
setDisplayCount(items.value.length);
if (
unref(displayList).length &&
unref(displayList).every((item, index) => item.key === items.value[index]?.key)
) {
return;
}
if (unref(displayList).length === 0) {
setDisplayCount(1);
} else {
// Find diff index
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 },
);
// Continue to show if last one finished typing
const onTypingComplete = useEventCallback((key: string | number) => {
if (key === unref(displayListLastKey)) {
setDisplayCount(unref(displayCount) + 1);
}
});
return [displayList, onTypingComplete] as const;
}

View File

@ -1,41 +0,0 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:14:55
*/
import { computed, type Ref } from 'vue';
import type { BubbleDataType, BubbleListProps } from '../types';
import type { BubbleProps } from '../types';
export type UnRef<T extends Ref<any>> = T extends Ref<infer R> ? R : never;
export type ListItemType = UnRef<ReturnType<typeof useListData>>[number];
export default function useListData(
items: Ref<BubbleListProps['items']>,
roles?: Ref<BubbleListProps['roles']>,
) {
const getRoleBubbleProps = (bubble: BubbleDataType, index: number): Partial<BubbleProps> => {
if (typeof roles.value === 'function') {
return roles.value(bubble, index);
}
if (roles) {
return roles.value?.[bubble.role!] || {};
}
return {};
}
const listData = computed(() =>
(items.value || []).map((bubbleData, i) => {
const mergedKey = bubbleData.key ?? `preset_${i}`;
return {
...getRoleBubbleProps(bubbleData, i),
...bubbleData,
key: mergedKey,
};
}));
return listData as Ref<any[]>;
}

View File

@ -1,37 +0,0 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:27:05
*/
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import type { BubbleProps, TypingOption } from '../types';
function useTypingConfig(typing: MaybeRefOrGetter<BubbleProps['typing']>) {
const typingEnabled = computed(() => {
if (!toValue(typing)) {
return false;
}
return true;
});
const baseConfig: Required<TypingOption> = {
step: 1,
interval: 50,
// set default suffix is empty
suffix: null,
};
const config = computed(() => {
const typingRaw = toValue(typing);
return {
...baseConfig,
...(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

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

View File

@ -1,4 +0,0 @@
<!--
* @Author: RenXiaoDong
* @Date: 2025-08-20 22:04:55
-->

View File

@ -1,29 +0,0 @@
<!--
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:20:21
-->
<script lang="tsx">
import { defineComponent } from 'vue';
interface LoadingProps {
prefixCls?: string;
}
export default defineComponent({
props: {
prefixCls: {
type: String,
default: '',
},
},
setup(props, { emit, expose }) {
return (
<span class={`${props.prefixCls}-dot`}>
<i class={`${props.prefixCls}-dot-item`} key={`item-${1}`} />
<i class={`${props.prefixCls}-dot-item`} key={`item-${2}`} />
<i class={`${props.prefixCls}-dot-item`} key={`item-${3}`} />
</span>
);
},
});
</script>

View File

@ -1,94 +0,0 @@
.xt-bubble-list {
gap: 8px;
.xt-bubble {
display: flex;
justify-content: start;
margin: 8px 0;
&.xt-bubble-start {
flex-direction: row;
}
&.xt-bubble-end {
justify-content: end;
flex-direction: row-reverse;
}
.xt-bubble-content {
color: var(--Text-1, #211f24);
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
.xt-bubble__avatar {
margin: 0 8px;
img {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
}
&.xt-bubble-content-round {
display: flex;
justify-content: center;
align-items: center;
border-radius: 50px;
background: var(--BG-200, #f2f3f5);
padding-inline: 0;
}
}
}
}
.xt-bubble--filled .xt-bubble__content {
background-color: #f5f5f5;
}
.xt-bubble--outlined .xt-bubble__content {
background-color: #fff;
border: 1px solid #e5e6eb;
}
.xt-bubble--shadow .xt-bubble__content {
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.xt-bubble--borderless .xt-bubble__content {
background-color: transparent;
}
.xt-bubble--shape-default .xt-bubble__content {
border-radius: 10px;
}
.xt-bubble--shape-round .xt-bubble__content {
border-radius: 18px;
}
.xt-bubble--shape-corner .xt-bubble__content {
border-radius: 4px;
}
.xt-bubble__header {
font-size: 12px;
color: #86909c;
margin-bottom: 6px;
}
.xt-bubble__footer {
font-size: 12px;
color: #86909c;
margin-top: 6px;
}
.xt-bubble__typing {
display: inline-block;
min-width: 36px;
}
.xt-bubble-list {
display: flex;
flex-direction: column;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}

View File

@ -1,90 +0,0 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 22:04:54
*/
import type { AvatarProps } from 'ant-design-vue';
import type { CSSProperties, HTMLAttributes, VNode } from 'vue';
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';
export type BubbleContentType = VNode | string | Record<PropertyKey, any> | number;
export type SlotInfoType = {
key?: string | number;
};
export interface _AvatarProps extends AvatarProps {
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>>;
avatar?: Partial<_AvatarProps> | VNode | (() => VNode);
placement?: 'start' | 'end';
loading?: boolean;
typing?: AvoidValidation<TypingOption | boolean>;
content?: ContentType;
messageRender?: (content: ContentType) => VNode | string;
loadingRender?: () => VNode;
variant?: 'filled' | 'borderless' | 'outlined' | 'shadow';
shape?: 'round' | 'corner';
_key?: number | string;
onTypingComplete?: VoidFunction;
header?: AvoidValidation<VNode | string | ((content: ContentType, info: SlotInfoType) => VNode | string)>;
footer?: AvoidValidation<VNode | string | ((content: ContentType, info: SlotInfoType) => VNode | string)>;
}
export interface BubbleRef {
nativeElement: HTMLElement;
}
export interface BubbleContextProps {
onUpdate?: VoidFunction;
}
export interface BubbleListRef {
nativeElement: HTMLDivElement;
scrollTo: (info: {
offset?: number;
key?: string | number;
behavior?: ScrollBehavior;
block?: ScrollLogicalPosition;
}) => void;
}
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

@ -1,41 +1,77 @@
/*
* @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 },
{ 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?.();
};

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

@ -1,19 +1,33 @@
/*
* @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';
}
/**
* Return typed content and typing status when typing is enabled.
* Or return content directly.
* Hook
*
*
*
* @param content -
* @param typingEnabled -
* @param typingStep -
* @param typingInterval -
* @returns [typedContent: 当前显示的内容, isTyping: 是否正在打字]
*/
const useTypedEffect = (
content: Ref<BubbleContentType>,
@ -21,20 +35,27 @@ const useTypedEffect = (
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));
// Reset typing index when content changed
// 监听内容变化,重置打字索引
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
@ -45,15 +66,18 @@ const useTypedEffect = (
{ immediate: true },
);
// Start typing
// 启动打字效果
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);
});
@ -62,12 +86,15 @@ const useTypedEffect = (
{ 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),
];
};

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

@ -25,8 +25,7 @@ export default defineComponent({
const { onUpdate } = unref(useBubbleContextInject());
const { typing: typingProp, loading } = props;
const [typingEnabled, typingStep, typingInterval, typingSuffix] = useTypingConfig(() => typingProp);
const [typingEnabled, typingStep, typingInterval, typingSuffix] = useTypingConfig(() => props.typing);
const [typedContent, isTyping] = useTypedEffect(content as any, typingEnabled, typingStep, typingInterval);
const triggerTypingCompleteRef = ref(false);
@ -34,7 +33,7 @@ export default defineComponent({
onUpdate?.();
});
watchEffect(() => {
if (!isTyping.value && !loading) {
if (!isTyping.value && !props.loading) {
if (!triggerTypingCompleteRef.value) {
triggerTypingCompleteRef.value = true;
props.onTypingComplete?.();
@ -48,11 +47,10 @@ export default defineComponent({
const mergedCls = computed(() => [
prefixCls,
`${prefixCls}-${props.placement ?? 'start'}`,
props.classNames?.root,
props.class,
{
[`${prefixCls}-typing`]:
isTyping.value && !loading && !props.messageRender && !slots.message && !typingSuffix.value,
isTyping.value && !props.loading && !props.messageRender && !slots.message && !typingSuffix.value,
},
]);
@ -71,8 +69,12 @@ export default defineComponent({
const contentNode = computed(() => {
if (props.loading) {
return slots.loading?.() ?? props.loadingRender?.() ?? <Loading prefixCls={prefixCls} />;
if (slots.loading) {
return slots.loading();
}
return props.loadingRender ? props.loadingRender() : <Loading />;
}
return (
<>
{mergedContent.value as any}
@ -96,7 +98,7 @@ export default defineComponent({
};
return () => (
<div class={mergedCls.value} style={{ ...(props.styles?.root || {}), ...(props.style || {}) }}>
<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}

View File

@ -1,5 +1,5 @@
<script lang="tsx">
import Bubble from './Bubble.vue';
import Bubble from './xt-bubble.vue';
import BubbleContextProvider from './context';
import useDisplayData from './hooks/useDisplayData';
import useListData from './hooks/useListData';
@ -48,8 +48,7 @@ export default defineComponent({
const listRef = ref<HTMLDivElement | null>(null);
const bubbleRefs = ref<Record<string | number, BubbleRef>>({});
const prefixCls = 'xt-bubble';
const listPrefixCls = `${prefixCls}-list`;
const listPrefixCls = 'xt-bubble-list'
const [initialized, setInitialized] = useState(false);
watchPostEffect(() => {
@ -152,8 +151,3 @@ export default defineComponent({
<style scoped lang="scss">
@import './style.scss';
</style>
<!--
* @Author: RenXiaoDong
* @Date: 2025-08-20 22:39:35
-->