first commit
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import {App, ConfigProvider, type ThemeConfig} from 'antd';
|
||||
import {type PropsWithChildren, useMemo} from 'react';
|
||||
import algorithm from "@/layout/algorithm.ts";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import useLanguage from '@/hooks/useLanguage';
|
||||
|
||||
function ContextHolder() {
|
||||
const { message, modal, notification } = App.useApp();
|
||||
window.$message = message;
|
||||
window.$modal = modal;
|
||||
window.$notification = notification;
|
||||
return null;
|
||||
}
|
||||
|
||||
const AppProvider = ({ children }: PropsWithChildren) => {
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
const { antdLocale } = useLanguage();
|
||||
|
||||
const theme: ThemeConfig = useMemo(() => ({
|
||||
components: {
|
||||
Layout: {
|
||||
headerPadding: "0 " + themeConfig.headerPadding + "px",
|
||||
headerHeight: themeConfig.headerHeight,
|
||||
bodyBg: themeConfig.bodyBg,
|
||||
footerBg: themeConfig.footerBg,
|
||||
headerBg: themeConfig.headerBg,
|
||||
headerColor: themeConfig.headerColor,
|
||||
siderBg: themeConfig.siderBg,
|
||||
footerPadding: 0
|
||||
},
|
||||
Menu: {
|
||||
activeBarBorderWidth: 0,
|
||||
itemBg: 'transparent',
|
||||
subMenuItemBg: 'transparent',
|
||||
}
|
||||
},
|
||||
token: {
|
||||
colorPrimary: themeConfig.colorPrimary,
|
||||
colorBgBase: themeConfig.colorBg,
|
||||
colorTextBase: themeConfig.colorText,
|
||||
colorError: themeConfig.colorError,
|
||||
colorInfo: themeConfig.colorPrimary,
|
||||
colorLink: themeConfig.colorPrimary,
|
||||
colorSuccess: themeConfig.colorSuccess,
|
||||
colorWarning: themeConfig.colorWarning,
|
||||
borderRadius: themeConfig.borderRadius,
|
||||
controlHeight: themeConfig.controlHeight,
|
||||
},
|
||||
algorithm: themeConfig.algorithm ? algorithm[themeConfig.algorithm] : undefined
|
||||
}), [themeConfig])
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={theme} locale={antdLocale}>
|
||||
<App>
|
||||
<ContextHolder />
|
||||
{children}
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppProvider;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import useAuthStore from "@/stores/user";
|
||||
|
||||
interface AuthButtonProps {
|
||||
auth?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const AuthButton = ({ auth, children }: AuthButtonProps) => {
|
||||
const access = useAuthStore(state => state.access);
|
||||
|
||||
const hasPermission = useMemo(() => {
|
||||
// 未指定权限,默认显示
|
||||
if (!auth) return true;
|
||||
|
||||
// 检查权限
|
||||
return access.includes(auth);
|
||||
}, [access, auth]);
|
||||
|
||||
// 无权限时不渲染
|
||||
if (!hasPermission) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default AuthButton;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Badge, Tag } from 'antd';
|
||||
import React, { useMemo } from 'react';
|
||||
import useDictStore from '@/stores/dict';
|
||||
|
||||
export interface DictTagProps {
|
||||
/** 字典编码 */
|
||||
code: string;
|
||||
/** 字典值 */
|
||||
value?: string | number;
|
||||
/** 渲染样式 */
|
||||
renderType?: 'text' | 'tag' | 'badge';
|
||||
/** 默认文本 */
|
||||
defaultText?: string;
|
||||
/** 默认颜色 */
|
||||
defaultColor?: string;
|
||||
/** 配置 */
|
||||
}
|
||||
|
||||
const DictTag: React.FC<DictTagProps> = (props) => {
|
||||
const {
|
||||
code,
|
||||
value,
|
||||
renderType = 'text',
|
||||
defaultText = '-',
|
||||
defaultColor = 'default',
|
||||
} = props;
|
||||
|
||||
const getDictItem = useDictStore((state) => state.getDictItem);
|
||||
|
||||
const dictItem = useMemo(() => {
|
||||
return getDictItem(code, value);
|
||||
}, [code, value, getDictItem]);
|
||||
|
||||
const label = dictItem?.label || defaultText;
|
||||
const color = dictItem?.color || defaultColor;
|
||||
|
||||
if (renderType === 'text') {
|
||||
return <span>{label}</span>;
|
||||
}
|
||||
|
||||
if (renderType === 'badge') {
|
||||
return <Badge color={color} text={label} />;
|
||||
}
|
||||
|
||||
return <Tag color={color}>{label}</Tag>;
|
||||
};
|
||||
|
||||
export default DictTag;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createFromIconfontCN, ExclamationOutlined } from '@ant-design/icons';
|
||||
import * as AntdIcons from '@ant-design/icons';
|
||||
import React from 'react';
|
||||
import {categories, oauthScriptUrl} from '@/utils/iconFields.ts'
|
||||
|
||||
const allIcons: any = AntdIcons;
|
||||
|
||||
const OtherIcons = createFromIconfontCN({
|
||||
scriptUrl: oauthScriptUrl,
|
||||
});
|
||||
|
||||
const IconFont = (props: {name?: string, style?: React.CSSProperties}) => {
|
||||
if (!props.name || !categories.allIcons.includes(props.name)) {
|
||||
return <ExclamationOutlined style={props.style} />
|
||||
} else if(allIcons[props.name]) {
|
||||
return React.createElement(allIcons[props.name] , {style: props.style})
|
||||
} else {
|
||||
return <OtherIcons type={props.name} className={props.name} style={props.style} />
|
||||
}
|
||||
}
|
||||
|
||||
export default IconFont;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, {useMemo} from 'react';
|
||||
import {Button, Dropdown, type MenuProps} from 'antd';
|
||||
import type { ButtonProps } from 'antd';
|
||||
import { TranslationOutlined } from '@ant-design/icons';
|
||||
import useLanguage from '@/hooks/useLanguage';
|
||||
|
||||
/**
|
||||
* 语言切换器组件
|
||||
*/
|
||||
const LanguageSwitcher: React.FC<ButtonProps> = (props) => {
|
||||
const { languageOptions, language, changeLanguage } = useLanguage();
|
||||
|
||||
/**
|
||||
* 生成语言下拉菜单项
|
||||
*/
|
||||
const languageMenuItems = useMemo<MenuProps['items']>(() => {
|
||||
return languageOptions.map((option) => ({
|
||||
key: option.value,
|
||||
label: option.label,
|
||||
onClick: () => changeLanguage(option.value),
|
||||
}));
|
||||
}, [languageOptions, changeLanguage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown menu={{ items: languageMenuItems, selectedKeys: [language] }}>
|
||||
<Button icon={<TranslationOutlined />} {...props} />
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
@@ -0,0 +1,140 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#root {
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% auto;
|
||||
}
|
||||
|
||||
.loading-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.loading-sub-title {
|
||||
margin-top: 20px;
|
||||
font-size: 1rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.page-loading-warp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 26px;
|
||||
}
|
||||
.ant-spin {
|
||||
position: absolute;
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
color: #1890ff;
|
||||
font-size: 14px;
|
||||
font-variant: tabular-nums;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
list-style: none;
|
||||
opacity: 0;
|
||||
transition: -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),-webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.ant-spin-spinning {
|
||||
position: static;
|
||||
display: inline-block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ant-spin-dot {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
background-color: #1890ff;
|
||||
border-radius: 100%;
|
||||
transform: scale(0.75);
|
||||
transform-origin: 50% 50%;
|
||||
opacity: 0.3;
|
||||
-webkit-animation: antspinmove 1s infinite linear alternate;
|
||||
animation: antSpinMove 1s infinite linear alternate;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item:nth-child(1) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item:nth-child(2) {
|
||||
top: 0;
|
||||
right: 0;
|
||||
-webkit-animation-delay: 0.4s;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item:nth-child(3) {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
-webkit-animation-delay: 0.8s;
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item:nth-child(4) {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
-webkit-animation-delay: 1.2s;
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
|
||||
.ant-spin-dot-spin {
|
||||
transform: rotate(45deg);
|
||||
animation: antRotate 1.2s infinite linear;
|
||||
}
|
||||
|
||||
.ant-spin-lg .ant-spin-dot {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.ant-spin-lg .ant-spin-dot i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@-webkit-keyframes antSpinMove {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes antSpinMove {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes antRotate {
|
||||
to {
|
||||
transform: rotate(405deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes antRotate {
|
||||
to {
|
||||
transform: rotate(405deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import './index.css';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
minHeight: 362
|
||||
}}>
|
||||
<div className="page-loading-warp">
|
||||
<div className="ant-spin ant-spin-lg ant-spin-spinning">
|
||||
<span className="ant-spin-dot ant-spin-dot-spin">
|
||||
<i className="ant-spin-dot-item"></i>
|
||||
<i className="ant-spin-dot-item"></i>
|
||||
<i className="ant-spin-dot-item"></i>
|
||||
<i className="ant-spin-dot-item"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="loading-title">
|
||||
正在加载资源
|
||||
</div>
|
||||
<div className="loading-sub-title">
|
||||
初次加载资源可能需要较多时间 请耐心等待
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useEffect } from "react";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import {useLocation} from "react-router";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useLayoutContext} from "@/layout/LayoutContext";
|
||||
import {findMenuByPath} from "@/layout/utils.ts";
|
||||
|
||||
const PageTitle: React.FC = () => {
|
||||
const {t} = useTranslation();
|
||||
const location = useLocation();
|
||||
const defaultSiteName = useGlobalStore(state => state.title);
|
||||
const { menus } = useLayoutContext();
|
||||
|
||||
useEffect(() => {
|
||||
const title = defaultSiteName || "Xin Admin";
|
||||
const menu = findMenuByPath(menus, location.pathname);
|
||||
if(!menu) {
|
||||
document.title = title;
|
||||
return;
|
||||
}
|
||||
const pageTitle = menu.local ? t(menu.local) : menu.name;
|
||||
if(pageTitle) {
|
||||
document.title = pageTitle+ ' - ' + title;
|
||||
} else {
|
||||
document.title = title;
|
||||
}
|
||||
|
||||
}, [location.pathname, defaultSiteName, t, menus]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default PageTitle;
|
||||
@@ -0,0 +1,320 @@
|
||||
import React, { useCallback, useImperativeHandle, useMemo, useState } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Button,
|
||||
Space,
|
||||
Row,
|
||||
Col,
|
||||
Modal,
|
||||
Drawer
|
||||
} from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { XinFormProps, XinFormRef, SubmitterButton } from './typings';
|
||||
import type { FormItemProps } from 'antd';
|
||||
import type { FormColumn } from '@/components/XinFormField/FieldRender/typings';
|
||||
import FieldRender from '@/components/XinFormField/FieldRender';
|
||||
import {pick} from "lodash";
|
||||
|
||||
/**
|
||||
* XinForm - JSON 配置动态表单组件
|
||||
*/
|
||||
function XinForm<T extends Record<string, any> = any>(props: XinFormProps<T>) {
|
||||
const {
|
||||
columns,
|
||||
layoutType = 'Form',
|
||||
grid = false,
|
||||
rowProps,
|
||||
colProps = { span: 12 },
|
||||
onFinish,
|
||||
formRef,
|
||||
form,
|
||||
modalProps,
|
||||
drawerProps,
|
||||
trigger,
|
||||
submitter,
|
||||
...formProps
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [xinForm] = Form.useForm<T>(form);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 暴露表单方法
|
||||
useImperativeHandle(formRef, (): XinFormRef => ({
|
||||
...xinForm,
|
||||
open: handleOpen,
|
||||
close: handleClose,
|
||||
isOpen: () => open,
|
||||
setLoading: (loading: boolean) => setLoading(loading),
|
||||
}));
|
||||
|
||||
// 表单提交处理
|
||||
const handleFinish = useCallback(async (values: T) => {
|
||||
if (!onFinish) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await onFinish(values);
|
||||
if (result !== false && (layoutType === 'ModalForm' || layoutType === 'DrawerForm')) {
|
||||
setOpen(false);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onFinish, layoutType]);
|
||||
|
||||
// 打开弹窗/抽屉
|
||||
const handleOpen =() => setOpen(true);
|
||||
|
||||
// 关闭弹窗/抽屉
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
// 渲染表单项
|
||||
const renderFormItem = useCallback((column: FormColumn<T>, index: number): React.ReactNode => {
|
||||
const {
|
||||
dataIndex,
|
||||
valueType,
|
||||
title = '',
|
||||
fieldProps = {},
|
||||
dependency,
|
||||
fieldRender,
|
||||
colProps: columnColProps = { span: 12 }
|
||||
} = column;
|
||||
|
||||
// Form.Item 允许的属性列表
|
||||
const formItemPropKeys = [
|
||||
'colon', 'extra', 'getValueFromEvent', 'help', 'hidden', 'htmlFor',
|
||||
'initialValue', 'name', 'normalize',
|
||||
'noStyle', 'preserve', 'tooltip', 'trigger',
|
||||
// 验证相关
|
||||
'required', 'rules', 'validateFirst', 'validateDebounce', 'validateStatus', 'hasFeedback',
|
||||
'validateTrigger', 'valuePropName', 'messageVariables',
|
||||
// 布局
|
||||
'wrapperCol', 'layout', 'label', 'labelAlign', 'labelCol'
|
||||
];
|
||||
|
||||
const formItemProps = pick(column, formItemPropKeys);
|
||||
|
||||
const key = String(dataIndex) || `form-item-${index}`;
|
||||
|
||||
if (dependency) {
|
||||
// 有依赖时使用 Form.Item 的 shouldUpdate
|
||||
return (
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({getFieldsValue}) => {
|
||||
const values = getFieldsValue() as T;
|
||||
// 判断是否隐藏
|
||||
const isHidden = dependency.visible ? !dependency.visible(values) : false;
|
||||
if (isHidden) return null;
|
||||
|
||||
// 判断是否禁用
|
||||
const isDisabled = dependency.disabled ? dependency.disabled(values) : false;
|
||||
// 动态 fieldProps
|
||||
const dynamicFieldProps = dependency.fieldProps ? dependency.fieldProps(values) : {};
|
||||
|
||||
const mergedFieldProps = {
|
||||
...fieldProps,
|
||||
...dynamicFieldProps,
|
||||
disabled: isDisabled || fieldProps?.disabled,
|
||||
}
|
||||
|
||||
const defaultFieldRender = (
|
||||
<FieldRender
|
||||
valueType={valueType}
|
||||
placeholder={title}
|
||||
{...mergedFieldProps}
|
||||
/>
|
||||
);
|
||||
|
||||
return grid ?
|
||||
<Col
|
||||
{...colProps}
|
||||
{...columnColProps}
|
||||
key={key}
|
||||
>
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={dataIndex}
|
||||
label={column.title || column.label}
|
||||
{...formItemProps as FormItemProps}
|
||||
>
|
||||
{fieldRender ? fieldRender(xinForm) : defaultFieldRender}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
:
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={dataIndex}
|
||||
label={column.title || column.label}
|
||||
{...formItemProps as FormItemProps}
|
||||
>
|
||||
{fieldRender ? fieldRender(xinForm) : defaultFieldRender}
|
||||
</Form.Item>
|
||||
}}
|
||||
</Form.Item>
|
||||
);
|
||||
} else {
|
||||
const defaultFieldRender = (
|
||||
<FieldRender
|
||||
valueType={valueType}
|
||||
placeholder={title}
|
||||
{...fieldProps}
|
||||
/>
|
||||
);
|
||||
return grid ?
|
||||
<Col
|
||||
{...colProps}
|
||||
{...columnColProps}
|
||||
key={key}
|
||||
>
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={dataIndex}
|
||||
label={column.title || column.label}
|
||||
{...formItemProps as FormItemProps}
|
||||
>
|
||||
{fieldRender ? fieldRender(xinForm) : defaultFieldRender}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
:
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={dataIndex}
|
||||
label={column.title || column.label}
|
||||
{...formItemProps as FormItemProps}
|
||||
>
|
||||
{fieldRender ? fieldRender(xinForm) : defaultFieldRender}
|
||||
</Form.Item>
|
||||
}
|
||||
}, [grid, form]);
|
||||
|
||||
// 渲染提交按钮
|
||||
const renderSubmitter = useMemo(() => {
|
||||
if (submitter?.render === false) return null;
|
||||
|
||||
const submitButton = (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => xinForm.submit()}
|
||||
{...submitter?.submitButtonProps}
|
||||
>
|
||||
{submitter?.submitText || t('xin.form.submit')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const closeButton = (
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={handleClose}
|
||||
{...submitter?.closeButtonProps}
|
||||
>
|
||||
{submitter?.closeText || t('xin.form.cancel')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const resetButton = (
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => xinForm.resetFields()}
|
||||
{...submitter?.resetButtonProps}
|
||||
>
|
||||
{submitter?.resetText || t('xin.form.reset')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const buttons: SubmitterButton = {
|
||||
submit: submitButton,
|
||||
close: closeButton,
|
||||
reset: resetButton,
|
||||
};
|
||||
|
||||
if (typeof submitter?.render === 'function') {
|
||||
return submitter.render(buttons, formRef);
|
||||
}
|
||||
|
||||
if(layoutType === 'Form') {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Space>
|
||||
{buttons.reset}
|
||||
{buttons.submit}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
);
|
||||
}else {
|
||||
return (
|
||||
<Space>
|
||||
{buttons.reset}
|
||||
{buttons.submit}
|
||||
{buttons.close}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
}, [loading, form, submitter, t]);
|
||||
|
||||
// 表单内容
|
||||
const formContent = useMemo(() => (
|
||||
<Form
|
||||
{...formProps}
|
||||
form={xinForm}
|
||||
onFinish={handleFinish}
|
||||
>
|
||||
{grid ? (
|
||||
<Row {...rowProps}>
|
||||
{columns.map((column, index) => renderFormItem(column, index))}
|
||||
</Row>
|
||||
) : (
|
||||
columns.map((column, index) => renderFormItem(column, index))
|
||||
)}
|
||||
{(layoutType === 'Form') && renderSubmitter}
|
||||
</Form>
|
||||
), [ form, handleFinish, props, grid, rowProps, columns, renderFormItem, layoutType, renderSubmitter ]);
|
||||
|
||||
// 触发器
|
||||
const triggerElement = useMemo(() => {
|
||||
if (!trigger) return null;
|
||||
return React.cloneElement(trigger as React.ReactElement<{ onClick?: () => void }>, {
|
||||
onClick: handleOpen,
|
||||
});
|
||||
}, [trigger, handleOpen]);
|
||||
|
||||
// 根据 layoutType 渲染
|
||||
if (layoutType === 'ModalForm') {
|
||||
return (
|
||||
<>
|
||||
{triggerElement}
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={renderSubmitter}
|
||||
{...modalProps}
|
||||
>
|
||||
{formContent}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (layoutType === 'DrawerForm') {
|
||||
return (
|
||||
<>
|
||||
{triggerElement}
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
footer={renderSubmitter}
|
||||
{...drawerProps}
|
||||
>
|
||||
{formContent}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return formContent;
|
||||
}
|
||||
|
||||
export default XinForm;
|
||||
|
||||
export type { XinFormProps, XinFormRef };
|
||||
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
FormProps,
|
||||
RowProps,
|
||||
ModalProps,
|
||||
DrawerProps,
|
||||
FormInstance,
|
||||
ButtonProps,
|
||||
ColProps,
|
||||
} from 'antd';
|
||||
import {type ReactNode, type RefObject} from 'react';
|
||||
import type { FormColumn } from '@/components/XinFormField/FieldRender/typings';
|
||||
|
||||
/**
|
||||
* 表单操作栏按钮
|
||||
*/
|
||||
export type SubmitterButton = {
|
||||
/** 提交按钮 */
|
||||
submit: ReactNode;
|
||||
/** 重置按钮 */
|
||||
reset: ReactNode;
|
||||
/** 关闭按钮 */
|
||||
close: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单操作栏属性
|
||||
*/
|
||||
export interface SubmitterProps {
|
||||
/** 操作栏渲染 */
|
||||
render?: false | ((dom: SubmitterButton, form?: RefObject<XinFormRef | null>) => ReactNode);
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string | ReactNode;
|
||||
/** 重置按钮文本 */
|
||||
resetText?: string | ReactNode;
|
||||
/** 关闭按钮文本 */
|
||||
closeText?: string | ReactNode;
|
||||
/** 提交按钮属性 */
|
||||
submitButtonProps?: Omit<ButtonProps, 'loading' | 'onClick'>;
|
||||
/** 重置按钮属性 */
|
||||
resetButtonProps?: Omit<ButtonProps, 'loading' | 'onClick'>;
|
||||
/** 关闭按钮属性 */
|
||||
closeButtonProps?: Omit<ButtonProps, 'loading' | 'onClick'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* XinForm 实例方法
|
||||
*/
|
||||
export interface XinFormRef<T = any> extends FormInstance<T> {
|
||||
/** 打开弹窗/抽屉 (仅 ModalForm/DrawerForm 有效) */
|
||||
open: () => void;
|
||||
/** 关闭弹窗/抽屉 (仅 ModalForm/DrawerForm 有效) */
|
||||
close: () => void;
|
||||
/** 获取弹窗/抽屉的打开状态 */
|
||||
isOpen: () => boolean;
|
||||
/** 设置加载状态 */
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* XinForm 组件属性
|
||||
*/
|
||||
export type XinFormProps<T = any> = Omit<FormProps<T>, 'onFinish'> & {
|
||||
/** 表单列配置 */
|
||||
columns: FormColumn<T>[];
|
||||
/** 表单布局类型 */
|
||||
layoutType?: 'Form' | 'ModalForm' | 'DrawerForm';
|
||||
/** 是否使用 Grid 布局 */
|
||||
grid?: boolean;
|
||||
/** 开启 grid 模式时传递给 Row */
|
||||
rowProps?: RowProps;
|
||||
/** 传递给表单项的 Col */
|
||||
colProps?: ColProps;
|
||||
/** 表单提交 */
|
||||
onFinish?: (values: T) => Promise<boolean | void>;
|
||||
/** 表单实例引用 */
|
||||
formRef?: RefObject<XinFormRef | null>;
|
||||
/** ModalForm 弹窗配置 */
|
||||
modalProps?: Omit<ModalProps, 'open'>;
|
||||
/** DrawerForm 抽屉配置 */
|
||||
drawerProps?: Omit<DrawerProps, 'open'>;
|
||||
/** 触发器 */
|
||||
trigger?: ReactNode;
|
||||
/** 渲染表单操作栏 */
|
||||
submitter?: SubmitterProps;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { FormColumn } from "./typings";
|
||||
import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Radio,
|
||||
Checkbox,
|
||||
Switch,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
TreeSelect,
|
||||
Cascader,
|
||||
Rate,
|
||||
Slider,
|
||||
ColorPicker
|
||||
} from 'antd';
|
||||
import type {
|
||||
InputProps,
|
||||
InputNumberProps,
|
||||
SelectProps,
|
||||
TreeSelectProps,
|
||||
RadioGroupProps,
|
||||
SwitchProps,
|
||||
RateProps,
|
||||
SliderSingleProps,
|
||||
DatePickerProps,
|
||||
TimePickerProps,
|
||||
ColorPickerProps
|
||||
} from 'antd';
|
||||
import type { PasswordProps, TextAreaProps } from "antd/es/input";
|
||||
import type { RangePickerProps } from "antd/es/date-picker";
|
||||
import type { CheckboxGroupProps } from 'antd/es/checkbox';
|
||||
import IconSelector from '@/components/XinFormField/IconSelector';
|
||||
import ImageUploader from '@/components/XinFormField/ImageUploader';
|
||||
import UserSelector from '@/components/XinFormField/UserSelector';
|
||||
import type { IconSelectProps } from '@/components/XinFormField/IconSelector/typings';
|
||||
import type { ImageUploaderProps } from '@/components/XinFormField/ImageUploader/typings';
|
||||
import type { UserSelectorProps } from '@/components/XinFormField/UserSelector/typings';
|
||||
import type {ReactNode} from "react";
|
||||
|
||||
const { TextArea, Password } = Input;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface FieldsRenderProps<T> extends Record<string, any> {
|
||||
valueType: FormColumn<T>['valueType'];
|
||||
}
|
||||
|
||||
export default function FieldRender<T>(props: FieldsRenderProps<T>) {
|
||||
const { valueType, ...fieldProps } = props;
|
||||
let dom: ReactNode;
|
||||
switch (valueType) {
|
||||
case 'password':
|
||||
dom = <Password {...fieldProps as PasswordProps}/>;
|
||||
break;
|
||||
case 'textarea':
|
||||
dom = <TextArea rows={4} {...fieldProps as TextAreaProps} />;
|
||||
break;
|
||||
case 'digit':
|
||||
dom = <InputNumber style={{ width: '100%' }} {...fieldProps as InputNumberProps} />;
|
||||
break;
|
||||
case 'money':
|
||||
dom = <InputNumber style={{ width: '100%' }} precision={2} prefix="¥" {...fieldProps as InputNumberProps} />;
|
||||
break;
|
||||
case 'select':
|
||||
dom = <Select {...fieldProps as SelectProps} />;
|
||||
break;
|
||||
case 'treeSelect':
|
||||
dom = <TreeSelect {...fieldProps as TreeSelectProps} />;
|
||||
break;
|
||||
case 'cascader':
|
||||
dom = <Cascader {...fieldProps as any} />;
|
||||
break;
|
||||
case 'radio':
|
||||
dom = <Radio.Group options={[]} optionType="default" {...fieldProps as RadioGroupProps} />;
|
||||
break;
|
||||
case 'radioButton':
|
||||
dom = <Radio.Group options={[]} optionType="button" {...fieldProps as RadioGroupProps} />;
|
||||
break;
|
||||
case 'checkbox':
|
||||
dom = <Checkbox.Group options={[]} {...fieldProps as CheckboxGroupProps} />;
|
||||
break;
|
||||
case 'switch':
|
||||
dom = <Switch {...fieldProps as SwitchProps} />;
|
||||
break;
|
||||
case 'rate':
|
||||
dom = <Rate {...fieldProps as RateProps} />;
|
||||
break;
|
||||
case 'slider':
|
||||
dom = <Slider {...fieldProps as SliderSingleProps} />;
|
||||
break;
|
||||
case 'date':
|
||||
dom = <DatePicker style={{ width: '100%' }} {...fieldProps as DatePickerProps} />;
|
||||
break;
|
||||
case 'dateTime':
|
||||
dom = <DatePicker style={{ width: '100%' }} showTime {...fieldProps as DatePickerProps} />;
|
||||
break;
|
||||
case 'dateRange':
|
||||
dom = <RangePicker style={{ width: '100%' }} {...fieldProps as RangePickerProps} />;
|
||||
break;
|
||||
case 'time':
|
||||
dom = <TimePicker style={{ width: '100%' }} {...fieldProps as TimePickerProps} />;
|
||||
break;
|
||||
case 'timeRange':
|
||||
dom = <TimePicker.RangePicker style={{ width: '100%' }} {...fieldProps as RangePickerProps} />;
|
||||
break;
|
||||
case 'week':
|
||||
dom = <DatePicker style={{ width: '100%' }} picker="week" {...fieldProps as DatePickerProps} />;
|
||||
break;
|
||||
case 'month':
|
||||
dom = <DatePicker style={{ width: '100%' }} picker="month" {...fieldProps as DatePickerProps} />;
|
||||
break;
|
||||
case 'quarter':
|
||||
dom = <DatePicker style={{ width: '100%' }} picker="quarter" {...fieldProps as DatePickerProps} />;
|
||||
break;
|
||||
case 'year':
|
||||
dom = <DatePicker style={{ width: '100%' }} picker="year" {...fieldProps as DatePickerProps} />;
|
||||
break;
|
||||
case 'color':
|
||||
dom = <ColorPicker {...fieldProps as ColorPickerProps} />;
|
||||
break;
|
||||
case 'image':
|
||||
dom = <ImageUploader {...fieldProps as ImageUploaderProps} />;
|
||||
break;
|
||||
case 'icon':
|
||||
dom = <IconSelector {...fieldProps as IconSelectProps} />;
|
||||
break;
|
||||
case 'user':
|
||||
dom = <UserSelector {...fieldProps as UserSelectorProps} />;
|
||||
break;
|
||||
case 'text':
|
||||
default:
|
||||
dom = <Input {...fieldProps as InputProps} />;
|
||||
break;
|
||||
}
|
||||
return dom;
|
||||
}
|
||||
|
||||
export type { FieldsRenderProps, FormColumn };
|
||||
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
FormItemProps,
|
||||
ColProps,
|
||||
FormInstance,
|
||||
InputProps,
|
||||
InputNumberProps,
|
||||
SelectProps,
|
||||
TreeSelectProps,
|
||||
CascaderProps,
|
||||
RadioGroupProps,
|
||||
SwitchProps,
|
||||
RateProps,
|
||||
SliderSingleProps,
|
||||
DatePickerProps,
|
||||
TimePickerProps,
|
||||
TimeRangePickerProps,
|
||||
ColorPickerProps
|
||||
} from 'antd';
|
||||
import type { TextAreaProps, PasswordProps } from 'antd/es/input';
|
||||
import type { RangePickerProps } from 'antd/es/date-picker';
|
||||
import type { CheckboxGroupProps } from 'antd/es/checkbox';
|
||||
import {type Key, type ReactNode} from 'react';
|
||||
import type { IconSelectProps } from '@/components/XinFormField/IconSelector/typings';
|
||||
import type { ImageUploaderProps } from '@/components/XinFormField/ImageUploader/typings';
|
||||
import type { UserSelectorProps } from '@/components/XinFormField/UserSelector/typings';
|
||||
|
||||
/**
|
||||
* 表单字段类型
|
||||
*/
|
||||
export type FieldValue =
|
||||
| 'text' // 文本输入
|
||||
| 'password' // 密码输入
|
||||
| 'textarea' // 多行文本
|
||||
| 'digit' // 数字输入
|
||||
| 'money' // 金额输入
|
||||
| 'select' // 下拉选择
|
||||
| 'treeSelect' // 树形选择
|
||||
| 'cascader' // 级联选择
|
||||
| 'radio' // 单选框
|
||||
| 'radioButton' // 单选按钮
|
||||
| 'checkbox' // 多选框
|
||||
| 'switch' // 开关
|
||||
| 'rate' // 评分
|
||||
| 'slider' // 滑动条
|
||||
| 'date' // 日期选择
|
||||
| 'dateTime' // 日期时间选择
|
||||
| 'dateRange' // 日期范围
|
||||
| 'time' // 时间选择
|
||||
| 'timeRange' // 时间范围
|
||||
| 'week' // 周选择
|
||||
| 'month' // 月选择
|
||||
| 'quarter' // 季度选择
|
||||
| 'year' // 年选择
|
||||
| 'image' // 图片上传
|
||||
| 'color' // 颜色选择
|
||||
| 'icon' // 图标选择
|
||||
| 'user' // 用户选择
|
||||
|
||||
/**
|
||||
* valueType 到组件 Props 的类型映射
|
||||
*/
|
||||
export interface FieldPropsMap {
|
||||
text: InputProps;
|
||||
password: PasswordProps;
|
||||
textarea: TextAreaProps;
|
||||
digit: InputNumberProps;
|
||||
money: InputNumberProps;
|
||||
select: SelectProps;
|
||||
treeSelect: TreeSelectProps;
|
||||
cascader: CascaderProps;
|
||||
radio: Omit<RadioGroupProps, 'optionType'>;
|
||||
radioButton: Omit<RadioGroupProps, 'optionType'>;
|
||||
checkbox: CheckboxGroupProps;
|
||||
switch: SwitchProps;
|
||||
rate: RateProps;
|
||||
slider: SliderSingleProps;
|
||||
date: DatePickerProps;
|
||||
dateTime: DatePickerProps;
|
||||
dateRange: RangePickerProps;
|
||||
time: TimePickerProps;
|
||||
timeRange: TimeRangePickerProps;
|
||||
week: DatePickerProps;
|
||||
month: DatePickerProps;
|
||||
quarter: DatePickerProps;
|
||||
year: DatePickerProps;
|
||||
image: ImageUploaderProps;
|
||||
color: ColorPickerProps;
|
||||
icon: IconSelectProps;
|
||||
user: UserSelectorProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段依赖配置
|
||||
*/
|
||||
export interface FieldDependency<T = any> {
|
||||
/** 依赖的字段名 */
|
||||
dependencies: (keyof T)[];
|
||||
/** 根据依赖值判断是否显示 */
|
||||
visible?: (values: Partial<T>) => boolean;
|
||||
/** 根据依赖值判断是否禁用 */
|
||||
disabled?: (values: Partial<T>) => boolean;
|
||||
/** 根据依赖值动态修改 fieldProps */
|
||||
fieldProps?: (values: Partial<T>) => Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 valueType 映射 fieldProps 类型的表单列配置
|
||||
*/
|
||||
type FormColumnMap<T> = {
|
||||
[K in FieldValue]: FormItemProps<T> & {
|
||||
/** 唯一标识 */
|
||||
key?: Key;
|
||||
/** 字段名 */
|
||||
dataIndex?: string | number | (string | number)[];
|
||||
/** 字段标签 */
|
||||
title?: string;
|
||||
/** 字段类型 */
|
||||
valueType?: K;
|
||||
/** 自定义字段渲染 */
|
||||
fieldRender?: (form: FormInstance<T>) => ReactNode;
|
||||
/** Col 属性 表单开启 grid 时生效 */
|
||||
colProps?: ColProps;
|
||||
/** 字段组件的属性,根据 valueType 自动推断类型 */
|
||||
fieldProps?: FieldPropsMap[K];
|
||||
/** 字段依赖配置 */
|
||||
dependency?: FieldDependency<T>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单列配置
|
||||
* fieldProps 类型会根据 valueType 自动推断
|
||||
*/
|
||||
export type FormColumn<T> = FormColumnMap<T>[FieldValue]
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Select, Modal, Tabs } from 'antd';
|
||||
import type { TabsProps } from 'antd';
|
||||
import { categories, type CategoriesKeys } from '@/utils/iconFields';
|
||||
import IconFont from '@/components/IconFont';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 图标选择器组件属性
|
||||
*/
|
||||
export interface IconSelectProps {
|
||||
value?: string;
|
||||
onChange?: (value: string | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图标选择器组件
|
||||
* 基于 Ant Design Select + Modal + Tabs 封装
|
||||
*
|
||||
* @example
|
||||
* <IconSelect
|
||||
* value={icon}
|
||||
* onChange={setIcon}
|
||||
* placeholder="请选择图标"
|
||||
* />
|
||||
*/
|
||||
const IconSelect: React.FC<IconSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedIcon, setSelectedIcon] = useState<string | undefined>(value);
|
||||
|
||||
// 同步外部 value
|
||||
React.useEffect(() => {
|
||||
setSelectedIcon(value);
|
||||
}, [value]);
|
||||
|
||||
// 处理图标选择
|
||||
const handleIconClick = useCallback((icon: string) => {
|
||||
setSelectedIcon(icon);
|
||||
onChange?.(icon);
|
||||
setOpen(false);
|
||||
}, [onChange]);
|
||||
|
||||
// 处理清空
|
||||
const handleClear = useCallback(() => {
|
||||
setSelectedIcon(undefined);
|
||||
onChange?.(null);
|
||||
}, [onChange]);
|
||||
|
||||
// 图标列表组件
|
||||
const IconsList = useCallback(({ type }: { type: CategoriesKeys }) => {
|
||||
return (
|
||||
<div className="flex flex-wrap max-h-[400px] overflow-auto gap-2">
|
||||
{categories[type].map((item) => (
|
||||
<div
|
||||
className="cursor-pointer p-2 border border-gray-300 rounded hover:border-blue-500 hover:bg-blue-50 transition-all flex items-center justify-center"
|
||||
key={item}
|
||||
onClick={() => handleIconClick(item)}
|
||||
title={item}
|
||||
>
|
||||
<IconFont name={item} style={{ fontSize: 20 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [handleIconClick]);
|
||||
|
||||
// Tabs 配置
|
||||
const tabItems: TabsProps['items'] = useMemo(() => [
|
||||
{
|
||||
key: 'use',
|
||||
label: t('xin.form.iconSelector.tabs.use'),
|
||||
children: <IconsList type="useIcons" />,
|
||||
},
|
||||
{
|
||||
key: 'suggestion',
|
||||
label: t('xin.form.iconSelector.tabs.suggestion'),
|
||||
children: <IconsList type="suggestionIcons" />,
|
||||
},
|
||||
{
|
||||
key: 'direction',
|
||||
label: t('xin.form.iconSelector.tabs.direction'),
|
||||
children: <IconsList type="directionIcons" />,
|
||||
},
|
||||
{
|
||||
key: 'editor',
|
||||
label: t('xin.form.iconSelector.tabs.editor'),
|
||||
children: <IconsList type="editorIcons" />,
|
||||
},
|
||||
{
|
||||
key: 'data',
|
||||
label: t('xin.form.iconSelector.tabs.data'),
|
||||
children: <IconsList type="dataIcons" />,
|
||||
},
|
||||
{
|
||||
key: 'logo',
|
||||
label: t('xin.form.iconSelector.tabs.logo'),
|
||||
children: <IconsList type="logoIcons" />,
|
||||
},
|
||||
{
|
||||
key: 'other',
|
||||
label: t('xin.form.iconSelector.tabs.other'),
|
||||
children: <IconsList type="otherIcons" />,
|
||||
},
|
||||
], [IconsList, t]);
|
||||
|
||||
// Select 选项
|
||||
const selectOptions = useMemo(() => {
|
||||
if (!selectedIcon) return [];
|
||||
return [{
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<IconFont name={selectedIcon} />
|
||||
<span>{selectedIcon}</span>
|
||||
</div>
|
||||
),
|
||||
value: selectedIcon,
|
||||
}];
|
||||
}, [selectedIcon]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
value={selectedIcon}
|
||||
placeholder={placeholder || t('xin.form.iconSelector.placeholder')}
|
||||
disabled={disabled}
|
||||
open={false}
|
||||
onClick={() => !disabled && !readonly && setOpen(true)}
|
||||
options={selectOptions}
|
||||
style={{ width: '100%' }}
|
||||
allowClear
|
||||
onClear={handleClear}
|
||||
suffixIcon={selectedIcon ? <IconFont name={selectedIcon} /> : undefined}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('xin.form.iconSelector.modal.title')}
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<Tabs defaultActiveKey="use" items={tabItems} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconSelect;
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 图标选择器组件属性
|
||||
*/
|
||||
export interface IconSelectProps {
|
||||
/**
|
||||
* 选中的图标名称
|
||||
*/
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* 值变化回调
|
||||
* @param value 选中的图标名称或 null(清空时)
|
||||
*/
|
||||
onChange?: (value: string | null) => void;
|
||||
|
||||
/**
|
||||
* 占位符文本
|
||||
* @default '请选择图标'
|
||||
*/
|
||||
placeholder?: string;
|
||||
|
||||
/**
|
||||
* 是否禁用
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* 是否只读(只读模式下不能打开选择弹窗)
|
||||
* @default false
|
||||
*/
|
||||
readonly?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Upload, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import ImgCrop from 'antd-img-crop';
|
||||
import type { UploadFile, UploadProps } from 'antd';
|
||||
import type { RcFile } from 'antd/es/upload';
|
||||
import type { ImageUploaderProps } from './typings';
|
||||
import type { ISysFileInfo } from '@/domain/iSysFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 图片上传组件
|
||||
*/
|
||||
const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
action,
|
||||
value,
|
||||
changeType,
|
||||
onChange,
|
||||
mode = 'single',
|
||||
disabled = false,
|
||||
maxCount,
|
||||
maxWidth = 1920,
|
||||
maxHeight = 1080,
|
||||
maxSize = 5,
|
||||
croppable = false,
|
||||
cropperOptions,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
|
||||
// 计算最大上传数量
|
||||
const uploadMaxCount = useMemo(() => maxCount || (mode === 'single' ? 1 : 9), [mode, maxCount]);
|
||||
|
||||
// 格式化图片列表
|
||||
const fileToList = (file: ISysFileInfo): UploadFile => ({
|
||||
uid: file.id?.toString() || `${file.id}`,
|
||||
name: file.file_name || `image-${file.id}`,
|
||||
status: 'done',
|
||||
url: file.preview_url,
|
||||
response: {
|
||||
success: true,
|
||||
data: file,
|
||||
message: 'Upload success',
|
||||
},
|
||||
});
|
||||
|
||||
// 默认文件列表
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setFileList([]);
|
||||
return;
|
||||
}
|
||||
const valueArray = Array.isArray(value) ? value : [value];
|
||||
const newFileList: UploadFile[] = valueArray.map(fileToList);
|
||||
setFileList(newFileList);
|
||||
}, [value]);
|
||||
|
||||
// 上传前校验
|
||||
const beforeUpload = useCallback((file: RcFile): Promise<boolean> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 1. 文件类型校验
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
message.error(t('xin.form.imageUploader.error.notImage'));
|
||||
reject(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 文件大小校验
|
||||
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
|
||||
if (!isLtMaxSize) {
|
||||
message.error(t('xin.form.imageUploader.error.sizeExceeded', { maxSize }));
|
||||
reject(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 图片尺寸校验
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (e) => {
|
||||
const img = document.createElement('img');
|
||||
img.src = e.target?.result as string;
|
||||
img.onload = () => {
|
||||
const isValidWidth = img.width <= maxWidth;
|
||||
const isValidHeight = img.height <= maxHeight;
|
||||
|
||||
if (!isValidWidth || !isValidHeight) {
|
||||
message.error(t('xin.form.imageUploader.error.dimensionExceeded', { maxWidth, maxHeight }));
|
||||
reject(false);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
message.error(t('xin.form.imageUploader.error.loadFailed'));
|
||||
reject(false);
|
||||
};
|
||||
};
|
||||
reader.onerror = () => {
|
||||
message.error(t('xin.form.imageUploader.error.readFailed'));
|
||||
reject(false);
|
||||
};
|
||||
});
|
||||
}, [maxSize, maxWidth, maxHeight]);
|
||||
|
||||
// 处理文件列表变化
|
||||
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
|
||||
// 如果所有文件都被删除
|
||||
if (newFileList.length === 0) return;
|
||||
setFileList(newFileList);
|
||||
// 全部上传完成格式化图片列表
|
||||
if (newFileList.every((file) => file.status === 'done' || file.status === 'error')) {
|
||||
// 上传成功的文件
|
||||
const uploadedFiles = newFileList
|
||||
.filter((file) => file.status === 'done' && file.response)
|
||||
.map((file) => file.response.data as ISysFileInfo);
|
||||
if( mode === 'single' ) {
|
||||
if (changeType !== 'id') {
|
||||
onChange?.(uploadedFiles[0]);
|
||||
} else {
|
||||
onChange?.(uploadedFiles[0].id);
|
||||
}
|
||||
} else {
|
||||
if (changeType !== 'id') {
|
||||
onChange?.(uploadedFiles);
|
||||
} else {
|
||||
onChange?.(uploadedFiles.map(item => item.id!));
|
||||
}
|
||||
}
|
||||
// 上传失败的文件
|
||||
const errorFiles = newFileList.filter((file) => file.status === 'error');
|
||||
setFileList([...uploadedFiles.map(fileToList), ...errorFiles]);
|
||||
}
|
||||
};
|
||||
|
||||
// 上传按钮
|
||||
const uploadButton = useMemo(() => (
|
||||
<button style={{ border: 0, background: 'none' }} type="button">
|
||||
<PlusOutlined />
|
||||
<div style={{ marginTop: 8 }}>{t('xin.form.imageUploader.uploadText')}</div>
|
||||
</button>
|
||||
), [t]);
|
||||
|
||||
// Upload 组件
|
||||
const uploadComponent = (
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
name="file"
|
||||
fileList={fileList}
|
||||
beforeUpload={beforeUpload}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
maxCount={uploadMaxCount}
|
||||
multiple={mode === 'multiple'}
|
||||
headers={{
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
}}
|
||||
onRemove={(file) => {
|
||||
setFileList(fileList.filter((item) => {
|
||||
return item.uid != file.uid
|
||||
}))
|
||||
}}
|
||||
action={import.meta.env.VITE_BASE_URL + action}
|
||||
accept="image/*"
|
||||
>
|
||||
{fileList.length >= uploadMaxCount ? null : uploadButton}
|
||||
</Upload>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{croppable && mode === 'single' ? (
|
||||
<ImgCrop {...cropperOptions}>
|
||||
{uploadComponent}
|
||||
</ImgCrop>
|
||||
) : (
|
||||
uploadComponent
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploader;
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { ISysFileInfo } from '@/domain/iSysFile';
|
||||
import type { ImgCropProps } from 'antd-img-crop';
|
||||
|
||||
/**
|
||||
* 图片上传器组件属性
|
||||
*/
|
||||
export interface ImageUploaderProps {
|
||||
/**
|
||||
* 上传地址
|
||||
*/
|
||||
action: string;
|
||||
|
||||
/**
|
||||
* 当前值
|
||||
* - 单选模式:ISysFileInfo | null
|
||||
* - 多选模式:ISysFileInfo[]
|
||||
*/
|
||||
value?: ISysFileInfo | ISysFileInfo[] | null;
|
||||
|
||||
/**
|
||||
* 赋值类型
|
||||
* 上传完成后赋值的类型。
|
||||
*/
|
||||
changeType?: 'id' | 'object';
|
||||
|
||||
/**
|
||||
* 上传完成回调
|
||||
* @param value ISysFileInfo | ISysFileInfo[] | null
|
||||
*/
|
||||
onChange?: (value?: ISysFileInfo | ISysFileInfo[] | number | number[] | null) => void;
|
||||
|
||||
/**
|
||||
* 上传模式
|
||||
* @default 'single'
|
||||
*/
|
||||
mode?: 'single' | 'multiple';
|
||||
|
||||
/**
|
||||
* 是否禁用
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* 最大上传数量
|
||||
* @default 1 (单选模式) | 9 (多选模式)
|
||||
*/
|
||||
maxCount?: number;
|
||||
|
||||
/**
|
||||
* 图片宽度限制 (px)
|
||||
* @default 1920
|
||||
*/
|
||||
maxWidth?: number;
|
||||
|
||||
/**
|
||||
* 图片高度限制 (px)
|
||||
* @default 1080
|
||||
*/
|
||||
maxHeight?: number;
|
||||
|
||||
/**
|
||||
* 图片大小限制 (MB)
|
||||
* @default 5
|
||||
*/
|
||||
maxSize?: number;
|
||||
|
||||
/**
|
||||
* 是否支持裁剪
|
||||
* @default false
|
||||
*/
|
||||
croppable?: boolean;
|
||||
|
||||
/**
|
||||
* 剪裁参数
|
||||
*/
|
||||
cropperOptions?: Omit<ImgCropProps, 'children'>;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Modal, Tag, Select, Table, Input, type TableProps, type TableColumnType } from 'antd';
|
||||
import type ISysUser from '@/domain/iSysUser';
|
||||
import { List } from '@/api/common/table';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 用户选择器组件属性
|
||||
*/
|
||||
export interface UserSelectorProps {
|
||||
value?: number | number[] | ISysUser | ISysUser[] | null;
|
||||
onChange?: (value: number | number[] | null) => void;
|
||||
mode?: 'single' | 'multiple';
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
showDept?: boolean;
|
||||
maxTagCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户选择器组件
|
||||
*/
|
||||
const UserSelector: React.FC<UserSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
mode = 'single',
|
||||
placeholder,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
showDept = true,
|
||||
maxTagCount = 2,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<ISysUser[]>([]);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
// 表格加载状态
|
||||
const [tableLoading, setTableLoading] = useState(false);
|
||||
// 表格数据
|
||||
const [dataSource, setDataSource] = useState<ISysUser[]>([]);
|
||||
// 分页参数
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
|
||||
/** 表格查询参数 */
|
||||
const [tableParams, setParams] = useState<{ keywordSearch: string }>();
|
||||
|
||||
// 从 value 加载用户信息
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedUsers([]);
|
||||
setSelectedRowKeys([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const valueArray = Array.isArray(value) ? value : [value];
|
||||
|
||||
// 如果是用户对象,直接使用
|
||||
if (typeof valueArray[0] === 'object') {
|
||||
setSelectedUsers(valueArray as ISysUser[]);
|
||||
setSelectedRowKeys(valueArray.map((u: any) => u.id));
|
||||
return;
|
||||
}
|
||||
|
||||
// 从 API 加载用户信息
|
||||
setLoading(true);
|
||||
Promise.all(valueArray.map(id => List<ISysUser>('/sys-user/list', { id: id as number })))
|
||||
.then(results => {
|
||||
const users = results.map(res => res.data.data?.data?.[0]).filter(Boolean) as ISysUser[];
|
||||
setSelectedUsers(users);
|
||||
setSelectedRowKeys(users.map(u => u.id!));
|
||||
})
|
||||
.catch(error => console.error('Failed to load users:', error))
|
||||
.finally(() => setLoading(false));
|
||||
}, [value]);
|
||||
|
||||
// 表格列配置
|
||||
const columns: TableColumnType<ISysUser>[] = useMemo(() => [
|
||||
{
|
||||
title: t('system.user.id'),
|
||||
dataIndex: 'id',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.user.username'),
|
||||
dataIndex: 'username',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.user.nickname'),
|
||||
dataIndex: 'nickname',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.user.mobile'),
|
||||
dataIndex: 'mobile',
|
||||
width: 130,
|
||||
align: 'center',
|
||||
},
|
||||
...(showDept ? [{
|
||||
title: t('system.user.dept'),
|
||||
dataIndex: 'dept_name',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
render: (text: any) => text ? <Tag color="volcano">{text}</Tag> : '-',
|
||||
}] : []),
|
||||
{
|
||||
title: t('system.user.status'),
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (value: number) => {
|
||||
return value === 1
|
||||
? <Tag color="success">{t('system.user.status.1')}</Tag>
|
||||
: <Tag color="error">{t('system.user.status.0')}</Tag>;
|
||||
},
|
||||
},
|
||||
], [t, showDept]);
|
||||
|
||||
// 处理选择确认
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange?.(mode === 'single' ? (selectedUsers[0]?.id || null) : selectedUsers.map(u => u.id!));
|
||||
setOpen(false);
|
||||
}, [mode, selectedUsers, onChange]);
|
||||
|
||||
// 处理行选择
|
||||
const handleRowSelectionChange = useCallback((keys: React.Key[], rows: ISysUser[]) => {
|
||||
if (mode === 'single' && keys.length > 1) {
|
||||
const lastKey = keys[keys.length - 1];
|
||||
const lastRow = rows.find(r => r.id === lastKey)!;
|
||||
setSelectedRowKeys([lastKey]);
|
||||
setSelectedUsers([lastRow]);
|
||||
} else {
|
||||
setSelectedRowKeys(keys);
|
||||
setSelectedUsers(rows);
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
// 获取表格数据
|
||||
const fetchTableData = useCallback(async (page = 1, pageSize = 10, keywordSearch?: string) => {
|
||||
setTableLoading(true);
|
||||
try {
|
||||
const api = '/system-user/list';
|
||||
const response = await List<ISysUser>(api, { page, pageSize, keywordSearch });
|
||||
setDataSource(response.data.data?.data || []);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: page,
|
||||
pageSize,
|
||||
total: response.data.data?.total || 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
setDataSource([]);
|
||||
} finally {
|
||||
setTableLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 打开弹窗时加载数据
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchTableData(1, pagination.pageSize, tableParams?.keywordSearch);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 表格配置
|
||||
const tableProps: TableProps<ISysUser> = useMemo(() => ({
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
dataSource,
|
||||
loading: tableLoading,
|
||||
rowSelection: {
|
||||
type: mode === 'single' ? 'radio' : 'checkbox',
|
||||
selectedRowKeys,
|
||||
onChange: handleRowSelectionChange,
|
||||
preserveSelectedRowKeys: true,
|
||||
},
|
||||
pagination: {
|
||||
...pagination,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
onChange: (page, pageSize) => {
|
||||
fetchTableData(page, pageSize, tableParams?.keywordSearch);
|
||||
},
|
||||
},
|
||||
bordered: true,
|
||||
size: 'small',
|
||||
scroll: { x: 800 },
|
||||
}), [columns, dataSource, tableLoading, mode, selectedRowKeys, handleRowSelectionChange, pagination, tableParams, fetchTableData]);
|
||||
|
||||
// 处理 Select 的 onChange
|
||||
const handleSelectChange = useCallback((selectedValue: number | number[]) => {
|
||||
if (mode === 'single') {
|
||||
const user = selectedUsers.find(u => u.id === selectedValue);
|
||||
if (!user && selectedValue) {
|
||||
// 如果选中的用户不在列表中,需要从 API 加载
|
||||
List<ISysUser>('/sys-user/list', { id: selectedValue as number })
|
||||
.then(res => {
|
||||
const loadedUser = res.data.data?.data?.[0];
|
||||
if (loadedUser) {
|
||||
setSelectedUsers([loadedUser]);
|
||||
setSelectedRowKeys([loadedUser.id!]);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Failed to load user:', error));
|
||||
}
|
||||
onChange?.(selectedValue as number || null);
|
||||
} else {
|
||||
onChange?.(selectedValue as number[]);
|
||||
}
|
||||
}, [mode, selectedUsers, onChange]);
|
||||
|
||||
// 生成 Select 的 options
|
||||
const selectOptions = useMemo(() => {
|
||||
return selectedUsers.map(user => ({
|
||||
label: user.nickname || user.username,
|
||||
value: user.id!,
|
||||
}));
|
||||
}, [selectedUsers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
mode={mode === 'multiple' ? 'multiple' : undefined}
|
||||
value={mode === 'single'
|
||||
? (selectedUsers[0]?.id || undefined)
|
||||
: selectedUsers.map(u => u.id!)
|
||||
}
|
||||
onChange={handleSelectChange}
|
||||
placeholder={placeholder || t('xin.form.userSelector.placeholder')}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
maxTagCount={maxTagCount}
|
||||
open={false}
|
||||
onClick={() => !disabled && !readonly && setOpen(true)}
|
||||
options={selectOptions}
|
||||
style={{ width: '100%' }}
|
||||
allowClear
|
||||
onClear={() => {
|
||||
setSelectedUsers([]);
|
||||
setSelectedRowKeys([]);
|
||||
onChange?.(mode === 'single' ? null : []);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('xin.form.userSelector.modal.title')}
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={handleSelect}
|
||||
width={1000}
|
||||
okText={t('xin.form.userSelector.modal.okText')}
|
||||
cancelText={t('xin.form.userSelector.modal.cancelText')}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Input.Search
|
||||
placeholder={t("system.user.searchPlaceholder")}
|
||||
style={{ width: 304 }}
|
||||
onSearch={(value: string) => {
|
||||
setParams({ keywordSearch: value });
|
||||
fetchTableData(1, pagination.pageSize, value);
|
||||
}}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<Table {...tableProps} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSelector;
|
||||
@@ -0,0 +1,68 @@
|
||||
import type ISysUser from '@/domain/iSysUser';
|
||||
|
||||
/**
|
||||
* 用户选择器的值类型
|
||||
* 支持单个ID、ID数组、或完整的用户对象
|
||||
*/
|
||||
export type UserSelectorValue =
|
||||
| number
|
||||
| number[]
|
||||
| ISysUser
|
||||
| ISysUser[]
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* 用户选择器组件属性
|
||||
*/
|
||||
export interface UserSelectorProps {
|
||||
/**
|
||||
* 选中的值
|
||||
* - 单选模式:number | ISysUser | null
|
||||
* - 多选模式:number[] | ISysUser[]
|
||||
*/
|
||||
value?: UserSelectorValue;
|
||||
|
||||
/**
|
||||
* 值变化回调
|
||||
* - 单选模式:返回 number | null
|
||||
* - 多选模式:返回 number[]
|
||||
*/
|
||||
onChange?: (value: number | number[] | null) => void;
|
||||
|
||||
/**
|
||||
* 选择模式
|
||||
* @default 'single'
|
||||
*/
|
||||
mode?: 'single' | 'multiple';
|
||||
|
||||
/**
|
||||
* 占位符文本
|
||||
*/
|
||||
placeholder?: string;
|
||||
|
||||
/**
|
||||
* 是否禁用
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* 是否只读(只读模式下不能打开选择弹窗)
|
||||
* @default false
|
||||
*/
|
||||
readonly?: boolean;
|
||||
|
||||
/**
|
||||
* 是否显示部门信息
|
||||
* @default true
|
||||
*/
|
||||
showDept?: boolean;
|
||||
|
||||
/**
|
||||
* 多选模式下最多显示的标签数量
|
||||
* 超出部分会显示 +N
|
||||
* @default 2
|
||||
*/
|
||||
maxTagCount?: number;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import IconSelector from './IconSelector';
|
||||
import ImageUploader from './ImageUploader';
|
||||
import UserSelector from './UserSelector';
|
||||
|
||||
export default {
|
||||
IconSelector,
|
||||
ImageUploader,
|
||||
UserSelector
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {
|
||||
Form,
|
||||
Button,
|
||||
Space, Row, Col,
|
||||
} from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SearchFormProps, SubmitterButton } from './typings';
|
||||
import type { FormItemProps } from 'antd';
|
||||
import type { FormColumn } from "@/components/XinFormField/FieldRender";
|
||||
import FieldRender from '@/components/XinFormField/FieldRender';
|
||||
import { pick } from 'lodash';
|
||||
|
||||
/**
|
||||
* SearchForm - JSON 配置动态搜索表单组件
|
||||
*/
|
||||
function SearchForm<T extends Record<string, any> = any>(props: SearchFormProps<T>) {
|
||||
const {
|
||||
columns,
|
||||
handleSearch,
|
||||
submitter,
|
||||
form: formRef,
|
||||
grid = true,
|
||||
rowProps = {
|
||||
gutter: [20, 20],
|
||||
wrap: true
|
||||
},
|
||||
colProps = {
|
||||
xs: 24,
|
||||
sm: 12,
|
||||
md: 12,
|
||||
lg: 8,
|
||||
xl: 6,
|
||||
xxl: 4
|
||||
},
|
||||
...formProps
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm<T>(formRef);
|
||||
|
||||
// 渲染表单项
|
||||
const renderFormItem = useCallback((column: FormColumn<T>, index: number): React.ReactNode => {
|
||||
const {
|
||||
dataIndex,
|
||||
valueType,
|
||||
title = '',
|
||||
fieldProps = {},
|
||||
fieldRender
|
||||
} = column;
|
||||
|
||||
// Form.Item 允许的属性列表
|
||||
const formItemPropKeys = [
|
||||
'colon', 'extra', 'getValueFromEvent', 'help', 'htmlFor',
|
||||
'initialValue', 'labelAlign', 'labelCol', 'name', 'normalize',
|
||||
'noStyle', 'tooltip', 'wrapperCol', 'layout'
|
||||
];
|
||||
const formItemProps = pick(column, formItemPropKeys);
|
||||
|
||||
const key = String(dataIndex) || `form-item-${index}`;
|
||||
|
||||
const defaultFieldRender = (
|
||||
<FieldRender
|
||||
valueType={valueType}
|
||||
placeholder={title}
|
||||
{...fieldProps}
|
||||
/>
|
||||
);
|
||||
|
||||
const formItemContent = (
|
||||
<Form.Item
|
||||
style={{marginBottom: 0}}
|
||||
key={key}
|
||||
name={key}
|
||||
label={column.title}
|
||||
{...formItemProps as FormItemProps}
|
||||
required={false}
|
||||
>
|
||||
{fieldRender ? fieldRender(form) : defaultFieldRender}
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
return grid ? <Col {...colProps} {...column.colProps} key={key}>{formItemContent}</Col> : formItemContent;
|
||||
}, [form, grid, colProps]);
|
||||
|
||||
// 渲染提交按钮
|
||||
const renderSubmitter = useMemo(() => {
|
||||
if (submitter?.render === false) return null;
|
||||
|
||||
const submitButton = (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => form.submit()}
|
||||
{...submitter?.submitButtonProps}
|
||||
>
|
||||
{submitter?.submitText || t('xin.table.search.search')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const resetButton = (
|
||||
<Button
|
||||
onClick={() => form.resetFields()}
|
||||
{...submitter?.resetButtonProps}
|
||||
>
|
||||
{submitter?.resetText || t('xin.table.search.reset')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const buttons: SubmitterButton = {
|
||||
search: submitButton,
|
||||
reset: resetButton
|
||||
};
|
||||
|
||||
if (typeof submitter?.render === 'function') {
|
||||
return submitter.render(buttons);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item style={{marginBottom: 0}}>
|
||||
<Space size={16}>
|
||||
{buttons.reset}
|
||||
{buttons.search}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
);
|
||||
}, [form, submitter, t]);
|
||||
|
||||
// 表单内容
|
||||
return (
|
||||
<Form
|
||||
{...formProps}
|
||||
form={form}
|
||||
onFinish={handleSearch}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{grid ? (
|
||||
<Row {...rowProps}>
|
||||
{columns.map((column, index) => renderFormItem(column, index))}
|
||||
<Col {...colProps}>{renderSubmitter}</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<>
|
||||
{columns.map((column, index) => renderFormItem(column, index))}
|
||||
{renderSubmitter}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchForm;
|
||||
export type { SearchFormProps };
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ButtonProps, ColProps, FormInstance, FormProps, RowProps } from "antd";
|
||||
import type { ReactNode } from "react";
|
||||
import type { FormColumn } from "@/components/XinFormField/FieldRender";
|
||||
|
||||
/**
|
||||
* 操作栏按钮
|
||||
*/
|
||||
export type SubmitterButton = {
|
||||
/** 查询按钮 */
|
||||
search: ReactNode;
|
||||
/** 重置按钮 */
|
||||
reset: ReactNode;
|
||||
/** 折叠按钮 */
|
||||
collapse?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单操作栏属性
|
||||
*/
|
||||
export interface SubmitterProps {
|
||||
/** 操作栏渲染 */
|
||||
render?: false | ((dom: SubmitterButton) => ReactNode);
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string | ReactNode;
|
||||
/** 重置按钮文本 */
|
||||
resetText?: string | ReactNode;
|
||||
/** 提交按钮属性 */
|
||||
submitButtonProps?: Omit<ButtonProps, 'loading' | 'onClick'>;
|
||||
/** 重置按钮属性 */
|
||||
resetButtonProps?: Omit<ButtonProps, 'loading' | 'onClick'>;
|
||||
/** 折叠按钮渲染 */
|
||||
collapseRender?: (collapse: boolean) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* XinSearch 组件属性
|
||||
*/
|
||||
export type SearchFormProps<T = any> = Omit<FormProps<T>, 'onFinish' | 'form'> & {
|
||||
/** 表单列配置 */
|
||||
columns: FormColumn<T>[];
|
||||
/** 表单实例引用 */
|
||||
form?: FormInstance<T>;
|
||||
/** 表单提交 */
|
||||
handleSearch?: (values: T) => Promise<boolean | void>;
|
||||
/** 渲染表单操作栏 */
|
||||
submitter?: SubmitterProps;
|
||||
/** 是否使用 Grid 布局 */
|
||||
grid?: boolean;
|
||||
/** 开启 grid 模式时传递给 Row */
|
||||
rowProps?: RowProps;
|
||||
/** 传递给表单项的 Col */
|
||||
colProps?: ColProps;
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
import {
|
||||
Button,
|
||||
Tree,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Space,
|
||||
Table,
|
||||
Tooltip,
|
||||
Popover,
|
||||
Dropdown,
|
||||
type TableProps,
|
||||
type TableColumnType,
|
||||
type TreeProps, ConfigProvider, Flex, Divider,
|
||||
} from "antd";
|
||||
import type {XinTableProps, XinTableInstance, RequestParams, FormMode} from "./typings";
|
||||
import SearchForm from "./SearchForm";
|
||||
import XinForm, {type XinFormRef} from "@/components/XinForm";
|
||||
import {type ReactNode, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from "react";
|
||||
import type {FormColumn} from "@/components/XinFormField/FieldRender/typings";
|
||||
import {Delete, List, Create, Update} from "@/api/common/table.ts";
|
||||
import {isArray, isEmpty, omit} from "lodash";
|
||||
import type {SearchProps} from "antd/es/input";
|
||||
import {
|
||||
BorderlessTableOutlined,
|
||||
BorderOutlined,
|
||||
ColumnHeightOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined
|
||||
} from "@ant-design/icons";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import type {DataNode} from "antd/es/tree";
|
||||
import AuthButton from "@/components/AuthButton";
|
||||
|
||||
// 默认分页大小
|
||||
const DEFAULT_PAGE_SIZE: number = 10;
|
||||
// 默认页数
|
||||
const DEFAULT_PAGE: number = 1;
|
||||
|
||||
export default function XinTable<T extends Record<string, any> = any>(props: XinTableProps<T>) {
|
||||
const {
|
||||
api,
|
||||
accessName,
|
||||
rowKey,
|
||||
columns,
|
||||
tableRef,
|
||||
|
||||
formProps,
|
||||
modalProps,
|
||||
cardProps,
|
||||
searchProps,
|
||||
operateProps = {},
|
||||
pagination: customPagination = {},
|
||||
|
||||
addShow = true,
|
||||
editShow = true,
|
||||
deleteShow = true,
|
||||
searchShow = true,
|
||||
operateShow = true,
|
||||
paginationShow = true,
|
||||
keywordSearchShow = true,
|
||||
|
||||
toolBarRender: customToolBalRender,
|
||||
actionBarRender: customActionBarRender,
|
||||
operateRender: customOperateRender,
|
||||
|
||||
handleRequest: customHandleRequest,
|
||||
requestParams: customRequestParams,
|
||||
handleFinish: customHandleFinish,
|
||||
} = props;
|
||||
|
||||
const {t} = useTranslation();
|
||||
const formRef = useRef<XinFormRef<T>>(null);
|
||||
|
||||
// 内部状态
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [dataSource, setDataSource] = useState<T[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [requestParams, setRequestParams] = useState<RequestParams>({
|
||||
page: DEFAULT_PAGE,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const [density, setDensity] = useState<TableProps['size']>();
|
||||
const [bordered, setBordered] = useState<boolean>();
|
||||
|
||||
const [searchRef] = Form.useForm<T>();
|
||||
const [columnsChecked, setColumnsChecked] = useState<any[]>([]);
|
||||
const [searchRender, setSearchRender] = useState<boolean>(false);
|
||||
|
||||
// 表单模式状态
|
||||
const [formMode, setFormMode] = useState<FormMode>('create');
|
||||
const [formDefaultValues, setFormDefaultValues] = useState<T | undefined>(undefined);
|
||||
|
||||
/** 表格请求 */
|
||||
const handleRequest = useCallback(async (params?: RequestParams) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const defaultParams: RequestParams = Object.assign({
|
||||
page: DEFAULT_PAGE,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
}, params);
|
||||
// 自定义参数处理
|
||||
const finalParams: RequestParams = customRequestParams ? customRequestParams(defaultParams) : defaultParams;
|
||||
// 自定义请求
|
||||
let listData: { data: T[]; total: number };
|
||||
if (customHandleRequest) {
|
||||
listData = await customHandleRequest(finalParams);
|
||||
} else {
|
||||
const { data } = await List<T>(api, finalParams);
|
||||
listData = data.data!;
|
||||
}
|
||||
setDataSource(listData.data);
|
||||
setTotal(listData.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [api, customHandleRequest, customRequestParams]);
|
||||
|
||||
/** 新增按钮点击 */
|
||||
const handleCreate = useCallback(() => {
|
||||
setFormMode('create');
|
||||
setFormDefaultValues(undefined);
|
||||
formRef.current?.resetFields();
|
||||
formRef.current?.open();
|
||||
}, []);
|
||||
|
||||
/** 修改按钮点击 */
|
||||
const handleUpdate = useCallback((record: T) => {
|
||||
setFormMode('update');
|
||||
setFormDefaultValues(record);
|
||||
formRef.current?.setFieldsValue(record);
|
||||
formRef.current?.open();
|
||||
}, []);
|
||||
|
||||
// 暴露方法到 tableRef
|
||||
useImperativeHandle(tableRef, (): XinTableInstance<T> => ({
|
||||
reload: async () => { await handleRequest(); },
|
||||
reset: async () => {
|
||||
searchRef.resetFields();
|
||||
setRequestParams({ page: 1, pageSize: 10 });
|
||||
await handleRequest({ page: 1, pageSize: 10 });
|
||||
},
|
||||
getDataSource: () => dataSource,
|
||||
setDataSource,
|
||||
getTotal: () => total,
|
||||
getLoading: () => loading,
|
||||
setLoading,
|
||||
setPageInfo: (page?, pageSize?) => {
|
||||
setRequestParams(prev => ({
|
||||
...prev,
|
||||
...(page !== undefined && { page }),
|
||||
...(pageSize !== undefined && { pageSize }),
|
||||
}));
|
||||
},
|
||||
getForm: () => formRef.current,
|
||||
getSearchForm: () => searchRef,
|
||||
}));
|
||||
|
||||
/** 初始化 */
|
||||
useEffect(() => { void handleRequest(); }, []);
|
||||
|
||||
/** 处理表格变化 */
|
||||
const handleTableChange: TableProps<T>['onChange'] = async (newPagination, newFilters, newSorter) => {
|
||||
const params: RequestParams = {
|
||||
...requestParams,
|
||||
page: newPagination.current ?? requestParams.page,
|
||||
pageSize: newPagination.pageSize ?? requestParams.pageSize,
|
||||
};
|
||||
// 处理筛选
|
||||
if (!isEmpty(newFilters)) {
|
||||
params.filterValues = newFilters;
|
||||
}
|
||||
// 处理排序
|
||||
if (newSorter && !isArray(newSorter) && !isEmpty(newSorter) && newSorter.field) {
|
||||
params.sorterValue = {
|
||||
field: String(newSorter.field),
|
||||
order: newSorter.order === 'ascend' ? 'asc' : 'desc',
|
||||
};
|
||||
} else {
|
||||
delete params.sorterValue;
|
||||
}
|
||||
setRequestParams(params);
|
||||
await handleRequest(params);
|
||||
};
|
||||
|
||||
/** 快速搜索 */
|
||||
const handleKeywordSearch: SearchProps['onSearch'] = async (value: string) => {
|
||||
if( !value ) {
|
||||
window.$message?.warning(t('xin.table.keywordEmpty'));
|
||||
return;
|
||||
}
|
||||
const params: RequestParams = {
|
||||
...requestParams,
|
||||
page: 1,
|
||||
keywordSearch: value,
|
||||
}
|
||||
setRequestParams(params);
|
||||
await handleRequest(params);
|
||||
};
|
||||
|
||||
/** 快速搜索 Change */
|
||||
const keywordSearchChange: SearchProps['onChange'] = (e) => {
|
||||
if( !e.target.value ) {
|
||||
const params = { ...requestParams };
|
||||
delete params.keywordSearch;
|
||||
setRequestParams(params);
|
||||
} else {
|
||||
setRequestParams({
|
||||
...requestParams,
|
||||
keywordSearch: e.target.value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** 搜索表单提交 */
|
||||
const handleSearch = async () => {
|
||||
const searchValues: T = searchRef.getFieldsValue();
|
||||
// 移除 空值
|
||||
Object.keys(searchValues).forEach((key) => {
|
||||
if (searchValues[key] === '' || searchValues[key] === undefined) {
|
||||
delete searchValues[key];
|
||||
}
|
||||
});
|
||||
await handleRequest({
|
||||
page: 1,
|
||||
...requestParams,
|
||||
...searchValues,
|
||||
});
|
||||
};
|
||||
|
||||
/** 搜索列 */
|
||||
const searchColumn: FormColumn<T>[] = useMemo(() => {
|
||||
if(!searchShow) return [];
|
||||
return columns.filter((column) => column.hideInSearch !== true);
|
||||
}, [columns, searchShow]);
|
||||
|
||||
/** 表单列 */
|
||||
const formColumn: FormColumn<T>[] = useMemo(() => {
|
||||
return columns.filter((column) => {
|
||||
if(formMode === 'update') {
|
||||
return column.hideInForm !== true && column.hideInUpdate !== true;
|
||||
} else {
|
||||
return column.hideInForm !== true && column.hideInCreate !== true;
|
||||
}
|
||||
});
|
||||
}, [columns, formMode]);
|
||||
|
||||
/** 默认表格列 */
|
||||
const defaultTableColumns = useMemo(() => {
|
||||
return columns
|
||||
.filter((column) => column.hideInTable !== true && column.dataIndex)
|
||||
.map(column => omit(column, ['hideInTable', 'hideInForm', 'hideInSearch', 'search']));
|
||||
}, [columns]);
|
||||
|
||||
/** 初始化列设置树数据 */
|
||||
useEffect(() => {
|
||||
const dataIndexList = defaultTableColumns.map(item => item.dataIndex!);
|
||||
setColumnsChecked(dataIndexList);
|
||||
}, [defaultTableColumns]);
|
||||
|
||||
/** 表格操作列渲染 */
|
||||
const operateRender = (record: T): ReactNode[] => {
|
||||
// 编辑按钮渲染
|
||||
const editRender = (): ReactNode => {
|
||||
const show = typeof editShow === 'function' ? editShow(record) : editShow;
|
||||
return show ? (
|
||||
<AuthButton auth={props.accessName + '.update'} key={'update'}>
|
||||
<Tooltip title={t('xin.table.edit')}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
size={'small'}
|
||||
onClick={() => handleUpdate(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</AuthButton>
|
||||
) : null;
|
||||
}
|
||||
// 删除按钮渲染
|
||||
const deleteRender = (): ReactNode => {
|
||||
const show = typeof deleteShow === 'function' ? deleteShow(record) : deleteShow;
|
||||
return show ? (
|
||||
<AuthButton auth={props.accessName + '.delete'} key={'delete'}>
|
||||
<Tooltip title={t('xin.table.delete')}>
|
||||
<Button
|
||||
danger
|
||||
type="primary"
|
||||
icon={<DeleteOutlined />}
|
||||
size={'small'}
|
||||
onClick={() => handleDelete(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</AuthButton>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (customOperateRender) {
|
||||
return customOperateRender(record, {
|
||||
del: deleteRender(),
|
||||
edit: editRender()
|
||||
});
|
||||
}
|
||||
return [deleteRender(), editRender()];
|
||||
};
|
||||
|
||||
/** 删除记录 */
|
||||
const handleDelete = async (record: T) => {
|
||||
window.$modal?.confirm({
|
||||
title: t('xin.table.deleteConfirm', { id: record[rowKey] }),
|
||||
okText: t('xin.table.deleteOk'),
|
||||
cancelText: t('xin.table.deleteCancel'),
|
||||
onOk: async () => {
|
||||
await Delete(api + `/${record[rowKey]}`);
|
||||
window.$message?.success(t('xin.table.deleteSuccess'));
|
||||
await handleRequest(requestParams);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
/** 表单提交处理 */
|
||||
const handleFinish = async (values: T) => {
|
||||
try {
|
||||
formRef.current?.setLoading(true);
|
||||
// 如果有自定义 onFinish,优先调用
|
||||
if (customHandleFinish) {
|
||||
const result = await customHandleFinish(values, formMode, formRef, formDefaultValues);
|
||||
if (result) {
|
||||
await handleRequest(requestParams);
|
||||
formRef.current?.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (formMode === 'create') {
|
||||
await Create(api, values);
|
||||
window.$message?.success(t('xin.table.form.createSuccess'));
|
||||
} else {
|
||||
if (formDefaultValues && rowKey) {
|
||||
await Update(api + `/${formDefaultValues[rowKey]}`, values);
|
||||
window.$message?.success(t('xin.table.form.updateSuccess'));
|
||||
} else {
|
||||
window.$message?.error(t('xin.table.form.updateKeyUndefined'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
await handleRequest(requestParams);
|
||||
formRef.current?.close();
|
||||
} finally {
|
||||
formRef.current?.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 密度设置菜单 */
|
||||
const densityMenu = {
|
||||
items: [
|
||||
{
|
||||
key: 'large',
|
||||
label: t('xin.table.density.default'),
|
||||
onClick: () => setDensity('large'),
|
||||
},
|
||||
{
|
||||
key: 'middle',
|
||||
label: t('xin.table.density.middle'),
|
||||
onClick: () => setDensity('middle'),
|
||||
},
|
||||
{
|
||||
key: 'small',
|
||||
label: t('xin.table.density.compact'),
|
||||
onClick: () => setDensity('small'),
|
||||
},
|
||||
],
|
||||
selectedKeys: [density || 'large'],
|
||||
};
|
||||
|
||||
/** 列设置树数据 */
|
||||
const columnTreeData: DataNode[] = useMemo(() => {
|
||||
const filteredColumns = columns.filter(item => item.hideInTable !== true && item.dataIndex);
|
||||
return filteredColumns
|
||||
.map((item) => ({
|
||||
key: String(item.dataIndex),
|
||||
title: item.title,
|
||||
}));
|
||||
}, [columns]);
|
||||
|
||||
/** 列设置选中改变 */
|
||||
const columnSettingCheck: TreeProps['onCheck'] = (keys) => {
|
||||
if(isArray(keys)) {
|
||||
setColumnsChecked(keys);
|
||||
}
|
||||
}
|
||||
|
||||
/** 最终表格列,计算排序以及显示状态 */
|
||||
const tableColumns: TableColumnType<T>[] = useMemo(() => {
|
||||
// 过滤出选中的列
|
||||
const filteredColumns = defaultTableColumns.filter(column =>
|
||||
columnsChecked.includes(column.dataIndex as any)
|
||||
);
|
||||
if(! operateShow) {
|
||||
return filteredColumns;
|
||||
}
|
||||
return [
|
||||
...filteredColumns,
|
||||
{
|
||||
title: t('xin.table.operate'),
|
||||
key: 'operate',
|
||||
align: 'center',
|
||||
...operateProps,
|
||||
render: (_, record: T) => (
|
||||
<Space>{...operateRender(record)}</Space>
|
||||
),
|
||||
}
|
||||
];
|
||||
}, [defaultTableColumns, columnsChecked, operateShow]);
|
||||
|
||||
/** 分页配置 */
|
||||
const paginationProps: TableProps['pagination'] = {
|
||||
showQuickJumper: true,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => t('xin.table.total', { total }),
|
||||
pageSize: requestParams.pageSize,
|
||||
...customPagination,
|
||||
current: requestParams.page,
|
||||
total,
|
||||
}
|
||||
|
||||
/** 顶部操作栏渲染 */
|
||||
const actionBarRender = (): ReactNode[] => {
|
||||
// 新增按钮渲染
|
||||
const addButtonRender = addShow ? (
|
||||
<AuthButton auth={accessName + '.create'}>
|
||||
<Button type="primary" onClick={handleCreate} icon={<PlusOutlined />}>{t('xin.table.add')}</Button>
|
||||
</AuthButton>
|
||||
) : null;
|
||||
// 搜索按钮渲染
|
||||
const searchButtonRender = searchShow ? (
|
||||
<Button type="primary" icon={<SearchOutlined/>} onClick={() => setSearchRender(!searchRender)}>
|
||||
搜索
|
||||
</Button>
|
||||
) : null;
|
||||
// 关键字搜索按钮渲染
|
||||
const keywordSearchRender = keywordSearchShow ? (
|
||||
<Input.Search
|
||||
onChange={keywordSearchChange}
|
||||
placeholder={t('xin.table.keywordPlaceholder')}
|
||||
style={{ width: 200 }}
|
||||
value={requestParams.keywordSearch}
|
||||
onSearch={handleKeywordSearch}
|
||||
/>
|
||||
) : null;
|
||||
// 自定义顶部操作栏渲染
|
||||
if (customActionBarRender) {
|
||||
return customActionBarRender({
|
||||
add: addButtonRender,
|
||||
search: searchButtonRender,
|
||||
keywordSearch: keywordSearchRender
|
||||
}).filter(Boolean);
|
||||
} else {
|
||||
return [addButtonRender, searchButtonRender, keywordSearchRender].filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
/** 顶部工具栏渲染 */
|
||||
const toolBarRender = (): ReactNode[] => {
|
||||
// 刷新
|
||||
const reload = (
|
||||
<Tooltip title={'刷新表格'}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => handleRequest()}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
// 密度设置
|
||||
const columnHeight = (
|
||||
<Dropdown menu={densityMenu} trigger={['click']}>
|
||||
<Button type="text" icon={<ColumnHeightOutlined />} />
|
||||
</Dropdown>
|
||||
);
|
||||
// 边框设置
|
||||
const hideBorder = (
|
||||
<Tooltip title={bordered ? t('xin.table.hideBorder') : t('xin.table.showBorder')}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={bordered ? <BorderOutlined /> : <BorderlessTableOutlined />}
|
||||
onClick={() => setBordered(!bordered)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
// 列设置
|
||||
const columnSetting = (
|
||||
<Popover
|
||||
content={(
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: { Tree: { switcherSize: 12 } },
|
||||
}}
|
||||
>
|
||||
<Tree
|
||||
icon={false}
|
||||
blockNode
|
||||
checkable={true}
|
||||
treeData={columnTreeData}
|
||||
selectable={false}
|
||||
checkedKeys={columnsChecked}
|
||||
onCheck={columnSettingCheck}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
|
||||
)}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
title={t('xin.table.columnSettings')}
|
||||
>
|
||||
<Tooltip title={t('xin.table.columnSettings')}>
|
||||
<Button type="text" icon={<SettingOutlined />} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
// 自定义顶部操作栏渲染
|
||||
if (customToolBalRender) {
|
||||
return customToolBalRender({reload, columnHeight, hideBorder, columnSetting}).filter(Boolean);
|
||||
} else {
|
||||
return [reload, columnHeight, hideBorder, columnSetting];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card {...cardProps}>
|
||||
{/* 搜索表单 */}
|
||||
{ searchRender && (
|
||||
<>
|
||||
<SearchForm<T>
|
||||
form={searchRef}
|
||||
columns={searchColumn}
|
||||
handleSearch={handleSearch}
|
||||
{...searchProps}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
{/* 操作栏 */}
|
||||
<Flex justify={'space-between'} align={'center'} style={{ marginBottom: 20 }}>
|
||||
<Space>{ ...actionBarRender() }</Space>
|
||||
<Space size={1}>{...toolBarRender()}</Space>
|
||||
</Flex>
|
||||
{/* 表格 */}
|
||||
<Table
|
||||
loading={loading}
|
||||
dataSource={dataSource}
|
||||
size={density}
|
||||
bordered={bordered}
|
||||
{...props}
|
||||
columns={tableColumns}
|
||||
rowKey={rowKey}
|
||||
onChange={handleTableChange}
|
||||
pagination={paginationShow ? paginationProps : false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 新增编辑表单 */}
|
||||
<XinForm
|
||||
{...formProps}
|
||||
columns={formColumn}
|
||||
formRef={formRef}
|
||||
layoutType="ModalForm"
|
||||
modalProps={{
|
||||
title: formMode === 'update' ? t('xin.table.form.editTitle') : t('xin.table.form.createTitle'),
|
||||
styles: { header: { marginBottom: 16 } },
|
||||
...modalProps,
|
||||
}}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type {
|
||||
TableProps,
|
||||
TableColumnType,
|
||||
CardProps,
|
||||
FormInstance,
|
||||
PaginationProps,
|
||||
} from 'antd';
|
||||
import type {ReactNode, RefObject, Dispatch, SetStateAction} from 'react';
|
||||
import type { FormColumn } from '@/components/XinFormField/FieldRender/typings';
|
||||
import type { SearchFormProps } from './SearchForm';
|
||||
import type {XinFormProps, XinFormRef} from '@/components/XinForm/typings';
|
||||
|
||||
/**
|
||||
* 表单模式
|
||||
*/
|
||||
export type FormMode = 'create' | 'update';
|
||||
|
||||
/**
|
||||
* 表格列配置
|
||||
*/
|
||||
export type XinTableColumn<T = any> = Omit<TableColumnType<T>, 'dataIndex'> & {
|
||||
hideInSearch?: boolean;
|
||||
hideInForm?: boolean;
|
||||
hideInTable?: boolean;
|
||||
hideInUpdate?: boolean;
|
||||
hideInCreate?: boolean;
|
||||
search?: FormColumn<T>;
|
||||
} & FormColumn<T>;
|
||||
|
||||
/**
|
||||
* 表格操作栏按钮
|
||||
*/
|
||||
export type OperateNode = {
|
||||
/** 删除按钮 */
|
||||
del: ReactNode;
|
||||
/** 编辑按钮 */
|
||||
edit: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部操作按钮渲染
|
||||
*/
|
||||
export type ActionNode = {
|
||||
/** 新增按钮 */
|
||||
add: ReactNode;
|
||||
/** 搜索按钮 */
|
||||
search: ReactNode;
|
||||
/** 关键字搜索框 */
|
||||
keywordSearch: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部右侧工具栏渲染
|
||||
*/
|
||||
export type ToolBarNode = {
|
||||
/** 刷新按钮 */
|
||||
reload: ReactNode;
|
||||
/** 密度设置按钮 */
|
||||
columnHeight: ReactNode;
|
||||
/** 边框设置按钮 */
|
||||
hideBorder: ReactNode;
|
||||
/** 列设置按钮 */
|
||||
columnSetting: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* XinTable 实例
|
||||
*/
|
||||
export interface XinTableInstance<T = any> {
|
||||
/** 刷新表格(保持当前页) */
|
||||
reload: (resetPage?: boolean) => Promise<void>;
|
||||
/** 重置表格到第一页并刷新 */
|
||||
reset: () => Promise<void>;
|
||||
/** 获取当前数据源 */
|
||||
getDataSource: () => T[];
|
||||
/** 设置数据源 */
|
||||
setDataSource: Dispatch<SetStateAction<T[]>>;
|
||||
/** 获取数据总数 */
|
||||
getTotal: () => number;
|
||||
/** 获取加载状态 */
|
||||
getLoading: () => boolean;
|
||||
/** 设置加载状态 */
|
||||
setLoading: Dispatch<SetStateAction<boolean>>;
|
||||
/** 设置分页参数 */
|
||||
setPageInfo: (page?: number, pageSize?: number) => void;
|
||||
/** 获取表单实例 */
|
||||
getForm: () => XinFormRef<T> | null | undefined;
|
||||
/** 获取搜索表单实例 */
|
||||
getSearchForm: () => FormInstance<T> | undefined;
|
||||
}
|
||||
|
||||
export interface SorterParams {
|
||||
field: string;
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/** 请求参数类型 */
|
||||
export interface RequestParams extends Record<string, any> {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
filter?: Record<string, any>;
|
||||
sorter?: SorterParams;
|
||||
keywordSearch?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* XinTable 组件属性
|
||||
*/
|
||||
export interface XinTableProps<T = any> extends Omit<TableProps<T>, 'columns' | 'rowKey' | 'onChange' | 'pagination'> {
|
||||
/** API 地址 */
|
||||
api: string;
|
||||
/** 权限名称前缀 */
|
||||
accessName: string;
|
||||
/** 主键 */
|
||||
rowKey: string;
|
||||
/** 列配置 */
|
||||
columns: XinTableColumn<T>[];
|
||||
|
||||
/** 表格 ref */
|
||||
tableRef?: RefObject<XinTableInstance<T> | null>;
|
||||
|
||||
/** 新增按钮显示 */
|
||||
addShow?: boolean;
|
||||
/** 编辑按钮显示 */
|
||||
editShow?: boolean | ((record: T) => boolean);
|
||||
/** 删除按钮显示 */
|
||||
deleteShow?: boolean | ((record: T) => boolean);
|
||||
/** 搜索栏显示 */
|
||||
searchShow?: boolean;
|
||||
/** 表格操作列显示 */
|
||||
operateShow?: boolean;
|
||||
/** 分页显示 */
|
||||
paginationShow?: boolean;
|
||||
/** 快速搜索显示 */
|
||||
keywordSearchShow?: boolean;
|
||||
|
||||
/** 表单属性 */
|
||||
formProps?: Omit<XinFormProps<T>, 'onFinish' | 'modalProps' | 'columns' | 'formRef' | 'layoutType'> | false;
|
||||
/** 表单属性 */
|
||||
modalProps?: XinFormProps<T>['modalProps'];
|
||||
/** 搜索栏属性 */
|
||||
searchProps?: Omit<SearchFormProps<T>, 'form'> | false;
|
||||
/** 操作栏属性 */
|
||||
operateProps?: TableColumnType<T>;
|
||||
/** 卡片属性 */
|
||||
cardProps?: Pick<CardProps, 'variant' | 'hoverable' | 'size' | 'classNames' | 'styles'>;
|
||||
/** 分页配置 */
|
||||
pagination?: Omit<PaginationProps, 'total' | 'onChange' | 'current'>;
|
||||
|
||||
/** 顶部操作栏渲染 */
|
||||
actionBarRender?: ((dom: ActionNode) => ReactNode[]);
|
||||
/** 工具栏渲染 */
|
||||
toolBarRender?: ((dom: ToolBarNode) => ReactNode[]);
|
||||
/** 表格操作栏渲染 */
|
||||
operateRender?: ((record: T, dom: OperateNode) => ReactNode[]);
|
||||
|
||||
/** 自定义请求 */
|
||||
handleRequest?: (params: RequestParams) => Promise<{ data: T[]; total: number }>;
|
||||
/** 请求参数处理 */
|
||||
requestParams?: (params: RequestParams) => RequestParams;
|
||||
/** 自定义表单请求 */
|
||||
handleFinish?: (values: T, mode: FormMode, formRef: RefObject<XinFormRef<T> | null>, defaultValue?: T) => Promise<boolean>;
|
||||
}
|
||||
Reference in New Issue
Block a user