Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add new API components (ApiCascader, ApiCheckbox, ApiRadio, ApiTransfer) and update imports #5095

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# 创建 api-cascader.vue 文件
<script lang="ts" setup>
import type { AnyPromiseFunction } from '@vben/types';

import { computed, ref, unref, useAttrs, type VNode, watch } from 'vue';

import { LoaderCircle } from '@vben/icons';
import { get, isEqual, isFunction } from '@vben-core/shared/utils';

import { objectOmit } from '@vueuse/core';

type OptionsItem = {
[name: string]: any;
children?: OptionsItem[];
disabled?: boolean;
label?: string;
value?: number | string;
};

interface Props {
/** 组件 */
component: VNode;
/** 是否将value从数字转为string */
numberToString?: boolean;
/** 获取options数据的函数 */
api?: (arg?: any) => Promise<OptionsItem[] | Record<string, any>>;
/** 传递给api的参数 */
params?: Record<string, any>;
/** 从api返回的结果中提取options数组的字段名 */
resultField?: string;
/** label字段名 */
labelField?: string;
/** children字段名 */
childrenField?: string;
/** value字段名 */
valueField?: string;
/** 组件接收options数据的属性名 */
optionsPropName?: string;
/** 是否立即调用api */
immediate?: boolean;
/** 每次`visibleEvent`事件发生时都重新请求数据 */
alwaysLoad?: boolean;
/** 在api请求之前的回调函数 */
beforeFetch?: AnyPromiseFunction<any, any>;
/** 在api请求之后的回调函数 */
afterFetch?: AnyPromiseFunction<any, any>;
/** 直接传入选项数据,也作为api返回空数据时的后备数据 */
options?: OptionsItem[];
/** 组件的插槽名称,用来显示一个"加载中"的图标 */
loadingSlot?: string;
/** 触发api请求的事件名 */
visibleEvent?: string;
/** 组件的v-model属性名,默认为modelValue */
modelPropName?: string;
}

defineOptions({ name: 'ApiCascader', inheritAttrs: false });

const props = withDefaults(defineProps<Props>(), {
labelField: 'label',
valueField: 'value',
childrenField: 'children',
optionsPropName: 'options',
resultField: '',
visibleEvent: '',
numberToString: false,
params: () => ({}),
immediate: true,
alwaysLoad: false,
loadingSlot: '',
beforeFetch: undefined,
afterFetch: undefined,
modelPropName: 'modelValue',
api: undefined,
options: () => [],
});

const emit = defineEmits<{
optionsChange: [OptionsItem[]];
}>();

const modelValue = defineModel({ default: [] });

const attrs = useAttrs();

const refOptions = ref<OptionsItem[]>([]);
const loading = ref(false);
// 首次是否加载过了
const isFirstLoaded = ref(false);

const getOptions = computed(() => {
const { labelField, valueField, childrenField, numberToString } = props;

const refOptionsData = unref(refOptions);

function transformData(data: OptionsItem[]): OptionsItem[] {
return data.map((item) => {
const value = get(item, valueField);
return {
...objectOmit(item, [labelField, valueField, childrenField]),
label: get(item, labelField),
value: numberToString ? `${value}` : value,
...(childrenField && item[childrenField]
? { children: transformData(item[childrenField]) }
: {}),
};
});
}

const data: OptionsItem[] = transformData(refOptionsData);

return data.length > 0 ? data : props.options;
});

const bindProps = computed(() => {
return {
[props.modelPropName]: unref(modelValue),
[props.optionsPropName]: unref(getOptions),
[`onUpdate:${props.modelPropName}`]: (val: any[]) => {
modelValue.value = val;
},
...objectOmit(attrs, ['onUpdate:value']),
...(props.visibleEvent
? {
[props.visibleEvent]: handleFetchForVisible,
}
: {}),
};
});

async function fetchApi() {
let { api, beforeFetch, afterFetch, params, resultField } = props;

if (!api || !isFunction(api) || loading.value) {
return;
}
refOptions.value = [];
try {
loading.value = true;
if (beforeFetch && isFunction(beforeFetch)) {
params = (await beforeFetch(params)) || params;
}
let res = await api(params);
if (afterFetch && isFunction(afterFetch)) {
res = (await afterFetch(res)) || res;
}
isFirstLoaded.value = true;
if (Array.isArray(res)) {
refOptions.value = res;
emitChange();
return;
}
if (resultField) {
refOptions.value = get(res, resultField) || [];
}
emitChange();
} catch (error) {
console.warn(error);
// reset status
isFirstLoaded.value = false;
} finally {
loading.value = false;
}
}

async function handleFetchForVisible(visible: boolean) {
if (visible) {
if (props.alwaysLoad) {
await fetchApi();
} else if (!props.immediate && !unref(isFirstLoaded)) {
await fetchApi();
}
}
}

watch(
() => props.params,
(value, oldValue) => {
if (isEqual(value, oldValue)) {
return;
}
fetchApi();
},
{ deep: true, immediate: props.immediate },
);

function emitChange() {
emit('optionsChange', unref(getOptions));
}
</script>
<template>
<div v-bind="{ ...$attrs }">
<component
:is="component"
v-bind="bindProps"
:placeholder="$attrs.placeholder"
>
<template v-for="item in Object.keys($slots)" #[item]="data">
<slot :name="item" v-bind="data || {}"></slot>
</template>
<template v-if="loadingSlot && loading" #[loadingSlot]>
<LoaderCircle class="animate-spin" />
</template>
</component>
</div>
</template>
Comment on lines +1 to +206
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Refactor Shared Functionality into a Composable

