Browse Source

feat: 对话框

BaiLuoYan 3 tháng trước cách đây
mục cha
commit
652924e75c

+ 2 - 0
src/App.tsx

@@ -6,6 +6,7 @@ import faIR from 'antd/locale/fa_IR';
 import { useTranslation } from 'react-i18next';
 import { RouterProvider } from 'react-router-dom';
 
+import { DialogContainer } from './components/Dialog/DialogContainer';
 import router from './router';
 import models from './utils/model/autoImportModels';
 
@@ -29,6 +30,7 @@ const App: React.FC = () => {
         <ConfigProvider locale={locale}>
             <ModelProviders>
                 <RouterProvider router={router} />
+                <DialogContainer />
             </ModelProviders>
         </ConfigProvider>
     );

+ 4 - 0
src/assets/iconify/single-color/crown.svg

@@ -0,0 +1,4 @@
+<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.49674 33.3317L3.33008 14.165L11.6634 18.3317L19.9967 6.66504L28.3301 18.3317L36.6634 14.165L32.4967 33.3317H7.49674Z" stroke="#F5D89F" style="stroke:#F5D89F;stroke:color(display-p3 0.9608 0.8471 0.6235);stroke-opacity:1;" stroke-width="2.5" stroke-linejoin="round"/>
+<path d="M20.0033 27.5016C21.8442 27.5016 23.3366 26.0092 23.3366 24.1683C23.3366 22.3274 21.8442 20.835 20.0033 20.835C18.1623 20.835 16.6699 22.3274 16.6699 24.1683C16.6699 26.0092 18.1623 27.5016 20.0033 27.5016Z" stroke="#F5D89F" style="stroke:#F5D89F;stroke:color(display-p3 0.9608 0.8471 0.6235);stroke-opacity:1;" stroke-width="2.5" stroke-linejoin="round"/>
+</svg>

+ 5 - 0
src/assets/iconify/single-color/info.svg

@@ -0,0 +1,5 @@
+<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M19.9987 36.6673C29.2034 36.6673 36.6654 29.2054 36.6654 20.0007C36.6654 10.7959 29.2034 3.33398 19.9987 3.33398C10.794 3.33398 3.33203 10.7959 3.33203 20.0007C3.33203 29.2054 10.794 36.6673 19.9987 36.6673Z" stroke="#EA9800" style="stroke:#EA9800;stroke:color(display-p3 0.9176 0.5961 0.0000);stroke-opacity:1;" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M20 26.6667V20" stroke="#EA9800" style="stroke:#EA9800;stroke:color(display-p3 0.9176 0.5961 0.0000);stroke-opacity:1;" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M20 13.334H20.0167" stroke="#EA9800" style="stroke:#EA9800;stroke:color(display-p3 0.9176 0.5961 0.0000);stroke-opacity:1;" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
src/assets/iconify/single-color/shield-tick.svg

@@ -0,0 +1,4 @@
+<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5 7.7132L20.0072 3.3335L35 7.7132V16.6949C35 26.1353 28.9585 34.5163 20.0022 37.5006C11.0434 34.5164 5 26.1335 5 16.6907V7.7132Z" stroke="#0EA5E9" style="stroke:#0EA5E9;stroke:color(display-p3 0.0549 0.6471 0.9137);stroke-opacity:1;" stroke-width="2.5" stroke-linejoin="round"/>
+<path d="M12.5 19.1667L18.3333 25L28.3333 15" stroke="#0EA5E9" style="stroke:#0EA5E9;stroke:color(display-p3 0.0549 0.6471 0.9137);stroke-opacity:1;" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 5 - 0
src/assets/iconify/single-color/user-circle.svg

