1
0
mirror of https://github.com/fumiama/paper-manager.git synced 2026-06-10 10:50:23 +08:00

add Form Table Upload to components

This commit is contained in:
源文雨
2023-03-15 11:15:06 +08:00
parent 0845a95eb8
commit 1dfebb09e2
80 changed files with 9628 additions and 2 deletions

View File

@@ -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 }

View File

@@ -0,0 +1,355 @@
<template>
<Form
v-bind="getBindValue"
:class="getFormClass"
ref="formElRef"
:model="formModel"
@keypress.enter="handleEnterPress"
>
<Row v-bind="getRow">
<slot name="formHeader"></slot>
<template v-for="schema in getSchema" :key="schema.field">
<FormItem
:isAdvanced="fieldsIsAdvancedMap[schema.field]"
:tableAction="tableAction"
:formActionType="formActionType"
:schema="schema"
:formProps="getProps"
:allDefaultValues="defaultValueRef"
:formModel="formModel"
:setFormModel="setFormModel"
>
<template #[item]="data" v-for="item in Object.keys($slots)">
<slot :name="item" v-bind="data || {}"></slot>
</template>
</FormItem>
</template>
<FormAction v-bind="getFormActionBindProps" @toggle-advanced="handleToggleAdvanced">
<template
#[item]="data"
v-for="item in ['resetBefore', 'submitBefore', 'advanceBefore', 'advanceAfter']"
>
<slot :name="item" v-bind="data || {}"></slot>
</template>
</FormAction>
<slot name="formFooter"></slot>
</Row>
</Form>
</template>
<script lang="ts">
import type { FormActionType, FormProps, FormSchema } from './types/form'
import type { AdvanceState } from './types/hooks'
import type { Ref } from 'vue'
import { defineComponent, reactive, ref, computed, unref, onMounted, watch, nextTick } from 'vue'
import { Form, Row } from 'ant-design-vue'
import FormItem from './components/FormItem.vue'
import FormAction from './components/FormAction.vue'
import { dateItemType } from './helper'
import { dateUtil } from '/@/utils/dateUtil'
// import { cloneDeep } from 'lodash-es'
import { deepMerge } from '/@/utils'
import { useFormValues } from './hooks/useFormValues'
import useAdvanced from './hooks/useAdvanced'
import { useFormEvents } from './hooks/useFormEvents'
import { createFormContext } from './hooks/useFormContext'
import { useAutoFocus } from './hooks/useAutoFocus'
import { useModalContext } from '/@/components/Modal'
import { useDebounceFn } from '@vueuse/core'
import { basicProps } from './props'
import { useDesign } from '/@/hooks/web/useDesign'
import { cloneDeep } from 'lodash-es'
export default defineComponent({
name: 'BasicForm',
components: { FormItem, Form, Row, FormAction },
props: basicProps,
emits: ['advanced-change', 'reset', 'submit', 'register', 'field-value-change'],
setup(props, { emit, attrs }) {
const formModel = reactive<Recordable>({})
const modalFn = useModalContext()
const advanceState = reactive<AdvanceState>({
isAdvanced: true,
hideAdvanceBtn: false,
isLoad: false,
actionSpan: 6,
})
const defaultValueRef = ref<Recordable>({})
const isInitedDefaultRef = ref(false)
const propsRef = ref<Partial<FormProps>>({})
const schemaRef = ref<Nullable<FormSchema[]>>(null)
const formElRef = ref<Nullable<FormActionType>>(null)
const { prefixCls } = useDesign('basic-form')
// Get the basic configuration of the form
const getProps = computed((): FormProps => {
return { ...props, ...unref(propsRef) } as FormProps
})
const getFormClass = computed(() => {
return [
prefixCls,
{
[`${prefixCls}--compact`]: unref(getProps).compact,
},
]
})
// Get uniform row style and Row configuration for the entire form
const getRow = computed((): Recordable => {
const { baseRowStyle = {}, rowProps } = unref(getProps)
return {
style: baseRowStyle,
...rowProps,
}
})
const getBindValue = computed(
() => ({ ...attrs, ...props, ...unref(getProps) } as Recordable),
)
const getSchema = computed((): FormSchema[] => {
const schemas: FormSchema[] = unref(schemaRef) || (unref(getProps).schemas as any)
for (const schema of schemas) {
const { defaultValue, component, isHandleDateDefaultValue = true } = schema
// handle date type
if (isHandleDateDefaultValue && defaultValue && dateItemType.includes(component)) {
if (!Array.isArray(defaultValue)) {
schema.defaultValue = dateUtil(defaultValue)
} else {
const def: any[] = []
defaultValue.forEach((item) => {
def.push(dateUtil(item))
})
schema.defaultValue = def
}
}
}
if (unref(getProps).showAdvancedButton) {
return cloneDeep(
schemas.filter((schema) => schema.component !== 'Divider') as FormSchema[],
)
} else {
return cloneDeep(schemas as FormSchema[])
}
})
const { handleToggleAdvanced, fieldsIsAdvancedMap } = useAdvanced({
advanceState,
emit,
getProps,
getSchema,
formModel,
defaultValueRef,
})
const { handleFormValues, initDefault } = useFormValues({
getProps,
defaultValueRef,
getSchema,
formModel,
})
useAutoFocus({
getSchema,
getProps,
isInitedDefault: isInitedDefaultRef,
formElRef: formElRef as Ref<FormActionType>,
})
const {
handleSubmit,
setFieldsValue,
clearValidate,
validate,
validateFields,
getFieldsValue,
updateSchema,
resetSchema,
appendSchemaByField,
removeSchemaByField,
resetFields,
scrollToField,
} = useFormEvents({
emit,
getProps,
formModel,
getSchema,
defaultValueRef,
formElRef: formElRef as Ref<FormActionType>,
schemaRef: schemaRef as Ref<FormSchema[]>,
handleFormValues,
})
createFormContext({
resetAction: resetFields,
submitAction: handleSubmit,
})
watch(
() => unref(getProps).model,
() => {
const { model } = unref(getProps)
if (!model) return
setFieldsValue(model)
},
{
immediate: true,
},
)
watch(
() => unref(getProps).schemas,
(schemas) => {
resetSchema(schemas ?? [])
},
)
watch(
() => getSchema.value,
(schema) => {
nextTick(() => {
// Solve the problem of modal adaptive height calculation when the form is placed in the modal
modalFn?.redoModalHeight?.()
})
if (unref(isInitedDefaultRef)) {
return
}
if (schema?.length) {
initDefault()
isInitedDefaultRef.value = true
}
},
)
watch(
() => formModel,
useDebounceFn(() => {
unref(getProps).submitOnChange && handleSubmit()
}, 300),
{ deep: true },
)
async function setProps(formProps: Partial<FormProps>): Promise<void> {
propsRef.value = deepMerge(unref(propsRef) || {}, formProps)
}
function setFormModel(key: string, value: any, schema: FormSchema) {
formModel[key] = value
emit('field-value-change', key, value)
// TODO 优化验证这里如果是autoLink=false手动关联的情况下才会再次触发此函数
if (schema && schema.itemProps && !schema.itemProps.autoLink) {
validateFields([key]).catch((_) => {})
}
}
function handleEnterPress(e: KeyboardEvent) {
const { autoSubmitOnEnter } = unref(getProps)
if (!autoSubmitOnEnter) return
if (e.key === 'Enter' && e.target && e.target instanceof HTMLElement) {
const target: HTMLElement = e.target as HTMLElement
if (target && target.tagName && target.tagName.toUpperCase() == 'INPUT') {
handleSubmit()
}
}
}
const formActionType: Partial<FormActionType> = {
getFieldsValue,
setFieldsValue,
resetFields,
updateSchema,
resetSchema,
setProps,
removeSchemaByField,
appendSchemaByField,
clearValidate,
validateFields,
validate,
submit: handleSubmit,
scrollToField: scrollToField,
}
onMounted(() => {
initDefault()
emit('register', formActionType)
})
return {
getBindValue,
handleToggleAdvanced,
handleEnterPress,
formModel,
defaultValueRef,
advanceState,
getRow,
getProps,
formElRef,
getSchema,
formActionType: formActionType as any,
setFormModel,
getFormClass,
getFormActionBindProps: computed(
(): Recordable => ({ ...getProps.value, ...advanceState }),
),
fieldsIsAdvancedMap,
...formActionType,
}
},
})
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-basic-form';
.@{prefix-cls} {
.ant-form-item {
&-label label::after {
margin: 0 6px 0 2px;
}
&-with-help {
margin-bottom: 0;
}
&:not(.ant-form-item-with-help) {
margin-bottom: 20px;
}
&.suffix-item {
.ant-form-item-children {
display: flex;
}
.ant-form-item-control {
margin-top: 4px;
}
.suffix {
display: inline-flex;
padding-left: 6px;
margin-top: 1px;
line-height: 1;
align-items: center;
}
}
}
.ant-form-explain {
font-size: 14px;
}
&--compact {
.ant-form-item {
margin-bottom: 8px !important;
}
}
}
</style>

View File

@@ -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<ComponentType, Component>();
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 };

View File

@@ -0,0 +1,198 @@
<template>
<a-cascader
v-model:value="state"
:options="options"
:load-data="loadData"
change-on-select
@change="handleChange"
:displayRender="handleRenderDisplay"
>
<template #suffixIcon v-if="loading">
<LoadingOutlined spin />
</template>
<template #notFoundContent v-if="loading">
<span>
<LoadingOutlined spin class="mr-1" />
{{ t('component.form.apiSelectNotFound') }}
</span>
</template>
</a-cascader>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, unref, watch, watchEffect } from 'vue';
import { Cascader } from 'ant-design-vue';
import { propTypes } from '/@/utils/propTypes';
import { isFunction } from '/@/utils/is';
import { get, omit } from 'lodash-es';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
import { LoadingOutlined } from '@ant-design/icons-vue';
import { useI18n } from '/@/hooks/web/useI18n';
interface Option {
value: string;
label: string;
loading?: boolean;
isLeaf?: boolean;
children?: Option[];
}
export default defineComponent({
name: 'ApiCascader',
components: {
LoadingOutlined,
[Cascader.name]: Cascader,
},
props: {
value: {
type: Array,
},
api: {
type: Function as PropType<(arg?: Recordable) => Promise<Option[]>>,
default: null,
},
numberToString: propTypes.bool,
resultField: propTypes.string.def(''),
labelField: propTypes.string.def('label'),
valueField: propTypes.string.def('value'),
childrenField: propTypes.string.def('children'),
asyncFetchParamKey: propTypes.string.def('parentCode'),
immediate: propTypes.bool.def(true),
// init fetch params
initFetchParams: {
type: Object as PropType<Recordable>,
default: () => ({}),
},
// 是否有下级,默认是
isLeaf: {
type: Function as PropType<(arg: Recordable) => boolean>,
default: null,
},
displayRenderArray: {
type: Array,
},
},
emits: ['change', 'defaultChange'],
setup(props, { emit }) {
const apiData = ref<any[]>([]);
const options = ref<Option[]>([]);
const loading = ref<boolean>(false);
const emitData = ref<any[]>([]);
const isFirstLoad = ref(true);
const { t } = useI18n();
// Embedded in the form, just use the hook binding to perform form verification
const [state] = useRuleFormItem(props, 'value', 'change', emitData);
watch(
apiData,
(data) => {
const opts = generatorOptions(data);
options.value = opts;
},
{ deep: true },
);
function generatorOptions(options: any[]): Option[] {
const { labelField, valueField, numberToString, childrenField, isLeaf } = props;
return options.reduce((prev, next: Recordable) => {
if (next) {
const value = next[valueField];
const item = {
...omit(next, [labelField, valueField]),
label: next[labelField],
value: numberToString ? `${value}` : value,
isLeaf: isLeaf && typeof isLeaf === 'function' ? isLeaf(next) : false,
};
const children = Reflect.get(next, childrenField);
if (children) {
Reflect.set(item, childrenField, generatorOptions(children));
}
prev.push(item);
}
return prev;
}, [] as Option[]);
}
async function initialFetch() {
const api = props.api;
if (!api || !isFunction(api)) return;
apiData.value = [];
loading.value = true;
try {
const res = await api(props.initFetchParams);
if (Array.isArray(res)) {
apiData.value = res;
return;
}
if (props.resultField) {
apiData.value = get(res, props.resultField) || [];
}
} catch (error) {
console.warn(error);
} finally {
loading.value = false;
}
}
async function loadData(selectedOptions: Option[]) {
const targetOption = selectedOptions[selectedOptions.length - 1];
targetOption.loading = true;
const api = props.api;
if (!api || !isFunction(api)) return;
try {
const res = await api({
[props.asyncFetchParamKey]: Reflect.get(targetOption, 'value'),
});
if (Array.isArray(res)) {
const children = generatorOptions(res);
targetOption.children = children;
return;
}
if (props.resultField) {
const children = generatorOptions(get(res, props.resultField) || []);
targetOption.children = children;
}
} catch (e) {
console.error(e);
} finally {
targetOption.loading = false;
}
}
watchEffect(() => {
props.immediate && initialFetch();
});
watch(
() => props.initFetchParams,
() => {
!unref(isFirstLoad) && initialFetch();
},
{ deep: true },
);
function handleChange(keys, args) {
emitData.value = args;
emit('defaultChange', keys, args);
}
function handleRenderDisplay({ labels, selectedOptions }) {
if (unref(emitData).length === selectedOptions.length) {
return labels.join(' / ');
}
if (props.displayRenderArray) {
return props.displayRenderArray.join(' / ');
}
return '';
}
return {
state,
options,
loading,
t,
handleChange,
loadData,
handleRenderDisplay,
};
},
});
</script>

View File

@@ -0,0 +1,135 @@
<!--
* @Description:It is troublesome to implement radio button group in the form. So it is extracted independently as a separate component
-->
<template>
<RadioGroup v-bind="attrs" v-model:value="state" button-style="solid">
<template v-for="item in getOptions" :key="`${item.value}`">
<RadioButton
v-if="props.isBtn"
:value="item.value"
:disabled="item.disabled"
@click="handleClick(item)"
>
{{ item.label }}
</RadioButton>
<Radio v-else :value="item.value" :disabled="item.disabled" @click="handleClick(item)">
{{ item.label }}
</Radio>
</template>
</RadioGroup>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, watchEffect, computed, unref, watch } from 'vue';
import { Radio } from 'ant-design-vue';
import { isFunction } from '/@/utils/is';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { propTypes } from '/@/utils/propTypes';
import { get, omit } from 'lodash-es';
import { useI18n } from '/@/hooks/web/useI18n';
type OptionsItem = { label: string; value: string | number | boolean; disabled?: boolean };
export default defineComponent({
name: 'ApiRadioGroup',
components: {
RadioGroup: Radio.Group,
RadioButton: Radio.Button,
Radio,
},
props: {
api: {
type: Function as PropType<(arg?: Recordable | string) => Promise<OptionsItem[]>>,
default: null,
},
params: {
type: [Object, String] as PropType<Recordable | string>,
default: () => ({}),
},
value: {
type: [String, Number, Boolean] as PropType<string | number | boolean>,
},
isBtn: {
type: [Boolean] as PropType<boolean>,
default: false,
},
numberToString: propTypes.bool,
resultField: propTypes.string.def(''),
labelField: propTypes.string.def('label'),
valueField: propTypes.string.def('value'),
immediate: propTypes.bool.def(true),
},
emits: ['options-change', 'change'],
setup(props, { emit }) {
const options = ref<OptionsItem[]>([]);
const loading = ref(false);
const isFirstLoad = ref(true);
const emitData = ref<any[]>([]);
const attrs = useAttrs();
const { t } = useI18n();
// Embedded in the form, just use the hook binding to perform form verification
const [state] = useRuleFormItem(props, 'value', 'change', emitData);
// Processing options value
const getOptions = computed(() => {
const { labelField, valueField, numberToString } = props;
return unref(options).reduce((prev, next: Recordable) => {
if (next) {
const value = next[valueField];
prev.push({
label: next[labelField],
value: numberToString ? `${value}` : value,
...omit(next, [labelField, valueField]),
});
}
return prev;
}, [] as OptionsItem[]);
});
watchEffect(() => {
props.immediate && fetch();
});
watch(
() => props.params,
() => {
!unref(isFirstLoad) && fetch();
},
{ deep: true },
);
async function fetch() {
const api = props.api;
if (!api || !isFunction(api)) return;
options.value = [];
try {
loading.value = true;
const res = await api(props.params);
if (Array.isArray(res)) {
options.value = res;
emitChange();
return;
}
if (props.resultField) {
options.value = get(res, props.resultField) || [];
}
emitChange();
} catch (error) {
console.warn(error);
} finally {
loading.value = false;
}
}
function emitChange() {
emit('options-change', unref(getOptions));
}
function handleClick(...args) {
emitData.value = args;
}
return { state, getOptions, attrs, loading, t, handleClick, props };
},
});
</script>

View File

