返回 AiToEarn
confirm.tsx
根目录 / project / aitoearn-web / src / utils / ui / confirm.tsx
1 /**
2 * confirm - 命令式确认对话框工具
3 * 用于替代 antd Modal.confirm
4 */
5
6 import { Loader2 } from 'lucide-react'
7 import * as React from 'react'
8 import { createRoot } from 'react-dom/client'
9 import { directTrans } from '@/app/i18n/client'
10 import {
11 AlertDialog,
12 AlertDialogAction,
13 AlertDialogCancel,
14 AlertDialogContent,
15 AlertDialogDescription,
16 AlertDialogFooter,
17 AlertDialogHeader,
18 AlertDialogTitle,
19 } from '@/components/ui/alert-dialog'
20 import { cn } from '@/utils/className'
21
22 // 获取翻译文本
23 function getTranslations() {
24 return {
25 ok: directTrans('common', 'actions.ok'),
26 cancel: directTrans('common', 'actions.cancel'),
27 }
28 }
29
30 export interface ConfirmOptions {
31 /** 对话框标题 */
32 title?: React.ReactNode
33 /** 对话框内容 */
34 content?: React.ReactNode
35 /** 确认按钮文字,默认 "确定" */
36 okText?: string
37 /** 取消按钮文字,默认 "取消"。传 null 则不显示取消按钮 */
38 cancelText?: string | null
39 /** 确认按钮类型 */
40 okType?: 'default' | 'destructive'
41 /** 点击确认回调 */
42 onOk?: () => void | Promise<void>
43 /** 点击取消回调 */
44 onCancel?: () => void
45 /** 是否居中显示 */
46 centered?: boolean
47 /** 自定义图标 */
48 icon?: React.ReactNode
49 /** 自定义类名 */
50 className?: string
51 /** 自定义层级,同时作用于遮罩和内容 */
52 zIndex?: number
53 }
54
55 interface ConfirmDialogProps extends ConfirmOptions {
56 open: boolean
57 onOpenChange: (open: boolean) => void
58 }
59
60 /**
61 * 确认对话框组件
62 */
63 const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
64 open,
65 onOpenChange,
66 title,
67 content,
68 okText,
69 cancelText,
70 okType = 'default',
71 onOk,
72 onCancel,
73 icon,
74 className,
75 zIndex,
76 }) => {
77 const [loading, setLoading] = React.useState(false)
78 const translations = getTranslations()
79 const overlayStyle = zIndex ? { zIndex: zIndex - 1 } : undefined
80 const contentStyle = zIndex ? { zIndex } : undefined
81
82 // 使用传入的值或翻译的默认值
83 const okButtonText = okText ?? translations.ok
84 const cancelButtonText = cancelText ?? translations.cancel
85
86 const handleOk = async (e?: React.MouseEvent) => {
87 // 阻止 AlertDialogAction 的默认关闭行为
88 e?.preventDefault()
89 e?.stopPropagation()
90
91 if (onOk) {
92 try {
93 setLoading(true)
94 await onOk()
95 // onOk 完成后,wrappedOptions.onOk 会处理 destroy 和 resolve
96 // 手动关闭对话框(但此时 destroy 已经执行,所以这个调用可能无效,但不影响)
97 onOpenChange(false)
98 }
99 finally {
100 setLoading(false)
101 }
102 }
103 else {
104 // 如果没有 onOk 回调,直接关闭
105 onOpenChange(false)
106 }
107 }
108
109 const handleCancel = (e?: React.MouseEvent) => {
110 // 阻止 AlertDialogCancel 的默认关闭行为
111 e?.preventDefault()
112 e?.stopPropagation()
113
114 if (onCancel) {
115 onCancel()
116 // onCancel 完成后,wrappedOptions.onCancel 会处理 destroy 和 resolve
117 // 手动关闭对话框(但此时 destroy 已经执行,所以这个调用可能无效,但不影响)
118 onOpenChange(false)
119 }
120 else {
121 // 如果没有 onCancel 回调,直接关闭
122 onOpenChange(false)
123 }
124 }
125
126 // 默认显示取消按钮,只有明确传 null 时才隐藏
127 const showCancel = cancelText !== null
128
129 return (
130 <AlertDialog open={open} onOpenChange={onOpenChange}>
131 <AlertDialogContent
132 className={cn('max-w-[420px]', className)}
133 style={contentStyle}
134 overlayStyle={overlayStyle}
135 >
136 <AlertDialogHeader>
137 <AlertDialogTitle className="flex items-center gap-2">
138 {icon}
139 {title}
140 </AlertDialogTitle>
141 {content && (
142 <AlertDialogDescription asChild>
143 <div className="text-sm text-muted-foreground">{content}</div>
144 </AlertDialogDescription>
145 )}
146 </AlertDialogHeader>
147 <AlertDialogFooter>
148 {showCancel && (
149 <AlertDialogCancel onClick={handleCancel} disabled={loading}>
150 {cancelButtonText}
151 </AlertDialogCancel>
152 )}
153 <AlertDialogAction
154 onClick={handleOk}
155 disabled={loading}
156 className={cn(
157 okType === 'destructive' && 'bg-destructive text-white hover:bg-destructive/90',
158 )}
159 >
160 {loading && <Loader2 className="h-4 w-4 animate-spin" />}
161 {okButtonText}
162 </AlertDialogAction>
163 </AlertDialogFooter>
164 </AlertDialogContent>
165 </AlertDialog>
166 )
167 }
168
169 /**
170 * 命令式调用确认对话框
171 *
172 * @example
173 * ```tsx
174 * import { confirm } from '@/utils/ui/confirm'
175 *
176 * confirm({
177 * title: '确认删除?',
178 * content: '此操作不可恢复',
179 * okType: 'destructive',
180 * onOk: async () => {
181 * await deleteItem()
182 * },
183 * })
184 * ```
185 */
186 export function confirm(options: ConfirmOptions): Promise<boolean> {
187 return new Promise((resolve) => {
188 const container = document.createElement('div')
189 container.id = `confirm-dialog-${Date.now()}`
190 document.body.appendChild(container)
191
192 const root = createRoot(container)
193
194 let isResolved = false
195
196 const destroy = () => {
197 root.unmount()
198 container.remove()
199 }
200
201 const handleOpenChange = (open: boolean) => {
202 if (!open && !isResolved) {
203 // 如果对话框关闭但还没有 resolve,说明是点击外部或按 ESC 关闭
204 isResolved = true
205 destroy()
206 resolve(false)
207 }
208 }
209
210 const wrappedOptions: ConfirmOptions = {
211 ...options,
212 onOk: async () => {
213 // 先设置 isResolved,防止 onOpenChange 触发时再次 resolve
214 isResolved = true
215 try {
216 await options.onOk?.()
217 }
218 catch (error) {
219 // 如果 onOk 出错,仍然 resolve true(因为用户已经确认了)
220 console.error('onOk error:', error)
221 }
222 destroy()
223 resolve(true)
224 },
225 onCancel: () => {
226 // 先设置 isResolved,防止 onOpenChange 触发时再次 resolve
227 isResolved = true
228 try {
229 options.onCancel?.()
230 }
231 catch (error) {
232 console.error('onCancel error:', error)
233 }
234 destroy()
235 resolve(false)
236 },
237 }
238
239 root.render(<ConfirmDialog {...wrappedOptions} open={true} onOpenChange={handleOpenChange} />)
240 })
241 }
242
243 export default confirm
244
244 lines Plain Text