refactor: 悦码项目重构
This commit is contained in:
6
apps/platform/src/components/basic/README.md
Normal file
6
apps/platform/src/components/basic/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
### 基础组件(目录说明)
|
||||
|
||||
| 组件名称 | 描述 | 是否全局组件 | 使用建议 |
|
||||
| --- | --- | --- | --- |
|
||||
| button | `按钮组件`基于 a-button 二次封装,主要扩展了按钮的颜色,基本使用方式与 antdv 的 a-button 保持一致 | 是 | -- |
|
||||
| check-box | `复选框`基于 a-checkbox 二次封装,基本使用方式与 antdv 的 a-checkbox 保持一致 | 否 | -- |
|
||||
1
apps/platform/src/components/basic/basic-arrow/index.ts
Normal file
1
apps/platform/src/components/basic/basic-arrow/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as BasicArrow } from './index.vue';
|
||||
24
apps/platform/src/components/basic/basic-arrow/index.vue
Normal file
24
apps/platform/src/components/basic/basic-arrow/index.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<DownOutlined class="collapse-icon" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { DownOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
expand: { type: Boolean },
|
||||
});
|
||||
|
||||
/**
|
||||
* @description 展开/收起 图标旋转转数
|
||||
*/
|
||||
const turn = computed(() => `${props.expand ? 0 : 0.5}turn`);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.collapse-icon {
|
||||
transform: rotate(v-bind(turn));
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
</style>
|
||||
94
apps/platform/src/components/basic/basic-help/index.vue
Normal file
94
apps/platform/src/components/basic/basic-help/index.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="tsx">
|
||||
import { defineComponent, computed, unref } from 'vue';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons-vue';
|
||||
import { Tooltip } from 'ant-design-vue';
|
||||
import type { CSSProperties, PropType } from 'vue';
|
||||
import { isString, isArray } from '@/utils/is';
|
||||
import { getSlot } from '@/utils/helper/tsxHelper';
|
||||
|
||||
const props = {
|
||||
/**
|
||||
* Help text max-width
|
||||
* @default: 600px
|
||||
*/
|
||||
maxWidth: { type: String, default: '600px' },
|
||||
/**
|
||||
* Whether to display the serial number
|
||||
* @default: false
|
||||
*/
|
||||
showIndex: { type: Boolean },
|
||||
/**
|
||||
* Help text font color
|
||||
* @default: #ffffff
|
||||
*/
|
||||
color: { type: String, default: '#ffffff' },
|
||||
/**
|
||||
* Help text font size
|
||||
* @default: 14px
|
||||
*/
|
||||
fontSize: { type: String, default: '14px' },
|
||||
/**
|
||||
* Help text list
|
||||
*/
|
||||
placement: { type: String, default: 'right' },
|
||||
/**
|
||||
* Help text list
|
||||
*/
|
||||
text: { type: [Array, String] as PropType<string[] | string> },
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BasicHelp',
|
||||
components: { Tooltip },
|
||||
props,
|
||||
setup(props, { slots }) {
|
||||
const getTooltipStyle = computed(
|
||||
(): CSSProperties => ({ color: props.color, fontSize: props.fontSize }),
|
||||
);
|
||||
|
||||
const getOverlayStyle = computed((): CSSProperties => ({ maxWidth: props.maxWidth }));
|
||||
|
||||
function renderTitle() {
|
||||
const textList = props.text;
|
||||
|
||||
if (isString(textList)) {
|
||||
return <p>{textList}</p>;
|
||||
}
|
||||
|
||||
if (isArray(textList)) {
|
||||
return textList.map((text, index) => {
|
||||
return (
|
||||
<p key={text}>
|
||||
<>
|
||||
{props.showIndex ? `${index + 1}. ` : ''}
|
||||
{text}
|
||||
</>
|
||||
</p>
|
||||
);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
return (
|
||||
<Tooltip
|
||||
overlayClassName="basic-help__wrap"
|
||||
title={<div style={unref(getTooltipStyle)}>{renderTitle()}</div>}
|
||||
autoAdjustOverflow={true}
|
||||
overlayStyle={unref(getOverlayStyle)}
|
||||
placement={props.placement as 'right'}
|
||||
>
|
||||
<span class="basic-help">{getSlot(slots) || <InfoCircleOutlined />}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.basic-help__wrap p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
22
apps/platform/src/components/basic/button/button.ts
Normal file
22
apps/platform/src/components/basic/button/button.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import buttonProps from 'ant-design-vue/es/button/buttonTypes';
|
||||
import { theme } from 'ant-design-vue';
|
||||
import type { ButtonType as AButtonType } from 'ant-design-vue/es/button/buttonTypes';
|
||||
import type { ExtractPropTypes } from 'vue';
|
||||
|
||||
const { defaultSeed } = theme;
|
||||
|
||||
export declare type ButtonProps = Partial<ExtractPropTypes<typeof buttonProps>>;
|
||||
|
||||
export type ButtonType = AButtonType | 'warning' | 'success' | 'error';
|
||||
|
||||
/** 这里自定义颜色 */
|
||||
export const buttonColorPrimary = {
|
||||
success: defaultSeed.colorSuccess,
|
||||
warning: defaultSeed.colorWarning,
|
||||
error: defaultSeed.colorError,
|
||||
} as const;
|
||||
|
||||
export const aButtonTypes = ['default', 'primary', 'ghost', 'dashed', 'link', 'text'];
|
||||
|
||||
export type { AButtonType };
|
||||
export { buttonProps };
|
||||
55
apps/platform/src/components/basic/button/button.vue
Normal file
55
apps/platform/src/components/basic/button/button.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<ProConfigProvider :theme="btnTheme">
|
||||
<component :is="h(Button, { ...$attrs, ...props, type: buttonType }, $slots)" />
|
||||
</ProConfigProvider>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, h } from 'vue';
|
||||
|
||||
import { useConfigContextInject } from 'ant-design-vue/es/config-provider/context';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
import { buttonProps, buttonColorPrimary, aButtonTypes } from './button';
|
||||
|
||||
import type { ButtonType, AButtonType } from './button';
|
||||
|
||||
const { theme: globalTheme } = useConfigContextInject();
|
||||
|
||||
defineOptions({
|
||||
name: 'AButton',
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
...buttonProps(),
|
||||
type: {
|
||||
type: String as PropType<ButtonType>,
|
||||
},
|
||||
// 自定义按钮颜色
|
||||
color: String,
|
||||
});
|
||||
|
||||
const isCustomType = computed(() => Reflect.has(buttonColorPrimary, props.type!));
|
||||
|
||||
const buttonType = computed<AButtonType>(() => {
|
||||
if (props.type && aButtonTypes.includes(props.type)) {
|
||||
return props.type as AButtonType;
|
||||
} else if (props.color || isCustomType.value) {
|
||||
return 'primary';
|
||||
}
|
||||
return 'default';
|
||||
});
|
||||
|
||||
const btnTheme = computed(() => {
|
||||
const type = props.type!;
|
||||
if (props.color || isCustomType.value) {
|
||||
return {
|
||||
...globalTheme.value,
|
||||
token: {
|
||||
colorPrimary: props.color || buttonColorPrimary[type],
|
||||
},
|
||||
};
|
||||
}
|
||||
return globalTheme.value;
|
||||
});
|
||||
</script>
|
||||
9
apps/platform/src/components/basic/button/index.ts
Normal file
9
apps/platform/src/components/basic/button/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import AButton from './button.vue';
|
||||
|
||||
export default AButton;
|
||||
|
||||
export const Button = AButton;
|
||||
|
||||
export * from './button';
|
||||
|
||||
export { AButton };
|
||||
54
apps/platform/src/components/basic/check-box/index.vue
Normal file
54
apps/platform/src/components/basic/check-box/index.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<Checkbox v-bind="getProps" v-model:checked="checkedModel" @change="handleChange">
|
||||
<slot />
|
||||
</Checkbox>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { checkboxProps } from 'ant-design-vue/es/checkbox';
|
||||
import { omit } from 'lodash-es';
|
||||
import { Checkbox } from 'ant-design-vue';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
...checkboxProps(),
|
||||
trueValue: {
|
||||
type: [Number, Boolean, String],
|
||||
default: true,
|
||||
},
|
||||
falseValue: {
|
||||
type: [Number, Boolean, String],
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:checked', 'change']);
|
||||
|
||||
const getProps = computed(() => {
|
||||
return omit(props, ['onUpdate:checked', 'onChange']);
|
||||
});
|
||||
|
||||
const checkedModel = computed<boolean>({
|
||||
get() {
|
||||
return props.checked === props.trueValue;
|
||||
},
|
||||
set(val) {
|
||||
emit('update:checked', val ? props.trueValue : props.falseValue);
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const evt = {
|
||||
...e,
|
||||
target: {
|
||||
...e.target,
|
||||
checked: e.target.checked ? props.trueValue : props.falseValue,
|
||||
},
|
||||
};
|
||||
emit('change', evt);
|
||||
};
|
||||
</script>
|
||||
3
apps/platform/src/components/basic/context-menu/index.ts
Normal file
3
apps/platform/src/components/basic/context-menu/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { createContextMenu, destroyContextMenu } from './src/createContextMenu';
|
||||
|
||||
export * from './src/typing';
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="tsx">
|
||||
import { defineComponent, computed, ref, unref } from 'vue';
|
||||
import { Menu, Dropdown } from 'ant-design-vue';
|
||||
import type { ContextMenuItem, ItemContentProps, Axis } from './typing';
|
||||
import type { FunctionalComponent, CSSProperties, PropType } from 'vue';
|
||||
import { IconFont } from '@/components/basic/icon';
|
||||
|
||||
const props = {
|
||||
width: { type: Number, default: 156 },
|
||||
customEvent: { type: Object as PropType<Event>, default: null },
|
||||
styles: { type: Object as PropType<CSSProperties> },
|
||||
showIcon: { type: Boolean, default: true },
|
||||
axis: {
|
||||
// The position of the right mouse button click
|
||||
type: Object as PropType<Axis>,
|
||||
default() {
|
||||
return { x: 0, y: 0 };
|
||||
},
|
||||
},
|
||||
items: {
|
||||
// The most important list, if not, will not be displayed
|
||||
type: Array as PropType<ContextMenuItem[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ItemContent: FunctionalComponent<ItemContentProps> = (props) => {
|
||||
const { item } = props;
|
||||
return (
|
||||
<span
|
||||
style="display: inline-block; width: 100%; "
|
||||
class="px-4"
|
||||
onClick={props.handler.bind(null, item)}
|
||||
>
|
||||
{props.showIcon && item.icon && <IconFont class="mr-2" type={item.icon} />}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ContextMenu',
|
||||
props,
|
||||
setup(props, { expose }) {
|
||||
const open = ref(true);
|
||||
|
||||
const getStyle = computed((): CSSProperties => {
|
||||
const { axis, items, styles, width } = props;
|
||||
const { x, y } = axis || { x: 0, y: 0 };
|
||||
const menuHeight = (items || []).length * 40;
|
||||
const menuWidth = width;
|
||||
const body = document.body;
|
||||
|
||||
const left = body.clientWidth < x + menuWidth ? x - menuWidth : x;
|
||||
const top = body.clientHeight < y + menuHeight ? y - menuHeight : y;
|
||||
return {
|
||||
position: 'absolute',
|
||||
width: `${width}px`,
|
||||
left: `${left + 1}px`,
|
||||
top: `${top + 1}px`,
|
||||
...styles,
|
||||
};
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
function handleAction(item: ContextMenuItem, e: MouseEvent) {
|
||||
const { handler, disabled } = item;
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
handler?.();
|
||||
close();
|
||||
}
|
||||
|
||||
function renderMenuItem(items: ContextMenuItem[]) {
|
||||
const visibleItems = items.filter((item) => !item.hidden);
|
||||
return visibleItems.map((item) => {
|
||||
const { disabled, label, children, divider = false } = item;
|
||||
|
||||
const contentProps = {
|
||||
item,
|
||||
handler: handleAction,
|
||||
showIcon: props.showIcon,
|
||||
};
|
||||
|
||||
if (!children || children.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<Menu.Item disabled={disabled} key={label}>
|
||||
<ItemContent {...contentProps} />
|
||||
</Menu.Item>
|
||||
{divider ? <Menu.Divider key={`d-${label}`} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu.SubMenu key={label} disabled={disabled}>
|
||||
{{
|
||||
title: () => <ItemContent {...contentProps} />,
|
||||
default: () => renderMenuItem(children),
|
||||
}}
|
||||
</Menu.SubMenu>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
expose({
|
||||
close,
|
||||
});
|
||||
|
||||
return () => {
|
||||
const { items } = props;
|
||||
return (
|
||||
<Dropdown open={open.value}>
|
||||
{{
|
||||
default: () => <div style={unref(getStyle)}></div>,
|
||||
overlay: () => (
|
||||
<Menu inlineIndent={12} mode="vertical">
|
||||
{renderMenuItem(items)}
|
||||
</Menu>
|
||||
),
|
||||
}}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createVNode, render } from 'vue';
|
||||
import contextMenuVue from './ContextMenu.vue';
|
||||
import type { CreateContextOptions, ContextMenuProps } from './typing';
|
||||
import { isClient } from '@/utils/is';
|
||||
|
||||
const menuManager: {
|
||||
domList: Element[];
|
||||
resolve: Fn;
|
||||
} = {
|
||||
domList: [],
|
||||
resolve: () => {},
|
||||
};
|
||||
|
||||
export const createContextMenu = function (options: CreateContextOptions) {
|
||||
const { event } = options || {};
|
||||
|
||||
event && event?.preventDefault();
|
||||
|
||||
if (!isClient) {
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const body = document.body;
|
||||
|
||||
const container = document.createElement('div');
|
||||
const propsData: Partial<ContextMenuProps> = {
|
||||
getPopupContainer: () => container,
|
||||
};
|
||||
if (options.styles) {
|
||||
propsData.styles = options.styles;
|
||||
}
|
||||
|
||||
if (options.items) {
|
||||
propsData.items = options.items;
|
||||
}
|
||||
|
||||
if (options.event) {
|
||||
propsData.customEvent = event;
|
||||
propsData.axis = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
|
||||
const vm = createVNode(contextMenuVue, propsData);
|
||||
render(vm, container);
|
||||
|
||||
const handleClick = function () {
|
||||
menuManager.resolve('');
|
||||
};
|
||||
|
||||
menuManager.domList.push(container);
|
||||
|
||||
const remove = function () {
|
||||
menuManager.domList.forEach((dom: Element) => {
|
||||
try {
|
||||
dom && body.removeChild(dom);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
body.removeEventListener('click', handleClick);
|
||||
body.removeEventListener('scroll', handleClick);
|
||||
};
|
||||
|
||||
menuManager.resolve = function (arg) {
|
||||
vm.component?.exposed?.close();
|
||||
remove();
|
||||
resolve(arg);
|
||||
};
|
||||
remove();
|
||||
body.appendChild(container);
|
||||
body.addEventListener('click', handleClick);
|
||||
body.addEventListener('scroll', handleClick);
|
||||
});
|
||||
};
|
||||
|
||||
export const destroyContextMenu = function () {
|
||||
if (menuManager) {
|
||||
menuManager.resolve('');
|
||||
menuManager.domList = [];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DropdownProps } from 'ant-design-vue/es/dropdown';
|
||||
|
||||
export interface Axis {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
icon?: string;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
handler?: Fn;
|
||||
divider?: boolean;
|
||||
children?: ContextMenuItem[];
|
||||
}
|
||||
export interface CreateContextOptions {
|
||||
event: MouseEvent;
|
||||
icon?: string;
|
||||
styles?: any;
|
||||
items?: ContextMenuItem[];
|
||||
}
|
||||
|
||||
export interface ContextMenuProps extends DropdownProps {
|
||||
event?: MouseEvent;
|
||||
styles?: any;
|
||||
items: ContextMenuItem[];
|
||||
customEvent?: MouseEvent;
|
||||
axis?: Axis;
|
||||
width?: number;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export interface ItemContentProps {
|
||||
showIcon: boolean | undefined;
|
||||
item: ContextMenuItem;
|
||||
handler: Fn;
|
||||
}
|
||||
8
apps/platform/src/components/basic/excel/index.ts
Normal file
8
apps/platform/src/components/basic/excel/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import impExcel from './src/ImportExcel.vue';
|
||||
import { withInstall } from '@/utils';
|
||||
export { useExportExcelModal } from './src/ExportExcelModal';
|
||||
|
||||
export const ImpExcel = withInstall(impExcel);
|
||||
// export const ExpExcelModal = withInstall(expExcelModal);
|
||||
export * from './src/typing';
|
||||
export * from './src/Export2Excel';
|
||||
59
apps/platform/src/components/basic/excel/src/Export2Excel.ts
Normal file
59
apps/platform/src/components/basic/excel/src/Export2Excel.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { utils, writeFile } from 'xlsx';
|
||||
import type { WorkBook } from 'xlsx';
|
||||
import type { JsonToSheet, AoAToSheet } from './typing';
|
||||
|
||||
const DEF_FILE_NAME = 'excel-list.xlsx';
|
||||
|
||||
export function jsonToSheetXlsx<T = any>({
|
||||
data,
|
||||
header,
|
||||
filename = DEF_FILE_NAME,
|
||||
json2sheetOpts = {},
|
||||
write2excelOpts = { bookType: 'xlsx' },
|
||||
}: JsonToSheet<T>) {
|
||||
let arrData = [...data];
|
||||
if (header) {
|
||||
arrData.unshift(header);
|
||||
const filterKeys = Object.keys(header);
|
||||
arrData = arrData.map((item) => filterKeys.reduce<any>((p, k) => ((p[k] = item[k]), p), {}));
|
||||
json2sheetOpts.skipHeader = true;
|
||||
}
|
||||
|
||||
const worksheet = utils.json_to_sheet(arrData, json2sheetOpts);
|
||||
|
||||
/* add worksheet to workbook */
|
||||
const workbook: WorkBook = {
|
||||
SheetNames: [filename],
|
||||
Sheets: {
|
||||
[filename]: worksheet,
|
||||
},
|
||||
};
|
||||
/* output format determined by filename */
|
||||
writeFile(workbook, filename, write2excelOpts);
|
||||
/* at this point, out.xlsb will have been downloaded */
|
||||
}
|
||||
|
||||
export function aoaToSheetXlsx<T = any>({
|
||||
data,
|
||||
header,
|
||||
filename = DEF_FILE_NAME,
|
||||
write2excelOpts = { bookType: 'xlsx' },
|
||||
}: AoAToSheet<T>) {
|
||||
const arrData = [...data];
|
||||
if (header) {
|
||||
arrData.unshift(header);
|
||||
}
|
||||
|
||||
const worksheet = utils.aoa_to_sheet(arrData);
|
||||
|
||||
/* add worksheet to workbook */
|
||||
const workbook: WorkBook = {
|
||||
SheetNames: [filename],
|
||||
Sheets: {
|
||||
[filename]: worksheet,
|
||||
},
|
||||
};
|
||||
/* output format determined by filename */
|
||||
writeFile(workbook, filename, write2excelOpts);
|
||||
/* at this point, out.xlsb will have been downloaded */
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ExportModalResult } from './typing';
|
||||
import type { FormSchema } from '@/components/core/schema-form/';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
import { useFormModal } from '@/hooks/useModal/';
|
||||
|
||||
export type OpenModalOptions = {
|
||||
onOk: (val: ExportModalResult) => any;
|
||||
};
|
||||
|
||||
const getSchemas = (t): FormSchema<ExportModalResult>[] => [
|
||||
{
|
||||
field: 'filename',
|
||||
component: 'Input',
|
||||
label: t('component.excel.fileName'),
|
||||
rules: [{ required: true }],
|
||||
},
|
||||
{
|
||||
field: 'bookType',
|
||||
component: 'Select',
|
||||
label: t('component.excel.fileType'),
|
||||
defaultValue: 'xlsx',
|
||||
rules: [{ required: true }],
|
||||
componentProps: {
|
||||
options: [
|
||||
{
|
||||
label: 'xlsx',
|
||||
value: 'xlsx',
|
||||
key: 'xlsx',
|
||||
},
|
||||
{
|
||||
label: 'html',
|
||||
value: 'html',
|
||||
key: 'html',
|
||||
},
|
||||
{
|
||||
label: 'csv',
|
||||
value: 'csv',
|
||||
key: 'csv',
|
||||
},
|
||||
{
|
||||
label: 'txt',
|
||||
value: 'txt',
|
||||
key: 'txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const useExportExcelModal = () => {
|
||||
const { t } = useI18n();
|
||||
const [showModal] = useFormModal();
|
||||
|
||||
const openModal = ({ onOk }: OpenModalOptions) => {
|
||||
showModal<ExportModalResult>({
|
||||
modalProps: {
|
||||
title: t('component.excel.exportModalTitle'),
|
||||
onFinish: async (values) => {
|
||||
const { filename, bookType } = values;
|
||||
|
||||
onOk({
|
||||
filename: `${filename.split('.').shift()}.${bookType}`,
|
||||
bookType,
|
||||
});
|
||||
},
|
||||
},
|
||||
formProps: {
|
||||
labelWidth: 100,
|
||||
schemas: getSchemas(t),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
openModal,
|
||||
};
|
||||
};
|
||||
159
apps/platform/src/components/basic/excel/src/ImportExcel.vue
Normal file
159
apps/platform/src/components/basic/excel/src/ImportExcel.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div>
|
||||
<input
|
||||
v-show="false"
|
||||
ref="inputRef"
|
||||
type="file"
|
||||
accept=".xlsx, .xls"
|
||||
@change="handleInputClick"
|
||||
/>
|
||||
<div @click="handleUpload">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, unref } from 'vue';
|
||||
import { read, utils } from 'xlsx';
|
||||
import type { WorkSheet, WorkBook } from 'xlsx';
|
||||
import type { ExcelData } from './typing';
|
||||
import { dateUtil } from '@/utils/dateUtil';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ImportExcel',
|
||||
props: {
|
||||
// 日期时间格式。如果不提供或者提供空值,将返回原始Date对象
|
||||
dateFormat: {
|
||||
type: String,
|
||||
},
|
||||
// 时区调整。实验性功能,仅为了解决读取日期时间值有偏差的问题。目前仅提供了+08:00时区的偏差修正值
|
||||
// https://github.com/SheetJS/sheetjs/issues/1470#issuecomment-501108554
|
||||
timeZone: {
|
||||
type: Number,
|
||||
default: 8,
|
||||
},
|
||||
},
|
||||
emits: ['success', 'error'],
|
||||
setup(props, { emit }) {
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const loadingRef = ref<Boolean>(false);
|
||||
|
||||
/**
|
||||
* @description: 第一行作为头部
|
||||
*/
|
||||
function getHeaderRow(sheet: WorkSheet) {
|
||||
if (!sheet || !sheet['!ref']) return [];
|
||||
const headers: string[] = [];
|
||||
// A3:B7=>{s:{c:0, r:2}, e:{c:1, r:6}}
|
||||
const range = utils.decode_range(sheet['!ref']);
|
||||
|
||||
const R = range.s.r;
|
||||
/* start in the first row */
|
||||
for (let C = range.s.c; C <= range.e.c; ++C) {
|
||||
/* walk every column in the range */
|
||||
const cell = sheet[utils.encode_cell({ c: C, r: R })];
|
||||
/* find the cell in the first row */
|
||||
let hdr = `UNKNOWN ${C}`; // <-- replace with your desired default
|
||||
if (cell && cell.t) hdr = utils.format_cell(cell);
|
||||
headers.push(hdr);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 获得excel数据
|
||||
*/
|
||||
function getExcelData(workbook: WorkBook) {
|
||||
const excelData: ExcelData[] = [];
|
||||
const { dateFormat, timeZone } = props;
|
||||
for (const sheetName of workbook.SheetNames) {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const header: string[] = getHeaderRow(worksheet);
|
||||
let results = utils.sheet_to_json(worksheet, {
|
||||
raw: true,
|
||||
dateNF: dateFormat, //Not worked
|
||||
}) as object[];
|
||||
results = results.map((row: object) => {
|
||||
for (const field in row) {
|
||||
if (row[field] instanceof Date) {
|
||||
if (timeZone === 8) {
|
||||
row[field].setSeconds(row[field].getSeconds() + 43);
|
||||
}
|
||||
if (dateFormat) {
|
||||
row[field] = dateUtil(row[field]).format(dateFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
excelData.push({
|
||||
header,
|
||||
results,
|
||||
meta: {
|
||||
sheetName,
|
||||
},
|
||||
});
|
||||
}
|
||||
return excelData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 读取excel数据
|
||||
*/
|
||||
function readerData(rawFile: File) {
|
||||
loadingRef.value = true;
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const data = e.target && e.target.result;
|
||||
const workbook = read(data, { type: 'array', cellDates: true });
|
||||
// console.log(workbook);
|
||||
/* DO SOMETHING WITH workbook HERE */
|
||||
const excelData = getExcelData(workbook);
|
||||
emit('success', excelData);
|
||||
resolve('');
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
emit('error');
|
||||
} finally {
|
||||
loadingRef.value = false;
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(rawFile);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function upload(rawFile: File) {
|
||||
const inputRefDom = unref(inputRef);
|
||||
if (inputRefDom) {
|
||||
// fix can't select the same excel
|
||||
inputRefDom.value = '';
|
||||
}
|
||||
await readerData(rawFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 触发选择文件管理器
|
||||
*/
|
||||
function handleInputClick(e: Event) {
|
||||
const files = e && (e.target as HTMLInputElement).files;
|
||||
const rawFile = files && files[0]; // only setting files[0]
|
||||
if (!rawFile) return;
|
||||
upload(rawFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 点击上传按钮
|
||||
*/
|
||||
function handleUpload() {
|
||||
const inputRefDom = unref(inputRef);
|
||||
inputRefDom && inputRefDom.click();
|
||||
}
|
||||
|
||||
return { handleUpload, handleInputClick, inputRef };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
27
apps/platform/src/components/basic/excel/src/typing.ts
Normal file
27
apps/platform/src/components/basic/excel/src/typing.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { JSON2SheetOpts, WritingOptions, BookType } from 'xlsx';
|
||||
|
||||
export interface ExcelData<T = any> {
|
||||
header: string[];
|
||||
results: T[];
|
||||
meta: { sheetName: string };
|
||||
}
|
||||
|
||||
export interface JsonToSheet<T = any> {
|
||||
data: T[];
|
||||
header?: T;
|
||||
filename?: string;
|
||||
json2sheetOpts?: JSON2SheetOpts;
|
||||
write2excelOpts?: WritingOptions;
|
||||
}
|
||||
|
||||
export interface AoAToSheet<T = any> {
|
||||
data: T[][];
|
||||
header?: T[];
|
||||
filename?: string;
|
||||
write2excelOpts?: WritingOptions;
|
||||
}
|
||||
|
||||
export interface ExportModalResult {
|
||||
filename: string;
|
||||
bookType: BookType;
|
||||
}
|
||||
57
apps/platform/src/components/basic/icon/Icon.vue
Normal file
57
apps/platform/src/components/basic/icon/Icon.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type CSSProperties, type VNode } from 'vue';
|
||||
import { useAttrs } from 'vue';
|
||||
import { Icon as IconifyIcon } from '@iconify/vue';
|
||||
import { isString, omit } from 'lodash-es';
|
||||
import SvgIcon from './src/SvgIcon.vue';
|
||||
import IconFont from './src/icon-font';
|
||||
import type { IconProps } from './src/props';
|
||||
|
||||
const props = withDefaults(defineProps<IconProps>(), {
|
||||
type: 'iconify',
|
||||
size: 16,
|
||||
});
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const getWrapStyle = computed((): CSSProperties => {
|
||||
const { size, color } = props;
|
||||
let fs = size;
|
||||
if (isString(size)) {
|
||||
fs = parseInt(size, 10);
|
||||
}
|
||||
|
||||
return {
|
||||
fontSize: `${fs}px`,
|
||||
color,
|
||||
display: 'inline-flex',
|
||||
};
|
||||
});
|
||||
|
||||
/** svg 不支持 title 属性,需要在其元素内部手动添加 title 标签 */
|
||||
const handleIconUpdated = (vnode: VNode) => {
|
||||
const title = attrs.title;
|
||||
if (vnode.el && title) {
|
||||
vnode.el.insertAdjacentHTML?.('afterbegin', `<title>${title}</title>`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="type === 'svg'">
|
||||
<SvgIcon v-bind="{ ...$attrs, ...props }" :name="icon" class="anticon" />
|
||||
</template>
|
||||
<template v-else-if="type === 'icon-font'">
|
||||
<IconFont v-bind="{ ...$attrs, ...props }" :type="icon" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<IconifyIcon
|
||||
v-bind="omit({ ...$attrs, ...props }, ['size', 'color'])"
|
||||
:style="getWrapStyle"
|
||||
class="anticon"
|
||||
@vue:updated="handleIconUpdated"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
8
apps/platform/src/components/basic/icon/index.ts
Normal file
8
apps/platform/src/components/basic/icon/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as IconPicker } from './src/IconPicker.vue';
|
||||
export { default as SvgIcon } from './src/SvgIcon.vue';
|
||||
export { default as Icon } from './Icon.vue';
|
||||
export { default as IconFont } from './src/icon-font';
|
||||
|
||||
export * from './src/props';
|
||||
|
||||
export { setupIcons } from './src/icons.data';
|
||||
120
apps/platform/src/components/basic/icon/src/IconPicker.vue
Normal file
120
apps/platform/src/components/basic/icon/src/IconPicker.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<a-form-item-rest>
|
||||
<a-popover
|
||||
v-model:open="visible"
|
||||
placement="bottomLeft"
|
||||
:overlay-inner-style="{ paddingTop: 0 }"
|
||||
trigger="click"
|
||||
>
|
||||
<template #title>
|
||||
<a-tabs
|
||||
v-model:activeKey="activeCateName"
|
||||
size="small"
|
||||
:tab-bar-style="{ marginBottom: '8px' }"
|
||||
>
|
||||
<a-tab-pane v-for="(_, cateName) in iconsMap" :key="cateName" :tab="cateName" />
|
||||
</a-tabs>
|
||||
<a-input
|
||||
autofocus
|
||||
allow-clear
|
||||
:placeholder="`从“${activeCateName}”中搜索图标`"
|
||||
@change="handleSearchChange"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<RecycleScroller
|
||||
class="select-box"
|
||||
:items="iconFilteredList"
|
||||
key-field="name"
|
||||
:item-size="38"
|
||||
:grid-items="9"
|
||||
:item-secondary-size="38"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div
|
||||
:key="item.name"
|
||||
:title="item.name"
|
||||
:class="{ active: modelValue == item.name }"
|
||||
class="select-box-item"
|
||||
@click="selectIcon(item.name)"
|
||||
>
|
||||
<Icon :icon="item.name" class="text-[20px]" />
|
||||
</div>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
</template>
|
||||
|
||||
<a-input v-bind="$attrs" v-model:value="modelValue" :placeholder="placeholder" allow-clear>
|
||||
<template v-if="modelValue" #prefix>
|
||||
<Icon :icon="modelValue" class="text-[20px]" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-popover>
|
||||
</a-form-item-rest>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { RecycleScroller } from 'vue-virtual-scroller';
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { setupIcons, icons } from './icons.data';
|
||||
import { iconPickerProps } from './props';
|
||||
|
||||
// 添加默认图标集合
|
||||
setupIcons();
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
defineProps(iconPickerProps);
|
||||
|
||||
const modelValue = defineModel<string>('value');
|
||||
|
||||
const iconsMap = Object.entries(icons).reduce(
|
||||
(prev, [cateName, curr]) => {
|
||||
prev[cateName] = Object.keys(curr.icons).map((name) => ({ name: `${curr.prefix}:${name}` }));
|
||||
return prev;
|
||||
},
|
||||
{
|
||||
全部: Object.values(icons).flatMap((item) =>
|
||||
Object.keys(item.icons).map((name) => ({ name: `${item.prefix}:${name}` })),
|
||||
),
|
||||
} as Recordable<{ name: string }[]>,
|
||||
);
|
||||
|
||||
const visible = ref(false);
|
||||
const activeCateName = ref('全部');
|
||||
const keyword = ref('');
|
||||
|
||||
const iconFilteredList = computed(() => {
|
||||
const list = iconsMap[activeCateName.value];
|
||||
return list.filter((item) => item.name.includes(keyword.value));
|
||||
});
|
||||
|
||||
const handleSearchChange = useDebounceFn((e: Event) => {
|
||||
keyword.value = (e.target as HTMLInputElement).value;
|
||||
}, 100);
|
||||
|
||||
const selectIcon = (name: string) => {
|
||||
modelValue.value = name;
|
||||
visible.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.select-box {
|
||||
@apply h-300px min-w-350px;
|
||||
|
||||
&-item {
|
||||
@apply flex m-2px p-6px;
|
||||
|
||||
border: 1px solid #e5e7eb;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
@apply border-blue-600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
33
apps/platform/src/components/basic/icon/src/SvgIcon.vue
Normal file
33
apps/platform/src/components/basic/icon/src/SvgIcon.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<svg v-bind="$attrs" class="svg-icon" :style="getStyle" aria-hidden="true">
|
||||
<use :xlink:href="symbolId" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, type CSSProperties } from 'vue';
|
||||
import { svgIconProps } from './props';
|
||||
|
||||
defineOptions({
|
||||
name: 'SvgIcon',
|
||||
});
|
||||
|
||||
const props = defineProps(svgIconProps);
|
||||
const symbolId = computed(() => `#${props.prefix}-${props.name}`);
|
||||
const getStyle = computed((): CSSProperties => {
|
||||
const { size } = props;
|
||||
const s = `${size}`.replace('px', '').concat('px');
|
||||
return {
|
||||
width: s,
|
||||
height: s,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.svg-icon {
|
||||
overflow: hidden;
|
||||
fill: currentcolor;
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
</style>
|
||||
74
apps/platform/src/components/basic/icon/src/icon-font.tsx
Normal file
74
apps/platform/src/components/basic/icon/src/icon-font.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { defineComponent, unref, computed } from 'vue';
|
||||
import { createFromIconfontCN } from '@ant-design/icons-vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { isString } from '@/utils/is';
|
||||
import { uniqueSlash } from '@/utils/urlUtils';
|
||||
|
||||
let scriptUrls = [uniqueSlash(`${import.meta.env.BASE_URL}/iconfont.js`)];
|
||||
|
||||
// 文档:https://antdv.com/components/icon-cn#components-icon-demo-iconfont
|
||||
let MyIconFont = createFromIconfontCN({
|
||||
// scriptUrl: '//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js',
|
||||
// scriptUrl: '//at.alicdn.com/t/font_2184398_zflo1kjcemp.js',
|
||||
// iconfont字体图标本地化,详见:/public/iconfont.js
|
||||
scriptUrl: scriptUrls,
|
||||
});
|
||||
|
||||
export default defineComponent({
|
||||
name: 'IconFont',
|
||||
props: {
|
||||
type: {
|
||||
type: String as PropType<string>,
|
||||
default: '',
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: 'icon-',
|
||||
},
|
||||
color: {
|
||||
type: String as PropType<string>,
|
||||
default: 'unset',
|
||||
},
|
||||
size: {
|
||||
type: [Number, String] as PropType<number | string>,
|
||||
default: 14,
|
||||
},
|
||||
scriptUrl: {
|
||||
// 阿里图库字体图标路径
|
||||
type: String as PropType<string | string[]>,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup(props, { attrs }) {
|
||||
// 如果外部传进来字体图标路径,则覆盖默认的
|
||||
if (props.scriptUrl) {
|
||||
scriptUrls = [...new Set(scriptUrls.concat(props.scriptUrl))];
|
||||
MyIconFont = createFromIconfontCN({
|
||||
scriptUrl: scriptUrls,
|
||||
});
|
||||
}
|
||||
|
||||
const wrapStyleRef = computed(() => {
|
||||
const { color, size } = props;
|
||||
|
||||
const fs = isString(size) ? parseFloat(size) : size;
|
||||
|
||||
return {
|
||||
color,
|
||||
fontSize: `${fs}px`,
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
const { type, prefix } = props;
|
||||
|
||||
return type ? (
|
||||
<MyIconFont
|
||||
type={type.startsWith(prefix) ? type : `${prefix}${type}`}
|
||||
{...attrs}
|
||||
style={unref(wrapStyleRef)}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
},
|
||||
});
|
||||
14
apps/platform/src/components/basic/icon/src/icons.data.ts
Normal file
14
apps/platform/src/components/basic/icon/src/icons.data.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { addCollection } from '@iconify/vue';
|
||||
|
||||
import ep from '@iconify-json/ep/icons.json';
|
||||
import antDesign from '@iconify-json/ant-design/icons.json';
|
||||
|
||||
export const icons = { 'Ant Design': antDesign, 'Element Plus': ep } as const;
|
||||
|
||||
export type DefaultIconsType =
|
||||
| `ep:${keyof typeof ep.icons}`
|
||||
| `ant-design:${keyof typeof antDesign.icons}`;
|
||||
|
||||
export const setupIcons = () => {
|
||||
Object.values(icons).forEach((item) => addCollection(item));
|
||||
};
|
||||
30
apps/platform/src/components/basic/icon/src/props.ts
Normal file
30
apps/platform/src/components/basic/icon/src/props.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { DefaultIconsType } from './icons.data';
|
||||
|
||||
export const svgIconProps = {
|
||||
prefix: {
|
||||
type: String,
|
||||
default: 'svg-icon',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: [Number, String],
|
||||
default: 16,
|
||||
},
|
||||
};
|
||||
|
||||
export const iconPickerProps = {
|
||||
value: {
|
||||
type: String as PropType<DefaultIconsType>,
|
||||
},
|
||||
placeholder: String,
|
||||
};
|
||||
|
||||
export type IconProps = {
|
||||
type?: 'svg' | 'iconify' | 'icon-font';
|
||||
icon: DefaultIconsType | string;
|
||||
color?: string;
|
||||
size?: string | number;
|
||||
};
|
||||
3
apps/platform/src/components/basic/iframe-page/index.ts
Normal file
3
apps/platform/src/components/basic/iframe-page/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import IFramePage from './index.vue';
|
||||
|
||||
export default IFramePage;
|
||||
37
apps/platform/src/components/basic/iframe-page/index.vue
Normal file
37
apps/platform/src/components/basic/iframe-page/index.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="iframe-box wh-full">
|
||||
<Spin :spinning="loading" size="large">
|
||||
<iframe class="wh-full" v-bind="$attrs" :src="src" @load="onFrameLoad" />
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { Spin } from 'ant-design-vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'IFramePage',
|
||||
});
|
||||
|
||||
defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const loading = ref(true);
|
||||
|
||||
const onFrameLoad = () => {
|
||||
loading.value = false;
|
||||
};
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.iframe-box {
|
||||
transform: translate(0);
|
||||
|
||||
:deep(div[class^='ant-spin']) {
|
||||
@apply wh-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as LocalePicker } from './index.vue';
|
||||
60
apps/platform/src/components/basic/locale-picker/index.vue
Normal file
60
apps/platform/src/components/basic/locale-picker/index.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<Dropdown placement="bottomRight">
|
||||
<SvgIcon name="locale" />
|
||||
<span v-if="showText" class="ml-1">{{ getLocaleText }}</span>
|
||||
<template #overlay>
|
||||
<Menu v-model:selectedKeys="selectedKeys" @click="handleMenuClick">
|
||||
<Menu.Item v-for="item in localeList" :key="item.lang">
|
||||
<a href="javascript:;">{{ item.icon }} {{ item.label }}</a>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, watchEffect, unref, computed } from 'vue';
|
||||
import { Dropdown, Menu } from 'ant-design-vue';
|
||||
import { useLocale } from '@/locales/useLocale';
|
||||
import { type LocaleType, localeList } from '@/locales/config';
|
||||
import { SvgIcon } from '@/components/basic/icon';
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Whether to display text
|
||||
*/
|
||||
showText: { type: Boolean, default: true },
|
||||
/**
|
||||
* Whether to refresh the interface when changing
|
||||
*/
|
||||
reload: { type: Boolean },
|
||||
});
|
||||
|
||||
const selectedKeys = ref<string[]>([]);
|
||||
|
||||
const { changeLocale, getLocale } = useLocale();
|
||||
|
||||
const getLocaleText = computed(() => {
|
||||
const key = selectedKeys.value[0];
|
||||
if (!key) {
|
||||
return '';
|
||||
}
|
||||
return localeList.find((item) => item.lang === key)?.label;
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
selectedKeys.value = [unref(getLocale)];
|
||||
});
|
||||
|
||||
async function toggleLocale(lang: LocaleType | string) {
|
||||
await changeLocale(lang as LocaleType);
|
||||
selectedKeys.value = [lang as string];
|
||||
props.reload && location.reload();
|
||||
}
|
||||
|
||||
function handleMenuClick({ key }) {
|
||||
if (unref(getLocale) === key) {
|
||||
return;
|
||||
}
|
||||
toggleLocale(key as string);
|
||||
}
|
||||
</script>
|
||||
194
apps/platform/src/components/basic/lockscreen/huawei-charge.vue
Normal file
194
apps/platform/src/components/basic/lockscreen/huawei-charge.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="huawei-charge">
|
||||
<div class="number">{{ battery.level.toFixed(0) }}%</div>
|
||||
<div class="contrast">
|
||||
<div class="circle" />
|
||||
<ul class="bubbles">
|
||||
<li v-for="i in 15" :key="i" />
|
||||
</ul>
|
||||
</div>
|
||||
<div class="charging">
|
||||
<div>{{ batteryStatus }}</div>
|
||||
<div v-show="Number.isFinite(battery.dischargingTime) && battery.dischargingTime != 0">
|
||||
剩余可使用时间:{{ calcDischargingTime }}
|
||||
</div>
|
||||
<span v-show="Number.isFinite(battery.chargingTime) && battery.chargingTime != 0">
|
||||
距离电池充满需要:{{ calcDischargingTime }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import type { Battery } from '@/hooks/useBattery';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'HuaweiCharge',
|
||||
// props: ['batteryStatus', 'battery', 'calcDischargingTime'],
|
||||
props: {
|
||||
battery: {
|
||||
// 电池对象
|
||||
type: Object as PropType<Battery>,
|
||||
default: () => ({}),
|
||||
},
|
||||
calcDischargingTime: {
|
||||
// 电池剩余时间可用时间
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
batteryStatus: {
|
||||
// 电池状态
|
||||
type: String,
|
||||
validator: (val: string) => ['充电中', '已充满', '已断开电源'].includes(val),
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.huawei-charge {
|
||||
.generate-columns(15);
|
||||
|
||||
// @for $i from 0 through 15 {
|
||||
// li:nth-child(#{$i}) {
|
||||
// $width: 15 + random(15) + px;
|
||||
|
||||
// top: 50%;
|
||||
// left: 15 + random(70) + px;
|
||||
// width: $width;
|
||||
// height: $width;
|
||||
// transform: translate(-50%, -50%);
|
||||
// animation: ~'moveToTop `Math.random(6) + 3`s ease-in-out -`Math.random(5000) / 1000`s infinite';
|
||||
// }
|
||||
// }
|
||||
|
||||
@keyframes trotate {
|
||||
50% {
|
||||
border-radius: 45% / 42% 38% 58% 49%;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-50%, -50%) rotate(720deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes move-to-top {
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-50%, -180px);
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hue-rotate {
|
||||
100% {
|
||||
filter: contrast(15) hue-rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
position: absolute;
|
||||
bottom: 20vh;
|
||||
left: 50vw;
|
||||
width: 300px;
|
||||
height: 400px;
|
||||
transform: translateX(-50%);
|
||||
.generate-columns(@n, @i: 0) when (@i =< @n) {
|
||||
.generate-columns(@n, (@i + 1));
|
||||
.column-@{i} {
|
||||
width: (@i * 100% / @n);
|
||||
}
|
||||
li:nth-child(@{i}) {
|
||||
@width: unit(~`Math.round(15 + Math.random() * 15) `, px);
|
||||
|
||||
top: 50%;
|
||||
left: unit(~`Math.round(Math.random() * 70) `, px);
|
||||
width: @width;
|
||||
height: @width;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: move-to-top unit(~`(Math.round(Math.random() * 6) + 3) `, s) ease-in-out
|
||||
unit(~`-(Math.random() * 5000 / 1000) `, s) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.number {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 27%;
|
||||
width: 300px;
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contrast {
|
||||
width: 300px;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
animation: hue-rotate 10s infinite linear;
|
||||
background-color: #000;
|
||||
filter: contrast(15) hue-rotate(0);
|
||||
|
||||
.circle {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
filter: blur(8px);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
transform: translate(-50%, -50%) rotate(0);
|
||||
animation: trotate 10s infinite linear;
|
||||
border-radius: 42% 38% 62% 49% / 45%;
|
||||
background-color: #00ff6f;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
width: 176px;
|
||||
height: 176px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background-color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.bubbles {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
transform: translate(-50%, 0);
|
||||
border-radius: 100px 100px 0 0;
|
||||
background-color: #00ff6f;
|
||||
filter: blur(5px);
|
||||
|
||||
li {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: #00ff6f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.charging {
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
apps/platform/src/components/basic/lockscreen/index.ts
Normal file
3
apps/platform/src/components/basic/lockscreen/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import LockScreen from './index.vue';
|
||||
|
||||
export { LockScreen };
|
||||
42
apps/platform/src/components/basic/lockscreen/index.vue
Normal file
42
apps/platform/src/components/basic/lockscreen/index.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<transition name="slide-up">
|
||||
<LockScreenPage v-if="isLock && isMouted && $route.name != LOGIN_NAME" />
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import LockScreenPage from './lockscreen-page.vue';
|
||||
import { useLockscreenStore } from '@/store/modules/lockscreen';
|
||||
import { LOGIN_NAME } from '@/router/constant';
|
||||
|
||||
const lockscreenStore = useLockscreenStore();
|
||||
const isLock = computed(() => lockscreenStore.isLock);
|
||||
const isMouted = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
isMouted.value = true;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.slide-up-enter-active {
|
||||
animation: slide-up 0.5s;
|
||||
}
|
||||
|
||||
.slide-up-leave-active {
|
||||
animation: slide-up 0.5s reverse;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
0% {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{ unLockLogin: isShowForm }"
|
||||
class="lockscreen"
|
||||
@keyup="isShowForm = true"
|
||||
@mousedown.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<template v-if="!isShowForm">
|
||||
<div class="lock-box">
|
||||
<div class="lock">
|
||||
<span class="lock-icon" title="解锁屏幕" @click="isShowForm = true">
|
||||
<Icon icon="ant-design:lock-outlined" size="30" />
|
||||
</span>
|
||||
</div>
|
||||
<h6 class="tips">点击解锁</h6>
|
||||
</div>
|
||||
<!-- 小米 / 华为 充电-->
|
||||
<component
|
||||
:is="BatteryComp"
|
||||
:battery="battery"
|
||||
:battery-status="batteryStatus"
|
||||
:calc-discharging-time="calcDischargingTime"
|
||||
/>
|
||||
<div class="local-time">
|
||||
<div class="time">{{ hour }}:{{ minute }}</div>
|
||||
<div class="date">{{ month }}月{{ day }}号,星期{{ week }}</div>
|
||||
</div>
|
||||
<div class="computer-status">
|
||||
<span :class="{ offline: !online }" class="network">
|
||||
<Icon icon="ant-design:wifi-outlined" size="30" class="network" />
|
||||
</span>
|
||||
<Icon icon="ant-design:api-outlined" size="30" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="login-box">
|
||||
<Avatar :size="80" :src="userStore.userInfo.avatar">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:user-outlined" size="50" />
|
||||
</template>
|
||||
</Avatar>
|
||||
<div class="username">{{ userStore.userInfo.username }}</div>
|
||||
<a-input-password v-model:value="password" autofocus :placeholder="pwdPlaceholder" />
|
||||
<div class="flex justify-between w-full">
|
||||
<template v-if="lockscreenStore.lockPwd">
|
||||
<a-button type="link" size="small" @click="hideLockForm">返回</a-button>
|
||||
<a-button type="link" size="small" @click="nav2login">返回登录</a-button>
|
||||
<a-button type="link" size="small" @click="onLogin">进入系统</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-button type="link" size="small" @click="cancelLock">取消锁屏</a-button>
|
||||
<a-button type="link" size="small" @click="lockScreen">确定锁屏</a-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, ref, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { Avatar, message } from 'ant-design-vue';
|
||||
import { useOnline } from '@/hooks/useOnline';
|
||||
import { useTime } from '@/hooks/useTime';
|
||||
import { useBattery } from '@/hooks/useBattery';
|
||||
import { useLockscreenStore } from '@/store/modules/lockscreen';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { LOGIN_NAME } from '@/router/constant';
|
||||
import { Icon } from '@/components/basic/icon';
|
||||
|
||||
const lockscreenStore = useLockscreenStore();
|
||||
const userStore = useUserStore();
|
||||
// const isLock = computed(() => lockscreenStore.isLock);
|
||||
// 获取本地时间
|
||||
const { month, day, hour, minute, week } = useTime();
|
||||
const { online } = useOnline();
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const { battery, batteryStatus, calcDischargingTime } = useBattery();
|
||||
|
||||
const BatteryComp = defineAsyncComponent(() => {
|
||||
return Math.random() > 0.49 ? import('./huawei-charge.vue') : import('./xiaomi-charge.vue');
|
||||
});
|
||||
|
||||
const isShowForm = ref(!lockscreenStore.lockPwd);
|
||||
const password = ref('');
|
||||
|
||||
const pwdPlaceholder = computed(() => {
|
||||
return lockscreenStore.lockPwd ? '请输入锁屏密码或用户密码' : '请输入锁屏密码(可选)';
|
||||
});
|
||||
|
||||
// 登录
|
||||
const onLogin = async () => {
|
||||
const pwd = password.value.trim();
|
||||
|
||||
if (pwd === '') return message.warn('密码不能为空');
|
||||
|
||||
if (lockscreenStore.verifyLockPwd(pwd)) {
|
||||
unlockScreen();
|
||||
} else {
|
||||
return message.warn('密码错误,请重新输入');
|
||||
}
|
||||
};
|
||||
|
||||
/** 隐藏锁屏输入表单 */
|
||||
const hideLockForm = () => {
|
||||
isShowForm.value = false;
|
||||
password.value = '';
|
||||
};
|
||||
|
||||
/** 取消锁屏 */
|
||||
const cancelLock = () => {
|
||||
isShowForm.value = false;
|
||||
lockscreenStore.setLock(false);
|
||||
};
|
||||
|
||||
// 确定锁屏
|
||||
const lockScreen = () => {
|
||||
const pwd = password.value.trim();
|
||||
lockscreenStore.setLockPwd(pwd);
|
||||
hideLockForm();
|
||||
};
|
||||
|
||||
// 取消锁屏/解锁锁屏
|
||||
const unlockScreen = () => {
|
||||
isShowForm.value = false;
|
||||
lockscreenStore.setLock(false);
|
||||
};
|
||||
// 输入密码 锁屏
|
||||
|
||||
const nav2login = () => {
|
||||
isShowForm.value = false;
|
||||
lockscreenStore.setLock(false);
|
||||
userStore.clearLoginStatus();
|
||||
router.replace({
|
||||
name: LOGIN_NAME,
|
||||
query: {
|
||||
redirect: route.fullPath,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.lockscreen {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: white;
|
||||
|
||||
&.unLockLogin {
|
||||
background-color: rgb(25 28 34 / 78%);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
.setting-box,
|
||||
.login-box {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 45%;
|
||||
left: 50%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 260px;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
> * {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.lock-box {
|
||||
position: absolute;
|
||||
top: 12vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 34px;
|
||||
|
||||
.tips {
|
||||
color: white;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.lock {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.lock-icon {
|
||||
cursor: pointer;
|
||||
|
||||
.anticon-unlock {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover .anticon-unlock {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
&:hover .anticon-lock {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.local-time {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 60px;
|
||||
font-family: helvetica;
|
||||
|
||||
.time {
|
||||
font-size: 70px;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.computer-status {
|
||||
position: absolute;
|
||||
right: 60px;
|
||||
bottom: 60px;
|
||||
font-size: 24px;
|
||||
|
||||
> * {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.network {
|
||||
position: relative;
|
||||
|
||||
&.offline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: 28px;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
background-color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
272
apps/platform/src/components/basic/lockscreen/xiaomi-charge.vue
Normal file
272
apps/platform/src/components/basic/lockscreen/xiaomi-charge.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="xiaomi-charge">
|
||||
<div v-for="i in 3" :key="i" class="outer">
|
||||
<div class="circle" :style="{ transform: `scale(${1.01 - 0.04 * (i - 1)})` }" />
|
||||
</div>
|
||||
<div class="line-box">
|
||||
<div class="line-left" />
|
||||
<div class="line-left line-right" />
|
||||
<div class="line-center line-center-left-2" />
|
||||
<div class="line-center line-center-left-1" />
|
||||
<div class="line-center" />
|
||||
<div class="line-center line-center-right-1" />
|
||||
<div class="line-center line-center-right-2" />
|
||||
</div>
|
||||
<div class="outer" style="transform: scale(0.68)">
|
||||
<div class="circle circle-blur" style="padding: 30px" />
|
||||
</div>
|
||||
<div v-for="i in 4" :key="i" class="outer">
|
||||
<div
|
||||
class="circle-white"
|
||||
:style="{
|
||||
transform: `scale(${1 - 0.02 * (i - 1)})`,
|
||||
animationDuration: `${500 - 20 * (i - 1)}ms`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="outer">
|
||||
<div class="text">{{ battery.level.toFixed(0) }}<span class="sub">%</span></div>
|
||||
</div>
|
||||
<div class="light" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import type { Battery } from '@/hooks/useBattery';
|
||||
export default defineComponent({
|
||||
name: 'XiaomiCharge',
|
||||
props: {
|
||||
battery: {
|
||||
// 电池对象
|
||||
type: Object as PropType<Battery>,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.xiaomi-charge {
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes up {
|
||||
0% {
|
||||
transform: translateY(80px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(-400px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes light {
|
||||
0% {
|
||||
transform: scale(0.3);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.3);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50vw;
|
||||
justify-content: center;
|
||||
width: 300px;
|
||||
height: 400px;
|
||||
transform: translateX(-50%);
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
width: 286px;
|
||||
height: 286px;
|
||||
padding: 2px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(#c71ff1, #2554ea);
|
||||
}
|
||||
|
||||
.circle::after {
|
||||
content: ' ';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.circle-blur {
|
||||
filter: blur(5px);
|
||||
animation: rotate 5s linear infinite;
|
||||
}
|
||||
|
||||
.circle-white {
|
||||
position: absolute;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
animation: rotate 500ms linear infinite;
|
||||
border-top: solid 1px rgb(255 255 255 / 6%);
|
||||
border-bottom: solid 1px rgb(255 255 255 / 8%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.outer {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 400px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.line-box {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 80px;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.line-left {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: -15px;
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 267px;
|
||||
border-top: solid 2px #2554ea;
|
||||
border-right: solid 2px #2554ea;
|
||||
border-top-right-radius: 40px;
|
||||
}
|
||||
|
||||
.line-left::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
transform: scaleY(0.96);
|
||||
transform-origin: center top;
|
||||
border-top: solid 2px #2554ea;
|
||||
border-right: solid 2px #2554ea;
|
||||
border-top-right-radius: 50px;
|
||||
}
|
||||
|
||||
.line-left::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
transform: scaleY(0.92);
|
||||
transform-origin: center top;
|
||||
border-top: solid 2px #2554ea;
|
||||
border-right: solid 2px #2554ea;
|
||||
border-top-right-radius: 60px;
|
||||
}
|
||||
|
||||
.line-right {
|
||||
transform: scaleX(-1);
|
||||
transform-origin: 55px;
|
||||
}
|
||||
|
||||
.line-center {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 39px;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: #231779;
|
||||
}
|
||||
|
||||
.line-center::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
width: 2px;
|
||||
height: 80px;
|
||||
animation: up 700ms linear infinite;
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
background: linear-gradient(#79ccea, transparent);
|
||||
}
|
||||
|
||||
.line-center-left-1 {
|
||||
transform: translateX(-9px);
|
||||
}
|
||||
|
||||
.line-center-left-2 {
|
||||
transform: translateX(-18px);
|
||||
}
|
||||
|
||||
.line-center-right-1 {
|
||||
transform: translateX(9px);
|
||||
}
|
||||
|
||||
.line-center-right-2 {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.line-center-left-1::before {
|
||||
animation-delay: -200ms;
|
||||
}
|
||||
|
||||
.line-center-left-2::before {
|
||||
animation-delay: -400ms;
|
||||
}
|
||||
|
||||
.line-center-right-1::before {
|
||||
animation-delay: -300ms;
|
||||
}
|
||||
|
||||
.line-center-right-2::before {
|
||||
animation-delay: -500ms;
|
||||
}
|
||||
|
||||
.text {
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
height: 80px;
|
||||
color: turquoise;
|
||||
font-size: 70px;
|
||||
line-height: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.light {
|
||||
position: absolute;
|
||||
bottom: -150px;
|
||||
width: 300px;
|
||||
height: 350px;
|
||||
animation: light 1.2s linear 1 forwards;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(#2554ea, transparent 60%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<object :key="url" :data="url" :type="type" width="100%" height="100%" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'preview-resource',
|
||||
});
|
||||
|
||||
defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
},
|
||||
});
|
||||
|
||||
// const allowTypes = [
|
||||
// 'image/',
|
||||
// 'video/',
|
||||
// 'audio/',
|
||||
// 'text/',
|
||||
// '/xml',
|
||||
// '/json',
|
||||
// '/javascript',
|
||||
// '/pdf',
|
||||
// ];
|
||||
|
||||
// const sandbox = computed(() => {
|
||||
// const isAllowType = allowTypes.some((n) => props.type?.includes(n));
|
||||
// if (isAllowType) {
|
||||
// return '';
|
||||
// }
|
||||
// return 'allow-downloads: false';
|
||||
// });
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { configProviderProps } from 'ant-design-vue/es/config-provider/context';
|
||||
import { merge } from 'lodash-es';
|
||||
import { ConfigProvider } from 'ant-design-vue';
|
||||
import { useLocale } from '@/locales/useLocale';
|
||||
import { useLayoutSettingStore } from '@/store/modules/layoutSetting';
|
||||
|
||||
defineOptions({
|
||||
name: 'ProConfigProvider',
|
||||
});
|
||||
|
||||
const props = defineProps(configProviderProps());
|
||||
|
||||
const layoutSetting = useLayoutSettingStore();
|
||||
const { getAntdLocale } = useLocale();
|
||||
|
||||
const theme = computed(() => {
|
||||
return merge({}, layoutSetting.themeConfig, props.theme);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider v-bind="$props" :locale="getAntdLocale" :theme="theme">
|
||||
<slot />
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
34
apps/platform/src/components/basic/progress/index.vue
Normal file
34
apps/platform/src/components/basic/progress/index.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Progress v-bind="myProps" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { progressProps, type ProgressProps } from 'ant-design-vue/es/progress/props';
|
||||
import { Progress } from 'ant-design-vue';
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
type StrokeColorType = ProgressProps['strokeColor'];
|
||||
type StrokeColorFn = (percent) => StrokeColorType;
|
||||
|
||||
const props = defineProps({
|
||||
...progressProps(),
|
||||
strokeColor: {
|
||||
type: [String, Object, Function] as PropType<StrokeColorType | StrokeColorFn>,
|
||||
},
|
||||
});
|
||||
|
||||
const myProps = computed(() => {
|
||||
if (typeof props.strokeColor === 'function') {
|
||||
return {
|
||||
...props,
|
||||
strokeColor: props.strokeColor(props.percent),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...props,
|
||||
strokeColor: props.strokeColor as StrokeColorType,
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
3
apps/platform/src/components/basic/split-panel/index.ts
Normal file
3
apps/platform/src/components/basic/split-panel/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import SplitPanel from './index.vue';
|
||||
|
||||
export { SplitPanel };
|
||||
104
apps/platform/src/components/basic/split-panel/index.vue
Normal file
104
apps/platform/src/components/basic/split-panel/index.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="split-wrapper">
|
||||
<div ref="scalable" class="scalable">
|
||||
<div class="left-content">
|
||||
<slot name="left-content"> 右边内容区 </slot>
|
||||
</div>
|
||||
<div ref="separator" class="separator" @mousedown="startDrag"><i /><i /></div>
|
||||
</div>
|
||||
<div class="right-content">
|
||||
<slot name="right-content"> 右边内容区 </slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { throttle } from 'lodash-es';
|
||||
|
||||
const scalable = ref<HTMLDivElement>();
|
||||
|
||||
let startX: number;
|
||||
let startWidth: number;
|
||||
|
||||
// 拖拽中
|
||||
// @throttle(20)
|
||||
const onDrag = throttle(function (e: MouseEvent) {
|
||||
scalable.value && (scalable.value.style.width = `${startWidth + e.clientX - startX}px`);
|
||||
}, 20);
|
||||
|
||||
// 拖拽结束
|
||||
const dragEnd = () => {
|
||||
document.documentElement.style.userSelect = 'unset';
|
||||
document.documentElement.removeEventListener('mousemove', onDrag);
|
||||
document.documentElement.removeEventListener('mouseup', dragEnd);
|
||||
};
|
||||
|
||||
// 鼠标按下
|
||||
const startDrag = (e: MouseEvent) => {
|
||||
startX = e.clientX;
|
||||
scalable.value && (startWidth = parseInt(window.getComputedStyle(scalable.value).width, 10));
|
||||
|
||||
document.documentElement.style.userSelect = 'none';
|
||||
document.documentElement.addEventListener('mousemove', onDrag);
|
||||
document.documentElement.addEventListener('mouseup', dragEnd);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@import '@/styles/theme.less';
|
||||
|
||||
@classNames: split-wrapper, separator;
|
||||
.themeBgColor(@classNames);
|
||||
|
||||
.split-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.scalable {
|
||||
position: relative;
|
||||
width: 240px;
|
||||
min-width: 100px;
|
||||
max-width: 50vw;
|
||||
overflow: auto;
|
||||
|
||||
.left-content {
|
||||
height: 100%;
|
||||
padding: 12px 20px 12px 12px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
box-shadow:
|
||||
-4px -2px 4px -5px rgb(0 0 0 / 35%),
|
||||
4px 3px 4px -5px rgb(0 0 0 / 35%);
|
||||
cursor: col-resize;
|
||||
|
||||
i {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
margin: 0 1px;
|
||||
background-color: #e9e9e9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.left-content,
|
||||
.right-content {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
apps/platform/src/components/basic/title-i18n/index.ts
Normal file
1
apps/platform/src/components/basic/title-i18n/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as TitleI18n } from './index.vue';
|
||||
26
apps/platform/src/components/basic/title-i18n/index.vue
Normal file
26
apps/platform/src/components/basic/title-i18n/index.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<i18n-t tag="span" :keypath="getTitle" scope="global" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type PropType, computed } from 'vue';
|
||||
import { useLocaleStore } from '@/store/modules/locale';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: [String, Object] as PropType<string | Title18n>,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const localeStore = useLocaleStore();
|
||||
|
||||
const getTitle = computed(() => {
|
||||
const { title = '' } = props;
|
||||
if (typeof title === 'object') {
|
||||
return title?.[localeStore.locale] ?? title;
|
||||
}
|
||||
return title;
|
||||
});
|
||||
</script>
|
||||
3
apps/platform/src/components/business/README.md
Normal file
3
apps/platform/src/components/business/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
### 业务组件(目录说明)
|
||||
|
||||
#### 与业务强耦合的组件可以放这里
|
||||
7
apps/platform/src/components/core/README.md
Normal file
7
apps/platform/src/components/core/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
### 核心组件(目录说明)
|
||||
|
||||
| 组件名称 | 描述 | 是否全局组件 | 使用建议 |
|
||||
| --- | --- | --- | --- |
|
||||
| draggable-modal | `可拖拽弹窗`基于 a-modal 二次封装的可拖拽模态框,基本使用方式与 antdv 的 a-modal 保持一致 | 否 | 有弹窗拖拽需求的可以使用此组件 |
|
||||
| dynamic-table | `动态表格`基于 a-table 二次封装的表格,基本使用方式与 antdv 的 a-table 保持一致 | 否 | 根据自己需求调整,建议全局使用统一的表格封装组件 |
|
||||
| schema-form | `动态表单`基于 a-form 二次封装。通过 JSON schema 的方式配置使用 | 否 | 定制性不高的表单都可以考虑使用 |
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DraggableModal } from './index.vue';
|
||||
365
apps/platform/src/components/core/draggable-modal/index.vue
Normal file
365
apps/platform/src/components/core/draggable-modal/index.vue
Normal file
@@ -0,0 +1,365 @@
|
||||
<template>
|
||||
<teleport :to="getContainer || 'body'">
|
||||
<ProConfigProvider>
|
||||
<div ref="modalWrap" class="draggable-modal" :class="{ fullscreen: fullscreenModel }">
|
||||
<Modal
|
||||
v-bind="omit(props, ['open', 'onCancel', 'onOk', 'onUpdate:open'])"
|
||||
v-model:open="openModel"
|
||||
:mask-closable="false"
|
||||
:get-container="() => modalWrapRef"
|
||||
:width="innerWidth || width"
|
||||
@ok="emit('ok')"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="title">{{ $attrs.title || '标题' }}</slot>
|
||||
</template>
|
||||
<template #closeIcon>
|
||||
<slot name="closeIcon">
|
||||
<Space class="ant-modal-operate" @click.stop>
|
||||
<FullscreenOutlined v-if="!fullscreenModel" @click="fullscreenModel = true" />
|
||||
<FullscreenExitOutlined v-else @click="restore" />
|
||||
<CloseOutlined @click="closeModal" />
|
||||
</Space>
|
||||
</slot>
|
||||
</template>
|
||||
<slot>
|
||||
① 窗口可以拖动;<br />
|
||||
② 窗口可以通过八个方向改变大小;<br />
|
||||
③ 窗口可以最小化、最大化、还原、关闭;<br />
|
||||
④ 限制窗口最小宽度/高度。
|
||||
</slot>
|
||||
<template v-if="$slots.footer" #footer>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</ProConfigProvider>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, nextTick, useTemplateRef } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { modalProps } from 'ant-design-vue/es/modal/Modal';
|
||||
import { CloseOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons-vue';
|
||||
import { throttle, omit } from 'lodash-es';
|
||||
import { Modal, Space } from 'ant-design-vue';
|
||||
|
||||
const props = defineProps({
|
||||
...modalProps(),
|
||||
fullscreen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'update:fullscreen', 'ok', 'cancel']);
|
||||
|
||||
const route = useRoute();
|
||||
const openModel = defineModel<boolean>('open');
|
||||
const fullscreenModel = ref(props.fullscreen);
|
||||
const innerWidth = ref('');
|
||||
|
||||
const cursorStyle = {
|
||||
top: 'n-resize',
|
||||
left: 'w-resize',
|
||||
right: 'e-resize',
|
||||
bottom: 's-resize',
|
||||
topLeft: 'nw-resize',
|
||||
topright: 'ne-resize',
|
||||
bottomLeft: 'sw-resize',
|
||||
bottomRight: 'se-resize',
|
||||
auto: 'auto',
|
||||
} as const;
|
||||
|
||||
// 是否已经初始化过了
|
||||
let inited = false;
|
||||
const modalWrapRef = useTemplateRef('modalWrap');
|
||||
|
||||
const closeModal = () => {
|
||||
openModel.value = false;
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
// 居中弹窗
|
||||
const centerModal = async () => {
|
||||
await nextTick();
|
||||
const modalEl = modalWrapRef.value?.querySelector<HTMLDivElement>('.ant-modal');
|
||||
|
||||
if (modalEl && modalEl.getBoundingClientRect().left < 1) {
|
||||
modalEl.style.left = `${(document.documentElement.clientWidth - modalEl.offsetWidth) / 2}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const restore = async () => {
|
||||
fullscreenModel.value = false;
|
||||
centerModal();
|
||||
};
|
||||
|
||||
const registerDragTitle = (dragEl: HTMLDivElement, handleEl: HTMLDivElement) => {
|
||||
handleEl.style.cursor = 'move';
|
||||
handleEl.onmousedown = throttle((e: MouseEvent) => {
|
||||
if (fullscreenModel.value) return;
|
||||
document.body.style.userSelect = 'none';
|
||||
const disX = e.clientX - dragEl.getBoundingClientRect().left;
|
||||
const disY = e.clientY - dragEl.getBoundingClientRect().top;
|
||||
const mousemove = (event: MouseEvent) => {
|
||||
if (fullscreenModel.value) return;
|
||||
let iL = event.clientX - disX;
|
||||
let iT = event.clientY - disY;
|
||||
const maxL = document.documentElement.clientWidth - dragEl.offsetWidth;
|
||||
const maxT = document.documentElement.clientHeight - dragEl.offsetHeight;
|
||||
|
||||
iL <= 0 && (iL = 0);
|
||||
iT <= 0 && (iT = 0);
|
||||
iL >= maxL && (iL = maxL);
|
||||
iT >= maxT && (iT = maxT);
|
||||
|
||||
dragEl.style.left = `${Math.max(iL, 0)}px`;
|
||||
dragEl.style.top = `${Math.max(iT, 0)}px`;
|
||||
};
|
||||
const mouseup = () => {
|
||||
document.removeEventListener('mousemove', mousemove);
|
||||
document.removeEventListener('mouseup', mouseup);
|
||||
document.body.style.userSelect = 'auto';
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', mousemove);
|
||||
document.addEventListener('mouseup', mouseup);
|
||||
}, 20);
|
||||
};
|
||||
|
||||
const initDrag = async () => {
|
||||
await nextTick();
|
||||
const modalWrapRefEl = modalWrapRef.value!;
|
||||
const modalWrapEl = modalWrapRefEl.querySelector<HTMLDivElement>('.ant-modal-wrap');
|
||||
const modalEl = modalWrapRefEl.querySelector<HTMLDivElement>('.ant-modal');
|
||||
if (modalWrapEl && modalEl) {
|
||||
centerModal();
|
||||
const headerEl = modalEl.querySelector<HTMLDivElement>('.ant-modal-header');
|
||||
headerEl && registerDragTitle(modalEl, headerEl);
|
||||
|
||||
modalWrapEl.onmousemove = throttle((event: MouseEvent) => {
|
||||
if (fullscreenModel.value) return;
|
||||
const left = event.clientX - modalEl.offsetLeft;
|
||||
const top = event.clientY - modalEl.offsetTop;
|
||||
const right = event.clientX - modalEl.offsetWidth - modalEl.offsetLeft;
|
||||
const bottom = event.clientY - modalEl.offsetHeight - modalEl.offsetTop;
|
||||
const isLeft = left <= 0 && left > -8;
|
||||
const isTop = top < 5 && top > -8;
|
||||
const isRight = right >= 0 && right < 8;
|
||||
const isBottom = bottom > -5 && bottom < 8;
|
||||
// 向左
|
||||
if (isLeft && top > 5 && bottom < -5) {
|
||||
modalWrapEl.style.cursor = cursorStyle.left;
|
||||
// 向上
|
||||
} else if (isTop && left > 5 && right < -5) {
|
||||
modalWrapEl.style.cursor = cursorStyle.top;
|
||||
// 向右
|
||||
} else if (isRight && top > 5 && bottom < -5) {
|
||||
modalWrapEl.style.cursor = cursorStyle.right;
|
||||
// 向下
|
||||
} else if (isBottom && left > 5 && right < -5) {
|
||||
modalWrapEl.style.cursor = cursorStyle.bottom;
|
||||
// 左上角
|
||||
} else if (left > -8 && left <= 5 && top <= 5 && top > -8) {
|
||||
modalWrapEl.style.cursor = cursorStyle.topLeft;
|
||||
// 左下角
|
||||
} else if (left > -8 && left <= 5 && bottom <= 5 && bottom > -8) {
|
||||
modalWrapEl.style.cursor = cursorStyle.bottomLeft;
|
||||
// 右上角
|
||||
} else if (right < 8 && right >= -5 && top <= 5 && top > -8) {
|
||||
modalWrapEl.style.cursor = cursorStyle.topright;
|
||||
// 右下角
|
||||
} else if (right < 8 && right >= -5 && bottom <= 5 && bottom > -8) {
|
||||
modalWrapEl.style.cursor = cursorStyle.bottomRight;
|
||||
} else {
|
||||
modalWrapEl.style.cursor = cursorStyle.auto;
|
||||
}
|
||||
}, 20);
|
||||
modalWrapEl.onmousedown = (e: MouseEvent) => {
|
||||
if (fullscreenModel.value) return;
|
||||
const {
|
||||
top: iParentTop,
|
||||
bottom: iParentBottom,
|
||||
left: iParentLeft,
|
||||
right: iParentRight,
|
||||
} = modalEl.getBoundingClientRect();
|
||||
|
||||
const disX = e.clientX - iParentLeft;
|
||||
const disY = e.clientY - iParentTop;
|
||||
const iParentWidth = modalEl.offsetWidth;
|
||||
const iParentHeight = modalEl.offsetHeight;
|
||||
|
||||
const cursor = modalWrapEl.style.cursor;
|
||||
|
||||
const mousemove = throttle((event: MouseEvent) => {
|
||||
if (fullscreenModel.value) return;
|
||||
if (cursor !== cursorStyle.auto) {
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
const mLeft = `${Math.max(0, event.clientX - disX)}px`;
|
||||
const mTop = `${Math.max(0, event.clientY - disY)}px`;
|
||||
const mLeftWidth = `${Math.min(
|
||||
iParentRight,
|
||||
iParentWidth + iParentLeft - event.clientX,
|
||||
)}px`;
|
||||
const mRightWidth = `${Math.min(
|
||||
window.innerWidth - iParentLeft,
|
||||
event.clientX - iParentLeft,
|
||||
)}px`;
|
||||
const mTopHeight = `${Math.min(
|
||||
iParentBottom,
|
||||
iParentHeight + iParentTop - event.clientY,
|
||||
)}px`;
|
||||
const mBottomHeight = `${Math.min(
|
||||
window.innerHeight - iParentTop,
|
||||
event.clientY - iParentTop,
|
||||
)}px`;
|
||||
|
||||
// 向左边拖拽
|
||||
if (cursor === cursorStyle.left) {
|
||||
modalEl.style.left = mLeft;
|
||||
modalEl.style.width = mLeftWidth;
|
||||
// 向上边拖拽
|
||||
} else if (cursor === cursorStyle.top) {
|
||||
modalEl.style.top = mTop;
|
||||
modalEl.style.height = mTopHeight;
|
||||
// 向右边拖拽
|
||||
} else if (cursor === cursorStyle.right) {
|
||||
modalEl.style.width = mRightWidth;
|
||||
// 向下拖拽
|
||||
} else if (cursor === cursorStyle.bottom) {
|
||||
modalEl.style.height = mBottomHeight;
|
||||
// 左上角拖拽
|
||||
} else if (cursor === cursorStyle.topLeft) {
|
||||
modalEl.style.left = mLeft;
|
||||
modalEl.style.top = mTop;
|
||||
modalEl.style.height = mTopHeight;
|
||||
modalEl.style.width = mLeftWidth;
|
||||
// 右上角拖拽
|
||||
} else if (cursor === cursorStyle.topright) {
|
||||
modalEl.style.top = mTop;
|
||||
modalEl.style.width = mRightWidth;
|
||||
modalEl.style.height = mTopHeight;
|
||||
// 左下角拖拽
|
||||
} else if (cursor === cursorStyle.bottomLeft) {
|
||||
modalEl.style.left = mLeft;
|
||||
modalEl.style.width = mLeftWidth;
|
||||
modalEl.style.height = mBottomHeight;
|
||||
// 右下角拖拽
|
||||
} else if (cursor === cursorStyle.bottomRight) {
|
||||
modalEl.style.width = mRightWidth;
|
||||
modalEl.style.height = mBottomHeight;
|
||||
}
|
||||
innerWidth.value = modalEl.style.width;
|
||||
}, 20);
|
||||
|
||||
const mouseup = () => {
|
||||
document.removeEventListener('mousemove', mousemove);
|
||||
document.removeEventListener('mouseup', mouseup);
|
||||
document.body.style.userSelect = 'auto';
|
||||
modalWrapEl.style.cursor = cursorStyle.auto;
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', mousemove);
|
||||
document.addEventListener('mouseup', mouseup);
|
||||
};
|
||||
}
|
||||
inited = true;
|
||||
};
|
||||
|
||||
watch(openModel, async (val) => {
|
||||
if ((val && Object.is(inited, false)) || props.destroyOnClose) {
|
||||
initDrag();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => route.fullPath, closeModal);
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.draggable-modal {
|
||||
&.fullscreen {
|
||||
.ant-modal {
|
||||
inset: 0 !important;
|
||||
width: 100% !important;
|
||||
max-width: 100vw !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-wrap {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.ant-modal {
|
||||
position: relative;
|
||||
min-width: 200px;
|
||||
min-height: 200px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.ant-modal-header {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
top: 6px;
|
||||
right: 30px;
|
||||
background-color: transparent;
|
||||
cursor: inherit;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.ant-space-item:hover .anticon,
|
||||
.ant-space-item:focus .anticon {
|
||||
color: rgb(0 0 0 / 75%);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ant-modal-close-x {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
line-height: 44px;
|
||||
|
||||
.ant-space {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
/* width: ~'v-bind("props.width")px'; */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
padding-top: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.ant-modal-header {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
flex: auto;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
5
apps/platform/src/components/core/dynamic-table/index.ts
Normal file
5
apps/platform/src/components/core/dynamic-table/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as DynamicTable } from './src/dynamic-table.vue';
|
||||
|
||||
export * from './src/types/';
|
||||
export * from './src/hooks/';
|
||||
export * from './src/dynamic-table';
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<a-spin :spinning="saving">
|
||||
<div class="editable-cell">
|
||||
<a-popover :open="!!errorMsgs?.length" placement="topRight">
|
||||
<template #content>
|
||||
<template v-for="err in errorMsgs" :key="err">
|
||||
<a-typography-text type="danger">{{ err }}</a-typography-text>
|
||||
</template>
|
||||
</template>
|
||||
<a-row type="flex" :gutter="8">
|
||||
<SchemaFormItem
|
||||
v-if="(getIsEditable || getIsCellEdit) && getSchema"
|
||||
v-model:form-model="editFormModel"
|
||||
:schema="getSchema"
|
||||
:table-instance="tableContext"
|
||||
:table-row-key="rowKey"
|
||||
>
|
||||
<template v-for="item in Object.keys($slots)" #[item]="data" :key="item">
|
||||
<slot :name="item" v-bind="data || {}" />
|
||||
</template>
|
||||
</SchemaFormItem>
|
||||
<a-col v-if="getIsCellEdit" :span="4" class="!flex items-center">
|
||||
<CheckOutlined @click="handleSaveCell" />
|
||||
<CloseOutlined @click="handleCancelSaveCell" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-popover>
|
||||
<template v-if="!isCellEdit && editableType === 'cell'">
|
||||
<slot />
|
||||
<EditOutlined @click="startEditCell" />
|
||||
</template>
|
||||
</div>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { EditOutlined, CloseOutlined, CheckOutlined } from '@ant-design/icons-vue';
|
||||
import { useTableContext } from '../../hooks';
|
||||
import type { PropType } from 'vue';
|
||||
import type { CustomRenderParams, EditableType } from '@/components/core/dynamic-table/src/types';
|
||||
import { schemaFormItemProps, SchemaFormItem } from '@/components/core/schema-form';
|
||||
import { isPromise } from '@/utils/is';
|
||||
|
||||
const props = defineProps({
|
||||
...schemaFormItemProps,
|
||||
rowKey: [String, Number] as PropType<Key>,
|
||||
editableType: [String] as PropType<EditableType>,
|
||||
column: [Object] as PropType<CustomRenderParams>,
|
||||
});
|
||||
const saving = ref(false);
|
||||
const isCellEdit = ref(props.column?.column?.defaultEditable);
|
||||
|
||||
const tableContext = useTableContext();
|
||||
const {
|
||||
tableProps,
|
||||
editFormModel,
|
||||
editTableFormRef,
|
||||
editFormErrorMsgs,
|
||||
editableCellKeys,
|
||||
isEditable,
|
||||
startCellEditable,
|
||||
cancelCellEditable,
|
||||
validateCell,
|
||||
} = tableContext;
|
||||
|
||||
const dataIndex = computed(() => {
|
||||
return String(props.column?.column?.dataIndex);
|
||||
});
|
||||
|
||||
const getSchema = computed(() => {
|
||||
const field = props.schema.field;
|
||||
const schema = editTableFormRef.value?.getSchemaByField(field) || props.schema;
|
||||
return {
|
||||
...schema,
|
||||
colProps: {
|
||||
...schema.colProps,
|
||||
span: props.editableType === 'cell' ? 20 : 24,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const getIsEditable = computed(() => {
|
||||
return props.rowKey && isEditable(props.rowKey);
|
||||
});
|
||||
|
||||
const getIsCellEdit = computed(() => {
|
||||
const { rowKey } = props;
|
||||
return (
|
||||
isCellEdit.value &&
|
||||
props.editableType === 'cell' &&
|
||||
editableCellKeys.value.has(`${rowKey}.${dataIndex.value}`)
|
||||
);
|
||||
});
|
||||
|
||||
const errorMsgs = computed(() => {
|
||||
const field = props.schema.field;
|
||||
return editFormErrorMsgs.value.get(field);
|
||||
});
|
||||
|
||||
const startEditCell = () => {
|
||||
startCellEditable(props.rowKey!, dataIndex.value, props.column?.record);
|
||||
isCellEdit.value = true;
|
||||
};
|
||||
|
||||
const handleSaveCell = async () => {
|
||||
const { rowKey, column } = props;
|
||||
await validateCell(rowKey!, dataIndex.value);
|
||||
const saveRes = tableProps.onSave?.(rowKey!, editFormModel.value[rowKey!], column?.record);
|
||||
if (isPromise(saveRes)) {
|
||||
saving.value = true;
|
||||
await saveRes.finally(() => (saving.value = false));
|
||||
}
|
||||
cancelCellEditable(rowKey!, dataIndex.value);
|
||||
isCellEdit.value = false;
|
||||
};
|
||||
|
||||
const handleCancelSaveCell = () => {
|
||||
const { rowKey, column } = props;
|
||||
tableProps.onCancel?.(rowKey!, editFormModel.value[rowKey!], column?.record);
|
||||
isCellEdit.value = false;
|
||||
cancelCellEditable(props.rowKey!, dataIndex.value);
|
||||
};
|
||||
|
||||
// 默认开启编辑的单元格
|
||||
if (isCellEdit.value && props.editableType === 'cell') {
|
||||
startEditCell();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.ant-table-cell:hover {
|
||||
.editable-cell .anticon-edit {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.editable-cell {
|
||||
position: relative;
|
||||
|
||||
.anticon-edit {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-explain) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-with-help) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as TableAction } from './table-action.vue';
|
||||
export { default as ToolBar } from './tool-bar/index.vue';
|
||||
export { default as EditableCell } from './editable-cell/index.vue';
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<template v-for="(actionItem, index) in getActions" :key="`${index}-${actionItem.label}`">
|
||||
<ActionItemRender :action="actionItem">
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
:loading="loadingMap.get(getKey(actionItem, index))"
|
||||
v-bind="actionItem"
|
||||
>
|
||||
{{ actionItem.label }}
|
||||
</a-button>
|
||||
</ActionItemRender>
|
||||
<a-divider v-if="divider && index < getActions.length - 1" type="vertical" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="tsx" setup>
|
||||
import { computed, ref, h, type FunctionalComponent } from 'vue';
|
||||
import { isFunction, isObject, isString } from 'lodash-es';
|
||||
import { Popconfirm, Tooltip, type TooltipProps } from 'ant-design-vue';
|
||||
import type { ActionItem } from '../types/tableAction';
|
||||
import type { CustomRenderParams } from '../types/column';
|
||||
import { hasPermission } from '@/permission';
|
||||
import { Icon } from '@/components/basic/icon';
|
||||
import { isPromise } from '@/utils/is';
|
||||
|
||||
const ActionItemRender: FunctionalComponent<{ action: ActionItem }> = ({ action }, { slots }) => {
|
||||
const { popConfirm, tooltip } = action;
|
||||
const PopconfirmRender = () => {
|
||||
if (popConfirm) {
|
||||
return h(Popconfirm, popConfirm, { default: slots.default });
|
||||
}
|
||||
return slots.default?.();
|
||||
};
|
||||
|
||||
if (tooltip) {
|
||||
return h(Tooltip, getTooltip(tooltip), { default: PopconfirmRender });
|
||||
}
|
||||
return PopconfirmRender();
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
actions: {
|
||||
// 表格行动作
|
||||
type: Array as PropType<ActionItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
columnParams: {
|
||||
type: Object as PropType<CustomRenderParams>,
|
||||
default: () => ({}),
|
||||
},
|
||||
divider: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
rowKey: [String, Number] as PropType<Key>,
|
||||
});
|
||||
|
||||
const clickFnFlag = '__TABLE_ACTION';
|
||||
const loadingMap = ref(new Map<string, boolean>());
|
||||
|
||||
const getActions = computed(() => {
|
||||
return props.actions
|
||||
.filter((item) => {
|
||||
const auth = item.auth;
|
||||
|
||||
if (Object.is(auth, undefined)) {
|
||||
return true;
|
||||
}
|
||||
if (isString(auth)) {
|
||||
const isValid = hasPermission(auth);
|
||||
item.disabled ??= !isValid;
|
||||
if (item.disabled && !isValid) {
|
||||
item.title = '对不起,您没有该操作权限!';
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
if (isObject(auth)) {
|
||||
const isValid = hasPermission(auth.perm);
|
||||
const isDisable = auth.effect !== 'delete';
|
||||
item.disabled ??= !isValid && isDisable;
|
||||
if (item.disabled && !isValid) {
|
||||
item.title = '对不起,您没有该操作权限!';
|
||||
}
|
||||
return isValid || isDisable;
|
||||
}
|
||||
})
|
||||
.map((item, index) => {
|
||||
const onClick = item.onClick;
|
||||
|
||||
if (isFunction(onClick) && !hasClickFnFlag(onClick)) {
|
||||
item.onClick = async () => {
|
||||
const callbackRes = onClick(props.columnParams);
|
||||
|
||||
if (isPromise(callbackRes)) {
|
||||
const key = getKey(item, index);
|
||||
loadingMap.value.set(key, true);
|
||||
await callbackRes.finally(() => {
|
||||
loadingMap.value.delete(key);
|
||||
});
|
||||
}
|
||||
};
|
||||
setClickFnFlag(item.onClick);
|
||||
}
|
||||
if (item.icon) {
|
||||
item.icon = <Icon icon={item.icon} class={{ 'mr-1': !!item.label }} />;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
});
|
||||
|
||||
const hasClickFnFlag = (clickFn: Function) => {
|
||||
return Reflect.get(clickFn, clickFnFlag);
|
||||
};
|
||||
|
||||
const setClickFnFlag = (clickFn: Function) => {
|
||||
Reflect.set(clickFn, clickFnFlag, true);
|
||||
};
|
||||
|
||||
const getKey = (actionItem: ActionItem, index: number) => {
|
||||
return `${props.rowKey}${index}${actionItem.label}`;
|
||||
};
|
||||
|
||||
const getTooltip = (data: ActionItem['tooltip']): TooltipProps => {
|
||||
return {
|
||||
getPopupContainer: () => document.body,
|
||||
placement: 'bottom',
|
||||
...(isString(data) ? { title: data } : data),
|
||||
};
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<Tooltip placement="top">
|
||||
<template #title>
|
||||
<span>{{ t('component.table.settingColumn') }}</span>
|
||||
</template>
|
||||
<Popover
|
||||
placement="bottomLeft"
|
||||
trigger="click"
|
||||
overlay-class-name="cloumn-list"
|
||||
@open-change="handleVisibleChange"
|
||||
>
|
||||
<template #title>
|
||||
<div class="popover-title">
|
||||
<Checkbox v-model:checked="checkAll" :indeterminate="indeterminate">
|
||||
{{ t('component.table.settingColumnShow') }}
|
||||
</Checkbox>
|
||||
<Checkbox v-model:checked="checkIndex" @change="handleIndexCheckChange">
|
||||
{{ t('component.table.settingIndexColumnShow') }}
|
||||
</Checkbox>
|
||||
<Checkbox v-model:checked="checkBordered" @change="handleBorderedCheckChange">
|
||||
{{ t('component.table.settingBordered') }}
|
||||
</Checkbox>
|
||||
<a-button size="small" type="link" @click="reset">
|
||||
{{ t('common.resetText') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div ref="columnListRef">
|
||||
<template v-for="item in tableColumns" :key="getColumnKey(item)">
|
||||
<div class="check-item">
|
||||
<div style="padding: 4px 16px 8px 0">
|
||||
<DragOutlined class="table-column-drag-icon pr-6px cursor-move" />
|
||||
<Checkbox
|
||||
v-model:checked="item.hideInTable"
|
||||
:true-value="false"
|
||||
:false-value="true"
|
||||
>
|
||||
{{ item.title }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div class="column-fixed">
|
||||
<Tooltip placement="bottomLeft" :mouse-leave-delay="0.4">
|
||||
<template #title> {{ t('component.table.settingFixedLeft') }} </template>
|
||||
<VerticalRightOutlined
|
||||
class="fixed-left"
|
||||
:class="{ active: item.fixed === 'left' }"
|
||||
@click="handleColumnFixed(item, 'left')"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Divider type="vertical" />
|
||||
<Tooltip placement="bottomLeft" :mouse-leave-delay="0.4">
|
||||
<template #title> {{ t('component.table.settingFixedRight') }} </template>
|
||||
<VerticalLeftOutlined
|
||||
class="fixed-right"
|
||||
:class="{ active: item.fixed === 'right' }"
|
||||
@click="handleColumnFixed(item, 'right')"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<SettingOutlined />
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, toRaw, unref, watch, type UnwrapRef } from 'vue';
|
||||
import {
|
||||
SettingOutlined,
|
||||
VerticalRightOutlined,
|
||||
VerticalLeftOutlined,
|
||||
DragOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { Tooltip, Popover, Divider } from 'ant-design-vue';
|
||||
import { useTableContext } from '../../hooks/useTableContext';
|
||||
import { ColumnKeyFlag, type TableColumn } from '../../types/column';
|
||||
import Checkbox from '@/components/basic/check-box/index.vue';
|
||||
import { useSortable } from '@/hooks/useSortable';
|
||||
import { isNil } from '@/utils/is';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { tableProps, innerColumns, setProps, getColumnKey } = useTableContext();
|
||||
|
||||
let inited = false;
|
||||
const defaultColumns = toRaw(
|
||||
innerColumns.value?.filter((n) => n.dataIndex !== ColumnKeyFlag.INDEX),
|
||||
);
|
||||
const defaultShowIndex = !!tableProps.showIndex;
|
||||
const defaultBordered = tableProps.bordered;
|
||||
const tableColumns = ref<TableColumn[]>([]);
|
||||
|
||||
const checkAll = computed<boolean>({
|
||||
get() {
|
||||
// @ts-ignore
|
||||
return tableColumns.value.length > 0 && tableColumns.value.every((n) => !n.hideInTable);
|
||||
},
|
||||
set(value) {
|
||||
tableColumns.value.forEach((item) => (item.hideInTable = !value));
|
||||
},
|
||||
});
|
||||
|
||||
const checkIndex = ref(defaultShowIndex);
|
||||
const checkBordered = ref(tableProps.bordered);
|
||||
const columnListRef = ref<HTMLDivElement>();
|
||||
|
||||
// 初始化选中状态
|
||||
const initCheckStatus = () => {
|
||||
tableColumns.value = cloneDeep(defaultColumns) as UnwrapRef<TableColumn[]>;
|
||||
checkIndex.value = defaultShowIndex;
|
||||
checkBordered.value = defaultBordered;
|
||||
tableColumns.value.forEach((item) => (item.hideInTable ??= false));
|
||||
};
|
||||
initCheckStatus();
|
||||
|
||||
const indeterminate = computed(() => {
|
||||
return (
|
||||
tableColumns.value.length > 0 &&
|
||||
tableColumns.value.some((n) => n.hideInTable) &&
|
||||
tableColumns.value.some((n) => !n.hideInTable)
|
||||
);
|
||||
});
|
||||
|
||||
watch(
|
||||
tableColumns,
|
||||
(columns) => {
|
||||
setProps({ columns });
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
// 设置序号列
|
||||
const handleIndexCheckChange = (e) => {
|
||||
setProps({ showIndex: e.target.checked });
|
||||
};
|
||||
// 设置边框
|
||||
const handleBorderedCheckChange = (e) => {
|
||||
setProps({ bordered: e.target.checked });
|
||||
};
|
||||
|
||||
const handleColumnFixed = (columItem, direction: 'left' | 'right') => {
|
||||
columItem.fixed = columItem.fixed === direction ? false : direction;
|
||||
};
|
||||
|
||||
async function handleVisibleChange() {
|
||||
if (inited) return;
|
||||
await nextTick();
|
||||
const columnListEl = unref(columnListRef);
|
||||
if (!columnListEl) return;
|
||||
|
||||
// Drag and drop sort
|
||||
const { initSortable } = useSortable(columnListEl, {
|
||||
handle: '.table-column-drag-icon',
|
||||
onEnd: (evt) => {
|
||||
const { oldIndex, newIndex } = evt;
|
||||
|
||||
if (isNil(oldIndex) || isNil(newIndex) || oldIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
// Sort column
|
||||
const columns = tableColumns.value;
|
||||
columns.splice(newIndex, 0, columns.splice(oldIndex, 1)[0]);
|
||||
},
|
||||
});
|
||||
initSortable();
|
||||
inited = true;
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
initCheckStatus();
|
||||
setProps({ showIndex: defaultShowIndex, bordered: defaultBordered });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.check-item {
|
||||
@apply flex justify-between;
|
||||
}
|
||||
|
||||
.column-fixed {
|
||||
.fixed-right,
|
||||
.fixed-left {
|
||||
&.active,
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, getCurrentInstance } from 'vue';
|
||||
import { FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons-vue';
|
||||
import { useTableContext } from '../../hooks/useTableContext';
|
||||
|
||||
const table = useTableContext();
|
||||
const isFullscreen = table.isFullscreen;
|
||||
const currentInstance = getCurrentInstance();
|
||||
const open = ref(false);
|
||||
|
||||
const updateAppContainerStyle = () => {
|
||||
const appEl: HTMLDivElement =
|
||||
currentInstance?.appContext.app._container || document.querySelector('#app');
|
||||
|
||||
appEl.style.setProperty('opacity', isFullscreen.value ? '0' : '1');
|
||||
appEl.style.setProperty('visibility', isFullscreen.value ? 'hidden' : 'visible');
|
||||
appEl.style.setProperty('position', isFullscreen.value ? 'absolute' : 'relative');
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
isFullscreen.value = !isFullscreen.value;
|
||||
open.value = false;
|
||||
updateAppContainerStyle();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-tooltip v-model:open="open" placement="top">
|
||||
<template #title>
|
||||
{{ isFullscreen ? '取消全屏' : '全屏' }}
|
||||
</template>
|
||||
<component
|
||||
:is="isFullscreen ? FullscreenExitOutlined : FullscreenOutlined"
|
||||
@click="toggleFullscreen"
|
||||
/>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<Space :size="8" class="dark:text-white">
|
||||
<SearchSetting />
|
||||
<RefreshSetting />
|
||||
<Fullscreen />
|
||||
<ColumnSetting />
|
||||
<SizeSetting />
|
||||
</Space>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Space } from 'ant-design-vue';
|
||||
import SizeSetting from './size-setting.vue';
|
||||
import RefreshSetting from './refresh-setting.vue';
|
||||
import ColumnSetting from './column-setting.vue';
|
||||
import SearchSetting from './search-setting.vue';
|
||||
import Fullscreen from './fullscreen.vue';
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<Tooltip placement="top">
|
||||
<template #title>
|
||||
<span>{{ t('common.redo') }}</span>
|
||||
</template>
|
||||
<RedoOutlined @click="redo" />
|
||||
</Tooltip>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { RedoOutlined } from '@ant-design/icons-vue';
|
||||
import { Tooltip } from 'ant-design-vue';
|
||||
import { useTableContext } from '../../hooks/useTableContext';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const table = useTableContext();
|
||||
|
||||
function redo() {
|
||||
table.reload();
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<template v-if="formSchemas?.length && tableProps.search">
|
||||
<Tooltip placement="top">
|
||||
<template #title>
|
||||
<span>{{ innerPropsRef.search ? '隐藏搜索' : '显示搜索' }}</span>
|
||||
</template>
|
||||
<SearchOutlined @click="toggle" />
|
||||
</Tooltip>
|
||||
</template>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { SearchOutlined } from '@ant-design/icons-vue';
|
||||
import { Tooltip } from 'ant-design-vue';
|
||||
import { useTableContext } from '../../hooks/useTableContext';
|
||||
|
||||
const { tableProps, innerPropsRef, setProps, formSchemas } = useTableContext();
|
||||
|
||||
function toggle() {
|
||||
setProps({
|
||||
search: !innerPropsRef.value.search,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<Tooltip placement="top">
|
||||
<template #title>
|
||||
<span>{{ t('component.table.settingDens') }}</span>
|
||||
</template>
|
||||
|
||||
<Dropdown placement="bottom" :trigger="['click']">
|
||||
<ColumnHeightOutlined />
|
||||
<template #overlay>
|
||||
<Menu v-model:selectedKeys="selectedKeysRef" selectable @click="handleMenuClick">
|
||||
<Menu.Item key="large">
|
||||
<span>{{ t('component.table.settingDensDefault') }}</span>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="middle">
|
||||
<span>{{ t('component.table.settingDensMiddle') }}</span>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="small">
|
||||
<span>{{ t('component.table.settingDensSmall') }}</span>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, unref } from 'vue';
|
||||
import { ColumnHeightOutlined } from '@ant-design/icons-vue';
|
||||
import { Tooltip, Dropdown, Menu } from 'ant-design-vue';
|
||||
import { useTableContext } from '../../hooks/useTableContext';
|
||||
import type { TableProps } from 'ant-design-vue/es/table/Table';
|
||||
import type { MenuInfo } from 'ant-design-vue/es/menu/src/interface';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
type SizeType = NonNullable<TableProps['size']>;
|
||||
|
||||
const { t } = useI18n();
|
||||
const table = useTableContext();
|
||||
|
||||
const selectedKeysRef = ref<SizeType[]>([unref(table.innerPropsRef)?.size || 'large']);
|
||||
|
||||
function handleMenuClick({ key }: MenuInfo & { key: SizeType }) {
|
||||
selectedKeysRef.value = [key];
|
||||
table.setProps({
|
||||
size: key,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="flex justify-between p-16px">
|
||||
<div class="flex">
|
||||
<slot name="headerTitle">
|
||||
<div class="title">
|
||||
{{ title }}
|
||||
<BasicHelp v-if="titleTooltip" class="ml-6px pt-3px" :text="titleTooltip" />
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<slot name="afterHeaderTitle" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Space>
|
||||
<slot name="toolbar" />
|
||||
|
||||
<span v-if="exportFileName" @click="exportData2Excel">
|
||||
<slot name="export-button">
|
||||
<a-button type="primary">导出</a-button>
|
||||
</slot>
|
||||
</span>
|
||||
</Space>
|
||||
|
||||
<Divider v-if="$slots.toolbar && showTableSetting" type="vertical" />
|
||||
<TableSetting v-if="showTableSetting" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Divider, Space } from 'ant-design-vue';
|
||||
import TableSetting from '../table-settings/index.vue';
|
||||
import BasicHelp from '@/components/basic/basic-help/index.vue';
|
||||
import { useTableContext } from '@/components/core/dynamic-table/src/hooks';
|
||||
|
||||
defineOptions({
|
||||
name: 'ToolBar',
|
||||
});
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
exportFileName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
titleTooltip: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showTableSetting: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { exportData2Excel } = useTableContext();
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SorterResult } from 'ant-design-vue/es/table/interface';
|
||||
import type { AlignType } from 'ant-design-vue/es/vc-table/interface';
|
||||
|
||||
/** 表格配置 */
|
||||
export default {
|
||||
fetchConfig: {
|
||||
// The field name of the current page passed to the background
|
||||
pageField: 'page',
|
||||
// The number field name of each page displayed in the background
|
||||
sizeField: 'pageSize',
|
||||
// Field name of the form data returned by the interface
|
||||
listField: 'items',
|
||||
// Total number of tables returned by the interface field name
|
||||
totalField: 'meta.totalItems',
|
||||
},
|
||||
// Number of pages that can be selected
|
||||
pageSizeOptions: ['10', '50', '80', '100'],
|
||||
// Default display quantity on one page
|
||||
defaultPageSize: 10,
|
||||
// Default layout of table cells
|
||||
defaultAlign: 'center' as AlignType,
|
||||
// Custom general sort function
|
||||
defaultSortFn: (sortInfo: SorterResult) => {
|
||||
const { field, order } = sortInfo;
|
||||
if (field && order) {
|
||||
return {
|
||||
// The sort field passed to the backend you
|
||||
field,
|
||||
// Sorting method passed to the background asc/desc
|
||||
order,
|
||||
};
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
// Custom general filter function
|
||||
defaultFilterFn: (data: Partial<Recordable<string[]>>) => {
|
||||
return data;
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,141 @@
|
||||
import { tableProps } from 'ant-design-vue/es/table';
|
||||
import tableConfig from './dynamic-table.config';
|
||||
import type { PropType, ExtractPublicPropTypes, EmitsToProps, EmitFn } from 'vue';
|
||||
import type { BookType } from 'xlsx';
|
||||
import type { TableColumn, OnChangeCallbackParams, EditableType, OnSave, OnCancel } from './types/';
|
||||
import type { SchemaFormProps } from '@/components/core/schema-form';
|
||||
import type { GetRowKey } from 'ant-design-vue/es/table/interface';
|
||||
import { isBoolean } from '@/utils/is';
|
||||
|
||||
export const dynamicTableProps = {
|
||||
...tableProps(),
|
||||
rowKey: {
|
||||
type: [String, Function] as PropType<string | GetRowKey<any>>,
|
||||
default: 'id',
|
||||
},
|
||||
/** 是否显示搜索表单 */
|
||||
search: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
/** 表单属性配置 */
|
||||
formProps: {
|
||||
type: Object as PropType<Partial<SchemaFormProps>>,
|
||||
default: () => ({}),
|
||||
},
|
||||
/** 表格列配置 */
|
||||
columns: {
|
||||
type: Array as PropType<TableColumn[]>,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
sortFn: {
|
||||
type: Function as PropType<(sortInfo: OnChangeCallbackParams[2]) => any>,
|
||||
default: tableConfig.defaultSortFn,
|
||||
},
|
||||
filterFn: {
|
||||
type: Function as PropType<(data: OnChangeCallbackParams[1]) => any>,
|
||||
default: tableConfig.defaultFilterFn,
|
||||
},
|
||||
/** 接口请求配置 */
|
||||
fetchConfig: {
|
||||
type: Object as PropType<Partial<typeof tableConfig.fetchConfig>>,
|
||||
default: () => tableConfig.fetchConfig,
|
||||
},
|
||||
/** 表格数据请求函数 */
|
||||
dataRequest: {
|
||||
// 获取列表数据函数API
|
||||
type: Function as PropType<(params: Recordable) => Promise<API.TableListResult | any[]>>,
|
||||
},
|
||||
/** 是否立即请求接口 */
|
||||
immediate: { type: Boolean as PropType<boolean>, default: true },
|
||||
// 额外的请求参数
|
||||
searchParams: {
|
||||
type: Object as PropType<Recordable>,
|
||||
},
|
||||
/** 是否显示索引号 */
|
||||
showIndex: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
/** 索引列属性配置 */
|
||||
indexColumnProps: {
|
||||
type: Object as PropType<Partial<TableColumn>>,
|
||||
default: () => ({}),
|
||||
},
|
||||
/** 是否显示表格工具栏 */
|
||||
showToolBar: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
/** 是否显示表格设置 */
|
||||
showTableSetting: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
/** 表格标题 */
|
||||
headerTitle: String as PropType<string>,
|
||||
/** 表格标题提示信息 */
|
||||
titleTooltip: String as PropType<string>,
|
||||
/** 表格自适应高度 */
|
||||
autoHeight: Boolean as PropType<boolean>,
|
||||
// excel导出配置
|
||||
/** 导出的文件名 */
|
||||
exportFileName: {
|
||||
type: String as PropType<string>,
|
||||
},
|
||||
/** xlsx的booktype */
|
||||
exportBookType: {
|
||||
type: String as PropType<BookType>,
|
||||
default: 'xlsx',
|
||||
},
|
||||
/** 自动宽度 */
|
||||
exportAutoWidth: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
/** 自定义数据导出格式函数 */
|
||||
exportFormatter: {
|
||||
type: Function as PropType<
|
||||
(columns: TableColumn[], tableData: any[]) => { header: string[]; data: any[] }
|
||||
>,
|
||||
default: null,
|
||||
},
|
||||
/** 编辑行类型
|
||||
* @const `single`: 只能同时编辑一行
|
||||
* @const `multiple`: 同时编辑多行
|
||||
* @const `cell`: 可编辑单元格
|
||||
* @defaultValue `single`
|
||||
*/
|
||||
editableType: {
|
||||
type: String as PropType<EditableType>,
|
||||
default: 'single',
|
||||
},
|
||||
/** 单元格保存编辑回调 */
|
||||
onSave: {
|
||||
type: Function as PropType<OnSave>,
|
||||
},
|
||||
/** 单元格取消编辑回调 */
|
||||
onCancel: {
|
||||
type: Function as PropType<OnCancel>,
|
||||
},
|
||||
/** 只能编辑一行的的提示 */
|
||||
onlyOneLineEditorAlertMessage: String,
|
||||
} as const;
|
||||
|
||||
export type DynamicTableProps = ExtractPublicPropTypes<typeof dynamicTableProps> &
|
||||
EmitsToProps<DynamicTableEmits>;
|
||||
|
||||
export const dynamicTableEmits = {
|
||||
change: (...rest: OnChangeCallbackParams) => rest.length === 4,
|
||||
'toggle-advanced': (isAdvanced: boolean) => isBoolean(isAdvanced),
|
||||
'fetch-error': (error) => error,
|
||||
search: (params) => params,
|
||||
reload: () => true,
|
||||
'update:expandedRowKeys': (keys: Key[]) => keys,
|
||||
'expanded-rows-change': (keyValues: string[]) => Array.isArray(keyValues),
|
||||
};
|
||||
|
||||
export type DynamicTableEmits = typeof dynamicTableEmits;
|
||||
|
||||
export type DynamicTableEmitFn = EmitFn<DynamicTableEmits>;
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div>
|
||||
<Teleport to="body" :disabled="!isFullscreen">
|
||||
<div ref="containerElRef">
|
||||
<SchemaForm
|
||||
v-if="innerPropsRef.search"
|
||||
ref="searchFormRef"
|
||||
class="bg-white dark:bg-black mb-16px !pt-24px pr-24px"
|
||||
submit-on-reset
|
||||
v-bind="getFormProps"
|
||||
:table-instance="dynamicTableContext"
|
||||
@toggle-advanced="(e) => $emit('toggle-advanced', e)"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template v-for="item of getFormSlotKeys" #[replaceFormSlotKey(item)]="data">
|
||||
<slot :name="item" v-bind="data || {}" />
|
||||
</template>
|
||||
</SchemaForm>
|
||||
<div class="bg-white dark:bg-black">
|
||||
<ToolBar
|
||||
v-if="showToolBar"
|
||||
:export-file-name="exportFileName"
|
||||
:title="headerTitle"
|
||||
:title-tooltip="titleTooltip"
|
||||
:show-table-setting="showTableSetting"
|
||||
>
|
||||
<template v-for="name of Object.keys($slots)" #[name]="data" :key="name">
|
||||
<slot :name="name" v-bind="data || {}" />
|
||||
</template>
|
||||
</ToolBar>
|
||||
<SchemaForm
|
||||
ref="editTableFormRef"
|
||||
no-style
|
||||
:initial-values="editFormModel"
|
||||
:show-action-button-group="false"
|
||||
:show-advanced-button="false"
|
||||
@validate="handleEditFormValidate"
|
||||
>
|
||||
<Table
|
||||
ref="tableRef"
|
||||
v-bind="tableProps"
|
||||
:columns="innerColumns"
|
||||
:data-source="tableData"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template v-for="(_, slotName) of $slots" #[slotName]="slotData" :key="slotName">
|
||||
<slot :name="slotName" v-bind="slotData" />
|
||||
</template>
|
||||
<template #bodyCell="slotData">
|
||||
<slot name="bodyCell" v-bind="slotData" />
|
||||
</template>
|
||||
</Table>
|
||||
</SchemaForm>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="tsx" setup>
|
||||
import { computed, onBeforeMount } from 'vue';
|
||||
import { Table } from 'ant-design-vue';
|
||||
import {
|
||||
useTableMethods,
|
||||
createTableContext,
|
||||
useExportData2Excel,
|
||||
useTableForm,
|
||||
useTableState,
|
||||
useColumns,
|
||||
} from './hooks';
|
||||
import { ToolBar } from './components';
|
||||
import { dynamicTableProps, dynamicTableEmits } from './dynamic-table';
|
||||
import { SchemaForm } from '@/components/core/schema-form';
|
||||
|
||||
defineOptions({
|
||||
name: 'DynamicTable',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps(dynamicTableProps);
|
||||
const emit = defineEmits(dynamicTableEmits);
|
||||
|
||||
// 表格内部状态
|
||||
const tableState = useTableState(props);
|
||||
const {
|
||||
tableRef,
|
||||
tableData,
|
||||
isFullscreen,
|
||||
containerElRef,
|
||||
searchFormRef,
|
||||
editTableFormRef,
|
||||
innerPropsRef,
|
||||
getBindValues,
|
||||
editFormModel,
|
||||
} = tableState;
|
||||
|
||||
// 表格内部方法
|
||||
const tableMethods = useTableMethods({ props, emit, tableState });
|
||||
const { fetchData, handleSubmit, handleTableChange, handleEditFormValidate } = tableMethods;
|
||||
|
||||
// 表格列的配置描述
|
||||
const { innerColumns } = useColumns({ props, tableState, tableMethods });
|
||||
|
||||
// 搜索表单
|
||||
const tableForm = useTableForm({ tableState, tableMethods });
|
||||
const { getFormProps, replaceFormSlotKey, getFormSlotKeys } = tableForm;
|
||||
|
||||
// 表单导出
|
||||
const exportData2ExcelHooks = useExportData2Excel({ props, tableState, tableMethods });
|
||||
|
||||
// 当前组件所有的状态和方法
|
||||
const dynamicTableContext = {
|
||||
tableProps: props,
|
||||
emit,
|
||||
innerColumns,
|
||||
...tableState,
|
||||
...tableForm,
|
||||
...tableMethods,
|
||||
...exportData2ExcelHooks,
|
||||
};
|
||||
|
||||
// 创建表格上下文
|
||||
createTableContext(dynamicTableContext);
|
||||
|
||||
defineExpose(dynamicTableContext);
|
||||
|
||||
const tableProps = computed<Recordable>(() => {
|
||||
const { getExpandOption } = tableMethods;
|
||||
return {
|
||||
...getBindValues.value,
|
||||
...getExpandOption.value,
|
||||
};
|
||||
});
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (props.immediate) {
|
||||
fetchData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.ant-table-wrapper) {
|
||||
padding: 0 6px 6px;
|
||||
|
||||
.ant-table {
|
||||
.ant-table-title {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ant-image:hover {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
margin-right: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './useTable';
|
||||
export * from './useTableContext';
|
||||
export * from './useExportData2Excel';
|
||||
export * from './useTableForm';
|
||||
export * from './useTableState';
|
||||
export * from './useTableMethods';
|
||||
export * from './useColumns';
|
||||
export * from './useEditable';
|
||||
export * from './useScroll';
|
||||
@@ -0,0 +1,155 @@
|
||||
import { unref, h, useSlots, ref, watchEffect } from 'vue';
|
||||
import { cloneDeep, isFunction, mergeWith } from 'lodash-es';
|
||||
import { Input } from 'ant-design-vue';
|
||||
import { EditableCell } from '../components';
|
||||
import { ColumnKeyFlag, columnKeyFlags, type CustomRenderParams } from '../types/column';
|
||||
import tableConfig from '../dynamic-table.config';
|
||||
import type { TableState } from './useTableState';
|
||||
import type { TableMethods } from './useTableMethods';
|
||||
import type { DynamicTableProps, TableColumn } from '@/components/core/dynamic-table';
|
||||
import type { FormSchema } from '@/components/core/schema-form';
|
||||
import { isBoolean } from '@/utils/is';
|
||||
import { TableAction } from '@/components/core/dynamic-table/src/components';
|
||||
|
||||
interface UseColumnsPayload {
|
||||
tableState: TableState;
|
||||
props: DynamicTableProps;
|
||||
tableMethods: TableMethods;
|
||||
}
|
||||
export type UseColumnsType = ReturnType<typeof useColumns>;
|
||||
|
||||
export const useColumns = (payload: UseColumnsPayload) => {
|
||||
const slots = useSlots();
|
||||
const { tableState, props, tableMethods } = payload;
|
||||
const { innerPropsRef, paginationRef } = tableState;
|
||||
const { getColumnKey, isEditable } = tableMethods;
|
||||
|
||||
const innerColumns = ref<TableColumn[]>([]);
|
||||
|
||||
watchEffect(() => {
|
||||
const innerProps = cloneDeep(unref(innerPropsRef));
|
||||
|
||||
// @ts-ignore
|
||||
const columns = innerProps!.columns!.filter((n) => !n.hideInTable);
|
||||
|
||||
// 是否添加序号列
|
||||
if (innerProps?.showIndex) {
|
||||
columns.unshift({
|
||||
dataIndex: ColumnKeyFlag.INDEX,
|
||||
title: '序号',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
fixed: 'left',
|
||||
...innerProps?.indexColumnProps,
|
||||
customRender: ({ index }) => {
|
||||
const getPagination = unref(paginationRef);
|
||||
if (isBoolean(getPagination)) {
|
||||
return index + 1;
|
||||
}
|
||||
const { current = 1, pageSize = 10 } = getPagination!;
|
||||
return ((current < 1 ? 1 : current) - 1) * pageSize + index + 1;
|
||||
},
|
||||
} as TableColumn);
|
||||
}
|
||||
|
||||
innerColumns.value = columns.map((item) => {
|
||||
const customRender = item.customRender;
|
||||
|
||||
const rowKey = props.rowKey as string;
|
||||
const columnKey = getColumnKey(item) as string;
|
||||
|
||||
item.align ||= tableConfig.defaultAlign;
|
||||
|
||||
item.customRender = (options) => {
|
||||
const { record, index, text } = options as CustomRenderParams<Recordable<any>>;
|
||||
/** 当前行是否开启了编辑行模式 */
|
||||
const isEditableRow = isEditable(record[rowKey]);
|
||||
/** 是否开启了单元格编辑模式 */
|
||||
const isEditableCell = innerProps.editableType === 'cell';
|
||||
/** 当前单元格是否允许被编辑 */
|
||||
const isCellEditable = isBoolean(item.editable)
|
||||
? item.editable
|
||||
: (item.editable?.(options) ?? true);
|
||||
/** 是否允许被编辑 */
|
||||
const isShowEditable =
|
||||
(isEditableRow || isEditableCell) &&
|
||||
isCellEditable &&
|
||||
!columnKeyFlags.includes(columnKey);
|
||||
|
||||
return isShowEditable
|
||||
? h(
|
||||
EditableCell,
|
||||
{
|
||||
schema: getColumnFormSchema(item, record) as any,
|
||||
rowKey: record[rowKey] ?? index,
|
||||
editableType: innerProps.editableType,
|
||||
column: options,
|
||||
},
|
||||
{ default: () => customRender?.(options) ?? text, ...slots },
|
||||
)
|
||||
: customRender?.(options);
|
||||
};
|
||||
|
||||
// 操作列
|
||||
if (item.actions && columnKey === ColumnKeyFlag.ACTION) {
|
||||
item.customRender = (options) => {
|
||||
const { record, index } = options;
|
||||
const tableContext = {
|
||||
...tableMethods,
|
||||
};
|
||||
return h(TableAction, {
|
||||
actions: item.actions!(options, tableContext),
|
||||
rowKey: record[rowKey] ?? index,
|
||||
columnParams: options,
|
||||
});
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: item.key ?? (item.dataIndex as Key),
|
||||
dataIndex: item.dataIndex ?? (item.key as Key),
|
||||
...item,
|
||||
} as TableColumn;
|
||||
});
|
||||
});
|
||||
|
||||
function mergeCustomizer(objValue, srcValue, key) {
|
||||
/** 这里着重处理 `componentProps` 为函数时的合并处理 */
|
||||
if (key === 'componentProps') {
|
||||
return (...rest) => {
|
||||
return {
|
||||
...(isFunction(objValue) ? objValue(...rest) : objValue),
|
||||
...(isFunction(srcValue) ? srcValue(...rest) : srcValue),
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前行的form schema */
|
||||
const getColumnFormSchema = (item: TableColumn, record: Recordable): FormSchema => {
|
||||
const key = getColumnKey(item) as string;
|
||||
/** 是否继承搜索表单的属性 */
|
||||
const isExtendSearchFormProps = !Object.is(
|
||||
item.editFormItemProps?.extendSearchFormProps,
|
||||
false,
|
||||
);
|
||||
|
||||
return {
|
||||
field: `${record[props.rowKey as string]}.${item.searchField ?? key}`,
|
||||
component: () => Input,
|
||||
defaultValue: record[key],
|
||||
colProps: {
|
||||
span: unref(innerPropsRef).editableType === 'cell' ? 20 : 24,
|
||||
},
|
||||
formItemProps: {
|
||||
help: '',
|
||||
},
|
||||
...(isExtendSearchFormProps
|
||||
? mergeWith(cloneDeep(item.formItemProps), item.editFormItemProps, mergeCustomizer)
|
||||
: item.editFormItemProps),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
innerColumns,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import { nextTick, watch } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { message } from 'ant-design-vue';
|
||||
import type { TableColumn } from '../types/column';
|
||||
import type { TableState } from './useTableState';
|
||||
import type { DynamicTableProps } from '../dynamic-table';
|
||||
|
||||
export type UseEditableType = ReturnType<typeof useEditable>;
|
||||
|
||||
interface UseEditablePayload {
|
||||
tableState: TableState;
|
||||
props: DynamicTableProps;
|
||||
}
|
||||
|
||||
export const useEditable = (payload: UseEditablePayload) => {
|
||||
const { props, tableState } = payload;
|
||||
const {
|
||||
tableData,
|
||||
editFormModel,
|
||||
editTableFormRef,
|
||||
editFormErrorMsgs,
|
||||
editableCellKeys,
|
||||
editableRowKeys,
|
||||
} = tableState;
|
||||
|
||||
watch(
|
||||
() => props.editableType,
|
||||
(type) => {
|
||||
if (type === 'cell') {
|
||||
editableRowKeys.value.clear();
|
||||
} else {
|
||||
editableCellKeys.value.clear();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** 设置表单值 */
|
||||
const setEditFormModel = (recordKey: Key, editValue: Recordable) => {
|
||||
Reflect.set(editFormModel.value, recordKey, editValue);
|
||||
nextTick(() => {
|
||||
editTableFormRef.value?.setFormModel(recordKey, editValue);
|
||||
});
|
||||
};
|
||||
|
||||
/** 获取要编辑的值 */
|
||||
const getEditValue = (
|
||||
recordKey: Key,
|
||||
currentRow?: Recordable,
|
||||
columns?: TableColumn<Recordable<any>>[],
|
||||
) => {
|
||||
// 克隆当前行数据作为临时编辑的表单数据,避免直接修改原数据
|
||||
const editValue = cloneDeep(
|
||||
currentRow ?? tableData.value.find((n) => n[String(props.rowKey)] === recordKey),
|
||||
);
|
||||
// 用户设置的默认值优先
|
||||
columns?.forEach((item) => {
|
||||
const { formItemProps, editFormItemProps } = item;
|
||||
const field = (item.dataIndex || item.key) as string;
|
||||
|
||||
// https://github.com/buqiyuan/vue3-antdv-admin/issues/194
|
||||
if (!Reflect.has(editValue, field)) {
|
||||
Reflect.set(editValue, field, undefined);
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.is(editFormItemProps?.extendSearchFormProps, false) &&
|
||||
formItemProps &&
|
||||
Reflect.has(formItemProps, 'defaultValue')
|
||||
) {
|
||||
editValue[field] = formItemProps.defaultValue;
|
||||
}
|
||||
if (editFormItemProps && Reflect.has(editFormItemProps, 'defaultValue')) {
|
||||
editValue[field] = editFormItemProps.defaultValue;
|
||||
}
|
||||
});
|
||||
return editValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 进入编辑行状态
|
||||
*
|
||||
* @param recordKey 当前行id,即table的rowKey
|
||||
* @param currentRow 当前行数据
|
||||
*/
|
||||
const startEditable = (recordKey: Key, currentRow?: Recordable) => {
|
||||
editableCellKeys.value.clear();
|
||||
console.log('startEditable editFormModel', editFormModel);
|
||||
// 如果是单行的话,不允许多行编辑
|
||||
if (editableRowKeys.value.size > 0 && props.editableType === 'single') {
|
||||
message.warn(props.onlyOneLineEditorAlertMessage || '只能同时编辑一行');
|
||||
return false;
|
||||
}
|
||||
const editValue = getEditValue(recordKey, currentRow, props.columns);
|
||||
setEditFormModel(recordKey, editValue);
|
||||
editableRowKeys.value.add(recordKey);
|
||||
return true;
|
||||
};
|
||||
|
||||
/** 进入编辑单元格状态 */
|
||||
const startCellEditable = (recordKey: Key, dataIndex: Key, currentRow?: Recordable) => {
|
||||
editableRowKeys.value.clear();
|
||||
const targetColumn = props.columns.filter((n) => n.dataIndex === dataIndex);
|
||||
const editValue = getEditValue(recordKey, currentRow, targetColumn);
|
||||
|
||||
editableCellKeys.value.add(`${recordKey}.${dataIndex}`);
|
||||
setEditFormModel(recordKey, {
|
||||
...(getEditFormModel(recordKey) || editValue),
|
||||
[dataIndex]: editValue[dataIndex],
|
||||
});
|
||||
};
|
||||
|
||||
/** 取消编辑单元格 */
|
||||
const cancelCellEditable = (recordKey: Key, dataIndex: Key) => {
|
||||
editableCellKeys.value.delete(`${recordKey}.${dataIndex}`);
|
||||
const editFormModel = getEditFormModel(recordKey);
|
||||
const record = tableData.value.find((n) => n[String(props.rowKey)] === recordKey);
|
||||
if (record) {
|
||||
// 取消编辑,还原默认值
|
||||
Reflect.set(editFormModel, dataIndex, record[dataIndex]);
|
||||
}
|
||||
/** 表单项的校验错误信息也清掉 */
|
||||
editFormErrorMsgs.value.delete(`${recordKey}.${dataIndex}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 退出编辑行状态
|
||||
*
|
||||
* @param recordKey
|
||||
*/
|
||||
const cancelEditable = (recordKey: Key) => {
|
||||
const formModel = getEditFormModel(recordKey);
|
||||
/** 表单项的校验错误信息也清掉 */
|
||||
Object.keys(formModel).forEach((field) =>
|
||||
editFormErrorMsgs.value.delete(`${recordKey}.${field}`),
|
||||
);
|
||||
|
||||
nextTick(() => {
|
||||
editTableFormRef.value?.delFormModel?.(recordKey);
|
||||
});
|
||||
|
||||
editableRowKeys.value.delete(recordKey);
|
||||
return Reflect.deleteProperty(editFormModel.value, recordKey);
|
||||
};
|
||||
|
||||
/** 这行是不是编辑状态 */
|
||||
const isEditable = (recordKey: Key) => editableRowKeys.value.has(recordKey);
|
||||
|
||||
/** 获取表单编辑后的值 */
|
||||
const getEditFormModel = (recordKey: Key) => Reflect.get(editFormModel.value, recordKey);
|
||||
|
||||
/** 行编辑表单是否校验通过 */
|
||||
const validateRow = async (recordKey: Key) => {
|
||||
const nameList = Object.keys(getEditFormModel(recordKey)).map((n) => [String(recordKey), n]);
|
||||
const result = await editTableFormRef.value?.validateFields(nameList);
|
||||
return result?.[recordKey] ?? result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 单元格表单是否校验通过
|
||||
* @param recordKey 当前行ID
|
||||
* @param dataIndex 当前单元格字段名, eg: `column.dataIndex`
|
||||
* */
|
||||
const validateCell = async (recordKey: Key, dataIndex: Key) => {
|
||||
const result = await editTableFormRef.value?.validateFields([[String(recordKey), dataIndex]]);
|
||||
return result?.[recordKey] ?? result;
|
||||
};
|
||||
|
||||
return {
|
||||
setEditFormModel,
|
||||
startEditable,
|
||||
startCellEditable,
|
||||
cancelCellEditable,
|
||||
cancelEditable,
|
||||
isEditable,
|
||||
validateRow,
|
||||
validateCell,
|
||||
getEditFormModel,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { get, isEmpty } from 'lodash-es';
|
||||
import { columnKeyFlags } from '../types';
|
||||
import type { TableState } from './useTableState';
|
||||
import type { DynamicTableProps } from '../dynamic-table';
|
||||
import type { TableMethods } from './useTableMethods';
|
||||
import { exportJson2Excel } from '@/utils/Export2Excel';
|
||||
|
||||
export type ExportData2Excel = ReturnType<typeof useExportData2Excel>;
|
||||
|
||||
interface UseExportData2ExcelPayload {
|
||||
tableState: TableState;
|
||||
props: DynamicTableProps;
|
||||
tableMethods: TableMethods;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出表格Excel
|
||||
*/
|
||||
export const useExportData2Excel = (payload: UseExportData2ExcelPayload) => {
|
||||
const { tableState, props, tableMethods } = payload;
|
||||
const { tableData } = tableState;
|
||||
const { getColumnKey } = tableMethods;
|
||||
|
||||
const exportData2Excel = () => {
|
||||
const { columns, exportFormatter, exportFileName, exportBookType, exportAutoWidth } = props;
|
||||
|
||||
const theaders = columns.filter((n) => {
|
||||
const key = getColumnKey(n);
|
||||
return key && !columnKeyFlags.includes(key);
|
||||
});
|
||||
|
||||
if (exportFormatter) {
|
||||
const { header, data } = exportFormatter(theaders, tableData.value);
|
||||
if (isEmpty(header) || isEmpty(data)) {
|
||||
return;
|
||||
} else {
|
||||
exportJson2Excel({
|
||||
header,
|
||||
data,
|
||||
filename: exportFileName,
|
||||
bookType: exportBookType,
|
||||
autoWidth: exportAutoWidth,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
exportJson2Excel({
|
||||
header: theaders.map((n) => n.title as string),
|
||||
data: tableData.value.map((v) => theaders.map((header) => get(v, getColumnKey(header)!))),
|
||||
filename: exportFileName,
|
||||
bookType: exportBookType,
|
||||
autoWidth: exportAutoWidth,
|
||||
});
|
||||
}
|
||||
};
|
||||
return { exportData2Excel };
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { computed, ref, type Ref } from 'vue';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { useMutationObserver, useResizeObserver } from '@vueuse/core';
|
||||
import type { DynamicTableProps } from '../dynamic-table';
|
||||
|
||||
type UseScrollParams = {
|
||||
props: DynamicTableProps;
|
||||
containerElRef: Ref<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export type UseScrollType = ReturnType<typeof useScroll>;
|
||||
|
||||
// 获取元素到顶部距离-通用方法
|
||||
export const getPositionTop = (node: HTMLElement) => {
|
||||
let top = node.offsetTop;
|
||||
let parent = node.offsetParent as HTMLElement;
|
||||
while (parent != null) {
|
||||
top += parent.offsetTop;
|
||||
parent = parent.offsetParent as HTMLElement;
|
||||
}
|
||||
return top; // 所有的父元素top和
|
||||
};
|
||||
|
||||
export const useScroll = ({ props, containerElRef }: UseScrollParams) => {
|
||||
const scrollY = ref<number>();
|
||||
|
||||
const scroll = computed(() => {
|
||||
return {
|
||||
y: scrollY.value,
|
||||
...props.scroll,
|
||||
};
|
||||
});
|
||||
|
||||
const getScrollY = debounce(() => {
|
||||
if (!props.autoHeight || !containerElRef.value) return;
|
||||
let paginationHeight = 0;
|
||||
const paginationEl = containerElRef.value.querySelector<HTMLDivElement>('.ant-pagination');
|
||||
if (paginationEl) {
|
||||
const { offsetHeight } = paginationEl;
|
||||
const { marginTop, marginBottom } = getComputedStyle(paginationEl);
|
||||
paginationHeight = offsetHeight + parseInt(marginTop) + parseInt(marginBottom);
|
||||
}
|
||||
const bodyEl =
|
||||
containerElRef.value.querySelector<HTMLDivElement>('.ant-table-body') ||
|
||||
containerElRef.value.querySelector<HTMLDivElement>('.ant-table-tbody');
|
||||
if (bodyEl) {
|
||||
const rootElHeight = document.documentElement.offsetHeight;
|
||||
const posTopHeight = getPositionTop(bodyEl as HTMLDivElement);
|
||||
const scrollbarHeight = bodyEl.offsetHeight - bodyEl.clientHeight;
|
||||
const y = rootElHeight - posTopHeight - scrollbarHeight - paginationHeight - 8;
|
||||
scrollY.value = y;
|
||||
// console.log('innerScroll.value', rootElHeight, posTopHeight, paginationHeight, y);
|
||||
}
|
||||
}, 20);
|
||||
|
||||
useMutationObserver(containerElRef, getScrollY, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
useResizeObserver(document.documentElement, getScrollY);
|
||||
|
||||
return {
|
||||
scroll,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { nextTick, ref, unref, watch } from 'vue';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import DynamicTable from '../dynamic-table.vue';
|
||||
import type { FunctionalComponent, Ref } from 'vue';
|
||||
import type { DynamicTableProps } from '../dynamic-table';
|
||||
|
||||
type DynamicTableInstance = InstanceType<typeof DynamicTable>;
|
||||
|
||||
export function useTable(props?: Partial<DynamicTableProps>) {
|
||||
const dynamicTableRef = ref<DynamicTableInstance>({} as DynamicTableInstance);
|
||||
|
||||
async function getTableInstance() {
|
||||
await nextTick();
|
||||
const table = unref(dynamicTableRef);
|
||||
if (isEmpty(table)) {
|
||||
console.error('未获取表格实例!');
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props,
|
||||
async () => {
|
||||
if (props) {
|
||||
// console.log('table onMounted', { ...props });
|
||||
await nextTick();
|
||||
const tableInstance = await getTableInstance();
|
||||
tableInstance?.setProps?.(props);
|
||||
}
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
flush: 'post',
|
||||
},
|
||||
);
|
||||
|
||||
const methods = new Proxy<Ref<DynamicTableInstance>>(dynamicTableRef, {
|
||||
get(target, key: string) {
|
||||
if (Reflect.has(target, key)) {
|
||||
return unref(target);
|
||||
}
|
||||
if (target.value && Reflect.has(target.value, key)) {
|
||||
return Reflect.get(target.value, key);
|
||||
}
|
||||
return async (...rest) => {
|
||||
const table = await getTableInstance();
|
||||
return table?.[key]?.(...rest);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const DynamicTableRender: FunctionalComponent<DynamicTableProps> = (
|
||||
compProps,
|
||||
{ attrs, slots },
|
||||
) => {
|
||||
return (
|
||||
<DynamicTable
|
||||
ref={dynamicTableRef}
|
||||
{...{ ...attrs, ...props, ...compProps }}
|
||||
v-slots={slots}
|
||||
></DynamicTable>
|
||||
);
|
||||
};
|
||||
|
||||
return [DynamicTableRender, unref(methods)] as const;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { injectLocal, provideLocal } from '@vueuse/core';
|
||||
import type {
|
||||
TableMethods,
|
||||
TableState,
|
||||
TableForm,
|
||||
UseEditableType,
|
||||
ExportData2Excel,
|
||||
UseColumnsType,
|
||||
} from './';
|
||||
import type { DynamicTableProps } from '../dynamic-table';
|
||||
|
||||
type DynamicTableType = {
|
||||
tableProps: DynamicTableProps;
|
||||
} & TableMethods &
|
||||
TableState &
|
||||
TableForm &
|
||||
UseEditableType &
|
||||
UseEditableType &
|
||||
ExportData2Excel &
|
||||
UseColumnsType;
|
||||
|
||||
const key = Symbol('dynamic-table');
|
||||
|
||||
export function createTableContext(instance: DynamicTableType) {
|
||||
provideLocal(key, instance);
|
||||
}
|
||||
|
||||
export function useTableContext() {
|
||||
return injectLocal(key) as DynamicTableType;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { computed, unref, toRaw, ref } from 'vue';
|
||||
import type { DynamicTableEmitFn, DynamicTableProps } from '../dynamic-table';
|
||||
import type { TableState } from './useTableState';
|
||||
|
||||
interface UseTableExpandPayload {
|
||||
tableState: TableState;
|
||||
props: DynamicTableProps;
|
||||
emit: DynamicTableEmitFn;
|
||||
}
|
||||
export type TableExpand = ReturnType<typeof useTableExpand>;
|
||||
|
||||
export function useTableExpand(payload: UseTableExpandPayload) {
|
||||
const { props, emit, tableState } = payload;
|
||||
const { tableData } = tableState;
|
||||
// 表格为树形结构时 展开的行
|
||||
const expandedRowKeys = ref<Key[]>([]);
|
||||
|
||||
const isTreeTable = computed(() => {
|
||||
const { childrenColumnName = 'children' } = props;
|
||||
/**
|
||||
* https://github.com/ant-design/ant-design/issues/42722
|
||||
* 目前官方树表格符合条件则会自动开启,且没有直接提供相应的关闭树状表格的 API,官方建议关闭树狀表格的方式
|
||||
* 是自己处理数据,将数据中的 children 字段设置为 null 则会关闭树状表格。
|
||||
*/
|
||||
return tableData.value.some((item) => {
|
||||
return Array.isArray(item[childrenColumnName]) && item[childrenColumnName].length;
|
||||
});
|
||||
});
|
||||
|
||||
const getExpandOption = computed(() => {
|
||||
if (!isTreeTable.value) 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 | number)[]) {
|
||||
if (!isTreeTable.value) return;
|
||||
expandedRowKeys.value = [...expandedRowKeys.value, ...keys];
|
||||
}
|
||||
|
||||
function getAllKeys(data?: Recordable[]) {
|
||||
const keys: string[] = [];
|
||||
const { childrenColumnName, rowKey } = props;
|
||||
toRaw(data || unref(tableData)).forEach((item) => {
|
||||
keys.push(item[rowKey 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 };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { unref, computed, watchEffect, useSlots } from 'vue';
|
||||
import { ColumnKeyFlag } from '../types/column';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import type { FormSchema, SchemaFormProps } from '@/components/core/schema-form';
|
||||
import type { TableState } from './useTableState';
|
||||
import type { TableMethods } from './useTableMethods';
|
||||
|
||||
export type TableForm = ReturnType<typeof useTableForm>;
|
||||
|
||||
interface UseTableFormPayload {
|
||||
tableState: TableState;
|
||||
tableMethods: TableMethods;
|
||||
}
|
||||
|
||||
export function useTableForm(payload: UseTableFormPayload) {
|
||||
const slots = useSlots();
|
||||
const { tableState, tableMethods } = payload;
|
||||
const { innerPropsRef, loadingRef } = tableState;
|
||||
const { getColumnKey, getSearchFormRef } = tableMethods;
|
||||
|
||||
const getFormProps = computed((): SchemaFormProps => {
|
||||
const { formProps } = unref(innerPropsRef);
|
||||
const { submitButtonOptions } = formProps || {};
|
||||
return {
|
||||
showAdvancedButton: true,
|
||||
layout: 'horizontal',
|
||||
labelWidth: 100,
|
||||
schemas: unref(formSchemas),
|
||||
...formProps,
|
||||
submitButtonOptions: { loading: unref(loadingRef), ...submitButtonOptions },
|
||||
compact: true,
|
||||
};
|
||||
});
|
||||
|
||||
const formSchemas = computed<FormSchema[]>(() => {
|
||||
const columnKeyFlags = Object.keys(ColumnKeyFlag);
|
||||
// @ts-ignore
|
||||
return unref(innerPropsRef)
|
||||
.columns.filter((n) => {
|
||||
const field = getColumnKey(n);
|
||||
return !n.hideInSearch && !!field && !columnKeyFlags.includes(field as string);
|
||||
})
|
||||
.map((n) => {
|
||||
return {
|
||||
field: n.searchField ?? ([] as string[]).concat(getColumnKey(n)).join('.'),
|
||||
component: 'Input',
|
||||
label: n.title as string,
|
||||
colProps: {
|
||||
span: 8,
|
||||
},
|
||||
...n.formItemProps,
|
||||
} as FormSchema;
|
||||
})
|
||||
.sort((a, b) => Number(a?.order) - Number(b?.order)) as FormSchema[];
|
||||
});
|
||||
|
||||
// 同步外部对props的修改
|
||||
watchEffect(() => getSearchFormRef()?.setSchemaFormProps(unref(getFormProps)), {
|
||||
flush: 'post',
|
||||
});
|
||||
|
||||
const getFormSlotKeys: ComputedRef<string[]> = computed(() => {
|
||||
const keys = Object.keys(slots);
|
||||
return keys
|
||||
.map((item) => (item.startsWith('form-') ? item : null))
|
||||
.filter((item): item is string => !!item);
|
||||
});
|
||||
|
||||
function replaceFormSlotKey(key: string) {
|
||||
if (!key) return '';
|
||||
return key?.replace?.(/form-/, '') ?? '';
|
||||
}
|
||||
|
||||
return {
|
||||
formSchemas,
|
||||
getFormProps,
|
||||
replaceFormSlotKey,
|
||||
getFormSlotKeys,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { unref, nextTick, getCurrentInstance, watch } from 'vue';
|
||||
import { isFunction, isBoolean, get, debounce } from 'lodash-es';
|
||||
import { useInfiniteScroll } from '@vueuse/core';
|
||||
import tableConfig from '../dynamic-table.config';
|
||||
import { useEditable } from './useEditable';
|
||||
import { useTableExpand } from './useTableExpand';
|
||||
import type { TableState, Pagination } from './useTableState';
|
||||
import type { DynamicTableEmitFn, DynamicTableProps } from '../dynamic-table';
|
||||
import type { OnChangeCallbackParams, TableColumn } from '../types/';
|
||||
import type { FormProps } from 'ant-design-vue';
|
||||
import { warn } from '@/utils/log';
|
||||
import { isObject } from '@/utils/is';
|
||||
|
||||
export type UseInfiniteScrollParams = Parameters<typeof useInfiniteScroll>;
|
||||
|
||||
export type TableMethods = ReturnType<typeof useTableMethods>;
|
||||
interface UseTableMethodsPayload {
|
||||
tableState: TableState;
|
||||
emit: DynamicTableEmitFn;
|
||||
props: DynamicTableProps;
|
||||
}
|
||||
|
||||
export const useTableMethods = (payload: UseTableMethodsPayload) => {
|
||||
const { props, emit, tableState } = payload;
|
||||
const {
|
||||
innerPropsRef,
|
||||
tableData,
|
||||
loadingRef,
|
||||
searchFormRef,
|
||||
paginationRef,
|
||||
editFormErrorMsgs,
|
||||
searchState,
|
||||
} = tableState;
|
||||
// 可编辑行
|
||||
const editableMethods = useEditable({ props, tableState });
|
||||
const expandMethods = useTableExpand({ props, tableState, emit });
|
||||
|
||||
watch(
|
||||
() => props.searchParams,
|
||||
() => {
|
||||
fetchData();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.dataSource,
|
||||
(val) => {
|
||||
updatePagination({
|
||||
total: val?.length,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const setProps = (props: Partial<DynamicTableProps>) => {
|
||||
Object.assign(innerPropsRef.value, props);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 表格查询
|
||||
*/
|
||||
const handleSubmit = (params, page = 1) => {
|
||||
updatePagination({
|
||||
current: page,
|
||||
});
|
||||
fetchData(params);
|
||||
emit('search', params);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {object} params 表格查询参数
|
||||
* @description 获取表格数据
|
||||
*/
|
||||
const fetchData = debounce(async (params: Recordable = {}) => {
|
||||
const { dataRequest, dataSource, fetchConfig, searchParams } = props;
|
||||
|
||||
if (!dataRequest || !isFunction(dataRequest) || Array.isArray(dataSource)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let pageParams: Recordable = {};
|
||||
const pagination = unref(paginationRef)!;
|
||||
|
||||
const { pageField, sizeField, listField, totalField } = {
|
||||
...tableConfig.fetchConfig,
|
||||
...fetchConfig,
|
||||
};
|
||||
|
||||
// 是否启用了分页
|
||||
const enablePagination = isObject(pagination);
|
||||
if (enablePagination) {
|
||||
pageParams = {
|
||||
[pageField]: pagination.current,
|
||||
[sizeField]: pagination.pageSize,
|
||||
};
|
||||
}
|
||||
const { sortInfo = {}, filterInfo } = searchState;
|
||||
// 表格查询参数
|
||||
let queryParams: Recordable = {
|
||||
...pageParams,
|
||||
...sortInfo,
|
||||
...filterInfo,
|
||||
...searchParams,
|
||||
...params,
|
||||
};
|
||||
await nextTick();
|
||||
if (searchFormRef.value) {
|
||||
const values = await searchFormRef.value.validate();
|
||||
queryParams = {
|
||||
...searchFormRef.value.handleFormValues(values),
|
||||
...queryParams,
|
||||
};
|
||||
}
|
||||
|
||||
loadingRef.value = true;
|
||||
const res = await dataRequest(queryParams);
|
||||
|
||||
const isArrayResult = Array.isArray(res);
|
||||
const resultItems: Recordable[] = isArrayResult ? res : get(res, listField);
|
||||
const resultTotal: number = isArrayResult ? res.length : Number(get(res, totalField));
|
||||
|
||||
if (enablePagination && resultTotal) {
|
||||
const { current = 1, pageSize = tableConfig.defaultPageSize } = pagination;
|
||||
const currentTotalPage = Math.ceil(resultTotal / pageSize);
|
||||
if (current > currentTotalPage) {
|
||||
updatePagination({
|
||||
current: currentTotalPage,
|
||||
});
|
||||
return await fetchData(params);
|
||||
}
|
||||
}
|
||||
tableData.value = resultItems;
|
||||
updatePagination({ total: ~~resultTotal });
|
||||
if (queryParams[pageField]) {
|
||||
updatePagination({ current: queryParams[pageField] || 1 });
|
||||
}
|
||||
return tableData;
|
||||
} catch (error) {
|
||||
warn(`表格查询出错:${error}`);
|
||||
emit('fetch-error', error);
|
||||
tableData.value = [];
|
||||
updatePagination({ total: 0 });
|
||||
} finally {
|
||||
loadingRef.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @description 刷新表格
|
||||
*/
|
||||
const reload = (resetPageIndex = false) => {
|
||||
const pagination = unref(paginationRef);
|
||||
if (Object.is(resetPageIndex, true) && isObject(pagination)) {
|
||||
pagination.current = 1;
|
||||
}
|
||||
emit('reload');
|
||||
return fetchData();
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 分页改变
|
||||
*/
|
||||
const handleTableChange = async (...rest: OnChangeCallbackParams) => {
|
||||
const [pagination, filters, sorter] = rest;
|
||||
const { sortFn, filterFn } = props;
|
||||
|
||||
if (searchFormRef.value) {
|
||||
await searchFormRef.value.validate();
|
||||
}
|
||||
updatePagination(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;
|
||||
}
|
||||
|
||||
await fetchData({});
|
||||
emit('change', ...rest);
|
||||
};
|
||||
|
||||
// dataIndex 可以为 a.b.c
|
||||
// const getDataIndexVal = (dataIndex, record) => dataIndex.split('.').reduce((pre, curr) => pre[curr], record)
|
||||
|
||||
// 获取表格列key
|
||||
const getColumnKey = (column: TableColumn) => {
|
||||
return (column?.key || column?.dataIndex) as string;
|
||||
};
|
||||
|
||||
/** 编辑表单验证失败回调 */
|
||||
const handleEditFormValidate: FormProps['onValidate'] = (name, status, errorMsgs) => {
|
||||
// console.log('errorInfo', editFormErrorMsgs);
|
||||
const key = Array.isArray(name) ? name.join('.') : name;
|
||||
if (status) {
|
||||
editFormErrorMsgs.value.delete(key);
|
||||
} else {
|
||||
editFormErrorMsgs.value.set(key, errorMsgs);
|
||||
}
|
||||
};
|
||||
|
||||
/** 更新表格分页信息 */
|
||||
const updatePagination = (info: Pagination = paginationRef.value) => {
|
||||
if (isBoolean(info)) {
|
||||
paginationRef.value = info;
|
||||
} else if (isObject(paginationRef.value)) {
|
||||
paginationRef.value = {
|
||||
...paginationRef.value,
|
||||
...info,
|
||||
};
|
||||
}
|
||||
};
|
||||
/** 表格无限滚动 */
|
||||
const onInfiniteScroll = (
|
||||
callback: UseInfiniteScrollParams[1],
|
||||
options?: UseInfiniteScrollParams[2],
|
||||
) => {
|
||||
const el = getCurrentInstance()?.proxy?.$el.querySelector('.ant-table-body');
|
||||
useInfiniteScroll(el, callback, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description当外部需要动态改变搜索表单的值或选项时,需要调用此方法获取dynamicFormRef实例
|
||||
*/
|
||||
const getSearchFormRef = () => searchFormRef.value;
|
||||
|
||||
return {
|
||||
...editableMethods,
|
||||
...expandMethods,
|
||||
setProps,
|
||||
handleSubmit,
|
||||
handleTableChange,
|
||||
getColumnKey,
|
||||
fetchData,
|
||||
getSearchFormRef,
|
||||
reload,
|
||||
onInfiniteScroll,
|
||||
handleEditFormValidate,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { computed, reactive, ref, unref, watch, useSlots } from 'vue';
|
||||
import { omit } from 'lodash-es';
|
||||
import tableConfig from '../dynamic-table.config';
|
||||
import { useScroll } from './useScroll';
|
||||
import type { DynamicTableProps } from '../dynamic-table';
|
||||
import type { TableProps, Table } from 'ant-design-vue';
|
||||
import type { SchemaForm } from '@/components/core/schema-form';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
export type Pagination = TableProps['pagination'];
|
||||
|
||||
export type TableState = ReturnType<typeof useTableState>;
|
||||
|
||||
interface SearchState {
|
||||
sortInfo: Recordable;
|
||||
filterInfo: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export const useTableState = (props: DynamicTableProps) => {
|
||||
const { t } = useI18n();
|
||||
const slots = useSlots();
|
||||
/** 表格实例 */
|
||||
const tableRef = ref<InstanceType<typeof Table>>();
|
||||
/** 查询表单实例 */
|
||||
const searchFormRef = ref<InstanceType<typeof SchemaForm>>();
|
||||
/** 编辑表格的表单实例 */
|
||||
const editTableFormRef = ref<InstanceType<typeof SchemaForm>>();
|
||||
/** 表格数据 */
|
||||
const tableData = ref<any[]>([]);
|
||||
/** 内部属性 */
|
||||
const innerPropsRef = ref<Partial<DynamicTableProps>>({ ...props });
|
||||
/** 分页配置参数 */
|
||||
const paginationRef = ref<NonNullable<Pagination>>(false);
|
||||
/** 表格加载 */
|
||||
const loadingRef = ref<boolean>(!!props.loading);
|
||||
/** 表格是否全屏 */
|
||||
const isFullscreen = ref(false);
|
||||
/** 动态表格 div 容器 */
|
||||
const containerElRef = ref<HTMLDivElement | null>(null);
|
||||
/** 编辑表单model */
|
||||
const editFormModel = ref<Recordable>({});
|
||||
/** 所有验证不通过的表单项 */
|
||||
const editFormErrorMsgs = ref(new Map());
|
||||
/** 当前所有正在被编辑的行key的格式为:`${recordKey}` */
|
||||
const editableRowKeys = ref(new Set<Key>());
|
||||
/** 当前所有正在被编辑的单元格key的格式为:`${recordKey}.${dataIndex}`,仅`editableType`为`cell`时有效 */
|
||||
const editableCellKeys = ref(new Set<Key>());
|
||||
/** 表格排序或过滤时的搜索参数 */
|
||||
const searchState = reactive<SearchState>({
|
||||
sortInfo: {},
|
||||
filterInfo: {},
|
||||
});
|
||||
|
||||
const { scroll } = useScroll({ props, containerElRef });
|
||||
|
||||
if (!Object.is(props.pagination, false)) {
|
||||
paginationRef.value = {
|
||||
current: 1,
|
||||
pageSize: tableConfig.defaultPageSize,
|
||||
total: 0,
|
||||
pageSizeOptions: [...tableConfig.pageSizeOptions],
|
||||
showQuickJumper: true,
|
||||
showSizeChanger: true, // 显示可改变每页数量
|
||||
showTotal: (total) => t('component.table.total', { total }), // 显示总数
|
||||
// onChange: (current, pageSize) => pageOption?.pageChange?.(current, pageSize),
|
||||
// onShowSizeChange: (current, pageSize) => pageOption?.pageChange?.(current, pageSize),
|
||||
...props.pagination,
|
||||
};
|
||||
}
|
||||
const getBindValues = computed(() => {
|
||||
const props = unref(innerPropsRef);
|
||||
|
||||
let propsData: Recordable = {
|
||||
...props,
|
||||
scroll: { ...unref(scroll), ...props.scroll },
|
||||
pagination: props.pagination ?? unref(paginationRef),
|
||||
rowKey: props.rowKey ?? 'id',
|
||||
loading: props.loading ?? unref(loadingRef),
|
||||
tableLayout: props.tableLayout ?? 'fixed',
|
||||
};
|
||||
if (slots.expandedRowRender) {
|
||||
propsData = omit(propsData, 'scroll');
|
||||
}
|
||||
|
||||
propsData = omit(propsData, ['class', 'onChange', 'columns']);
|
||||
return propsData;
|
||||
});
|
||||
|
||||
// 如果外界设置了dataSource,那就直接用外界提供的数据
|
||||
watch(
|
||||
() => props.dataSource,
|
||||
(val) => {
|
||||
if (val) {
|
||||
tableData.value = val;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.columns,
|
||||
(val) => {
|
||||
if (val) {
|
||||
Object.assign(innerPropsRef.value, { columns: val });
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tableRef,
|
||||
editTableFormRef,
|
||||
loadingRef,
|
||||
isFullscreen,
|
||||
containerElRef,
|
||||
tableData,
|
||||
searchFormRef,
|
||||
innerPropsRef,
|
||||
getBindValues,
|
||||
paginationRef,
|
||||
editFormModel,
|
||||
editFormErrorMsgs,
|
||||
editableCellKeys,
|
||||
editableRowKeys,
|
||||
searchState,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table';
|
||||
import type { FormSchema, GetFieldKeys } from '@/components/core/schema-form';
|
||||
import type { ActionItem } from './tableAction';
|
||||
import type { TableActionType } from '@/components/core/dynamic-table/src/types';
|
||||
import type { DataIndex } from 'ant-design-vue/es/vc-table/interface';
|
||||
|
||||
export type ColumnType<T> = ColumnsType<T>[number];
|
||||
|
||||
export type CustomRenderParams<T extends object = Recordable> = Omit<
|
||||
Parameters<NonNullable<ColumnType<T>['customRender']>>[number],
|
||||
'column'
|
||||
> & { column: TableColumn<T> };
|
||||
|
||||
// export type EditableConfig<T = any> = {
|
||||
// /** 可编辑表格的类型,`单行编辑` | `多行编辑` | `可编辑单元格` */
|
||||
// type: 'single' | 'multiple' | 'cell';
|
||||
// /** 传递给 Form.Item 的配置,可以配置 rules */
|
||||
// formProps?: Partial<FormSchema<T>>;
|
||||
// /** 行保存的时候 */
|
||||
// onSave?: (
|
||||
// /** 行 id,一般是唯一id */
|
||||
// key: Key,
|
||||
// /** 当前修改的行的值,只有 form 在内的会被设置 */
|
||||
// record: T,
|
||||
// /** 原始值,可以用于判断是否修改 */
|
||||
// originRow: T,
|
||||
// ) => Promise<any | void>;
|
||||
// };
|
||||
|
||||
/**
|
||||
* 表格属性
|
||||
*/
|
||||
export type TableColumn<T extends object = Recordable> = ColumnType<T> & {
|
||||
dataIndex?: GetFieldKeys<T> | ColumnKeyFlagType | Omit<DataIndex, string>;
|
||||
/** 指定搜索的字段 */
|
||||
searchField?: string;
|
||||
/** 在查询表单中不展示此项 */
|
||||
hideInSearch?: boolean;
|
||||
/** 在 Table 中不展示此列 */
|
||||
hideInTable?: boolean;
|
||||
/** 传递给搜索表单 Form.Item 的配置,可以配置 rules */
|
||||
formItemProps?: Partial<FormSchema<T>>;
|
||||
/** 传递给可编辑表格 Form.Item 的配置,可以配置 rules */
|
||||
editFormItemProps?: Partial<FormSchema<T>> & {
|
||||
/**
|
||||
* 是否继承于搜索表单`TableColumn.formItemProps`的所有属性,为深拷贝合并
|
||||
* 值为`true`时的行为伪代码如下:
|
||||
* ```js
|
||||
* Object.assign({}, TableColumn.formItemProps, TableColumn.editFormItemProps)
|
||||
* ```
|
||||
* @defaultValue 默认值为`true`
|
||||
* */
|
||||
extendSearchFormProps?: boolean;
|
||||
};
|
||||
/** 操作列,一般用于对表格某一行数据进行操作 */
|
||||
actions?: (params: CustomRenderParams<T>, action: TableActionType) => ActionItem[];
|
||||
/** 当前单元格是否允许被编辑 */
|
||||
editable?: boolean | ((params: CustomRenderParams<T>) => boolean);
|
||||
/** 当前单元格是否默认开启编辑,仅 `editableType`为`cell`时有效 */
|
||||
defaultEditable?: boolean;
|
||||
};
|
||||
|
||||
export enum ColumnKeyFlag {
|
||||
ACTION = 'ACTION',
|
||||
INDEX = 'INDEX',
|
||||
}
|
||||
|
||||
export const columnKeyFlags = Object.values(ColumnKeyFlag) as string[];
|
||||
export type ColumnKeyFlagType = `${ColumnKeyFlag}`;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './table';
|
||||
export * from './column';
|
||||
export * from './tableAction';
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { TableProps } from 'ant-design-vue';
|
||||
import type { TablePaginationConfig } from 'ant-design-vue/es/table';
|
||||
|
||||
/**
|
||||
* 加载表格数据的参数
|
||||
*/
|
||||
export type LoadDataParams = TablePaginationConfig & {
|
||||
/** 根据自己业务需求定义页码 */
|
||||
page?: number;
|
||||
/** 根据自己业务需求定义页数据条数 */
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/** 表格onChange事件回调参数 */
|
||||
export type OnChangeCallbackParams = Parameters<NonNullable<TableProps['onChange']>>;
|
||||
|
||||
/** 表格onChange事件回调函数 */
|
||||
export type OnChangeCallback = TableProps['onChange'];
|
||||
|
||||
/** 编辑行类型 */
|
||||
export type EditableType = 'single' | 'multiple' | 'cell';
|
||||
|
||||
/** 单元格保存回调 */
|
||||
export type OnSave<T = any> = (
|
||||
/** 行 id,一般是唯一id */
|
||||
key: Key,
|
||||
/** 当前修改的行的值,只有 form 在内的会被设置 */
|
||||
record: T,
|
||||
/** 原始值,可以用于判断是否修改 */
|
||||
originRow: T,
|
||||
) => Promise<any | void>;
|
||||
|
||||
/** 单元格取消保存回调 */
|
||||
export type OnCancel<T = any> = (
|
||||
/** 行 id,一般是唯一id */
|
||||
key: Key,
|
||||
/** 当前修改的行的值,只有 form 在内的会被设置 */
|
||||
record: T,
|
||||
/** 原始值,可以用于判断是否修改 */
|
||||
originRow: T,
|
||||
) => any | void;
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Ref } from 'vue';
|
||||
import type { CustomRenderParams } from './column';
|
||||
import type { PopconfirmProps } from 'ant-design-vue/es/popconfirm';
|
||||
import type { ButtonProps, TooltipProps } from 'ant-design-vue/es/components';
|
||||
import type { TableMethods, UseEditableType } from '../hooks/';
|
||||
import type { PermissionType } from '@/permission/permCode';
|
||||
import type { ButtonType } from '@/components/basic/button';
|
||||
|
||||
export type ActionItem = Omit<ButtonProps, 'onClick' | 'loading' | 'type'> & {
|
||||
onClick?: Fn<CustomRenderParams, any>;
|
||||
label?: string;
|
||||
color?: string;
|
||||
type?: ButtonType;
|
||||
loading?: Ref<ButtonProps['loading']> | ButtonProps['loading'];
|
||||
icon?: string;
|
||||
popConfirm?: PopConfirm;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
// 权限编码控制是否显示
|
||||
// auth?: RoleEnum | RoleEnum[] | string | string[];
|
||||
// 业务控制是否显示
|
||||
ifShow?: boolean | ((action: ActionItem) => boolean);
|
||||
tooltip?: string | TooltipProps;
|
||||
/** 设置按钮权限, effect不传默认为disable */
|
||||
auth?:
|
||||
| PermissionType
|
||||
| {
|
||||
perm: PermissionType;
|
||||
/** 无权限时,按钮不可见或是处于禁用状态 */
|
||||
effect?: 'delete' | 'disable';
|
||||
};
|
||||
};
|
||||
|
||||
export type PopConfirm = PopconfirmProps & {
|
||||
title: string;
|
||||
okText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: Fn<CustomRenderParams, any>;
|
||||
onCancel?: Fn<CustomRenderParams, any>;
|
||||
icon?: string;
|
||||
placement?:
|
||||
| 'top'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'bottom'
|
||||
| 'topLeft'
|
||||
| 'topRight'
|
||||
| 'leftTop'
|
||||
| 'leftBottom'
|
||||
| 'rightTop'
|
||||
| 'rightBottom'
|
||||
| 'bottomLeft'
|
||||
| 'bottomRight';
|
||||
};
|
||||
|
||||
export type TableActionType = {
|
||||
/** 刷新并清空,页码也会重置,不包括搜索表单 */
|
||||
reload: TableMethods['reload'];
|
||||
/** 设置动态表格属性 */
|
||||
setProps: TableMethods['setProps'];
|
||||
/** 获取远程数据 */
|
||||
fetchData: TableMethods['fetchData'];
|
||||
/** 进入编辑状态 */
|
||||
startEditable: UseEditableType['startEditable'];
|
||||
/** 取消编辑 */
|
||||
cancelEditable: UseEditableType['cancelEditable'];
|
||||
/** 获取编辑后表单的值 */
|
||||
getEditFormModel: UseEditableType['getEditFormModel'];
|
||||
/** 当前行是否处于编辑状态 */
|
||||
isEditable: UseEditableType['isEditable'];
|
||||
/** 行编辑表单是否校验通过 */
|
||||
validateRow: UseEditableType['validateRow'];
|
||||
};
|
||||
8
apps/platform/src/components/core/schema-form/index.ts
Normal file
8
apps/platform/src/components/core/schema-form/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as SchemaFormItem } from './src/schema-form-item.vue';
|
||||
export { default as SchemaForm } from './src/schema-form.vue';
|
||||
|
||||
export * from './src/types/';
|
||||
export * from './src/schema-form';
|
||||
export * from './src/schema-form-item';
|
||||
export * from './src/hooks/';
|
||||
export * from './src/components/';
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Component list, register here to setting it in the form
|
||||
*/
|
||||
import {
|
||||
Input,
|
||||
Select,
|
||||
Radio,
|
||||
Checkbox,
|
||||
AutoComplete,
|
||||
Cascader,
|
||||
DatePicker,
|
||||
InputNumber,
|
||||
Switch,
|
||||
TimePicker,
|
||||
TreeSelect,
|
||||
Tree,
|
||||
Slider,
|
||||
Rate,
|
||||
Divider,
|
||||
Upload,
|
||||
} from 'ant-design-vue';
|
||||
import type { Component, VNodeProps } from 'vue';
|
||||
|
||||
const componentMap = {
|
||||
Input,
|
||||
InputGroup: Input.Group,
|
||||
InputPassword: Input.Password,
|
||||
InputSearch: Input.Search,
|
||||
InputTextArea: Input.TextArea,
|
||||
InputNumber,
|
||||
AutoComplete,
|
||||
Select,
|
||||
TreeSelect,
|
||||
Tree,
|
||||
Switch,
|
||||
RadioGroup: Radio.Group,
|
||||
Checkbox,
|
||||
CheckboxGroup: Checkbox.Group,
|
||||
Cascader,
|
||||
Slider,
|
||||
Rate,
|
||||
DatePicker,
|
||||
MonthPicker: DatePicker.MonthPicker,
|
||||
RangePicker: DatePicker.RangePicker,
|
||||
WeekPicker: DatePicker.WeekPicker,
|
||||
TimePicker,
|
||||
Upload,
|
||||
|
||||
Divider,
|
||||
};
|
||||
|
||||
type ExtractPropTypes<T extends Component> = T extends new (...args: any) => any
|
||||
? Writable<Omit<InstanceType<T>['$props'], keyof VNodeProps>>
|
||||
: never;
|
||||
|
||||
type ComponentMapType = typeof componentMap;
|
||||
|
||||
export type ComponentType = keyof ComponentMapType;
|
||||
|
||||
export type ComponentMapProps = {
|
||||
[K in ComponentType]: ExtractPropTypes<ComponentMapType[K]>;
|
||||
};
|
||||
|
||||
export type AllComponentProps = ComponentMapProps[ComponentType];
|
||||
|
||||
export { componentMap };
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<Select
|
||||
v-bind="getProps"
|
||||
:options="getOptions"
|
||||
@dropdown-visible-change="handleFetch"
|
||||
@change="handleChange"
|
||||
>
|
||||
<template v-for="item in Object.keys($slots)" #[item]="data">
|
||||
<slot :name="item" v-bind="data || {}" />
|
||||
</template>
|
||||
<template v-if="loading" #suffixIcon>
|
||||
<LoadingOutlined spin />
|
||||
</template>
|
||||
<template v-if="loading" #notFoundContent>
|
||||
<span>
|
||||
<LoadingOutlined spin class="mr-1" />
|
||||
{{ t('component.form.apiSelectNotFound') }}
|
||||
</span>
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, watchEffect, computed, unref, watch } from 'vue';
|
||||
import { get, omit } from 'lodash-es';
|
||||
import { LoadingOutlined } from '@ant-design/icons-vue';
|
||||
import { selectProps } from 'ant-design-vue/es/select';
|
||||
import { Select } from 'ant-design-vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { isFunction } from '@/utils/is';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { propTypes } from '@/utils/propTypes';
|
||||
|
||||
type OptionsItem = { label: string; value: string; disabled?: boolean };
|
||||
|
||||
defineOptions({
|
||||
name: 'ApiSelect',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
...selectProps(),
|
||||
value: [Array, Object, String, Number],
|
||||
numberToString: propTypes.bool,
|
||||
api: {
|
||||
type: Function as PropType<(arg?: Recordable) => Promise<any>>,
|
||||
default: null,
|
||||
},
|
||||
// api params
|
||||
params: {
|
||||
type: Object as PropType<Recordable>,
|
||||
default: () => ({}),
|
||||
},
|
||||
// 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),
|
||||
});
|
||||
|
||||
const emit = defineEmits(['options-change', 'change']);
|
||||
|
||||
const options = ref<OptionsItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const isFirstLoad = ref(true);
|
||||
const emitData = ref<any[]>([]);
|
||||
const { t } = useI18n();
|
||||
|
||||
const getProps = computed(() => props as Recordable);
|
||||
|
||||
// Embedded in the form, just use the hook binding to perform form verification
|
||||
|
||||
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(
|
||||
() => 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;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<Col v-if="showActionButtonGroup" v-bind="actionColOpt">
|
||||
<div style="width: 100%" :style="{ textAlign: actionColOpt.style.textAlign }">
|
||||
<Form.Item>
|
||||
<slot name="resetBefore" />
|
||||
<a-button
|
||||
v-if="showResetButton"
|
||||
type="default"
|
||||
class="mr-2"
|
||||
v-bind="getResetBtnOptions"
|
||||
@click="resetFields"
|
||||
>
|
||||
{{ getResetBtnOptions.text }}
|
||||
</a-button>
|
||||
<slot name="submitBefore" />
|
||||
|
||||
<a-button
|
||||
v-if="showSubmitButton"
|
||||
type="primary"
|
||||
class="mr-2"
|
||||
v-bind="getSubmitBtnOptions"
|
||||
@click="handleSubmit($event)"
|
||||
>
|
||||
{{ getSubmitBtnOptions.text }}
|
||||
</a-button>
|
||||
|
||||
<slot name="advanceBefore" />
|
||||
<a-button
|
||||
v-if="showAdvancedButton && !hideAdvanceBtn"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="toggleAdvanced"
|
||||
>
|
||||
{{ isAdvanced ? t('component.form.putAway') : t('component.form.unfold') }}
|
||||
<BasicArrow class="ml-1" :expand="!isAdvanced" />
|
||||
</a-button>
|
||||
<slot name="advanceAfter" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Col>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, type PropType } from 'vue';
|
||||
import { Form, Col } from 'ant-design-vue';
|
||||
import { useFormContext } from '../hooks/useFormContext';
|
||||
import type { ColEx } from '../types/component';
|
||||
import type { ButtonProps } from '@/components/basic/button';
|
||||
import { BasicArrow } from '@/components/basic/basic-arrow';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
type ButtonOptions = Partial<ButtonProps> & { text: string };
|
||||
|
||||
defineOptions({
|
||||
name: 'FormAction',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle-advanced']);
|
||||
|
||||
const props = defineProps({
|
||||
showActionButtonGroup: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showResetButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSubmitButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showAdvancedButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
resetButtonOptions: {
|
||||
type: Object as PropType<ButtonOptions>,
|
||||
default: () => ({}),
|
||||
},
|
||||
submitButtonOptions: {
|
||||
type: Object as PropType<ButtonOptions>,
|
||||
default: () => ({}),
|
||||
},
|
||||
actionColOptions: {
|
||||
type: Object as PropType<Partial<ColEx>>,
|
||||
default: () => ({}),
|
||||
},
|
||||
actionSpan: {
|
||||
type: Number,
|
||||
default: 6,
|
||||
},
|
||||
isAdvanced: Boolean,
|
||||
hideAdvanceBtn: Boolean,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { resetFields, submit } = useFormContext();
|
||||
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', props.isAdvanced);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
await submit(e).catch(() => {});
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ApiSelect } from './ApiSelect.vue';
|
||||
70
apps/platform/src/components/core/schema-form/src/helper.ts
Normal file
70
apps/platform/src/components/core/schema-form/src/helper.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import dayjs from 'dayjs';
|
||||
import type { RuleObject } from 'ant-design-vue/es/form/';
|
||||
import type { ComponentType } from './types/component';
|
||||
import { isNumber } from '@/utils/is';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
/**
|
||||
* @description: 生成placeholder
|
||||
*/
|
||||
export function createPlaceholderMessage(component: ComponentType, label = '') {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (component.includes('Input') || component.includes('Complete')) {
|
||||
return `${t('common.inputText')}${label}`;
|
||||
}
|
||||
const chooseTypes: ComponentType[] = [
|
||||
'Select',
|
||||
'Cascader',
|
||||
'Checkbox',
|
||||
'CheckboxGroup',
|
||||
'Switch',
|
||||
'TreeSelect',
|
||||
];
|
||||
if (component.includes('Picker') || chooseTypes.includes(component)) {
|
||||
return `${t('common.chooseText')}${label}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const DATE_TYPE = ['DatePicker', 'MonthPicker', 'WeekPicker', 'TimePicker'];
|
||||
|
||||
function genType() {
|
||||
return [...DATE_TYPE, 'RangePicker'];
|
||||
}
|
||||
|
||||
export function setComponentRuleType(
|
||||
rule: RuleObject,
|
||||
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) ? dayjs(value).format(valueFormat) : value
|
||||
} else if (DATE_TYPE.includes(component) && value) {
|
||||
attr.value = dayjs(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();
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './useForm';
|
||||
export * from './useFormState';
|
||||
export * from './useFormContext';
|
||||
export * from './useFormMethods';
|
||||
export * from './useLabelWidth';
|
||||
export * from './useAdvanced';
|
||||
@@ -0,0 +1,159 @@
|
||||
import { computed, unref, watch } from 'vue';
|
||||
import { useFormContext } from './useFormContext';
|
||||
import type { ColEx } from '../types/component';
|
||||
import type { FormState } from './useFormState';
|
||||
import type { SchemaFormEmitFn } from '../schema-form';
|
||||
import { isBoolean, isFunction, isNumber, isObject } from '@/utils/is';
|
||||
import { useBreakpoint } from '@/hooks/event/useBreakpoint';
|
||||
|
||||
const BASIC_COL_LEN = 24;
|
||||
|
||||
interface UseAdvancedPayload {
|
||||
formState: FormState;
|
||||
emit: SchemaFormEmitFn;
|
||||
}
|
||||
|
||||
export const useAdvanced = (payload: UseAdvancedPayload) => {
|
||||
const { formState, emit } = payload;
|
||||
const formContext = useFormContext();
|
||||
const { realWidthRef, screenEnum, screenRef } = useBreakpoint();
|
||||
const { advanceState, getFormProps, formPropsRef, formModel, defaultFormValues } = formState;
|
||||
|
||||
const getEmptySpan = computed((): number => {
|
||||
if (!advanceState.isAdvanced) {
|
||||
return 0;
|
||||
}
|
||||
// For some special cases, you need to manually specify additional blank lines
|
||||
const emptySpan = unref(getFormProps).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;
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => formPropsRef.value.schemas, () => advanceState.isAdvanced, () => unref(realWidthRef)],
|
||||
() => {
|
||||
const { showAdvancedButton } = unref(getFormProps);
|
||||
if (showAdvancedButton) {
|
||||
updateAdvanced();
|
||||
}
|
||||
},
|
||||
{ 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(getFormProps).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(getFormProps).alwaysShowLines || 1)) {
|
||||
return { isAdvanced: advanceState.isAdvanced, itemColSum };
|
||||
} else {
|
||||
// The first line is always displayed
|
||||
return { isAdvanced: true, itemColSum };
|
||||
}
|
||||
}
|
||||
|
||||
function updateAdvanced() {
|
||||
let itemColSum = 0;
|
||||
let realItemColSum = 0;
|
||||
const { baseColProps = {} } = unref(getFormProps);
|
||||
|
||||
for (const schema of unref(formPropsRef).schemas) {
|
||||
const { vShow, colProps } = schema;
|
||||
let isShow = true;
|
||||
|
||||
if (isBoolean(vShow)) {
|
||||
isShow = vShow;
|
||||
}
|
||||
|
||||
if (isFunction(vShow)) {
|
||||
isShow = vShow({
|
||||
schema: computed(() => {
|
||||
// @ts-ignore
|
||||
return unref(formSchemasRef).schemas.find((n) => n.field === schema.field) as any;
|
||||
}),
|
||||
formModel,
|
||||
field: schema.field,
|
||||
formInstance: formContext,
|
||||
values: {
|
||||
...unref(defaultFormValues),
|
||||
...formModel,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isShow && (colProps || baseColProps)) {
|
||||
const { itemColSum: sum, isAdvanced } = getAdvanced(
|
||||
{ ...baseColProps, ...colProps },
|
||||
itemColSum,
|
||||
);
|
||||
|
||||
itemColSum = sum || 0;
|
||||
if (isAdvanced) {
|
||||
realItemColSum = itemColSum;
|
||||
}
|
||||
|
||||
schema.isAdvanced = isAdvanced;
|
||||
}
|
||||
}
|
||||
|
||||
advanceState.actionSpan = (realItemColSum % BASIC_COL_LEN) + unref(getEmptySpan);
|
||||
|
||||
getAdvanced(unref(getFormProps).actionColOptions || { span: BASIC_COL_LEN }, itemColSum, true);
|
||||
|
||||
emit('advanced-change');
|
||||
}
|
||||
|
||||
function handleToggleAdvanced() {
|
||||
advanceState.isAdvanced = !advanceState.isAdvanced;
|
||||
}
|
||||
|
||||
return { handleToggleAdvanced };
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { nextTick, ref, unref, watch } from 'vue';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import SchemaForm from '../schema-form.vue';
|
||||
import type { FunctionalComponent, Ref } from 'vue';
|
||||
import type { SchemaFormProps } from '../schema-form';
|
||||
|
||||
type SchemaFormInstance = InstanceType<typeof SchemaForm>;
|
||||
|
||||
export function useForm(props?: Partial<SchemaFormProps>) {
|
||||
const formRef = ref<SchemaFormInstance>({} as SchemaFormInstance);
|
||||
|
||||
async function getFormInstance() {
|
||||
await nextTick();
|
||||
const form = unref(formRef);
|
||||
if (isEmpty(form)) {
|
||||
console.error('未获取表单实例!');
|
||||
}
|
||||
return form;
|
||||
}
|
||||
watch(
|
||||
() => props,
|
||||
async () => {
|
||||
if (props) {
|
||||
await nextTick();
|
||||
const formInstance = await getFormInstance();
|
||||
formInstance.setSchemaFormProps?.(props);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
flush: 'post',
|
||||
},
|
||||
);
|
||||
|
||||
const methods = new Proxy<Ref<SchemaFormInstance>>(formRef, {
|
||||
get(target, key: string) {
|
||||
if (Reflect.has(target, key)) {
|
||||
return unref(target);
|
||||
}
|
||||
if (target.value && Reflect.has(target.value, key)) {
|
||||
return Reflect.get(target.value, key);
|
||||
}
|
||||
return async (...rest) => {
|
||||
const form = await getFormInstance();
|
||||
return form?.[key]?.(...rest);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const SchemaFormRender: FunctionalComponent<SchemaFormProps> = (compProps, { attrs, slots }) => {
|
||||
return (
|
||||
<SchemaForm
|
||||
ref={formRef}
|
||||
{...{ ...attrs, ...props, ...compProps }}
|
||||
v-slots={slots}
|
||||
></SchemaForm>
|
||||
);
|
||||
};
|
||||
|
||||
return [SchemaFormRender, unref(methods)] as const;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { injectLocal, provideLocal } from '@vueuse/core';
|
||||
import type { FormMethods } from './useFormMethods';
|
||||
import type { FormState } from './useFormState';
|
||||
|
||||
export interface SchemaFormInstance extends FormMethods, FormState {}
|
||||
|
||||
const key = Symbol('schema-form');
|
||||
|
||||
export async function createFormContext(instance: SchemaFormInstance) {
|
||||
provideLocal(key, instance);
|
||||
}
|
||||
|
||||
export function useFormContext(formProps = {}) {
|
||||
return injectLocal(key, formProps) as SchemaFormInstance;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { toRaw, unref } from 'vue';
|
||||
import { set, unset, isNil, uniqBy } from 'lodash-es';
|
||||
import dayjs from 'dayjs';
|
||||
import { dateItemType, handleInputNumberValue } from '../helper';
|
||||
import type { SchemaFormEmitFn, SchemaFormProps } from '../schema-form';
|
||||
import type { FormSchema } from '../types/form';
|
||||
import type { NamePath } from 'ant-design-vue/lib/form/interface';
|
||||
import type { FormState } from './useFormState';
|
||||
import { deepMerge } from '@/utils/';
|
||||
import { isFunction, isObject, isArray, isString } from '@/utils/is';
|
||||
import { dateUtil } from '@/utils/dateUtil';
|
||||
|
||||
interface UseFormMethodsPayload {
|
||||
formState: FormState;
|
||||
emit: SchemaFormEmitFn;
|
||||
}
|
||||
|
||||
export type FormMethods = ReturnType<typeof useFormMethods>;
|
||||
|
||||
export const useFormMethods = (payload: UseFormMethodsPayload) => {
|
||||
const { formState, emit } = payload;
|
||||
const {
|
||||
compRefMap,
|
||||
formModel,
|
||||
formPropsRef,
|
||||
cacheFormModel,
|
||||
schemaFormRef,
|
||||
getFormProps,
|
||||
defaultFormValues,
|
||||
originComponentPropsFnMap,
|
||||
} = formState;
|
||||
|
||||
// 将所有的表单组件实例保存起来, 方便外面通过表单组件实例操作
|
||||
const setItemRef = (field: string) => {
|
||||
return (el) => {
|
||||
if (el) {
|
||||
compRefMap.set(field, el);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
function getFieldsValue(): Recordable {
|
||||
const formEl = unref(schemaFormRef);
|
||||
if (!formEl) return {};
|
||||
return handleFormValues(toRaw(unref(formModel)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: Is it time
|
||||
*/
|
||||
function itemIsDateType(key: string) {
|
||||
// @ts-ignore
|
||||
return unref(formPropsRef).schemas?.some((item) => {
|
||||
return item.field === key && isString(item.component)
|
||||
? dateItemType.includes(item.component)
|
||||
: false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 设置表单字段值
|
||||
*/
|
||||
async function setFieldsValue(values: Recordable) {
|
||||
const schemas = unref(formPropsRef).schemas;
|
||||
const fields = schemas.map((item) => item.field).filter(Boolean);
|
||||
|
||||
Object.assign(cacheFormModel, values);
|
||||
|
||||
const validKeys: string[] = [];
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
const schema = schemas.find((item) => item.field === key);
|
||||
|
||||
const hasKey = Reflect.has(values, key);
|
||||
if (isString(schema?.component)) {
|
||||
value = handleInputNumberValue(schema?.component, value);
|
||||
}
|
||||
// 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 ? dayjs(ele) : null);
|
||||
}
|
||||
set(formModel, key, arr);
|
||||
} else {
|
||||
const { componentProps } = schema || {};
|
||||
let _props = componentProps as any;
|
||||
if (isFunction(componentProps)) {
|
||||
_props = _props({ formPropsRef, formModel });
|
||||
}
|
||||
set(formModel, key, value ? (_props?.valueFormat ? value : dayjs(value)) : null);
|
||||
}
|
||||
} else {
|
||||
set(formModel, key, value);
|
||||
}
|
||||
validKeys.push(key);
|
||||
}
|
||||
});
|
||||
// console.log('formModel', formModel);
|
||||
await validateFields(validKeys).catch((_) => {});
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
unref(formPropsRef).schemas = updateData as FormSchema[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 插入到指定 field 后面,如果没传指定 field,则插入到最后,当 first = true 时插入到第一个位置
|
||||
*/
|
||||
async function appendSchemaByField(schemaItem: FormSchema, prefixField?: string, first = false) {
|
||||
const schemaList = [...unref(formPropsRef).schemas];
|
||||
|
||||
const index = schemaList.findIndex((schema) => schema.field === prefixField);
|
||||
|
||||
if (!prefixField || index === -1 || first) {
|
||||
first ? schemaList.unshift(schemaItem) : schemaList.push(schemaItem);
|
||||
formPropsRef.value.schemas = schemaList;
|
||||
return;
|
||||
}
|
||||
if (index !== -1) {
|
||||
schemaList.splice(index + 1, 0, schemaItem);
|
||||
}
|
||||
formPropsRef.value.schemas = schemaList;
|
||||
setDefaultValue(formPropsRef.value.schemas);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 根据 field 删除 Schema
|
||||
*/
|
||||
async function removeSchemaByField(fields: string | string[]): Promise<void> {
|
||||
const schemaList = [...unref(formPropsRef).schemas];
|
||||
|
||||
if (!fields) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fieldList: string[] = isString(fields) ? [fields] : fields;
|
||||
if (isString(fields)) {
|
||||
fieldList = [fields];
|
||||
}
|
||||
for (const field of fieldList) {
|
||||
if (isString(field)) {
|
||||
const index = schemaList.findIndex((schema) => schema.field === field);
|
||||
if (index !== -1) {
|
||||
unset(formModel, field);
|
||||
schemaList.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formPropsRef.value.schemas = schemaList;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 根据 field 查找 Schema
|
||||
*/
|
||||
function getSchemaByField(field: string): FormSchema | undefined {
|
||||
return unref(formPropsRef).schemas.find((schema) => field === schema.field);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 更新formItemSchema
|
||||
*/
|
||||
const 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) {
|
||||
console.error(
|
||||
'All children of the form Schema array that need to be updated must contain the `field` field',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const schemas: FormSchema[] = [];
|
||||
const updatedSchemas: FormSchema[] = [];
|
||||
|
||||
unref(formPropsRef).schemas.forEach((val) => {
|
||||
const updateItem = updateData.find((n) => val.field === n.field);
|
||||
if (updateItem) {
|
||||
const compProps = updateItem.componentProps;
|
||||
const newSchema = deepMerge(val, updateItem);
|
||||
|
||||
if (originComponentPropsFnMap.has(val.field)) {
|
||||
const originCompPropsFn = originComponentPropsFnMap.get(val.field)!;
|
||||
|
||||
newSchema.componentProps = (opt) => {
|
||||
return {
|
||||
...originCompPropsFn(opt),
|
||||
...(isFunction(compProps) ? compProps(opt) : compProps),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
updatedSchemas.push(newSchema);
|
||||
schemas.push(newSchema);
|
||||
} else {
|
||||
schemas.push(val);
|
||||
}
|
||||
});
|
||||
|
||||
setDefaultValue(updatedSchemas);
|
||||
formPropsRef.value.schemas = uniqBy<FormSchema>(schemas, '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 &&
|
||||
!isNil(item.defaultValue) &&
|
||||
(!(item.field in currentFieldsValue) || isNil(currentFieldsValue[item.field]))
|
||||
) {
|
||||
obj[item.field] = item.defaultValue;
|
||||
}
|
||||
});
|
||||
setFieldsValue(obj);
|
||||
}
|
||||
|
||||
async function resetFields(): Promise<void> {
|
||||
const { resetFunc, submitOnReset } = unref(getFormProps);
|
||||
|
||||
if (isFunction(resetFunc)) {
|
||||
await resetFunc();
|
||||
}
|
||||
|
||||
Object.keys(formModel).forEach((key) => {
|
||||
set(formModel, key, defaultFormValues[key]);
|
||||
});
|
||||
|
||||
emit('reset', formModel);
|
||||
submitOnReset && handleSubmit();
|
||||
setTimeout(clearValidate);
|
||||
}
|
||||
|
||||
async function validateFields(nameList?: NamePath[] | undefined) {
|
||||
return schemaFormRef.value?.validateFields(nameList);
|
||||
}
|
||||
|
||||
async function validate(nameList?: NamePath[] | undefined) {
|
||||
return await schemaFormRef.value!.validate(nameList);
|
||||
}
|
||||
|
||||
async function clearValidate(name?: string | string[]) {
|
||||
await schemaFormRef.value?.clearValidate(name);
|
||||
}
|
||||
|
||||
async function scrollToField(name: NamePath, options?: ScrollOptions | undefined) {
|
||||
await schemaFormRef.value?.scrollToField(name, options);
|
||||
}
|
||||
|
||||
// 设置某个字段的值
|
||||
const setFormModel = (key: Key, value: any) => {
|
||||
set(formModel, key, value);
|
||||
set(cacheFormModel, key, value);
|
||||
const { validateTrigger } = unref(getFormProps);
|
||||
if (!validateTrigger || validateTrigger === 'change') {
|
||||
schemaFormRef.value?.validateFields([key]);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除某个字段
|
||||
const delFormModel = (key: Key) => {
|
||||
return unset(formModel, key);
|
||||
};
|
||||
|
||||
const setSchemaFormProps = (formProps: Partial<SchemaFormProps>) => {
|
||||
const { schemas } = formPropsRef.value;
|
||||
// TODO: deepMerge
|
||||
formPropsRef.value = deepMerge(unref(formPropsRef) || {}, formProps);
|
||||
// @ts-ignore
|
||||
formPropsRef.value.schemas = schemas?.length ? schemas : formProps.schemas;
|
||||
};
|
||||
|
||||
// Processing form values
|
||||
function handleFormValues(values: Recordable) {
|
||||
if (!isObject(values)) {
|
||||
return {};
|
||||
}
|
||||
const res: Recordable = {};
|
||||
for (let [key, value] of Object.entries(values)) {
|
||||
if (!key || (isArray(value) && value.length === 0) || isFunction(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { transformDateFunc } = unref(getFormProps);
|
||||
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)) {
|
||||
value = value.trim();
|
||||
}
|
||||
|
||||
const schemaItem = getSchemaByField(key);
|
||||
|
||||
if (isFunction(schemaItem?.transform)) {
|
||||
value = schemaItem?.transform(value);
|
||||
if (isObject(value)) {
|
||||
Object.assign(res, value);
|
||||
Reflect.deleteProperty(res, key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
set(res, key, value);
|
||||
}
|
||||
return handleRangeTimeValue(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: Processing time interval parameters
|
||||
*/
|
||||
function handleRangeTimeValue(values: Recordable) {
|
||||
const fieldMapToTime = unref(getFormProps).fieldMapToTime;
|
||||
|
||||
if (!fieldMapToTime || !Array.isArray(fieldMapToTime)) {
|
||||
return values;
|
||||
}
|
||||
|
||||
for (const [field, [startTimeKey, endTimeKey], format = 'YYYY-MM-DD'] of fieldMapToTime) {
|
||||
if (!field || !startTimeKey || !endTimeKey || !values[field]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [startTime, endTime]: string[] = values[field];
|
||||
|
||||
values[startTimeKey] = dateUtil(startTime).format(format);
|
||||
values[endTimeKey] = dateUtil(endTime).format(format);
|
||||
Reflect.deleteProperty(values, field);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
const handleEnterPress = (e: KeyboardEvent) => {
|
||||
const { autoSubmitOnEnter } = unref(formPropsRef);
|
||||
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(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function handleSubmit(e?: Event) {
|
||||
e?.preventDefault?.();
|
||||
const { submitFunc } = unref(getFormProps);
|
||||
if (submitFunc && isFunction(submitFunc)) {
|
||||
await submitFunc();
|
||||
return;
|
||||
}
|
||||
const formEl = unref(schemaFormRef);
|
||||
if (!formEl) return;
|
||||
try {
|
||||
const values = await validate();
|
||||
const res = handleFormValues(values);
|
||||
emit('submit', res);
|
||||
return res;
|
||||
} catch (error: any) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
submit: handleSubmit,
|
||||
setItemRef,
|
||||
clearValidate,
|
||||
validate,
|
||||
validateFields,
|
||||
getFieldsValue,
|
||||
updateSchema,
|
||||
resetSchema,
|
||||
getSchemaByField,
|
||||
appendSchemaByField,
|
||||
removeSchemaByField,
|
||||
resetFields,
|
||||
setFieldsValue,
|
||||
scrollToField,
|
||||
setDefaultValue,
|
||||
setFormModel,
|
||||
delFormModel,
|
||||
setSchemaFormProps,
|
||||
handleFormValues,
|
||||
handleEnterPress,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { computed, reactive, ref, unref, watch, useAttrs } from 'vue';
|
||||
import { isUndefined, set } from 'lodash-es';
|
||||
import type { DefineComponent } from 'vue';
|
||||
import type { AdvanceState } from '../types/hooks';
|
||||
import type { SchemaFormProps } from '../schema-form';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import type { ComponentProps, RenderCallbackParams } from '../types';
|
||||
import { isFunction } from '@/utils/is';
|
||||
|
||||
export type FormState = ReturnType<typeof useFormState>;
|
||||
|
||||
export const useFormState = (props: SchemaFormProps) => {
|
||||
const attrs = useAttrs();
|
||||
/** // TODO 将formSchema克隆一份,避免修改原有的formSchema */
|
||||
const formPropsRef = ref<SchemaFormProps>({ ...props });
|
||||
/** 表单项数据 */
|
||||
const formModel = reactive({ ...props.initialValues });
|
||||
/** 表单默认数据 */
|
||||
const defaultFormValues = reactive({ ...props.initialValues });
|
||||
/** 表单实例 */
|
||||
const schemaFormRef = ref<FormInstance>();
|
||||
/** 缓存的表单值,用于恢复form-item v-if为true后的值 */
|
||||
const cacheFormModel = { ...props.initialValues };
|
||||
/** 将所有的表单组件实例保存起来 */
|
||||
const compRefMap = new Map<string, DefineComponent<any>>();
|
||||
/** 初始时的componentProps,用于updateSchema更新时不覆盖componentProps为函数时的值 */
|
||||
const originComponentPropsFnMap = new Map<
|
||||
string,
|
||||
(opt: RenderCallbackParams) => ComponentProps
|
||||
>();
|
||||
|
||||
const advanceState = reactive<AdvanceState>({
|
||||
isAdvanced: true,
|
||||
hideAdvanceBtn: false,
|
||||
isLoad: false,
|
||||
actionSpan: 6,
|
||||
});
|
||||
|
||||
// 获取表单所有属性
|
||||
const getFormProps = computed(() => {
|
||||
return {
|
||||
...attrs,
|
||||
...formPropsRef.value,
|
||||
} as SchemaFormProps;
|
||||
});
|
||||
|
||||
// 获取栅栏Row配置
|
||||
const getRowConfig = computed((): Recordable => {
|
||||
const { baseRowStyle = {}, rowProps } = unref(getFormProps);
|
||||
return {
|
||||
style: baseRowStyle,
|
||||
...rowProps,
|
||||
};
|
||||
});
|
||||
|
||||
const getFormActionBindProps = computed(
|
||||
(): Recordable => ({ ...getFormProps.value, ...advanceState }),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => formPropsRef.value.schemas,
|
||||
() => {
|
||||
formPropsRef.value.schemas?.forEach((item) => {
|
||||
if (!originComponentPropsFnMap.has(item.field) && isFunction(item.componentProps)) {
|
||||
originComponentPropsFnMap.set(item.field, item.componentProps);
|
||||
}
|
||||
if (!isUndefined(item.defaultValue)) {
|
||||
set(defaultFormValues, item.field, item.defaultValue);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
formModel,
|
||||
defaultFormValues,
|
||||
schemaFormRef,
|
||||
formPropsRef,
|
||||
cacheFormModel,
|
||||
compRefMap,
|
||||
getFormProps,
|
||||
advanceState,
|
||||
getRowConfig,
|
||||
getFormActionBindProps,
|
||||
originComponentPropsFnMap,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { computed, unref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import type { FormSchema } from '../types/form';
|
||||
import type { SchemaFormProps } from '../schema-form';
|
||||
import { isNumber } from '@/utils/is';
|
||||
|
||||
export function useItemLabelWidth(schemaItemRef: Ref<FormSchema>, propsRef: Ref<SchemaFormProps>) {
|
||||
return computed(() => {
|
||||
const schemaItem = unref(schemaItemRef);
|
||||
const { labelCol = {}, wrapperCol = {} } = schemaItem.formItemProps || {};
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { TableActionType } from '@/components/core/dynamic-table';
|
||||
import type { FormSchema } from './types';
|
||||
|
||||
export const schemaFormItemProps = {
|
||||
formModel: {
|
||||
type: Object as PropType<Record<string, any>>,
|
||||
default: () => ({}),
|
||||
},
|
||||
schema: {
|
||||
type: Object as PropType<FormSchema>,
|
||||
default: () => ({}),
|
||||
},
|
||||
// 动态表格实例
|
||||
tableInstance: {
|
||||
type: Object as PropType<TableActionType>,
|
||||
},
|
||||
// 动态表格rowKey
|
||||
tableRowKey: {
|
||||
type: [String, Number] as PropType<Key>,
|
||||
},
|
||||
};
|
||||
|
||||
export type SchemaFormItemProps = typeof schemaFormItemProps;
|
||||
@@ -0,0 +1,470 @@
|
||||
<template>
|
||||
<Col v-if="getShow.isIfShow" v-show="getShow.isShow" v-bind="schema.colProps">
|
||||
<Divider v-if="schema.component === 'Divider'" v-bind="Object.assign(getComponentProps)">
|
||||
<component :is="renderLabelHelpMessage" />
|
||||
</Divider>
|
||||
<Form.Item
|
||||
v-else
|
||||
v-bind="{ ...schema.formItemProps, ...itemLabelWidthProp }"
|
||||
:label="renderLabelHelpMessage"
|
||||
:name="namePath"
|
||||
:rules="getRules"
|
||||
>
|
||||
<!-- 前置插槽 -->
|
||||
<template v-if="schema.beforeSlot">
|
||||
<slot v-if="isString(schema.beforeSlot)" :name="schema.beforeSlot" v-bind="getValues">
|
||||
<span class="mr-[6px]">{{ schema.beforeSlot }}</span>
|
||||
</slot>
|
||||
<component :is="schema.beforeSlot(getValues)" v-if="isFunction(schema.beforeSlot)" />
|
||||
</template>
|
||||
<!-- 自定义插槽 -->
|
||||
<slot v-if="schema.slot" :name="schema.slot" v-bind="getValues" />
|
||||
<template v-else-if="getComponent">
|
||||
<component
|
||||
:is="getComponent"
|
||||
:ref="setItemRef(schema.field)"
|
||||
v-bind="getComponentProps"
|
||||
v-model:[modelValueType]="modelValue"
|
||||
:allow-clear="true"
|
||||
:disabled="getDisable"
|
||||
:loading="schema.loading"
|
||||
v-on="componentEvents"
|
||||
>
|
||||
<template v-if="Object.is(schema.loading, true)" #notFoundContent>
|
||||
<Spin size="small" />
|
||||
</template>
|
||||
<template
|
||||
v-for="(slotFn, slotName) in getComponentSlots"
|
||||
#[slotName]="slotData"
|
||||
:key="slotName"
|
||||
>
|
||||
<component :is="slotFn?.({ ...getValues, slotData }) ?? slotFn" :key="slotName" />
|
||||
</template>
|
||||
</component>
|
||||
</template>
|
||||
<!-- 后置插槽 -->
|
||||
<template v-if="schema.afterSlot">
|
||||
<slot v-if="isString(schema.afterSlot)" :name="schema.afterSlot" v-bind="getValues">
|
||||
<span class="ml-[6px]">{{ schema.afterSlot }}</span>
|
||||
</slot>
|
||||
<component :is="schema.afterSlot(getValues)" v-if="isFunction(schema.afterSlot)" />
|
||||
</template>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
import { computed, unref, toRefs, isVNode, watch, nextTick } from 'vue';
|
||||
import { cloneDeep, debounce, isEqual } from 'lodash-es';
|
||||
import { Form, Col, Spin, Divider } from 'ant-design-vue';
|
||||
import { useItemLabelWidth } from './hooks/useLabelWidth';
|
||||
import { componentMap } from './componentMap';
|
||||
import { createPlaceholderMessage } from './helper';
|
||||
import { useFormContext } from './hooks/useFormContext';
|
||||
import { schemaFormItemProps } from './schema-form-item';
|
||||
import type { ComponentType } from './componentMap';
|
||||
import type { CustomRenderFn, FormSchema, RenderCallbackParams, ComponentProps } from './types/';
|
||||
import type { RuleObject } from 'ant-design-vue/es/form/';
|
||||
import { isBoolean, isNull, isObject, isString, isFunction, isArray } from '@/utils/is';
|
||||
import BasicHelp from '@/components/basic/basic-help/index.vue';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
defineOptions({
|
||||
name: 'SchemaFormItem',
|
||||
});
|
||||
|
||||
const props = defineProps(schemaFormItemProps);
|
||||
const emit = defineEmits(['update:formModel']);
|
||||
|
||||
// schemaForm组件实例
|
||||
const formContext = useFormContext();
|
||||
const { formPropsRef, setItemRef, updateSchema, getSchemaByField, appendSchemaByField } =
|
||||
formContext;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { schema } = toRefs(props);
|
||||
|
||||
const itemLabelWidthProp = useItemLabelWidth(schema, formPropsRef);
|
||||
|
||||
const namePath = computed<string[]>(() => {
|
||||
return isArray(schema.value.field) ? schema.value.field : schema.value.field.split('.');
|
||||
});
|
||||
|
||||
const modelValue = computed({
|
||||
get() {
|
||||
return namePath.value.reduce((prev, field) => prev?.[field], props.formModel);
|
||||
},
|
||||
set(val) {
|
||||
const namePath = schema.value.field.split('.');
|
||||
const prop = namePath.pop()!;
|
||||
const target = namePath.reduce((prev, field) => (prev[field] ??= {}), props.formModel);
|
||||
target[prop] = val;
|
||||
emit('update:formModel', props.formModel);
|
||||
},
|
||||
});
|
||||
|
||||
const modelValueType = computed<string>(() => {
|
||||
const { component, componentProps } = schema.value;
|
||||
if (!isFunction(componentProps) && componentProps?.vModelKey) {
|
||||
return componentProps.vModelKey;
|
||||
}
|
||||
const isCheck = isString(component) && ['Switch', 'Checkbox'].includes(component);
|
||||
const isUpload = component === 'Upload';
|
||||
return {
|
||||
true: 'value',
|
||||
[`${isCheck}`]: 'checked',
|
||||
[`${isUpload}`]: 'file-list',
|
||||
}['true'];
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
const getValues = computed<RenderCallbackParams>(() => {
|
||||
const { formModel, schema, tableInstance } = props;
|
||||
|
||||
const { mergeDynamicData } = unref(formPropsRef);
|
||||
return {
|
||||
field: schema.field,
|
||||
formInstance: formContext,
|
||||
tableInstance,
|
||||
tableRowKey: props.tableRowKey,
|
||||
formModel: props.tableRowKey ? formModel[props.tableRowKey] : formModel,
|
||||
values: {
|
||||
...mergeDynamicData,
|
||||
...formModel,
|
||||
} as Recordable,
|
||||
schema: computed(() => props.schema),
|
||||
};
|
||||
});
|
||||
|
||||
const getShow = computed<{ isShow: boolean; isIfShow: boolean }>(() => {
|
||||
const { vShow, vIf, isAdvanced = false } = unref(schema);
|
||||
const { showAdvancedButton } = unref(formPropsRef);
|
||||
const itemIsAdvanced = showAdvancedButton ? (isBoolean(isAdvanced) ? isAdvanced : true) : true;
|
||||
|
||||
let isShow = true;
|
||||
let isIfShow = true;
|
||||
|
||||
if (isBoolean(vShow)) {
|
||||
isShow = vShow;
|
||||
}
|
||||
if (isBoolean(vIf)) {
|
||||
isIfShow = vIf;
|
||||
}
|
||||
if (isFunction(vShow)) {
|
||||
isShow = vShow(unref(getValues));
|
||||
}
|
||||
if (isFunction(vIf)) {
|
||||
isIfShow = vIf(unref(getValues));
|
||||
}
|
||||
isShow = isShow && itemIsAdvanced;
|
||||
|
||||
return { isShow, isIfShow };
|
||||
});
|
||||
|
||||
const getDisable = computed(() => {
|
||||
const { disabled: globDisabled } = unref(formPropsRef);
|
||||
const { dynamicDisabled } = props.schema;
|
||||
const { disabled: itemDisabled = false } = unref(getComponentProps);
|
||||
let disabled = !!globDisabled || itemDisabled;
|
||||
if (isBoolean(dynamicDisabled)) {
|
||||
disabled = dynamicDisabled;
|
||||
}
|
||||
if (isFunction(dynamicDisabled)) {
|
||||
disabled = dynamicDisabled(unref(getValues));
|
||||
}
|
||||
return disabled;
|
||||
});
|
||||
|
||||
const vnodeFactory = (
|
||||
component: FormSchema['componentSlots'] | FormSchema['component'],
|
||||
values = unref(getValues),
|
||||
) => {
|
||||
if (isString(component)) {
|
||||
return <>{component}</>;
|
||||
} else if (isVNode(component)) {
|
||||
return component;
|
||||
} else if (isFunction(component)) {
|
||||
return vnodeFactory((component as CustomRenderFn)(values));
|
||||
} else if (component && isObject(component)) {
|
||||
const compKeys = Object.keys(component);
|
||||
// 如果是组件对象直接return
|
||||
if (compKeys.some((n) => n.startsWith('_') || ['setup', 'render'].includes(n))) {
|
||||
return component;
|
||||
}
|
||||
return compKeys.reduce<Recordable<CustomRenderFn>>((slots, slotName) => {
|
||||
slots[slotName] = (...rest: any) => vnodeFactory(component[slotName], ...rest);
|
||||
return slots;
|
||||
}, {});
|
||||
}
|
||||
return component;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 当前表单项组件
|
||||
*/
|
||||
const getComponent = computed(() => {
|
||||
const component = props.schema.component;
|
||||
return isString(component)
|
||||
? (componentMap[component] ?? vnodeFactory(component))
|
||||
: vnodeFactory(component);
|
||||
});
|
||||
|
||||
/**
|
||||
* @description 当前表单项组件的插槽
|
||||
*/
|
||||
const getComponentSlots = computed<Recordable<CustomRenderFn>>(() => {
|
||||
const componentSlots = props.schema.componentSlots ?? {};
|
||||
return isString(componentSlots) || isVNode(componentSlots)
|
||||
? {
|
||||
default: (...rest: any) => vnodeFactory(componentSlots, rest),
|
||||
}
|
||||
: vnodeFactory(componentSlots);
|
||||
});
|
||||
|
||||
const getLabel = computed(() => {
|
||||
const label = props.schema.label;
|
||||
return isFunction(label) ? label(unref(getValues)) : label;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description 表单组件props
|
||||
*/
|
||||
const getComponentProps = computed(() => {
|
||||
const { schema } = props;
|
||||
let { componentProps = {}, component } = schema;
|
||||
|
||||
if (isFunction(componentProps)) {
|
||||
componentProps = componentProps(unref(getValues)) ?? {};
|
||||
}
|
||||
|
||||
if (component !== 'RangePicker' && isString(component)) {
|
||||
componentProps.placeholder ??= createPlaceholderMessage(component, getLabel.value);
|
||||
}
|
||||
if (schema.component === 'Divider') {
|
||||
componentProps = Object.assign({ type: 'horizontal' }, componentProps, {
|
||||
orientation: 'left',
|
||||
plain: true,
|
||||
});
|
||||
}
|
||||
if (isVNode(getComponent.value)) {
|
||||
Object.assign(componentProps, getComponent.value.props);
|
||||
}
|
||||
|
||||
return componentProps;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description 表单组件事件
|
||||
*/
|
||||
const componentEvents = computed(() => {
|
||||
const componentProps = getComponentProps.value;
|
||||
return Object.keys(componentProps).reduce((prev, key) => {
|
||||
if (/^on([A-Z])/.test(key)) {
|
||||
// e.g: onChange => change
|
||||
const eventKey = key.replace(/^on([A-Z])/, '$1').toLocaleLowerCase();
|
||||
prev[eventKey] = componentProps[key];
|
||||
}
|
||||
return prev;
|
||||
}, {});
|
||||
});
|
||||
|
||||
const renderLabelHelpMessage = computed(() => {
|
||||
const { helpMessage, helpComponentProps, subLabel } = props.schema;
|
||||
const renderLabel = subLabel ? (
|
||||
<span>
|
||||
{getLabel.value} <span class="text-secondary">{subLabel}</span>
|
||||
</span>
|
||||
) : (
|
||||
vnodeFactory(getLabel.value)
|
||||
);
|
||||
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 setComponentRuleType(rule: RuleObject, 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';
|
||||
}
|
||||
}
|
||||
|
||||
const getRules = computed(() => {
|
||||
const {
|
||||
rules: defRules = [],
|
||||
component,
|
||||
rulesMessageJoinLabel,
|
||||
dynamicRules,
|
||||
required,
|
||||
} = props.schema;
|
||||
|
||||
if (isFunction(dynamicRules)) {
|
||||
return dynamicRules(unref(getValues)) as RuleObject[];
|
||||
}
|
||||
|
||||
let rules = cloneDeep<RuleObject[]>(defRules);
|
||||
const { rulesMessageJoinLabel: globalRulesMessageJoinLabel } = unref(formPropsRef);
|
||||
|
||||
const joinLabel = Reflect.has(unref(formPropsRef), 'rulesMessageJoinLabel')
|
||||
? rulesMessageJoinLabel
|
||||
: globalRulesMessageJoinLabel;
|
||||
const defaultMsg = isString(component)
|
||||
? `${createPlaceholderMessage(component, getLabel.value)}${joinLabel ? getLabel.value : ''}`
|
||||
: undefined;
|
||||
|
||||
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;
|
||||
|
||||
if ((!rules || rules.length === 0) && getRequired) {
|
||||
rules = [{ required: getRequired, validator }];
|
||||
}
|
||||
|
||||
const requiredRuleIndex: number = rules.findIndex(
|
||||
(rule) => Reflect.has(rule, 'required') && !Reflect.has(rule, 'validator'),
|
||||
);
|
||||
|
||||
if (requiredRuleIndex !== -1) {
|
||||
const rule = rules[requiredRuleIndex];
|
||||
|
||||
if (component && isString(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(getComponentProps)?.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;
|
||||
});
|
||||
|
||||
const fetchRemoteData = async (request: PromiseFn<RenderCallbackParams, any>) => {
|
||||
try {
|
||||
const newSchema = Object.assign(schema.value, {
|
||||
loading: true,
|
||||
componentProps: {
|
||||
...unref(getComponentProps),
|
||||
options: [],
|
||||
},
|
||||
});
|
||||
|
||||
updateSchema(newSchema);
|
||||
|
||||
const result = await request(unref(getValues));
|
||||
const { component } = unref(schema);
|
||||
const componentProps = newSchema.componentProps as ComponentProps;
|
||||
|
||||
if (['Select', 'RadioGroup', 'CheckboxGroup'].some((n) => n === component)) {
|
||||
componentProps.options = result;
|
||||
} else if (['TreeSelect', 'Tree'].some((n) => n === component)) {
|
||||
componentProps.treeData = result;
|
||||
}
|
||||
if (newSchema.componentProps) {
|
||||
newSchema.componentProps.requestResult = result;
|
||||
}
|
||||
newSchema.loading = false;
|
||||
updateSchema(newSchema);
|
||||
} finally {
|
||||
await nextTick();
|
||||
schema.value.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const initRequestConfig = () => {
|
||||
const request = getComponentProps.value.request;
|
||||
if (request) {
|
||||
if (isFunction(request)) {
|
||||
fetchRemoteData(request);
|
||||
} else {
|
||||
const { watchFields = [], options = {}, wait = 0, callback } = request;
|
||||
const params = watchFields.map((field) => () => props.formModel[field]);
|
||||
watch(
|
||||
params,
|
||||
debounce(() => {
|
||||
fetchRemoteData(callback);
|
||||
}, wait),
|
||||
{
|
||||
...options,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
getShow,
|
||||
(val, oldVal) => {
|
||||
if (!isEqual(val, oldVal) && val.isIfShow && val.isShow) {
|
||||
if (!getSchemaByField(props.schema.field)) {
|
||||
appendSchemaByField(props.schema);
|
||||
}
|
||||
initRequestConfig();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.ant-form-item-control-input-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> div {
|
||||
flex: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
127
apps/platform/src/components/core/schema-form/src/schema-form.ts
Normal file
127
apps/platform/src/components/core/schema-form/src/schema-form.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { formProps, type FormProps } from 'ant-design-vue/es/form';
|
||||
import type { ColEx } from './types/component';
|
||||
import type {
|
||||
ExtractPublicPropTypes,
|
||||
ComponentInternalInstance,
|
||||
CSSProperties,
|
||||
EmitsToProps,
|
||||
EmitFn,
|
||||
} from 'vue';
|
||||
import type { FieldMapToTime, FormSchema, RowProps } from './types/form';
|
||||
import type { ButtonProps } from '@/components/basic/button';
|
||||
import type { TableActionType } from '@/components/core/dynamic-table';
|
||||
import { isObject } from '@/utils/is';
|
||||
|
||||
export const aFormPropKeys = Object.keys(formProps());
|
||||
|
||||
export const schemaFormProps = {
|
||||
...formProps(),
|
||||
layout: {
|
||||
type: String as PropType<FormProps['layout']>,
|
||||
default: 'horizontal',
|
||||
},
|
||||
/** 预置字段默认值 */
|
||||
initialValues: {
|
||||
type: Object as PropType<Recordable>,
|
||||
default: () => ({}),
|
||||
},
|
||||
// 标签宽度 固定宽度
|
||||
labelWidth: {
|
||||
type: [Number, String] as PropType<number | string>,
|
||||
default: 0,
|
||||
},
|
||||
fieldMapToTime: {
|
||||
type: Array as PropType<FieldMapToTime>,
|
||||
default: () => [],
|
||||
},
|
||||
compact: { type: Boolean as PropType<boolean> },
|
||||
/** 表单配置规则 */
|
||||
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: { type: Boolean as PropType<boolean>, default: true },
|
||||
/** 在INPUT组件上单击回车时,是否自动提交 */
|
||||
autoSubmitOnEnter: { type: Boolean as PropType<boolean>, default: false },
|
||||
submitOnReset: { type: Boolean as PropType<boolean> },
|
||||
submitOnChange: { type: Boolean as PropType<boolean> },
|
||||
/** 禁用表单 */
|
||||
disabled: { type: Boolean as PropType<boolean> },
|
||||
emptySpan: {
|
||||
type: [Number, Object] as PropType<number>,
|
||||
default: 0,
|
||||
},
|
||||
/** 是否显示收起展开按钮 */
|
||||
showAdvancedButton: { type: Boolean as PropType<boolean> },
|
||||
/** 转化时间 */
|
||||
transformDateFunc: {
|
||||
type: Function as PropType<Fn>,
|
||||
default: (date: any) => {
|
||||
return date?.format?.('YYYY-MM-DD HH:mm:ss') ?? date;
|
||||
},
|
||||
},
|
||||
rulesMessageJoinLabel: { type: Boolean as PropType<boolean>, default: true },
|
||||
/** 超过3行自动折叠 */
|
||||
autoAdvancedLine: {
|
||||
type: Number as PropType<number>,
|
||||
default: 3,
|
||||
},
|
||||
/** 不受折叠影响的行数 */
|
||||
alwaysShowLines: {
|
||||
type: Number as PropType<number>,
|
||||
default: 1,
|
||||
},
|
||||
|
||||
/** 是否显示操作按钮 */
|
||||
showActionButtonGroup: { type: Boolean as PropType<boolean>, default: true },
|
||||
/** 操作列Col配置 */
|
||||
actionColOptions: Object as PropType<Partial<ColEx>>,
|
||||
/** 显示重置按钮 */
|
||||
showResetButton: { type: Boolean as PropType<boolean>, default: true },
|
||||
/** 是否聚焦第一个输入框,只在第一个表单项为input的时候作用 */
|
||||
autoFocusFirstItem: { type: Boolean as PropType<boolean> },
|
||||
/** 重置按钮配置 */
|
||||
resetButtonOptions: Object as PropType<Partial<ButtonProps>>,
|
||||
|
||||
/** 显示确认按钮 */
|
||||
showSubmitButton: { type: Boolean as PropType<boolean>, default: true },
|
||||
/** 确认按钮配置 */
|
||||
submitButtonOptions: Object as PropType<Partial<ButtonProps>>,
|
||||
|
||||
/** 自定义重置函数 */
|
||||
resetFunc: Function as PropType<() => Promise<void>>,
|
||||
submitFunc: Function as PropType<() => Promise<void>>,
|
||||
/** 动态表格实例 */
|
||||
tableInstance: {
|
||||
type: Object as PropType<TableActionType>,
|
||||
},
|
||||
|
||||
rowProps: Object as PropType<RowProps>,
|
||||
};
|
||||
|
||||
export const schemaFormEmits = {
|
||||
register: (exposed: ComponentInternalInstance['exposed']) => isObject(exposed),
|
||||
reset: (formModel: Recordable<any>) => isObject(formModel),
|
||||
submit: (formModel: Recordable<any>) => isObject(formModel),
|
||||
'advanced-change': () => true,
|
||||
};
|
||||
|
||||
export type SchemaFormEmits = typeof schemaFormEmits;
|
||||
|
||||
export type SchemaFormEmitFn = EmitFn<SchemaFormEmits>;
|
||||
|
||||
export type SchemaFormProps<T extends object = any> = ExtractPublicPropTypes<
|
||||
Omit<typeof schemaFormProps, 'schemas'>
|
||||
> & {
|
||||
schemas: FormSchema<T>[];
|
||||
} & EmitsToProps<SchemaFormEmits>;
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<Form
|
||||
ref="schemaFormRef"
|
||||
v-bind="pick(getFormProps, aFormPropKeys)"
|
||||
:model="formModel"
|
||||
@keypress.enter="handleEnterPress"
|
||||
>
|
||||
<Row v-bind="getRowConfig">
|
||||
<slot name="formHeader" />
|
||||
<slot>
|
||||
<template v-for="schemaItem in formPropsRef.schemas" :key="schemaItem.field">
|
||||
<component
|
||||
:is="h(SchemaFormItem, {}, $slots)"
|
||||
v-model:form-model="formModel"
|
||||
:schema="schemaItem"
|
||||
:table-instance="tableInstance"
|
||||
/>
|
||||
</template>
|
||||
<FormAction
|
||||
v-if="showActionButtonGroup"
|
||||
v-bind="getFormActionBindProps"
|
||||
@toggle-advanced="handleToggleAdvanced"
|
||||
>
|
||||
<template
|
||||
v-for="item in ['resetBefore', 'submitBefore', 'advanceBefore', 'advanceAfter']"
|
||||
#[item]="data"
|
||||
>
|
||||
<slot :name="item" v-bind="data || {}" />
|
||||
</template>
|
||||
</FormAction>
|
||||
</slot>
|
||||
<slot name="formFooter" />
|
||||
</Row>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { h } from 'vue';
|
||||
import { pick } from 'lodash-es';
|
||||
import { Form, Row } from 'ant-design-vue';
|
||||
import SchemaFormItem from './schema-form-item.vue';
|
||||
import FormAction from './components/form-action.vue';
|
||||
import { createFormContext, useFormState, useFormMethods, useAdvanced } from './hooks/';
|
||||
import { schemaFormProps, schemaFormEmits, aFormPropKeys } from './schema-form';
|
||||
|
||||
defineOptions({
|
||||
name: 'SchemaForm',
|
||||
});
|
||||
|
||||
const props = defineProps(schemaFormProps);
|
||||
const emit = defineEmits(schemaFormEmits);
|
||||
|
||||
// 表单内部状态
|
||||
const formState = useFormState(props);
|
||||
const {
|
||||
formModel,
|
||||
getRowConfig,
|
||||
schemaFormRef,
|
||||
getFormProps,
|
||||
getFormActionBindProps,
|
||||
formPropsRef,
|
||||
} = formState;
|
||||
|
||||
// 表单内部方法
|
||||
const formMethods = useFormMethods({ formState, emit });
|
||||
const { handleEnterPress, setDefaultValue } = formMethods;
|
||||
|
||||
/** 当前组件所有的状态和方法 */
|
||||
const schemaFormContext = {
|
||||
props,
|
||||
emit,
|
||||
...formState,
|
||||
...formMethods,
|
||||
};
|
||||
/** 创建表单上下文 */
|
||||
createFormContext(schemaFormContext);
|
||||
|
||||
// 搜索表单 展开/收起 表单项hooks
|
||||
const { handleToggleAdvanced } = useAdvanced({ formState, emit });
|
||||
|
||||
emit('register', schemaFormContext);
|
||||
|
||||
defineExpose(schemaFormContext);
|
||||
|
||||
// 初始化表单默认值
|
||||
setDefaultValue(formPropsRef.value.schemas);
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { CSSProperties, WatchOptions } from 'vue';
|
||||
import type { RenderCallbackParams } from './form';
|
||||
import type { ComponentMapProps, ComponentType } from '../componentMap';
|
||||
|
||||
export type { ComponentType };
|
||||
|
||||
type ColSpanType = number | string;
|
||||
|
||||
/** 组件异步请求配置 */
|
||||
type RequestConfig =
|
||||
| PromiseFn<RenderCallbackParams, any>
|
||||
| {
|
||||
/** 指定监听的字段名, 当该字段值发生变化时会调用 callback */
|
||||
watchFields: string[];
|
||||
callback: PromiseFn<RenderCallbackParams, any>;
|
||||
options?: WatchOptions;
|
||||
/** debounce 请求防抖 */
|
||||
wait?: number;
|
||||
};
|
||||
|
||||
/** 组件属性 */
|
||||
export type ComponentProps<T extends ComponentType = ComponentType> = ComponentMapProps[T] & {
|
||||
/** 组件异步请求数据 */
|
||||
request?: RequestConfig;
|
||||
/** 组件异步请求数据结果 */
|
||||
requestResult?: any;
|
||||
style?: CSSProperties;
|
||||
/** 手动指定v-model绑定的key */
|
||||
vModelKey?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
167
apps/platform/src/components/core/schema-form/src/types/form.ts
Normal file
167
apps/platform/src/components/core/schema-form/src/types/form.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import type { RowProps } from 'ant-design-vue';
|
||||
import type { RuleObject } from 'ant-design-vue/es/form/interface';
|
||||
import type { FormItemProps } from 'ant-design-vue/es/form/FormItem';
|
||||
import type { Component, ComputedRef, VNode } from 'vue';
|
||||
import type { ButtonProps as AntdButtonProps } from '@/components/basic/button';
|
||||
import type { ColEx, ComponentType, ComponentProps } from './component';
|
||||
|
||||
import type { TableActionType } from '@/components/core/dynamic-table';
|
||||
import type { SchemaFormInstance } from '../hooks/useFormContext';
|
||||
|
||||
export type { RowProps };
|
||||
|
||||
export type FieldMapToTime = [string, [string, string], string?][];
|
||||
|
||||
export type Rule = RuleObject & {
|
||||
trigger?: 'blur' | 'change' | ['change', 'blur'];
|
||||
};
|
||||
|
||||
/** 获取所有字段名 */
|
||||
export type GetFieldKeys<T> = Exclude<keyof T, symbol | number>;
|
||||
|
||||
export interface RenderCallbackParams<
|
||||
T extends object = Recordable,
|
||||
P extends ComponentProps = ComponentProps,
|
||||
> {
|
||||
schema: ComputedRef<
|
||||
FormSchema<T> & {
|
||||
componentProps: P;
|
||||
}
|
||||
>;
|
||||
/** 响应式的表单数据对象 */
|
||||
formModel: Objectable<T>;
|
||||
field: GetFieldKeys<T>;
|
||||
/** 非响应式的表单数据对象(最终表单要提交的数据) */
|
||||
values: any;
|
||||
/** 动态表单实例 */
|
||||
formInstance: SchemaFormInstance;
|
||||
/** 动态表格实例 */
|
||||
tableInstance?: TableActionType;
|
||||
/** 动态表格rowKey */
|
||||
tableRowKey?: Key;
|
||||
/** 作用域插槽数据 */
|
||||
slotData?: Recordable;
|
||||
}
|
||||
/** 自定义VNode渲染器 */
|
||||
export type CustomRenderFn<T extends object = Recordable> = (
|
||||
renderCallbackParams: RenderCallbackParams<T>,
|
||||
) => VNode | VNode[] | string;
|
||||
|
||||
export interface ButtonProps extends AntdButtonProps {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
type ComponentSchema<T extends object = Recordable> =
|
||||
| {
|
||||
[K in ComponentType]: {
|
||||
/** 表单项对应的组件,eg: Input */
|
||||
component: K;
|
||||
/** 表单组件属性 */
|
||||
componentProps?:
|
||||
| ComponentProps<K>
|
||||
| ((opt: RenderCallbackParams<T, ComponentProps<K>>) => ComponentProps<K>);
|
||||
};
|
||||
}[ComponentType]
|
||||
| {
|
||||
component: CustomRenderFn<T> | ((opt: RenderCallbackParams<T>) => Component);
|
||||
componentProps?: ComponentProps | ((opt: RenderCallbackParams<T>) => ComponentProps);
|
||||
};
|
||||
|
||||
/** 表单项 */
|
||||
export type FormSchema<T extends object = Recordable> = ComponentSchema<T> & {
|
||||
/** 字段名 */
|
||||
field: GetFieldKeys<T>;
|
||||
// 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 | ((v: RenderCallbackParams<T>) => string);
|
||||
// Auxiliary text
|
||||
subLabel?: string;
|
||||
// Help text on the right side of the text
|
||||
helpMessage?:
|
||||
| string
|
||||
| string[]
|
||||
| ((renderCallbackParams: RenderCallbackParams<T>) => 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;
|
||||
|
||||
/** 表单组件slots,例如 a-input 的 suffix slot 可以写成:{ suffix: () => VNode } */
|
||||
componentSlots?:
|
||||
| ((renderCallbackParams: RenderCallbackParams<T>) => Recordable<CustomRenderFn<T>>)
|
||||
| Recordable<CustomRenderFn<T>>
|
||||
| ReturnType<CustomRenderFn>;
|
||||
// Required
|
||||
required?: boolean | ((renderCallbackParams: RenderCallbackParams<T>) => boolean);
|
||||
|
||||
suffix?: string | number | ((values: RenderCallbackParams<T>) => string | number);
|
||||
|
||||
// Validation rules
|
||||
rules?: Rule[];
|
||||
// Check whether the information is added to the label
|
||||
rulesMessageJoinLabel?: boolean;
|
||||
/** 组件加载状态 */
|
||||
loading?: boolean;
|
||||
|
||||
// Reference formModelItem
|
||||
formItemProps?: Partial<FormItemProps>;
|
||||
|
||||
// col configuration outside formModelItem
|
||||
colProps?: Partial<ColEx>;
|
||||
|
||||
/** 搜索表单项排序 */
|
||||
order?: number;
|
||||
// 默认值
|
||||
defaultValue?: any;
|
||||
isAdvanced?: boolean;
|
||||
|
||||
// Matching details components
|
||||
span?: number;
|
||||
/** 作用同v-show */
|
||||
vShow?: boolean | ((renderCallbackParams: RenderCallbackParams<T>) => any);
|
||||
/** 作用同v-if */
|
||||
vIf?: boolean | ((renderCallbackParams: RenderCallbackParams<T>) => any);
|
||||
/**
|
||||
* 转换表单项的值
|
||||
* @param value 转换前的值
|
||||
* @returns 返回值若是基本类型,则将作为当前表单项的最终值;
|
||||
* 若返回值是对象,则对象的 key 将会覆盖当前表单项定义的 field 字段
|
||||
*/
|
||||
transform?: (value: any) => any;
|
||||
/** 渲染col内容需要外层包装form-item */
|
||||
renderColContent?: CustomRenderFn<T>;
|
||||
|
||||
/** Custom slot, in from-item */
|
||||
slot?: string;
|
||||
/** 表单组件前置插槽 */
|
||||
beforeSlot?: string | ((renderCallbackParams: RenderCallbackParams<T>) => any);
|
||||
/** 表单组件后置插槽 */
|
||||
afterSlot?: string | ((renderCallbackParams: RenderCallbackParams<T>) => any);
|
||||
|
||||
// 自定义槽,类似renderColContent
|
||||
colSlot?: string;
|
||||
|
||||
dynamicDisabled?: boolean | ((renderCallbackParams: RenderCallbackParams<T>) => boolean);
|
||||
|
||||
dynamicRules?: (renderCallbackParams: RenderCallbackParams<T>) => 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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface AdvanceState {
|
||||
isAdvanced: boolean;
|
||||
hideAdvanceBtn: boolean;
|
||||
isLoad: boolean;
|
||||
actionSpan: number;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './form';
|
||||
export * from './component';
|
||||
export * from './hooks';
|
||||
17
apps/platform/src/components/micro-container/index.vue
Normal file
17
apps/platform/src/components/micro-container/index.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
width="100%"
|
||||
style="border: none"
|
||||
height="100%"
|
||||
:src="route.meta?.app?.url"
|
||||
/>
|
||||
<!-- <component :is="WujieVue" v-bind="route.meta?.app" /> -->
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router';
|
||||
import WujieVue from 'wujie-vue3';
|
||||
|
||||
const route = useRoute();
|
||||
</script>
|
||||
Reference in New Issue
Block a user