🍓 feat(sysDict): 优化组件代码和注释,新增多选模式(数组模式和逗号字符串模式)

This commit is contained in:
喵你个汪呀 2025-08-17 12:51:55 +08:00
parent 75e5b8a06b
commit 383bcaae7f

View File

@ -1,263 +1,464 @@
<!-- 组件使用文档 https://gitee.com/zuohuaijun/Admin.NET/pulls/1559 -->
<!--
字典组件 - 根据字典编码渲染不同类型的数据展示控件
组件文档https://gitee.com/zuohuaijun/Admin.NET/pulls/1559
功能特性
- 支持多种渲染方式标签下拉框单选框复选框单选按钮组
- 支持常量和普通字典
- 支持多选和单选模式
- 支持数组多选模式和逗号多选模式
- 支持自定义显示格式和过滤
-->
<script lang="ts">
const renderTypeArray = ['tag', 'select', 'radio', 'checkbox', 'radio-button'] as const;
/**
* 支持的渲染类型常量
* @type {Array}
* @constant
*/
const RENDER_TYPES = ['tag', 'select', 'radio', 'checkbox', 'radio-button'] as const;
type RenderType = typeof RENDER_TYPES[number];
/**
* 支持的标签类型常量
* @type {Array}
* @constant
*/
const TAG_TYPES = ['success', 'warning', 'info', 'primary', 'danger'] as const;
type TagType = typeof TAG_TYPES[number];
/**
* 字典项数据结构
* @interface
* @property {string} [tagType] - 标签类型当renderAs='tag'时生效
* @property {string} [styleSetting] - 自定义样式
* @property {string} [classSetting] - 自定义类名
* @property {string} [label] - 显示文本
* @property {string|number} [value] -
*/
interface DictItem {
[key: string]: any;
tagType?: TagType;
styleSetting?: string;
classSetting?: string;
label?: string;
value?: string | number;
}
/**
* 多选值模式枚举
* @enum {string}
* @property {string} Array - 数组模式['1','2','3']
* @property {string} Comma - 逗号分隔模式'1,2,3'
*/
enum MultipleModel {
Array = 'array',
Comma = 'comma',
}
/**
* 检查是否为合法的渲染类型
* @function
* @param {any} value - 待检查的值
* @returns {value is RenderType} - 是否为合法的渲染类型
*/
function isRenderType(value: any): value is RenderType {
return RENDER_TYPES.includes(value);
}
/**
* 检查是否为合法的多选模式
* @function
* @param {any} value - 待检查的值
* @returns {value is MultipleModel} - 是否为合法的多选模式
*/
function isMultipleModel(value: any): value is MultipleModel {
return Object.values(MultipleModel).includes(value);
}
</script>
<script setup lang="ts">
import { reactive, watch, PropType } from 'vue';
import { reactive, watch, computed, PropType } from 'vue';
import { useUserInfo } from '/@/stores/userInfo';
type RenderType = (typeof renderTypeArray)[number];
type DictItem = {
[key: string]: any;
tagType?: string;
styleSetting?: string;
classSetting?: string;
};
const userStore = useUserInfo();
const emit = defineEmits(['update:modelValue']);
/**
* 组件属性定义
*/
const props = defineProps({
/**
* 绑定的值支持多种类型
* @example
* <g-sys-dict v-model="selectedValue" code="xxxx" />
*/
modelValue: {
type: [String, Number, Boolean, Array, null] as PropType<string | number | boolean | any[] | null>,
default: null,
required: true,
},
/**
* 字典编码用于获取字典项
* @example 'gender'
*/
code: {
type: String,
required: true,
},
/**
* 是否是常量
* @default false
*/
isConst: {
type: Boolean,
default: false,
},
/**
* 字典项中用于显示的字段名
* @default 'label'
*/
propLabel: {
type: String,
default: 'label',
},
/**
* 字典项中用于取值的字段名
* @default 'value'
*/
propValue: {
type: String,
default: 'value',
},
/**
* 字典项过滤函数
* @param dict - 字典项
* @returns 是否保留该项
* @default (dict) => true
*/
onItemFilter: {
type: Function as PropType<(dict: DictItem) => boolean>,
default: (dict: DictItem) => true,
},
/**
* 字典项显示内容格式化函数
* @param dict - 字典项
* @returns 格式化后的显示内容
* @default () => undefined
*/
onItemFormatter: {
type: Function as PropType<(dict: DictItem) => string | undefined | null>,
default: () => undefined,
},
/**
* 组件渲染方式
* @values 'tag', 'select', 'radio', 'checkbox', 'radio-button'
* @default 'tag'
*/
renderAs: {
type: String as PropType<RenderType>,
default: 'tag',
validator(value: any) {
return renderTypeArray.includes(value);
},
},
/**
* 是否多选
* @default false
*/
multiple: {
type: Boolean,
default: false,
},
/**
* 绑定值支持多种类型
* @type {string|number|boolean|Array|null}
* @required
* @example
* //
* <g-sys-dict v-model="selectedValue" code="gender" renderAs="select" />
*
* //
* <g-sys-dict v-model="selectedValues" code="roles" renderAs="select" multiple />
*
* //
* <g-sys-dict v-model="selectedValues" code="roles" renderAs="select" multiple multiple-model="comma" />
*/
modelValue: {
type: [String, Number, Boolean, Array, null] as PropType<string | number | boolean | any[] | null>,
default: null,
required: true,
},
/**
* 字典编码用于从字典中获取数据
* @type {string}
* @required
* @example 'gender'
*/
code: {
type: String,
required: true,
},
/**
* 是否为常量字典true从常量列表获取false从字典列表获取
* @type {boolean}
* @default false
* @example true
*/
isConst: {
type: Boolean,
default: false,
},
/**
* 字典项中用于显示的字段名
* @type {string}
* @default 'label'
* @example 'name'
*/
propLabel: {
type: String,
default: 'label',
},
/**
* 字典项中用于取值的字段名
* @type {string}
* @default 'value'
* @example 'id'
*/
propValue: {
type: String,
default: 'value',
},
/**
* 字典项过滤函数
* @type {Function}
* @param {DictItem} dict - 当前字典项
* @returns {boolean} - 是否保留该项
* @default (dict) => true
* @example
* //
* :onItemFilter="(dict) => dict.status === 1"
*/
onItemFilter: {
type: Function as PropType<(dict: DictItem) => boolean>,
default: () => true,
},
/**
* 字典项显示内容格式化函数
* @type {Function}
* @param {DictItem} dict - 当前字典项
* @returns {string|undefined|null} - 格式化后的显示内容
* @default () => undefined
* @example
* //
* :onItemFormatter="(dict) => `${dict.label} <icon-user />`"
*/
onItemFormatter: {
type: Function as PropType<(dict: DictItem) => string | undefined | null>,
default: () => undefined,
},
/**
* 组件渲染方式
* @type {'tag'|'select'|'radio'|'checkbox'|'radio-button'}
* @default 'tag'
* @example 'select'
*/
renderAs: {
type: String as PropType<RenderType>,
default: 'tag',
validator: isRenderType,
},
/**
* 是否多选仅在renderAs为select/checkbox时有效
* @type {boolean}
* @default false
* @example true
*/
multiple: {
type: Boolean,
default: false,
},
/**
* 多选值模式仅在multiple为true时有效
* @type {'array'|'comma'}
* @default 'array'
* @example 'comma'
*/
multipleModel: {
type: String as PropType<MultipleModel>,
default: MultipleModel.Array,
validator: isMultipleModel,
},
});
const state = reactive({
dict: undefined as DictItem | DictItem[] | undefined,
dictData: [] as DictItem[],
value: undefined as any,
/**
* 格式化后的字典数据计算属性
* @computed
* @returns {DictItem[]} - 过滤并格式化后的字典数据
*/
const formattedDictData = computed(() => {
return state.dictData
.filter(props.onItemFilter)
.map(item => ({
...item,
label: item[props.propLabel],
value: item[props.propValue],
}));
});
//
const getDataList = () => {
if (props.isConst) {
const data = userStore.constList?.find((x: any) => x.code === props.code)?.data?.result ?? [];
// 便
data?.forEach((item: any) => {
item.label = item.name;
item.value = item.code;
delete item.name;
});
return data;
} else {
return userStore.dictList[props.code];
}
/**
* 当前选中的字典项计算属性
* @computed
* @returns {DictItem|DictItem[]|null} - 当前选中的字典项或字典项数组
*/
const currentDictItems = computed(() => {
if (!state.value) return null;
if (Array.isArray(state.value)) {
//
const uniqueValues = [...new Set(state.value)];
return formattedDictData.value.filter(item =>
uniqueValues.includes(item.value)
);
}
return formattedDictData.value.find(item => item.value == state.value) || null;
});
/**
* 获取字典数据列表
* @function
* @returns {DictItem[]} - 字典数据列表
* @throws {Error} - 获取数据失败时抛出错误
*/
const getDataList = (): DictItem[] => {
try {
if (!props.code) {
console.error('字典编码不能为空');
return [];
}
const source = props.isConst ? userStore.constList : userStore.dictList;
const data = props.isConst
? source?.find((x: any) => x.code === props.code)?.data?.result ?? []
: source[props.code] ?? [];
return data.map((item: any) => ({
...item,
label: item[props.propLabel] ?? item.name,
value: item[props.propValue] ?? item.code,
}));
} catch (error) {
console.error(`获取字典[${props.code}]数据失败:`, error);
return [];
}
};
//
const setDictData = () => {
state.dictData = getDataList()?.filter(props.onItemFilter) ?? [];
processNumericValues(props.modelValue);
};
//
/**
* 处理数字类型的值
* @function
* @param {any} value - 待处理的值
*/
const processNumericValues = (value: any) => {
if (typeof value === 'number' || (Array.isArray(value) && typeof value[0] === 'number')) {
state.dictData.forEach((item) => {
item[props.propValue] = Number(item[props.propValue]);
});
}
if (typeof value === 'number' || (Array.isArray(value) && typeof value[0] === 'number')) {
state.dictData.forEach(item => {
if (item.value) {
item.value = Number(item.value);
}
});
}
};
//
const trySetMultipleValue = (value: any) => {
let newValue = value;
if (typeof value === 'string') {
const trimmedValue = value.trim();
if (trimmedValue.startsWith('[') && trimmedValue.endsWith(']')) {
try {
newValue = JSON.parse(trimmedValue);
} catch (error) {
console.warn('[g-sys-dict]解析多选值失败, 异常信息:', error);
}
}
} else if ((props.renderAs === 'checkbox' || props.multiple) && !value) {
newValue = [];
}
if (newValue != value) updateValue(newValue);
/**
* 解析多选值修复逗号模式问题
* @function
* @param {any} value - 待解析的值
* @returns {any} - 解析后的值
*/
const parseMultipleValue = (value: any): any => {
//
if (value === null || value === undefined || value === '') {
return props.multiple ? [] : value;
}
setDictData();
return newValue;
//
if (typeof value === 'string') {
const trimmedValue = value.trim();
// JSON
if (trimmedValue.startsWith('[') && trimmedValue.endsWith(']')) {
try {
const parsed = JSON.parse(trimmedValue);
if (Array.isArray(parsed)) {
//
return [...new Set(parsed.filter(Boolean))];
}
return parsed;
} catch (error) {
console.warn('[g-sys-dict] 解析多选值失败:', error);
return [];
}
}
//
if (props.multipleModel === MultipleModel.Comma && trimmedValue.includes(',')) {
//
return [...new Set(
trimmedValue.split(',')
.map(item => item.trim())
.filter(Boolean)
)];
}
//
return props.multiple ? [trimmedValue] : trimmedValue;
}
// -
if (Array.isArray(value)) {
return [...new Set(value.filter(Boolean))];
}
//
return value;
};
//
const setDictValue = (value: any) => {
value = trySetMultipleValue(value);
if (Array.isArray(value)) {
state.dict = state.dictData?.filter((x) => value.find((y) => y == x[props.propValue]));
state.dict?.forEach(ensureTagType);
} else {
state.dict = state.dictData?.find((x) => x[props.propValue] == value);
if (state.dict) ensureTagType(state.dict);
}
state.value = value;
};
//
const ensureTagType = (item: DictItem) => {
if (!['success', 'warning', 'info', 'primary', 'danger'].includes(item.tagType ?? '')) {
item.tagType = 'primary';
}
};
//
/**
* 更新绑定值修复逗号模式问题
* @function
* @param {any} newValue - 新值
*/
const updateValue = (newValue: any) => {
emit('update:modelValue', newValue);
//
let processedValue = newValue;
if (Array.isArray(newValue)) {
processedValue = [...new Set(newValue.filter(v => v !== null && v !== undefined && v !== ''))];
}
let emitValue = processedValue;
if (props.multipleModel === MultipleModel.Comma) {
if (Array.isArray(processedValue)) {
emitValue = processedValue.length > 0 ? processedValue.join(',') : '';
} else if (processedValue === null || processedValue === undefined) {
emitValue = '';
}
}
state.value = processedValue;
emit('update:modelValue', emitValue);
};
//
const getDisplayText = (dict: DictItem | undefined = undefined) => {
if (dict) return props.onItemFormatter?.(dict) ?? dict[props.propLabel];
return state.value;
/**
* 确保标签类型存在
* @function
* @param {DictItem} item - 字典项
* @returns {TagType} - 合法的标签类型
*/
const ensureTagType = (item: DictItem): TagType => {
return TAG_TYPES.includes(item.tagType as TagType) ? item.tagType as TagType : 'primary';
};
watch(
() => props.modelValue,
(newValue) => setDictValue(newValue),
{ immediate: true }
);
/**
* 计算显示的文本
* @function
* @param {DictItem} [dict] - 字典项
* @returns {string} - 显示文本
*/
const getDisplayText = (dict?: DictItem): string => {
if (!dict) return String(state.value || '');
const formattedText = props.onItemFormatter?.(dict);
return formattedText ?? dict[props.propLabel] ?? '';
};
watch(
() => userStore.dictList,
() => setDictValue(state.value),
{ immediate: true }
);
/**
* 初始化数据
* @function
*/
const initData = () => {
state.dictData = getDataList();
processNumericValues(props.modelValue);
const initialValue = parseMultipleValue(props.modelValue);
if (initialValue !== state.value) {
state.value = initialValue;
}
};
watch(
() => userStore.constList,
() => setDictValue(state.value),
{ immediate: true }
);
/**
* 组件状态
* @property {DictItem[]} dictData - 原始字典数据
* @property {any} value - 当前值
*/
const state = reactive({
dictData: [] as DictItem[],
value: parseMultipleValue(props.modelValue),
});
//
watch(() => props.modelValue, (newValue) => {
state.value = parseMultipleValue(newValue);
});
watch(() => [userStore.dictList, userStore.constList], initData, { immediate: true });
</script>
<template>
<!-- 渲染标签 -->
<template v-if="props.renderAs === 'tag'">
<template v-if="Array.isArray(state.dict)">
<el-tag v-for="(item, index) in state.dict" :key="index" v-bind="$attrs" :type="item.tagType" :style="item.styleSetting" :class="item.classSetting" class="mr2">
{{ getDisplayText(item) }}
</el-tag>
</template>
<template v-else>
<el-tag v-if="state.dict" v-bind="$attrs" :type="state.dict.tagType" :style="state.dict.styleSetting" :class="state.dict.classSetting">
{{ getDisplayText(state.dict) }}
</el-tag>
<span v-else>{{ getDisplayText() }}</span>
</template>
</template>
<!-- 渲染标签 -->
<template v-if="props.renderAs === 'tag'">
<template v-if="Array.isArray(currentDictItems)">
<el-tag v-for="(item, index) in currentDictItems" :key="index" v-bind="$attrs" :type="ensureTagType(item)" :style="item.styleSetting" :class="item.classSetting" class="mr2">
{{ getDisplayText(item) }}
</el-tag>
</template>
<template v-else>
<el-tag v-if="currentDictItems" v-bind="$attrs" :type="ensureTagType(currentDictItems)" :style="currentDictItems.styleSetting" :class="currentDictItems.classSetting">
{{ getDisplayText(currentDictItems) }}
</el-tag>
<span v-else>{{ getDisplayText() }}</span>
</template>
</template>
<!-- 渲染选择器 -->
<template v-if="props.renderAs === 'select'">
<el-select v-model="state.value" v-bind="$attrs" :multiple="props.multiple" @change="updateValue" clearable>
<el-option v-for="(item, index) in state.dictData" :key="index" :label="getDisplayText(item)" :value="item[propValue]" />
</el-select>
</template>
<!-- 渲染选择器 -->
<el-select v-else-if="props.renderAs === 'select'" v-model="state.value" v-bind="$attrs" :multiple="props.multiple" @change="updateValue" clearable>
<el-option v-for="(item, index) in formattedDictData" :key="index" :label="getDisplayText(item)" :value="item.value" />
</el-select>
<!-- 渲染复选框多选 -->
<template v-if="props.renderAs === 'checkbox'">
<el-checkbox-group v-model="state.value" v-bind="$attrs" @change="updateValue">
<el-checkbox-button v-for="(item, index) in state.dictData" :key="index" :value="item[propValue]">
{{ getDisplayText(item) }}
</el-checkbox-button>
</el-checkbox-group>
</template>
<!-- 渲染复选框多选 -->
<el-checkbox-group v-else-if="props.renderAs === 'checkbox'" v-model="state.value" v-bind="$attrs" @change="updateValue">
<el-checkbox-button v-for="(item, index) in formattedDictData" :key="index" :value="item.value">
{{ getDisplayText(item) }}
</el-checkbox-button>
</el-checkbox-group>
<!-- 渲染单选框 -->
<template v-if="props.renderAs === 'radio'">
<el-radio-group v-model="state.value" v-bind="$attrs" @change="updateValue">
<el-radio v-for="(item, index) in state.dictData" :key="index" :value="item[propValue]">
{{ getDisplayText(item) }}
</el-radio>
</el-radio-group>
</template>
<!-- 渲染单选框 -->
<el-radio-group v-else-if="props.renderAs === 'radio'" v-model="state.value" v-bind="$attrs" @change="updateValue">
<el-radio v-for="(item, index) in formattedDictData" :key="index" :value="item.value">
{{ getDisplayText(item) }}
</el-radio>
</el-radio-group>
<!-- 渲染单选框按钮 -->
<template v-if="props.renderAs === 'radio-button'">
<el-radio-group v-model="state.value" v-bind="$attrs" @change="updateValue">
<el-radio-button v-for="(item, index) in state.dictData" :key="index" :value="item[propValue]">
{{ getDisplayText(item) }}
</el-radio-button>
</el-radio-group>
</template>
<!-- 渲染单选框按钮 -->
<el-radio-group v-else-if="props.renderAs === 'radio-button'" v-model="state.value" v-bind="$attrs" @change="updateValue">
<el-radio-button v-for="(item, index) in formattedDictData" :key="index" :value="item.value">
{{ getDisplayText(item) }}
</el-radio-button>
</el-radio-group>
</template>
<style scoped lang="scss"></style>