feat: bubble组件封装
This commit is contained in:
@ -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;
|
|
||||||
}
|
|
||||||
@ -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[]>;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
@ -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';
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
<!--
|
|
||||||
* @Author: RenXiaoDong
|
|
||||||
* @Date: 2025-08-20 22:04:55
|
|
||||||
-->
|
|
||||||
@ -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>
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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>;
|
|
||||||
}
|
|
||||||
@ -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?.();
|
||||||
};
|
};
|
||||||
87
src/components/xt-chat/xt-bubble/hooks/useDisplayData.ts
Normal file
87
src/components/xt-chat/xt-bubble/hooks/useDisplayData.ts
Normal 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;
|
||||||
|
}
|
||||||
79
src/components/xt-chat/xt-bubble/hooks/useListData.ts
Normal file
79
src/components/xt-chat/xt-bubble/hooks/useListData.ts
Normal 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[]>;
|
||||||
|
}
|
||||||
@ -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),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
61
src/components/xt-chat/xt-bubble/hooks/useTypingConfig.ts
Normal file
61
src/components/xt-chat/xt-bubble/hooks/useTypingConfig.ts
Normal 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;
|
||||||
9
src/components/xt-chat/xt-bubble/index.ts
Normal file
9
src/components/xt-chat/xt-bubble/index.ts
Normal 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';
|
||||||
|
|
||||||
|
|
||||||
66
src/components/xt-chat/xt-bubble/loading.vue
Normal file
66
src/components/xt-chat/xt-bubble/loading.vue
Normal 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>
|
||||||
94
src/components/xt-chat/xt-bubble/style.scss
Normal file
94
src/components/xt-chat/xt-bubble/style.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/components/xt-chat/xt-bubble/types.ts
Normal file
186
src/components/xt-chat/xt-bubble/types.ts
Normal 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>;
|
||||||
|
}
|
||||||
@ -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}
|
||||||
@ -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
|
|
||||||
-->
|
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -11,4 +11,3 @@
|
|||||||
@import "./steps.scss";
|
@import "./steps.scss";
|
||||||
@import "./form.scss";
|
@import "./form.scss";
|
||||||
@import "./chat-sender.scss";
|
@import "./chat-sender.scss";
|
||||||
@import "./chat-bubble.scss";
|
|
||||||
|
|||||||
@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user