feat: bubble组件封装

This commit is contained in:
renxiaodong
2025-08-21 00:24:36 +08:00
parent 6e3158cdb4
commit 64621d9add
17 changed files with 911 additions and 5 deletions

View 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>

View 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
-->

View 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;

View 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;
}

View 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[]>;
}

View 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;

View 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;

View 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';

View File

@ -0,0 +1,4 @@
<!--
* @Author: RenXiaoDong
* @Date: 2025-08-20 22:04:55
-->

View 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>

View 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;
}

View 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>;
}

View 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
View 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
View 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
View 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 };
}

View File

@ -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,6 +28,7 @@ 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();
@ -35,7 +36,7 @@ export default {
conversationList.value.push({ conversationList.value.push({
id: tempId, id: tempId,
loading: true, loading: true,
role: ANSWER_ROLE role: ANSWER_ROLE,
}); });
setTimeout(() => { setTimeout(() => {