feat(通用组件): 新增 Dialog 和 Pagination 组件

- Dialog: 封装可复用的弹窗组件,支持受控和非受控模式
- Pagination: 封装统一样式的分页组件,匹配系统主题色
- 优化组件API设计,提升开发体验
This commit is contained in:
gaoziman 2025-11-05 23:49:28 +08:00
parent a44b06cc15
commit a495a3c115
6 changed files with 935 additions and 0 deletions

View File

@ -0,0 +1,344 @@
# Dialog 弹框通用组件
基于 Arco Design Modal 封装的通用弹框组件,提供更丰富的功能和更好的用户体验。
## 特性
- ✅ 完整的 TypeScript 类型支持
- ✅ 支持受控和非受控两种模式
- ✅ 提供 `beforeClose` 钩子,可以阻止关闭
- ✅ 支持异步确认操作
- ✅ 自定义头部、内容、底部渲染
- ✅ 响应式主题适配(亮色/暗色)
- ✅ 美观的 UI 设计
- ✅ 完善的 ref 方法调用
## 基础用法
### 1. 非受控模式(推荐)
```tsx
import { useRef } from 'react';
import Dialog, { DialogRef } from '@/components/Dialog';
function Demo() {
const dialogRef = useRef<DialogRef>(null);
const handleConfirm = async () => {
// 执行异步操作
await api.deleteUser();
};
return (
<>
<Button onClick={() => dialogRef.current?.open()}>打开弹框</Button>
<Dialog ref={dialogRef} title="确认删除" onConfirm={handleConfirm}>
确定要删除这条记录吗?
</Dialog>
</>
);
}
```
### 2. 受控模式
```tsx
import { useState } from 'react';
import Dialog from '@/components/Dialog';
function Demo() {
const [visible, setVisible] = useState(false);
return (
<>
<Button onClick={() => setVisible(true)}>打开弹框</Button>
<Dialog visible={visible} onVisibleChange={setVisible} title="用户信息">
<div>用户详细信息...</div>
</Dialog>
</>
);
}
```
## 高级用法
### 1. beforeClose 钩子
```tsx
<Dialog
ref={dialogRef}
title="编辑用户"
beforeClose={async () => {
// 检查是否有未保存的修改
if (hasUnsavedChanges) {
const confirmed = await showConfirm('有未保存的修改,确定关闭吗?');
return confirmed;
}
return true;
}}
>
<UserForm />
</Dialog>
```
### 2. 自定义底部按钮
```tsx
<Dialog
ref={dialogRef}
title="批量操作"
footer={(close) => (
<Space>
<Button onClick={close}>取消</Button>
<Button type="primary" onClick={handleBatchDelete}>
批量删除
</Button>
<Button type="primary" status="warning" onClick={handleBatchExport}>
批量导出
</Button>
</Space>
)}
>
<BatchOperationForm />
</Dialog>
```
### 3. 自定义头部
```tsx
<Dialog
ref={dialogRef}
header={
<div style={{ display: 'flex', alignItems: 'center' }}>
<IconUser style={{ marginRight: 8 }} />
<span>用户详情</span>
<Tag color="blue" style={{ marginLeft: 'auto' }}>
VIP
</Tag>
</div>
}
>
<UserDetail />
</Dialog>
```
### 4. 隐藏底部按钮
```tsx
<Dialog ref={dialogRef} title="查看详情" footerHidden>
<DetailView />
</Dialog>
```
### 5. 只显示确认按钮
```tsx
<Dialog
ref={dialogRef}
title="提示信息"
showCancel={false}
confirmText="我知道了"
>
操作成功!
</Dialog>
```
### 6. 异步确认操作
```tsx
<Dialog
ref={dialogRef}
title="提交表单"
onConfirm={async () => {
// 组件会自动显示 loading 状态
await api.submitForm(formData);
Message.success('提交成功');
}}
>
<Form />
</Dialog>
```
## API
### Props
| 参数 | 说明 | 类型 | 默认值 |
| --------------- | ----------------------------------- | ----------------------------------- | -------- |
| title | 弹框标题 | `ReactNode` | `'提示'` |
| width | 弹框宽度 | `number \| string` | `520` |
| height | 内容区域高度 | `number \| string` | `'auto'` |
| confirmText | 确认按钮文本 | `string` | `'确定'` |
| cancelText | 取消按钮文本 | `string` | `'取消'` |
| showConfirm | 是否显示确认按钮 | `boolean` | `true` |
| showCancel | 是否显示取消按钮 | `boolean` | `true` |
| confirmLoading | 确认按钮加载状态 | `boolean` | `false` |
| confirmDisabled | 确认按钮禁用状态 | `boolean` | `false` |
| footerHidden | 隐藏底部按钮区域 | `boolean` | `false` |
| maskClosable | 点击遮罩层是否关闭 | `boolean` | `false` |
| escToClose | ESC 键是否关闭 | `boolean` | `true` |
| closable | 是否显示关闭按钮 | `boolean` | `true` |
| className | 自定义类名 | `string` | - |
| children | 弹框内容 | `ReactNode` | - |
| footer | 自定义底部渲染 | `ReactNode \| (close) => ReactNode` | - |
| header | 自定义头部渲染 | `ReactNode` | - |
| alignCenter | 是否居中显示 | `boolean` | `true` |
| mountContainer | 挂载容器 | `string \| HTMLElement` | - |
| onConfirm | 点击确认按钮的回调 | `() => void \| Promise<void>` | - |
| onCancel | 点击取消按钮的回调 | `() => void` | - |
| beforeClose | 关闭前的回调,返回 false 可阻止关闭 | `() => boolean \| Promise<boolean>` | - |
| onClose | 关闭后的回调 | `() => void` | - |
| onOpen | 打开后的回调 | `() => void` | - |
| visible | 受控模式:是否显示 | `boolean` | - |
| onVisibleChange | 受控模式:显示状态变化回调 | `(visible: boolean) => void` | - |
### Ref 方法
| 方法 | 说明 | 类型 |
| ---------- | ---------------------------------- | --------------- |
| open | 打开弹框 | `() => void` |
| close | 关闭弹框 | `() => void` |
| forceClose | 强制关闭弹框(不触发 beforeClose | `() => void` |
| isVisible | 获取当前显示状态 | `() => boolean` |
## 设计原则
### 与 Vue CoiDialog 对比改进
| 特性 | Vue CoiDialog | React Dialog |
| ---------------- | ------------- | ------------------- |
| TypeScript 支持 | ✅ | ✅ |
| 受控/非受控模式 | ❌ | ✅ |
| beforeClose 钩子 | ❌ | ✅ |
| 响应式主题 | ❌ | ✅ |
| 异步确认处理 | 手动 | 自动 |
| 方法调用 | defineExpose | useImperativeHandle |
### 样式特点
- 圆角设计12px
- 柔和阴影效果
- 清晰的分隔线
- 响应式主题适配
- 自定义滚动条样式
## 注意事项
1. **受控与非受控模式**
- 非受控模式:使用 `ref` 调用方法
- 受控模式:使用 `visible``onVisibleChange`
- 不要混用两种模式
2. **异步操作**
- `onConfirm` 返回 Promise 时,组件会自动显示 loading
- 操作失败不会关闭弹框
- 操作成功会自动关闭弹框
3. **beforeClose 钩子**
- 返回 `false` 可以阻止关闭
- 支持异步判断
- `forceClose()` 方法会跳过此钩子
4. **性能优化**
- 使用 `unmountOnExit` 卸载未显示的弹框
- 避免在循环中创建多个弹框实例
## 完整示例
```tsx
import { useRef, useState } from 'react';
import { Button, Form, Input, Message } from '@arco-design/web-react';
import Dialog, { DialogRef } from '@/components/Dialog';
function UserManagement() {
const dialogRef = useRef<DialogRef>(null);
const [formData, setFormData] = useState({});
const [hasChanges, setHasChanges] = useState(false);
const handleSubmit = async () => {
try {
await api.updateUser(formData);
Message.success('保存成功');
setHasChanges(false);
} catch (error) {
Message.error('保存失败');
throw error; // 抛出错误,阻止弹框关闭
}
};
const handleBeforeClose = async () => {
if (hasChanges) {
return await new Promise((resolve) => {
Modal.confirm({
title: '确认关闭',
content: '有未保存的修改,确定关闭吗?',
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
}
return true;
};
return (
<>
<Button type="primary" onClick={() => dialogRef.current?.open()}>
编辑用户
</Button>
<Dialog
ref={dialogRef}
title="编辑用户信息"
width={600}
onConfirm={handleSubmit}
beforeClose={handleBeforeClose}
>
<Form onChange={() => setHasChanges(true)}>
<Form.Item label="用户名">
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item label="邮箱">
<Input placeholder="请输入邮箱" />
</Form.Item>
</Form>
</Dialog>
</>
);
}
```
## 最佳实践
1. **统一管理弹框状态**
```tsx
// 推荐:使用 ref 统一管理
const dialogRef = useRef<DialogRef>(null);
```
2. **错误处理**
```tsx
onConfirm={async () => {
try {
await api.submit();
} catch (error) {
Message.error(error.message);
throw error; // 阻止关闭
}
}}
```
3. **复用弹框实例**
```tsx
// 推荐:一个弹框实例处理多种情况
const openEditDialog = (user) => {
setCurrentUser(user);
dialogRef.current?.open();
};
```

View File

@ -0,0 +1,224 @@
import React, {
forwardRef,
useImperativeHandle,
useState,
useCallback,
useEffect,
} from 'react';
import { Modal, Button, Space } from '@arco-design/web-react';
import type { DialogProps, DialogRef } from './interface';
import styles from './style/index.module.less';
/**
* Dialog
*
* @example
* ```tsx
* const dialogRef = useRef<DialogRef>(null);
*
* <Dialog
* ref={dialogRef}
* title="确认操作"
* onConfirm={handleConfirm}
* >
*
* </Dialog>
*
* // 调用方法
* dialogRef.current?.open();
* ```
*/
const Dialog = forwardRef<DialogRef, DialogProps>((props, ref) => {
const {
title = '提示',
width = 520,
height = 'auto',
confirmText = '确定',
cancelText = '取消',
showConfirm = true,
showCancel = true,
confirmLoading = false,
confirmDisabled = false,
footerHidden = false,
maskClosable = false,
escToClose = true,
closable = true,
className = '',
children,
footer,
header,
alignCenter = true,
mountContainer,
onConfirm,
onCancel,
beforeClose,
onClose,
onOpen,
visible: controlledVisible,
onVisibleChange,
} = props;
// 内部状态管理
const [innerVisible, setInnerVisible] = useState(false);
const [loading, setLoading] = useState(false);
// 判断是否为受控组件
const isControlled = controlledVisible !== undefined;
const visible = isControlled ? controlledVisible : innerVisible;
// 更新显示状态
const updateVisible = useCallback(
(newVisible: boolean) => {
if (!isControlled) {
setInnerVisible(newVisible);
}
onVisibleChange?.(newVisible);
},
[isControlled, onVisibleChange]
);
// 打开弹框
const handleOpen = useCallback(() => {
updateVisible(true);
onOpen?.();
}, [updateVisible, onOpen]);
// 关闭弹框
const handleClose = useCallback(
async (force = false) => {
// 如果不是强制关闭,执行 beforeClose 钩子
if (!force && beforeClose) {
try {
const canClose = await beforeClose();
if (!canClose) {
return;
}
} catch (error) {
console.error('beforeClose error:', error);
return;
}
}
updateVisible(false);
onClose?.();
},
[beforeClose, updateVisible, onClose]
);
// 确认按钮处理
const handleConfirm = useCallback(async () => {
if (!onConfirm) {
handleClose();
return;
}
try {
setLoading(true);
await onConfirm();
handleClose();
} catch (error) {
console.error('onConfirm error:', error);
} finally {
setLoading(false);
}
}, [onConfirm, handleClose]);
// 取消按钮处理
const handleCancel = useCallback(() => {
onCancel?.();
handleClose();
}, [onCancel, handleClose]);
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
open: handleOpen,
close: () => handleClose(false),
forceClose: () => handleClose(true),
isVisible: () => visible,
}));
// 自定义底部渲染
const renderFooter = () => {
if (footerHidden) {
return null;
}
if (footer) {
return typeof footer === 'function' ? footer(handleClose) : footer;
}
return (
<div className={styles['dialog-footer']}>
<Space>
{showCancel && (
<Button size="default" onClick={handleCancel}>
{cancelText}
</Button>
)}
{showConfirm && (
<Button
type="primary"
size="default"
loading={loading || confirmLoading}
disabled={confirmDisabled}
onClick={handleConfirm}
>
{confirmText}
</Button>
)}
</Space>
</div>
);
};
// 自定义头部渲染
const renderHeader = () => {
if (header !== undefined) {
return header;
}
return title;
};
return (
<Modal
className={`${styles['dialog-wrapper']} ${className}`}
visible={visible}
title={renderHeader()}
footer={renderFooter()}
onCancel={handleClose}
maskClosable={maskClosable}
escToExit={escToClose}
closable={closable}
alignCenter={alignCenter}
unmountOnExit
style={{
width: typeof width === 'number' ? `${width}px` : width,
}}
getPopupContainer={
mountContainer
? () => {
if (typeof mountContainer === 'string') {
return document.querySelector(mountContainer) || document.body;
}
return mountContainer;
}
: undefined
}
>
<div
className={styles['dialog-content']}
style={{
height: typeof height === 'number' ? `${height}px` : height,
maxHeight: height === 'auto' ? 'none' : undefined,
}}
>
{children}
</div>
</Modal>
);
});
Dialog.displayName = 'Dialog';
export default Dialog;
export type { DialogProps, DialogRef };

