File size: 5,646 Bytes
55847d9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | /* eslint-disable react-refresh/only-export-components */
/**
* Vela 弹窗报错组件 — JetBrains / VSCode 风格
*
* 用于需要用户明确知晓的关键错误(如项目加载失败、文件读写错误等)。
* 完全使用项目 CSS 变量,自动适配深色 / 浅色主题。
*
* 使用 CSS 动画(dialog-enter / dialog-exit / backdrop-enter)统一进出场效果。
*
* 用法:
* import { alertError } from '@/components/ui/AlertDialog'
* alertError('不是有效的 Vela 项目目录', { title: '打开项目失败' })
*/
import { createRoot } from 'react-dom/client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { AlertCircle } from 'lucide-react'
import { Button } from './Button'
import i18n from '../../i18n'
// ===== 类型定义 =====
interface AlertOptions {
/** 弹窗标题,默认「发生错误」 */
title?: string
/** 确认按钮文字,默认「确定」 */
confirmText?: string
}
interface AlertDialogProps extends AlertOptions {
message: string
onClose: () => void
}
// ===== 弹窗组件 =====
function AlertDialog({
title = i18n.t('error', { ns: 'common' }),
message,
confirmText = i18n.t('ok', { ns: 'common' }),
onClose,
}: AlertDialogProps) {
const [isExiting, setIsExiting] = useState(false)
const btnRef = useRef<HTMLButtonElement>(null)
const handleClose = useCallback(() => {
setIsExiting(true)
setTimeout(onClose, 200)
}, [onClose])
// 进场聚焦确认按钮
useEffect(() => {
btnRef.current?.focus()
}, [])
// ESC 关闭
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [handleClose])
return (
/* 遮罩 — 使用统一 CSS 变量和动画 */
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--color-backdrop)',
backdropFilter: 'blur(8px)',
pointerEvents: 'auto',
/* 使用 both 填充模式,让 0% 关键帧在动画前就应用,杜绝闪烁 */
animation: isExiting
? 'backdrop-exit 0.15s ease-out both'
: 'backdrop-enter 0.25s ease-out both',
}}
onClick={handleClose}
>
{/* 弹窗主体 */}
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="alert-title"
aria-describedby="alert-message"
style={{
/* 基础样式 */
backgroundColor: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-2xl)',
boxShadow: 'var(--shadow-popover)',
minWidth: 360,
maxWidth: 460,
width: '90vw',
/* CSS 动画,使用 both 从而提前应用 0% 关键帧,彻底杜绝闪烁现象 */
animation: isExiting
? 'dialog-exit 0.15s ease-out both'
: 'dialog-enter 0.3s var(--transition-spring) both',
}}
onClick={e => e.stopPropagation()}
>
{/* ── 标题区 ── */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '12px 16px 10px',
borderBottom: '1px solid var(--color-border)',
}}
>
<AlertCircle
size={15}
style={{ color: 'var(--color-error)', flexShrink: 0 }}
/>
<span
id="alert-title"
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--color-text)',
lineHeight: 1.3,
flex: 1,
}}
>
{title}
</span>
</div>
{/* ── 消息体 ── */}
<div
id="alert-message"
style={{
padding: '14px 16px 16px',
fontSize: 12,
lineHeight: 1.7,
color: 'var(--color-text-secondary)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{message}
</div>
{/* ── 按钮区 —— 右对齐,VSCode 风格 ── */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
padding: '0 16px 14px',
}}
>
<Button
ref={btnRef}
variant="default"
size="sm"
onClick={handleClose}
>
{confirmText}
</Button>
</div>
</div>
</div>
)
}
// ===== 公共 API =====
/**
* 显示弹窗报错,返回 Promise(用户确认后 resolve)。
* 也可以不 await,fire-and-forget。
*
* @example
* await alertError('不是有效的 Vela 项目目录', { title: '打开项目失败' })
* alertError('写入失败,请检查磁盘权限。')
*/
export function alertError(
message: string,
options?: AlertOptions,
): Promise<void> {
return new Promise(resolve => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const cleanup = () => {
root.unmount()
document.body.removeChild(container)
resolve()
}
root.render(
<AlertDialog
message={message}
title={options?.title}
confirmText={options?.confirmText}
onClose={cleanup}
/>
)
})
}
|