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">
|
||||
import { message } from 'ant-design-vue';
|
||||
import { BubbleList } from 'ant-design-x-vue';
|
||||
import { message, Avatar } from 'ant-design-vue';
|
||||
import { BubbleList } from '@/components/xt-chat/bubble';
|
||||
import type { BubbleListProps } from 'ant-design-x-vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import SenderInput from '../sender-input/index.vue';
|
||||
@ -28,6 +28,7 @@ export default {
|
||||
conversationList.value.push({
|
||||
role: QUESTION_ROLE,
|
||||
content: searchValue.value,
|
||||
avatar: () => <Avatar />
|
||||
});
|
||||
|
||||
const tempId = Date.now();
|
||||
@ -35,7 +36,7 @@ export default {
|
||||
conversationList.value.push({
|
||||
id: tempId,
|
||||
loading: true,
|
||||
role: ANSWER_ROLE
|
||||
role: ANSWER_ROLE,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
Reference in New Issue
Block a user