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 * @Author: RenXiaoDong
* @Date: 2025-08-20 23:17:49 * @Date: 2025-08-20 23:17:49
* @Description:
*
*/ */
import { computed, defineComponent, inject, provide, shallowRef, triggerRef, unref, watch } from "vue"; import { computed, defineComponent, inject, provide, shallowRef, triggerRef, unref, watch } from "vue";
import type { ComputedRef, InjectionKey } from "vue"; import type { ComputedRef, InjectionKey } from "vue";
import { objectType } from "@/utils/type"; import { objectType } from "@/utils/type";
import type { BubbleContextProps } from "./types"; import type { BubbleContextProps } from "./types";
/**
*
* Vue的依赖注入系统
*/
const BubbleContextKey: InjectionKey<ComputedRef<BubbleContextProps>> = const BubbleContextKey: InjectionKey<ComputedRef<BubbleContextProps>> =
Symbol('BubbleContext'); Symbol('BubbleContext');
/**
* API
* 访
*/
export const globalBubbleContextApi = shallowRef<BubbleContextProps>(); export const globalBubbleContextApi = shallowRef<BubbleContextProps>();
/**
* Hook
* API
*
* @param value -
*/
export const useBubbleContextProvider = (value: ComputedRef<BubbleContextProps>) => { export const useBubbleContextProvider = (value: ComputedRef<BubbleContextProps>) => {
// 向子组件提供上下文
provide(BubbleContextKey, value); provide(BubbleContextKey, value);
// 监听上下文变化同步更新全局API
watch( watch(
value, value,
() => { () => {
globalBubbleContextApi.value = unref(value); globalBubbleContextApi.value = unref(value);
// 触发响应式更新
triggerRef(globalBubbleContextApi); triggerRef(globalBubbleContextApi);
}, },
{ immediate: true, deep: true }, { immediate: true, deep: true }, // 立即执行,深度监听
); );
}; };
/**
* Hook
* API获取气泡上下文
*
* @returns
*/
export const useBubbleContextInject = () => { export const useBubbleContextInject = () => {
return inject( return inject(
BubbleContextKey, BubbleContextKey,
// 如果没有找到注入的上下文使用全局API作为后备
computed(() => globalBubbleContextApi.value || {}), computed(() => globalBubbleContextApi.value || {}),
); );
}; };
/**
*
* 使
*/
export const BubbleContextProvider = defineComponent({ export const BubbleContextProvider = defineComponent({
props: { props: {
// 上下文值,支持对象类型验证
value: objectType<BubbleContextProps>(), value: objectType<BubbleContextProps>(),
}, },
setup(props, { slots }) { setup(props, { slots }) {
// 使用计算属性包装props.value确保响应式
useBubbleContextProvider(computed(() => props.value)); useBubbleContextProvider(computed(() => props.value));
// 渲染默认插槽内容
return () => { return () => {
return slots.default?.(); 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 * @Author: RenXiaoDong
* @Date: 2025-08-20 23:26:23 * @Date: 2025-08-20 23:26:23
* @Description: Hook
*
*/ */
import useState from '@/hooks/useState'; import useState from '@/hooks/useState';
import { computed, onWatcherCleanup, unref, watch } from 'vue'; import { computed, onWatcherCleanup, unref, watch } from 'vue';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import type { BubbleContentType } from '../types'; import type { BubbleContentType } from '../types';
/**
*
* @param str -
* @returns
*/
function isString(str: any): str is string { function isString(str: any): str is string {
return typeof str === 'string'; return typeof str === 'string';
} }
/** /**
* Return typed content and typing status when typing is enabled. * Hook
* Or return content directly. *
*
*
* @param content -
* @param typingEnabled -
* @param typingStep -
* @param typingInterval -
* @returns [typedContent: 当前显示的内容, isTyping: 是否正在打字]
*/ */
const useTypedEffect = ( const useTypedEffect = (
content: Ref<BubbleContentType>, content: Ref<BubbleContentType>,
@ -21,20 +35,27 @@ const useTypedEffect = (
typingStep: Ref<number>, typingStep: Ref<number>,
typingInterval: Ref<number>, typingInterval: Ref<number>,
): [typedContent: Ref<BubbleContentType>, isTyping: Ref<boolean>] => { ): [typedContent: Ref<BubbleContentType>, isTyping: Ref<boolean>] => {
// 记录上一次的内容,用于检测内容变化
const [prevContent, setPrevContent] = useState<BubbleContentType>(''); const [prevContent, setPrevContent] = useState<BubbleContentType>('');
// 当前打字位置(已显示的字符数)
const [typingIndex, setTypingIndex] = useState<number>(1); const [typingIndex, setTypingIndex] = useState<number>(1);
// 合并打字启用状态:只有在启用打字且内容为字符串时才生效
const mergedTypingEnabled = computed(() => typingEnabled.value && isString(content.value)); const mergedTypingEnabled = computed(() => typingEnabled.value && isString(content.value));
// Reset typing index when content changed // 监听内容变化,重置打字索引
watch( watch(
content, content,
() => { () => {
const prevContentValue = unref(prevContent); const prevContentValue = unref(prevContent);
// 更新上一次的内容记录
setPrevContent(content.value); setPrevContent(content.value);
// 如果未启用打字效果且内容为字符串,直接显示全部内容
if (!mergedTypingEnabled.value && isString(content.value)) { if (!mergedTypingEnabled.value && isString(content.value)) {
setTypingIndex(content.value.length); setTypingIndex(content.value.length);
} else if ( } else if (
// 如果内容为字符串,且新内容不是以旧内容开头,重置打字索引
isString(content.value) && isString(content.value) &&
isString(prevContentValue) && isString(prevContentValue) &&
content.value.indexOf(prevContentValue) !== 0 content.value.indexOf(prevContentValue) !== 0
@ -45,15 +66,18 @@ const useTypedEffect = (
{ immediate: true }, { immediate: true },
); );
// Start typing // 启动打字效果
watch( watch(
[typingIndex, typingEnabled, content], [typingIndex, typingEnabled, content],
() => { () => {
// 只有在启用打字、内容为字符串且未显示完所有内容时才执行
if (mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length) { if (mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length) {
// 设置定时器,逐步增加显示字符数
const id = setTimeout(() => { const id = setTimeout(() => {
setTypingIndex(unref(typingIndex) + typingStep.value); setTypingIndex(unref(typingIndex) + typingStep.value);
}, typingInterval.value); }, typingInterval.value);
// 清理定时器,避免内存泄漏
onWatcherCleanup(() => { onWatcherCleanup(() => {
clearTimeout(id); clearTimeout(id);
}); });
@ -62,12 +86,15 @@ const useTypedEffect = (
{ immediate: true }, { immediate: true },
); );
// 计算当前应该显示的内容
const mergedTypingContent = computed(() => const mergedTypingContent = computed(() =>
// 如果启用打字且内容为字符串,显示部分内容;否则显示全部内容
mergedTypingEnabled.value && isString(content.value) ? content.value.slice(0, unref(typingIndex)) : content.value, mergedTypingEnabled.value && isString(content.value) ? content.value.slice(0, unref(typingIndex)) : content.value,
); );
return [ return [
mergedTypingContent, mergedTypingContent,
// 计算是否正在打字:启用打字 + 内容为字符串 + 未显示完所有内容
computed(() => mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length), 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 { onUpdate } = unref(useBubbleContextInject());
const { typing: typingProp, loading } = props; const [typingEnabled, typingStep, typingInterval, typingSuffix] = useTypingConfig(() => props.typing);
const [typingEnabled, typingStep, typingInterval, typingSuffix] = useTypingConfig(() => typingProp);
const [typedContent, isTyping] = useTypedEffect(content as any, typingEnabled, typingStep, typingInterval); const [typedContent, isTyping] = useTypedEffect(content as any, typingEnabled, typingStep, typingInterval);
const triggerTypingCompleteRef = ref(false); const triggerTypingCompleteRef = ref(false);
@ -34,7 +33,7 @@ export default defineComponent({
onUpdate?.(); onUpdate?.();
}); });
watchEffect(() => { watchEffect(() => {
if (!isTyping.value && !loading) { if (!isTyping.value && !props.loading) {
if (!triggerTypingCompleteRef.value) { if (!triggerTypingCompleteRef.value) {
triggerTypingCompleteRef.value = true; triggerTypingCompleteRef.value = true;
props.onTypingComplete?.(); props.onTypingComplete?.();
@ -48,11 +47,10 @@ export default defineComponent({
const mergedCls = computed(() => [ const mergedCls = computed(() => [
prefixCls, prefixCls,
`${prefixCls}-${props.placement ?? 'start'}`, `${prefixCls}-${props.placement ?? 'start'}`,
props.classNames?.root,
props.class, props.class,
{ {
[`${prefixCls}-typing`]: [`${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(() => { const contentNode = computed(() => {
if (props.loading) { if (props.loading) {
return slots.loading?.() ?? props.loadingRender?.() ?? <Loading prefixCls={prefixCls} />; if (slots.loading) {
return slots.loading();
}
return props.loadingRender ? props.loadingRender() : <Loading />;
} }
return ( return (
<> <>
{mergedContent.value as any} {mergedContent.value as any}
@ -96,7 +98,7 @@ export default defineComponent({
}; };
return () => ( return () => (
<div class={mergedCls.value} style={{ ...(props.styles?.root || {}), ...(props.style || {}) }}> <div class={mergedCls.value} style={{ ...(props.style || {}) }}>
{(slots.avatar || props.avatar) && ( {(slots.avatar || props.avatar) && (
<div class={[`${prefixCls}-avatar`, props.classNames?.avatar]} style={props.styles?.avatar}> <div class={[`${prefixCls}-avatar`, props.classNames?.avatar]} style={props.styles?.avatar}>
{avatarNode.value as any} {avatarNode.value as any}

View File

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

View File

@ -108,7 +108,7 @@ const checkHasInviteCode = () => {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
z-index: 9999; z-index: 1000;
width: 100%; width: 100%;
} }
.layout-content-wrap { .layout-content-wrap {

View File

@ -1,21 +0,0 @@
.ant-bubble-list {
gap: 8px;
.ant-bubble {
.ant-bubble-content {
color: var(--Text-1, #211f24);
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
&.ant-bubble-content-round {
display: flex;
justify-content: center;
align-items: center;
border-radius: 50px;
background: var(--BG-200, #f2f3f5);
padding-inline: 0;
}
}
}
}

View File

@ -10,5 +10,4 @@
@import "./button.scss"; @import "./button.scss";
@import "./steps.scss"; @import "./steps.scss";
@import "./form.scss"; @import "./form.scss";
@import "./chat-sender.scss"; @import "./chat-sender.scss";
@import "./chat-bubble.scss";

View File

@ -1,6 +1,6 @@
<script lang="tsx"> <script lang="tsx">
import { message, Avatar } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import { BubbleList } from '@/components/xt-chat/bubble'; import { BubbleList } from '@/components/xt-chat/xt-bubble';
import type { BubbleListProps } from 'ant-design-x-vue'; import type { BubbleListProps } from 'ant-design-x-vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import SenderInput from '../sender-input/index.vue'; import SenderInput from '../sender-input/index.vue';
@ -28,7 +28,6 @@ export default {
conversationList.value.push({ conversationList.value.push({
role: QUESTION_ROLE, role: QUESTION_ROLE,
content: searchValue.value, content: searchValue.value,
avatar: () => <Avatar />
}); });
const tempId = Date.now(); const tempId = Date.now();
@ -69,17 +68,13 @@ export default {
variant: 'borderless', variant: 'borderless',
typing: { step: 2, interval: 100 }, typing: { step: 2, interval: 100 },
onTypingComplete: () => { onTypingComplete: () => {
console.log('onTypingComplete');
generateLoading.value = false; generateLoading.value = false;
}, },
}, },
[QUESTION_ROLE]: { [QUESTION_ROLE]: {
placement: 'end', placement: 'end',
shape: 'round', shape: 'round'
styles: {
content: {
padding: '6px 12px',
},
},
}, },
}; };

View File

@ -1,7 +1,7 @@
<script lang="jsx"> <script lang="jsx">
import { Drawer } from 'ant-design-vue'; import { Drawer } from 'ant-design-vue';
import TextoverTips from '@/components/text-over-tips'; import TextoverTips from '@/components/text-over-tips';
import Conversations from '@/components/xt-chat/conversations'; import Conversations from '@/components/xt-chat/xt-conversations';
import SvgIcon from '@/components/svg-icon'; import SvgIcon from '@/components/svg-icon';
import { Button, Flex, Input } from 'ant-design-vue'; import { Button, Flex, Input } from 'ant-design-vue';
import DeleteChatModal from './delete-chat-modal.vue'; import DeleteChatModal from './delete-chat-modal.vue';