feat: bubble组件封装
This commit is contained in:
135
src/components/xt-chat/Bubble/Bubble.vue
Normal file
135
src/components/xt-chat/Bubble/Bubble.vue
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
<script lang="tsx">
|
||||||
|
import { Avatar } from 'ant-design-vue';
|
||||||
|
import { computed, defineComponent, ref, toRef, unref, watch, watchEffect } from 'vue';
|
||||||
|
import Loading from './loading.vue';
|
||||||
|
import useTypingConfig from './hooks/useTypingConfig';
|
||||||
|
import useTypedEffect from './hooks/useTypedEffect';
|
||||||
|
import { useBubbleContextInject } from './context';
|
||||||
|
import type { BubbleProps, BubbleContentType, SlotInfoType } from './types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'Bubble',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {} as any,
|
||||||
|
setup(_, { attrs, slots }) {
|
||||||
|
const props = attrs as unknown as BubbleProps<BubbleContentType> & { style?: any; class?: any };
|
||||||
|
|
||||||
|
const content = ref<BubbleContentType>(props.content ?? '');
|
||||||
|
watch(
|
||||||
|
() => props.content,
|
||||||
|
(val) => {
|
||||||
|
content.value = val as any;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { onUpdate } = unref(useBubbleContextInject());
|
||||||
|
|
||||||
|
const { typing: typingProp, loading } = props;
|
||||||
|
const [typingEnabled, typingStep, typingInterval, typingSuffix] = useTypingConfig(() => typingProp);
|
||||||
|
const [typedContent, isTyping] = useTypedEffect(content as any, typingEnabled, typingStep, typingInterval);
|
||||||
|
|
||||||
|
const triggerTypingCompleteRef = ref(false);
|
||||||
|
watch(typedContent, () => {
|
||||||
|
onUpdate?.();
|
||||||
|
});
|
||||||
|
watchEffect(() => {
|
||||||
|
if (!isTyping.value && !loading) {
|
||||||
|
if (!triggerTypingCompleteRef.value) {
|
||||||
|
triggerTypingCompleteRef.value = true;
|
||||||
|
props.onTypingComplete?.();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
triggerTypingCompleteRef.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const prefixCls = 'xt-bubble';
|
||||||
|
const mergedCls = computed(() => [
|
||||||
|
prefixCls,
|
||||||
|
`${prefixCls}-${props.placement ?? 'start'}`,
|
||||||
|
props.classNames?.root,
|
||||||
|
props.class,
|
||||||
|
{
|
||||||
|
[`${prefixCls}-typing`]:
|
||||||
|
isTyping.value && !loading && !props.messageRender && !slots.message && !typingSuffix.value,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const avatarNode = computed(() => {
|
||||||
|
if (slots.avatar) return slots.avatar();
|
||||||
|
const avatar = props.avatar as any;
|
||||||
|
return typeof avatar === 'function' ? avatar() : avatar && avatar.src ? <Avatar {...avatar} /> : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedContent = computed(() => {
|
||||||
|
if (slots.message) return slots.message({ content: typedContent.value as any }) as any;
|
||||||
|
return props.messageRender
|
||||||
|
? (props.messageRender(typedContent.value as any) as any)
|
||||||
|
: (typedContent.value as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentNode = computed(() => {
|
||||||
|
if (props.loading) {
|
||||||
|
return slots.loading?.() ?? props.loadingRender?.() ?? <Loading prefixCls={prefixCls} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mergedContent.value as any}
|
||||||
|
{isTyping.value && unref(typingSuffix)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderHeader = () => {
|
||||||
|
const info: SlotInfoType = { key: props._key };
|
||||||
|
if (slots.header) return slots.header({ content: typedContent.value as any, info }) as any;
|
||||||
|
const h = props.header as any;
|
||||||
|
return typeof h === 'function' ? h(typedContent.value as any, info) : h;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
const info: SlotInfoType = { key: props._key };
|
||||||
|
if (slots.footer) return slots.footer({ content: typedContent.value as any, info }) as any;
|
||||||
|
const f = props.footer as any;
|
||||||
|
return typeof f === 'function' ? f(typedContent.value as any, info) : f;
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class={mergedCls.value} style={{ ...(props.styles?.root || {}), ...(props.style || {}) }}>
|
||||||
|
{(slots.avatar || props.avatar) && (
|
||||||
|
<div class={[`${prefixCls}-avatar`, props.classNames?.avatar]} style={props.styles?.avatar}>
|
||||||
|
{avatarNode.value as any}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
`${prefixCls}-content`,
|
||||||
|
`${prefixCls}-content-${props.variant ?? 'filled'}`,
|
||||||
|
{ [`${prefixCls}-content-${props.shape}`]: props.shape },
|
||||||
|
props.classNames?.content,
|
||||||
|
]}
|
||||||
|
style={props.styles?.content}
|
||||||
|
>
|
||||||
|
{renderHeader() ? (
|
||||||
|
<div class={[`${prefixCls}-header`, props.classNames?.header]} style={props.styles?.header}>
|
||||||
|
{renderHeader()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{contentNode.value as any}
|
||||||
|
{renderFooter() ? (
|
||||||
|
<div class={[`${prefixCls}-footer`, props.classNames?.footer]} style={props.styles?.footer}>
|
||||||
|
{renderFooter()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import './style.scss';
|
||||||
|
</style>
|
||||||
159
src/components/xt-chat/Bubble/BubbleList.vue
Normal file
159
src/components/xt-chat/Bubble/BubbleList.vue
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
<script lang="tsx">
|
||||||
|
import Bubble from './Bubble.vue';
|
||||||
|
import BubbleContextProvider from './context';
|
||||||
|
import useDisplayData from './hooks/useDisplayData';
|
||||||
|
import useListData from './hooks/useListData';
|
||||||
|
import useState from '@/hooks/useState';
|
||||||
|
import pickAttrs from '@/utils/pick-attrs';
|
||||||
|
import { useEventCallback } from '@/hooks/useEventCallback';
|
||||||
|
import type { BubbleListProps, BubbleRef, RolesType } from './types';
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
defineComponent,
|
||||||
|
mergeProps,
|
||||||
|
nextTick,
|
||||||
|
onWatcherCleanup,
|
||||||
|
ref,
|
||||||
|
unref,
|
||||||
|
useAttrs,
|
||||||
|
watch,
|
||||||
|
watchPostEffect,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'BubbleList',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {} as any,
|
||||||
|
setup(_, { attrs, slots }) {
|
||||||
|
const props = attrs as unknown as BubbleListProps & { class?: any; style?: any };
|
||||||
|
const passThroughAttrs = useAttrs();
|
||||||
|
|
||||||
|
const TOLERANCE = 1;
|
||||||
|
|
||||||
|
const domProps = computed(() => pickAttrs(mergeProps(props as any, passThroughAttrs)) as any);
|
||||||
|
|
||||||
|
const items = ref(props.items);
|
||||||
|
const roles = ref(props.roles as RolesType);
|
||||||
|
watch(
|
||||||
|
() => props.items,
|
||||||
|
(val) => (items.value = val),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => props.roles,
|
||||||
|
(val) => (roles.value = val as RolesType),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const listRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const bubbleRefs = ref<Record<string | number, BubbleRef>>({});
|
||||||
|
|
||||||
|
const prefixCls = 'xt-bubble';
|
||||||
|
const listPrefixCls = `${prefixCls}-list`;
|
||||||
|
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
watchPostEffect(() => {
|
||||||
|
setInitialized(true);
|
||||||
|
onWatcherCleanup(() => setInitialized(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
// data
|
||||||
|
const mergedData = useListData(items as any, roles as any);
|
||||||
|
const [displayData, onTypingComplete] = useDisplayData(mergedData);
|
||||||
|
|
||||||
|
// scroll
|
||||||
|
const [scrollReachEnd, setScrollReachEnd] = useState(true);
|
||||||
|
const [updateCount, setUpdateCount] = useState(0);
|
||||||
|
|
||||||
|
const onInternalScroll = (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
setScrollReachEnd(target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight <= TOLERANCE);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(updateCount, () => {
|
||||||
|
if ((props.autoScroll ?? true) && unref(listRef) && unref(scrollReachEnd)) {
|
||||||
|
nextTick(() => {
|
||||||
|
unref(listRef)!.scrollTo({ top: unref(listRef)!.scrollHeight });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => unref(displayData).length,
|
||||||
|
() => {
|
||||||
|
if (props.autoScroll ?? true) {
|
||||||
|
const lastItemKey = unref(displayData)[unref(displayData).length - 2]?.key;
|
||||||
|
const bubbleInst = unref(bubbleRefs)[lastItemKey!];
|
||||||
|
if (bubbleInst) {
|
||||||
|
const { nativeElement } = bubbleInst;
|
||||||
|
const { top = 0, bottom = 0 } = nativeElement?.getBoundingClientRect() ?? {};
|
||||||
|
const { top: listTop, bottom: listBottom } = unref(listRef)!.getBoundingClientRect();
|
||||||
|
const isVisible = top < listBottom && bottom > listTop;
|
||||||
|
if (isVisible) {
|
||||||
|
setUpdateCount(unref(updateCount) + 1);
|
||||||
|
setScrollReachEnd(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onBubbleUpdate = useEventCallback<void>(() => {
|
||||||
|
if (props.autoScroll ?? true) setUpdateCount(unref(updateCount) + 1);
|
||||||
|
});
|
||||||
|
const context = computed(() => ({ onUpdate: onBubbleUpdate }));
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<BubbleContextProvider value={context.value}>
|
||||||
|
<div
|
||||||
|
{...domProps.value}
|
||||||
|
class={[
|
||||||
|
listPrefixCls,
|
||||||
|
props.rootClassName,
|
||||||
|
props.class,
|
||||||
|
{ [`${listPrefixCls}-reach-end`]: unref(scrollReachEnd) },
|
||||||
|
]}
|
||||||
|
style={props.style as any}
|
||||||
|
ref={listRef}
|
||||||
|
onScroll={onInternalScroll}
|
||||||
|
>
|
||||||
|
{unref(displayData).map(({ key, onTypingComplete: onTypingCompleteBubble, ...bubble }: any) => (
|
||||||
|
<Bubble
|
||||||
|
{...bubble}
|
||||||
|
avatar={slots.avatar ? () => slots.avatar?.({ item: { key, ...bubble } }) : (bubble as any).avatar}
|
||||||
|
header={slots.header?.({ item: { key, ...bubble } }) ?? (bubble as any).header}
|
||||||
|
footer={slots.footer?.({ item: { key, ...bubble } }) ?? (bubble as any).footer}
|
||||||
|
loadingRender={
|
||||||
|
slots.loading ? () => slots.loading({ item: { key, ...bubble } }) : (bubble as any).loadingRender
|
||||||
|
}
|
||||||
|
content={slots.message?.({ item: { key, ...bubble } }) ?? (bubble as any).content}
|
||||||
|
key={key}
|
||||||
|
ref={(node: any) => {
|
||||||
|
if (node) {
|
||||||
|
bubbleRefs.value[key] = node as BubbleRef;
|
||||||
|
} else {
|
||||||
|
delete bubbleRefs.value[key];
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
typing={unref(initialized) ? (bubble as any).typing : false}
|
||||||
|
onTypingComplete={() => {
|
||||||
|
onTypingCompleteBubble?.();
|
||||||
|
onTypingComplete(key);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</BubbleContextProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import './style.scss';
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
* @Author: RenXiaoDong
|
||||||
|
* @Date: 2025-08-20 22:39:35
|
||||||
|
-->
|
||||||
45
src/components/xt-chat/Bubble/context.ts
Normal file
45
src/components/xt-chat/Bubble/context.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* @Author: RenXiaoDong
|
||||||
|
* @Date: 2025-08-20 23:17:49
|
||||||
|
*/
|
||||||
|
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";
|
||||||
|
|
||||||
|
const BubbleContextKey: InjectionKey<ComputedRef<BubbleContextProps>> =
|
||||||
|
Symbol('BubbleContext');
|
||||||
|
|
||||||
|
export const globalBubbleContextApi = shallowRef<BubbleContextProps>();
|
||||||
|
|
||||||
|
export const useBubbleContextProvider = (value: ComputedRef<BubbleContextProps>) => {
|
||||||
|
provide(BubbleContextKey, value);
|
||||||
|
watch(
|
||||||
|
value,
|
||||||
|
() => {
|
||||||
|
globalBubbleContextApi.value = unref(value);
|
||||||
|
triggerRef(globalBubbleContextApi);
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBubbleContextInject = () => {
|
||||||
|
return inject(
|
||||||
|
BubbleContextKey,
|
||||||
|
computed(() => globalBubbleContextApi.value || {}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const BubbleContextProvider = defineComponent({
|
||||||
|
props: {
|
||||||
|
value: objectType<BubbleContextProps>(),
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
useBubbleContextProvider(computed(() => props.value));
|
||||||
|
return () => {
|
||||||
|
return slots.default?.();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default BubbleContextProvider;
|
||||||
58
src/components/xt-chat/Bubble/hooks/useDisplayData.ts
Normal file
58
src/components/xt-chat/Bubble/hooks/useDisplayData.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
41
src/components/xt-chat/Bubble/hooks/useListData.ts
Normal file
41
src/components/xt-chat/Bubble/hooks/useListData.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* @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[]>;
|
||||||
|
}
|
||||||
75
src/components/xt-chat/Bubble/hooks/useTypedEffect.ts
Normal file
75
src/components/xt-chat/Bubble/hooks/useTypedEffect.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* @Author: RenXiaoDong
|
||||||
|
* @Date: 2025-08-20 23:26:23
|
||||||
|
*/
|
||||||
|
import useState from '@/hooks/useState';
|
||||||
|
import { computed, onWatcherCleanup, unref, watch } from 'vue';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import type { BubbleContentType } from '../types';
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
const useTypedEffect = (
|
||||||
|
content: Ref<BubbleContentType>,
|
||||||
|
typingEnabled: Ref<boolean>,
|
||||||
|
typingStep: Ref<number>,
|
||||||
|
typingInterval: Ref<number>,
|
||||||
|
): [typedContent: Ref<BubbleContentType>, isTyping: Ref<boolean>] => {
|
||||||
|
const [prevContent, setPrevContent] = useState<BubbleContentType>('');
|
||||||
|
const [typingIndex, setTypingIndex] = useState<number>(1);
|
||||||
|
|
||||||
|
const mergedTypingEnabled = computed(() => typingEnabled.value && isString(content.value));
|
||||||
|
|
||||||
|
// 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
|
||||||
|
) {
|
||||||
|
setTypingIndex(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedTypingContent = computed(() =>
|
||||||
|
mergedTypingEnabled.value && isString(content.value) ? content.value.slice(0, unref(typingIndex)) : content.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
mergedTypingContent,
|
||||||
|
computed(() => mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useTypedEffect;
|
||||||
37
src/components/xt-chat/Bubble/hooks/useTypingConfig.ts
Normal file
37
src/components/xt-chat/Bubble/hooks/useTypingConfig.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* @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;
|
||||||
9
src/components/xt-chat/Bubble/index.ts
Normal file
9
src/components/xt-chat/Bubble/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
* @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';
|
||||||
|
|
||||||
|
|
||||||
4
src/components/xt-chat/Bubble/index.vue
Normal file
4
src/components/xt-chat/Bubble/index.vue
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<!--
|
||||||
|
* @Author: RenXiaoDong
|
||||||
|
* @Date: 2025-08-20 22:04:55
|
||||||
|
-->
|
||||||
29
src/components/xt-chat/Bubble/loading.vue
Normal file
29
src/components/xt-chat/Bubble/loading.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<!--
|
||||||
|
* @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>
|
||||||
94
src/components/xt-chat/Bubble/style.scss
Normal file
94
src/components/xt-chat/Bubble/style.scss
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
90
src/components/xt-chat/Bubble/types.ts
Normal file
90
src/components/xt-chat/Bubble/types.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* @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>;
|
||||||
|
}
|
||||||
15
src/hooks/useEventCallback.ts
Normal file
15
src/hooks/useEventCallback.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
* @Author: RenXiaoDong
|
||||||
|
* @Date: 2025-08-20 23:12:14
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export function useEventCallback<T>(handler?: (value: T) => void): (value: T) => void {
|
||||||
|
const callbackRef = ref(handler);
|
||||||
|
const fn = ref((value: T) => {
|
||||||
|
callbackRef.value && callbackRef.value(value);
|
||||||
|
});
|
||||||
|
callbackRef.value = handler;
|
||||||
|
|
||||||
|
return fn.value;
|
||||||
|
}
|
||||||
21
src/hooks/useState.ts
Normal file
21
src/hooks/useState.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* @Author: RenXiaoDong
|
||||||
|
* @Date: 2025-08-20 23:14:22
|
||||||
|
*/
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export default function useState<T, R = Ref<T>>(
|
||||||
|
defaultStateValue?: T | (() => T),
|
||||||
|
): [R, (val: T) => void] {
|
||||||
|
const initValue: T =
|
||||||
|
typeof defaultStateValue === 'function' ? (defaultStateValue as any)() : defaultStateValue;
|
||||||
|
|
||||||
|
const innerValue = ref(initValue) as Ref<T>;
|
||||||
|
|
||||||
|
function triggerChange(newValue: T) {
|
||||||
|
innerValue.value = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [innerValue as unknown as R, triggerChange];
|
||||||
|
}
|
||||||
80
src/utils/pick-attrs.ts
Normal file
80
src/utils/pick-attrs.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* @Author: RenXiaoDong
|
||||||
|
* @Date: 2025-08-20 23:43:17
|
||||||
|
*/
|
||||||
|
const attributes = `accept acceptcharset accesskey action allowfullscreen allowtransparency
|
||||||
|
alt async autocomplete autofocus autoplay capture cellpadding cellspacing challenge
|
||||||
|
charset checked classid class colspan cols content contenteditable contextmenu
|
||||||
|
controls coords crossorigin data datetime default defer dir disabled download draggable
|
||||||
|
enctype form formaction formenctype formmethod formnovalidate formtarget frameborder
|
||||||
|
headers height hidden high href hreflang htmlfor for httpequiv icon id inputmode integrity
|
||||||
|
is keyparams keytype kind label lang list loop low manifest marginheight marginwidth max maxlength media
|
||||||
|
mediagroup method min minlength multiple muted name novalidate nonce open
|
||||||
|
optimum pattern placeholder poster preload radiogroup readonly rel required
|
||||||
|
reversed role rowspan rows sandbox scope scoped scrolling seamless selected
|
||||||
|
shape size sizes span spellcheck src srcdoc srclang srcset start step style
|
||||||
|
summary tabindex target title type usemap value width wmode wrap`;
|
||||||
|
|
||||||
|
const eventsName = `onCopy onCut onPaste onCompositionend onCompositionstart onCompositionupdate onKeydown
|
||||||
|
onKeypress onKeyup onFocus onBlur onChange onInput onSubmit onClick onContextmenu onDoubleclick onDblclick
|
||||||
|
onDrag onDragend onDragenter onDragexit onDragleave onDragover onDragstart onDrop onMousedown
|
||||||
|
onMouseenter onMouseleave onMousemove onMouseout onMouseover onMouseup onSelect onTouchcancel
|
||||||
|
onTouchend onTouchmove onTouchstart onTouchstartPassive onTouchmovePassive onScroll onWheel onAbort onCanplay onCanplaythrough
|
||||||
|
onDurationchange onEmptied onEncrypted onEnded onError onLoadeddata onLoadedmetadata
|
||||||
|
onLoadstart onPause onPlay onPlaying onProgress onRatechange onSeeked onSeeking onStalled onSuspend onTimeupdate onVolumechange onWaiting onLoad onError`;
|
||||||
|
|
||||||
|
const propList = `${attributes} ${eventsName}`.split(/[\s\n]+/);
|
||||||
|
|
||||||
|
/* eslint-enable max-len */
|
||||||
|
const ariaPrefix = 'aria-';
|
||||||
|
const dataPrefix = 'data-';
|
||||||
|
|
||||||
|
function match(key: string, prefix: string) {
|
||||||
|
return key.indexOf(prefix) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PickConfig {
|
||||||
|
aria?: boolean;
|
||||||
|
data?: boolean;
|
||||||
|
attr?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picker props from exist props with filter
|
||||||
|
* @param props Passed props
|
||||||
|
* @param ariaOnly boolean | { aria?: boolean; data?: boolean; attr?: boolean; } filter config
|
||||||
|
*/
|
||||||
|
export default function pickAttrs(props: object, ariaOnly: boolean | PickConfig = false) {
|
||||||
|
let mergedConfig;
|
||||||
|
if (ariaOnly === false) {
|
||||||
|
mergedConfig = {
|
||||||
|
aria: true,
|
||||||
|
data: true,
|
||||||
|
attr: true,
|
||||||
|
};
|
||||||
|
} else if (ariaOnly === true) {
|
||||||
|
mergedConfig = {
|
||||||
|
aria: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
mergedConfig = {
|
||||||
|
...ariaOnly,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrs = {};
|
||||||
|
Object.keys(props).forEach((key) => {
|
||||||
|
if (
|
||||||
|
// Aria
|
||||||
|
(mergedConfig.aria && (key === 'role' || match(key, ariaPrefix))) ||
|
||||||
|
// Data
|
||||||
|
(mergedConfig.data && match(key, dataPrefix)) ||
|
||||||
|
// Attr
|
||||||
|
(mergedConfig.attr && (propList.includes(key) || propList.includes(key.toLowerCase())))
|
||||||
|
) {
|
||||||
|
// @ts-ignore
|
||||||
|
attrs[key] = props[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
13
src/utils/type.ts
Normal file
13
src/utils/type.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
* @Author: RenXiaoDong
|
||||||
|
* @Date: 2025-08-20 23:18:36
|
||||||
|
*/
|
||||||
|
import type { PropType, VNode } from 'vue';
|
||||||
|
|
||||||
|
declare type VNodeChildAtom = VNode | string | number | boolean | null | undefined | void;
|
||||||
|
|
||||||
|
export type VueNode = VNodeChildAtom | VNodeChildAtom[] | VNode;
|
||||||
|
|
||||||
|
export function objectType<T = {}>(defaultVal?: T) {
|
||||||
|
return { type: Object as PropType<T>, default: defaultVal as T };
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<script lang="tsx">
|
<script lang="tsx">
|
||||||
import { message } from 'ant-design-vue';
|
import { message, Avatar } from 'ant-design-vue';
|
||||||
import { BubbleList } from 'ant-design-x-vue';
|
import { BubbleList } from '@/components/xt-chat/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,16 +28,17 @@ 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();
|
||||||
const tempIndex = conversationList.value.length;
|
const tempIndex = conversationList.value.length;
|
||||||
conversationList.value.push({
|
conversationList.value.push({
|
||||||
id: tempId,
|
id: tempId,
|
||||||
loading: true,
|
loading: true,
|
||||||
role: ANSWER_ROLE
|
role: ANSWER_ROLE,
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const content = `> Render as markdown content to show rich text!
|
const content = `> Render as markdown content to show rich text!
|
||||||
Link: [Ant Design X](https://x.ant.design)
|
Link: [Ant Design X](https://x.ant.design)
|
||||||
|
|||||||
Reference in New Issue
Block a user