diff --git a/frontend/vben/src/components/Form/index.ts b/frontend/vben/src/components/Form/index.ts new file mode 100644 index 0000000..eb7b797 --- /dev/null +++ b/frontend/vben/src/components/Form/index.ts @@ -0,0 +1,17 @@ +import BasicForm from './src/BasicForm.vue' + +export * from './src/types/form' +export * from './src/types/formItem' + +export { useComponentRegister } from './src/hooks/useComponentRegister' +export { useForm } from './src/hooks/useForm' + +export { default as ApiSelect } from './src/components/ApiSelect.vue' +export { default as RadioButtonGroup } from './src/components/RadioButtonGroup.vue' +export { default as ApiTreeSelect } from './src/components/ApiTreeSelect.vue' +export { default as ApiTree } from './src/components/ApiTree.vue' +export { default as ApiRadioGroup } from './src/components/ApiRadioGroup.vue' +export { default as ApiCascader } from './src/components/ApiCascader.vue' +export { default as ApiTransfer } from './src/components/ApiTransfer.vue' + +export { BasicForm } diff --git a/frontend/vben/src/components/Form/src/BasicForm.vue b/frontend/vben/src/components/Form/src/BasicForm.vue new file mode 100644 index 0000000..d1e97c1 --- /dev/null +++ b/frontend/vben/src/components/Form/src/BasicForm.vue @@ -0,0 +1,355 @@ + + + diff --git a/frontend/vben/src/components/Form/src/componentMap.ts b/frontend/vben/src/components/Form/src/componentMap.ts new file mode 100644 index 0000000..53e65c9 --- /dev/null +++ b/frontend/vben/src/components/Form/src/componentMap.ts @@ -0,0 +1,83 @@ +import type { Component } from 'vue'; +import type { ComponentType } from './types/index'; + +/** + * Component list, register here to setting it in the form + */ +import { + Input, + Select, + Radio, + Checkbox, + AutoComplete, + Cascader, + DatePicker, + InputNumber, + Switch, + TimePicker, + TreeSelect, + Slider, + Rate, + Divider, +} from 'ant-design-vue'; + +import ApiRadioGroup from './components/ApiRadioGroup.vue'; +import RadioButtonGroup from './components/RadioButtonGroup.vue'; +import ApiSelect from './components/ApiSelect.vue'; +import ApiTree from './components/ApiTree.vue'; +import ApiTreeSelect from './components/ApiTreeSelect.vue'; +import ApiCascader from './components/ApiCascader.vue'; +import ApiTransfer from './components/ApiTransfer.vue'; +import { BasicUpload } from '/@/components/Upload'; +import { StrengthMeter } from '/@/components/StrengthMeter'; +import { IconPicker } from '/@/components/Icon'; +import { CountdownInput } from '/@/components/CountDown'; + +const componentMap = new Map(); + +componentMap.set('Input', Input); +componentMap.set('InputGroup', Input.Group); +componentMap.set('InputPassword', Input.Password); +componentMap.set('InputSearch', Input.Search); +componentMap.set('InputTextArea', Input.TextArea); +componentMap.set('InputNumber', InputNumber); +componentMap.set('AutoComplete', AutoComplete); + +componentMap.set('Select', Select); +componentMap.set('ApiSelect', ApiSelect); +componentMap.set('ApiTree', ApiTree); +componentMap.set('TreeSelect', TreeSelect); +componentMap.set('ApiTreeSelect', ApiTreeSelect); +componentMap.set('ApiRadioGroup', ApiRadioGroup); +componentMap.set('Switch', Switch); +componentMap.set('RadioButtonGroup', RadioButtonGroup); +componentMap.set('RadioGroup', Radio.Group); +componentMap.set('Checkbox', Checkbox); +componentMap.set('CheckboxGroup', Checkbox.Group); +componentMap.set('ApiCascader', ApiCascader); +componentMap.set('Cascader', Cascader); +componentMap.set('Slider', Slider); +componentMap.set('Rate', Rate); +componentMap.set('ApiTransfer', ApiTransfer); + +componentMap.set('DatePicker', DatePicker); +componentMap.set('MonthPicker', DatePicker.MonthPicker); +componentMap.set('RangePicker', DatePicker.RangePicker); +componentMap.set('WeekPicker', DatePicker.WeekPicker); +componentMap.set('TimePicker', TimePicker); +componentMap.set('StrengthMeter', StrengthMeter); +componentMap.set('IconPicker', IconPicker); +componentMap.set('InputCountDown', CountdownInput); + +componentMap.set('Upload', BasicUpload); +componentMap.set('Divider', Divider); + +export function add(compName: ComponentType, component: Component) { + componentMap.set(compName, component); +} + +export function del(compName: ComponentType) { + componentMap.delete(compName); +} + +export { componentMap }; diff --git a/frontend/vben/src/components/Form/src/components/ApiCascader.vue b/frontend/vben/src/components/Form/src/components/ApiCascader.vue new file mode 100644 index 0000000..948386a --- /dev/null +++ b/frontend/vben/src/components/Form/src/components/ApiCascader.vue @@ -0,0 +1,198 @@ + + diff --git a/frontend/vben/src/components/Form/src/components/ApiRadioGroup.vue b/frontend/vben/src/components/Form/src/components/ApiRadioGroup.vue new file mode 100644 index 0000000..d9dc0fa --- /dev/null +++ b/frontend/vben/src/components/Form/src/components/ApiRadioGroup.vue @@ -0,0 +1,135 @@ + + + diff --git a/frontend/vben/src/components/Form/src/components/ApiSelect.vue b/frontend/vben/src/components/Form/src/components/ApiSelect.vue new file mode 100644 index 0000000..8bb0bfe --- /dev/null +++ b/frontend/vben/src/components/Form/src/components/ApiSelect.vue @@ -0,0 +1,151 @@ + + diff --git a/frontend/vben/src/components/Form/src/components/ApiTransfer.vue b/frontend/vben/src/components/Form/src/components/ApiTransfer.vue new file mode 100644 index 0000000..ced8f35 --- /dev/null +++ b/frontend/vben/src/components/Form/src/components/ApiTransfer.vue @@ -0,0 +1,137 @@ + + + diff --git a/frontend/vben/src/components/Form/src/components/ApiTree.vue b/frontend/vben/src/components/Form/src/components/ApiTree.vue new file mode 100644 index 0000000..0ec6917 --- /dev/null +++ b/frontend/vben/src/components/Form/src/components/ApiTree.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/vben/src/components/Form/src/components/ApiTreeSelect.vue b/frontend/vben/src/components/Form/src/components/ApiTreeSelect.vue new file mode 100644 index 0000000..3f073d3 --- /dev/null +++ b/frontend/vben/src/components/Form/src/components/ApiTreeSelect.vue @@ -0,0 +1,86 @@ + + + diff --git a/frontend/vben/src/components/Form/src/components/FormAction.vue b/frontend/vben/src/components/Form/src/components/FormAction.vue new file mode 100644 index 0000000..8dec49f --- /dev/null +++ b/frontend/vben/src/components/Form/src/components/FormAction.vue @@ -0,0 +1,135 @@ + + diff --git a/frontend/vben/src/components/Form/src/components/FormItem.vue b/frontend/vben/src/components/Form/src/components/FormItem.vue new file mode 100644 index 0000000..595b4a9 --- /dev/null +++ b/frontend/vben/src/components/Form/src/components/FormItem.vue @@ -0,0 +1,412 @@ + diff --git a/frontend/vben/src/components/Form/src/components/RadioButtonGroup.vue b/frontend/vben/src/components/Form/src/components/RadioButtonGroup.vue new file mode 100644 index 0000000..4974641 --- /dev/null +++ b/frontend/vben/src/components/Form/src/components/RadioButtonGroup.vue @@ -0,0 +1,63 @@ + + + diff --git a/frontend/vben/src/components/Form/src/helper.ts b/frontend/vben/src/components/Form/src/helper.ts new file mode 100644 index 0000000..7171c7b --- /dev/null +++ b/frontend/vben/src/components/Form/src/helper.ts @@ -0,0 +1,87 @@ +import type { ValidationRule } from 'ant-design-vue/lib/form/Form'; +import type { ComponentType } from './types/index'; +import { useI18n } from '/@/hooks/web/useI18n'; +import { dateUtil } from '/@/utils/dateUtil'; +import { isNumber, isObject } from '/@/utils/is'; + +const { t } = useI18n(); + +/** + * @description: 生成placeholder + */ +export function createPlaceholderMessage(component: ComponentType) { + if (component.includes('Input') || component.includes('Complete')) { + return t('common.inputText'); + } + if (component.includes('Picker')) { + return t('common.chooseText'); + } + if ( + component.includes('Select') || + component.includes('Cascader') || + component.includes('Checkbox') || + component.includes('Radio') || + component.includes('Switch') + ) { + // return `请选择${label}`; + return t('common.chooseText'); + } + return ''; +} + +const DATE_TYPE = ['DatePicker', 'MonthPicker', 'WeekPicker', 'TimePicker']; + +function genType() { + return [...DATE_TYPE, 'RangePicker']; +} + +export function setComponentRuleType( + rule: ValidationRule, + component: ComponentType, + valueFormat: string, +) { + if (['DatePicker', 'MonthPicker', 'WeekPicker', 'TimePicker'].includes(component)) { + rule.type = valueFormat ? 'string' : 'object'; + } else if (['RangePicker', 'Upload', 'CheckboxGroup', 'TimePicker'].includes(component)) { + rule.type = 'array'; + } else if (['InputNumber'].includes(component)) { + rule.type = 'number'; + } +} + +export function processDateValue(attr: Recordable, component: string) { + const { valueFormat, value } = attr; + if (valueFormat) { + attr.value = isObject(value) ? dateUtil(value).format(valueFormat) : value; + } else if (DATE_TYPE.includes(component) && value) { + attr.value = dateUtil(attr.value); + } +} + +export function handleInputNumberValue(component?: ComponentType, val?: any) { + if (!component) return val; + if (['Input', 'InputPassword', 'InputSearch', 'InputTextArea'].includes(component)) { + return val && isNumber(val) ? `${val}` : val; + } + return val; +} + +/** + * 时间字段 + */ +export const dateItemType = genType(); + +export const defaultValueComponents = ['Input', 'InputPassword', 'InputSearch', 'InputTextArea']; + +// TODO 自定义组件封装会出现验证问题,因此这里目前改成手动触发验证 +export const NO_AUTO_LINK_COMPONENTS: ComponentType[] = [ + 'Upload', + 'ApiTransfer', + 'ApiTree', + 'ApiSelect', + 'ApiTreeSelect', + 'ApiRadioGroup', + 'ApiCascader', + 'AutoComplete', + 'RadioButtonGroup', +]; diff --git a/frontend/vben/src/components/Form/src/hooks/useAdvanced.ts b/frontend/vben/src/components/Form/src/hooks/useAdvanced.ts new file mode 100644 index 0000000..9bd2d9d --- /dev/null +++ b/frontend/vben/src/components/Form/src/hooks/useAdvanced.ts @@ -0,0 +1,172 @@ +import type { ColEx } from '../types'; +import type { AdvanceState } from '../types/hooks'; +import { ComputedRef, getCurrentInstance, Ref, shallowReactive } from 'vue'; +import type { FormProps, FormSchema } from '../types/form'; +import { computed, unref, watch } from 'vue'; +import { isBoolean, isFunction, isNumber, isObject } from '/@/utils/is'; +import { useBreakpoint } from '/@/hooks/event/useBreakpoint'; +import { useDebounceFn } from '@vueuse/core'; + +const BASIC_COL_LEN = 24; + +interface UseAdvancedContext { + advanceState: AdvanceState; + emit: EmitType; + getProps: ComputedRef; + getSchema: ComputedRef; + formModel: Recordable; + defaultValueRef: Ref; +} + +export default function ({ + advanceState, + emit, + getProps, + getSchema, + formModel, + defaultValueRef, +}: UseAdvancedContext) { + const vm = getCurrentInstance(); + + const { realWidthRef, screenEnum, screenRef } = useBreakpoint(); + + const getEmptySpan = computed((): number => { + if (!advanceState.isAdvanced) { + return 0; + } + // For some special cases, you need to manually specify additional blank lines + const emptySpan = unref(getProps).emptySpan || 0; + + if (isNumber(emptySpan)) { + return emptySpan; + } + if (isObject(emptySpan)) { + const { span = 0 } = emptySpan; + const screen = unref(screenRef) as string; + + const screenSpan = (emptySpan as any)[screen.toLowerCase()]; + return screenSpan || span || 0; + } + return 0; + }); + + const debounceUpdateAdvanced = useDebounceFn(updateAdvanced, 30); + + watch( + [() => unref(getSchema), () => advanceState.isAdvanced, () => unref(realWidthRef)], + () => { + const { showAdvancedButton } = unref(getProps); + if (showAdvancedButton) { + debounceUpdateAdvanced(); + } + }, + { immediate: true }, + ); + + function getAdvanced(itemCol: Partial, itemColSum = 0, isLastAction = false) { + const width = unref(realWidthRef); + + const mdWidth = + parseInt(itemCol.md as string) || + parseInt(itemCol.xs as string) || + parseInt(itemCol.sm as string) || + (itemCol.span as number) || + BASIC_COL_LEN; + + const lgWidth = parseInt(itemCol.lg as string) || mdWidth; + const xlWidth = parseInt(itemCol.xl as string) || lgWidth; + const xxlWidth = parseInt(itemCol.xxl as string) || xlWidth; + if (width <= screenEnum.LG) { + itemColSum += mdWidth; + } else if (width < screenEnum.XL) { + itemColSum += lgWidth; + } else if (width < screenEnum.XXL) { + itemColSum += xlWidth; + } else { + itemColSum += xxlWidth; + } + + if (isLastAction) { + advanceState.hideAdvanceBtn = false; + if (itemColSum <= BASIC_COL_LEN * 2) { + // When less than or equal to 2 lines, the collapse and expand buttons are not displayed + advanceState.hideAdvanceBtn = true; + advanceState.isAdvanced = true; + } else if ( + itemColSum > BASIC_COL_LEN * 2 && + itemColSum <= BASIC_COL_LEN * (unref(getProps).autoAdvancedLine || 3) + ) { + advanceState.hideAdvanceBtn = false; + + // More than 3 lines collapsed by default + } else if (!advanceState.isLoad) { + advanceState.isLoad = true; + advanceState.isAdvanced = !advanceState.isAdvanced; + } + return { isAdvanced: advanceState.isAdvanced, itemColSum }; + } + if (itemColSum > BASIC_COL_LEN * (unref(getProps).alwaysShowLines || 1)) { + return { isAdvanced: advanceState.isAdvanced, itemColSum }; + } else { + // The first line is always displayed + return { isAdvanced: true, itemColSum }; + } + } + + const fieldsIsAdvancedMap = shallowReactive({}); + + function updateAdvanced() { + let itemColSum = 0; + let realItemColSum = 0; + const { baseColProps = {} } = unref(getProps); + + for (const schema of unref(getSchema)) { + const { show, colProps } = schema; + let isShow = true; + + if (isBoolean(show)) { + isShow = show; + } + + if (isFunction(show)) { + isShow = show({ + schema: schema, + model: formModel, + field: schema.field, + values: { + ...unref(defaultValueRef), + ...formModel, + }, + }); + } + + if (isShow && (colProps || baseColProps)) { + const { itemColSum: sum, isAdvanced } = getAdvanced( + { ...baseColProps, ...colProps }, + itemColSum, + ); + + itemColSum = sum || 0; + if (isAdvanced) { + realItemColSum = itemColSum; + } + fieldsIsAdvancedMap[schema.field] = isAdvanced; + } + } + + // 确保页面发送更新 + vm?.proxy?.$forceUpdate(); + + advanceState.actionSpan = (realItemColSum % BASIC_COL_LEN) + unref(getEmptySpan); + + getAdvanced(unref(getProps).actionColOptions || { span: BASIC_COL_LEN }, itemColSum, true); + + emit('advanced-change'); + } + + function handleToggleAdvanced() { + advanceState.isAdvanced = !advanceState.isAdvanced; + } + + return { handleToggleAdvanced, fieldsIsAdvancedMap }; +} diff --git a/frontend/vben/src/components/Form/src/hooks/useAutoFocus.ts b/frontend/vben/src/components/Form/src/hooks/useAutoFocus.ts new file mode 100644 index 0000000..e24dd6b --- /dev/null +++ b/frontend/vben/src/components/Form/src/hooks/useAutoFocus.ts @@ -0,0 +1,40 @@ +import type { ComputedRef, Ref } from 'vue'; +import type { FormSchema, FormActionType, FormProps } from '../types/form'; + +import { unref, nextTick, watchEffect } from 'vue'; + +interface UseAutoFocusContext { + getSchema: ComputedRef; + getProps: ComputedRef; + isInitedDefault: Ref; + formElRef: Ref; +} +export async function useAutoFocus({ + getSchema, + getProps, + formElRef, + isInitedDefault, +}: UseAutoFocusContext) { + watchEffect(async () => { + if (unref(isInitedDefault) || !unref(getProps).autoFocusFirstItem) { + return; + } + await nextTick(); + const schemas = unref(getSchema); + const formEl = unref(formElRef); + const el = (formEl as any)?.$el as HTMLElement; + if (!formEl || !el || !schemas || schemas.length === 0) { + return; + } + + const firstItem = schemas[0]; + // Only open when the first form item is input type + if (!firstItem.component.includes('Input')) { + return; + } + + const inputEl = el.querySelector('.ant-row:first-child input') as Nullable; + if (!inputEl) return; + inputEl?.focus(); + }); +} diff --git a/frontend/vben/src/components/Form/src/hooks/useComponentRegister.ts b/frontend/vben/src/components/Form/src/hooks/useComponentRegister.ts new file mode 100644 index 0000000..218aaa9 --- /dev/null +++ b/frontend/vben/src/components/Form/src/hooks/useComponentRegister.ts @@ -0,0 +1,11 @@ +import type { ComponentType } from '../types/index'; +import { tryOnUnmounted } from '@vueuse/core'; +import { add, del } from '../componentMap'; +import type { Component } from 'vue'; + +export function useComponentRegister(compName: ComponentType, comp: Component) { + add(compName, comp); + tryOnUnmounted(() => { + del(compName); + }); +} diff --git a/frontend/vben/src/components/Form/src/hooks/useForm.ts b/frontend/vben/src/components/Form/src/hooks/useForm.ts new file mode 100644 index 0000000..40f246d --- /dev/null +++ b/frontend/vben/src/components/Form/src/hooks/useForm.ts @@ -0,0 +1,122 @@ +import type { FormProps, FormActionType, UseFormReturnType, FormSchema } from '../types/form'; +import type { NamePath } from 'ant-design-vue/lib/form/interface'; +import type { DynamicProps } from '/#/utils'; +import { ref, onUnmounted, unref, nextTick, watch } from 'vue'; +import { isProdMode } from '/@/utils/env'; +import { error } from '/@/utils/log'; +import { getDynamicProps } from '/@/utils'; + +export declare type ValidateFields = (nameList?: NamePath[]) => Promise; + +type Props = Partial>; + +export function useForm(props?: Props): UseFormReturnType { + const formRef = ref>(null); + const loadedRef = ref>(false); + + async function getForm() { + const form = unref(formRef); + if (!form) { + error( + 'The form instance has not been obtained, please make sure that the form has been rendered when performing the form operation!', + ); + } + await nextTick(); + return form as FormActionType; + } + + function register(instance: FormActionType) { + isProdMode() && + onUnmounted(() => { + formRef.value = null; + loadedRef.value = null; + }); + if (unref(loadedRef) && isProdMode() && instance === unref(formRef)) return; + + formRef.value = instance; + loadedRef.value = true; + + watch( + () => props, + () => { + props && instance.setProps(getDynamicProps(props)); + }, + { + immediate: true, + deep: true, + }, + ); + } + + const methods: FormActionType = { + scrollToField: async (name: NamePath, options?: ScrollOptions | undefined) => { + const form = await getForm(); + form.scrollToField(name, options); + }, + setProps: async (formProps: Partial) => { + const form = await getForm(); + form.setProps(formProps); + }, + + updateSchema: async (data: Partial | Partial[]) => { + const form = await getForm(); + form.updateSchema(data); + }, + + resetSchema: async (data: Partial | Partial[]) => { + const form = await getForm(); + form.resetSchema(data); + }, + + clearValidate: async (name?: string | string[]) => { + const form = await getForm(); + form.clearValidate(name); + }, + + resetFields: async () => { + getForm().then(async (form) => { + await form.resetFields(); + }); + }, + + removeSchemaByField: async (field: string | string[]) => { + unref(formRef)?.removeSchemaByField(field); + }, + + // TODO promisify + getFieldsValue: () => { + return unref(formRef)?.getFieldsValue() as T; + }, + + setFieldsValue: async (values: T) => { + const form = await getForm(); + form.setFieldsValue(values); + }, + + appendSchemaByField: async ( + schema: FormSchema | FormSchema[], + prefixField: string | undefined, + first: boolean, + ) => { + const form = await getForm(); + form.appendSchemaByField(schema, prefixField, first); + }, + + submit: async (): Promise => { + const form = await getForm(); + return form.submit(); + }, + + validate: async (nameList?: NamePath[]): Promise => { + const form = await getForm(); + return form.validate(nameList); + }, + + validateFields: async (nameList?: NamePath[]): Promise => { + const form = await getForm(); + return form.validateFields(nameList); + }, + }; + + return [register, methods]; +} diff --git a/frontend/vben/src/components/Form/src/hooks/useFormContext.ts b/frontend/vben/src/components/Form/src/hooks/useFormContext.ts new file mode 100644 index 0000000..01dfadd --- /dev/null +++ b/frontend/vben/src/components/Form/src/hooks/useFormContext.ts @@ -0,0 +1,17 @@ +import type { InjectionKey } from 'vue'; +import { createContext, useContext } from '/@/hooks/core/useContext'; + +export interface FormContextProps { + resetAction: () => Promise; + submitAction: () => Promise; +} + +const key: InjectionKey = Symbol(); + +export function createFormContext(context: FormContextProps) { + return createContext(context, key); +} + +export function useFormContext() { + return useContext(key); +} diff --git a/frontend/vben/src/components/Form/src/hooks/useFormEvents.ts b/frontend/vben/src/components/Form/src/hooks/useFormEvents.ts new file mode 100644 index 0000000..c6b9b6e --- /dev/null +++ b/frontend/vben/src/components/Form/src/hooks/useFormEvents.ts @@ -0,0 +1,338 @@ +import type { ComputedRef, Ref } from 'vue'; +import type { FormProps, FormSchema, FormActionType } from '../types/form'; +import type { NamePath } from 'ant-design-vue/lib/form/interface'; +import { unref, toRaw, nextTick } from 'vue'; +import { + isArray, + isFunction, + isObject, + isString, + isDef, + isNullOrUnDef, + isEmpty, +} from '/@/utils/is'; +import { deepMerge } from '/@/utils'; +import { dateItemType, handleInputNumberValue, defaultValueComponents } from '../helper'; +import { dateUtil } from '/@/utils/dateUtil'; +import { cloneDeep, uniqBy } from 'lodash-es'; +import { error } from '/@/utils/log'; + +interface UseFormActionContext { + emit: EmitType; + getProps: ComputedRef; + getSchema: ComputedRef; + formModel: Recordable; + defaultValueRef: Ref; + formElRef: Ref; + schemaRef: Ref; + handleFormValues: Fn; +} +export function useFormEvents({ + emit, + getProps, + formModel, + getSchema, + defaultValueRef, + formElRef, + schemaRef, + handleFormValues, +}: UseFormActionContext) { + async function resetFields(): Promise { + const { resetFunc, submitOnReset } = unref(getProps); + resetFunc && isFunction(resetFunc) && (await resetFunc()); + + const formEl = unref(formElRef); + if (!formEl) return; + + Object.keys(formModel).forEach((key) => { + const schema = unref(getSchema).find((item) => item.field === key); + const isInput = schema?.component && defaultValueComponents.includes(schema.component); + const defaultValue = cloneDeep(defaultValueRef.value[key]); + formModel[key] = isInput ? defaultValue || '' : defaultValue; + }); + nextTick(() => clearValidate()); + + emit('reset', toRaw(formModel)); + submitOnReset && handleSubmit(); + } + + /** + * @description: Set form value + */ + async function setFieldsValue(values: Recordable): Promise { + const fields = unref(getSchema) + .map((item) => item.field) + .filter(Boolean); + + // key 支持 a.b.c 的嵌套写法 + const delimiter = '.'; + const nestKeyArray = fields.filter((item) => String(item).indexOf(delimiter) >= 0); + + const validKeys: string[] = []; + Object.keys(values).forEach((key) => { + const schema = unref(getSchema).find((item) => item.field === key); + let value = values[key]; + + const hasKey = Reflect.has(values, key); + + value = handleInputNumberValue(schema?.component, value); + const { componentProps } = schema || {}; + let _props = componentProps as any; + if (typeof componentProps === 'function') { + _props = _props({ formModel: unref(formModel) }); + } + // 0| '' is allow + if (hasKey && fields.includes(key)) { + // time type + if (itemIsDateType(key)) { + if (Array.isArray(value)) { + const arr: any[] = []; + for (const ele of value) { + arr.push(ele ? dateUtil(ele) : null); + } + unref(formModel)[key] = arr; + } else { + unref(formModel)[key] = value ? (_props?.valueFormat ? value : dateUtil(value)) : null; + } + } else { + unref(formModel)[key] = value; + } + if (_props?.onChange) { + _props?.onChange(value); + } + validKeys.push(key); + } else { + nestKeyArray.forEach((nestKey: string) => { + try { + const value = nestKey.split('.').reduce((out, item) => out[item], values); + if (isDef(value)) { + unref(formModel)[nestKey] = unref(value); + validKeys.push(nestKey); + } + } catch (e) { + // key not exist + if (isDef(defaultValueRef.value[nestKey])) { + unref(formModel)[nestKey] = cloneDeep(unref(defaultValueRef.value[nestKey])); + } + } + }); + } + }); + validateFields(validKeys).catch((_) => {}); + } + /** + * @description: Delete based on field name + */ + async function removeSchemaByField(fields: string | string[]): Promise { + const schemaList: FormSchema[] = cloneDeep(unref(getSchema)); + if (!fields) { + return; + } + + let fieldList: string[] = isString(fields) ? [fields] : fields; + if (isString(fields)) { + fieldList = [fields]; + } + for (const field of fieldList) { + _removeSchemaByFeild(field, schemaList); + } + schemaRef.value = schemaList; + } + + /** + * @description: Delete based on field name + */ + function _removeSchemaByFeild(field: string, schemaList: FormSchema[]): void { + if (isString(field)) { + const index = schemaList.findIndex((schema) => schema.field === field); + if (index !== -1) { + delete formModel[field]; + schemaList.splice(index, 1); + } + } + } + + /** + * @description: Insert after a certain field, if not insert the last + */ + async function appendSchemaByField( + schema: FormSchema | FormSchema[], + prefixField?: string, + first = false, + ) { + const schemaList: FormSchema[] = cloneDeep(unref(getSchema)); + + const index = schemaList.findIndex((schema) => schema.field === prefixField); + const _schemaList = isObject(schema) ? [schema as FormSchema] : (schema as FormSchema[]); + if (!prefixField || index === -1 || first) { + first ? schemaList.unshift(..._schemaList) : schemaList.push(..._schemaList); + schemaRef.value = schemaList; + _setDefaultValue(schema); + return; + } + if (index !== -1) { + schemaList.splice(index + 1, 0, ..._schemaList); + } + _setDefaultValue(schema); + + schemaRef.value = schemaList; + } + + async function resetSchema(data: Partial | Partial[]) { + let updateData: Partial[] = []; + if (isObject(data)) { + updateData.push(data as FormSchema); + } + if (isArray(data)) { + updateData = [...data]; + } + + const hasField = updateData.every( + (item) => item.component === 'Divider' || (Reflect.has(item, 'field') && item.field), + ); + + if (!hasField) { + error( + 'All children of the form Schema array that need to be updated must contain the `field` field', + ); + return; + } + schemaRef.value = updateData as FormSchema[]; + } + + async function updateSchema(data: Partial | Partial[]) { + let updateData: Partial[] = []; + if (isObject(data)) { + updateData.push(data as FormSchema); + } + if (isArray(data)) { + updateData = [...data]; + } + + const hasField = updateData.every( + (item) => item.component === 'Divider' || (Reflect.has(item, 'field') && item.field), + ); + + if (!hasField) { + error( + 'All children of the form Schema array that need to be updated must contain the `field` field', + ); + return; + } + const schema: FormSchema[] = []; + unref(getSchema).forEach((val) => { + let _val; + updateData.forEach((item) => { + if (val.field === item.field) { + _val = item; + } + }); + if (_val !== undefined && val.field === _val.field) { + const newSchema = deepMerge(val, _val); + schema.push(newSchema as FormSchema); + } else { + schema.push(val); + } + }); + _setDefaultValue(schema); + + schemaRef.value = uniqBy(schema, 'field'); + } + + function _setDefaultValue(data: FormSchema | FormSchema[]) { + let schemas: FormSchema[] = []; + if (isObject(data)) { + schemas.push(data as FormSchema); + } + if (isArray(data)) { + schemas = [...data]; + } + + const obj: Recordable = {}; + const currentFieldsValue = getFieldsValue(); + schemas.forEach((item) => { + if ( + item.component != 'Divider' && + Reflect.has(item, 'field') && + item.field && + !isNullOrUnDef(item.defaultValue) && + (!(item.field in currentFieldsValue) || + isNullOrUnDef(currentFieldsValue[item.field]) || + isEmpty(currentFieldsValue[item.field])) + ) { + obj[item.field] = item.defaultValue; + } + }); + setFieldsValue(obj); + } + + function getFieldsValue(): Recordable { + const formEl = unref(formElRef); + if (!formEl) return {}; + return handleFormValues(toRaw(unref(formModel))); + } + + /** + * @description: Is it time + */ + function itemIsDateType(key: string) { + return unref(getSchema).some((item) => { + return item.field === key ? dateItemType.includes(item.component) : false; + }); + } + + async function validateFields(nameList?: NamePath[] | undefined) { + return unref(formElRef)?.validateFields(nameList); + } + + async function validate(nameList?: NamePath[] | undefined) { + return await unref(formElRef)?.validate(nameList); + } + + async function clearValidate(name?: string | string[]) { + await unref(formElRef)?.clearValidate(name); + } + + async function scrollToField(name: NamePath, options?: ScrollOptions | undefined) { + await unref(formElRef)?.scrollToField(name, options); + } + + /** + * @description: Form submission + */ + async function handleSubmit(e?: Event): Promise { + e && e.preventDefault(); + const { submitFunc } = unref(getProps); + if (submitFunc && isFunction(submitFunc)) { + await submitFunc(); + return; + } + const formEl = unref(formElRef); + if (!formEl) return; + try { + const values = await validate(); + const res = handleFormValues(values); + emit('submit', res); + } catch (error: any) { + if (error?.outOfDate === false && error?.errorFields) { + return; + } + throw new Error(error); + } + } + + return { + handleSubmit, + clearValidate, + validate, + validateFields, + getFieldsValue, + updateSchema, + resetSchema, + appendSchemaByField, + removeSchemaByField, + resetFields, + setFieldsValue, + scrollToField, + }; +} diff --git a/frontend/vben/src/components/Form/src/hooks/useFormValues.ts b/frontend/vben/src/components/Form/src/hooks/useFormValues.ts new file mode 100644 index 0000000..650d17d --- /dev/null +++ b/frontend/vben/src/components/Form/src/hooks/useFormValues.ts @@ -0,0 +1,143 @@ +import { isArray, isFunction, isObject, isString, isNullOrUnDef } from '/@/utils/is'; +import { dateUtil } from '/@/utils/dateUtil'; +import { unref } from 'vue'; +import type { Ref, ComputedRef } from 'vue'; +import type { FormProps, FormSchema } from '../types/form'; +import { cloneDeep, set } from 'lodash-es'; + +interface UseFormValuesContext { + defaultValueRef: Ref; + getSchema: ComputedRef; + getProps: ComputedRef; + formModel: Recordable; +} + +/** + * @desription deconstruct array-link key. This method will mutate the target. + */ +function tryDeconstructArray(key: string, value: any, target: Recordable) { + const pattern = /^\[(.+)\]$/; + if (pattern.test(key)) { + const match = key.match(pattern); + if (match && match[1]) { + const keys = match[1].split(','); + value = Array.isArray(value) ? value : [value]; + keys.forEach((k, index) => { + set(target, k.trim(), value[index]); + }); + return true; + } + } +} + +/** + * @desription deconstruct object-link key. This method will mutate the target. + */ +function tryDeconstructObject(key: string, value: any, target: Recordable) { + const pattern = /^\{(.+)\}$/; + if (pattern.test(key)) { + const match = key.match(pattern); + if (match && match[1]) { + const keys = match[1].split(','); + value = isObject(value) ? value : {}; + keys.forEach((k) => { + set(target, k.trim(), value[k.trim()]); + }); + return true; + } + } +} + +export function useFormValues({ + defaultValueRef, + getSchema, + formModel, + getProps, +}: UseFormValuesContext) { + // Processing form values + function handleFormValues(values: Recordable) { + if (!isObject(values)) { + return {}; + } + const res: Recordable = {}; + for (const item of Object.entries(values)) { + let [, value] = item; + const [key] = item; + if (!key || (isArray(value) && value.length === 0) || isFunction(value)) { + continue; + } + const transformDateFunc = unref(getProps).transformDateFunc; + if (isObject(value)) { + value = transformDateFunc?.(value); + } + + if (isArray(value) && value[0]?.format && value[1]?.format) { + value = value.map((item) => transformDateFunc?.(item)); + } + // Remove spaces + if (isString(value)) { + // remove params from URL + if(value === '') { + value = undefined; + }else { + value = value.trim(); + } + } + if (!tryDeconstructArray(key, value, res) && !tryDeconstructObject(key, value, res)) { + // 没有解构成功的,按原样赋值 + set(res, key, value); + } + } + return handleRangeTimeValue(res); + } + + /** + * @description: Processing time interval parameters + */ + function handleRangeTimeValue(values: Recordable) { + const fieldMapToTime = unref(getProps).fieldMapToTime; + + if (!fieldMapToTime || !Array.isArray(fieldMapToTime)) { + return values; + } + + for (const [field, [startTimeKey, endTimeKey], format = 'YYYY-MM-DD'] of fieldMapToTime) { + if (!field || !startTimeKey || !endTimeKey) { + continue; + } + // If the value to be converted is empty, remove the field + if (!values[field]) { + Reflect.deleteProperty(values, field); + continue; + } + + const [startTime, endTime]: string[] = values[field]; + + const [startTimeFormat, endTimeFormat] = Array.isArray(format) ? format : [format, format]; + + values[startTimeKey] = dateUtil(startTime).format(startTimeFormat); + values[endTimeKey] = dateUtil(endTime).format(endTimeFormat); + Reflect.deleteProperty(values, field); + } + + return values; + } + + function initDefault() { + const schemas = unref(getSchema); + const obj: Recordable = {}; + schemas.forEach((item) => { + const { defaultValue } = item; + if (!isNullOrUnDef(defaultValue)) { + obj[item.field] = defaultValue; + + if (formModel[item.field] === undefined) { + formModel[item.field] = defaultValue; + } + } + }); + defaultValueRef.value = cloneDeep(obj); + } + + return { handleFormValues, initDefault }; +} diff --git a/frontend/vben/src/components/Form/src/hooks/useLabelWidth.ts b/frontend/vben/src/components/Form/src/hooks/useLabelWidth.ts new file mode 100644 index 0000000..3befa1c --- /dev/null +++ b/frontend/vben/src/components/Form/src/hooks/useLabelWidth.ts @@ -0,0 +1,42 @@ +import type { Ref } from 'vue'; +import { computed, unref } from 'vue'; +import type { FormProps, FormSchema } from '../types/form'; +import { isNumber } from '/@/utils/is'; + +export function useItemLabelWidth(schemaItemRef: Ref, propsRef: Ref) { + return computed(() => { + const schemaItem = unref(schemaItemRef); + const { labelCol = {}, wrapperCol = {} } = schemaItem.itemProps || {}; + const { labelWidth, disabledLabelWidth } = schemaItem; + + const { + labelWidth: globalLabelWidth, + labelCol: globalLabelCol, + wrapperCol: globWrapperCol, + layout, + } = unref(propsRef); + + // If labelWidth is set globally, all items setting + if ((!globalLabelWidth && !labelWidth && !globalLabelCol) || disabledLabelWidth) { + labelCol.style = { + textAlign: 'left', + }; + return { labelCol, wrapperCol }; + } + let width = labelWidth || globalLabelWidth; + const col = { ...globalLabelCol, ...labelCol }; + const wrapCol = { ...globWrapperCol, ...wrapperCol }; + + if (width) { + width = isNumber(width) ? `${width}px` : width; + } + + return { + labelCol: { style: { width }, ...col }, + wrapperCol: { + style: { width: layout === 'vertical' ? '100%' : `calc(100% - ${width})` }, + ...wrapCol, + }, + }; + }); +} diff --git a/frontend/vben/src/components/Form/src/props.ts b/frontend/vben/src/components/Form/src/props.ts new file mode 100644 index 0000000..f3f6a2e --- /dev/null +++ b/frontend/vben/src/components/Form/src/props.ts @@ -0,0 +1,103 @@ +import type { FieldMapToTime, FormSchema } from './types/form'; +import type { CSSProperties, PropType } from 'vue'; +import type { ColEx } from './types'; +import type { TableActionType } from '/@/components/Table'; +import type { ButtonProps } from 'ant-design-vue/es/button/buttonTypes'; +import type { RowProps } from 'ant-design-vue/lib/grid/Row'; +import { propTypes } from '/@/utils/propTypes'; + +export const basicProps = { + model: { + type: Object as PropType, + default: () => ({}), + }, + // 标签宽度 固定宽度 + labelWidth: { + type: [Number, String] as PropType, + default: 0, + }, + fieldMapToTime: { + type: Array as PropType, + default: () => [], + }, + compact: propTypes.bool, + // 表单配置规则 + schemas: { + type: Array as PropType, + default: () => [], + }, + mergeDynamicData: { + type: Object as PropType, + default: null, + }, + baseRowStyle: { + type: Object as PropType, + }, + baseColProps: { + type: Object as PropType>, + }, + autoSetPlaceHolder: propTypes.bool.def(true), + // 在INPUT组件上单击回车时,是否自动提交 + autoSubmitOnEnter: propTypes.bool.def(false), + submitOnReset: propTypes.bool, + submitOnChange: propTypes.bool, + size: propTypes.oneOf(['default', 'small', 'large']).def('default'), + // 禁用表单 + disabled: propTypes.bool, + emptySpan: { + type: [Number, Object] as PropType, + default: 0, + }, + // 是否显示收起展开按钮 + showAdvancedButton: propTypes.bool, + // 转化时间 + transformDateFunc: { + type: Function as PropType, + default: (date: any) => { + return date?.format?.('YYYY-MM-DD HH:mm:ss') ?? date; + }, + }, + rulesMessageJoinLabel: propTypes.bool.def(true), + // 超过3行自动折叠 + autoAdvancedLine: propTypes.number.def(3), + // 不受折叠影响的行数 + alwaysShowLines: propTypes.number.def(1), + + // 是否显示操作按钮 + showActionButtonGroup: propTypes.bool.def(true), + // 操作列Col配置 + actionColOptions: Object as PropType>, + // 显示重置按钮 + showResetButton: propTypes.bool.def(true), + // 是否聚焦第一个输入框,只在第一个表单项为input的时候作用 + autoFocusFirstItem: propTypes.bool, + // 重置按钮配置 + resetButtonOptions: Object as PropType>, + + // 显示确认按钮 + showSubmitButton: propTypes.bool.def(true), + // 确认按钮配置 + submitButtonOptions: Object as PropType>, + + // 自定义重置函数 + resetFunc: Function as PropType<() => Promise>, + submitFunc: Function as PropType<() => Promise>, + + // 以下为默认props + hideRequiredMark: propTypes.bool, + + labelCol: Object as PropType>, + + layout: propTypes.oneOf(['horizontal', 'vertical', 'inline']).def('horizontal'), + tableAction: { + type: Object as PropType, + }, + + wrapperCol: Object as PropType>, + + colon: propTypes.bool, + + labelAlign: propTypes.string, + + rowProps: Object as PropType, +}; diff --git a/frontend/vben/src/components/Form/src/types/form.ts b/frontend/vben/src/components/Form/src/types/form.ts new file mode 100644 index 0000000..7bb5ce9 --- /dev/null +++ b/frontend/vben/src/components/Form/src/types/form.ts @@ -0,0 +1,227 @@ +import type { NamePath, RuleObject } from 'ant-design-vue/lib/form/interface'; +import type { VNode } from 'vue'; +import type { ButtonProps as AntdButtonProps } from '/@/components/Button'; +import type { FormItem } from './formItem'; +import type { ColEx, ComponentType } from './index'; +import type { TableActionType } from '/@/components/Table/src/types/table'; +import type { CSSProperties } from 'vue'; +import type { RowProps } from 'ant-design-vue/lib/grid/Row'; + +export type FieldMapToTime = [string, [string, string], (string | [string, string])?][]; + +export type Rule = RuleObject & { + trigger?: 'blur' | 'change' | ['change', 'blur']; +}; + +export interface RenderCallbackParams { + schema: FormSchema; + values: Recordable; + model: Recordable; + field: string; +} + +export interface ButtonProps extends AntdButtonProps { + text?: string; +} + +export interface FormActionType { + submit: () => Promise; + setFieldsValue: (values: T) => Promise; + resetFields: () => Promise; + getFieldsValue: () => Recordable; + clearValidate: (name?: string | string[]) => Promise; + updateSchema: (data: Partial | Partial[]) => Promise; + resetSchema: (data: Partial | Partial[]) => Promise; + setProps: (formProps: Partial) => Promise; + removeSchemaByField: (field: string | string[]) => Promise; + appendSchemaByField: ( + schema: FormSchema | FormSchema[], + prefixField: string | undefined, + first?: boolean | undefined, + ) => Promise; + validateFields: (nameList?: NamePath[]) => Promise; + validate: (nameList?: NamePath[]) => Promise; + scrollToField: (name: NamePath, options?: ScrollOptions) => Promise; +} + +export type RegisterFn = (formInstance: FormActionType) => void; + +export type UseFormReturnType = [RegisterFn, FormActionType]; + +export interface FormProps { + name?: string; + layout?: 'vertical' | 'inline' | 'horizontal'; + // Form value + model?: Recordable; + // The width of all items in the entire form + labelWidth?: number | string; + // alignment + labelAlign?: 'left' | 'right'; + // Row configuration for the entire form + rowProps?: RowProps; + // Submit form on reset + submitOnReset?: boolean; + // Submit form on form changing + submitOnChange?: boolean; + // Col configuration for the entire form + labelCol?: Partial; + // Col configuration for the entire form + wrapperCol?: Partial; + + // General row style + baseRowStyle?: CSSProperties; + + // General col configuration + baseColProps?: Partial; + + // Form configuration rules + schemas?: FormSchema[]; + // Function values used to merge into dynamic control form items + mergeDynamicData?: Recordable; + // Compact mode for search forms + compact?: boolean; + // Blank line span + emptySpan?: number | Partial; + // Internal component size of the form + size?: 'default' | 'small' | 'large'; + // Whether to disable + disabled?: boolean; + // Time interval fields are mapped into multiple + fieldMapToTime?: FieldMapToTime; + // Placeholder is set automatically + autoSetPlaceHolder?: boolean; + // Auto submit on press enter on input + autoSubmitOnEnter?: boolean; + // Check whether the information is added to the label + rulesMessageJoinLabel?: boolean; + // Whether to show collapse and expand buttons + showAdvancedButton?: boolean; + // Whether to focus on the first input box, only works when the first form item is input + autoFocusFirstItem?: boolean; + // Automatically collapse over the specified number of rows + autoAdvancedLine?: number; + // Always show lines + alwaysShowLines?: number; + // Whether to show the operation button + showActionButtonGroup?: boolean; + + // Reset button configuration + resetButtonOptions?: Partial; + + // Confirm button configuration + submitButtonOptions?: Partial; + + // Operation column configuration + actionColOptions?: Partial; + + // Show reset button + showResetButton?: boolean; + // Show confirmation button + showSubmitButton?: boolean; + + resetFunc?: () => Promise; + submitFunc?: () => Promise; + transformDateFunc?: (date: any) => string; + colon?: boolean; +} +export interface FormSchema { + // Field name + field: string; + // Event name triggered by internal value change, default change + changeEvent?: string; + // Variable name bound to v-model Default value + valueField?: string; + // Label name + label: string | VNode; + // Auxiliary text + subLabel?: string; + // Help text on the right side of the text + helpMessage?: + | string + | string[] + | ((renderCallbackParams: RenderCallbackParams) => string | string[]); + // BaseHelp component props + helpComponentProps?: Partial; + // Label width, if it is passed, the labelCol and WrapperCol configured by itemProps will be invalid + labelWidth?: string | number; + // Disable the adjustment of labelWidth with global settings of formModel, and manually set labelCol and wrapperCol by yourself + disabledLabelWidth?: boolean; + // render component + component: ComponentType; + // Component parameters + componentProps?: + | ((opt: { + schema: FormSchema; + tableAction: TableActionType; + formActionType: FormActionType; + formModel: Recordable; + }) => Recordable) + | object; + // Required + required?: boolean | ((renderCallbackParams: RenderCallbackParams) => boolean); + + suffix?: string | number | ((values: RenderCallbackParams) => string | number); + + // Validation rules + rules?: Rule[]; + // Check whether the information is added to the label + rulesMessageJoinLabel?: boolean; + + // Reference formModelItem + itemProps?: Partial; + + // col configuration outside formModelItem + colProps?: Partial; + + // 默认值 + defaultValue?: any; + + // 是否自动处理与时间相关组件的默认值 + isHandleDateDefaultValue?: boolean; + + isAdvanced?: boolean; + + // Matching details components + span?: number; + + ifShow?: boolean | ((renderCallbackParams: RenderCallbackParams) => boolean); + + show?: boolean | ((renderCallbackParams: RenderCallbackParams) => boolean); + + // Render the content in the form-item tag + render?: (renderCallbackParams: RenderCallbackParams) => VNode | VNode[] | string; + + // Rendering col content requires outer wrapper form-item + renderColContent?: (renderCallbackParams: RenderCallbackParams) => VNode | VNode[] | string; + + renderComponentContent?: + | ((renderCallbackParams: RenderCallbackParams) => any) + | VNode + | VNode[] + | string; + + // Custom slot, in from-item + slot?: string; + + // Custom slot, similar to renderColContent + colSlot?: string; + + dynamicDisabled?: boolean | ((renderCallbackParams: RenderCallbackParams) => boolean); + + dynamicRules?: (renderCallbackParams: RenderCallbackParams) => Rule[]; +} +export interface HelpComponentProps { + maxWidth: string; + // Whether to display the serial number + showIndex: boolean; + // Text list + text: any; + // colour + color: string; + // font size + fontSize: string; + icon: string; + absolute: boolean; + // Positioning + position: any; +} diff --git a/frontend/vben/src/components/Form/src/types/formItem.ts b/frontend/vben/src/components/Form/src/types/formItem.ts new file mode 100644 index 0000000..77b238a --- /dev/null +++ b/frontend/vben/src/components/Form/src/types/formItem.ts @@ -0,0 +1,91 @@ +import type { NamePath } from 'ant-design-vue/lib/form/interface'; +import type { ColProps } from 'ant-design-vue/lib/grid/Col'; +import type { HTMLAttributes, VNodeChild } from 'vue'; + +export interface FormItem { + /** + * Used with label, whether to display : after label text. + * @default true + * @type boolean + */ + colon?: boolean; + + /** + * The extra prompt message. It is similar to help. Usage example: to display error message and prompt message at the same time. + * @type any (string | slot) + */ + extra?: string | VNodeChild | JSX.Element; + + /** + * Used with validateStatus, this option specifies the validation status icon. Recommended to be used only with Input. + * @default false + * @type boolean + */ + hasFeedback?: boolean; + + /** + * The prompt message. If not provided, the prompt message will be generated by the validation rule. + * @type any (string | slot) + */ + help?: string | VNodeChild | JSX.Element; + + /** + * Label test + * @type any (string | slot) + */ + label?: string | VNodeChild | JSX.Element; + + /** + * The layout of label. You can set span offset to something like {span: 3, offset: 12} or sm: {span: 3, offset: 12} same as with + * @type Col + */ + labelCol?: ColProps & HTMLAttributes; + + /** + * Whether provided or not, it will be generated by the validation rule. + * @default false + * @type boolean + */ + required?: boolean; + + /** + * The validation status. If not provided, it will be generated by validation rule. options: 'success' 'warning' 'error' 'validating' + * @type string + */ + validateStatus?: '' | 'success' | 'warning' | 'error' | 'validating'; + + /** + * The layout for input controls, same as labelCol + * @type Col + */ + wrapperCol?: ColProps; + /** + * Set sub label htmlFor. + */ + htmlFor?: string; + /** + * text align of label + */ + labelAlign?: 'left' | 'right'; + /** + * a key of model. In the setting of validate and resetFields method, the attribute is required + */ + name?: NamePath; + /** + * validation rules of form + */ + rules?: object | object[]; + /** + * Whether to automatically associate form fields. In most cases, you can setting automatic association. + * If the conditions for automatic association are not met, you can manually associate them. See the notes below. + */ + autoLink?: boolean; + /** + * Whether stop validate on first rule of error for this field. + */ + validateFirst?: boolean; + /** + * When to validate the value of children node + */ + validateTrigger?: string | string[] | false; +} diff --git a/frontend/vben/src/components/Form/src/types/hooks.ts b/frontend/vben/src/components/Form/src/types/hooks.ts new file mode 100644 index 0000000..0308e73 --- /dev/null +++ b/frontend/vben/src/components/Form/src/types/hooks.ts @@ -0,0 +1,6 @@ +export interface AdvanceState { + isAdvanced: boolean; + hideAdvanceBtn: boolean; + isLoad: boolean; + actionSpan: number; +} diff --git a/frontend/vben/src/components/Form/src/types/index.ts b/frontend/vben/src/components/Form/src/types/index.ts new file mode 100644 index 0000000..294b080 --- /dev/null +++ b/frontend/vben/src/components/Form/src/types/index.ts @@ -0,0 +1,117 @@ +type ColSpanType = number | string; +export interface ColEx { + style?: any; + /** + * raster number of cells to occupy, 0 corresponds to display: none + * @default none (0) + * @type ColSpanType + */ + span?: ColSpanType; + + /** + * raster order, used in flex layout mode + * @default 0 + * @type ColSpanType + */ + order?: ColSpanType; + + /** + * the layout fill of flex + * @default none + * @type ColSpanType + */ + flex?: ColSpanType; + + /** + * the number of cells to offset Col from the left + * @default 0 + * @type ColSpanType + */ + offset?: ColSpanType; + + /** + * the number of cells that raster is moved to the right + * @default 0 + * @type ColSpanType + */ + push?: ColSpanType; + + /** + * the number of cells that raster is moved to the left + * @default 0 + * @type ColSpanType + */ + pull?: ColSpanType; + + /** + * <576px and also default setting, could be a span value or an object containing above props + * @type { span: ColSpanType, offset: ColSpanType } | ColSpanType + */ + xs?: { span: ColSpanType; offset: ColSpanType } | ColSpanType; + + /** + * ≥576px, could be a span value or an object containing above props + * @type { span: ColSpanType, offset: ColSpanType } | ColSpanType + */ + sm?: { span: ColSpanType; offset: ColSpanType } | ColSpanType; + + /** + * ≥768px, could be a span value or an object containing above props + * @type { span: ColSpanType, offset: ColSpanType } | ColSpanType + */ + md?: { span: ColSpanType; offset: ColSpanType } | ColSpanType; + + /** + * ≥992px, could be a span value or an object containing above props + * @type { span: ColSpanType, offset: ColSpanType } | ColSpanType + */ + lg?: { span: ColSpanType; offset: ColSpanType } | ColSpanType; + + /** + * ≥1200px, could be a span value or an object containing above props + * @type { span: ColSpanType, offset: ColSpanType } | ColSpanType + */ + xl?: { span: ColSpanType; offset: ColSpanType } | ColSpanType; + + /** + * ≥1600px, could be a span value or an object containing above props + * @type { span: ColSpanType, offset: ColSpanType } | ColSpanType + */ + xxl?: { span: ColSpanType; offset: ColSpanType } | ColSpanType; +} + +export type ComponentType = + | 'Input' + | 'InputGroup' + | 'InputPassword' + | 'InputSearch' + | 'InputTextArea' + | 'InputNumber' + | 'InputCountDown' + | 'Select' + | 'ApiSelect' + | 'TreeSelect' + | 'ApiTree' + | 'ApiTreeSelect' + | 'ApiRadioGroup' + | 'RadioButtonGroup' + | 'RadioGroup' + | 'Checkbox' + | 'CheckboxGroup' + | 'AutoComplete' + | 'ApiCascader' + | 'Cascader' + | 'DatePicker' + | 'MonthPicker' + | 'RangePicker' + | 'WeekPicker' + | 'TimePicker' + | 'Switch' + | 'StrengthMeter' + | 'Upload' + | 'IconPicker' + | 'Render' + | 'Slider' + | 'Rate' + | 'Divider' + | 'ApiTransfer'; diff --git a/frontend/vben/src/components/Table/index.ts b/frontend/vben/src/components/Table/index.ts new file mode 100644 index 0000000..79540b4 --- /dev/null +++ b/frontend/vben/src/components/Table/index.ts @@ -0,0 +1,11 @@ +export { default as BasicTable } from './src/BasicTable.vue' +export { default as TableAction } from './src/components/TableAction.vue' +export { default as EditTableHeaderIcon } from './src/components/EditTableHeaderIcon.vue' +export { default as TableImg } from './src/components/TableImg.vue' + +export * from './src/types/table' +export * from './src/types/pagination' +export * from './src/types/tableAction' +export { useTable } from './src/hooks/useTable' +export type { FormSchema, FormProps } from '/@/components/Form/src/types/form' +export type { EditRecordRow } from './src/components/editable' diff --git a/frontend/vben/src/components/Table/src/BasicTable.vue b/frontend/vben/src/components/Table/src/BasicTable.vue new file mode 100644 index 0000000..111dafa --- /dev/null +++ b/frontend/vben/src/components/Table/src/BasicTable.vue @@ -0,0 +1,451 @@ + + + diff --git a/frontend/vben/src/components/Table/src/componentMap.ts b/frontend/vben/src/components/Table/src/componentMap.ts new file mode 100644 index 0000000..d1323f6 --- /dev/null +++ b/frontend/vben/src/components/Table/src/componentMap.ts @@ -0,0 +1,40 @@ +import type { Component } from 'vue' +import { + Input, + Select, + Checkbox, + InputNumber, + Switch, + DatePicker, + TimePicker, + AutoComplete, + Radio, +} from 'ant-design-vue' +import type { ComponentType } from './types/componentType' +import { ApiSelect, ApiTreeSelect, RadioButtonGroup, ApiRadioGroup } from '/@/components/Form' + +const componentMap = new Map() + +componentMap.set('Input', Input) +componentMap.set('InputNumber', InputNumber) +componentMap.set('Select', Select) +componentMap.set('ApiSelect', ApiSelect) +componentMap.set('AutoComplete', AutoComplete) +componentMap.set('ApiTreeSelect', ApiTreeSelect) +componentMap.set('Switch', Switch) +componentMap.set('Checkbox', Checkbox) +componentMap.set('DatePicker', DatePicker) +componentMap.set('TimePicker', TimePicker) +componentMap.set('RadioGroup', Radio.Group) +componentMap.set('RadioButtonGroup', RadioButtonGroup) +componentMap.set('ApiRadioGroup', ApiRadioGroup) + +export function add(compName: ComponentType, component: Component) { + componentMap.set(compName, component) +} + +export function del(compName: ComponentType) { + componentMap.delete(compName) +} + +export { componentMap } diff --git a/frontend/vben/src/components/Table/src/components/EditTableHeaderIcon.vue b/frontend/vben/src/components/Table/src/components/EditTableHeaderIcon.vue new file mode 100644 index 0000000..369820e --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/EditTableHeaderIcon.vue @@ -0,0 +1,16 @@ + + diff --git a/frontend/vben/src/components/Table/src/components/HeaderCell.vue b/frontend/vben/src/components/Table/src/components/HeaderCell.vue new file mode 100644 index 0000000..c21bfd0 --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/HeaderCell.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/vben/src/components/Table/src/components/TableAction.vue b/frontend/vben/src/components/Table/src/components/TableAction.vue new file mode 100644 index 0000000..99425f9 --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/TableAction.vue @@ -0,0 +1,202 @@ + + + diff --git a/frontend/vben/src/components/Table/src/components/TableFooter.vue b/frontend/vben/src/components/Table/src/components/TableFooter.vue new file mode 100644 index 0000000..68e556b --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/TableFooter.vue @@ -0,0 +1,94 @@ + + diff --git a/frontend/vben/src/components/Table/src/components/TableHeader.vue b/frontend/vben/src/components/Table/src/components/TableHeader.vue new file mode 100644 index 0000000..189e913 --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/TableHeader.vue @@ -0,0 +1,81 @@ + + + diff --git a/frontend/vben/src/components/Table/src/components/TableImg.vue b/frontend/vben/src/components/Table/src/components/TableImg.vue new file mode 100644 index 0000000..0867bda --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/TableImg.vue @@ -0,0 +1,91 @@ + + + diff --git a/frontend/vben/src/components/Table/src/components/TableTitle.vue b/frontend/vben/src/components/Table/src/components/TableTitle.vue new file mode 100644 index 0000000..0b797e1 --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/TableTitle.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/vben/src/components/Table/src/components/editable/CellComponent.ts b/frontend/vben/src/components/Table/src/components/editable/CellComponent.ts new file mode 100644 index 0000000..3a16693 --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/editable/CellComponent.ts @@ -0,0 +1,44 @@ +import type { FunctionalComponent, defineComponent } from 'vue'; +import type { ComponentType } from '../../types/componentType'; +import { componentMap } from '/@/components/Table/src/componentMap'; + +import { Popover } from 'ant-design-vue'; +import { h } from 'vue'; + +export interface ComponentProps { + component: ComponentType; + rule: boolean; + popoverVisible: boolean; + ruleMessage: string; + getPopupContainer?: Fn; +} + +export const CellComponent: FunctionalComponent = ( + { + component = 'Input', + rule = true, + ruleMessage, + popoverVisible, + getPopupContainer, + }: ComponentProps, + { attrs }, +) => { + const Comp = componentMap.get(component) as typeof defineComponent; + + const DefaultComp = h(Comp, attrs); + if (!rule) { + return DefaultComp; + } + return h( + Popover, + { + overlayClassName: 'edit-cell-rule-popover', + visible: !!popoverVisible, + ...(getPopupContainer ? { getPopupContainer } : {}), + }, + { + default: () => DefaultComp, + content: () => ruleMessage, + }, + ); +}; diff --git a/frontend/vben/src/components/Table/src/components/editable/EditableCell.vue b/frontend/vben/src/components/Table/src/components/editable/EditableCell.vue new file mode 100644 index 0000000..5ae35a6 --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/editable/EditableCell.vue @@ -0,0 +1,534 @@ + + diff --git a/frontend/vben/src/components/Table/src/components/editable/helper.ts b/frontend/vben/src/components/Table/src/components/editable/helper.ts new file mode 100644 index 0000000..9c600c9 --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/editable/helper.ts @@ -0,0 +1,28 @@ +import { ComponentType } from '../../types/componentType'; +import { useI18n } from '/@/hooks/web/useI18n'; + +const { t } = useI18n(); + +/** + * @description: 生成placeholder + */ +export function createPlaceholderMessage(component: ComponentType) { + if (component.includes('Input') || component.includes('AutoComplete')) { + return t('common.inputText'); + } + if (component.includes('Picker')) { + return t('common.chooseText'); + } + + if ( + component.includes('Select') || + component.includes('Checkbox') || + component.includes('Radio') || + component.includes('Switch') || + component.includes('DatePicker') || + component.includes('TimePicker') + ) { + return t('common.chooseText'); + } + return ''; +} diff --git a/frontend/vben/src/components/Table/src/components/editable/index.ts b/frontend/vben/src/components/Table/src/components/editable/index.ts new file mode 100644 index 0000000..4f7d4da --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/editable/index.ts @@ -0,0 +1,68 @@ +import type { BasicColumn } from '/@/components/Table/src/types/table'; + +import { h, Ref } from 'vue'; + +import EditableCell from './EditableCell.vue'; +import { isArray } from '/@/utils/is'; + +interface Params { + text: string; + record: Recordable; + index: number; +} + +export function renderEditCell(column: BasicColumn) { + return ({ text: value, record, index }: Params) => { + record.onValid = async () => { + if (isArray(record?.validCbs)) { + const validFns = (record?.validCbs || []).map((fn) => fn()); + const res = await Promise.all(validFns); + return res.every((item) => !!item); + } else { + return false; + } + }; + + record.onEdit = async (edit: boolean, submit = false) => { + if (!submit) { + record.editable = edit; + } + + if (!edit && submit) { + if (!(await record.onValid())) return false; + const res = await record.onSubmitEdit?.(); + if (res) { + record.editable = false; + return true; + } + return false; + } + // cancel + if (!edit && !submit) { + record.onCancelEdit?.(); + } + return true; + }; + + return h(EditableCell, { + value, + record, + column, + index, + }); + }; +} + +export type EditRecordRow = Partial< + { + onEdit: (editable: boolean, submit?: boolean) => Promise; + onValid: () => Promise; + editable: boolean; + onCancel: Fn; + onSubmit: Fn; + submitCbs: Fn[]; + cancelCbs: Fn[]; + validCbs: Fn[]; + editValueRefs: Recordable; + } & T +>; diff --git a/frontend/vben/src/components/Table/src/components/settings/ColumnSetting.vue b/frontend/vben/src/components/Table/src/components/settings/ColumnSetting.vue new file mode 100644 index 0000000..628b820 --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/settings/ColumnSetting.vue @@ -0,0 +1,484 @@ + + + diff --git a/frontend/vben/src/components/Table/src/components/settings/FullScreenSetting.vue b/frontend/vben/src/components/Table/src/components/settings/FullScreenSetting.vue new file mode 100644 index 0000000..af07f84 --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/settings/FullScreenSetting.vue @@ -0,0 +1,38 @@ + + diff --git a/frontend/vben/src/components/Table/src/components/settings/RedoSetting.vue b/frontend/vben/src/components/Table/src/components/settings/RedoSetting.vue new file mode 100644 index 0000000..81829a1 --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/settings/RedoSetting.vue @@ -0,0 +1,33 @@ + + diff --git a/frontend/vben/src/components/Table/src/components/settings/SizeSetting.vue b/frontend/vben/src/components/Table/src/components/settings/SizeSetting.vue new file mode 100644 index 0000000..79c4a22 --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/settings/SizeSetting.vue @@ -0,0 +1,64 @@ + + diff --git a/frontend/vben/src/components/Table/src/components/settings/index.vue b/frontend/vben/src/components/Table/src/components/settings/index.vue new file mode 100644 index 0000000..ab03cb2 --- /dev/null +++ b/frontend/vben/src/components/Table/src/components/settings/index.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/vben/src/components/Table/src/const.ts b/frontend/vben/src/components/Table/src/const.ts new file mode 100644 index 0000000..fe36404 --- /dev/null +++ b/frontend/vben/src/components/Table/src/const.ts @@ -0,0 +1,38 @@ +import componentSetting from '/@/settings/componentSetting' + +const { table } = componentSetting + +const { + pageSizeOptions, + defaultPageSize, + fetchSetting, + defaultSize, + defaultSortFn, + defaultFilterFn, +} = table + +export const ROW_KEY = 'key' + +// Optional display number per page +export const PAGE_SIZE_OPTIONS = pageSizeOptions + +// Number of items displayed per page +export const PAGE_SIZE = defaultPageSize + +// Common interface field settings +export const FETCH_SETTING = fetchSetting + +// Default Size +export const DEFAULT_SIZE = defaultSize + +// Configure general sort function +export const DEFAULT_SORT_FN = defaultSortFn + +export const DEFAULT_FILTER_FN = defaultFilterFn + +// Default layout of table cells +export const DEFAULT_ALIGN = 'center' + +export const INDEX_COLUMN_FLAG = 'INDEX' + +export const ACTION_COLUMN_FLAG = 'ACTION' diff --git a/frontend/vben/src/components/Table/src/hooks/useColumns.ts b/frontend/vben/src/components/Table/src/hooks/useColumns.ts new file mode 100644 index 0000000..b55a8cd --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useColumns.ts @@ -0,0 +1,324 @@ +import type { BasicColumn, BasicTableProps, CellFormat, GetColumnsParams } from '../types/table' +import type { PaginationProps } from '../types/pagination' +import type { ComputedRef } from 'vue' +import { computed, Ref, ref, reactive, toRaw, unref, watch } from 'vue' +import { renderEditCell } from '../components/editable' +import { usePermission } from '/@/hooks/web/usePermission' +import { useI18n } from '/@/hooks/web/useI18n' +import { isArray, isBoolean, isFunction, isMap, isString } from '/@/utils/is' +import { cloneDeep, isEqual } from 'lodash-es' +import { formatToDate } from '/@/utils/dateUtil' +import { ACTION_COLUMN_FLAG, DEFAULT_ALIGN, INDEX_COLUMN_FLAG, PAGE_SIZE } from '../const' + +function handleItem(item: BasicColumn, ellipsis: boolean) { + const { key, dataIndex, children } = item + item.align = item.align || DEFAULT_ALIGN + if (ellipsis) { + if (!key) { + item.key = dataIndex + } + if (!isBoolean(item.ellipsis)) { + Object.assign(item, { + ellipsis, + }) + } + } + if (children && children.length) { + handleChildren(children, !!ellipsis) + } +} + +function handleChildren(children: BasicColumn[] | undefined, ellipsis: boolean) { + if (!children) return + children.forEach((item) => { + const { children } = item + handleItem(item, ellipsis) + handleChildren(children, ellipsis) + }) +} + +function handleIndexColumn( + propsRef: ComputedRef, + getPaginationRef: ComputedRef, + columns: BasicColumn[], +) { + const { t } = useI18n() + + const { showIndexColumn, indexColumnProps, isTreeTable } = unref(propsRef) + + let pushIndexColumns = false + if (unref(isTreeTable)) { + return + } + columns.forEach(() => { + const indIndex = columns.findIndex((column) => column.flag === INDEX_COLUMN_FLAG) + if (showIndexColumn) { + pushIndexColumns = indIndex === -1 + } else if (!showIndexColumn && indIndex !== -1) { + columns.splice(indIndex, 1) + } + }) + + if (!pushIndexColumns) return + + const isFixedLeft = columns.some((item) => item.fixed === 'left') + + columns.unshift({ + flag: INDEX_COLUMN_FLAG, + width: 50, + title: t('component.table.index'), + align: 'center', + customRender: ({ index }) => { + const getPagination = unref(getPaginationRef) + if (isBoolean(getPagination)) { + return `${index + 1}` + } + const { current = 1, pageSize = PAGE_SIZE } = getPagination + return ((current < 1 ? 1 : current) - 1) * pageSize + index + 1 + }, + ...(isFixedLeft + ? { + fixed: 'left', + } + : {}), + ...indexColumnProps, + }) +} + +function handleActionColumn(propsRef: ComputedRef, columns: BasicColumn[]) { + const { actionColumn } = unref(propsRef) + if (!actionColumn) return + + const hasIndex = columns.findIndex((column) => column.flag === ACTION_COLUMN_FLAG) + if (hasIndex === -1) { + columns.push({ + ...columns[hasIndex], + fixed: 'right', + ...actionColumn, + flag: ACTION_COLUMN_FLAG, + }) + } +} + +export function useColumns( + propsRef: ComputedRef, + getPaginationRef: ComputedRef, +) { + const columnsRef = ref(unref(propsRef).columns) as unknown as Ref + let cacheColumns = unref(propsRef).columns + + const getColumnsRef = computed(() => { + const columns = cloneDeep(unref(columnsRef)) + + handleIndexColumn(propsRef, getPaginationRef, columns) + handleActionColumn(propsRef, columns) + if (!columns) { + return [] + } + const { ellipsis } = unref(propsRef) + + columns.forEach((item) => { + const { customRender, slots } = item + + handleItem( + item, + Reflect.has(item, 'ellipsis') ? !!item.ellipsis : !!ellipsis && !customRender && !slots, + ) + }) + return columns + }) + + function isIfShow(column: BasicColumn): boolean { + const ifShow = column.ifShow + + let isIfShow = true + + if (isBoolean(ifShow)) { + isIfShow = ifShow + } + if (isFunction(ifShow)) { + isIfShow = ifShow(column) + } + return isIfShow + } + const { hasPermission } = usePermission() + + const getViewColumns = computed(() => { + const viewColumns = sortFixedColumn(unref(getColumnsRef)) + + const mapFn = (column) => { + const { slots, customRender, format, edit, editRow, flag } = column + + if (!slots || !slots?.title) { + // column.slots = { title: `header-${dataIndex}`, ...(slots || {}) } + column.customTitle = column.title + Reflect.deleteProperty(column, 'title') + } + const isDefaultAction = [INDEX_COLUMN_FLAG, ACTION_COLUMN_FLAG].includes(flag!) + if (!customRender && format && !edit && !isDefaultAction) { + column.customRender = ({ text, record, index }) => { + return formatCell(text, format, record, index) + } + } + + // edit table + if ((edit || editRow) && !isDefaultAction) { + column.customRender = renderEditCell(column) + } + return reactive(column) + } + + const columns = cloneDeep(viewColumns) + return columns + .filter((column) => hasPermission(column.auth) && isIfShow(column)) + .map((column) => { + // Support table multiple header editable + if (column.children?.length) { + column.children = column.children.map(mapFn) + } + + return mapFn(column) + }) + }) + + watch( + () => unref(propsRef).columns, + (columns) => { + columnsRef.value = columns + cacheColumns = columns?.filter((item) => !item.flag) ?? [] + }, + ) + + function setCacheColumnsByField(dataIndex: string | undefined, value: Partial) { + if (!dataIndex || !value) { + return + } + cacheColumns.forEach((item) => { + if (item.dataIndex === dataIndex) { + Object.assign(item, value) + return + } + }) + } + /** + * set columns + * @param columnList key|column + */ + function setColumns(columnList: Partial[] | (string | string[])[]) { + const columns = cloneDeep(columnList) + if (!isArray(columns)) return + + if (columns.length <= 0) { + columnsRef.value = [] + return + } + + const firstColumn = columns[0] + + const cacheKeys = cacheColumns.map((item) => item.dataIndex) + + if (!isString(firstColumn) && !isArray(firstColumn)) { + columnsRef.value = columns as BasicColumn[] + } else { + const columnKeys = (columns as (string | string[])[]).map((m) => m.toString()) + const newColumns: BasicColumn[] = [] + cacheColumns.forEach((item) => { + newColumns.push({ + ...item, + defaultHidden: !columnKeys.includes(item.dataIndex?.toString() || (item.key as string)), + }) + }) + // Sort according to another array + if (!isEqual(cacheKeys, columns)) { + newColumns.sort((prev, next) => { + return ( + columnKeys.indexOf(prev.dataIndex?.toString() as string) - + columnKeys.indexOf(next.dataIndex?.toString() as string) + ) + }) + } + columnsRef.value = newColumns + } + } + + function getColumns(opt?: GetColumnsParams) { + const { ignoreIndex, ignoreAction, sort } = opt || {} + let columns = toRaw(unref(getColumnsRef)) + if (ignoreIndex) { + columns = columns.filter((item) => item.flag !== INDEX_COLUMN_FLAG) + } + if (ignoreAction) { + columns = columns.filter((item) => item.flag !== ACTION_COLUMN_FLAG) + } + + if (sort) { + columns = sortFixedColumn(columns) + } + + return columns + } + function getCacheColumns() { + return cacheColumns + } + + return { + getColumnsRef, + getCacheColumns, + getColumns, + setColumns, + getViewColumns, + setCacheColumnsByField, + } +} + +function sortFixedColumn(columns: BasicColumn[]) { + const fixedLeftColumns: BasicColumn[] = [] + const fixedRightColumns: BasicColumn[] = [] + const defColumns: BasicColumn[] = [] + for (const column of columns) { + if (column.fixed === 'left') { + fixedLeftColumns.push(column) + continue + } + if (column.fixed === 'right') { + fixedRightColumns.push(column) + continue + } + defColumns.push(column) + } + return [...fixedLeftColumns, ...defColumns, ...fixedRightColumns].filter( + (item) => !item.defaultHidden, + ) +} + +// format cell +export function formatCell(text: string, format: CellFormat, record: Recordable, index: number) { + if (!format) { + return text + } + + // custom function + if (isFunction(format)) { + return format(text, record, index) + } + + try { + // date type + const DATE_FORMAT_PREFIX = 'date|' + if (isString(format) && format.startsWith(DATE_FORMAT_PREFIX) && text) { + const dateFormat = format.replace(DATE_FORMAT_PREFIX, '') + + if (!dateFormat) { + return text + } + return formatToDate(text, dateFormat) + } + + // Map + if (isMap(format)) { + return format.get(text) + } + } catch (error) { + return text + } +} diff --git a/frontend/vben/src/components/Table/src/hooks/useCustomRow.ts b/frontend/vben/src/components/Table/src/hooks/useCustomRow.ts new file mode 100644 index 0000000..ea2e801 --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useCustomRow.ts @@ -0,0 +1,100 @@ +import type { ComputedRef } from 'vue' +import type { BasicTableProps } from '../types/table' +import { unref } from 'vue' +import { ROW_KEY } from '../const' +import { isString, isFunction } from '/@/utils/is' + +interface Options { + setSelectedRowKeys: (keys: string[]) => void + getSelectRowKeys: () => string[] + clearSelectedRowKeys: () => void + emit: EmitType + getAutoCreateKey: ComputedRef +} + +function getKey( + record: Recordable, + rowKey: string | ((record: Record) => string) | undefined, + autoCreateKey?: boolean, +) { + if (!rowKey || autoCreateKey) { + return record[ROW_KEY] + } + if (isString(rowKey)) { + return record[rowKey] + } + if (isFunction(rowKey)) { + return record[rowKey(record)] + } + return null +} + +export function useCustomRow( + propsRef: ComputedRef, + { setSelectedRowKeys, getSelectRowKeys, getAutoCreateKey, clearSelectedRowKeys, emit }: Options, +) { + const customRow = (record: Recordable, index: number) => { + return { + onClick: (e: Event) => { + e?.stopPropagation() + function handleClick() { + const { rowSelection, rowKey, clickToRowSelect } = unref(propsRef) + if (!rowSelection || !clickToRowSelect) return + const keys = getSelectRowKeys() || [] + const key = getKey(record, rowKey, unref(getAutoCreateKey)) + if (!key) return + + const isCheckbox = rowSelection.type === 'checkbox' + if (isCheckbox) { + // 找到tr + const tr: HTMLElement = (e as MouseEvent) + .composedPath?.() + .find((dom: HTMLElement) => dom.tagName === 'TR') as HTMLElement + if (!tr) return + // 找到Checkbox,检查是否为disabled + const checkBox = tr.querySelector('input[type=checkbox]') + if (!checkBox || checkBox.hasAttribute('disabled')) return + if (!keys.includes(key)) { + setSelectedRowKeys([...keys, key]) + return + } + const keyIndex = keys.findIndex((item) => item === key) + keys.splice(keyIndex, 1) + setSelectedRowKeys(keys) + return + } + + const isRadio = rowSelection.type === 'radio' + if (isRadio) { + if (!keys.includes(key)) { + if (keys.length) { + clearSelectedRowKeys() + } + setSelectedRowKeys([key]) + return + } + clearSelectedRowKeys() + } + } + handleClick() + emit('row-click', record, index, e) + }, + onDblclick: (event: Event) => { + emit('row-dbClick', record, index, event) + }, + onContextmenu: (event: Event) => { + emit('row-contextmenu', record, index, event) + }, + onMouseenter: (event: Event) => { + emit('row-mouseenter', record, index, event) + }, + onMouseleave: (event: Event) => { + emit('row-mouseleave', record, index, event) + }, + } + } + + return { + customRow, + } +} diff --git a/frontend/vben/src/components/Table/src/hooks/useDataSource.ts b/frontend/vben/src/components/Table/src/hooks/useDataSource.ts new file mode 100644 index 0000000..7417af6 --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useDataSource.ts @@ -0,0 +1,389 @@ +import type { BasicTableProps, FetchParams, SorterResult } from '../types/table' +import type { PaginationProps } from '../types/pagination' +import { + ref, + unref, + ComputedRef, + computed, + onMounted, + watch, + reactive, + Ref, + watchEffect, +} from 'vue' +import { useTimeoutFn } from '/@/hooks/core/useTimeout' +import { buildUUID } from '/@/utils/uuid' +import { isFunction, isBoolean, isObject } from '/@/utils/is' +import { get, cloneDeep, merge } from 'lodash-es' +import { FETCH_SETTING, ROW_KEY, PAGE_SIZE } from '../const' + +interface ActionType { + getPaginationInfo: ComputedRef + setPagination: (info: Partial) => void + setLoading: (loading: boolean) => void + getFieldsValue: () => Recordable + clearSelectedRowKeys: () => void + tableData: Ref +} + +interface SearchState { + sortInfo: Recordable + filterInfo: Record +} +export function useDataSource( + propsRef: ComputedRef, + { + getPaginationInfo, + setPagination, + setLoading, + getFieldsValue, + clearSelectedRowKeys, + tableData, + }: ActionType, + emit: EmitType, +) { + const searchState = reactive({ + sortInfo: {}, + filterInfo: {}, + }) + const dataSourceRef = ref([]) + const rawDataSourceRef = ref({}) + + watchEffect(() => { + tableData.value = unref(dataSourceRef) + }) + + watch( + () => unref(propsRef).dataSource, + () => { + const { dataSource, api } = unref(propsRef) + !api && dataSource && (dataSourceRef.value = dataSource) + }, + { + immediate: true, + }, + ) + + function handleTableChange( + pagination: PaginationProps, + filters: Partial>, + sorter: SorterResult, + ) { + const { clearSelectOnPageChange, sortFn, filterFn } = unref(propsRef) + if (clearSelectOnPageChange) { + clearSelectedRowKeys() + } + setPagination(pagination) + + const params: Recordable = {} + if (sorter && isFunction(sortFn)) { + const sortInfo = sortFn(sorter) + searchState.sortInfo = sortInfo + params.sortInfo = sortInfo + } + + if (filters && isFunction(filterFn)) { + const filterInfo = filterFn(filters) + searchState.filterInfo = filterInfo + params.filterInfo = filterInfo + } + fetch(params) + } + + function setTableKey(items: any[]) { + if (!items || !Array.isArray(items)) return + items.forEach((item) => { + if (!item[ROW_KEY]) { + item[ROW_KEY] = buildUUID() + } + if (item.children && item.children.length) { + setTableKey(item.children) + } + }) + } + + const getAutoCreateKey = computed(() => { + return unref(propsRef).autoCreateKey && !unref(propsRef).rowKey + }) + + const getRowKey = computed(() => { + const { rowKey } = unref(propsRef) + return unref(getAutoCreateKey) ? ROW_KEY : rowKey + }) + + const getDataSourceRef = computed(() => { + const dataSource = unref(dataSourceRef) + if (!dataSource || dataSource.length === 0) { + return unref(dataSourceRef) + } + if (unref(getAutoCreateKey)) { + const firstItem = dataSource[0] + const lastItem = dataSource[dataSource.length - 1] + + if (firstItem && lastItem) { + if (!firstItem[ROW_KEY] || !lastItem[ROW_KEY]) { + const data = cloneDeep(unref(dataSourceRef)) + data.forEach((item) => { + if (!item[ROW_KEY]) { + item[ROW_KEY] = buildUUID() + } + if (item.children && item.children.length) { + setTableKey(item.children) + } + }) + dataSourceRef.value = data + } + } + } + return unref(dataSourceRef) + }) + + async function updateTableData(index: number, key: string, value: any) { + const record = dataSourceRef.value[index] + if (record) { + dataSourceRef.value[index][key] = value + } + return dataSourceRef.value[index] + } + + function updateTableDataRecord( + rowKey: string | number, + record: Recordable, + ): Recordable | undefined { + const row = findTableDataRecord(rowKey) + + if (row) { + for (const field in row) { + if (Reflect.has(record, field)) row[field] = record[field] + } + return row + } + } + + function deleteTableDataRecord(rowKey: string | number | string[] | number[]) { + if (!dataSourceRef.value || dataSourceRef.value.length == 0) return + const rowKeyName = unref(getRowKey) + if (!rowKeyName) return + const rowKeys = !Array.isArray(rowKey) ? [rowKey] : rowKey + + function deleteRow(data, key) { + const row: { index: number; data: [] } = findRow(data, key) + if (row === null || row.index === -1) { + return + } + row.data.splice(row.index, 1) + + function findRow(data, key) { + if (data === null || data === undefined) { + return null + } + for (let i = 0; i < data.length; i++) { + const row = data[i] + let targetKeyName: string = rowKeyName as string + if (isFunction(rowKeyName)) { + targetKeyName = rowKeyName(row) + } + if (row[targetKeyName] === key) { + return { index: i, data } + } + if (row.children?.length > 0) { + const result = findRow(row.children, key) + if (result != null) { + return result + } + } + } + return null + } + } + + for (const key of rowKeys) { + deleteRow(dataSourceRef.value, key) + deleteRow(unref(propsRef).dataSource, key) + } + setPagination({ + total: unref(propsRef).dataSource?.length, + }) + } + + function insertTableDataRecord( + record: Recordable | Recordable[], + index: number, + ): Recordable[] | undefined { + // if (!dataSourceRef.value || dataSourceRef.value.length == 0) return + index = index ?? dataSourceRef.value?.length + const _record = isObject(record) ? [record as Recordable] : (record as Recordable[]) + unref(dataSourceRef).splice(index, 0, ..._record) + return unref(dataSourceRef) + } + + function findTableDataRecord(rowKey: string | number) { + if (!dataSourceRef.value || dataSourceRef.value.length == 0) return + + const rowKeyName = unref(getRowKey) + if (!rowKeyName) return + + const { childrenColumnName = 'children' } = unref(propsRef) + + const findRow = (array: any[]) => { + let ret + array.some(function iter(r) { + if (typeof rowKeyName === 'function') { + if ((rowKeyName(r) as string) === rowKey) { + ret = r + return true + } + } else { + if (Reflect.has(r, rowKeyName) && r[rowKeyName] === rowKey) { + ret = r + return true + } + } + return r[childrenColumnName] && r[childrenColumnName].some(iter) + }) + return ret + } + + // const row = dataSourceRef.value.find(r => { + // if (typeof rowKeyName === 'function') { + // return (rowKeyName(r) as string) === rowKey + // } else { + // return Reflect.has(r, rowKeyName) && r[rowKeyName] === rowKey + // } + // }) + return findRow(dataSourceRef.value) + } + + async function fetch(opt?: FetchParams) { + const { + api, + searchInfo, + defSort, + fetchSetting, + beforeFetch, + afterFetch, + useSearchForm, + pagination, + } = unref(propsRef) + if (!api || !isFunction(api)) return + try { + setLoading(true) + const { pageField, sizeField, listField, totalField } = Object.assign( + {}, + FETCH_SETTING, + fetchSetting, + ) + let pageParams: Recordable = {} + + const { current = 1, pageSize = PAGE_SIZE } = unref(getPaginationInfo) as PaginationProps + + if ((isBoolean(pagination) && !pagination) || isBoolean(getPaginationInfo)) { + pageParams = {} + } else { + pageParams[pageField] = (opt && opt.page) || current + pageParams[sizeField] = pageSize + } + + const { sortInfo = {}, filterInfo } = searchState + + let params: Recordable = merge( + pageParams, + useSearchForm ? getFieldsValue() : {}, + searchInfo, + opt?.searchInfo ?? {}, + defSort, + sortInfo, + filterInfo, + opt?.sortInfo ?? {}, + opt?.filterInfo ?? {}, + ) + if (beforeFetch && isFunction(beforeFetch)) { + params = (await beforeFetch(params)) || params + } + + const res = await api(params) + rawDataSourceRef.value = res + + const isArrayResult = Array.isArray(res) + + let resultItems: Recordable[] = isArrayResult ? res : get(res, listField) + const resultTotal: number = isArrayResult ? res.length : get(res, totalField) + + // 假如数据变少,导致总页数变少并小于当前选中页码,通过getPaginationRef获取到的页码是不正确的,需获取正确的页码再次执行 + if (Number(resultTotal)) { + const currentTotalPage = Math.ceil(resultTotal / pageSize) + if (current > currentTotalPage) { + setPagination({ + current: currentTotalPage, + }) + return await fetch(opt) + } + } + + if (afterFetch && isFunction(afterFetch)) { + resultItems = (await afterFetch(resultItems)) || resultItems + } + dataSourceRef.value = resultItems + setPagination({ + total: resultTotal || 0, + }) + if (opt && opt.page) { + setPagination({ + current: opt.page || 1, + }) + } + emit('fetch-success', { + items: unref(resultItems), + total: resultTotal, + }) + return resultItems + } catch (error) { + emit('fetch-error', error) + dataSourceRef.value = [] + setPagination({ + total: 0, + }) + } finally { + setLoading(false) + } + } + + function setTableData(values: T[]) { + dataSourceRef.value = values + } + + function getDataSource() { + return getDataSourceRef.value as T[] + } + + function getRawDataSource() { + return rawDataSourceRef.value as T + } + + async function reload(opt?: FetchParams) { + return await fetch(opt) + } + + onMounted(() => { + useTimeoutFn(() => { + unref(propsRef).immediate && fetch() + }, 16) + }) + + return { + getDataSourceRef, + getDataSource, + getRawDataSource, + getRowKey, + setTableData, + getAutoCreateKey, + fetch, + reload, + updateTableData, + updateTableDataRecord, + deleteTableDataRecord, + insertTableDataRecord, + findTableDataRecord, + handleTableChange, + } +} diff --git a/frontend/vben/src/components/Table/src/hooks/useLoading.ts b/frontend/vben/src/components/Table/src/hooks/useLoading.ts new file mode 100644 index 0000000..5e52566 --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useLoading.ts @@ -0,0 +1,21 @@ +import { ref, ComputedRef, unref, computed, watch } from 'vue' +import type { BasicTableProps } from '../types/table' + +export function useLoading(props: ComputedRef) { + const loadingRef = ref(unref(props).loading) + + watch( + () => unref(props).loading, + (loading) => { + loadingRef.value = loading + }, + ) + + const getLoading = computed(() => unref(loadingRef)) + + function setLoading(loading: boolean) { + loadingRef.value = loading + } + + return { getLoading, setLoading } +} diff --git a/frontend/vben/src/components/Table/src/hooks/usePagination.tsx b/frontend/vben/src/components/Table/src/hooks/usePagination.tsx new file mode 100644 index 0000000..8219e55 --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/usePagination.tsx @@ -0,0 +1,85 @@ +import type { PaginationProps } from '../types/pagination' +import type { BasicTableProps } from '../types/table' +import { computed, unref, ref, ComputedRef, watch } from 'vue' +import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue' +import { isBoolean } from '/@/utils/is' +import { PAGE_SIZE, PAGE_SIZE_OPTIONS } from '../const' +import { useI18n } from '/@/hooks/web/useI18n' + +interface ItemRender { + page: number + type: 'page' | 'prev' | 'next' + originalElement: any +} + +function itemRender({ page, type, originalElement }: ItemRender) { + if (type === 'prev') { + return page === 0 ? null : + } else if (type === 'next') { + return page === 1 ? null : + } + return originalElement +} + +export function usePagination(refProps: ComputedRef) { + const { t } = useI18n() + + const configRef = ref({}) + const show = ref(true) + + watch( + () => unref(refProps).pagination, + (pagination) => { + if (!isBoolean(pagination) && pagination) { + configRef.value = { + ...unref(configRef), + ...(pagination ?? {}), + } + } + }, + ) + + const getPaginationInfo = computed((): PaginationProps | boolean => { + const { pagination } = unref(refProps) + + if (!unref(show) || (isBoolean(pagination) && !pagination)) { + return false + } + + return { + current: 1, + pageSize: PAGE_SIZE, + size: 'small', + defaultPageSize: PAGE_SIZE, + showTotal: (total) => t('component.table.total', { total }), + showSizeChanger: true, + pageSizeOptions: PAGE_SIZE_OPTIONS, + itemRender: itemRender, + showQuickJumper: true, + ...(isBoolean(pagination) ? {} : pagination), + ...unref(configRef), + } + }) + + function setPagination(info: Partial) { + const paginationInfo = unref(getPaginationInfo) + configRef.value = { + ...(!isBoolean(paginationInfo) ? paginationInfo : {}), + ...info, + } + } + + function getPagination() { + return unref(getPaginationInfo) + } + + function getShowPagination() { + return unref(show) + } + + async function setShowPagination(flag: boolean) { + show.value = flag + } + + return { getPagination, getPaginationInfo, setShowPagination, getShowPagination, setPagination } +} diff --git a/frontend/vben/src/components/Table/src/hooks/useRowSelection.ts b/frontend/vben/src/components/Table/src/hooks/useRowSelection.ts new file mode 100644 index 0000000..402906f --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useRowSelection.ts @@ -0,0 +1,122 @@ +import { isFunction } from '/@/utils/is' +import type { BasicTableProps, TableRowSelection } from '../types/table' +import { computed, ComputedRef, nextTick, Ref, ref, toRaw, unref, watch } from 'vue' +import { ROW_KEY } from '../const' +import { omit } from 'lodash-es' +import { findNodeAll } from '/@/utils/helper/treeHelper' + +export function useRowSelection( + propsRef: ComputedRef, + tableData: Ref, + emit: EmitType, +) { + const selectedRowKeysRef = ref([]) + const selectedRowRef = ref([]) + + const getRowSelectionRef = computed((): TableRowSelection | null => { + const { rowSelection } = unref(propsRef) + if (!rowSelection) { + return null + } + + return { + selectedRowKeys: unref(selectedRowKeysRef), + onChange: (selectedRowKeys: string[]) => { + setSelectedRowKeys(selectedRowKeys) + }, + ...omit(rowSelection, ['onChange']), + } + }) + + watch( + () => unref(propsRef).rowSelection?.selectedRowKeys, + (v: string[]) => { + setSelectedRowKeys(v) + }, + ) + + watch( + () => unref(selectedRowKeysRef), + () => { + nextTick(() => { + const { rowSelection } = unref(propsRef) + if (rowSelection) { + const { onChange } = rowSelection + if (onChange && isFunction(onChange)) onChange(getSelectRowKeys(), getSelectRows()) + } + emit('selection-change', { + keys: getSelectRowKeys(), + rows: getSelectRows(), + }) + }) + }, + { deep: true }, + ) + + const getAutoCreateKey = computed(() => { + return unref(propsRef).autoCreateKey && !unref(propsRef).rowKey + }) + + const getRowKey = computed(() => { + const { rowKey } = unref(propsRef) + return unref(getAutoCreateKey) ? ROW_KEY : rowKey + }) + + function setSelectedRowKeys(rowKeys: string[]) { + selectedRowKeysRef.value = rowKeys + const allSelectedRows = findNodeAll( + toRaw(unref(tableData)).concat(toRaw(unref(selectedRowRef))), + (item) => rowKeys?.includes(item[unref(getRowKey) as string]), + { + children: propsRef.value.childrenColumnName ?? 'children', + }, + ) + const trueSelectedRows: any[] = [] + rowKeys?.forEach((key: string) => { + const found = allSelectedRows.find((item) => item[unref(getRowKey) as string] === key) + found && trueSelectedRows.push(found) + }) + selectedRowRef.value = trueSelectedRows + } + + function setSelectedRows(rows: Recordable[]) { + selectedRowRef.value = rows + } + + function clearSelectedRowKeys() { + selectedRowRef.value = [] + selectedRowKeysRef.value = [] + } + + function deleteSelectRowByKey(key: string) { + const selectedRowKeys = unref(selectedRowKeysRef) + const index = selectedRowKeys.findIndex((item) => item === key) + if (index !== -1) { + unref(selectedRowKeysRef).splice(index, 1) + } + } + + function getSelectRowKeys() { + return unref(selectedRowKeysRef) + } + + function getSelectRows() { + // const ret = toRaw(unref(selectedRowRef)).map((item) => toRaw(item)) + return unref(selectedRowRef) as T[] + } + + function getRowSelection() { + return unref(getRowSelectionRef)! + } + + return { + getRowSelection, + getRowSelectionRef, + getSelectRows, + getSelectRowKeys, + setSelectedRowKeys, + clearSelectedRowKeys, + deleteSelectRowByKey, + setSelectedRows, + } +} diff --git a/frontend/vben/src/components/Table/src/hooks/useScrollTo.ts b/frontend/vben/src/components/Table/src/hooks/useScrollTo.ts new file mode 100644 index 0000000..91077c7 --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useScrollTo.ts @@ -0,0 +1,55 @@ +import type { ComputedRef, Ref } from 'vue' +import { nextTick, unref } from 'vue' +import { warn } from '/@/utils/log' + +export function useTableScrollTo( + tableElRef: Ref, + getDataSourceRef: ComputedRef, +) { + let bodyEl: HTMLElement | null + + async function findTargetRowToScroll(targetRowData: Recordable) { + const { id } = targetRowData + const targetRowEl: HTMLElement | null | undefined = bodyEl?.querySelector( + `[data-row-key="${id}"]`, + ) + //Add a delay to get new dataSource + await nextTick() + bodyEl?.scrollTo({ + top: targetRowEl?.offsetTop ?? 0, + behavior: 'smooth', + }) + } + + function scrollTo(pos: string): void { + const table = unref(tableElRef) + if (!table) return + + const tableEl: Element = table.$el + if (!tableEl) return + + if (!bodyEl) { + bodyEl = tableEl.querySelector('.ant-table-body') + if (!bodyEl) return + } + + const dataSource = unref(getDataSourceRef) + if (!dataSource) return + + // judge pos type + if (pos === 'top') { + findTargetRowToScroll(dataSource[0]) + } else if (pos === 'bottom') { + findTargetRowToScroll(dataSource[dataSource.length - 1]) + } else { + const targetRowData = dataSource.find((data) => data.id === pos) + if (targetRowData) { + findTargetRowToScroll(targetRowData) + } else { + warn(`id: ${pos} doesn't exist`) + } + } + } + + return { scrollTo } +} diff --git a/frontend/vben/src/components/Table/src/hooks/useTable.ts b/frontend/vben/src/components/Table/src/hooks/useTable.ts new file mode 100644 index 0000000..aafbcb1 --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useTable.ts @@ -0,0 +1,170 @@ +import type { BasicTableProps, TableActionType, FetchParams, BasicColumn } from '../types/table' +import type { PaginationProps } from '../types/pagination' +import type { DynamicProps } from '/#/utils' +import type { FormActionType } from '/@/components/Form' +import type { WatchStopHandle } from 'vue' +import { getDynamicProps } from '/@/utils' +import { ref, onUnmounted, unref, watch, toRaw } from 'vue' +import { isProdMode } from '/@/utils/env' +import { error } from '/@/utils/log' + +type Props = Partial> + +type UseTableMethod = TableActionType & { + getForm: () => FormActionType +} + +export function useTable(tableProps?: Props): [ + (instance: TableActionType, formInstance: UseTableMethod) => void, + TableActionType & { + getForm: () => FormActionType + }, +] { + const tableRef = ref>(null) + const loadedRef = ref>(false) + const formRef = ref>(null) + + let stopWatch: WatchStopHandle + + function register(instance: TableActionType, formInstance: UseTableMethod) { + isProdMode() && + onUnmounted(() => { + tableRef.value = null + loadedRef.value = null + }) + + if (unref(loadedRef) && isProdMode() && instance === unref(tableRef)) return + + tableRef.value = instance + formRef.value = formInstance + tableProps && instance.setProps(getDynamicProps(tableProps)) + loadedRef.value = true + + stopWatch?.() + + stopWatch = watch( + () => tableProps, + () => { + tableProps && instance.setProps(getDynamicProps(tableProps)) + }, + { + immediate: true, + deep: true, + }, + ) + } + + function getTableInstance(): TableActionType { + const table = unref(tableRef) + if (!table) { + error( + 'The table instance has not been obtained yet, please make sure the table is presented when performing the table operation!', + ) + } + return table as TableActionType + } + + const methods: TableActionType & { + getForm: () => FormActionType + } = { + reload: async (opt?: FetchParams) => { + return await getTableInstance().reload(opt) + }, + setProps: (props: Partial) => { + getTableInstance().setProps(props) + }, + redoHeight: () => { + getTableInstance().redoHeight() + }, + setSelectedRows: (rows: Recordable[]) => { + return toRaw(getTableInstance().setSelectedRows(rows)) + }, + setLoading: (loading: boolean) => { + getTableInstance().setLoading(loading) + }, + getDataSource: () => { + return getTableInstance().getDataSource() + }, + getRawDataSource: () => { + return getTableInstance().getRawDataSource() + }, + getColumns: ({ ignoreIndex = false }: { ignoreIndex?: boolean } = {}) => { + const columns = getTableInstance().getColumns({ ignoreIndex }) || [] + return toRaw(columns) + }, + setColumns: (columns: BasicColumn[]) => { + getTableInstance().setColumns(columns) + }, + setTableData: (values: any[]) => { + return getTableInstance().setTableData(values) + }, + setPagination: (info: Partial) => { + return getTableInstance().setPagination(info) + }, + deleteSelectRowByKey: (key: string) => { + getTableInstance().deleteSelectRowByKey(key) + }, + getSelectRowKeys: () => { + return toRaw(getTableInstance().getSelectRowKeys()) + }, + getSelectRows: () => { + return toRaw(getTableInstance().getSelectRows()) + }, + clearSelectedRowKeys: () => { + getTableInstance().clearSelectedRowKeys() + }, + setSelectedRowKeys: (keys: string[] | number[]) => { + getTableInstance().setSelectedRowKeys(keys) + }, + getPaginationRef: () => { + return getTableInstance().getPaginationRef() + }, + getSize: () => { + return toRaw(getTableInstance().getSize()) + }, + updateTableData: (index: number, key: string, value: any) => { + return getTableInstance().updateTableData(index, key, value) + }, + deleteTableDataRecord: (rowKey: string | number | string[] | number[]) => { + return getTableInstance().deleteTableDataRecord(rowKey) + }, + insertTableDataRecord: (record: Recordable | Recordable[], index?: number) => { + return getTableInstance().insertTableDataRecord(record, index) + }, + updateTableDataRecord: (rowKey: string | number, record: Recordable) => { + return getTableInstance().updateTableDataRecord(rowKey, record) + }, + findTableDataRecord: (rowKey: string | number) => { + return getTableInstance().findTableDataRecord(rowKey) + }, + getRowSelection: () => { + return toRaw(getTableInstance().getRowSelection()) + }, + getCacheColumns: () => { + return toRaw(getTableInstance().getCacheColumns()) + }, + getForm: () => { + return unref(formRef) as unknown as FormActionType + }, + setShowPagination: async (show: boolean) => { + getTableInstance().setShowPagination(show) + }, + getShowPagination: () => { + return toRaw(getTableInstance().getShowPagination()) + }, + expandAll: () => { + getTableInstance().expandAll() + }, + expandRows: (keys: string[]) => { + getTableInstance().expandRows(keys) + }, + collapseAll: () => { + getTableInstance().collapseAll() + }, + scrollTo: (pos: string) => { + getTableInstance().scrollTo(pos) + }, + } + + return [register, methods] +} diff --git a/frontend/vben/src/components/Table/src/hooks/useTableContext.ts b/frontend/vben/src/components/Table/src/hooks/useTableContext.ts new file mode 100644 index 0000000..b40e734 --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useTableContext.ts @@ -0,0 +1,22 @@ +import type { Ref } from 'vue' +import type { BasicTableProps, TableActionType } from '../types/table' +import { provide, inject, ComputedRef } from 'vue' + +const key = Symbol('basic-table') + +type Instance = TableActionType & { + wrapRef: Ref> + getBindValues: ComputedRef +} + +type RetInstance = Omit & { + getBindValues: ComputedRef +} + +export function createTableContext(instance: Instance) { + provide(key, instance) +} + +export function useTableContext(): RetInstance { + return inject(key) as RetInstance +} diff --git a/frontend/vben/src/components/Table/src/hooks/useTableExpand.ts b/frontend/vben/src/components/Table/src/hooks/useTableExpand.ts new file mode 100644 index 0000000..e05cb07 --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useTableExpand.ts @@ -0,0 +1,65 @@ +import type { ComputedRef, Ref } from 'vue' +import type { BasicTableProps } from '../types/table' +import { computed, unref, ref, toRaw } from 'vue' +import { ROW_KEY } from '../const' + +export function useTableExpand( + propsRef: ComputedRef, + tableData: Ref, + emit: EmitType, +) { + const expandedRowKeys = ref([]) + + const getAutoCreateKey = computed(() => { + return unref(propsRef).autoCreateKey && !unref(propsRef).rowKey + }) + + const getRowKey = computed(() => { + const { rowKey } = unref(propsRef) + return unref(getAutoCreateKey) ? ROW_KEY : rowKey + }) + + const getExpandOption = computed(() => { + const { isTreeTable } = unref(propsRef) + if (!isTreeTable) return {} + + return { + expandedRowKeys: unref(expandedRowKeys), + onExpandedRowsChange: (keys: string[]) => { + expandedRowKeys.value = keys + emit('expanded-rows-change', keys) + }, + } + }) + + function expandAll() { + const keys = getAllKeys() + expandedRowKeys.value = keys + } + + function expandRows(keys: string[]) { + // use row ID expands the specified table row + const { isTreeTable } = unref(propsRef) + if (!isTreeTable) return + expandedRowKeys.value = [...expandedRowKeys.value, ...keys] + } + + function getAllKeys(data?: Recordable[]) { + const keys: string[] = [] + const { childrenColumnName } = unref(propsRef) + toRaw(data || unref(tableData)).forEach((item) => { + keys.push(item[unref(getRowKey) as string]) + const children = item[childrenColumnName || 'children'] + if (children?.length) { + keys.push(...getAllKeys(children)) + } + }) + return keys + } + + function collapseAll() { + expandedRowKeys.value = [] + } + + return { getExpandOption, expandAll, expandRows, collapseAll } +} diff --git a/frontend/vben/src/components/Table/src/hooks/useTableFooter.ts b/frontend/vben/src/components/Table/src/hooks/useTableFooter.ts new file mode 100644 index 0000000..9bd34a7 --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useTableFooter.ts @@ -0,0 +1,56 @@ +import type { ComputedRef, Ref } from 'vue' +import type { BasicTableProps } from '../types/table' +import { unref, computed, h, nextTick, watchEffect } from 'vue' +import TableFooter from '../components/TableFooter.vue' +import { useEventListener } from '/@/hooks/event/useEventListener' + +export function useTableFooter( + propsRef: ComputedRef, + scrollRef: ComputedRef<{ + x: string | number | true + y: string | number | null + scrollToFirstRowOnChange: boolean + }>, + tableElRef: Ref, + getDataSourceRef: ComputedRef, +) { + const getIsEmptyData = computed(() => { + return (unref(getDataSourceRef) || []).length === 0 + }) + + const getFooterProps = computed((): Recordable | undefined => { + const { summaryFunc, showSummary, summaryData } = unref(propsRef) + return showSummary && !unref(getIsEmptyData) + ? () => h(TableFooter, { summaryFunc, summaryData, scroll: unref(scrollRef) }) + : undefined + }) + + watchEffect(() => { + handleSummary() + }) + + function handleSummary() { + const { showSummary } = unref(propsRef) + if (!showSummary || unref(getIsEmptyData)) return + + nextTick(() => { + const tableEl = unref(tableElRef) + if (!tableEl) return + const bodyDom = tableEl.$el.querySelector('.ant-table-content') + useEventListener({ + el: bodyDom, + name: 'scroll', + listener: () => { + const footerBodyDom = tableEl.$el.querySelector( + '.ant-table-footer .ant-table-content', + ) as HTMLDivElement + if (!footerBodyDom || !bodyDom) return + footerBodyDom.scrollLeft = bodyDom.scrollLeft + }, + wait: 0, + options: true, + }) + }) + } + return { getFooterProps } +} diff --git a/frontend/vben/src/components/Table/src/hooks/useTableForm.ts b/frontend/vben/src/components/Table/src/hooks/useTableForm.ts new file mode 100644 index 0000000..e6a1eef --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useTableForm.ts @@ -0,0 +1,50 @@ +import type { ComputedRef, Slots } from 'vue' +import type { BasicTableProps, FetchParams } from '../types/table' +import { unref, computed } from 'vue' +import type { FormProps } from '/@/components/Form' +import { isFunction } from '/@/utils/is' + +export function useTableForm( + propsRef: ComputedRef, + slots: Slots, + fetch: (opt?: FetchParams | undefined) => Promise, + getLoading: ComputedRef, +) { + const getFormProps = computed((): Partial => { + const { formConfig } = unref(propsRef) + const { submitButtonOptions } = formConfig || {} + return { + showAdvancedButton: true, + ...formConfig, + submitButtonOptions: { loading: unref(getLoading), ...submitButtonOptions }, + compact: true, + } + }) + + const getFormSlotKeys: ComputedRef = computed(() => { + const keys = Object.keys(slots) + return keys + .map((item) => (item.startsWith('form-') ? item : null)) + .filter((item) => !!item) as string[] + }) + + function replaceFormSlotKey(key: string) { + if (!key) return '' + return key?.replace?.(/form\-/, '') ?? '' + } + + function handleSearchInfoChange(info: Recordable) { + const { handleSearchInfoFn } = unref(propsRef) + if (handleSearchInfoFn && isFunction(handleSearchInfoFn)) { + info = handleSearchInfoFn(info) || info + } + fetch({ searchInfo: info, page: 1 }) + } + + return { + getFormProps, + replaceFormSlotKey, + getFormSlotKeys, + handleSearchInfoChange, + } +} diff --git a/frontend/vben/src/components/Table/src/hooks/useTableHeader.ts b/frontend/vben/src/components/Table/src/hooks/useTableHeader.ts new file mode 100644 index 0000000..6c21438 --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useTableHeader.ts @@ -0,0 +1,54 @@ +import type { ComputedRef, Slots } from 'vue' +import type { BasicTableProps, InnerHandlers } from '../types/table' +import { unref, computed, h } from 'vue' +import TableHeader from '../components/TableHeader.vue' +import { isString } from '/@/utils/is' +import { getSlot } from '/@/utils/helper/tsxHelper' + +export function useTableHeader( + propsRef: ComputedRef, + slots: Slots, + handlers: InnerHandlers, +) { + const getHeaderProps = computed((): Recordable => { + const { title, showTableSetting, titleHelpMessage, tableSetting } = unref(propsRef) + const hideTitle = !slots.tableTitle && !title && !slots.toolbar && !showTableSetting + if (hideTitle && !isString(title)) { + return {} + } + + return { + title: hideTitle + ? null + : () => + h( + TableHeader, + { + title, + titleHelpMessage, + showTableSetting, + tableSetting, + onColumnsChange: handlers.onColumnsChange, + } as Recordable, + { + ...(slots.toolbar + ? { + toolbar: () => getSlot(slots, 'toolbar'), + } + : {}), + ...(slots.tableTitle + ? { + tableTitle: () => getSlot(slots, 'tableTitle'), + } + : {}), + ...(slots.headerTop + ? { + headerTop: () => getSlot(slots, 'headerTop'), + } + : {}), + }, + ), + } + }) + return { getHeaderProps } +} diff --git a/frontend/vben/src/components/Table/src/hooks/useTableScroll.ts b/frontend/vben/src/components/Table/src/hooks/useTableScroll.ts new file mode 100644 index 0000000..6dcc9b3 --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useTableScroll.ts @@ -0,0 +1,218 @@ +import type { BasicTableProps, TableRowSelection, BasicColumn } from '../types/table' +import { Ref, ComputedRef, ref } from 'vue' +import { computed, unref, nextTick, watch } from 'vue' +import { getViewportOffset } from '/@/utils/domUtils' +import { isBoolean } from '/@/utils/is' +import { useWindowSizeFn } from '/@/hooks/event/useWindowSizeFn' +import { useModalContext } from '/@/components/Modal' +import { onMountedOrActivated } from '/@/hooks/core/onMountedOrActivated' +import { useDebounceFn } from '@vueuse/core' + +export function useTableScroll( + propsRef: ComputedRef, + tableElRef: Ref, + columnsRef: ComputedRef, + rowSelectionRef: ComputedRef, + getDataSourceRef: ComputedRef, + wrapRef: Ref, + formRef: Ref, +) { + const tableHeightRef: Ref> = ref(167) + const modalFn = useModalContext() + + // Greater than animation time 280 + const debounceRedoHeight = useDebounceFn(redoHeight, 100) + + const getCanResize = computed(() => { + const { canResize, scroll } = unref(propsRef) + return canResize && !(scroll || {}).y + }) + + watch( + () => [unref(getCanResize), unref(getDataSourceRef)?.length], + () => { + debounceRedoHeight() + }, + { + flush: 'post', + }, + ) + + function redoHeight() { + nextTick(() => { + calcTableHeight() + }) + } + + function setHeight(height: number) { + tableHeightRef.value = height + // Solve the problem of modal adaptive height calculation when the form is placed in the modal + modalFn?.redoModalHeight?.() + } + + // No need to repeat queries + let paginationEl: HTMLElement | null + let footerEl: HTMLElement | null + let bodyEl: HTMLElement | null + + async function calcTableHeight() { + const { resizeHeightOffset, pagination, maxHeight, isCanResizeParent, useSearchForm } = + unref(propsRef) + const tableData = unref(getDataSourceRef) + + const table = unref(tableElRef) + if (!table) return + + const tableEl: Element = table.$el + if (!tableEl) return + + if (!bodyEl) { + bodyEl = tableEl.querySelector('.ant-table-body') + if (!bodyEl) return + } + + const hasScrollBarY = bodyEl.scrollHeight > bodyEl.clientHeight + const hasScrollBarX = bodyEl.scrollWidth > bodyEl.clientWidth + + if (hasScrollBarY) { + tableEl.classList.contains('hide-scrollbar-y') && tableEl.classList.remove('hide-scrollbar-y') + } else { + !tableEl.classList.contains('hide-scrollbar-y') && tableEl.classList.add('hide-scrollbar-y') + } + + if (hasScrollBarX) { + tableEl.classList.contains('hide-scrollbar-x') && tableEl.classList.remove('hide-scrollbar-x') + } else { + !tableEl.classList.contains('hide-scrollbar-x') && tableEl.classList.add('hide-scrollbar-x') + } + + bodyEl!.style.height = 'unset' + + if (!unref(getCanResize) || !unref(tableData) || tableData.length === 0) return + + await nextTick() + // Add a delay to get the correct bottomIncludeBody paginationHeight footerHeight headerHeight + + const headEl = tableEl.querySelector('.ant-table-thead ') + + if (!headEl) return + + // Table height from bottom height-custom offset + let paddingHeight = 32 + // Pager height + let paginationHeight = 2 + if (!isBoolean(pagination)) { + paginationEl = tableEl.querySelector('.ant-pagination') as HTMLElement + if (paginationEl) { + const offsetHeight = paginationEl.offsetHeight + paginationHeight += offsetHeight || 0 + } else { + // TODO First fix 24 + paginationHeight += 24 + } + } else { + paginationHeight = -8 + } + + let footerHeight = 0 + if (!isBoolean(pagination)) { + if (!footerEl) { + footerEl = tableEl.querySelector('.ant-table-footer') as HTMLElement + } else { + const offsetHeight = footerEl.offsetHeight + footerHeight += offsetHeight || 0 + } + } + + let headerHeight = 0 + if (headEl) { + headerHeight = (headEl as HTMLElement).offsetHeight + } + + let bottomIncludeBody = 0 + if (unref(wrapRef) && isCanResizeParent) { + const tablePadding = 12 + const formMargin = 16 + let paginationMargin = 10 + const wrapHeight = unref(wrapRef)?.offsetHeight ?? 0 + + let formHeight = unref(formRef)?.$el.offsetHeight ?? 0 + if (formHeight) { + formHeight += formMargin + } + if (isBoolean(pagination) && !pagination) { + paginationMargin = 0 + } + if (isBoolean(useSearchForm) && !useSearchForm) { + paddingHeight = 0 + } + + const headerCellHeight = + (tableEl.querySelector('.ant-table-title') as HTMLElement)?.offsetHeight ?? 0 + + console.log(wrapHeight - formHeight - headerCellHeight - tablePadding - paginationMargin) + bottomIncludeBody = + wrapHeight - formHeight - headerCellHeight - tablePadding - paginationMargin + } else { + // Table height from bottom + bottomIncludeBody = getViewportOffset(headEl).bottomIncludeBody + } + + let height = + bottomIncludeBody - + (resizeHeightOffset || 0) - + paddingHeight - + paginationHeight - + footerHeight - + headerHeight + height = (height > maxHeight! ? (maxHeight as number) : height) ?? height + setHeight(height) + + bodyEl!.style.height = `${height}px` + } + useWindowSizeFn(calcTableHeight, 280) + onMountedOrActivated(() => { + calcTableHeight() + nextTick(() => { + debounceRedoHeight() + }) + }) + + const getScrollX = computed(() => { + let width = 0 + if (unref(rowSelectionRef)) { + width += 60 + } + + // TODO props ?? 0 + const NORMAL_WIDTH = 150 + + const columns = unref(columnsRef).filter((item) => !item.defaultHidden) + columns.forEach((item) => { + width += Number.parseFloat(item.width as string) || 0 + }) + const unsetWidthColumns = columns.filter((item) => !Reflect.has(item, 'width')) + + const len = unsetWidthColumns.length + if (len !== 0) { + width += len * NORMAL_WIDTH + } + + const table = unref(tableElRef) + const tableWidth = table?.$el?.offsetWidth ?? 0 + return tableWidth > width ? '100%' : width + }) + + const getScrollRef = computed(() => { + const tableHeight = unref(tableHeightRef) + const { canResize, scroll } = unref(propsRef) + return { + x: unref(getScrollX), + y: canResize ? tableHeight : null, + scrollToFirstRowOnChange: false, + ...scroll, + } + }) + + return { getScrollRef, redoHeight } +} diff --git a/frontend/vben/src/components/Table/src/hooks/useTableStyle.ts b/frontend/vben/src/components/Table/src/hooks/useTableStyle.ts new file mode 100644 index 0000000..95bd018 --- /dev/null +++ b/frontend/vben/src/components/Table/src/hooks/useTableStyle.ts @@ -0,0 +1,20 @@ +import type { ComputedRef } from 'vue' +import type { BasicTableProps, TableCustomRecord } from '../types/table' +import { unref } from 'vue' +import { isFunction } from '/@/utils/is' + +export function useTableStyle(propsRef: ComputedRef, prefixCls: string) { + function getRowClassName(record: TableCustomRecord, index: number) { + const { striped, rowClassName } = unref(propsRef) + const classNames: string[] = [] + if (striped) { + classNames.push((index || 0) % 2 === 1 ? `${prefixCls}-row__striped` : '') + } + if (rowClassName && isFunction(rowClassName)) { + classNames.push(rowClassName(record, index)) + } + return classNames.filter((cls) => !!cls).join(' ') + } + + return { getRowClassName } +} diff --git a/frontend/vben/src/components/Table/src/props.ts b/frontend/vben/src/components/Table/src/props.ts new file mode 100644 index 0000000..28278ef --- /dev/null +++ b/frontend/vben/src/components/Table/src/props.ts @@ -0,0 +1,151 @@ +import type { PropType } from 'vue' +import type { PaginationProps } from './types/pagination' +import type { + BasicColumn, + FetchSetting, + TableSetting, + SorterResult, + TableCustomRecord, + TableRowSelection, + SizeType, +} from './types/table' +import type { FormProps } from '/@/components/Form' + +import { DEFAULT_FILTER_FN, DEFAULT_SORT_FN, FETCH_SETTING, DEFAULT_SIZE } from './const' +import { propTypes } from '/@/utils/propTypes' + +export const basicProps = { + clickToRowSelect: { type: Boolean, default: true }, + isTreeTable: Boolean, + tableSetting: propTypes.shape({}), + inset: Boolean, + sortFn: { + type: Function as PropType<(sortInfo: SorterResult) => any>, + default: DEFAULT_SORT_FN, + }, + filterFn: { + type: Function as PropType<(data: Partial>) => any>, + default: DEFAULT_FILTER_FN, + }, + showTableSetting: Boolean, + autoCreateKey: { type: Boolean, default: true }, + striped: { type: Boolean, default: true }, + showSummary: Boolean, + summaryFunc: { + type: [Function, Array] as PropType<(...arg: any[]) => any[]>, + default: null, + }, + summaryData: { + type: Array as PropType, + default: null, + }, + indentSize: propTypes.number.def(24), + canColDrag: { type: Boolean, default: true }, + api: { + type: Function as PropType<(...arg: any[]) => Promise>, + default: null, + }, + beforeFetch: { + type: Function as PropType, + default: null, + }, + afterFetch: { + type: Function as PropType, + default: null, + }, + handleSearchInfoFn: { + type: Function as PropType, + default: null, + }, + fetchSetting: { + type: Object as PropType, + default: () => { + return FETCH_SETTING + }, + }, + // 立即请求接口 + immediate: { type: Boolean, default: true }, + emptyDataIsShowTable: { type: Boolean, default: true }, + // 额外的请求参数 + searchInfo: { + type: Object as PropType, + default: null, + }, + // 默认的排序参数 + defSort: { + type: Object as PropType, + default: null, + }, + // 使用搜索表单 + useSearchForm: propTypes.bool, + // 表单配置 + formConfig: { + type: Object as PropType>, + default: null, + }, + columns: { + type: Array as PropType, + default: () => [], + }, + showIndexColumn: { type: Boolean, default: true }, + indexColumnProps: { + type: Object as PropType, + default: null, + }, + actionColumn: { + type: Object as PropType, + default: null, + }, + ellipsis: { type: Boolean, default: true }, + isCanResizeParent: { type: Boolean, default: false }, + canResize: { type: Boolean, default: true }, + clearSelectOnPageChange: propTypes.bool, + resizeHeightOffset: propTypes.number.def(0), + rowSelection: { + type: Object as PropType, + default: null, + }, + title: { + type: [String, Function] as PropType string)>, + default: null, + }, + titleHelpMessage: { + type: [String, Array] as PropType, + }, + maxHeight: propTypes.number, + dataSource: { + type: Array as PropType, + default: null, + }, + rowKey: { + type: [String, Function] as PropType string)>, + default: '', + }, + bordered: propTypes.bool, + pagination: { + type: [Object, Boolean] as PropType, + default: null, + }, + loading: propTypes.bool, + rowClassName: { + type: Function as PropType<(record: TableCustomRecord, index: number) => string>, + }, + scroll: { + type: Object as PropType<{ x: number | string | true; y: number | string }>, + default: null, + }, + beforeEditSubmit: { + type: Function as PropType< + (data: { + record: Recordable + index: number + key: string | number + value: any + }) => Promise + >, + }, + size: { + type: String as PropType, + default: DEFAULT_SIZE, + }, +} diff --git a/frontend/vben/src/components/Table/src/types/column.ts b/frontend/vben/src/components/Table/src/types/column.ts new file mode 100644 index 0000000..236f180 --- /dev/null +++ b/frontend/vben/src/components/Table/src/types/column.ts @@ -0,0 +1,198 @@ +import { VNodeChild } from 'vue' + +export interface ColumnFilterItem { + text?: string + value?: string + children?: any +} + +export declare type SortOrder = 'ascend' | 'descend' + +export interface RecordProps { + text: any + record: T + index: number +} + +export interface FilterDropdownProps { + prefixCls?: string + setSelectedKeys?: (selectedKeys: string[]) => void + selectedKeys?: string[] + confirm?: () => void + clearFilters?: () => void + filters?: ColumnFilterItem[] + getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement + visible?: boolean +} + +export declare type CustomRenderFunction = (record: RecordProps) => VNodeChild | JSX.Element + +export interface ColumnProps { + /** + * specify how content is aligned + * @default 'left' + * @type string + */ + align?: 'left' | 'right' | 'center' + + /** + * ellipsize cell content, not working with sorter and filters for now. + * tableLayout would be fixed when ellipsis is true. + * @default false + * @type boolean + */ + ellipsis?: boolean + + /** + * Span of this column's title + * @type number + */ + colSpan?: number + + /** + * Display field of the data record, could be set like a.b.c + * @type string + */ + dataIndex?: string + + /** + * Default filtered values + * @type string[] + */ + defaultFilteredValue?: string[] + + /** + * Default order of sorted values: 'ascend' 'descend' null + * @type string + */ + defaultSortOrder?: SortOrder + + /** + * Customized filter overlay + * @type any (slot) + */ + filterDropdown?: + | VNodeChild + | JSX.Element + | ((props: FilterDropdownProps) => VNodeChild | JSX.Element) + + /** + * Whether filterDropdown is visible + * @type boolean + */ + filterDropdownVisible?: boolean + + /** + * Whether the dataSource is filtered + * @default false + * @type boolean + */ + filtered?: boolean + + /** + * Controlled filtered value, filter icon will highlight + * @type string[] + */ + filteredValue?: string[] + + /** + * Customized filter icon + * @default false + * @type any + */ + filterIcon?: boolean | VNodeChild | JSX.Element + + /** + * Whether multiple filters can be selected + * @default true + * @type boolean + */ + filterMultiple?: boolean + + /** + * Filter menu config + * @type object[] + */ + filters?: ColumnFilterItem[] + + /** + * Set column to be fixed: true(same as left) 'left' 'right' + * @default false + * @type boolean | string + */ + fixed?: boolean | 'left' | 'right' + + /** + * Unique key of this column, you can ignore this prop if you've set a unique dataIndex + * @type string + */ + key?: string + + /** + * Renderer of the table cell. The return value should be a VNode, or an object for colSpan/rowSpan config + * @type Function | ScopedSlot + */ + customRender?: CustomRenderFunction | VNodeChild | JSX.Element + + /** + * Sort function for local sort, see Array.sort's compareFunction. If you need sort buttons only, set to true + * @type boolean | Function + */ + sorter?: boolean | Function + + /** + * Order of sorted values: 'ascend' 'descend' false + * @type boolean | string + */ + sortOrder?: boolean | SortOrder + + /** + * supported sort way, could be 'ascend', 'descend' + * @default ['ascend', 'descend'] + * @type string[] + */ + sortDirections?: SortOrder[] + + /** + * Title of this column + * @type any (string | slot) + */ + title?: VNodeChild | JSX.Element + + /** + * Width of this column + * @type string | number + */ + width?: string | number + + /** + * Set props on per cell + * @type Function + */ + customCell?: (record: T, rowIndex: number) => object + + /** + * Set props on per header cell + * @type object + */ + customHeaderCell?: (column: ColumnProps) => object + + /** + * Callback executed when the confirm filter button is clicked, Use as a filter event when using template or jsx + * @type Function + */ + onFilter?: (value: any, record: T) => boolean + + /** + * Callback executed when filterDropdownVisible is changed, Use as a filterDropdownVisible event when using template or jsx + * @type Function + */ + onFilterDropdownVisibleChange?: (visible: boolean) => void + + /** + * When using columns, you can setting this property to configure the properties that support the slot, + * such as slots: { filterIcon: 'XXX'} + * @type object + */ + slots?: Recordable +} diff --git a/frontend/vben/src/components/Table/src/types/componentType.ts b/frontend/vben/src/components/Table/src/types/componentType.ts new file mode 100644 index 0000000..293f07c --- /dev/null +++ b/frontend/vben/src/components/Table/src/types/componentType.ts @@ -0,0 +1,14 @@ +export type ComponentType = + | 'Input' + | 'InputNumber' + | 'Select' + | 'ApiSelect' + | 'AutoComplete' + | 'ApiTreeSelect' + | 'Checkbox' + | 'Switch' + | 'DatePicker' + | 'TimePicker' + | 'RadioGroup' + | 'RadioButtonGroup' + | 'ApiRadioGroup' diff --git a/frontend/vben/src/components/Table/src/types/pagination.ts b/frontend/vben/src/components/Table/src/types/pagination.ts new file mode 100644 index 0000000..354f143 --- /dev/null +++ b/frontend/vben/src/components/Table/src/types/pagination.ts @@ -0,0 +1,115 @@ +import Pagination from 'ant-design-vue/lib/pagination' +import { VNodeChild } from 'vue' + +interface PaginationRenderProps { + page: number + type: 'page' | 'prev' | 'next' + originalElement: any +} + +type PaginationPositon = + | 'topLeft' + | 'topCenter' + | 'topRight' + | 'bottomLeft' + | 'bottomCenter' + | 'bottomRight' + +export declare class PaginationConfig extends Pagination { + position?: PaginationPositon[] +} + +export interface PaginationProps { + /** + * total number of data items + * @default 0 + * @type number + */ + total?: number + + /** + * default initial page number + * @default 1 + * @type number + */ + defaultCurrent?: number + + /** + * current page number + * @type number + */ + current?: number + + /** + * default number of data items per page + * @default 10 + * @type number + */ + defaultPageSize?: number + + /** + * number of data items per page + * @type number + */ + pageSize?: number + + /** + * Whether to hide pager on single page + * @default false + * @type boolean + */ + hideOnSinglePage?: boolean + + /** + * determine whether pageSize can be changed + * @default false + * @type boolean + */ + showSizeChanger?: boolean + + /** + * specify the sizeChanger options + * @default ['10', '20', '30', '40'] + * @type string[] + */ + pageSizeOptions?: string[] + + /** + * determine whether you can jump to pages directly + * @default false + * @type boolean + */ + showQuickJumper?: boolean | object + + /** + * to display the total number and range + * @type Function + */ + showTotal?: (total: number, range: [number, number]) => any + + /** + * specify the size of Pagination, can be set to small + * @default '' + * @type string + */ + size?: string + + /** + * whether to setting simple mode + * @type boolean + */ + simple?: boolean + + /** + * to customize item innerHTML + * @type Function + */ + itemRender?: (props: PaginationRenderProps) => VNodeChild | JSX.Element + + /** + * specify the position of Pagination + * @default ['bottomRight'] + * @type string[] + */ + position?: PaginationPositon[] +} diff --git a/frontend/vben/src/components/Table/src/types/table.ts b/frontend/vben/src/components/Table/src/types/table.ts new file mode 100644 index 0000000..a97de96 --- /dev/null +++ b/frontend/vben/src/components/Table/src/types/table.ts @@ -0,0 +1,479 @@ +import type { VNodeChild } from 'vue' +import type { PaginationProps } from './pagination' +import type { FormProps } from '/@/components/Form' +import type { TableRowSelection as ITableRowSelection } from 'ant-design-vue/lib/table/interface' +import type { ColumnProps } from 'ant-design-vue/lib/table' + +import { ComponentType } from './componentType' +import { VueNode } from '/@/utils/propTypes' +import { RoleEnum } from '/@/enums/roleEnum' + +export declare type SortOrder = 'ascend' | 'descend' + +export interface TableCurrentDataSource { + currentDataSource: T[] +} + +export interface TableRowSelection extends ITableRowSelection { + /** + * Callback executed when selected rows change + * @type Function + */ + onChange?: (selectedRowKeys: string[] | number[], selectedRows: T[]) => any + + /** + * Callback executed when select/deselect one row + * @type Function + */ + onSelect?: (record: T, selected: boolean, selectedRows: Object[], nativeEvent: Event) => any + + /** + * Callback executed when select/deselect all rows + * @type Function + */ + onSelectAll?: (selected: boolean, selectedRows: T[], changeRows: T[]) => any + + /** + * Callback executed when row selection is inverted + * @type Function + */ + onSelectInvert?: (selectedRows: string[] | number[]) => any +} + +export interface TableCustomRecord { + record?: T + index?: number +} + +export interface ExpandedRowRenderRecord extends TableCustomRecord { + indent?: number + expanded?: boolean +} +export interface ColumnFilterItem { + text?: string + value?: string + children?: any +} + +export interface TableCustomRecord { + record?: T + index?: number +} + +export interface SorterResult { + column: ColumnProps + order: SortOrder + field: string + columnKey: string +} + +export interface FetchParams { + searchInfo?: Recordable + page?: number + sortInfo?: Recordable + filterInfo?: Recordable +} + +export interface GetColumnsParams { + ignoreIndex?: boolean + ignoreAction?: boolean + sort?: boolean +} + +export type SizeType = 'default' | 'middle' | 'small' | 'large' + +export interface TableActionType { + reload: (opt?: FetchParams) => Promise + setSelectedRows: (rows: Recordable[]) => void + getSelectRows: () => T[] + clearSelectedRowKeys: () => void + expandAll: () => void + expandRows: (keys: string[] | number[]) => void + collapseAll: () => void + scrollTo: (pos: string) => void // pos: id | "top" | "bottom" + getSelectRowKeys: () => string[] + deleteSelectRowByKey: (key: string) => void + setPagination: (info: Partial) => void + setTableData: (values: T[]) => void + updateTableDataRecord: (rowKey: string | number, record: Recordable) => Recordable | void + deleteTableDataRecord: (rowKey: string | number | string[] | number[]) => void + insertTableDataRecord: (record: Recordable | Recordable[], index?: number) => Recordable[] | void + findTableDataRecord: (rowKey: string | number) => Recordable | void + getColumns: (opt?: GetColumnsParams) => BasicColumn[] + setColumns: (columns: BasicColumn[] | string[]) => void + getDataSource: () => T[] + getRawDataSource: () => T + setLoading: (loading: boolean) => void + setProps: (props: Partial) => void + redoHeight: () => void + setSelectedRowKeys: (rowKeys: string[] | number[]) => void + getPaginationRef: () => PaginationProps | boolean + getSize: () => SizeType + getRowSelection: () => TableRowSelection + getCacheColumns: () => BasicColumn[] + emit?: EmitType + updateTableData: (index: number, key: string, value: any) => Recordable + setShowPagination: (show: boolean) => Promise + getShowPagination: () => boolean + setCacheColumnsByField?: (dataIndex: string | undefined, value: BasicColumn) => void +} + +export interface FetchSetting { + // 请求接口当前页数 + pageField: string + // 每页显示多少条 + sizeField: string + // 请求结果列表字段 支持 a.b.c + listField: string + // 请求结果总数字段 支持 a.b.c + totalField: string +} + +export interface TableSetting { + redo?: boolean + size?: boolean + setting?: boolean + fullScreen?: boolean +} + +export interface BasicTableProps { + // 点击行选中 + clickToRowSelect?: boolean + isTreeTable?: boolean + // 自定义排序方法 + sortFn?: (sortInfo: SorterResult) => any + // 排序方法 + filterFn?: (data: Partial>) => any + // 取消表格的默认padding + inset?: boolean + // 显示表格设置 + showTableSetting?: boolean + tableSetting?: TableSetting + // 斑马纹 + striped?: boolean + // 是否自动生成key + autoCreateKey?: boolean + // 计算合计行的方法 + summaryFunc?: (...arg: any) => Recordable[] + // 自定义合计表格内容 + summaryData?: Recordable[] + // 是否显示合计行 + showSummary?: boolean + // 是否可拖拽列 + canColDrag?: boolean + // 接口请求对象 + api?: (...arg: any) => Promise + // 请求之前处理参数 + beforeFetch?: Fn + // 自定义处理接口返回参数 + afterFetch?: Fn + // 查询条件请求之前处理 + handleSearchInfoFn?: Fn + // 请求接口配置 + fetchSetting?: Partial + // 立即请求接口 + immediate?: boolean + // 在开起搜索表单的时候,如果没有数据是否显示表格 + emptyDataIsShowTable?: boolean + // 额外的请求参数 + searchInfo?: Recordable + // 默认的排序参数 + defSort?: Recordable + // 使用搜索表单 + useSearchForm?: boolean + // 表单配置 + formConfig?: Partial + // 列配置 + columns: BasicColumn[] + // 是否显示序号列 + showIndexColumn?: boolean + // 序号列配置 + indexColumnProps?: BasicColumn + actionColumn?: BasicColumn + // 文本超过宽度是否显示。。。 + ellipsis?: boolean + // 是否继承父级高度(父级高度-表单高度-padding高度) + isCanResizeParent?: boolean + // 是否可以自适应高度 + canResize?: boolean + // 自适应高度偏移, 计算结果-偏移量 + resizeHeightOffset?: number + + // 在分页改变的时候清空选项 + clearSelectOnPageChange?: boolean + // + rowKey?: string | ((record: Recordable) => string) + // 数据 + dataSource?: Recordable[] + // 标题右侧提示 + titleHelpMessage?: string | string[] + // 表格滚动最大高度 + maxHeight?: number + // 是否显示边框 + bordered?: boolean + // 分页配置 + pagination?: PaginationProps | boolean + // loading加载 + loading?: boolean + + /** + * The column contains children to display + * @default 'children' + * @type string | string[] + */ + childrenColumnName?: string + + /** + * Override default table elements + * @type object + */ + components?: object + + /** + * Expand all rows initially + * @default false + * @type boolean + */ + defaultExpandAllRows?: boolean + + /** + * Initial expanded row keys + * @type string[] + */ + defaultExpandedRowKeys?: string[] + + /** + * Current expanded row keys + * @type string[] + */ + expandedRowKeys?: string[] + + /** + * Expanded container render for each row + * @type Function + */ + expandedRowRender?: (record?: ExpandedRowRenderRecord) => VNodeChild | JSX.Element + + /** + * Customize row expand Icon. + * @type Function | VNodeChild + */ + expandIcon?: Function | VNodeChild | JSX.Element + + /** + * Whether to expand row by clicking anywhere in the whole row + * @default false + * @type boolean + */ + expandRowByClick?: boolean + + /** + * The index of `expandIcon` which column will be inserted when `expandIconAsCell` is false. default 0 + */ + expandIconColumnIndex?: number + + /** + * Table footer renderer + * @type Function | VNodeChild + */ + footer?: Function | VNodeChild | JSX.Element + + /** + * Indent size in pixels of tree data + * @default 15 + * @type number + */ + indentSize?: number + + /** + * i18n text including filter, sort, empty text, etc + * @default { filterConfirm: 'Ok', filterReset: 'Reset', emptyText: 'No Data' } + * @type object + */ + locale?: object + + /** + * Row's className + * @type Function + */ + rowClassName?: (record: TableCustomRecord, index: number) => string + + /** + * Row selection config + * @type object + */ + rowSelection?: TableRowSelection + + /** + * Set horizontal or vertical scrolling, can also be used to specify the width and height of the scroll area. + * It is recommended to set a number for x, if you want to set it to true, + * you need to add style .ant-table td { white-space: nowrap }. + * @type object + */ + scroll?: { x?: number | string | true; y?: number | string } + + /** + * Whether to show table header + * @default true + * @type boolean + */ + showHeader?: boolean + + /** + * Size of table + * @default 'default' + * @type string + */ + size?: SizeType + + /** + * Table title renderer + * @type Function | ScopedSlot + */ + title?: VNodeChild | JSX.Element | string | ((data: Recordable) => string) + + /** + * Set props on per header row + * @type Function + */ + customHeaderRow?: (column: ColumnProps, index: number) => object + + /** + * Set props on per row + * @type Function + */ + customRow?: (record: T, index: number) => object + + /** + * `table-layout` attribute of table element + * `fixed` when header/columns are fixed, or using `column.ellipsis` + * + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/table-layout + * @version 1.5.0 + */ + tableLayout?: 'auto' | 'fixed' | string + + /** + * the render container of dropdowns in table + * @param triggerNode + * @version 1.5.0 + */ + getPopupContainer?: (triggerNode?: HTMLElement) => HTMLElement + + /** + * Data can be changed again before rendering. + * The default configuration of general user empty data. + * You can configured globally through [ConfigProvider](https://antdv.com/components/config-provider-cn/) + * + * @version 1.5.4 + */ + transformCellText?: Function + + /** + * Callback executed before editable cell submit value, not for row-editor + * + * The cell will not submit data while callback return false + */ + beforeEditSubmit?: (data: { + record: Recordable + index: number + key: string | number + value: any + }) => Promise + + /** + * Callback executed when pagination, filters or sorter is changed + * @param pagination + * @param filters + * @param sorter + * @param currentDataSource + */ + onChange?: (pagination: any, filters: any, sorter: any, extra: any) => void + + /** + * Callback executed when the row expand icon is clicked + * + * @param expanded + * @param record + */ + onExpand?: (expande: boolean, record: T) => void + + /** + * Callback executed when the expanded rows change + * @param expandedRows + */ + onExpandedRowsChange?: (expandedRows: string[] | number[]) => void + + onColumnsChange?: (data: ColumnChangeParam[]) => void +} + +export type CellFormat = + | string + | ((text: string, record: Recordable, index: number) => string | number) + | Map + +// @ts-ignore +export interface BasicColumn extends ColumnProps { + children?: BasicColumn[] + filters?: { + text: string + value: string + children?: + | unknown[] + | (((props: Record) => unknown[]) & (() => unknown[]) & (() => unknown[])) + }[] + + // + flag?: 'INDEX' | 'DEFAULT' | 'CHECKBOX' | 'RADIO' | 'ACTION' + customTitle?: VueNode + + slots?: Recordable + + // Whether to hide the column by default, it can be displayed in the column configuration + defaultHidden?: boolean + + // Help text for table column header + helpMessage?: string | string[] + + format?: CellFormat + + // Editable + edit?: boolean + editRow?: boolean + editable?: boolean + editComponent?: ComponentType + editComponentProps?: + | ((opt: { + text: string | number | boolean | Recordable + record: Recordable + column: BasicColumn + index: number + }) => Recordable) + | Recordable + editRule?: boolean | ((text: string, record: Recordable) => Promise) + editValueMap?: (value: any) => string + onEditRow?: () => void + // 权限编码控制是否显示 + auth?: RoleEnum | RoleEnum[] | string | string[] + // 业务控制是否显示 + ifShow?: boolean | ((column: BasicColumn) => boolean) + // 自定义修改后显示的内容 + editRender?: (opt: { + text: string | number | boolean | Recordable + record: Recordable + column: BasicColumn + index: number + }) => VNodeChild | JSX.Element + // 动态 Disabled + editDynamicDisabled?: boolean | ((record: Recordable) => boolean) +} + +export type ColumnChangeParam = { + dataIndex: string + fixed: boolean | 'left' | 'right' | undefined + visible: boolean +} + +export interface InnerHandlers { + onColumnsChange: (data: ColumnChangeParam[]) => void +} diff --git a/frontend/vben/src/components/Table/src/types/tableAction.ts b/frontend/vben/src/components/Table/src/types/tableAction.ts new file mode 100644 index 0000000..8bcef32 --- /dev/null +++ b/frontend/vben/src/components/Table/src/types/tableAction.ts @@ -0,0 +1,39 @@ +import { ButtonProps } from 'ant-design-vue/es/button/buttonTypes' +import { TooltipProps } from 'ant-design-vue/es/tooltip/Tooltip' +import { RoleEnum } from '/@/enums/roleEnum' +export interface ActionItem extends ButtonProps { + onClick?: Fn + label?: string + color?: 'success' | 'error' | 'warning' + icon?: string + popConfirm?: PopConfirm + disabled?: boolean + divider?: boolean + // 权限编码控制是否显示 + auth?: RoleEnum | RoleEnum[] | string | string[] + // 业务控制是否显示 + ifShow?: boolean | ((action: ActionItem) => boolean) + tooltip?: string | TooltipProps +} + +export interface PopConfirm { + title: string + okText?: string + cancelText?: string + confirm: Fn + cancel?: Fn + icon?: string + placement?: + | 'top' + | 'left' + | 'right' + | 'bottom' + | 'topLeft' + | 'topRight' + | 'leftTop' + | 'leftBottom' + | 'rightTop' + | 'rightBottom' + | 'bottomLeft' + | 'bottomRight' +} diff --git a/frontend/vben/src/components/Upload/index.ts b/frontend/vben/src/components/Upload/index.ts new file mode 100644 index 0000000..3ffa3da --- /dev/null +++ b/frontend/vben/src/components/Upload/index.ts @@ -0,0 +1,4 @@ +import { withInstall } from '/@/utils' +import basicUpload from './src/BasicUpload.vue' + +export const BasicUpload = withInstall(basicUpload) diff --git a/frontend/vben/src/components/Upload/src/BasicUpload.vue b/frontend/vben/src/components/Upload/src/BasicUpload.vue new file mode 100644 index 0000000..41c96c2 --- /dev/null +++ b/frontend/vben/src/components/Upload/src/BasicUpload.vue @@ -0,0 +1,111 @@ + + diff --git a/frontend/vben/src/components/Upload/src/FileList.vue b/frontend/vben/src/components/Upload/src/FileList.vue new file mode 100644 index 0000000..df1a78e --- /dev/null +++ b/frontend/vben/src/components/Upload/src/FileList.vue @@ -0,0 +1,97 @@ + + diff --git a/frontend/vben/src/components/Upload/src/ThumbUrl.vue b/frontend/vben/src/components/Upload/src/ThumbUrl.vue new file mode 100644 index 0000000..f52641c --- /dev/null +++ b/frontend/vben/src/components/Upload/src/ThumbUrl.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/vben/src/components/Upload/src/UploadModal.vue b/frontend/vben/src/components/Upload/src/UploadModal.vue new file mode 100644 index 0000000..c9a6fd9 --- /dev/null +++ b/frontend/vben/src/components/Upload/src/UploadModal.vue @@ -0,0 +1,298 @@ + + + diff --git a/frontend/vben/src/components/Upload/src/UploadPreviewModal.vue b/frontend/vben/src/components/Upload/src/UploadPreviewModal.vue new file mode 100644 index 0000000..f053d72 --- /dev/null +++ b/frontend/vben/src/components/Upload/src/UploadPreviewModal.vue @@ -0,0 +1,92 @@ + + + diff --git a/frontend/vben/src/components/Upload/src/data.tsx b/frontend/vben/src/components/Upload/src/data.tsx new file mode 100644 index 0000000..69c2ab3 --- /dev/null +++ b/frontend/vben/src/components/Upload/src/data.tsx @@ -0,0 +1,153 @@ +import type { BasicColumn, ActionItem } from '/@/components/Table' +import { FileItem, PreviewFileItem, UploadResultStatus } from './typing' +import { + // checkImgType, + isImgTypeByName, +} from './helper' +import { Progress, Tag } from 'ant-design-vue' +import TableAction from '/@/components/Table/src/components/TableAction.vue' +import ThumbUrl from './ThumbUrl.vue' +import { useI18n } from '/@/hooks/web/useI18n' + +const { t } = useI18n() + +// 文件上传列表 +export function createTableColumns(): BasicColumn[] { + return [ + { + dataIndex: 'thumbUrl', + title: t('component.upload.legend'), + width: 100, + customRender: ({ record }) => { + const { thumbUrl } = (record as FileItem) || {} + return thumbUrl && + }, + }, + { + dataIndex: 'name', + title: t('component.upload.fileName'), + align: 'left', + customRender: ({ text, record }) => { + const { percent, status: uploadStatus } = (record as FileItem) || {} + let status: 'normal' | 'exception' | 'active' | 'success' = 'normal' + if (uploadStatus === UploadResultStatus.ERROR) { + status = 'exception' + } else if (uploadStatus === UploadResultStatus.UPLOADING) { + status = 'active' + } else if (uploadStatus === UploadResultStatus.SUCCESS) { + status = 'success' + } + return ( + +

+ {text} +

+ +
+ ) + }, + }, + { + dataIndex: 'size', + title: t('component.upload.fileSize'), + width: 100, + customRender: ({ text = 0 }) => { + return text && (text / 1024).toFixed(2) + 'KB' + }, + }, + // { + // dataIndex: 'type', + // title: '文件类型', + // width: 100, + // }, + { + dataIndex: 'status', + title: t('component.upload.fileStatue'), + width: 100, + customRender: ({ text }) => { + if (text === UploadResultStatus.SUCCESS) { + return {() => t('component.upload.uploadSuccess')} + } else if (text === UploadResultStatus.ERROR) { + return {() => t('component.upload.uploadError')} + } else if (text === UploadResultStatus.UPLOADING) { + return {() => t('component.upload.uploading')} + } + + return text + }, + }, + ] +} +export function createActionColumn(handleRemove: Function): BasicColumn { + return { + width: 120, + title: t('component.upload.operating'), + dataIndex: 'action', + fixed: false, + customRender: ({ record }) => { + const actions: ActionItem[] = [ + { + label: t('component.upload.del'), + color: 'error', + onClick: handleRemove.bind(null, record), + }, + ] + // if (checkImgType(record)) { + // actions.unshift({ + // label: t('component.upload.preview'), + // onClick: handlePreview.bind(null, record), + // }) + // } + return + }, + } +} +// 文件预览列表 +export function createPreviewColumns(): BasicColumn[] { + return [ + { + dataIndex: 'url', + title: t('component.upload.legend'), + width: 100, + customRender: ({ record }) => { + const { url } = (record as PreviewFileItem) || {} + return isImgTypeByName(url) && + }, + }, + { + dataIndex: 'name', + title: t('component.upload.fileName'), + align: 'left', + }, + ] +} + +export function createPreviewActionColumn({ + handleRemove, + handleDownload, +}: { + handleRemove: Fn + handleDownload: Fn +}): BasicColumn { + return { + width: 160, + title: t('component.upload.operating'), + dataIndex: 'action', + fixed: false, + customRender: ({ record }) => { + const actions: ActionItem[] = [ + { + label: t('component.upload.del'), + color: 'error', + onClick: handleRemove.bind(null, record), + }, + { + label: t('component.upload.download'), + onClick: handleDownload.bind(null, record), + }, + ] + + return + }, + } +} diff --git a/frontend/vben/src/components/Upload/src/helper.ts b/frontend/vben/src/components/Upload/src/helper.ts new file mode 100644 index 0000000..83b48e5 --- /dev/null +++ b/frontend/vben/src/components/Upload/src/helper.ts @@ -0,0 +1,27 @@ +export function checkFileType(file: File, accepts: string[]) { + const newTypes = accepts.join('|') + // const reg = /\.(jpg|jpeg|png|gif|txt|doc|docx|xls|xlsx|xml)$/i + const reg = new RegExp('\\.(' + newTypes + ')$', 'i') + + return reg.test(file.name) +} + +export function checkImgType(file: File) { + return isImgTypeByName(file.name) +} + +export function isImgTypeByName(name: string) { + return /\.(jpg|jpeg|png|gif)$/i.test(name) +} + +export function getBase64WithFile(file: File) { + return new Promise<{ + result: string + file: File + }>((resolve, reject) => { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = () => resolve({ result: reader.result as string, file }) + reader.onerror = (error) => reject(error) + }) +} diff --git a/frontend/vben/src/components/Upload/src/props.ts b/frontend/vben/src/components/Upload/src/props.ts new file mode 100644 index 0000000..742c905 --- /dev/null +++ b/frontend/vben/src/components/Upload/src/props.ts @@ -0,0 +1,83 @@ +import type { PropType } from 'vue' +import { FileBasicColumn } from './typing' + +export const basicProps = { + helpText: { + type: String as PropType, + default: '', + }, + // 文件最大多少MB + maxSize: { + type: Number as PropType, + default: 2, + }, + // 最大数量的文件,Infinity不限制 + maxNumber: { + type: Number as PropType, + default: Infinity, + }, + // 根据后缀,或者其他 + accept: { + type: Array as PropType, + default: () => [], + }, + multiple: { + type: Boolean as PropType, + default: true, + }, + uploadParams: { + type: Object as PropType, + default: () => ({}), + }, + api: { + type: Function as PropType, + default: null, + required: true, + }, + name: { + type: String as PropType, + default: 'file', + }, + filename: { + type: String as PropType, + default: null, + }, +} + +export const uploadContainerProps = { + value: { + type: Array as PropType, + default: () => [], + }, + ...basicProps, + showPreviewNumber: { + type: Boolean as PropType, + default: true, + }, + emptyHidePreview: { + type: Boolean as PropType, + default: false, + }, +} + +export const previewProps = { + value: { + type: Array as PropType, + default: () => [], + }, +} + +export const fileListProps = { + columns: { + type: Array as PropType, + default: null, + }, + actionColumn: { + type: Object as PropType, + default: null, + }, + dataSource: { + type: Array as PropType, + default: null, + }, +} diff --git a/frontend/vben/src/components/Upload/src/typing.ts b/frontend/vben/src/components/Upload/src/typing.ts new file mode 100644 index 0000000..4b4d09a --- /dev/null +++ b/frontend/vben/src/components/Upload/src/typing.ts @@ -0,0 +1,55 @@ +import { UploadApiResult } from '/@/api/sys/model/uploadModel' + +export enum UploadResultStatus { + SUCCESS = 'success', + ERROR = 'error', + UPLOADING = 'uploading', +} + +export interface FileItem { + thumbUrl?: string + name: string + size: string | number + type?: string + percent: number + file: File + status?: UploadResultStatus + responseData?: UploadApiResult + uuid: string +} + +export interface PreviewFileItem { + url: string + name: string + type: string +} + +export interface FileBasicColumn { + /** + * Renderer of the table cell. The return value should be a VNode, or an object for colSpan/rowSpan config + * @type Function | ScopedSlot + */ + customRender?: Function + /** + * Title of this column + * @type any (string | slot) + */ + title: string + + /** + * Width of this column + * @type string | number + */ + width?: number + /** + * Display field of the data record, could be set like a.b.c + * @type string + */ + dataIndex: string + /** + * specify how content is aligned + * @default 'left' + * @type string + */ + align?: 'left' | 'right' | 'center' +} diff --git a/frontend/vben/src/components/Upload/src/useUpload.ts b/frontend/vben/src/components/Upload/src/useUpload.ts new file mode 100644 index 0000000..033666a --- /dev/null +++ b/frontend/vben/src/components/Upload/src/useUpload.ts @@ -0,0 +1,60 @@ +import { Ref, unref, computed } from 'vue' +import { useI18n } from '/@/hooks/web/useI18n' +const { t } = useI18n() +export function useUploadType({ + acceptRef, + helpTextRef, + maxNumberRef, + maxSizeRef, +}: { + acceptRef: Ref + helpTextRef: Ref + maxNumberRef: Ref + maxSizeRef: Ref +}) { + // 文件类型限制 + const getAccept = computed(() => { + const accept = unref(acceptRef) + if (accept && accept.length > 0) { + return accept + } + return [] + }) + const getStringAccept = computed(() => { + return unref(getAccept) + .map((item) => { + if (item.indexOf('/') > 0 || item.startsWith('.')) { + return item + } else { + return `.${item}` + } + }) + .join(',') + }) + + // 支持jpg、jpeg、png格式,不超过2M,最多可选择10张图片,。 + const getHelpText = computed(() => { + const helpText = unref(helpTextRef) + if (helpText) { + return helpText + } + const helpTexts: string[] = [] + + const accept = unref(acceptRef) + if (accept.length > 0) { + helpTexts.push(t('component.upload.accept', [accept.join(',')])) + } + + const maxSize = unref(maxSizeRef) + if (maxSize) { + helpTexts.push(t('component.upload.maxSize', [maxSize])) + } + + const maxNumber = unref(maxNumberRef) + if (maxNumber && maxNumber !== Infinity) { + helpTexts.push(t('component.upload.maxNumber', [maxNumber])) + } + return helpTexts.join(',') + }) + return { getAccept, getStringAccept, getHelpText } +} diff --git a/frontend/vben/src/views/page/filelist/data.tsx b/frontend/vben/src/views/page/filelist/data.tsx index 2598df3..2893eff 100644 --- a/frontend/vben/src/views/page/filelist/data.tsx +++ b/frontend/vben/src/views/page/filelist/data.tsx @@ -6,7 +6,6 @@ export const cardList = (() => { title: 'Vben Admin', description: '基于Vue Next, TypeScript, Ant Design Vue实现的一套完整的企业级后台管理系统', datetime: '2020-11-26 17:39', - extra: '编辑', icon: 'logos:vue', color: '#1890ff', author: 'Vben', diff --git a/frontend/vben/src/views/page/filelist/index.vue b/frontend/vben/src/views/page/filelist/index.vue index 6ba8c1f..10d5b4e 100644 --- a/frontend/vben/src/views/page/filelist/index.vue +++ b/frontend/vben/src/views/page/filelist/index.vue @@ -27,7 +27,7 @@