@@ -0,0 +1,151 @@
<template>
<Select
@dropdown-visible-change="handleFetch"
v-bind="$attrs"
@change="handleChange"
:options="getOptions"
v-model:value="state"
>
<template #[item]="data" v-for="item in Object.keys($slots)">
<slot :name="item" v-bind="data || {}"></slot>
</template>
<template #suffixIcon v-if="loading">
<LoadingOutlined spin />
</template>
<template #notFoundContent v-if="loading">
<span>
<LoadingOutlined spin class="mr-1" />
{{ t('component.form.apiSelectNotFound') }}
</span>
</template>
</Select>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, watchEffect, computed, unref, watch } from 'vue';
import { Select } from 'ant-design-vue';
import { isFunction } from '/@/utils/is';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { get, omit } from 'lodash-es';
import { LoadingOutlined } from '@ant-design/icons-vue';
import { useI18n } from '/@/hooks/web/useI18n';
import { propTypes } from '/@/utils/propTypes';
type OptionsItem = { label: string; value: string; disabled?: boolean };
export default defineComponent({
name: 'ApiSelect',
components: {
Select,
LoadingOutlined,
},
inheritAttrs: false,
props: {
value: [Array, Object, String, Number],
numberToString: propTypes.bool,
api: {
type: Function as PropType<(arg?: Recordable) => Promise<OptionsItem[]>>,
default: null,
},
// api params
params: propTypes.any.def({}),
// support xxx.xxx.xx
resultField: propTypes.string.def(''),
labelField: propTypes.string.def('label'),
valueField: propTypes.string.def('value'),
immediate: propTypes.bool.def(true),
alwaysLoad: propTypes.bool.def(false),
},
emits: ['options-change', 'change', 'update:value'],
setup(props, { emit }) {
const options = ref<OptionsItem[]>([]);
const loading = ref(false);
const isFirstLoad = ref(true);
const emitData = ref<any[]>([]);
const attrs = useAttrs();
const { t } = useI18n();
// Embedded in the form, just use the hook binding to perform form verification
const [state] = useRuleFormItem(props, 'value', 'change', emitData);
const getOptions = computed(() => {
const { labelField, valueField, numberToString } = props;
return unref(options).reduce((prev, next: Recordable) => {
if (next) {
const value = next[valueField];
prev.push({
...omit(next, [labelField, valueField]),
label: next[labelField],
value: numberToString ? `${value}` : value,
});
}
return prev;
}, [] as OptionsItem[]);
});
watchEffect(() => {
props.immediate && !props.alwaysLoad && fetch();
});
watch(
() => state.value,
(v) => {
emit('update:value', v);
},
);
watch(
() => props.params,
() => {
!unref(isFirstLoad) && fetch();
},
{ deep: true },
);
async function fetch() {
const api = props.api;
if (!api || !isFunction(api)) return;
options.value = [];
try {
loading.value = true;
const res = await api(props.params);
if (Array.isArray(res)) {
options.value = res;
emitChange();
return;
}
if (props.resultField) {
options.value = get(res, props.resultField) || [];
}
emitChange();
} catch (error) {
console.warn(error);
} finally {
loading.value = false;
}
}
async function handleFetch(visible) {
if (visible) {
if (props.alwaysLoad) {
await fetch();
} else if (!props.immediate && unref(isFirstLoad)) {
await fetch();
isFirstLoad.value = false;
}
}
}
function emitChange() {
emit('options-change', unref(getOptions));
}
function handleChange(_, ...args) {
emitData.value = args;
}
return { state, attrs, getOptions, loading, t, handleFetch, handleChange };
},
});
</script>

View File

@@ -0,0 +1,137 @@
<template>
<Transfer
:data-source="getdataSource"
:filter-option="filterOption"
:render="(item) => item.title"
:showSelectAll="showSelectAll"
:selectedKeys="selectedKeys"
:targetKeys="getTargetKeys"
:showSearch="showSearch"
@change="handleChange"
/>
</template>
<script lang="ts">
import { computed, defineComponent, watch, ref, unref, watchEffect } from 'vue';
import { Transfer } from 'ant-design-vue';
import { isFunction } from '/@/utils/is';
import { get, omit } from 'lodash-es';
import { propTypes } from '/@/utils/propTypes';
import { useI18n } from '/@/hooks/web/useI18n';
import { TransferDirection, TransferItem } from 'ant-design-vue/lib/transfer';
export default defineComponent({
name: 'ApiTransfer',
components: { Transfer },
props: {
value: { type: Array as PropType<Array<string>> },
api: {
type: Function as PropType<(arg?: Recordable) => Promise<TransferItem[]>>,
default: null,
},
params: { type: Object },
dataSource: { type: Array as PropType<Array<TransferItem>> },
immediate: propTypes.bool.def(true),
alwaysLoad: propTypes.bool.def(false),
afterFetch: { type: Function as PropType<Fn> },
resultField: propTypes.string.def(''),
labelField: propTypes.string.def('title'),
valueField: propTypes.string.def('key'),
showSearch: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
filterOption: {
type: Function as PropType<(inputValue: string, item: TransferItem) => boolean>,
},
selectedKeys: { type: Array as PropType<Array<string>> },
showSelectAll: { type: Boolean, default: false },
targetKeys: { type: Array as PropType<Array<string>> },
},
emits: ['options-change', 'change'],
setup(props, { attrs, emit }) {
const _dataSource = ref<TransferItem[]>([]);
const _targetKeys = ref<string[]>([]);
const { t } = useI18n();
const getAttrs = computed(() => {
return {
...(!props.api ? { dataSource: unref(_dataSource) } : {}),
...attrs,
};
});
const getdataSource = computed(() => {
const { labelField, valueField } = props;
return unref(_dataSource).reduce((prev, next: Recordable) => {
if (next) {
prev.push({
...omit(next, [labelField, valueField]),
title: next[labelField],
key: next[valueField],
});
}
return prev;
}, [] as TransferItem[]);
});
const getTargetKeys = computed<string[]>(() => {
if (unref(_targetKeys).length > 0) {
return unref(_targetKeys);
}
if (Array.isArray(props.value)) {
return props.value;
}
if (Array.isArray(props.targetKeys)){
return props.targetKeys;
}
return [];
});
function handleChange(keys: string[], direction: TransferDirection, moveKeys: string[]) {
_targetKeys.value = keys;
console.log(direction);
console.log(moveKeys);
emit('change', keys);
}
watchEffect(() => {
props.immediate && !props.alwaysLoad && fetch();
});
watch(
() => props.params,
() => {
fetch();
},
{ deep: true },
);
async function fetch() {
const api = props.api;
if (!api || !isFunction(api)) {
if (Array.isArray(props.dataSource)) {
_dataSource.value = props.dataSource;
}
return;
}
_dataSource.value = [];
try {
const res = await api(props.params);
if (Array.isArray(res)) {
_dataSource.value = res;
emitChange();
return;
}
if (props.resultField) {
_dataSource.value = get(res, props.resultField) || [];
}
emitChange();
} catch (error) {
console.warn(error);
} finally {
}
}
function emitChange() {
emit('options-change', unref(getdataSource));
}
return { getTargetKeys, getdataSource, t, getAttrs, handleChange };
},
});
</script>

View File

@@ -0,0 +1,90 @@
<template>
<a-tree v-bind="getAttrs" @change="handleChange">
<template #[item]="data" v-for="item in Object.keys($slots)">
<slot :name="item" v-bind="data || {}"></slot>
</template>
<template #suffixIcon v-if="loading">
<LoadingOutlined spin />
</template>
</a-tree>
</template>
<script lang="ts">
import { computed, defineComponent, watch, ref, onMounted, unref } from 'vue';
import { Tree } from 'ant-design-vue';
import { isArray, isFunction } from '/@/utils/is';
import { get } from 'lodash-es';
import { propTypes } from '/@/utils/propTypes';
import { LoadingOutlined } from '@ant-design/icons-vue';
export default defineComponent({
name: 'ApiTree',
components: { ATree: Tree, LoadingOutlined },
props: {
api: { type: Function as PropType<(arg?: Recordable) => Promise<Recordable>> },
params: { type: Object },
immediate: { type: Boolean, default: true },
resultField: propTypes.string.def(''),
afterFetch: { type: Function as PropType<Fn> },
},
emits: ['options-change', 'change'],
setup(props, { attrs, emit }) {
const treeData = ref<Recordable[]>([]);
const isFirstLoaded = ref<Boolean>(false);
const loading = ref(false);
const getAttrs = computed(() => {
return {
...(props.api ? { treeData: unref(treeData) } : {}),
...attrs,
};
});
function handleChange(...args) {
emit('change', ...args);
}
watch(
() => props.params,
() => {
!unref(isFirstLoaded) && fetch();
},
{ deep: true },
);
watch(
() => props.immediate,
(v) => {
v && !isFirstLoaded.value && fetch();
},
);
onMounted(() => {
props.immediate && fetch();
});
async function fetch() {
const { api, afterFetch } = props;
if (!api || !isFunction(api)) return;
loading.value = true;
treeData.value = [];
let result;
try {
result = await api(props.params);
} catch (e) {
console.error(e);
}
if (afterFetch && isFunction(afterFetch)) {
result = afterFetch(result);
}
loading.value = false;
if (!result) return;
if (!isArray(result)) {
result = get(result, props.resultField);
}
treeData.value = (result as Recordable[]) || [];
isFirstLoaded.value = true;
emit('options-change', treeData.value);
}
return { getAttrs, loading, handleChange };
},
});
</script>

View File

@@ -0,0 +1,86 @@
<template>
<a-tree-select v-bind="getAttrs" @change="handleChange">
<template #[item]="data" v-for="item in Object.keys($slots)">
<slot :name="item" v-bind="data || {}"></slot>
</template>
<template #suffixIcon v-if="loading">
<LoadingOutlined spin />
</template>
</a-tree-select>
</template>
<script lang="ts">
import { computed, defineComponent, watch, ref, onMounted, unref } from 'vue';
import { TreeSelect } from 'ant-design-vue';
import { isArray, isFunction } from '/@/utils/is';
import { get } from 'lodash-es';
import { propTypes } from '/@/utils/propTypes';
import { LoadingOutlined } from '@ant-design/icons-vue';
export default defineComponent({
name: 'ApiTreeSelect',
components: { ATreeSelect: TreeSelect, LoadingOutlined },
props: {
api: { type: Function as PropType<(arg?: Recordable) => Promise<Recordable>> },
params: { type: Object },
immediate: { type: Boolean, default: true },
resultField: propTypes.string.def(''),
},
emits: ['options-change', 'change'],
setup(props, { attrs, emit }) {
const treeData = ref<Recordable[]>([]);
const isFirstLoaded = ref<Boolean>(false);
const loading = ref(false);
const getAttrs = computed(() => {
return {
...(props.api ? { treeData: unref(treeData) } : {}),
...attrs,
};
});
function handleChange(...args) {
emit('change', ...args);
}
watch(
() => props.params,
() => {
!unref(isFirstLoaded) && fetch();
},
{ deep: true },
);
watch(
() => props.immediate,
(v) => {
v && !isFirstLoaded.value && fetch();
},
);
onMounted(() => {
props.immediate && fetch();
});
async function fetch() {
const { api } = props;
if (!api || !isFunction(api)) return;
loading.value = true;
treeData.value = [];
let result;
try {
result = await api(props.params);
} catch (e) {
console.error(e);
}
loading.value = false;
if (!result) return;
if (!isArray(result)) {
result = get(result, props.resultField);
}
treeData.value = (result as Recordable[]) || [];
isFirstLoaded.value = true;
emit('options-change', treeData.value);
}
return { getAttrs, loading, handleChange };
},
});
</script>

View File

@@ -0,0 +1,135 @@
<template>
<a-col v-bind="actionColOpt" v-if="showActionButtonGroup">
<div style="width: 100%" :style="{ textAlign: actionColOpt.style.textAlign }">
<FormItem>
<slot name="resetBefore"></slot>
<Button
type="default"
class="mr-2"
v-bind="getResetBtnOptions"
@click="resetAction"
v-if="showResetButton"
>
{{ getResetBtnOptions.text }}
</Button>
<slot name="submitBefore"></slot>
<Button
type="primary"
class="mr-2"
v-bind="getSubmitBtnOptions"
@click="submitAction"
v-if="showSubmitButton"
>
{{ getSubmitBtnOptions.text }}
</Button>
<slot name="advanceBefore"></slot>
<Button
type="link"
size="small"
@click="toggleAdvanced"
v-if="showAdvancedButton && !hideAdvanceBtn"
>
{{ isAdvanced ? t('component.form.putAway') : t('component.form.unfold') }}
<BasicArrow class="ml-1" :expand="!isAdvanced" up />
</Button>
<slot name="advanceAfter"></slot>
</FormItem>
</div>
</a-col>
</template>
<script lang="ts">
import type { ColEx } from '../types/index';
//import type { ButtonProps } from 'ant-design-vue/es/button/buttonTypes';
import { defineComponent, computed, PropType } from 'vue';
import { Form, Col } from 'ant-design-vue';
import { Button, ButtonProps } from '/@/components/Button';
import { BasicArrow } from '/@/components/Basic';
import { useFormContext } from '../hooks/useFormContext';
import { useI18n } from '/@/hooks/web/useI18n';
import { propTypes } from '/@/utils/propTypes';
type ButtonOptions = Partial<ButtonProps> & { text: string };
export default defineComponent({
name: 'BasicFormAction',
components: {
FormItem: Form.Item,
Button,
BasicArrow,
[Col.name]: Col,
},
props: {
showActionButtonGroup: propTypes.bool.def(true),
showResetButton: propTypes.bool.def(true),
showSubmitButton: propTypes.bool.def(true),
showAdvancedButton: propTypes.bool.def(true),
resetButtonOptions: {
type: Object as PropType<ButtonOptions>,
default: () => ({}),
},
submitButtonOptions: {
type: Object as PropType<ButtonOptions>,
default: () => ({}),
},
actionColOptions: {
type: Object as PropType<Partial<ColEx>>,
default: () => ({}),
},
actionSpan: propTypes.number.def(6),
isAdvanced: propTypes.bool,
hideAdvanceBtn: propTypes.bool,
},
emits: ['toggle-advanced'],
setup(props, { emit }) {
const { t } = useI18n();
const actionColOpt = computed(() => {
const { showAdvancedButton, actionSpan: span, actionColOptions } = props;
const actionSpan = 24 - span;
const advancedSpanObj = showAdvancedButton
? { span: actionSpan < 6 ? 24 : actionSpan }
: {};
const actionColOpt: Partial<ColEx> = {
style: { textAlign: 'right' },
span: showAdvancedButton ? 6 : 4,
...advancedSpanObj,
...actionColOptions,
};
return actionColOpt;
});
const getResetBtnOptions = computed((): ButtonOptions => {
return Object.assign(
{
text: t('common.resetText'),
},
props.resetButtonOptions,
);
});
const getSubmitBtnOptions = computed(() => {
return Object.assign(
{
text: t('common.queryText'),
},
props.submitButtonOptions,
);
});
function toggleAdvanced() {
emit('toggle-advanced');
}
return {
t,
actionColOpt,
getResetBtnOptions,
getSubmitBtnOptions,
toggleAdvanced,
...useFormContext(),
};
},
});
</script>

View File

