diff --git a/src/components/xt-chat/Bubble/hooks/useDisplayData.ts b/src/components/xt-chat/Bubble/hooks/useDisplayData.ts deleted file mode 100644 index 77967f3..0000000 --- a/src/components/xt-chat/Bubble/hooks/useDisplayData.ts +++ /dev/null @@ -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, (value: string | number) => void]; - -export default function useDisplayData(items: Ref): 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; -} diff --git a/src/components/xt-chat/Bubble/hooks/useListData.ts b/src/components/xt-chat/Bubble/hooks/useListData.ts deleted file mode 100644 index cd0142d..0000000 --- a/src/components/xt-chat/Bubble/hooks/useListData.ts +++ /dev/null @@ -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 ? R : never; - -export type ListItemType = UnRef>[number]; - -export default function useListData( - items: Ref, - roles?: Ref, -) { - const getRoleBubbleProps = (bubble: BubbleDataType, index: number): Partial => { - 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; -} \ No newline at end of file diff --git a/src/components/xt-chat/Bubble/hooks/useTypingConfig.ts b/src/components/xt-chat/Bubble/hooks/useTypingConfig.ts deleted file mode 100644 index 5ffa015..0000000 --- a/src/components/xt-chat/Bubble/hooks/useTypingConfig.ts +++ /dev/null @@ -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) { - const typingEnabled = computed(() => { - if (!toValue(typing)) { - return false; - } - return true; - }); - const baseConfig: Required = { - 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; \ No newline at end of file diff --git a/src/components/xt-chat/Bubble/index.ts b/src/components/xt-chat/Bubble/index.ts deleted file mode 100644 index d98e559..0000000 --- a/src/components/xt-chat/Bubble/index.ts +++ /dev/null @@ -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'; - - diff --git a/src/components/xt-chat/Bubble/index.vue b/src/components/xt-chat/Bubble/index.vue deleted file mode 100644 index 452b383..0000000 --- a/src/components/xt-chat/Bubble/index.vue +++ /dev/null @@ -1,4 +0,0 @@ - diff --git a/src/components/xt-chat/Bubble/loading.vue b/src/components/xt-chat/Bubble/loading.vue deleted file mode 100644 index c6e96b8..0000000 --- a/src/components/xt-chat/Bubble/loading.vue +++ /dev/null @@ -1,29 +0,0 @@ - - diff --git a/src/components/xt-chat/Bubble/style.scss b/src/components/xt-chat/Bubble/style.scss deleted file mode 100644 index ae35d27..0000000 --- a/src/components/xt-chat/Bubble/style.scss +++ /dev/null @@ -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; -} diff --git a/src/components/xt-chat/Bubble/types.ts b/src/components/xt-chat/Bubble/types.ts deleted file mode 100644 index 0bbc1c4..0000000 --- a/src/components/xt-chat/Bubble/types.ts +++ /dev/null @@ -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; - -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 | number; - -export type SlotInfoType = { - key?: string | number; -}; - -export interface _AvatarProps extends AvatarProps { - class: string; - style: CSSProperties; -} - -export interface BubbleProps extends /* @vue-ignore */ Omit { - prefixCls?: string; - rootClassName?: string; - styles?: Partial>; - classNames?: Partial>; - avatar?: Partial<_AvatarProps> | VNode | (() => VNode); - placement?: 'start' | 'end'; - loading?: boolean; - typing?: AvoidValidation; - 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)>; - footer?: AvoidValidation 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 & { - key?: string | number; - role?: string; -}; - -export type RoleType = Partial, 'content'>>; - -export type RolesType = Record | ((bubbleDataP: BubbleDataType, index: number) => RoleType); - -export interface BubbleListProps extends /* @vue-ignore */ HTMLAttributes { - prefixCls?: string; - rootClassName?: string; - items?: BubbleDataType[]; - autoScroll?: boolean; - roles?: AvoidValidation; -} \ No newline at end of file diff --git a/src/components/xt-chat/Bubble/context.ts b/src/components/xt-chat/xt-bubble/context.ts similarity index 51% rename from src/components/xt-chat/Bubble/context.ts rename to src/components/xt-chat/xt-bubble/context.ts index 99d1f41..25303c0 100644 --- a/src/components/xt-chat/Bubble/context.ts +++ b/src/components/xt-chat/xt-bubble/context.ts @@ -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> = Symbol('BubbleContext'); +/** + * 全局气泡上下文API + * 提供全局访问气泡上下文的能力,用于跨组件通信 + */ export const globalBubbleContextApi = shallowRef(); +/** + * 气泡上下文提供者Hook + * 向子组件提供气泡上下文,并同步更新全局API + * + * @param value - 要提供的上下文值(计算属性) + */ export const useBubbleContextProvider = (value: ComputedRef) => { + // 向子组件提供上下文 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(), }, setup(props, { slots }) { + // 使用计算属性包装props.value,确保响应式 useBubbleContextProvider(computed(() => props.value)); + + // 渲染默认插槽内容 return () => { return slots.default?.(); }; diff --git a/src/components/xt-chat/xt-bubble/hooks/useDisplayData.ts b/src/components/xt-chat/xt-bubble/hooks/useDisplayData.ts new file mode 100644 index 0000000..932eef6 --- /dev/null +++ b/src/components/xt-chat/xt-bubble/hooks/useDisplayData.ts @@ -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, (value: string | number) => void]; + +/** + * 数据显示逻辑Hook + * 实现气泡列表的渐进式显示,支持打字效果和动态加载 + * + * @param items - 完整的数据列表引用 + * @returns [displayList, onTypingComplete] - 当前显示列表和打字完成回调 + */ +export default function useDisplayData(items: Ref): 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; +} diff --git a/src/components/xt-chat/xt-bubble/hooks/useListData.ts b/src/components/xt-chat/xt-bubble/hooks/useListData.ts new file mode 100644 index 0000000..2c59520 --- /dev/null +++ b/src/components/xt-chat/xt-bubble/hooks/useListData.ts @@ -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 ? R : never; + +/** + * 列表项类型定义 + * 从useListData返回值中提取单个列表项的类型 + */ +export type ListItemType = UnRef>[number]; + +/** + * 气泡列表数据处理Hook + * 处理原始气泡数据,应用角色配置,生成最终的气泡属性 + * + * @param items - 原始气泡数据列表 + * @param roles - 角色配置,可以是对象或函数 + * @returns 处理后的气泡数据列表 + */ +export default function useListData( + items: Ref, + roles?: Ref, +) { + /** + * 获取角色对应的气泡属性配置 + * 根据气泡的角色类型,返回对应的样式和属性配置 + * + * @param bubble - 气泡数据 + * @param index - 气泡在列表中的索引 + * @returns 角色对应的气泡属性 + */ + const getRoleBubbleProps = (bubble: BubbleDataType, index: number): Partial => { + // 如果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; +} \ No newline at end of file diff --git a/src/components/xt-chat/Bubble/hooks/useTypedEffect.ts b/src/components/xt-chat/xt-bubble/hooks/useTypedEffect.ts similarity index 57% rename from src/components/xt-chat/Bubble/hooks/useTypedEffect.ts rename to src/components/xt-chat/xt-bubble/hooks/useTypedEffect.ts index 1cbe4b9..baf54f6 100644 --- a/src/components/xt-chat/Bubble/hooks/useTypedEffect.ts +++ b/src/components/xt-chat/xt-bubble/hooks/useTypedEffect.ts @@ -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, @@ -21,20 +35,27 @@ const useTypedEffect = ( typingStep: Ref, typingInterval: Ref, ): [typedContent: Ref, isTyping: Ref] => { + // 记录上一次的内容,用于检测内容变化 const [prevContent, setPrevContent] = useState(''); + // 当前打字位置(已显示的字符数) const [typingIndex, setTypingIndex] = useState(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), ]; }; diff --git a/src/components/xt-chat/xt-bubble/hooks/useTypingConfig.ts b/src/components/xt-chat/xt-bubble/hooks/useTypingConfig.ts new file mode 100644 index 0000000..ecdcc8d --- /dev/null +++ b/src/components/xt-chat/xt-bubble/hooks/useTypingConfig.ts @@ -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) { + /** + * 计算是否启用打字效果 + * 只有当typing为真值时才启用打字效果 + */ + const typingEnabled = computed(() => { + if (!toValue(typing)) { + return false; + } + return true; + }); + + /** + * 基础配置:提供默认的打字参数 + * 当用户没有提供完整配置时,使用这些默认值 + */ + const baseConfig: Required = { + 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; \ No newline at end of file diff --git a/src/components/xt-chat/xt-bubble/index.ts b/src/components/xt-chat/xt-bubble/index.ts new file mode 100644 index 0000000..75d4bce --- /dev/null +++ b/src/components/xt-chat/xt-bubble/index.ts @@ -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'; + + diff --git a/src/components/xt-chat/xt-bubble/loading.vue b/src/components/xt-chat/xt-bubble/loading.vue new file mode 100644 index 0000000..1a8e64f --- /dev/null +++ b/src/components/xt-chat/xt-bubble/loading.vue @@ -0,0 +1,66 @@ + + + + diff --git a/src/components/xt-chat/xt-bubble/style.scss b/src/components/xt-chat/xt-bubble/style.scss new file mode 100644 index 0000000..cf36a5f --- /dev/null +++ b/src/components/xt-chat/xt-bubble/style.scss @@ -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; + } +} diff --git a/src/components/xt-chat/xt-bubble/types.ts b/src/components/xt-chat/xt-bubble/types.ts new file mode 100644 index 0000000..4c3f96e --- /dev/null +++ b/src/components/xt-chat/xt-bubble/types.ts @@ -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; + +/** + * 打字效果配置选项 + * 控制消息的打字动画效果 + */ +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 | 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 extends /* @vue-ignore */ Omit { + /** 组件前缀类名 */ + prefixCls?: string; + /** 根元素的自定义类名 */ + rootClassName?: string; + /** 各部分的样式配置 */ + styles?: Partial>; + /** 各部分的类名配置 */ + classNames?: Partial>; + /** 头像配置:可以是属性对象、VNode或渲染函数 */ + avatar?: Partial<_AvatarProps> | VNode | (() => VNode); + /** 气泡位置:start(左侧) | end(右侧) */ + placement?: 'start' | 'end'; + /** 是否显示加载状态 */ + loading?: boolean; + /** 打字效果配置:可以是配置对象或布尔值 */ + typing?: AvoidValidation; + /** 气泡内容 */ + 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)>; + /** 底部内容:可以是VNode、字符串或渲染函数 */ + footer?: AvoidValidation 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 & { + /** 气泡的唯一标识键 */ + key?: string | number; + /** 气泡的角色类型 */ + role?: string; +}; + +/** + * 角色类型 + * 定义不同角色的气泡样式配置 + */ +export type RoleType = Partial, 'content'>>; + +/** + * 角色配置类型 + * 可以是角色配置对象或根据气泡数据和索引返回角色配置的函数 + */ +export type RolesType = Record | ((bubbleDataP: BubbleDataType, index: number) => RoleType); + +/** + * 气泡列表属性接口 + * 定义气泡列表组件的所有可配置属性 + */ +export interface BubbleListProps extends /* @vue-ignore */ HTMLAttributes { + /** 组件前缀类名 */ + prefixCls?: string; + /** 根元素的自定义类名 */ + rootClassName?: string; + /** 气泡数据数组 */ + items?: BubbleDataType[]; + /** 是否自动滚动到最新消息 */ + autoScroll?: boolean; + /** 角色配置:定义不同角色的气泡样式 */ + roles?: AvoidValidation; +} \ No newline at end of file diff --git a/src/components/xt-chat/Bubble/Bubble.vue b/src/components/xt-chat/xt-bubble/xt-bubble.vue similarity index 89% rename from src/components/xt-chat/Bubble/Bubble.vue rename to src/components/xt-chat/xt-bubble/xt-bubble.vue index 3ff5f5a..43e08c8 100644 --- a/src/components/xt-chat/Bubble/Bubble.vue +++ b/src/components/xt-chat/xt-bubble/xt-bubble.vue @@ -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?.() ?? ; + if (slots.loading) { + return slots.loading(); + } + return props.loadingRender ? props.loadingRender() : ; } + return ( <> {mergedContent.value as any} @@ -96,7 +98,7 @@ export default defineComponent({ }; return () => ( -
+
{(slots.avatar || props.avatar) && (
{avatarNode.value as any} diff --git a/src/components/xt-chat/Bubble/BubbleList.vue b/src/components/xt-chat/xt-bubble/xt-bubbleList.vue similarity index 96% rename from src/components/xt-chat/Bubble/BubbleList.vue rename to src/components/xt-chat/xt-bubble/xt-bubbleList.vue index 51943d4..00676c0 100644 --- a/src/components/xt-chat/Bubble/BubbleList.vue +++ b/src/components/xt-chat/xt-bubble/xt-bubbleList.vue @@ -1,5 +1,5 @@