refactor: 悦码项目重构

This commit is contained in:
wangxuefeng
2025-02-19 13:42:56 +08:00
parent c8c9406fd5
commit eab709f94f
494 changed files with 50986 additions and 27639 deletions

View File

@@ -0,0 +1,6 @@
### 基础组件(目录说明)
| 组件名称 | 描述 | 是否全局组件 | 使用建议 |
| --- | --- | --- | --- |
| button | `按钮组件`基于 a-button 二次封装,主要扩展了按钮的颜色,基本使用方式与 antdv 的 a-button 保持一致 | 是 | -- |
| check-box | `复选框`基于 a-checkbox 二次封装,基本使用方式与 antdv 的 a-checkbox 保持一致 | 否 | -- |

View File

@@ -0,0 +1 @@
export { default as BasicArrow } from './index.vue';

View 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>

View 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>

View 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 };

View 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>

View File

@@ -0,0 +1,9 @@
import AButton from './button.vue';
export default AButton;
export const Button = AButton;
export * from './button';
export { AButton };

View 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>

View File

@@ -0,0 +1,3 @@
export { createContextMenu, destroyContextMenu } from './src/createContextMenu';
export * from './src/typing';

View File

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

View File

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

View File

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

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

View 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 */
}

View File

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

View 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>

View 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;
}

View 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>

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

View 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>

View 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>

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

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

View 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;
};

View File

@@ -0,0 +1,3 @@
import IFramePage from './index.vue';
export default IFramePage;

View 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>

View File

@@ -0,0 +1 @@
export { default as LocalePicker } from './index.vue';

View 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>

View 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>

View File

@@ -0,0 +1,3 @@
import LockScreen from './index.vue';
export { LockScreen };

View 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>

View File

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

View 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>

View File

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

View File

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

View 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>

View File

@@ -0,0 +1,3 @@
import SplitPanel from './index.vue';
export { SplitPanel };

View 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>

View File

@@ -0,0 +1 @@
export { default as TitleI18n } from './index.vue';

View 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>

View File

@@ -0,0 +1,3 @@
### 业务组件(目录说明)
#### 与业务强耦合的组件可以放这里

View 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 的方式配置使用 | 否 | 定制性不高的表单都可以考虑使用 |

View File

@@ -0,0 +1 @@
export { default as DraggableModal } from './index.vue';

View 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>

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export * from './table';
export * from './column';
export * from './tableAction';

View File

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

View File

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

View 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/';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { default as ApiSelect } from './ApiSelect.vue';

View 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();

View File

@@ -0,0 +1,6 @@
export * from './useForm';
export * from './useFormState';
export * from './useFormContext';
export * from './useFormMethods';
export * from './useLabelWidth';
export * from './useAdvanced';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>;

View File

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

View File

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

View 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;
}

View File

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

View File

@@ -0,0 +1,3 @@
export * from './form';
export * from './component';
export * from './hooks';

View 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>