@@ -0,0 +1,412 @@
<script lang="tsx">
import type { PropType, Ref } from 'vue';
import { computed, defineComponent, toRefs, unref } from 'vue';
import type { FormActionType, FormProps, FormSchema } from '../types/form';
import type { ValidationRule } from 'ant-design-vue/lib/form/Form';
import type { TableActionType } from '/@/components/Table';
import { Col, Divider, Form } from 'ant-design-vue';
import { componentMap } from '../componentMap';
import { BasicHelp } from '/@/components/Basic';
import { isBoolean, isFunction, isNull } from '/@/utils/is';
import { getSlot } from '/@/utils/helper/tsxHelper';
import {
createPlaceholderMessage,
NO_AUTO_LINK_COMPONENTS,
setComponentRuleType,
} from '../helper';
import { cloneDeep, upperFirst } from 'lodash-es';
import { useItemLabelWidth } from '../hooks/useLabelWidth';
import { useI18n } from '/@/hooks/web/useI18n';
export default defineComponent({
name: 'BasicFormItem',
inheritAttrs: false,
props: {
schema: {
type: Object as PropType<FormSchema>,
default: () => ({}),
},
formProps: {
type: Object as PropType<FormProps>,
default: () => ({}),
},
allDefaultValues: {
type: Object as PropType<Recordable>,
default: () => ({}),
},
formModel: {
type: Object as PropType<Recordable>,
default: () => ({}),
},
setFormModel: {
type: Function as PropType<(key: string, value: any, schema: FormSchema) => void>,
default: null,
},
tableAction: {
type: Object as PropType<TableActionType>,
},
formActionType: {
type: Object as PropType<FormActionType>,
},
isAdvanced: {
type: Boolean,
},
},
setup(props, { slots }) {
const { t } = useI18n();
const { schema, formProps } = toRefs(props) as {
schema: Ref<FormSchema>;
formProps: Ref<FormProps>;
};
const itemLabelWidthProp = useItemLabelWidth(schema, formProps);
const getValues = computed(() => {
const { allDefaultValues, formModel, schema } = props;
const { mergeDynamicData } = props.formProps;
return {
field: schema.field,
model: formModel,
values: {
...mergeDynamicData,
...allDefaultValues,
...formModel,
} as Recordable,
schema: schema,
};
});
const getComponentsProps = computed(() => {
const { schema, tableAction, formModel, formActionType } = props;
let { componentProps = {} } = schema;
if (isFunction(componentProps)) {
componentProps = componentProps({ schema, tableAction, formModel, formActionType }) ?? {};
}
if (schema.component === 'Divider') {
componentProps = Object.assign(
{ type: 'horizontal' },
{
orientation: 'left',
plain: true,
},
componentProps,
);
}
return componentProps as Recordable;
});
const getDisable = computed(() => {
const { disabled: globDisabled } = props.formProps;
const { dynamicDisabled } = props.schema;
const { disabled: itemDisabled = false } = unref(getComponentsProps);
let disabled = !!globDisabled || itemDisabled;
if (isBoolean(dynamicDisabled)) {
disabled = dynamicDisabled;
}
if (isFunction(dynamicDisabled)) {
disabled = dynamicDisabled(unref(getValues));
}
return disabled;
});
function getShow(): { isShow: boolean; isIfShow: boolean } {
const { show, ifShow } = props.schema;
const { showAdvancedButton } = props.formProps;
const itemIsAdvanced = showAdvancedButton
? isBoolean(props.isAdvanced)
? props.isAdvanced
: true
: true;
let isShow = true;
let isIfShow = true;
if (isBoolean(show)) {
isShow = show;
}
if (isBoolean(ifShow)) {
isIfShow = ifShow;
}
if (isFunction(show)) {
isShow = show(unref(getValues));
}
if (isFunction(ifShow)) {
isIfShow = ifShow(unref(getValues));
}
isShow = isShow && itemIsAdvanced;
return { isShow, isIfShow };
}
function handleRules(): ValidationRule[] {
const {
rules: defRules = [],
component,
rulesMessageJoinLabel,
label,
dynamicRules,
required,
} = props.schema;
if (isFunction(dynamicRules)) {
return dynamicRules(unref(getValues)) as ValidationRule[];
}
let rules: ValidationRule[] = cloneDeep(defRules) as ValidationRule[];
const { rulesMessageJoinLabel: globalRulesMessageJoinLabel } = props.formProps;
const joinLabel = Reflect.has(props.schema, 'rulesMessageJoinLabel')
? rulesMessageJoinLabel
: globalRulesMessageJoinLabel;
const defaultMsg = createPlaceholderMessage(component) + `${joinLabel ? label : ''}`;
function validator(rule: any, value: any) {
const msg = rule.message || defaultMsg;
if (value === undefined || isNull(value)) {
// 空值
return Promise.reject(msg);
} else if (Array.isArray(value) && value.length === 0) {
// 数组类型
return Promise.reject(msg);
} else if (typeof value === 'string' && value.trim() === '') {
// 空字符串
return Promise.reject(msg);
} else if (
typeof value === 'object' &&
Reflect.has(value, 'checked') &&
Reflect.has(value, 'halfChecked') &&
Array.isArray(value.checked) &&
Array.isArray(value.halfChecked) &&
value.checked.length === 0 &&
value.halfChecked.length === 0
) {
// 非关联选择的tree组件
return Promise.reject(msg);
}
return Promise.resolve();
}
const getRequired = isFunction(required) ? required(unref(getValues)) : required;
/*
* 1、若设置了required属性又没有其他的rules就创建一个验证规则
* 2、若设置了required属性又存在其他的rules则只rules中不存在required属性时才添加验证required的规则
* 也就是说rules中的required优先级大于required
*/
if (getRequired) {
if (!rules || rules.length === 0) {
rules = [{ required: getRequired, validator }];
} else {
const requiredIndex: number = rules.findIndex((rule) => Reflect.has(rule, 'required'));
if (requiredIndex === -1) {
rules.push({ required: getRequired, validator });
}
}
}
const requiredRuleIndex: number = rules.findIndex(
(rule) => Reflect.has(rule, 'required') && !Reflect.has(rule, 'validator'),
);
if (requiredRuleIndex !== -1) {
const rule = rules[requiredRuleIndex];
const { isShow } = getShow();
if (!isShow) {
rule.required = false;
}
if (component) {
if (!Reflect.has(rule, 'type')) {
rule.type = component === 'InputNumber' ? 'number' : 'string';
}
rule.message = rule.message || defaultMsg;
if (component.includes('Input') || component.includes('Textarea')) {
rule.whitespace = true;
}
const valueFormat = unref(getComponentsProps)?.valueFormat;
setComponentRuleType(rule, component, valueFormat);
}
}
// Maximum input length rule check
const characterInx = rules.findIndex((val) => val.max);
if (characterInx !== -1 && !rules[characterInx].validator) {
rules[characterInx].message =
rules[characterInx].message ||
t('component.form.maxTip', [rules[characterInx].max] as Recordable);
}
return rules;
}
function renderComponent() {
const {
renderComponentContent,
component,
field,
changeEvent = 'change',
valueField,
} = props.schema;
const isCheck = component && ['Switch', 'Checkbox'].includes(component);
const eventKey = `on${upperFirst(changeEvent)}`;
const on = {
[eventKey]: (...args: Nullable<Recordable>[]) => {
const [e] = args;
if (propsData[eventKey]) {
propsData[eventKey](...args);
}
const target = e ? e.target : null;
const value = target ? (isCheck ? target.checked : target.value) : e;
props.setFormModel(field, value, props.schema);
},
};
const Comp = componentMap.get(component) as ReturnType<typeof defineComponent>;
const { autoSetPlaceHolder, size } = props.formProps;
const propsData: Recordable = {
allowClear: true,
getPopupContainer: (trigger: Element) => trigger.parentNode,
size,
...unref(getComponentsProps),
disabled: unref(getDisable),
};
const isCreatePlaceholder = !propsData.disabled && autoSetPlaceHolder;
// RangePicker place is an array
if (isCreatePlaceholder && component !== 'RangePicker' && component) {
propsData.placeholder =
unref(getComponentsProps)?.placeholder || createPlaceholderMessage(component);
}
propsData.codeField = field;
propsData.formValues = unref(getValues);
const bindValue: Recordable = {
[valueField || (isCheck ? 'checked' : 'value')]: props.formModel[field],
};
const compAttr: Recordable = {
...propsData,
...on,
...bindValue,
};
if (!renderComponentContent) {
return <Comp {...compAttr} />;
}
const compSlot = isFunction(renderComponentContent)
? { ...renderComponentContent(unref(getValues)) }
: {
default: () => renderComponentContent,
};
return <Comp {...compAttr}>{compSlot}</Comp>;
}
function renderLabelHelpMessage() {
const { label, helpMessage, helpComponentProps, subLabel } = props.schema;
const renderLabel = subLabel ? (
<span>
{label} <span class="text-secondary">{subLabel}</span>
</span>
) : (
label
);
const getHelpMessage = isFunction(helpMessage)
? helpMessage(unref(getValues))
: helpMessage;
if (!getHelpMessage || (Array.isArray(getHelpMessage) && getHelpMessage.length === 0)) {
return renderLabel;
}
return (
<span>
{renderLabel}
<BasicHelp placement="top" class="mx-1" text={getHelpMessage} {...helpComponentProps} />
</span>
);
}
function renderItem() {
const { itemProps, slot, render, field, suffix, component } = props.schema;
const { labelCol, wrapperCol } = unref(itemLabelWidthProp);
const { colon } = props.formProps;
if (component === 'Divider') {
return (
<Col span={24}>
<Divider {...unref(getComponentsProps)}>{renderLabelHelpMessage()}</Divider>
</Col>
);
} else {
const getContent = () => {
return slot
? getSlot(slots, slot, unref(getValues))
: render
? render(unref(getValues))
: renderComponent();
};
const showSuffix = !!suffix;
const getSuffix = isFunction(suffix) ? suffix(unref(getValues)) : suffix;
// TODO 自定义组件验证会出现问题因此这里框架默认将自定义组件设置手动触发验证如果其他组件还有此问题请手动设置autoLink=false
if (NO_AUTO_LINK_COMPONENTS.includes(component)) {
props.schema &&
(props.schema.itemProps! = {
autoLink: false,
...props.schema.itemProps,
});
}
return (
<Form.Item
name={field}
colon={colon}
class={{ 'suffix-item': showSuffix }}
{...(itemProps as Recordable)}
label={renderLabelHelpMessage()}
rules={handleRules()}
labelCol={labelCol}
wrapperCol={wrapperCol}
>
<div style="display:flex">
<div style="flex:1;">{getContent()}</div>
{showSuffix && <span class="suffix">{getSuffix}</span>}
</div>
</Form.Item>
);
}
}
return () => {
const { colProps = {}, colSlot, renderColContent, component } = props.schema;
if (!componentMap.has(component)) {
return null;
}
const { baseColProps = {} } = props.formProps;
const realColProps = { ...baseColProps, ...colProps };
const { isIfShow, isShow } = getShow();
const values = unref(getValues);
const getContent = () => {
return colSlot
? getSlot(slots, colSlot, values)
: renderColContent
? renderColContent(values)
: renderItem();
};
return (
isIfShow && (
<Col {...realColProps} v-show={isShow}>
{getContent()}
</Col>
)
);
};
},
});
</script>

View File

@@ -0,0 +1,63 @@
<!--
* @Description:It is troublesome to implement radio button group in the form. So it is extracted independently as a separate component
-->
<template>
<RadioGroup v-bind="attrs" v-model:value="state" button-style="solid">
<template v-for="item in getOptions" :key="`${item.value}`">
<RadioButton :value="item.value" :disabled="item.disabled" @click="handleClick(item)">
{{ item.label }}
</RadioButton>
</template>
</RadioGroup>
</template>
<script lang="ts">
import { defineComponent, PropType, computed, ref } from 'vue';
import { Radio } from 'ant-design-vue';
import { isString } from '/@/utils/is';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
import { useAttrs } from '/@/hooks/core/useAttrs';
type OptionsItem = { label: string; value: string | number | boolean; disabled?: boolean };
type RadioItem = string | OptionsItem;
export default defineComponent({
name: 'RadioButtonGroup',
components: {
RadioGroup: Radio.Group,
RadioButton: Radio.Button,
},
props: {
value: {
type: [String, Number, Boolean] as PropType<string | number | boolean>,
},
options: {
type: Array as PropType<RadioItem[]>,
default: () => [],
},
},
emits: ['change'],
setup(props) {
const attrs = useAttrs();
const emitData = ref<any[]>([]);
// Embedded in the form, just use the hook binding to perform form verification
const [state] = useRuleFormItem(props, 'value', 'change', emitData);
// Processing options value
const getOptions = computed((): OptionsItem[] => {
const { options } = props;
if (!options || options?.length === 0) return [];
const isStringArr = options.some((item) => isString(item));
if (!isStringArr) return options as OptionsItem[];
return options.map((item) => ({ label: item, value: item })) as OptionsItem[];
});
function handleClick(...args) {
emitData.value = args;
}
return { state, getOptions, attrs, handleClick };
},
});
</script>

View File

@@ -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',
];

View File

@@ -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<FormProps>;
getSchema: ComputedRef<FormSchema[]>;
formModel: Recordable;
defaultValueRef: Ref<Recordable>;
}
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<ColEx>, 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 };
}

View File

@@ -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<FormSchema[]>;
getProps: ComputedRef<FormProps>;
isInitedDefault: Ref<boolean>;
formElRef: Ref<FormActionType>;
}
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<HTMLInputElement>;
if (!inputEl) return;
inputEl?.focus();
});
}

View File

@@ -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);
});
}

View File

@@ -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<Recordable>;
type Props = Partial<DynamicProps<FormProps>>;
export function useForm(props?: Props): UseFormReturnType {
const formRef = ref<Nullable<FormActionType>>(null);
const loadedRef = ref<Nullable<boolean>>(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<FormProps>) => {
const form = await getForm();
form.setProps(formProps);
},
updateSchema: async (data: Partial<FormSchema> | Partial<FormSchema>[]) => {
const form = await getForm();
form.updateSchema(data);
},
resetSchema: async (data: Partial<FormSchema> | Partial<FormSchema>[]) => {
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: <T>() => {
return unref(formRef)?.getFieldsValue() as T;
},
setFieldsValue: async <T>(values: T) => {
const form = await getForm();
form.setFieldsValue<T>(values);
},
appendSchemaByField: async (
schema: FormSchema | FormSchema[],
prefixField: string | undefined,
first: boolean,
) => {
const form = await getForm();
form.appendSchemaByField(schema, prefixField, first);
},
submit: async (): Promise<any> => {
const form = await getForm();
return form.submit();
},
validate: async (nameList?: NamePath[]): Promise<Recordable> => {
const form = await getForm();
return form.validate(nameList);
},
validateFields: async (nameList?: NamePath[]): Promise<Recordable> => {
const form = await getForm();
return form.validateFields(nameList);
},
};
return [register, methods];
}

View File

@@ -0,0 +1,17 @@
import type { InjectionKey } from 'vue';
import { createContext, useContext } from '/@/hooks/core/useContext';
export interface FormContextProps {
resetAction: () => Promise<void>;
submitAction: () => Promise<void>;
}
const key: InjectionKey<FormContextProps> = Symbol();
export function createFormContext(context: FormContextProps) {
return createContext<FormContextProps>(context, key);
}
export function useFormContext() {
return useContext<FormContextProps>(key);
}

View File

@@ -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<FormProps>;
getSchema: ComputedRef<FormSchema[]>;
formModel: Recordable;
defaultValueRef: Ref<Recordable>;
formElRef: Ref<FormActionType>;
schemaRef: Ref<FormSchema[]>;
handleFormValues: Fn;
}
export function useFormEvents({
emit,
getProps,
formModel,
getSchema,
defaultValueRef,
formElRef,
schemaRef,
handleFormValues,
}: UseFormActionContext) {
async function resetFields(): Promise<void> {
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<void> {
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<void> {
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<FormSchema> | Partial<FormSchema>[]) {
let updateData: Partial<FormSchema>[] = [];
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<FormSchema> | Partial<FormSchema>[]) {
let updateData: Partial<FormSchema>[] = [];
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<void> {
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,
};
}

View File

@@ -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<any>;
getSchema: ComputedRef<FormSchema[]>;
getProps: ComputedRef<FormProps>;
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 };
}

View File

@@ -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<FormSchema>, propsRef: Ref<FormProps>) {
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,
},
};
});
}

View File

@@ -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<Recordable>,
default: () => ({}),
},
// 标签宽度 固定宽度
labelWidth: {
type: [Number, String] as PropType<number | string>,
default: 0,
},
fieldMapToTime: {
type: Array as PropType<FieldMapToTime>,
default: () => [],
},
compact: propTypes.bool,
// 表单配置规则
schemas: {
type: Array as PropType<FormSchema[]>,
default: () => [],
},
mergeDynamicData: {
type: Object as PropType<Recordable>,
default: null,
},
baseRowStyle: {
type: Object as PropType<CSSProperties>,
},
baseColProps: {
type: Object as PropType<Partial<ColEx>>,
},
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<number | Recordable>,
default: 0,
},
// 是否显示收起展开按钮
showAdvancedButton: propTypes.bool,
// 转化时间
transformDateFunc: {
type: Function as PropType<Fn>,
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<Partial<ColEx>>,
// 显示重置按钮
showResetButton: propTypes.bool.def(true),
// 是否聚焦第一个输入框只在第一个表单项为input的时候作用
autoFocusFirstItem: propTypes.bool,
// 重置按钮配置
resetButtonOptions: Object as PropType<Partial<ButtonProps>>,
// 显示确认按钮
showSubmitButton: propTypes.bool.def(true),
// 确认按钮配置
submitButtonOptions: Object as PropType<Partial<ButtonProps>>,
// 自定义重置函数
resetFunc: Function as PropType<() => Promise<void>>,
submitFunc: Function as PropType<() => Promise<void>>,
// 以下为默认props
hideRequiredMark: propTypes.bool,
labelCol: Object as PropType<Partial<ColEx>>,
layout: propTypes.oneOf(['horizontal', 'vertical', 'inline']).def('horizontal'),
tableAction: {
type: Object as PropType<TableActionType>,
},
wrapperCol: Object as PropType<Partial<ColEx>>,
colon: propTypes.bool,
labelAlign: propTypes.string,
rowProps: Object as PropType<RowProps>,
};

View File

@@ -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<void>;
setFieldsValue: <T>(values: T) => Promise<void>;
resetFields: () => Promise<void>;
getFieldsValue: () => Recordable;
clearValidate: (name?: string | string[]) => Promise<void>;
updateSchema: (data: Partial<FormSchema> | Partial<FormSchema>[]) => Promise<void>;
resetSchema: (data: Partial<FormSchema> | Partial<FormSchema>[]) => Promise<void>;
setProps: (formProps: Partial<FormProps>) => Promise<void>;
removeSchemaByField: (field: string | string[]) => Promise<void>;
appendSchemaByField: (
schema: FormSchema | FormSchema[],
prefixField: string | undefined,
first?: boolean | undefined,
) => Promise<void>;
validateFields: (nameList?: NamePath[]) => Promise<any>;
validate: (nameList?: NamePath[]) => Promise<any>;
scrollToField: (name: NamePath, options?: ScrollOptions) => Promise<void>;
}
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<ColEx>;
// Col configuration for the entire form
wrapperCol?: Partial<ColEx>;
// General row style
baseRowStyle?: CSSProperties;
// General col configuration
baseColProps?: Partial<ColEx>;
// 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<ColEx>;
// 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<ButtonProps>;
// Confirm button configuration
submitButtonOptions?: Partial<ButtonProps>;
// Operation column configuration
actionColOptions?: Partial<ColEx>;
// Show reset button
showResetButton?: boolean;
// Show confirmation button
showSubmitButton?: boolean;
resetFunc?: () => Promise<void>;
submitFunc?: () => Promise<void>;
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<HelpComponentProps>;
// 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<FormItem>;
// col configuration outside formModelItem
colProps?: Partial<ColEx>;
// 默认值
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;
}

View File

@@ -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 <Col>
* @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;
}

View File

@@ -0,0 +1,6 @@
export interface AdvanceState {
isAdvanced: boolean;
hideAdvanceBtn: boolean;
isLoad: boolean;
actionSpan: number;
}

View File

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

View File

@@ -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'

View File