@@ -0,0 +1,5 @@
+<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M19.9987 36.6663C29.2034 36.6663 36.6654 29.2044 36.6654 19.9997C36.6654 10.7949 29.2034 3.33301 19.9987 3.33301C10.7939 3.33301 3.33203 10.7949 3.33203 19.9997C3.33203 29.2044 10.7939 36.6663 19.9987 36.6663Z" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M19.9987 19.1663C22.2999 19.1663 24.1654 17.3008 24.1654 14.9997C24.1654 12.6985 22.2999 10.833 19.9987 10.833C17.6975 10.833 15.832 12.6985 15.832 14.9997C15.832 17.3008 17.6975 19.1663 19.9987 19.1663Z" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="3.33333" stroke-linejoin="round"/>
+<path d="M8.35156 31.9437C8.63798 27.6008 12.2512 24.167 16.6666 24.167H23.3332C27.7427 24.167 31.3523 27.5917 31.6471 31.9264" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 26 - 0
src/components/Dialog/DialogContainer.tsx

@@ -0,0 +1,26 @@
+import { Fragment } from 'react';
+
+import { dialogModel } from '@/models/dialogModel';
+
+import Dialog from './index';
+
+/**
+ * Dialog 容器组件
+ * 负责渲染所有通过 dialogModel 打开的对话框
+ */
+export const DialogContainer: React.FC = () => {
+    const { dialogs } = dialogModel.useModel();
+    return (
+        <Fragment>
+            {dialogs.map((dialog) => (
+                <Dialog
+                    key={dialog.id}
+                    {...dialog}
+                    open={true}
+                    id={dialog.id}
+                    zIndex={dialog.zIndex}
+                />
+            ))}
+        </Fragment>
+    );
+};

+ 120 - 0
src/components/Dialog/index.tsx

@@ -0,0 +1,120 @@
+import { Fragment, memo } from 'react';
+
+import { Icon, IconifyIcon } from '@iconify/react';
+
+import { useResponsive } from '@/hooks/useResponsive';
+import { dialogModel } from '@/models/dialogModel';
+import { useAction } from './useAction';
+
+export interface DialogButton {
+    label: string;
+    onClick?: (event: React.MouseEvent<HTMLButtonElement>, dialogId: string) => void;
+    variant?: 'primary' | 'secondary';
+}
+
+export interface DialogProps {
+    open: boolean;
+    id: string;
+    icon?: string | IconifyIcon;
+    title: React.ReactNode;
+    content: React.ReactNode;
+    buttons?: DialogButton[];
+    zIndex: number;
+    maskClosable?: boolean;
+}
+
+const Dialog = memo(({ open, id, icon, title, content, buttons, zIndex, maskClosable = true }: DialogProps) => {
+    const { isMobile } = useResponsive();
+    const { closeDialog } = dialogModel.useModel();
+    const { handleOverlayClick, handleButtonClick } = useAction({ id, closeDialog, maskClosable });
+
+    if (!open) {
+        return null;
+    }
+
+    const renderIcon = () => {
+        if (!icon) return null;
+        return <Icon icon={icon} className="w-10 h-10 flex-shrink-0 text-white" />;
+    };
+
+    return (
+        <Fragment>
+            <div
+                className="fixed inset-0 bg-black/50 flex items-center justify-center p-4"
+                style={{ zIndex }}
+                onClick={handleOverlayClick}
+            >
+                <div
+                    className={`bg-[#272A33] flex flex-col max-w-full ${
+                        isMobile
+                            ? 'w-[328px] rounded-2xl shadow-[0px_8px_36px_0px_rgba(0,0,0,0.16)] pt-2'
+                            : 'w-[544px] rounded-xl shadow-[0px_8px_8px_-4px_rgba(16,24,40,0.04),0px_20px_24px_-4px_rgba(16,24,40,0.1)]'
+                    }`}
+                    onClick={(e) => e.stopPropagation()}
+                >
+                    <div className={`flex flex-col ${isMobile ? 'gap-2.5 p-[10px_24px]' : 'gap-5 p-6 pt-6'}`}>
+                        {isMobile ? (
+                            <>
+                                {icon && <div className="flex justify-start">{renderIcon()}</div>}
+                                {title && (
+                                    <h2 className="text-white text-[22px] leading-[1.27] font-[510] text-start">
+                                        {title}
+                                    </h2>
+                                )}
+                                {content && (
+                                    <div className="text-white px-0 py-2 text-sm leading-[1.43] text-start">
+                                        {content}
+                                    </div>
+                                )}
+                            </>
+                        ) : (
+                            <>
+                                {(icon || title) && (
+                                    <div className="flex items-center gap-[14px]">
+                                        {renderIcon()}
+                                        {title && (
+                                            <h2 className="flex-1 text-white text-2xl leading-[1.17] font-semibold">
+                                                {title}
+                                            </h2>
+                                        )}
+                                    </div>
+                                )}
+                                {content && (
+                                    <div className="text-white text-sm leading-[1.43]">{content}</div>
+                                )}
+                            </>
+                        )}
+                    </div>
+                    {buttons && buttons.length > 0 && (
+                        <div
+                            className={`flex ${
+                                buttons.length === 1 ? 'flex-col' : 'flex-row'
+                            } ${isMobile ? 'gap-2.5 px-6 pt-4 pb-6' : 'gap-6 p-6'}`}
+                        >
+                            {buttons.map((button, index) => (
+                                <button
+                                    key={index}
+                                    type="button"
+                                    onClick={(e) => handleButtonClick(button, e, id)}
+                                    className={`flex-1 rounded-lg flex items-center justify-center gap-2.5 px-5 ${
+                                        isMobile ? 'h-[42px] py-2.5' : 'h-11 py-3.5'
+                                    } ${
+                                        button.variant === 'secondary'
+                                            ? 'bg-[#1C1E25] text-[#646776]'
+                                            : 'bg-[#0EA5E9] text-[#F5F5F5]'
+                                    } text-sm leading-[1.21] font-medium`}
+                                >
+                                    {button.label}
+                                </button>
+                            ))}
+                        </div>
+                    )}
+                </div>
+            </div>
+        </Fragment>
+    );
+});
+
+Dialog.displayName = 'Dialog';
+
+export default Dialog;