View File

@ -0,0 +1,73 @@
import { ReactNode } from 'react';
/**
* Dialog
*/
export interface DialogProps {
/** 弹框标题 */
title?: ReactNode;
/** 弹框宽度 */
width?: number | string;
/** 内容区域高度 */
height?: number | string;
/** 确认按钮文本 */
confirmText?: string;
/** 取消按钮文本 */
cancelText?: string;
/** 是否显示确认按钮 */
showConfirm?: boolean;
/** 是否显示取消按钮 */
showCancel?: boolean;
/** 确认按钮加载状态 */
confirmLoading?: boolean;
/** 确认按钮禁用状态 */
confirmDisabled?: boolean;
/** 隐藏底部按钮区域 */
footerHidden?: boolean;
/** 点击遮罩层是否关闭 */
maskClosable?: boolean;
/** ESC键是否关闭 */
escToClose?: boolean;
/** 是否显示关闭按钮 */
closable?: boolean;
/** 弹框类名 */
className?: string;
/** 自定义内容渲染 */
children?: ReactNode;
/** 自定义底部渲染 */
footer?: ReactNode | ((close: () => void) => ReactNode);
/** 自定义头部渲染 */
header?: ReactNode;
/** 是否居中显示 */
alignCenter?: boolean;
/** 挂载容器 */
mountContainer?: string | HTMLElement;
/** 点击确认按钮的回调 */
onConfirm?: () => void | Promise<void>;
/** 点击取消按钮的回调 */
onCancel?: () => void;
/** 关闭前的回调,返回 false 可以阻止关闭 */
beforeClose?: () => boolean | Promise<boolean>;
/** 关闭后的回调 */
onClose?: () => void;
/** 打开后的回调 */
onOpen?: () => void;
/** 受控模式:是否显示 */
visible?: boolean;
/** 受控模式:显示状态变化回调 */
onVisibleChange?: (visible: boolean) => void;
}
/**
* Dialog
*/
export interface DialogRef {
/** 打开弹框 */
open: () => void;
/** 关闭弹框 */
close: () => void;
/** 快速关闭弹框(不触发 beforeClose */
forceClose: () => void;
/** 获取当前显示状态 */
isVisible: () => boolean;
}