@@ -0,0 +1,451 @@
<template>
<div ref="wrapRef" :class="getWrapperClass">
<BasicForm
ref="formRef"
submitOnReset
v-bind="getFormProps"
v-if="getBindValues.useSearchForm"
:tableAction="tableAction"
@register="registerForm"
@submit="handleSearchInfoChange"
@advanced-change="redoHeight"
>
<template #[replaceFormSlotKey(item)]="data" v-for="item in getFormSlotKeys">
<slot :name="item" v-bind="data || {}"></slot>
</template>
</BasicForm>
<Table
ref="tableElRef"
v-bind="getBindValues"
:rowClassName="getRowClassName"
v-show="getEmptyDataIsShowTable"
@change="handleTableChange"
>
<template #[item]="data" v-for="item in Object.keys($slots)" :key="item">
<slot :name="item" v-bind="data || {}"></slot>
</template>
<template #headerCell="{ column }">
<HeaderCell :column="column" />
</template>
<!-- 增加对antdv3.x兼容 -->
<template #bodyCell="data">
<slot name="bodyCell" v-bind="data || {}"></slot>
</template>
<!-- <template #[`header-${column.dataIndex}`] v-for="(column, index) in columns" :key="index">-->
<!-- <HeaderCell :column="column" />-->
<!-- </template>-->
</Table>
</div>
</template>
<script lang="ts">
import type { BasicTableProps, TableActionType, SizeType, ColumnChangeParam } from './types/table'
import { defineComponent, ref, computed, unref, toRaw, inject, watchEffect } from 'vue'
import { Table } from 'ant-design-vue'
import { BasicForm, useForm } from '/@/components/Form/index'
import { PageWrapperFixedHeightKey } from '/@/components/Page'
import HeaderCell from './components/HeaderCell.vue'
import { InnerHandlers } from './types/table'
import { usePagination } from './hooks/usePagination'
import { useColumns } from './hooks/useColumns'
import { useDataSource } from './hooks/useDataSource'
import { useLoading } from './hooks/useLoading'
import { useRowSelection } from './hooks/useRowSelection'
import { useTableScroll } from './hooks/useTableScroll'
import { useTableScrollTo } from './hooks/useScrollTo'
import { useCustomRow } from './hooks/useCustomRow'
import { useTableStyle } from './hooks/useTableStyle'
import { useTableHeader } from './hooks/useTableHeader'
import { useTableExpand } from './hooks/useTableExpand'
import { createTableContext } from './hooks/useTableContext'
import { useTableFooter } from './hooks/useTableFooter'
import { useTableForm } from './hooks/useTableForm'
import { useDesign } from '/@/hooks/web/useDesign'
import { omit } from 'lodash-es'
import { basicProps } from './props'
import { isFunction } from '/@/utils/is'
import { warn } from '/@/utils/log'
export default defineComponent({
name: 'BasicTable',
components: {
Table,
BasicForm,
HeaderCell,
},
props: basicProps,
emits: [
'fetch-success',
'fetch-error',
'selection-change',
'register',
'row-click',
'row-dbClick',
'row-contextmenu',
'row-mouseenter',
'row-mouseleave',
'edit-end',
'edit-cancel',
'edit-row-end',
'edit-change',
'expanded-rows-change',
'change',
'columns-change',
],
setup(props, { attrs, emit, slots, expose }) {
const tableElRef = ref(null)
const tableData = ref<Recordable[]>([])
const wrapRef = ref(null)
const formRef = ref(null)
const innerPropsRef = ref<Partial<BasicTableProps>>()
const { prefixCls } = useDesign('basic-table')
const [registerForm, formActions] = useForm()
const getProps = computed(() => {
return { ...props, ...unref(innerPropsRef) } as BasicTableProps
})
const isFixedHeightPage = inject(PageWrapperFixedHeightKey, false)
watchEffect(() => {
unref(isFixedHeightPage) &&
props.canResize &&
warn(
"'canResize' of BasicTable may not work in PageWrapper with 'fixedHeight' (especially in hot updates)",
)
})
const { getLoading, setLoading } = useLoading(getProps)
const {
getPaginationInfo,
getPagination,
setPagination,
setShowPagination,
getShowPagination,
} = usePagination(getProps)
const {
getRowSelection,
getRowSelectionRef,
getSelectRows,
setSelectedRows,
clearSelectedRowKeys,
getSelectRowKeys,
deleteSelectRowByKey,
setSelectedRowKeys,
} = useRowSelection(getProps, tableData, emit)
const {
handleTableChange: onTableChange,
getDataSourceRef,
getDataSource,
getRawDataSource,
setTableData,
updateTableDataRecord,
deleteTableDataRecord,
insertTableDataRecord,
findTableDataRecord,
fetch,
getRowKey,
reload,
getAutoCreateKey,
updateTableData,
} = useDataSource(
getProps,
{
tableData,
getPaginationInfo,
setLoading,
setPagination,
getFieldsValue: formActions.getFieldsValue,
clearSelectedRowKeys,
},
emit,
)
function handleTableChange(...args) {
onTableChange.call(undefined, ...args)
emit('change', ...args)
// 解决通过useTable注册onChange时不起作用的问题
const { onChange } = unref(getProps)
onChange && isFunction(onChange) && onChange.call(undefined, ...args)
}
const {
getViewColumns,
getColumns,
setCacheColumnsByField,
setColumns,
getColumnsRef,
getCacheColumns,
} = useColumns(getProps, getPaginationInfo)
const { getScrollRef, redoHeight } = useTableScroll(
getProps,
tableElRef,
getColumnsRef,
getRowSelectionRef,
getDataSourceRef,
wrapRef,
formRef,
)
const { scrollTo } = useTableScrollTo(tableElRef, getDataSourceRef)
const { customRow } = useCustomRow(getProps, {
setSelectedRowKeys,
getSelectRowKeys,
clearSelectedRowKeys,
getAutoCreateKey,
emit,
})
const { getRowClassName } = useTableStyle(getProps, prefixCls)
const { getExpandOption, expandAll, expandRows, collapseAll } = useTableExpand(
getProps,
tableData,
emit,
)
const handlers: InnerHandlers = {
onColumnsChange: (data: ColumnChangeParam[]) => {
emit('columns-change', data)
// support useTable
unref(getProps).onColumnsChange?.(data)
},
}
const { getHeaderProps } = useTableHeader(getProps, slots, handlers)
const { getFooterProps } = useTableFooter(
getProps,
getScrollRef,
tableElRef,
getDataSourceRef,
)
const { getFormProps, replaceFormSlotKey, getFormSlotKeys, handleSearchInfoChange } =
useTableForm(getProps, slots, fetch, getLoading)
const getBindValues = computed(() => {
const dataSource = unref(getDataSourceRef)
let propsData: Recordable = {
...attrs,
customRow,
...unref(getProps),
...unref(getHeaderProps),
scroll: unref(getScrollRef),
loading: unref(getLoading),
tableLayout: 'fixed',
rowSelection: unref(getRowSelectionRef),
rowKey: unref(getRowKey),
columns: toRaw(unref(getViewColumns)),
pagination: toRaw(unref(getPaginationInfo)),
dataSource,
footer: unref(getFooterProps),
...unref(getExpandOption),
}
// if (slots.expandedRowRender) {
// propsData = omit(propsData, 'scroll')
// }
propsData = omit(propsData, ['class', 'onChange'])
return propsData
})
const getWrapperClass = computed(() => {
const values = unref(getBindValues)
return [
prefixCls,
attrs.class,
{
[`${prefixCls}-form-container`]: values.useSearchForm,
[`${prefixCls}--inset`]: values.inset,
},
]
})
const getEmptyDataIsShowTable = computed(() => {
const { emptyDataIsShowTable, useSearchForm } = unref(getProps)
if (emptyDataIsShowTable || !useSearchForm) {
return true
}
return !!unref(getDataSourceRef).length
})
function setProps(props: Partial<BasicTableProps>) {
innerPropsRef.value = { ...unref(innerPropsRef), ...props }
}
const tableAction: TableActionType = {
reload,
getSelectRows,
setSelectedRows,
clearSelectedRowKeys,
getSelectRowKeys,
deleteSelectRowByKey,
setPagination,
setTableData,
updateTableDataRecord,
deleteTableDataRecord,
insertTableDataRecord,
findTableDataRecord,
redoHeight,
setSelectedRowKeys,
setColumns,
setLoading,
getDataSource,
getRawDataSource,
setProps,
getRowSelection,
getPaginationRef: getPagination,
getColumns,
getCacheColumns,
emit,
updateTableData,
setShowPagination,
getShowPagination,
setCacheColumnsByField,
expandAll,
expandRows,
collapseAll,
scrollTo,
getSize: () => {
return unref(getBindValues).size as SizeType
},
}
createTableContext({ ...tableAction, wrapRef, getBindValues })
expose(tableAction)
emit('register', tableAction, formActions)
return {
formRef,
tableElRef,
getBindValues,
getLoading,
registerForm,
handleSearchInfoChange,
getEmptyDataIsShowTable,
handleTableChange,
getRowClassName,
wrapRef,
tableAction,
redoHeight,
getFormProps: getFormProps as any,
replaceFormSlotKey,
getFormSlotKeys,
getWrapperClass,
columns: getViewColumns,
}
},
})
</script>
<style lang="less">
@border-color: #cecece4d;
@prefix-cls: ~'@{namespace}-basic-table';
[data-theme='dark'] {
.ant-table-tbody > tr:hover.ant-table-row-selected > td,
.ant-table-tbody > tr.ant-table-row-selected td {
background-color: #262626;
}
}
.@{prefix-cls} {
max-width: 100%;
height: 100%;
&-row__striped {
td {
background-color: @app-content-background;
}
}
&-form-container {
padding: 16px;
.ant-form {
width: 100%;
padding: 12px 10px 6px;
margin-bottom: 16px;
background-color: @component-background;
border-radius: 2px;
}
}
.ant-tag {
margin-right: 0;
}
.ant-table-wrapper {
padding: 6px;
background-color: @component-background;
border-radius: 2px;
.ant-table-title {
min-height: 40px;
padding: 0 0 8px !important;
}
.ant-table.ant-table-bordered .ant-table-title {
border: none !important;
}
}
.ant-table {
width: 100%;
overflow-x: hidden;
&-title {
display: flex;
padding: 8px 6px;
border-bottom: none;
justify-content: space-between;
align-items: center;
}
//.ant-table-tbody > tr.ant-table-row-selected td {
//background-color: fade(@primary-color, 8%) !important;
//}
}
.ant-pagination {
margin: 10px 0 0;
}
.ant-table-footer {
padding: 0;
.ant-table-wrapper {
padding: 0;
}
table {
border: none !important;
}
.ant-table-body {
overflow-x: hidden !important;
// overflow-y: scroll !important;
}
td {
padding: 12px 8px;
}
}
&--inset {
.ant-table-wrapper {
padding: 0;
}
}
}
</style>

View File

@@ -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<ComponentType, Component>()
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 }

View File

@@ -0,0 +1,16 @@
<template>
<span>
<slot></slot>
{{ title }}
<FormOutlined />
</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { FormOutlined } from '@ant-design/icons-vue';
export default defineComponent({
name: 'EditTableHeaderIcon',
components: { FormOutlined },
props: { title: { type: String, default: '' } },
});
</script>

View File