+ 29 - 0
src/components/Dialog/useAction.ts

@@ -0,0 +1,29 @@
+import { useCallback } from 'react';
+
+import type { DialogButton } from './index';
+
+interface UseActionProps {
+    id?: string;
+    closeDialog: (id: string) => void;
+    maskClosable?: boolean;
+}
+
+export function useAction({ id, closeDialog, maskClosable = true }: UseActionProps) {
+    const handleOverlayClick = useCallback(() => {
+        if (id && maskClosable) {
+            closeDialog(id);
+        }
+    }, [id, closeDialog, maskClosable]);
+
+    const handleButtonClick = useCallback(
+        (button: DialogButton, event: React.MouseEvent<HTMLButtonElement>, dialogId: string) => {
+            button.onClick?.(event, dialogId);
+        },
+        []
+    );
+
+    return {
+        handleOverlayClick,
+        handleButtonClick,
+    };
+}

+ 63 - 0
src/models/dialogModel.ts

@@ -0,0 +1,63 @@
+import { useState, useCallback } from 'react';
+
+import { createModel } from '../utils/model/createModel';
+
+import type { DialogProps } from '@/components/Dialog';
+
+interface DialogItem extends Omit<DialogProps, 'open' | 'id' | 'zIndex'> {
+    id: string;
+    zIndex: number;
+    closeOnOverlayClick?: boolean;
+}
+
+interface DialogState {
+    dialogs: DialogItem[];
+    nextZIndex: number;
+}
+
+interface DialogModel extends DialogState {
+    openDialog: (props: Omit<DialogProps, 'open' | 'id' | 'zIndex'>) => string;
+    closeDialog: (id: string) => void;
+}
+
+const BASE_Z_INDEX = 500;
+
+const useDialogModel = (): DialogModel => {
+    const [state, setState] = useState<DialogState>({
+        dialogs: [],
+        nextZIndex: BASE_Z_INDEX,
+    });
+
+    const openDialog = useCallback((props: Omit<DialogProps, 'open' | 'id' | 'zIndex'>): string => {
+        const id = `dialog-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+        setState((prev) => {
+            const zIndex = prev.nextZIndex;
+            const newDialog: DialogItem = {
+                ...props,
+                id,
+                zIndex,
+            };
+            return {
+                ...prev,
+                dialogs: [...prev.dialogs, newDialog],
+                nextZIndex: zIndex + 1,
+            };
+        });
+        return id;
+    }, []);
+
+    const closeDialog = useCallback((id: string) => {
+        setState((prev) => ({
+            ...prev,
+            dialogs: prev.dialogs.filter((dialog) => dialog.id !== id),
+        }));
+    }, []);
+
+    return {
+        ...state,
+        openDialog,
+        closeDialog,
+    };
+};
+
+export const dialogModel = createModel(useDialogModel, 'dialog');

+ 192 - 1
src/pages/home/index.tsx

@@ -16,8 +16,10 @@ import { useNavigate } from 'react-router-dom';
 
 import multiColorIcon from '@/assets/iconify/multi-color/logo.svg';
 import singleColorIcon from '@/assets/iconify/single-color/home.svg';
+import infoIcon from '@/assets/iconify/single-color/info.svg';
 import LanguageSwitch from '@/components/LanguageSwitch';
 import { reportEvent } from '@/firebase';
+import { dialogModel } from '@/models/dialogModel';
 import { userModel } from '@/models/userModel';
 import { fetchLogin } from '@/services/login';
 import { createLocalTools } from '@/utils/localUtils';
@@ -28,6 +30,7 @@ const Home: React.FC = () => {
     const { t, i18n } = useTranslation();
     const navigate = useNavigate();
     const user = userModel.useModel();
+    const { openDialog, closeDialog } = dialogModel.useModel();
     const [loading, setLoading] = useState(false);
     const [loginStatus, setLoginStatus] = useState<string>('');
     const [storageStatus, setStorageStatus] = useState<string>('');
@@ -126,8 +129,158 @@ const Home: React.FC = () => {
         i18n.changeLanguage(lang);
     };
 
+    // 对话框演示 - 单个按钮
+    const handleOpenSingleButtonDialog = () => {
+        openDialog({
+            icon: infoIcon,
+            title: 'Payment Failure',
+            content: 'Oops! Your transaction failed due to some errors, please try again.',
+            buttons: [
+                {
+                    label: 'OK',
+                    onClick: (_event, id) => {
+                        closeDialog(id);
+                    },
+                },
+            ],
+        });
+    };
+
+    // 对话框演示 - 两个按钮
+    const handleOpenTwoButtonDialog = () => {
+        openDialog({
+            icon: infoIcon,
+            title: 'Payment verification',
+            content:
+                'Orders are being verified, if there is no response for a long time, please contact customer service if you have any questions!',
+            buttons: [
+                {
+                    label: 'Cancel',
+                    variant: 'secondary',
+                    onClick: (_event, id) => {
+                        closeDialog(id);
+                    },
+                },
+                {
+                    label: 'Contact us',
+                    onClick: (_event, id) => {
+                        closeDialog(id);
+                    },
+                },
+            ],
+        });
+    };
+
+    // 对话框演示 - 无图标
+    const handleOpenNoIconDialog = () => {
+        openDialog({
+            title: 'Simple Dialog',
+            content: 'This is a dialog without an icon.',
+            buttons: [
+                {
+                    label: 'Close',
+                    onClick: (_event, id) => {
+                        closeDialog(id);
+                    },
+                },
+            ],
+        });
+    };
+
+    // 对话框演示 - 自定义内容
+    const handleOpenCustomContentDialog = () => {
+        openDialog({
+            icon: infoIcon,
+            title: (
+                <span>
+                    Custom <strong>Title</strong>
+                </span>
+            ),
+            content: (
+                <div>
+                    <p>This dialog has custom ReactNode content.</p>
+                    <ul className="list-disc list-inside mt-2">
+                        <li>Feature 1</li>
+                        <li>Feature 2</li>
+                        <li>Feature 3</li>
+                    </ul>
+                </div>
+            ),
+            buttons: [
+                {
+                    label: 'Got it',
+                    onClick: (_event, id) => {
+                        closeDialog(id);
+                    },
+                },
+            ],
+        });
+    };
+
+    // 对话框演示 - 禁止点击遮罩层关闭
+    const handleOpenNonClosableDialog = () => {
+        openDialog({
+            icon: infoIcon,
+            title: 'Important Notice',
+            content: 'This dialog cannot be closed by clicking the mask. You must click the button to close it.',
+            maskClosable: false,
+            buttons: [
+                {
+                    label: 'I Understand',
+                    onClick: (_event, id) => {
+                        closeDialog(id);
+                    },
+                },
+            ],
+        });
+    };
+
+    // 对话框演示 - 嵌套对话框
+    const handleOpenNestedDialog = () => {
+        openDialog({
+            icon: infoIcon,
+            title: 'First Dialog',
+            content: 'This is the first dialog. Click the button below to open a nested dialog.',
+            buttons: [
+                {
+                    label: 'Open Nested Dialog',
+                    onClick: (_event, id) => {
+                        openDialog({
+                            icon: infoIcon,
+                            title: 'Nested Dialog',
+                            content: 'This is a nested dialog opened from the first dialog. Notice how the z-index is automatically managed.',
+                            buttons: [
+                                {
+                                    label: 'Close Nested',
+                                    variant: 'secondary',
+                                    onClick: (_event, nestedId) => {
+                                        closeDialog(nestedId);
+                                    },
+                                },
+                                {
+                                    label: 'Close All',
+                                    onClick: (_event, nestedId) => {
+                                        closeDialog(nestedId);
+                                        closeDialog(id);
+                                    },
+                                },
+                            ],
+                        });
+                    },
+                },
+                {
+                    label: 'Close First',
+                    variant: 'secondary',
+                    onClick: (_event, id) => {
+                        closeDialog(id);
+                    },
+                },
+            ],
+        });
+    };
+
     return (
-        <div className="max-w-4xl mx-auto p-4">
+        <div className="max-w-[1000px] mx-auto p-4">
             <div className="flex justify-between items-center mb-6">
                 <h1 className="text-3xl font-bold text-gray-900">{t('pages.home.title')}</h1>
                 <LanguageSwitch />
@@ -304,6 +457,44 @@ const Home: React.FC = () => {
                     </div>
                 </div>
             </Card>
+
+            {/* 对话框演示卡片 */}
+            <Card className="mb-6">
+                <Title level={3}>对话框演示</Title>
+                <div className="space-y-4">
+                    <div>
+                        <Title level={4}>Dialog 组件示例</Title>
+                        <Space wrap>
+                            <Button type="primary" onClick={handleOpenSingleButtonDialog}>
+                                单个按钮对话框
+                            </Button>
+                            <Button type="primary" onClick={handleOpenTwoButtonDialog}>
+                                两个按钮对话框
+                            </Button>
+                            <Button onClick={handleOpenNoIconDialog}>无图标对话框</Button>
+                            <Button onClick={handleOpenCustomContentDialog}>
+                                自定义内容对话框
+                            </Button>
+                            <Button onClick={handleOpenNonClosableDialog}>
+                                禁止遮罩关闭对话框
+                            </Button>
+                            <Button onClick={handleOpenNestedDialog}>嵌套对话框</Button>
+                        </Space>
+                    </div>
+                    <div>
+                        <Title level={5}>使用说明</Title>
+                        <ul className="list-disc list-inside space-y-1 text-gray-600">
+                            <li>通过 dialogModel.openDialog() 打开对话框</li>
+                            <li>openDialog 返回对话框 id,可用于后续关闭</li>
+                            <li>按钮的 onClick 接收 (event, dialogId) 两个参数</li>
+                            <li>默认情况下,点击遮罩层可以关闭对话框</li>
+                            <li>设置 maskClosable: false 可禁止点击遮罩层关闭</li>
+                            <li>支持嵌套对话框,z-index 自动管理</li>
+                            <li>支持图标、标题、内容的自定义</li>
+                        </ul>
+                    </div>
+                </div>
+            </Card>
         </div>
     );
 };