first commit

This commit is contained in:
liu
2026-07-13 15:23:29 +08:00
commit 50885a98c8
473 changed files with 33772 additions and 0 deletions
@@ -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;
}
+9
View File
@@ -0,0 +1,9 @@
import IconSelector from './IconSelector';
import ImageUploader from './ImageUploader';
import UserSelector from './UserSelector';
export default {
IconSelector,
ImageUploader,
UserSelector
}