@@ -0,0 +1,48 @@
<template>
<EditTableHeaderCell v-if="getIsEdit">
{{ getTitle }}
</EditTableHeaderCell>
<span v-else>{{ getTitle }}</span>
<BasicHelp v-if="getHelpMessage" :text="getHelpMessage" :class="`${prefixCls}__help`" />
</template>
<script lang="ts">
import type { PropType } from 'vue';
import type { BasicColumn } from '../types/table';
import { defineComponent, computed } from 'vue';
import BasicHelp from '/@/components/Basic/src/BasicHelp.vue';
import EditTableHeaderCell from './EditTableHeaderIcon.vue';
import { useDesign } from '/@/hooks/web/useDesign';
export default defineComponent({
name: 'TableHeaderCell',
components: {
EditTableHeaderCell,
BasicHelp,
},
props: {
column: {
type: Object as PropType<BasicColumn>,
default: () => ({}),
},
},
setup(props) {
const { prefixCls } = useDesign('basic-table-header-cell');
const getIsEdit = computed(() => !!props.column?.edit);
const getTitle = computed(() => props.column?.customTitle || props.column?.title);
const getHelpMessage = computed(() => props.column?.helpMessage);
return { prefixCls, getIsEdit, getTitle, getHelpMessage };
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-basic-table-header-cell';
.@{prefix-cls} {
&__help {
margin-left: 8px;
color: rgb(0 0 0 / 65%) !important;
}
}
</style>

View File

@@ -0,0 +1,202 @@
<template>
<div :class="[prefixCls, getAlign]" @click="onCellClick">
<template v-for="(action, index) in getActions" :key="`${index}-${action.label}`">
<Tooltip v-if="action.tooltip" v-bind="getTooltip(action.tooltip)">
<PopConfirmButton v-bind="action">
<Icon :icon="action.icon" :class="{ 'mr-1': !!action.label }" v-if="action.icon" />
<template v-if="action.label">{{ action.label }}</template>
</PopConfirmButton>
</Tooltip>
<PopConfirmButton v-else v-bind="action">
<Icon :icon="action.icon" :class="{ 'mr-1': !!action.label }" v-if="action.icon" />
<template v-if="action.label">{{ action.label }}</template>
</PopConfirmButton>
<Divider
type="vertical"
class="action-divider"
v-if="divider && index < getActions.length - 1"
/>
</template>
<Dropdown
:trigger="['hover']"
:dropMenuList="getDropdownList"
popconfirm
v-if="dropDownActions && getDropdownList.length > 0"
>
<slot name="more"></slot>
<a-button type="link" size="small" v-if="!$slots.more">
<MoreOutlined class="icon-more" />
</a-button>
</Dropdown>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed, toRaw, unref } from 'vue';
import { MoreOutlined } from '@ant-design/icons-vue';
import { Divider, Tooltip, TooltipProps } from 'ant-design-vue';
import Icon from '/@/components/Icon/index';
import { ActionItem, TableActionType } from '/@/components/Table';
import { PopConfirmButton } from '/@/components/Button';
import { Dropdown } from '/@/components/Dropdown';
import { useDesign } from '/@/hooks/web/useDesign';
import { useTableContext } from '../hooks/useTableContext';
import { usePermission } from '/@/hooks/web/usePermission';
import { isBoolean, isFunction, isString } from '/@/utils/is';
import { propTypes } from '/@/utils/propTypes';
import { ACTION_COLUMN_FLAG } from '../const';
export default defineComponent({
name: 'TableAction',
components: { Icon, PopConfirmButton, Divider, Dropdown, MoreOutlined, Tooltip },
props: {
actions: {
type: Array as PropType<ActionItem[]>,
default: null,
},
dropDownActions: {
type: Array as PropType<ActionItem[]>,
default: null,
},
divider: propTypes.bool.def(true),
outside: propTypes.bool,
stopButtonPropagation: propTypes.bool.def(false),
},
setup(props) {
const { prefixCls } = useDesign('basic-table-action');
let table: Partial<TableActionType> = {};
if (!props.outside) {
table = useTableContext();
}
const { hasPermission } = usePermission();
function isIfShow(action: ActionItem): boolean {
const ifShow = action.ifShow;
let isIfShow = true;
if (isBoolean(ifShow)) {
isIfShow = ifShow;
}
if (isFunction(ifShow)) {
isIfShow = ifShow(action);
}
return isIfShow;
}
const getActions = computed(() => {
return (toRaw(props.actions) || [])
.filter((action) => {
return hasPermission(action.auth) && isIfShow(action);
})
.map((action) => {
const { popConfirm } = action;
return {
getPopupContainer: () => unref((table as any)?.wrapRef) ?? document.body,
type: 'link',
size: 'small',
...action,
...(popConfirm || {}),
onConfirm: popConfirm?.confirm,
onCancel: popConfirm?.cancel,
enable: !!popConfirm,
};
});
});
const getDropdownList = computed((): any[] => {
const list = (toRaw(props.dropDownActions) || []).filter((action) => {
return hasPermission(action.auth) && isIfShow(action);
});
return list.map((action, index) => {
const { label, popConfirm } = action;
return {
...action,
...popConfirm,
onConfirm: popConfirm?.confirm,
onCancel: popConfirm?.cancel,
text: label,
divider: index < list.length - 1 ? props.divider : false,
};
});
});
const getAlign = computed(() => {
const columns = (table as TableActionType)?.getColumns?.() || [];
const actionColumn = columns.find((item) => item.flag === ACTION_COLUMN_FLAG);
return actionColumn?.align ?? 'left';
});
function getTooltip(data: string | TooltipProps): TooltipProps {
return {
getPopupContainer: () => unref((table as any)?.wrapRef) ?? document.body,
placement: 'bottom',
...(isString(data) ? { title: data } : data),
};
}
function onCellClick(e: MouseEvent) {
if (!props.stopButtonPropagation) return;
const path = e.composedPath() as HTMLElement[];
const isInButton = path.find((ele) => {
return ele.tagName?.toUpperCase() === 'BUTTON';
});
isInButton && e.stopPropagation();
}
return { prefixCls, getActions, getDropdownList, getAlign, onCellClick, getTooltip };
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-basic-table-action';
.@{prefix-cls} {
display: flex;
align-items: center;
.action-divider {
display: table;
}
&.left {
justify-content: flex-start;
}
&.center {
justify-content: center;
}
&.right {
justify-content: flex-end;
}
button {
display: flex;
align-items: center;
span {
margin-left: 0 !important;
}
}
button.ant-btn-circle {
span {
margin: auto !important;
}
}
.ant-divider,
.ant-divider-vertical {
margin: 0 2px;
}
.icon-more {
transform: rotate(90deg);
svg {
font-size: 1.1em;
font-weight: 700;
}
}
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<Table
v-if="summaryFunc || summaryData"
:showHeader="false"
:bordered="false"
:pagination="false"
:dataSource="getDataSource"
:rowKey="(r) => r[rowKey]"
:columns="getColumns"
tableLayout="fixed"
:scroll="scroll"
/>
</template>
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent, unref, computed, toRaw } from 'vue';
import { Table } from 'ant-design-vue';
import { cloneDeep } from 'lodash-es';
import { isFunction } from '/@/utils/is';
import type { BasicColumn } from '../types/table';
import { INDEX_COLUMN_FLAG } from '../const';
import { propTypes } from '/@/utils/propTypes';
import { useTableContext } from '../hooks/useTableContext';
const SUMMARY_ROW_KEY = '_row';
const SUMMARY_INDEX_KEY = '_index';
export default defineComponent({
name: 'BasicTableFooter',
components: { Table },
props: {
summaryFunc: {
type: Function as PropType<Fn>,
},
summaryData: {
type: Array as PropType<Recordable[]>,
},
scroll: {
type: Object as PropType<Recordable>,
},
rowKey: propTypes.string.def('key'),
},
setup(props) {
const table = useTableContext();
const getDataSource = computed((): Recordable[] => {
const { summaryFunc, summaryData } = props;
if (summaryData?.length) {
summaryData.forEach((item, i) => (item[props.rowKey] = `${i}`));
return summaryData;
}
if (!isFunction(summaryFunc)) {
return [];
}
let dataSource = toRaw(unref(table.getDataSource()));
dataSource = summaryFunc(dataSource);
dataSource.forEach((item, i) => {
item[props.rowKey] = `${i}`;
});
return dataSource;
});
const getColumns = computed(() => {
const dataSource = unref(getDataSource);
const columns: BasicColumn[] = cloneDeep(table.getColumns());
const index = columns.findIndex((item) => item.flag === INDEX_COLUMN_FLAG);
const hasRowSummary = dataSource.some((item) => Reflect.has(item, SUMMARY_ROW_KEY));
const hasIndexSummary = dataSource.some((item) => Reflect.has(item, SUMMARY_INDEX_KEY));
if (index !== -1) {
if (hasIndexSummary) {
columns[index].customRender = ({ record }) => record[SUMMARY_INDEX_KEY];
columns[index].ellipsis = false;
} else {
Reflect.deleteProperty(columns[index], 'customRender');
}
}
if (table.getRowSelection() && hasRowSummary) {
const isFixed = columns.some((col) => col.fixed === 'left');
columns.unshift({
width: 60,
title: 'selection',
key: 'selectionKey',
align: 'center',
...(isFixed ? { fixed: 'left' } : {}),
customRender: ({ record }) => record[SUMMARY_ROW_KEY],
});
}
return columns;
});
return { getColumns, getDataSource };
},
});
</script>

View File

@@ -0,0 +1,81 @@
<template>
<div style="width: 100%">
<div v-if="$slots.headerTop" style="margin: 5px">
<slot name="headerTop"></slot>
</div>
<div class="flex items-center">
<slot name="tableTitle" v-if="$slots.tableTitle"></slot>
<TableTitle
:helpMessage="titleHelpMessage"
:title="title"
v-if="!$slots.tableTitle && title"
/>
<div :class="`${prefixCls}__toolbar`">
<slot name="toolbar"></slot>
<Divider type="vertical" v-if="$slots.toolbar && showTableSetting" />
<TableSetting
:setting="tableSetting"
v-if="showTableSetting"
@columns-change="handleColumnChange"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import type { TableSetting, ColumnChangeParam } from '../types/table';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { Divider } from 'ant-design-vue';
import TableSettingComponent from './settings/index.vue';
import TableTitle from './TableTitle.vue';
import { useDesign } from '/@/hooks/web/useDesign';
export default defineComponent({
name: 'BasicTableHeader',
components: {
Divider,
TableTitle,
TableSetting: TableSettingComponent,
},
props: {
title: {
type: [Function, String] as PropType<string | ((data: Recordable) => string)>,
},
tableSetting: {
type: Object as PropType<TableSetting>,
},
showTableSetting: {
type: Boolean,
},
titleHelpMessage: {
type: [String, Array] as PropType<string | string[]>,
default: '',
},
},
emits: ['columns-change'],
setup(_, { emit }) {
const { prefixCls } = useDesign('basic-table-header');
function handleColumnChange(data: ColumnChangeParam[]) {
emit('columns-change', data);
}
return { prefixCls, handleColumnChange };
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-basic-table-header';
.@{prefix-cls} {
&__toolbar {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
> * {
margin-right: 8px;
}
}
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<div
:class="prefixCls"
class="flex items-center mx-auto"
v-if="imgList && imgList.length"
:style="getWrapStyle"
>
<Badge :count="!showBadge || imgList.length == 1 ? 0 : imgList.length" v-if="simpleShow">
<div class="img-div">
<PreviewGroup>
<template v-for="(img, index) in imgList" :key="img">
<AImage
:width="size"
:style="{
display: index === 0 ? '' : 'none !important',
}"
:src="srcPrefix + img"
:fallback="fallback"
/>
</template>
</PreviewGroup>
</div>
</Badge>
<PreviewGroup v-else>
<template v-for="(img, index) in imgList" :key="img">
<AImage
:width="size"
:style="{ marginLeft: index === 0 ? 0 : margin + 'px' }"
:src="srcPrefix + img"
:fallback="fallback"
/>
</template>
</PreviewGroup>
</div>
</template>
<script lang="ts">
import type { CSSProperties } from 'vue';
import { defineComponent, computed } from 'vue';
import { useDesign } from '/@/hooks/web/useDesign';
import { Image, Badge } from 'ant-design-vue';
import { propTypes } from '/@/utils/propTypes';
export default defineComponent({
name: 'TableImage',
components: { AImage: Image, PreviewGroup: Image.PreviewGroup, Badge },
props: {
imgList: propTypes.arrayOf(propTypes.string),
size: propTypes.number.def(40),
// 是否简单显示(只显示第一张图片)
simpleShow: propTypes.bool,
// 简单模式下是否显示图片数量的badge
showBadge: propTypes.bool.def(true),
// 图片间距
margin: propTypes.number.def(4),
// src前缀将会附加在imgList中每一项之前
srcPrefix: propTypes.string.def(''),
// fallback,加载失败显示图像占位符。
fallback: propTypes.string.def(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghgg0Aj8i0JO4OzsrPv69Wv+hi2qPHr0qNvf39+iI97soRIh4f3z58/u7du3SXX7Xt7Z2enevHmzfQe+oSN2apSAPj09TSrb+XKI/f379+08+A0cNRE2ANkupk+ACNPvkSPcAAEibACyXUyfABGm3yNHuAECRNgAZLuYPgEirKlHu7u7XdyytGwHAd8jjNyng4OD7vnz51dbPT8/7z58+NB9+/bt6jU/TI+AGWHEnrx48eJ/EsSmHzx40L18+fLyzxF3ZVMjEyDCiEDjMYZZS5wiPXnyZFbJaxMhQIQRGzHvWR7XCyOCXsOmiDAi1HmPMMQjDpbpEiDCiL358eNHurW/5SnWdIBbXiDCiA38/Pnzrce2YyZ4//59F3ePLNMl4PbpiL2J0L979+7yDtHDhw8vtzzvdGnEXdvUigSIsCLAWavHp/+qM0BcXMd/q25n1vF57TYBp0a3mUzilePj4+7k5KSLb6gt6ydAhPUzXnoPR0dHl79WGTNCfBnn1uvSCJdegQhLI1vvCk+fPu2ePXt2tZOYEV6/fn31dz+shwAR1sP1cqvLntbEN9MxA9xcYjsxS1jWR4AIa2Ibzx0tc44fYX/16lV6NDFLXH+YL32jwiACRBiEbf5KcXoTIsQSpzXx4N28Ja4BQoK7rgXiydbHjx/P25TaQAJEGAguWy0+2Q8PD6/Ki4R8EVl+bzBOnZY95fq9rj9zAkTI2SxdidBHqG9+skdw43borCXO/ZcJdraPWdv22uIEiLA4q7nvvCug8WTqzQveOH26fodo7g6uFe/a17W3+nFBAkRYENRdb1vkkz1CH9cPsVy/jrhr27PqMYvENYNlHAIesRiBYwRy0V+8iXP8+/fvX11Mr7L7ECueb/r48eMqm7FuI2BGWDEG8cm+7G3NEOfmdcTQw4h9/55lhm7DekRYKQPZF2ArbXTAyu4kDYB2YxUzwg0gi/41ztHnfQG26HbGel/crVrm7tNY+/1btkOEAZ2M05r4FB7r9GbAIdxaZYrHdOsgJ/wCEQY0J74TmOKnbxxT9n3FgGGWWsVdowHtjt9Nnvf7yQM2aZU/TIAIAxrw6dOnAWtZZcoEnBpNuTuObWMEiLAx1HY0ZQJEmHJ3HNvGCBBhY6jtaMoEiJB0Z29vL6ls58vxPcO8/zfrdo5qvKO+d3Fx8Wu8zf1dW4p/cPzLly/dtv9Ts/EbcvGAHhHyfBIhZ6NSiIBTo0LNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiEC/wGgKKC4YMA4TAAAAABJRU5ErkJggg==',
),
},
setup(props) {
const getWrapStyle = computed((): CSSProperties => {
const { size } = props;
const s = `${size}px`;
return { height: s, width: s };
});
const { prefixCls } = useDesign('basic-table-img');
return { prefixCls, getWrapStyle };
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-basic-table-img';
.@{prefix-cls} {
.ant-image {
margin-right: 4px;
cursor: zoom-in;
img {
border-radius: 2px;
}
}
.img-div {
display: inline-grid;
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<BasicTitle :class="prefixCls" v-if="getTitle" :helpMessage="helpMessage">
{{ getTitle }}
</BasicTitle>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from 'vue';
import { BasicTitle } from '/@/components/Basic/index';
import { useDesign } from '/@/hooks/web/useDesign';
import { isFunction } from '/@/utils/is';
export default defineComponent({
name: 'BasicTableTitle',
components: { BasicTitle },
props: {
title: {
type: [Function, String] as PropType<string | ((data: Recordable) => string)>,
},
getSelectRows: {
type: Function as PropType<() => Recordable[]>,
},
helpMessage: {
type: [String, Array] as PropType<string | string[]>,
},
},
setup(props) {
const { prefixCls } = useDesign('basic-table-title');
const getTitle = computed(() => {
const { title, getSelectRows = () => {} } = props;
let tit = title;
if (isFunction(title)) {
tit = title({
selectRows: getSelectRows(),
});
}
return tit;
});
return { getTitle, prefixCls };
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-basic-table-title';
.@{prefix-cls} {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -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,
},
);
};

View File

@@ -0,0 +1,534 @@
<script lang="tsx">
import type { CSSProperties, PropType } from 'vue';
import { computed, defineComponent, nextTick, ref, toRaw, unref, watchEffect } from 'vue';
import type { BasicColumn } from '../../types/table';
import type { EditRecordRow } from './index';
import { CheckOutlined, CloseOutlined, FormOutlined } from '@ant-design/icons-vue';
import { CellComponent } from './CellComponent';
import { useDesign } from '/@/hooks/web/useDesign';
import { useTableContext } from '../../hooks/useTableContext';
import clickOutside from '/@/directives/clickOutside';
import { propTypes } from '/@/utils/propTypes';
import { isArray, isBoolean, isFunction, isNumber, isString } from '/@/utils/is';
import { createPlaceholderMessage } from './helper';
import { pick, set } from 'lodash-es';
import { treeToList } from '/@/utils/helper/treeHelper';
import { Spin } from 'ant-design-vue';
export default defineComponent({
name: 'EditableCell',
components: { FormOutlined, CloseOutlined, CheckOutlined, CellComponent, Spin },
directives: {
clickOutside,
},
props: {
value: {
type: [String, Number, Boolean, Object] as PropType<string | number | boolean | Recordable>,
default: '',
},
record: {
type: Object as PropType<EditRecordRow>,
},
column: {
type: Object as PropType<BasicColumn>,
default: () => ({}),
},
index: propTypes.number,
},
setup(props) {
const table = useTableContext();
const isEdit = ref(false);
const elRef = ref();
const ruleVisible = ref(false);
const ruleMessage = ref('');
const optionsRef = ref<LabelValueOptions>([]);
const currentValueRef = ref<any>(props.value);
const defaultValueRef = ref<any>(props.value);
const spinning = ref<boolean>(false);
const { prefixCls } = useDesign('editable-cell');
const getComponent = computed(() => props.column?.editComponent || 'Input');
const getRule = computed(() => props.column?.editRule);
const getRuleVisible = computed(() => {
return unref(ruleMessage) && unref(ruleVisible);
});
const getIsCheckComp = computed(() => {
const component = unref(getComponent);
return ['Checkbox', 'Switch'].includes(component);
});
const getComponentProps = computed(() => {
const isCheckValue = unref(getIsCheckComp);
const valueField = isCheckValue ? 'checked' : 'value';
const val = unref(currentValueRef);
const value = isCheckValue ? (isNumber(val) && isBoolean(val) ? val : !!val) : val;
let compProps = props.column?.editComponentProps ?? {};
const { record, column, index } = props;
if (isFunction(compProps)) {
compProps = compProps({ text: val, record, column, index }) ?? {};
}
// 用临时变量存储 onChange方法 用于 handleChange方法 获取并删除原始onChange, 防止存在两个 onChange
compProps.onChangeTemp = compProps.onChange;
delete compProps.onChange;
const component = unref(getComponent);
const apiSelectProps: Recordable = {};
if (component === 'ApiSelect') {
apiSelectProps.cache = true;
}
upEditDynamicDisabled(record, column, value);
return {
size: 'small',
getPopupContainer: () => unref(table?.wrapRef.value) ?? document.body,
placeholder: createPlaceholderMessage(unref(getComponent)),
...apiSelectProps,
...compProps,
[valueField]: value,
disabled: unref(getDisable),
} as any;
});
function upEditDynamicDisabled(record, column, value) {
if (!record) return false;
const { key, dataIndex } = column;
if (!key && !dataIndex) return;
const dataKey = (dataIndex || key) as string;
set(record, dataKey, value);
}
const getDisable = computed(() => {
const { editDynamicDisabled } = props.column;
let disabled = false;
if (isBoolean(editDynamicDisabled)) {
disabled = editDynamicDisabled;
}
if (isFunction(editDynamicDisabled)) {
const { record } = props;
disabled = editDynamicDisabled({ record });
}
return disabled;
});
const getValues = computed(() => {
const { editValueMap } = props.column;
const value = unref(currentValueRef);
if (editValueMap && isFunction(editValueMap)) {
return editValueMap(value);
}
const component = unref(getComponent);
if (!component.includes('Select') && !component.includes('Radio')) {
return value;
}
const options: LabelValueOptions =
unref(getComponentProps)?.options ?? (unref(optionsRef) || []);
const option = options.find((item) => `${item.value}` === `${value}`);
return option?.label ?? value;
});
const getWrapperStyle = computed((): CSSProperties => {
if (unref(getIsCheckComp) || unref(getRowEditable)) {
return {};
}
return {
width: 'calc(100% - 48px)',
};
});
const getWrapperClass = computed(() => {
const { align = 'center' } = props.column;
return `edit-cell-align-${align}`;
});
const getRowEditable = computed(() => {
const { editable } = props.record || {};
return !!editable;
});
watchEffect(() => {
// defaultValueRef.value = props.value;
currentValueRef.value = props.value;
});
watchEffect(() => {
const { editable } = props.column;
if (isBoolean(editable) || isBoolean(unref(getRowEditable))) {
isEdit.value = !!editable || unref(getRowEditable);
}
});
function handleEdit() {
if (unref(getRowEditable) || unref(props.column?.editRow)) return;
ruleMessage.value = '';
isEdit.value = true;
nextTick(() => {
const el = unref(elRef);
el?.focus?.();
});
}
async function handleChange(e: any) {
const component = unref(getComponent);
if (!e) {
currentValueRef.value = e;
} else if (component === 'Checkbox') {
currentValueRef.value = (e as ChangeEvent).target.checked;
} else if (component === 'Switch') {
currentValueRef.value = e;
} else if (e?.target && Reflect.has(e.target, 'value')) {
currentValueRef.value = (e as ChangeEvent).target.value;
} else if (isString(e) || isBoolean(e) || isNumber(e) || isArray(e)) {
currentValueRef.value = e;
}
const onChange = unref(getComponentProps)?.onChangeTemp;
if (onChange && isFunction(onChange)) onChange(...arguments);
table.emit?.('edit-change', {
column: props.column,
value: unref(currentValueRef),
record: toRaw(props.record),
});
handleSubmitRule();
}
async function handleSubmitRule() {
const { column, record } = props;
const { editRule } = column;
const currentValue = unref(currentValueRef);
if (editRule) {
if (isBoolean(editRule) && !currentValue && !isNumber(currentValue)) {
ruleVisible.value = true;
const component = unref(getComponent);
ruleMessage.value = createPlaceholderMessage(component);
return false;
}
if (isFunction(editRule)) {
const res = await editRule(currentValue, record as Recordable);
if (!!res) {
ruleMessage.value = res;
ruleVisible.value = true;
return false;
} else {
ruleMessage.value = '';
return true;
}
}
}
ruleMessage.value = '';
return true;
}
async function handleSubmit(needEmit = true, valid = true) {
if (valid) {
const isPass = await handleSubmitRule();
if (!isPass) return false;
}
const { column, index, record } = props;
if (!record) return false;
const { key, dataIndex } = column;
const value = unref(currentValueRef);
if (!key && !dataIndex) return;
const dataKey = (dataIndex || key) as string;
if (!record.editable) {
const { getBindValues } = table;
const { beforeEditSubmit, columns } = unref(getBindValues);
if (beforeEditSubmit && isFunction(beforeEditSubmit)) {
spinning.value = true;
const keys: string[] = columns
.map((_column) => _column.dataIndex)
.filter((field) => !!field) as string[];
let result: any = true;
try {
result = await beforeEditSubmit({
record: pick(record, keys),
index,
key: dataKey as string,
value,
});
} catch (e) {
result = false;
} finally {
spinning.value = false;
}
if (result === false) {
return;
}
}
}
set(record, dataKey, value);
//const record = await table.updateTableData(index, dataKey, value);
needEmit && table.emit?.('edit-end', { record, index, key: dataKey, value });
isEdit.value = false;
}
async function handleEnter() {
if (props.column?.editRow) {
return;
}
handleSubmit();
}
function handleSubmitClick() {
handleSubmit();
}
function handleCancel() {
isEdit.value = false;
currentValueRef.value = defaultValueRef.value;
const { column, index, record } = props;
const { key, dataIndex } = column;
table.emit?.('edit-cancel', {
record,
index,
key: dataIndex || key,
value: unref(currentValueRef),
});
}
function onClickOutside() {
if (props.column?.editable || unref(getRowEditable)) {
return;
}
const component = unref(getComponent);
if (component.includes('Input')) {
handleCancel();
}
}
// only ApiSelect or TreeSelect
function handleOptionsChange(options: LabelValueOptions) {
const { replaceFields } = unref(getComponentProps);
const component = unref(getComponent);
if (component === 'ApiTreeSelect') {
const { title = 'title', value = 'value', children = 'children' } = replaceFields || {};
let listOptions: Recordable[] = treeToList(options, { children });
listOptions = listOptions.map((item) => {
return {
label: item[title],
value: item[value],
};
});
optionsRef.value = listOptions as LabelValueOptions;
} else {
optionsRef.value = options;
}
}
function initCbs(cbs: 'submitCbs' | 'validCbs' | 'cancelCbs', handle: Fn) {
if (props.record) {
/* eslint-disable */
isArray(props.record[cbs])
? props.record[cbs]?.push(handle)
: (props.record[cbs] = [handle]);
}
}
if (props.record) {
initCbs('submitCbs', handleSubmit);
initCbs('validCbs', handleSubmitRule);
initCbs('cancelCbs', handleCancel);
if (props.column.dataIndex) {
if (!props.record.editValueRefs) props.record.editValueRefs = {};
props.record.editValueRefs[props.column.dataIndex as any] = currentValueRef;
}
/* eslint-disable */
props.record.onCancelEdit = () => {
isArray(props.record?.cancelCbs) && props.record?.cancelCbs.forEach((fn) => fn());
};
/* eslint-disable */
props.record.onSubmitEdit = async () => {
if (isArray(props.record?.submitCbs)) {
if (!props.record?.onValid?.()) return;
const submitFns = props.record?.submitCbs || [];
submitFns.forEach((fn) => fn(false, false));
table.emit?.('edit-row-end');
return true;
}
};
}
return {
isEdit,
prefixCls,
handleEdit,
currentValueRef,
handleSubmit,
handleChange,
handleCancel,
elRef,
getComponent,
getRule,
onClickOutside,
ruleMessage,
getRuleVisible,
getComponentProps,
handleOptionsChange,
getWrapperStyle,
getWrapperClass,
getRowEditable,
getValues,
handleEnter,
handleSubmitClick,
spinning,
};
},
render() {
return (
<div class={this.prefixCls}>
<div
v-show={!this.isEdit}
class={{ [`${this.prefixCls}__normal`]: true, 'ellipsis-cell': this.column.ellipsis }}
onClick={this.handleEdit}
>
<div class="cell-content" title={this.column.ellipsis ? this.getValues ?? '' : ''}>
{this.column.editRender
? this.column.editRender({
text: this.value,
record: this.record as Recordable,
column: this.column,
index: this.index,
})
: this.getValues ?? '\u00A0'}
</div>
{!this.column.editRow && <FormOutlined class={`${this.prefixCls}__normal-icon`} />}
</div>
{this.isEdit && (
<Spin spinning={this.spinning}>
<div class={`${this.prefixCls}__wrapper`} v-click-outside={this.onClickOutside}>
<CellComponent
{...this.getComponentProps}
component={this.getComponent}
style={this.getWrapperStyle}
popoverVisible={this.getRuleVisible}
rule={this.getRule}
ruleMessage={this.ruleMessage}
class={this.getWrapperClass}
ref="elRef"
onChange={this.handleChange}
onOptionsChange={this.handleOptionsChange}
onPressEnter={this.handleEnter}
/>
{!this.getRowEditable && (
<div class={`${this.prefixCls}__action`}>
<CheckOutlined
class={[`${this.prefixCls}__icon`, 'mx-2']}
onClick={this.handleSubmitClick}
/>
<CloseOutlined class={`${this.prefixCls}__icon `} onClick={this.handleCancel} />
</div>
)}
</div>
</Spin>
)}
</div>
);
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-editable-cell';
.edit-cell-align-left {
text-align: left;
input:not(.ant-calendar-picker-input, .ant-time-picker-input) {
text-align: left;
}
}
.edit-cell-align-center {
text-align: center;
input:not(.ant-calendar-picker-input, .ant-time-picker-input) {
text-align: center;
}
}
.edit-cell-align-right {
text-align: right;
input:not(.ant-calendar-picker-input, .ant-time-picker-input) {
text-align: right;
}
}
.edit-cell-rule-popover {
.ant-popover-inner-content {
padding: 4px 8px;
color: @error-color;
// border: 1px solid @error-color;
border-radius: 2px;
}
}
.@{prefix-cls} {
position: relative;
&__wrapper {
display: flex;
align-items: center;
justify-content: center;
> .ant-select {
min-width: calc(100% - 50px);
}
}
&__icon {
&:hover {
transform: scale(1.2);
svg {
color: @primary-color;
}
}
}
.ellipsis-cell {
.cell-content {
overflow-wrap: break-word;
word-break: break-word;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
&__normal {
&-icon {
position: absolute;
top: 4px;
right: 0;
display: none;
width: 20px;
cursor: pointer;
}
}
&:hover {
.@{prefix-cls}__normal-icon {
display: inline-block;
}
}
}
</style>

View File

@@ -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 '';
}

View File

@@ -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<T = Recordable> = Partial<
{
onEdit: (editable: boolean, submit?: boolean) => Promise<boolean>;
onValid: () => Promise<boolean>;
editable: boolean;
onCancel: Fn;
onSubmit: Fn;
submitCbs: Fn[];
cancelCbs: Fn[];
validCbs: Fn[];
editValueRefs: Recordable<Ref>;
} & T
>;

View File

@@ -0,0 +1,484 @@
<template>
<Tooltip placement="top">
<template #title>
<span>{{ t('component.table.settingColumn') }}</span>
</template>
<Popover
placement="bottomLeft"
trigger="click"
@visible-change="handleVisibleChange"
:overlayClassName="`${prefixCls}__cloumn-list`"
:getPopupContainer="getPopupContainer"
>
<template #title>
<div :class="`${prefixCls}__popover-title`">
<Checkbox
:indeterminate="indeterminate"
v-model:checked="checkAll"
@change="onCheckAllChange"
>
{{ t('component.table.settingColumnShow') }}
</Checkbox>
<Checkbox v-model:checked="checkIndex" @change="handleIndexCheckChange">
{{ t('component.table.settingIndexColumnShow') }}
</Checkbox>
<Checkbox
v-model:checked="checkSelect"
@change="handleSelectCheckChange"
:disabled="!defaultRowSelection"
>
{{ t('component.table.settingSelectColumnShow') }}
</Checkbox>
<a-button size="small" type="link" @click="reset">
{{ t('common.resetText') }}
</a-button>
</div>
</template>
<template #content>
<ScrollContainer>
<CheckboxGroup v-model:value="checkedList" @change="onChange" ref="columnListRef">
<template v-for="item in plainOptions" :key="item.value">
<div :class="`${prefixCls}__check-item`" v-if="!('ifShow' in item && !item.ifShow)">
<DragOutlined class="table-column-drag-icon" />
<Checkbox :value="item.value">
{{ item.label }}
</Checkbox>
<Tooltip
placement="bottomLeft"
:mouseLeaveDelay="0.4"
:getPopupContainer="getPopupContainer"
>
<template #title>
{{ t('component.table.settingFixedLeft') }}
</template>
<Icon
icon="line-md:arrow-align-left"
:class="[
`${prefixCls}__fixed-left`,
{
active: item.fixed === 'left',
disabled: !checkedList.includes(item.value),
},
]"
@click="handleColumnFixed(item, 'left')"
/>
</Tooltip>
<Divider type="vertical" />
<Tooltip
placement="bottomLeft"
:mouseLeaveDelay="0.4"
:getPopupContainer="getPopupContainer"
>
<template #title>
{{ t('component.table.settingFixedRight') }}
</template>
<Icon
icon="line-md:arrow-align-left"
:class="[
`${prefixCls}__fixed-right`,
{
active: item.fixed === 'right',
disabled: !checkedList.includes(item.value),
},
]"
@click="handleColumnFixed(item, 'right')"
/>
</Tooltip>
</div>
</template>
</CheckboxGroup>
</ScrollContainer>
</template>
<SettingOutlined />
</Popover>
</Tooltip>
</template>
<script lang="ts">
import type { BasicColumn, ColumnChangeParam } from '../../types/table';
import {
defineComponent,
ref,
reactive,
toRefs,
watchEffect,
nextTick,
unref,
computed,
} from 'vue';
import { Tooltip, Popover, Checkbox, Divider } from 'ant-design-vue';
import type { CheckboxChangeEvent } from 'ant-design-vue/lib/checkbox/interface';
import { SettingOutlined, DragOutlined } from '@ant-design/icons-vue';
import { Icon } from '/@/components/Icon';
import { ScrollContainer } from '/@/components/Container';
import { useI18n } from '/@/hooks/web/useI18n';
import { useTableContext } from '../../hooks/useTableContext';
import { useDesign } from '/@/hooks/web/useDesign';
// import { useSortable } from '/@/hooks/web/useSortable';
import { isFunction, isNullAndUnDef } from '/@/utils/is';
import { getPopupContainer as getParentContainer } from '/@/utils';
import { cloneDeep, omit } from 'lodash-es';
import Sortablejs from 'sortablejs';
import type Sortable from 'sortablejs';
interface State {
checkAll: boolean;
isInit?: boolean;
checkedList: string[];
defaultCheckList: string[];
}
interface Options {
label: string;
value: string;
fixed?: boolean | 'left' | 'right';
}
export default defineComponent({
name: 'ColumnSetting',
components: {
SettingOutlined,
Popover,
Tooltip,
Checkbox,
CheckboxGroup: Checkbox.Group,
DragOutlined,
ScrollContainer,
Divider,
Icon,
},
emits: ['columns-change'],
setup(_, { emit, attrs }) {
const { t } = useI18n();
const table = useTableContext();
const defaultRowSelection = omit(table.getRowSelection(), 'selectedRowKeys');
let inited = false;
const cachePlainOptions = ref<Options[]>([]);
const plainOptions = ref<Options[] | any>([]);
const plainSortOptions = ref<Options[]>([]);
const columnListRef = ref<ComponentRef>(null);
const state = reactive<State>({
checkAll: true,
checkedList: [],
defaultCheckList: [],
});
const checkIndex = ref(false);
const checkSelect = ref(false);
const { prefixCls } = useDesign('basic-column-setting');
const getValues = computed(() => {
return unref(table?.getBindValues) || {};
});
watchEffect(() => {
const columns = table.getColumns();
setTimeout(() => {
if (columns.length && !state.isInit) {
init();
}
}, 0);
});
watchEffect(() => {
const values = unref(getValues);
checkIndex.value = !!values.showIndexColumn;
checkSelect.value = !!values.rowSelection;
});
function getColumns() {
const ret: Options[] = [];
table.getColumns({ ignoreIndex: true, ignoreAction: true }).forEach((item) => {
ret.push({
label: (item.title as string) || (item.customTitle as string),
value: (item.dataIndex || item.title) as string,
...item,
});
});
return ret;
}
function init() {
const columns = getColumns();
const checkList = table
.getColumns({ ignoreAction: true, ignoreIndex: true })
.map((item) => {
if (item.defaultHidden) {
return '';
}
return item.dataIndex || item.title;
})
.filter(Boolean) as string[];
if (!plainOptions.value.length) {
plainOptions.value = columns;
plainSortOptions.value = columns;
cachePlainOptions.value = columns;
state.defaultCheckList = checkList;
} else {
// const fixedColumns = columns.filter((item) =>
// Reflect.has(item, 'fixed')
// ) as BasicColumn[];
unref(plainOptions).forEach((item: BasicColumn) => {
const findItem = columns.find((col: BasicColumn) => col.dataIndex === item.dataIndex);
if (findItem) {
item.fixed = findItem.fixed;
}
});
}
state.isInit = true;
state.checkedList = checkList;
}
// checkAll change
function onCheckAllChange(e: CheckboxChangeEvent) {
const checkList = plainOptions.value.map((item) => item.value);
if (e.target.checked) {
state.checkedList = checkList;
setColumns(checkList);
} else {
state.checkedList = [];
setColumns([]);
}
}
const indeterminate = computed(() => {
const len = plainOptions.value.length;
let checkedLen = state.checkedList.length;
// unref(checkIndex) && checkedLen--;
return checkedLen > 0 && checkedLen < len;
});
// Trigger when check/uncheck a column
function onChange(checkedList: string[]) {
const len = plainSortOptions.value.length;
state.checkAll = checkedList.length === len;
const sortList = unref(plainSortOptions).map((item) => item.value);
checkedList.sort((prev, next) => {
return sortList.indexOf(prev) - sortList.indexOf(next);
});
setColumns(checkedList);
}
let sortable: Sortable;
let sortableOrder: string[] = [];
// reset columns
function reset() {
state.checkedList = [...state.defaultCheckList];
state.checkAll = true;
plainOptions.value = unref(cachePlainOptions);
plainSortOptions.value = unref(cachePlainOptions);
setColumns(table.getCacheColumns());
sortable.sort(sortableOrder);
}
// Open the pop-up window for drag and drop initialization
function handleVisibleChange() {
if (inited) return;
nextTick(() => {
const columnListEl = unref(columnListRef);
if (!columnListEl) return;
const el = columnListEl.$el as any;
if (!el) return;
// Drag and drop sort
sortable = Sortablejs.create(unref(el), {
animation: 500,
delay: 400,
delayOnTouchOnly: true,
handle: '.table-column-drag-icon ',
onEnd: (evt) => {
const { oldIndex, newIndex } = evt;
if (isNullAndUnDef(oldIndex) || isNullAndUnDef(newIndex) || oldIndex === newIndex) {
return;
}
// Sort column
const columns = cloneDeep(plainSortOptions.value);
if (oldIndex > newIndex) {
columns.splice(newIndex, 0, columns[oldIndex]);
columns.splice(oldIndex + 1, 1);
} else {
columns.splice(newIndex + 1, 0, columns[oldIndex]);
columns.splice(oldIndex, 1);
}
plainSortOptions.value = columns;
setColumns(
columns
.map((col: Options) => col.value)
.filter((value: string) => state.checkedList.includes(value)),
);
},
});
// 记录原始order 序列
sortableOrder = sortable.toArray();
inited = true;
});
}
// Control whether the serial number column is displayed
function handleIndexCheckChange(e: CheckboxChangeEvent) {
table.setProps({
showIndexColumn: e.target.checked,
});
}
// Control whether the check box is displayed
function handleSelectCheckChange(e: CheckboxChangeEvent) {
table.setProps({
rowSelection: e.target.checked ? defaultRowSelection : undefined,
});
}
function handleColumnFixed(item: BasicColumn, fixed?: 'left' | 'right') {
if (!state.checkedList.includes(item.dataIndex as string)) return;
const columns = getColumns().filter((c: BasicColumn) =>
state.checkedList.includes(c.dataIndex as string),
) as BasicColumn[];
const isFixed = item.fixed === fixed ? false : fixed;
const index = columns.findIndex((col) => col.dataIndex === item.dataIndex);
if (index !== -1) {
columns[index].fixed = isFixed;
}
item.fixed = isFixed;
if (isFixed && !item.width) {
item.width = 100;
}
table.setCacheColumnsByField?.(item.dataIndex as string, { fixed: isFixed });
setColumns(columns);
}
function setColumns(columns: BasicColumn[] | string[]) {
table.setColumns(columns);
const data: ColumnChangeParam[] = unref(plainSortOptions).map((col) => {
const visible =
columns.findIndex(
(c: BasicColumn | string) =>
c === col.value || (typeof c !== 'string' && c.dataIndex === col.value),
) !== -1;
return { dataIndex: col.value, fixed: col.fixed, visible };
});
emit('columns-change', data);
}
function getPopupContainer() {
return isFunction(attrs.getPopupContainer)
? attrs.getPopupContainer()
: getParentContainer();
}
return {
t,
...toRefs(state),
indeterminate,
onCheckAllChange,
onChange,
plainOptions,
reset,
prefixCls,
columnListRef,
handleVisibleChange,
checkIndex,
checkSelect,
handleIndexCheckChange,
handleSelectCheckChange,
defaultRowSelection,
handleColumnFixed,
getPopupContainer,
};
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-basic-column-setting';
.table-column-drag-icon {
margin: 0 5px;
cursor: move;
}
.@{prefix-cls} {
&__popover-title {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
}
&__check-item {
display: flex;
align-items: center;
min-width: 100%;
padding: 4px 16px 8px 0;
.ant-checkbox-wrapper {
width: 100%;
&:hover {
color: @primary-color;
}
}
}
&__fixed-left,
&__fixed-right {
color: rgb(0 0 0 / 45%);
cursor: pointer;
&.active,
&:hover {
color: @primary-color;
}
&.disabled {
color: @disabled-color;
cursor: not-allowed;
}
}
&__fixed-right {
transform: rotate(180deg);
}
&__cloumn-list {
svg {
width: 1em !important;
height: 1em !important;
}
.ant-popover-inner-content {
// max-height: 360px;
padding-right: 0;
padding-left: 0;
// overflow: auto;
}
.ant-checkbox-group {
width: 100%;
min-width: 260px;
// flex-wrap: wrap;
}
.scrollbar {
height: 220px;
}
}
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<Tooltip placement="top">
<template #title>
<span>{{ t('component.table.settingFullScreen') }}</span>
</template>
<FullscreenOutlined @click="toggle" v-if="!isFullscreen" />
<FullscreenExitOutlined @click="toggle" v-else />
</Tooltip>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { Tooltip } from 'ant-design-vue';
import { FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons-vue';
import { useFullscreen } from '@vueuse/core';
import { useI18n } from '/@/hooks/web/useI18n';
import { useTableContext } from '../../hooks/useTableContext';
export default defineComponent({
name: 'FullScreenSetting',
components: {
FullscreenExitOutlined,
FullscreenOutlined,
Tooltip,
},
setup() {
const table = useTableContext();
const { t } = useI18n();
const { toggle, isFullscreen } = useFullscreen(table.wrapRef);
return {
toggle,
isFullscreen,
t,
};
},
});
</script>

View File

@@ -0,0 +1,33 @@
<template>
<Tooltip placement="top">
<template #title>
<span>{{ t('common.redo') }}</span>
</template>
<RedoOutlined @click="redo" />
</Tooltip>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { Tooltip } from 'ant-design-vue';
import { RedoOutlined } from '@ant-design/icons-vue';
import { useI18n } from '/@/hooks/web/useI18n';
import { useTableContext } from '../../hooks/useTableContext';
export default defineComponent({
name: 'RedoSetting',
components: {
RedoOutlined,
Tooltip,
},
setup() {
const table = useTableContext();
const { t } = useI18n();
function redo() {
table.reload();
}
return { redo, t };
},
});
</script>

View File

@@ -0,0 +1,64 @@
<template>
<Tooltip placement="top">
<template #title>
<span>{{ t('component.table.settingDens') }}</span>
</template>
<Dropdown placement="bottom" :trigger="['click']" :getPopupContainer="getPopupContainer">
<ColumnHeightOutlined />
<template #overlay>
<Menu @click="handleTitleClick" selectable v-model:selectedKeys="selectedKeysRef">
<MenuItem key="default">
<span>{{ t('component.table.settingDensDefault') }}</span>
</MenuItem>
<MenuItem key="middle">
<span>{{ t('component.table.settingDensMiddle') }}</span>
</MenuItem>
<MenuItem key="small">
<span>{{ t('component.table.settingDensSmall') }}</span>
</MenuItem>
</Menu>
</template>
</Dropdown>
</Tooltip>
</template>
<script lang="ts">
import type { SizeType } from '../../types/table';
import { defineComponent, ref } from 'vue';
import { Tooltip, Dropdown, Menu } from 'ant-design-vue';
import { ColumnHeightOutlined } from '@ant-design/icons-vue';
import { useI18n } from '/@/hooks/web/useI18n';
import { useTableContext } from '../../hooks/useTableContext';
import { getPopupContainer } from '/@/utils';
export default defineComponent({
name: 'SizeSetting',
components: {
ColumnHeightOutlined,
Tooltip,
Dropdown,
Menu,
MenuItem: Menu.Item,
},
setup() {
const table = useTableContext();
const { t } = useI18n();
const selectedKeysRef = ref<SizeType[]>([table.getSize()]);
function handleTitleClick({ key }: { key: SizeType }) {
selectedKeysRef.value = [key];
table.setProps({
size: key,
});
}
return {
handleTitleClick,
selectedKeysRef,
getPopupContainer,
t,
};
},
});
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div class="table-settings">
<RedoSetting v-if="getSetting.redo" :getPopupContainer="getTableContainer" />
<SizeSetting v-if="getSetting.size" :getPopupContainer="getTableContainer" />
<ColumnSetting
v-if="getSetting.setting"
@columns-change="handleColumnChange"
:getPopupContainer="getTableContainer"
/>
<FullScreenSetting v-if="getSetting.fullScreen" :getPopupContainer="getTableContainer" />
</div>
</template>
<script lang="ts">
import type { PropType } from 'vue';
import type { TableSetting, ColumnChangeParam } from '../../types/table';
import { defineComponent, computed, unref } from 'vue';
import ColumnSetting from './ColumnSetting.vue';
import SizeSetting from './SizeSetting.vue';
import RedoSetting from './RedoSetting.vue';
import FullScreenSetting from './FullScreenSetting.vue';
import { useI18n } from '/@/hooks/web/useI18n';
import { useTableContext } from '../../hooks/useTableContext';
export default defineComponent({
name: 'TableSetting',
components: {
ColumnSetting,
SizeSetting,
RedoSetting,
FullScreenSetting,
},
props: {
setting: {
type: Object as PropType<TableSetting>,
default: () => ({}),
},
},
emits: ['columns-change'],
setup(props, { emit }) {
const { t } = useI18n();
const table = useTableContext();
const getSetting = computed((): TableSetting => {
return {
redo: true,
size: true,
setting: true,
fullScreen: false,
...props.setting,
};
});
function handleColumnChange(data: ColumnChangeParam[]) {
emit('columns-change', data);
}
function getTableContainer() {
return table ? unref(table.wrapRef) : document.body;
}
return { getSetting, t, handleColumnChange, getTableContainer };
},
});
</script>
<style lang="less">
.table-settings {
& > * {
margin-right: 12px;
}
svg {
width: 1.3em;
height: 1.3em;
}
}
</style>

View File

@@ -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'

View File

@@ -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<BasicTableProps>,
getPaginationRef: ComputedRef<boolean | PaginationProps>,
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<BasicTableProps>, 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<BasicTableProps>,
getPaginationRef: ComputedRef<boolean | PaginationProps>,
) {
const columnsRef = ref(unref(propsRef).columns) as unknown as Ref<BasicColumn[]>
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<BasicColumn>) {
if (!dataIndex || !value) {
return
}
cacheColumns.forEach((item) => {
if (item.dataIndex === dataIndex) {
Object.assign(item, value)
return
}
})
}
/**
* set columns
* @param columnList keycolumn
*/
function setColumns(columnList: Partial<BasicColumn>[] | (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
}
}

View File

@@ -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<boolean | undefined>
}
function getKey(
record: Recordable,
rowKey: string | ((record: Record<string, any>) => 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<BasicTableProps>,
{ 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,
}
}

View File

@@ -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<boolean | PaginationProps>
setPagination: (info: Partial<PaginationProps>) => void
setLoading: (loading: boolean) => void
getFieldsValue: () => Recordable
clearSelectedRowKeys: () => void
tableData: Ref<Recordable[]>
}
interface SearchState {
sortInfo: Recordable
filterInfo: Record<string, string[]>
}
export function useDataSource(
propsRef: ComputedRef<BasicTableProps>,
{
getPaginationInfo,
setPagination,
setLoading,
getFieldsValue,
clearSelectedRowKeys,
tableData,
}: ActionType,
emit: EmitType,
) {
const searchState = reactive<SearchState>({
sortInfo: {},
filterInfo: {},
})
const dataSourceRef = ref<Recordable[]>([])
const rawDataSourceRef = ref<Recordable>({})
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<Recordable<string[]>>,
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<T = Recordable>(values: T[]) {
dataSourceRef.value = values
}
function getDataSource<T = Recordable>() {
return getDataSourceRef.value as T[]
}
function getRawDataSource<T = Recordable>() {
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,
}
}

View File

@@ -0,0 +1,21 @@
import { ref, ComputedRef, unref, computed, watch } from 'vue'
import type { BasicTableProps } from '../types/table'
export function useLoading(props: ComputedRef<BasicTableProps>) {
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 }
}

View File

@@ -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 : <LeftOutlined />
} else if (type === 'next') {
return page === 1 ? null : <RightOutlined />
}
return originalElement
}
export function usePagination(refProps: ComputedRef<BasicTableProps>) {
const { t } = useI18n()
const configRef = ref<PaginationProps>({})
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<PaginationProps>) {
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 }
}

View File

@@ -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<BasicTableProps>,
tableData: Ref<Recordable[]>,
emit: EmitType,
) {
const selectedRowKeysRef = ref<string[]>([])
const selectedRowRef = ref<Recordable[]>([])
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<T = Recordable>() {
// 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,
}
}

View File

@@ -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<ComponentRef>,
getDataSourceRef: ComputedRef<Recordable[]>,
) {
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 }
}

View File

@@ -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<DynamicProps<BasicTableProps>>
type UseTableMethod = TableActionType & {
getForm: () => FormActionType
}
export function useTable(tableProps?: Props): [
(instance: TableActionType, formInstance: UseTableMethod) => void,
TableActionType & {
getForm: () => FormActionType
},
] {
const tableRef = ref<Nullable<TableActionType>>(null)
const loadedRef = ref<Nullable<boolean>>(false)
const formRef = ref<Nullable<UseTableMethod>>(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<BasicTableProps>) => {
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<PaginationProps>) => {
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]
}

View File

@@ -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<Nullable<HTMLElement>>
getBindValues: ComputedRef<Recordable>
}
type RetInstance = Omit<Instance, 'getBindValues'> & {
getBindValues: ComputedRef<BasicTableProps>
}
export function createTableContext(instance: Instance) {
provide(key, instance)
}
export function useTableContext(): RetInstance {
return inject(key) as RetInstance
}

View File

@@ -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<BasicTableProps>,
tableData: Ref<Recordable[]>,
emit: EmitType,
) {
const expandedRowKeys = ref<string[]>([])
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 }
}

View File

@@ -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<BasicTableProps>,
scrollRef: ComputedRef<{
x: string | number | true
y: string | number | null
scrollToFirstRowOnChange: boolean
}>,
tableElRef: Ref<ComponentRef>,
getDataSourceRef: ComputedRef<Recordable>,
) {
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 }
}

View File

@@ -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<BasicTableProps>,
slots: Slots,
fetch: (opt?: FetchParams | undefined) => Promise<void>,
getLoading: ComputedRef<boolean | undefined>,
) {
const getFormProps = computed((): Partial<FormProps> => {
const { formConfig } = unref(propsRef)
const { submitButtonOptions } = formConfig || {}
return {
showAdvancedButton: true,
...formConfig,
submitButtonOptions: { loading: unref(getLoading), ...submitButtonOptions },
compact: true,
}
})
const getFormSlotKeys: ComputedRef<string[]> = 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,
}
}

View File

@@ -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<BasicTableProps>,
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 }
}

View File

@@ -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<BasicTableProps>,
tableElRef: Ref<ComponentRef>,
columnsRef: ComputedRef<BasicColumn[]>,
rowSelectionRef: ComputedRef<TableRowSelection | null>,
getDataSourceRef: ComputedRef<Recordable[]>,
wrapRef: Ref<HTMLElement | null>,
formRef: Ref<ComponentRef>,
) {
const tableHeightRef: Ref<Nullable<number | string>> = 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 }
}

View File

@@ -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<BasicTableProps>, 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 }
}