View File

@ -0,0 +1,113 @@
.dialog-wrapper {
// 覆盖 Arco Design Modal 默认样式
:global {
.arco-modal {
// 弹框整体样式
.arco-modal-wrapper {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 12%);
}
// 头部样式
.arco-modal-header {
padding: 16px 24px;
border-bottom: 1px solid var(--color-border-2, #f0f0f0);
background: var(--color-bg-2, #fff);
.arco-modal-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-1, #1d2129);
line-height: 24px;
}
}
// 关闭按钮样式
.arco-modal-close-icon {
width: 32px;
height: 32px;
line-height: 32px;
border-radius: 6px;
transition: all 0.2s ease;
color: var(--color-text-3, #86909c);
&:hover {
background-color: var(--color-fill-2, #f7f8fa);
color: var(--color-text-1, #1d2129);
}
}
// 内容区域样式
.arco-modal-body {
padding: 24px;
background: var(--color-bg-1, #fff);
color: var(--color-text-2, #4e5969);
font-size: 14px;
line-height: 22px;
}
// 底部样式
.arco-modal-footer {
padding: 12px 24px 16px;
border-top: 1px solid var(--color-border-2, #f0f0f0);
background: var(--color-bg-2, #fafafa);
}
}
}
}
// 内容区域
.dialog-content {
overflow-y: auto;
overflow-x: hidden;
// 自定义滚动条样式
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 20%);
border-radius: 3px;
&:hover {
background-color: rgba(0, 0, 0, 30%);
}
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
}
// 底部按钮区域
.dialog-footer {
display: flex;
justify-content: center;
align-items: center;
}
// 暗色主题适配
:global {
body[arco-theme='dark'] {
.dialog-wrapper {
.arco-modal {
.arco-modal-header {
background: var(--color-bg-3);
border-bottom-color: var(--color-border-2);
}
.arco-modal-body {
background: var(--color-bg-2);
}
.arco-modal-footer {
background: var(--color-bg-3);
border-top-color: var(--color-border-2);
}
}
}
}
}

View File

@ -0,0 +1,27 @@
import React from 'react';
import {
Pagination as ArcoPagination,
PaginationProps,
} from '@arco-design/web-react';
import styles from './style/index.module.less';
interface CustomPaginationProps extends PaginationProps {
className?: string;
}
const Pagination: React.FC<CustomPaginationProps> = (props) => {
const { className, ...restProps } = props;
return (
<div className={`${styles['custom-pagination']} ${className || ''}`}>
<ArcoPagination
{...restProps}
showJumper={false}
sizeCanChange={true}
showTotal={(total) => `${total}`}
/>
</div>
);
};
export default Pagination;

View File

@ -0,0 +1,154 @@
.custom-pagination {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 16px 0;
// 覆盖 Arco Design 分页组件的样式
:global {
.arco-pagination {
display: flex;
align-items: center;
gap: 8px;
// 总数显示
.arco-pagination-total-text {
font-size: 14px;
color: #4e5969;
margin-right: 16px;
}
// 页码按钮样式
.arco-pagination-item {
min-width: 36px;
height: 36px;
line-height: 36px;
padding: 0;
margin: 0 4px;
border: none;
border-radius: 8px;
background-color: #f2f3f5;
color: #4e5969;
font-weight: 500;
transition: all 0.2s ease;
&:hover {
background-color: #e5e6eb;
color: #1d2129;
}
// 当前选中的页码
&.arco-pagination-item-active {
background: linear-gradient(
135deg,
rgb(var(--arcoblue-6)) 0%,
rgb(var(--arcoblue-7)) 100%
);
color: #fff;
font-weight: 600;
box-shadow: 0 2px 8px rgba(var(--arcoblue-6), 0.3);
&:hover {
background: linear-gradient(
135deg,
rgb(var(--arcoblue-7)) 0%,
rgb(var(--arcoblue-8)) 100%
);
box-shadow: 0 4px 12px rgba(var(--arcoblue-6), 0.4);
}
}
// 禁用状态
&.arco-pagination-item-disabled {
background-color: #f7f8fa;
color: #c9cdd4;
cursor: not-allowed;
&:hover {
background-color: #f7f8fa;
color: #c9cdd4;
}
}
}
// 省略号
.arco-pagination-item-jumper {
min-width: 36px;
height: 36px;
line-height: 36px;
border: none;
background-color: transparent;
color: #86909c;
}
// 上一页/下一页按钮
.arco-pagination-item-previous,
.arco-pagination-item-next {
min-width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background-color: #f2f3f5;
transition: all 0.2s ease;
.arco-icon {
color: #4e5969;
}
&:not(.arco-pagination-item-disabled):hover {
background-color: #e5e6eb;
.arco-icon {
color: #1d2129;
}
}
&.arco-pagination-item-disabled {
background-color: #f7f8fa;
cursor: not-allowed;
.arco-icon {
color: #c9cdd4;
}
}
}
// 每页显示数量选择器
.arco-pagination-size-changer {
margin-left: 16px;
.arco-select-view {
border-radius: 8px;
background-color: #f2f3f5;
border: none;
&:hover {
background-color: #e5e6eb;
}
}
}
// 跳转输入框
.arco-pagination-jumper {
margin-left: 16px;
.arco-pagination-jumper-input {
.arco-input-inner-wrapper {
border-radius: 8px;
background-color: #f2f3f5;
border: none;
&:hover {
background-color: #e5e6eb;
}
&:focus-within {
background-color: #fff;
border: 1px solid rgb(var(--arcoblue-6));
}
}
}
}
}
}
}