There is significant overlap in logic with other components. Abstracting shared functionalities into a composable will improve code maintainability and reduce redundancy.

Example:

export function useApiOptions(props) {
  // Shared logic for fetching and transforming options
}

Use in your component:

const { refOptions, getOptions, fetchApi } = useApiOptions(props);

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ApiCascader } from './api-cascader.vue';
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<script lang="ts" setup>
import type { AnyPromiseFunction } from '@vben/types';

import { computed, ref, unref, useAttrs, type VNode, watch } from 'vue';

import { get, isEqual, isFunction } from '@vben-core/shared/utils';

import { objectOmit } from '@vueuse/core';

type OptionsItem = {
[name: string]: any;
disabled?: boolean;
label: string;
value: number | string;
};

interface Props {
/** 组件 */
component: VNode;
/** 是否将value从数字转为string */
numberToString?: boolean;
/** 获取options数据的函数 */
api?: (arg?: any) => Promise<OptionsItem[] | Record<string, any>>;
/** 传递给api的参数 */
params?: Record<string, any>;
/** 从api返回的结果中提取options数组的字段名 */
resultField?: string;
/** label字段名 */
labelField?: string;
/** value字段名 */
valueField?: string;
/** 组件接收options数据的属性名 */
optionsPropName?: string;
/** 是否立即调用api */
immediate?: boolean;
/** 每次`visibleEvent`事件发生时都重新请求数据 */
alwaysLoad?: boolean;
/** 在api请求之前的回调函数 */
beforeFetch?: AnyPromiseFunction<any, any>;
/** 在api请求之后的回调函数 */
afterFetch?: AnyPromiseFunction<any, any>;
/** 直接传入选项数据,也作为api返回空数据时的后备数据 */
options?: OptionsItem[];
/** 触发api请求的事件名 */
visibleEvent?: string;
/** 组件的v-model属性名,默认为modelValue。部分组件可能为value */
modelPropName?: string;
}

defineOptions({ name: 'ApiCheckbox', inheritAttrs: false });

const props = withDefaults(defineProps<Props>(), {
labelField: 'label',
valueField: 'value',
optionsPropName: 'options',
resultField: '',
visibleEvent: '',
numberToString: false,
params: () => ({}),
immediate: true,
alwaysLoad: false,
beforeFetch: undefined,
afterFetch: undefined,
modelPropName: 'modelValue',
api: undefined,
options: () => [],
});

const emit = defineEmits<{
optionsChange: [OptionsItem[]];
}>();

const modelValue = defineModel({ default: [] });

const attrs = useAttrs();

const { class: attrsClass, style: attrsStyle }: Record<string, any> = attrs;

const refOptions = ref<OptionsItem[]>([]);
// 首次是否加载过了
const isFirstLoaded = ref(false);

const getOptions = computed(() => {
const { labelField, valueField, numberToString } = props;

const refOptionsData = unref(refOptions);

function transformData(data: OptionsItem[]): OptionsItem[] {
return data.map((item) => {
const value = get(item, valueField);
return {
...objectOmit(item, [labelField, valueField]),
label: get(item, labelField),
value: numberToString ? `${value}` : value,
};
});
}

const data: OptionsItem[] = transformData(refOptionsData);

return data.length > 0 ? data : props.options;
});

const bindProps = computed(() => {
return {
[props.modelPropName]: unref(modelValue),
[props.optionsPropName]: unref(getOptions),
[`onUpdate:${props.modelPropName}`]: (val: []) => {
modelValue.value = val;
},
...objectOmit(attrs, ['onUpdate:value']),
...(props.visibleEvent
? {
[props.visibleEvent]: handleFetchForVisible,
}
: {}),
};
});

async function fetchApi() {
let { api, beforeFetch, afterFetch, params, resultField } = props;

if (!api || !isFunction(api)) {
return;
}
refOptions.value = [];
try {
if (beforeFetch && isFunction(beforeFetch)) {
params = (await beforeFetch(params)) || params;
}
let res = await api(params);
if (afterFetch && isFunction(afterFetch)) {
res = (await afterFetch(res)) || res;
}
isFirstLoaded.value = true;
if (Array.isArray(res)) {
refOptions.value = res;
emitChange();
return;
}
if (resultField) {
refOptions.value = get(res, resultField) || [];
}
emitChange();
} catch (error) {
console.warn(error);
// reset status
isFirstLoaded.value = false;
}
}

async function handleFetchForVisible(visible: boolean) {
if (visible) {
if (props.alwaysLoad) {
await fetchApi();
} else if (!props.immediate && !unref(isFirstLoaded)) {
await fetchApi();
}
}
}

watch(
() => props.params,
(value, oldValue) => {
if (isEqual(value, oldValue)) {
return;
}
fetchApi();
},
{ deep: true, immediate: props.immediate },
);

function emitChange() {
emit('optionsChange', unref(getOptions));
}
</script>
<template>
<div :class="attrsClass" :style="attrsStyle">
<component :is="component" v-bind="bindProps">
<template v-for="item in Object.keys($slots)" #[item]="data">
<slot :name="item" v-bind="data || {}"></slot>
</template>
</component>
</div>
</template>
Comment on lines +1 to +185
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Abstract Shared Logic to Reduce Duplication

The component shares similar logic with other components like ApiRadio, ApiCascader, and ApiTransfer. Refactoring shared code into a reusable composable can enhance maintainability and reduce duplication.

Example:

// useApiOptions.ts
export function useApiOptions(props) {
  // Shared logic here
}

Usage in the component:

const { refOptions, getOptions, fetchApi } = useApiOptions(props);

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ApiCheckbox } from './api-checkbox.vue';
Loading
Loading