View File

@@ -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<TableSetting>({}),
inset: Boolean,
sortFn: {
type: Function as PropType<(sortInfo: SorterResult) => any>,
default: DEFAULT_SORT_FN,
},
filterFn: {
type: Function as PropType<(data: Partial<Recordable<string[]>>) => 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<Recordable[]>,
default: null,
},
indentSize: propTypes.number.def(24),
canColDrag: { type: Boolean, default: true },
api: {
type: Function as PropType<(...arg: any[]) => Promise<any>>,
default: null,
},
beforeFetch: {
type: Function as PropType<Fn>,
default: null,
},
afterFetch: {
type: Function as PropType<Fn>,
default: null,
},
handleSearchInfoFn: {
type: Function as PropType<Fn>,
default: null,
},
fetchSetting: {
type: Object as PropType<FetchSetting>,
default: () => {
return FETCH_SETTING
},
},
// 立即请求接口
immediate: { type: Boolean, default: true },
emptyDataIsShowTable: { type: Boolean, default: true },
// 额外的请求参数
searchInfo: {
type: Object as PropType<Recordable>,
default: null,
},
// 默认的排序参数
defSort: {
type: Object as PropType<Recordable>,
default: null,
},
// 使用搜索表单
useSearchForm: propTypes.bool,
// 表单配置
formConfig: {
type: Object as PropType<Partial<FormProps>>,
default: null,
},
columns: {
type: Array as PropType<BasicColumn[]>,
default: () => [],
},
showIndexColumn: { type: Boolean, default: true },
indexColumnProps: {
type: Object as PropType<BasicColumn>,
default: null,
},
actionColumn: {
type: Object as PropType<BasicColumn>,
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<TableRowSelection | null>,
default: null,
},
title: {
type: [String, Function] as PropType<string | ((data: Recordable) => string)>,
default: null,
},
titleHelpMessage: {
type: [String, Array] as PropType<string | string[]>,
},
maxHeight: propTypes.number,
dataSource: {
type: Array as PropType<Recordable[]>,
default: null,
},
rowKey: {
type: [String, Function] as PropType<string | ((record: Recordable) => string)>,
default: '',
},
bordered: propTypes.bool,
pagination: {
type: [Object, Boolean] as PropType<PaginationProps | boolean>,
default: null,
},
loading: propTypes.bool,
rowClassName: {
type: Function as PropType<(record: TableCustomRecord<any>, 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<any>
>,
},
size: {
type: String as PropType<SizeType>,
default: DEFAULT_SIZE,
},
}

