Files
ruoyi-plus-vben5-h/packages/effects/common-ui/src/components/api-component/api-component.vue
Caisin 2b1fcb038b feat(common-ui): add labelFn support to ApiComponent (#7801)
* feat: allow api-component labels to be derived from option data

ApiComponent already normalizes option records into the label/value shape used by
consuming controls, but label text could only come from a single field. Add
labelFn so callers can build labels from the full option record while keeping
labelField as the fallback path.

This keeps the change inside the existing component instead of introducing a
wrapper, and it also normalizes direct options through the same transform path
as API-loaded options for consistent behavior.

Constraint: Must extend the existing ApiComponent API instead of adding a second
Constraint: wrapper component
Rejected: Add a separate ApiLabelComponent wrapper |
Rejected: extra surface area for one option-mapping concern
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep labelFn as a presentation transform and preserve labelField
Directive: fallback for existing callers
Tested: pnpm exec eslint api-component.vue index.ts types.ts
Tested: pnpm exec vue-tsc --noEmit -p packages/effects/common-ui/tsconfig.json
Not-tested: runtime integration in consuming select/tree-select components

* Update packages/effects/common-ui/src/components/api-component/api-component.vue

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-13 17:52:17 +08:00

254 lines
6.2 KiB
Vue

<script lang="ts" setup>
import type {
ApiComponentProps,
ApiComponentOptionsItem as OptionsItem,
} from './types';
import { computed, nextTick, ref, unref, useAttrs, watch } from 'vue';
import { LoaderCircle } from '@vben/icons';
import { cloneDeep, get, isEqual, isFunction } from '@vben-core/shared/utils';
import { objectOmit } from '@vueuse/core';
defineOptions({ name: 'ApiComponent', inheritAttrs: false });
const props = withDefaults(defineProps<ApiComponentProps>(), {
labelField: 'label',
valueField: 'value',
labelFn: undefined,
disabledField: 'disabled',
childrenField: '',
optionsPropName: 'options',
resultField: '',
visibleEvent: '',
numberToString: false,
params: () => ({}),
immediate: true,
alwaysLoad: false,
loadingSlot: '',
beforeFetch: undefined,
shouldFetch: undefined,
afterFetch: undefined,
modelPropName: 'modelValue',
api: undefined,
autoSelect: false,
options: () => [],
});
const emit = defineEmits<{
optionsChange: [OptionsItem[]];
}>();
const modelValue = defineModel<any>({ default: undefined });
const attrs = useAttrs();
const innerParams = ref({});
const refOptions = ref<OptionsItem[]>([]);
const loading = ref(false);
// 首次是否加载过了
const isFirstLoaded = ref(false);
// 标记是否有待处理的请求
const hasPendingRequest = ref(false);
const getOptions = computed(() => {
const {
labelField,
labelFn,
valueField,
disabledField,
childrenField,
numberToString,
} = props;
function transformData(data: OptionsItem[] = []): OptionsItem[] {
return data.map((item) => {
const value = get(item, valueField);
const children = childrenField ? get(item, childrenField) : item.children;
return {
...objectOmit(item, [
labelField,
valueField,
disabledField,
...(childrenField ? [childrenField] : []),
]),
label: labelFn ? labelFn(item) : get(item, labelField),
value: numberToString ? `${value}` : value,
disabled: get(item, disabledField),
...(Array.isArray(children) && children.length > 0
? { children: transformData(children) }
: {}),
};
});
}
const data = transformData(unref(refOptions));
return data.length > 0 ? data : transformData(props.options);
});
const bindProps = computed(() => {
return {
[props.modelPropName]: unref(modelValue),
[props.optionsPropName]: unref(getOptions),
[`onUpdate:${props.modelPropName}`]: (val: string) => {
modelValue.value = val;
},
...objectOmit(attrs, [`onUpdate:${props.modelPropName}`]),
...(props.visibleEvent
? {
[props.visibleEvent]: handleFetchForVisible,
}
: {}),
};
});
async function fetchApi() {
const { api, beforeFetch, shouldFetch, afterFetch, resultField } = props;
if (!api || !isFunction(api)) {
return;
}
// 如果正在加载,标记有待处理的请求并返回
if (loading.value) {
hasPendingRequest.value = true;
return;
}
refOptions.value = [];
try {
loading.value = true;
let finalParams = unref(mergedParams);
if (beforeFetch && isFunction(beforeFetch)) {
finalParams = (await beforeFetch(cloneDeep(finalParams))) || finalParams;
}
// 判断是否需要控制执行中断
if (
shouldFetch &&
isFunction(shouldFetch) &&
!(await shouldFetch(finalParams))
) {
return;
}
let res = await api(finalParams);
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;
// 如果有待处理的请求,立即触发新的请求
if (hasPendingRequest.value) {
hasPendingRequest.value = false;
// 使用 nextTick 确保状态更新完成后再触发新请求
await nextTick();
fetchApi();
}
}
}
async function handleFetchForVisible(visible: boolean) {
if (visible) {
if (props.alwaysLoad) {
await fetchApi();
} else if (!props.immediate && !unref(isFirstLoaded)) {
await fetchApi();
}
}
}
const mergedParams = computed(() => {
return {
...props.params,
...unref(innerParams),
};
});
watch(
mergedParams,
(value, oldValue) => {
if (isEqual(value, oldValue)) {
return;
}
fetchApi();
},
{ deep: true, immediate: props.immediate },
);
function emitChange() {
if (
modelValue.value === undefined &&
props.autoSelect &&
unref(getOptions).length > 0
) {
let firstOption;
if (isFunction(props.autoSelect)) {
firstOption = props.autoSelect(unref(getOptions));
} else {
switch (props.autoSelect) {
case 'first': {
firstOption = unref(getOptions)[0];
break;
}
case 'last': {
firstOption = unref(getOptions)[unref(getOptions).length - 1];
break;
}
case 'one': {
if (unref(getOptions).length === 1) {
firstOption = unref(getOptions)[0];
}
break;
}
}
}
if (firstOption) modelValue.value = firstOption.value;
}
emit('optionsChange', unref(getOptions));
}
const componentRef = ref();
defineExpose({
/** 获取options数据 */
getOptions: () => unref(getOptions),
/** 获取当前值 */
getValue: () => unref(modelValue),
/** 获取被包装的组件实例 */
getComponentRef: <T = any>() => componentRef.value as T,
/** 更新Api参数 */
updateParam(newParams: Record<string, any>) {
innerParams.value = newParams;
},
});
</script>
<template>
<component
:is="component"
v-bind="bindProps"
:placeholder="$attrs.placeholder"
ref="componentRef"
>
<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>
</template>