diff --git a/src/components/Dialog/README.md b/src/components/Dialog/README.md new file mode 100644 index 0000000..8fda4c7 --- /dev/null +++ b/src/components/Dialog/README.md @@ -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(null); + + const handleConfirm = async () => { + // 执行异步操作 + await api.deleteUser(); + }; + + return ( + <> + + + + 确定要删除这条记录吗? + + + ); +} +``` + +### 2. 受控模式 + +```tsx +import { useState } from 'react'; +import Dialog from '@/components/Dialog'; + +function Demo() { + const [visible, setVisible] = useState(false); + + return ( + <> + + + +
用户详细信息...
+
+ + ); +} +``` + +## 高级用法 + +### 1. beforeClose 钩子 + +```tsx + { + // 检查是否有未保存的修改 + if (hasUnsavedChanges) { + const confirmed = await showConfirm('有未保存的修改,确定关闭吗?'); + return confirmed; + } + return true; + }} +> + + +``` + +### 2. 自定义底部按钮 + +```tsx + ( + + + + + + )} +> + + +``` + +### 3. 自定义头部 + +```tsx + + + 用户详情 + + VIP + + + } +> + + +``` + +### 4. 隐藏底部按钮 + +```tsx + + + +``` + +### 5. 只显示确认按钮 + +```tsx + + 操作成功! + +``` + +### 6. 异步确认操作 + +```tsx + { + // 组件会自动显示 loading 状态 + await api.submitForm(formData); + Message.success('提交成功'); + }} +> +
+
+``` + +## 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` | - | +| onCancel | 点击取消按钮的回调 | `() => void` | - | +| beforeClose | 关闭前的回调,返回 false 可阻止关闭 | `() => boolean \| Promise` | - | +| 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(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 ( + <> + + + + setHasChanges(true)}> + + + + + + + + + + ); +} +``` + +## 最佳实践 + +1. **统一管理弹框状态** + + ```tsx + // 推荐:使用 ref 统一管理 + const dialogRef = useRef(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(); + }; + ``` diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx new file mode 100644 index 0000000..f798ea3 --- /dev/null +++ b/src/components/Dialog/index.tsx @@ -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(null); + * + * + * 确定要执行此操作吗? + * + * + * // 调用方法 + * dialogRef.current?.open(); + * ``` + */ +const Dialog = forwardRef((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 ( +
+ + {showCancel && ( + + )} + {showConfirm && ( + + )} + +
+ ); + }; + + // 自定义头部渲染 + const renderHeader = () => { + if (header !== undefined) { + return header; + } + return title; + }; + + return ( + { + if (typeof mountContainer === 'string') { + return document.querySelector(mountContainer) || document.body; + } + return mountContainer; + } + : undefined + } + > +
+ {children} +
+
+ ); +}); + +Dialog.displayName = 'Dialog'; + +export default Dialog; +export type { DialogProps, DialogRef }; diff --git a/src/components/Dialog/interface.ts b/src/components/Dialog/interface.ts new file mode 100644 index 0000000..1300a4d --- /dev/null +++ b/src/components/Dialog/interface.ts @@ -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; + /** 点击取消按钮的回调 */ + onCancel?: () => void; + /** 关闭前的回调,返回 false 可以阻止关闭 */ + beforeClose?: () => boolean | Promise; + /** 关闭后的回调 */ + onClose?: () => void; + /** 打开后的回调 */ + onOpen?: () => void; + /** 受控模式:是否显示 */ + visible?: boolean; + /** 受控模式:显示状态变化回调 */ + onVisibleChange?: (visible: boolean) => void; +} + +/** + * Dialog 组件实例方法 + */ +export interface DialogRef { + /** 打开弹框 */ + open: () => void; + /** 关闭弹框 */ + close: () => void; + /** 快速关闭弹框(不触发 beforeClose) */ + forceClose: () => void; + /** 获取当前显示状态 */ + isVisible: () => boolean; +} diff --git a/src/components/Dialog/style/index.module.less b/src/components/Dialog/style/index.module.less new file mode 100644 index 0000000..9082ce9 --- /dev/null +++ b/src/components/Dialog/style/index.module.less @@ -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); + } + } + } + } +} diff --git a/src/components/Pagination/index.tsx b/src/components/Pagination/index.tsx new file mode 100644 index 0000000..3ca390c --- /dev/null +++ b/src/components/Pagination/index.tsx @@ -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 = (props) => { + const { className, ...restProps } = props; + + return ( +
+ `共 ${total} 条`} + /> +
+ ); +}; + +export default Pagination; diff --git a/src/components/Pagination/style/index.module.less b/src/components/Pagination/style/index.module.less new file mode 100644 index 0000000..259c84c --- /dev/null +++ b/src/components/Pagination/style/index.module.less @@ -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)); + } + } + } + } + } + } +}