View File

@@ -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<T> {
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<T> = (record: RecordProps<T>) => VNodeChild | JSX.Element
export interface ColumnProps<T> {
/**
* 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<T> | 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<T>) => 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<string>
}

View File

@@ -0,0 +1,14 @@
export type ComponentType =
| 'Input'
| 'InputNumber'
| 'Select'
| 'ApiSelect'
| 'AutoComplete'
| 'ApiTreeSelect'
| 'Checkbox'
| 'Switch'
| 'DatePicker'
| 'TimePicker'
| 'RadioGroup'
| 'RadioButtonGroup'
| 'ApiRadioGroup'

View File

@@ -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[]
}

View File

@@ -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<T = Recordable> {
currentDataSource: T[]
}
export interface TableRowSelection<T = any> 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<T> {
record?: T
index?: number
}
export interface ExpandedRowRenderRecord<T> extends TableCustomRecord<T> {
indent?: number
expanded?: boolean
}
export interface ColumnFilterItem {
text?: string
value?: string
children?: any
}
export interface TableCustomRecord<T = Recordable> {
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<void>
setSelectedRows: (rows: Recordable[]) => void
getSelectRows: <T = Recordable>() => 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<PaginationProps>) => void
setTableData: <T = Recordable>(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 = Recordable>() => T[]
getRawDataSource: <T = Recordable>() => T
setLoading: (loading: boolean) => void
setProps: (props: Partial<BasicTableProps>) => void
redoHeight: () => void
setSelectedRowKeys: (rowKeys: string[] | number[]) => void
getPaginationRef: () => PaginationProps | boolean
getSize: () => SizeType
getRowSelection: () => TableRowSelection<Recordable>
getCacheColumns: () => BasicColumn[]
emit?: EmitType
updateTableData: (index: number, key: string, value: any) => Recordable
setShowPagination: (show: boolean) => Promise<void>
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<T = any> {
// 点击行选中
clickToRowSelect?: boolean
isTreeTable?: boolean
// 自定义排序方法
sortFn?: (sortInfo: SorterResult) => any
// 排序方法
filterFn?: (data: Partial<Recordable<string[]>>) => 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<any>
// 请求之前处理参数
beforeFetch?: Fn
// 自定义处理接口返回参数
afterFetch?: Fn
// 查询条件请求之前处理
handleSearchInfoFn?: Fn
// 请求接口配置
fetchSetting?: Partial<FetchSetting>
// 立即请求接口
immediate?: boolean
// 在开起搜索表单的时候,如果没有数据是否显示表格
emptyDataIsShowTable?: boolean
// 额外的请求参数
searchInfo?: Recordable
// 默认的排序参数
defSort?: Recordable
// 使用搜索表单
useSearchForm?: boolean
// 表单配置
formConfig?: Partial<FormProps>
// 列配置
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<T>) => 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<T>, 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<any>
/**
* 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<string | number, any>
// @ts-ignore
export interface BasicColumn extends ColumnProps<Recordable> {
children?: BasicColumn[]
filters?: {
text: string
value: string
children?:
| unknown[]
| (((props: Record<string, unknown>) => 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<string>)
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
}

View File

@@ -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'
}

View File

@@ -0,0 +1,4 @@
import { withInstall } from '/@/utils'
import basicUpload from './src/BasicUpload.vue'
export const BasicUpload = withInstall(basicUpload)

View File

@@ -0,0 +1,111 @@
<template>
<div>
<Space>
<a-button type="primary" @click="openUploadModal" preIcon="carbon:cloud-upload">
{{ t('component.upload.upload') }}
</a-button>
<Tooltip placement="bottom" v-if="showPreview">
<template #title>
{{ t('component.upload.uploaded') }}
<template v-if="fileList.length">
{{ fileList.length }}
</template>
</template>
<a-button @click="openPreviewModal">
<Icon icon="bi:eye" />
<template v-if="fileList.length && showPreviewNumber">
{{ fileList.length }}
</template>
</a-button>
</Tooltip>
</Space>
<UploadModal
v-bind="bindValue"
:previewFileList="fileList"
@register="registerUploadModal"
@change="handleChange"
@delete="handleDelete"
/>
<UploadPreviewModal
:value="fileList"
@register="registerPreviewModal"
@list-change="handlePreviewChange"
@delete="handlePreviewDelete"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch, unref, computed } from 'vue'
import { Icon } from '/@/components/Icon'
import { Tooltip, Space } from 'ant-design-vue'
import { useModal } from '/@/components/Modal'
import { uploadContainerProps } from './props'
import { omit } from 'lodash-es'
import { useI18n } from '/@/hooks/web/useI18n'
import { isArray } from '/@/utils/is'
import UploadModal from './UploadModal.vue'
import UploadPreviewModal from './UploadPreviewModal.vue'
export default defineComponent({
name: 'BasicUpload',
components: { UploadModal, Space, UploadPreviewModal, Icon, Tooltip },
props: uploadContainerProps,
emits: ['change', 'delete', 'preview-delete', 'update:value'],
setup(props, { emit, attrs }) {
const { t } = useI18n()
// 上传modal
const [registerUploadModal, { openModal: openUploadModal }] = useModal()
// 预览modal
const [registerPreviewModal, { openModal: openPreviewModal }] = useModal()
const fileList = ref<string[]>([])
const showPreview = computed(() => {
const { emptyHidePreview } = props
if (!emptyHidePreview) return true
return emptyHidePreview ? fileList.value.length > 0 : true
})
const bindValue = computed(() => {
const value = { ...attrs, ...props }
return omit(value, 'onChange')
})
watch(
() => props.value,
(value = []) => {
fileList.value = isArray(value) ? value : []
},
{ immediate: true },
)
// 上传modal保存操作
function handleChange(urls: string[]) {
fileList.value = [...unref(fileList), ...(urls || [])]
emit('update:value', fileList.value)
emit('change', fileList.value)
}
// 预览modal保存操作
function handlePreviewChange(urls: string[]) {
fileList.value = [...(urls || [])]
emit('update:value', fileList.value)
emit('change', fileList.value)
}
function handleDelete(record: Recordable) {
emit('delete', record)
}
function handlePreviewDelete(url: string) {
emit('preview-delete', url)
}
return {
registerUploadModal,
openUploadModal,
handleChange,
handlePreviewChange,
registerPreviewModal,
openPreviewModal,
fileList,
showPreview,
bindValue,
handleDelete,
handlePreviewDelete,
t,
}
},
})
</script>

View File

@@ -0,0 +1,97 @@
<script lang="tsx">
import { defineComponent, CSSProperties, watch, nextTick } from 'vue'
import { fileListProps } from './props'
import { isFunction } from '/@/utils/is'
import { useModalContext } from '/@/components/Modal/src/hooks/useModalContext'
export default defineComponent({
name: 'FileList',
props: fileListProps,
setup(props) {
const modalFn = useModalContext()
watch(
() => props.dataSource,
() => {
nextTick(() => {
modalFn?.redoModalHeight?.()
})
},
)
return () => {
const { columns, actionColumn, dataSource } = props
const columnList = [...columns, actionColumn]
return (
<table class="file-table">
<colgroup>
{columnList.map((item) => {
const { width = 0, dataIndex } = item
const style: CSSProperties = {
width: `${width}px`,
minWidth: `${width}px`,
}
return <col style={width ? style : {}} key={dataIndex} />
})}
</colgroup>
<thead>
<tr class="file-table-tr">
{columnList.map((item) => {
const { title = '', align = 'center', dataIndex } = item
return (
<th class={['file-table-th', align]} key={dataIndex}>
{title}
</th>
)
})}
</tr>
</thead>
<tbody>
{dataSource.map((record = {}, index) => {
return (
<tr class="file-table-tr" key={`${index + record.name || ''}`}>
{columnList.map((item) => {
const { dataIndex = '', customRender, align = 'center' } = item
const render = customRender && isFunction(customRender)
return (
<td class={['file-table-td', align]} key={dataIndex}>
{render
? customRender?.({ text: record[dataIndex], record })
: record[dataIndex]}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
)
}
},
})
</script>
<style lang="less">
.file-table {
width: 100%;
border-collapse: collapse;
.center {
text-align: center;
}
.left {
text-align: left;
}
.right {
text-align: right;
}
&-th,
&-td {
padding: 12px 8px;
}
thead {
background-color: @background-color-light;
}
table,
td,
th {
border: 1px solid @border-color-base;
}
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<span class="thumb">
<Image v-if="fileUrl" :src="fileUrl" :width="104" />
</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { propTypes } from '/@/utils/propTypes'
import { Image } from 'ant-design-vue'
export default defineComponent({
components: { Image },
props: {
fileUrl: propTypes.string.def(''),
fileName: propTypes.string.def(''),
},
})
</script>
<style lang="less">
.thumb {
img {
position: static;
display: block;
cursor: zoom-in;
border-radius: 4px;
object-fit: cover;
}
}
</style>

View File

@@ -0,0 +1,298 @@
<template>
<BasicModal
width="800px"
:title="t('component.upload.upload')"
:okText="t('component.upload.save')"
v-bind="$attrs"
@register="register"
@ok="handleOk"
:closeFunc="handleCloseFunc"
:maskClosable="false"
:keyboard="false"
class="upload-modal"
:okButtonProps="getOkButtonProps"
:cancelButtonProps="{ disabled: isUploadingRef }"
>
<template #centerFooter>
<a-button
@click="handleStartUpload"
color="success"
:disabled="!getIsSelectFile"
:loading="isUploadingRef"
>
{{ getUploadBtnText }}
</a-button>
</template>
<div class="upload-modal-toolbar">
<Alert :message="getHelpText" type="info" banner class="upload-modal-toolbar__text" />
<Upload
:accept="getStringAccept"
:multiple="multiple"
:before-upload="beforeUpload"
:show-upload-list="false"
class="upload-modal-toolbar__btn"
>
<a-button type="primary">
{{ t('component.upload.choose') }}
</a-button>
</Upload>
</div>
<FileList :dataSource="fileListRef" :columns="columns" :actionColumn="actionColumn" />
</BasicModal>
</template>
<script lang="ts">
import { defineComponent, reactive, ref, toRefs, unref, computed, PropType } from 'vue'
import { Upload, Alert } from 'ant-design-vue'
import { BasicModal, useModalInner } from '/@/components/Modal'
// import { BasicTable, useTable } from '/@/components/Table'
// hooks
import { useUploadType } from './useUpload'
import { useMessage } from '/@/hooks/web/useMessage'
// types
import { FileItem, UploadResultStatus } from './typing'
import { basicProps } from './props'
import { createTableColumns, createActionColumn } from './data'
// utils
import { checkImgType, getBase64WithFile } from './helper'
import { buildUUID } from '/@/utils/uuid'
import { isFunction } from '/@/utils/is'
import { warn } from '/@/utils/log'
import FileList from './FileList.vue'
import { useI18n } from '/@/hooks/web/useI18n'
export default defineComponent({
components: { BasicModal, Upload, Alert, FileList },
props: {
...basicProps,
previewFileList: {
type: Array as PropType<string[]>,
default: () => [],
},
},
emits: ['change', 'register', 'delete'],
setup(props, { emit }) {
const state = reactive<{ fileList: FileItem[] }>({
fileList: [],
})
// 是否正在上传
const isUploadingRef = ref(false)
const fileListRef = ref<FileItem[]>([])
const { accept, helpText, maxNumber, maxSize } = toRefs(props)
const { t } = useI18n()
const [register, { closeModal }] = useModalInner()
const { getStringAccept, getHelpText } = useUploadType({
acceptRef: accept,
helpTextRef: helpText,
maxNumberRef: maxNumber,
maxSizeRef: maxSize,
})
const { createMessage } = useMessage()
const getIsSelectFile = computed(() => {
return (
fileListRef.value.length > 0 &&
!fileListRef.value.every((item) => item.status === UploadResultStatus.SUCCESS)
)
})
const getOkButtonProps = computed(() => {
const someSuccess = fileListRef.value.some(
(item) => item.status === UploadResultStatus.SUCCESS,
)
return {
disabled: isUploadingRef.value || fileListRef.value.length === 0 || !someSuccess,
}
})
const getUploadBtnText = computed(() => {
const someError = fileListRef.value.some((item) => item.status === UploadResultStatus.ERROR)
return isUploadingRef.value
? t('component.upload.uploading')
: someError
? t('component.upload.reUploadFailed')
: t('component.upload.startUpload')
})
// 上传前校验
function beforeUpload(file: File) {
const { size, name } = file
const { maxSize } = props
// 设置最大值,则判断
if (maxSize && file.size / 1024 / 1024 >= maxSize) {
createMessage.error(t('component.upload.maxSizeMultiple', [maxSize]))
return false
}
const commonItem = {
uuid: buildUUID(),
file,
size,
name,
percent: 0,
type: name.split('.').pop(),
}
// 生成图片缩略图
if (checkImgType(file)) {
// beforeUpload如果异步会调用自带上传方法
// file.thumbUrl = await getBase64(file)
getBase64WithFile(file).then(({ result: thumbUrl }) => {
fileListRef.value = [
...unref(fileListRef),
{
thumbUrl,
...commonItem,
},
]
})
} else {
fileListRef.value = [...unref(fileListRef), commonItem]
}
return false
}
// 删除
function handleRemove(record: FileItem) {
const index = fileListRef.value.findIndex((item) => item.uuid === record.uuid)
index !== -1 && fileListRef.value.splice(index, 1)
emit('delete', record)
}
// 预览
// function handlePreview(record: FileItem) {
// const { thumbUrl = '' } = record
// createImgPreview({
// imageList: [thumbUrl],
// })
// }
async function uploadApiByItem(item: FileItem) {
const { api } = props
if (!api || !isFunction(api)) {
return warn('upload api must exist and be a function')
}
try {
item.status = UploadResultStatus.UPLOADING
const { data } = await props.api?.(
{
data: {
...(props.uploadParams || {}),
},
file: item.file,
name: props.name,
filename: props.filename,
},
function onUploadProgress(progressEvent: ProgressEvent) {
const complete = ((progressEvent.loaded / progressEvent.total) * 100) | 0
item.percent = complete
},
)
item.status = UploadResultStatus.SUCCESS
item.responseData = data
return {
success: true,
error: null,
}
} catch (e) {
console.log(e)
item.status = UploadResultStatus.ERROR
return {
success: false,
error: e,
}
}
}
// 点击开始上传
async function handleStartUpload() {
const { maxNumber } = props
if ((fileListRef.value.length + props.previewFileList?.length ?? 0) > maxNumber) {
return createMessage.warning(t('component.upload.maxNumber', [maxNumber]))
}
try {
isUploadingRef.value = true
// 只上传不是成功状态的
const uploadFileList =
fileListRef.value.filter((item) => item.status !== UploadResultStatus.SUCCESS) || []
const data = await Promise.all(
uploadFileList.map((item) => {
return uploadApiByItem(item)
}),
)
isUploadingRef.value = false
// 生产环境:抛出错误
const errorList = data.filter((item: any) => !item.success)
if (errorList.length > 0) throw errorList
} catch (e) {
isUploadingRef.value = false
throw e
}
}
// 点击保存
function handleOk() {
const { maxNumber } = props
if (fileListRef.value.length > maxNumber) {
return createMessage.warning(t('component.upload.maxNumber', [maxNumber]))
}
if (isUploadingRef.value) {
return createMessage.warning(t('component.upload.saveWarn'))
}
const fileList: string[] = []
for (const item of fileListRef.value) {
const { status, responseData } = item
if (status === UploadResultStatus.SUCCESS && responseData) {
fileList.push(responseData.url)
}
}
// 存在一个上传成功的即可保存
if (fileList.length <= 0) {
return createMessage.warning(t('component.upload.saveError'))
}
fileListRef.value = []
closeModal()
emit('change', fileList)
}
// 点击关闭:则所有操作不保存,包括上传的
async function handleCloseFunc() {
if (!isUploadingRef.value) {
fileListRef.value = []
return true
} else {
createMessage.warning(t('component.upload.uploadWait'))
return false
}
}
return {
columns: createTableColumns() as any[],
actionColumn: createActionColumn(handleRemove) as any,
register,
closeModal,
getHelpText,
getStringAccept,
getOkButtonProps,
beforeUpload,
// registerTable,
fileListRef,
state,
isUploadingRef,
handleStartUpload,
handleOk,
handleCloseFunc,
getIsSelectFile,
getUploadBtnText,
t,
}
},
})
</script>
<style lang="less">
.upload-modal {
.ant-upload-list {
display: none;
}
.ant-table-wrapper .ant-spin-nested-loading {
padding: 0;
}
&-toolbar {
display: flex;
align-items: center;
margin-bottom: 8px;
&__btn {
margin-left: 8px;
text-align: right;
flex: 1;
}
}
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<BasicModal
width="800px"
:title="t('component.upload.preview')"
class="upload-preview-modal"
v-bind="$attrs"
@register="register"
:showOkBtn="false"
>
<FileList :dataSource="fileListRef" :columns="columns" :actionColumn="actionColumn" />
</BasicModal>
</template>
<script lang="ts">
import { defineComponent, watch, ref } from 'vue'
// import { BasicTable, useTable } from '/@/components/Table'
import FileList from './FileList.vue'
import { BasicModal, useModalInner } from '/@/components/Modal'
import { previewProps } from './props'
import { PreviewFileItem } from './typing'
import { downloadByUrl } from '/@/utils/file/download'
import { createPreviewColumns, createPreviewActionColumn } from './data'
import { useI18n } from '/@/hooks/web/useI18n'
import { isArray } from '/@/utils/is'
export default defineComponent({
components: { BasicModal, FileList },
props: previewProps,
emits: ['list-change', 'register', 'delete'],
setup(props, { emit }) {
const [register, { closeModal }] = useModalInner()
const { t } = useI18n()
const fileListRef = ref<PreviewFileItem[]>([])
watch(
() => props.value,
(value) => {
if (!isArray(value)) value = []
fileListRef.value = value
.filter((item) => !!item)
.map((item) => {
return {
url: item,
type: item.split('.').pop() || '',
name: item.split('/').pop() || '',
}
})
},
{ immediate: true },
)
// 删除
function handleRemove(record: PreviewFileItem) {
const index = fileListRef.value.findIndex((item) => item.url === record.url)
if (index !== -1) {
const removed = fileListRef.value.splice(index, 1)
emit('delete', removed[0].url)
emit(
'list-change',
fileListRef.value.map((item) => item.url),
)
}
}
// // 预览
// function handlePreview(record: PreviewFileItem) {
// const { url = '' } = record
// createImgPreview({
// imageList: [url],
// })
// }
// 下载
function handleDownload(record: PreviewFileItem) {
const { url = '' } = record
downloadByUrl({ url })
}
return {
t,
register,
closeModal,
fileListRef,
columns: createPreviewColumns() as any[],
actionColumn: createPreviewActionColumn({ handleRemove, handleDownload }) as any,
}
},
})
</script>
<style lang="less">
.upload-preview-modal {
.ant-upload-list {
display: none;
}
.ant-table-wrapper .ant-spin-nested-loading {
padding: 0;
}
}
</style>

View File

@@ -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 && <ThumbUrl fileUrl={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 (
<span>
<p class="truncate mb-1" title={text}>
{text}
</p>
<Progress percent={percent} size="small" status={status} />
</span>
)
},
},
{
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 <Tag color="green">{() => t('component.upload.uploadSuccess')}</Tag>
} else if (text === UploadResultStatus.ERROR) {
return <Tag color="red">{() => t('component.upload.uploadError')}</Tag>
} else if (text === UploadResultStatus.UPLOADING) {
return <Tag color="blue">{() => t('component.upload.uploading')}</Tag>
}
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 <TableAction actions={actions} outside={true} />
},
}
}
// 文件预览列表
export function createPreviewColumns(): BasicColumn[] {
return [
{
dataIndex: 'url',
title: t('component.upload.legend'),
width: 100,
customRender: ({ record }) => {
const { url } = (record as PreviewFileItem) || {}
return isImgTypeByName(url) && <ThumbUrl fileUrl={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 <TableAction actions={actions} outside={true} />
},
}
}

View File

@@ -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)
})
}

View File

@@ -0,0 +1,83 @@
import type { PropType } from 'vue'
import { FileBasicColumn } from './typing'
export const basicProps = {
helpText: {
type: String as PropType<string>,
default: '',
},
// 文件最大多少MB
maxSize: {
type: Number as PropType<number>,
default: 2,
},
// 最大数量的文件Infinity不限制
maxNumber: {
type: Number as PropType<number>,
default: Infinity,
},
// 根据后缀,或者其他
accept: {
type: Array as PropType<string[]>,
default: () => [],
},
multiple: {
type: Boolean as PropType<boolean>,
default: true,
},
uploadParams: {
type: Object as PropType<any>,
default: () => ({}),
},
api: {
type: Function as PropType<PromiseFn>,
default: null,
required: true,
},
name: {
type: String as PropType<string>,
default: 'file',
},
filename: {
type: String as PropType<string>,
default: null,
},
}
export const uploadContainerProps = {
value: {
type: Array as PropType<string[]>,
default: () => [],
},
...basicProps,
showPreviewNumber: {
type: Boolean as PropType<boolean>,
default: true,
},
emptyHidePreview: {
type: Boolean as PropType<boolean>,
default: false,
},
}
export const previewProps = {
value: {
type: Array as PropType<string[]>,
default: () => [],
},
}
export const fileListProps = {
columns: {
type: Array as PropType<FileBasicColumn[]>,
default: null,
},
actionColumn: {
type: Object as PropType<FileBasicColumn>,
default: null,
},
dataSource: {
type: Array as PropType<any[]>,
default: null,
},
}

View File

@@ -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'
}

View File

@@ -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<string[]>
helpTextRef: Ref<string>
maxNumberRef: Ref<number>
maxSizeRef: Ref<number>
}) {
// 文件类型限制
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 }
}