mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2025-12-26 20:50:30 +08:00
feat: add detailed error toast with copy functionality and refactor error handling
This commit is contained in:
parent
972d2fe946
commit
34fdd21878
@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { IoCheckmark, IoClose, IoAlertSharp, IoInformationSharp } from 'react-icons/io5'
|
||||
import { IoCheckmark, IoClose, IoAlertSharp, IoInformationSharp, IoCopy } from 'react-icons/io5'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
@ -11,6 +11,7 @@ interface ToastData {
|
||||
message: string
|
||||
duration?: number
|
||||
exiting?: boolean
|
||||
detailed?: boolean
|
||||
}
|
||||
|
||||
type ToastListener = (toasts: ToastData[]) => void
|
||||
@ -38,11 +39,18 @@ const removeToast = (id: string): void => {
|
||||
notifyListeners()
|
||||
}
|
||||
|
||||
const addDetailedToast = (type: ToastType, message: string, title?: string): void => {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
toasts = [...toasts.slice(-4), { id, type, message, title, duration: 8000, detailed: true }]
|
||||
notifyListeners()
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, title?: string): void => addToast('success', message, title),
|
||||
error: (message: string, title?: string): void => addToast('error', message, title, 1800),
|
||||
warning: (message: string, title?: string): void => addToast('warning', message, title),
|
||||
info: (message: string, title?: string): void => addToast('info', message, title)
|
||||
info: (message: string, title?: string): void => addToast('info', message, title),
|
||||
detailedError: (message: string, title?: string): void => addDetailedToast('error', message, title)
|
||||
}
|
||||
|
||||
const ToastItem: React.FC<{
|
||||
@ -50,6 +58,7 @@ const ToastItem: React.FC<{
|
||||
onRemove: (id: string) => void
|
||||
}> = ({ data, onRemove }) => {
|
||||
useEffect(() => {
|
||||
if (data.detailed) return
|
||||
const duration = data.duration || 3500
|
||||
const exitTimer = setTimeout(() => markExiting(data.id), duration - 200)
|
||||
const removeTimer = setTimeout(() => onRemove(data.id), duration)
|
||||
@ -57,7 +66,7 @@ const ToastItem: React.FC<{
|
||||
clearTimeout(exitTimer)
|
||||
clearTimeout(removeTimer)
|
||||
}
|
||||
}, [data.id, data.duration, onRemove])
|
||||
}, [data.id, data.duration, data.detailed, onRemove])
|
||||
|
||||
const theme: Record<ToastType, { icon: React.ReactNode; bg: string; iconBg: string }> = {
|
||||
success: {
|
||||
@ -85,6 +94,66 @@ const ToastItem: React.FC<{
|
||||
const { icon, iconBg } = theme[data.type]
|
||||
const duration = data.duration || 3500
|
||||
|
||||
const handleClose = (): void => {
|
||||
markExiting(data.id)
|
||||
setTimeout(() => onRemove(data.id), 150)
|
||||
}
|
||||
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
await navigator.clipboard.writeText(data.message)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
if (data.detailed) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative flex flex-col gap-3 p-4
|
||||
bg-content1 rounded-xl
|
||||
shadow-xl border border-danger/30
|
||||
${data.exiting ? 'toast-exit' : 'toast-enter'}
|
||||
`}
|
||||
style={{ width: 480 }}
|
||||
>
|
||||
<div className="flex items-center justify-between overflow-visible">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex-shrink-0 w-8 h-8 ${iconBg} rounded-full flex items-center justify-center`}>
|
||||
{icon}
|
||||
</div>
|
||||
<p className="text-base font-semibold text-foreground">{data.title || '错误'}</p>
|
||||
</div>
|
||||
<div className="relative" style={{ zIndex: 99999 }}>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-2 rounded-lg hover:bg-default-200 transition-colors"
|
||||
>
|
||||
<div className="relative w-4 h-4">
|
||||
<IoCopy className={`absolute inset-0 text-base text-foreground-500 transition-all duration-200 ${copied ? 'opacity-0 scale-50' : 'opacity-100 scale-100'}`} />
|
||||
<IoCheckmark className={`absolute inset-0 text-base text-success transition-all duration-200 ${copied ? 'opacity-100 scale-100' : 'opacity-0 scale-50'}`} />
|
||||
</div>
|
||||
</button>
|
||||
<div className={`absolute top-full mt-1 left-1/2 -translate-x-1/2 px-2 py-1 text-xs text-foreground bg-content2 border border-default-200 rounded shadow-md whitespace-nowrap transition-all duration-200 ${copied ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-1 pointer-events-none'}`} style={{ zIndex: 99999 }}>
|
||||
已复制
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-default-100 rounded-lg p-3 max-h-60 overflow-y-auto scrollbar-thin">
|
||||
<pre className="text-xs text-foreground-600 whitespace-pre-wrap break-words font-mono select-text leading-relaxed">
|
||||
{data.message}
|
||||
</pre>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="self-end px-4 py-1.5 text-sm font-medium text-white bg-danger rounded-lg hover:bg-danger/90 transition-colors"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
@ -109,10 +178,7 @@ const ToastItem: React.FC<{
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
markExiting(data.id)
|
||||
setTimeout(() => onRemove(data.id), 150)
|
||||
}}
|
||||
onClick={handleClose}
|
||||
className="flex-shrink-0 p-1 rounded-full hover:bg-default-200/60 transition-colors"
|
||||
>
|
||||
<IoClose className="text-base text-foreground-400" />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react'
|
||||
import { toast } from '@renderer/components/base/toast'
|
||||
import { showError } from '@renderer/utils/error-display'
|
||||
import useSWR from 'swr'
|
||||
import { getAppConfig, patchAppConfig as patch } from '@renderer/utils/ipc'
|
||||
|
||||
@ -18,7 +18,7 @@ export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children
|
||||
try {
|
||||
await patch(value)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
await showError(e, '更新应用配置失败')
|
||||
} finally {
|
||||
mutateAppConfig()
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react'
|
||||
import { toast } from '@renderer/components/base/toast'
|
||||
import { showError } from '@renderer/utils/error-display'
|
||||
import useSWR from 'swr'
|
||||
import { getControledMihomoConfig, patchControledMihomoConfig as patch } from '@renderer/utils/ipc'
|
||||
|
||||
@ -23,7 +23,7 @@ export const ControledMihomoConfigProvider: React.FC<{ children: ReactNode }> =
|
||||
try {
|
||||
await patch(value)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
await showError(e, '更新内核配置失败')
|
||||
} finally {
|
||||
mutateControledMihomoConfig()
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react'
|
||||
import { toast } from '@renderer/components/base/toast'
|
||||
import { showError } from '@renderer/utils/error-display'
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
getOverrideConfig,
|
||||
@ -29,7 +29,7 @@ export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ chil
|
||||
try {
|
||||
await set(config)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
await showError(e, '保存覆写配置失败')
|
||||
} finally {
|
||||
mutateOverrideConfig()
|
||||
}
|
||||
@ -39,7 +39,7 @@ export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ chil
|
||||
try {
|
||||
await add(item)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
await showError(e, '添加覆写失败')
|
||||
} finally {
|
||||
mutateOverrideConfig()
|
||||
}
|
||||
@ -49,7 +49,7 @@ export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ chil
|
||||
try {
|
||||
await remove(id)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
await showError(e, '删除覆写失败')
|
||||
} finally {
|
||||
mutateOverrideConfig()
|
||||
}
|
||||
@ -59,7 +59,7 @@ export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ chil
|
||||
try {
|
||||
await update(item)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
await showError(e, '更新覆写失败')
|
||||
} finally {
|
||||
mutateOverrideConfig()
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { createContext, ReactNode, useContext } from 'react'
|
||||
import { showError } from '@renderer/utils/error-display'
|
||||
import { toast } from '@renderer/components/base/toast'
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
@ -33,7 +34,7 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
|
||||
try {
|
||||
await set(config)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
await showError(e, '保存配置失败')
|
||||
} finally {
|
||||
mutateProfileConfig()
|
||||
window.electron.ipcRenderer.send('updateTrayMenu')
|
||||
@ -44,7 +45,7 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
|
||||
try {
|
||||
await add(item)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
await showError(e, '添加配置失败')
|
||||
} finally {
|
||||
mutateProfileConfig()
|
||||
window.electron.ipcRenderer.send('updateTrayMenu')
|
||||
@ -55,7 +56,7 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
|
||||
try {
|
||||
await remove(id)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
await showError(e, '删除配置失败')
|
||||
} finally {
|
||||
mutateProfileConfig()
|
||||
window.electron.ipcRenderer.send('updateTrayMenu')
|
||||
@ -66,7 +67,7 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
|
||||
try {
|
||||
await update(item)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
await showError(e, '更新配置失败')
|
||||
} finally {
|
||||
mutateProfileConfig()
|
||||
window.electron.ipcRenderer.send('updateTrayMenu')
|
||||
@ -108,7 +109,7 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
|
||||
if (errorMsg.includes('reply was never sent')) {
|
||||
setTimeout(() => mutateProfileConfig(), 1000)
|
||||
} else {
|
||||
toast.error(errorMsg, '切换配置失败')
|
||||
await showError(errorMsg, '切换配置失败')
|
||||
mutateProfileConfig()
|
||||
}
|
||||
} finally {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Button, Tab, Input, Switch, Tabs, Divider } from '@heroui/react'
|
||||
import BasePage from '@renderer/components/base/base-page'
|
||||
import { toast } from '@renderer/components/base/toast'
|
||||
import { showErrorSync } from '@renderer/utils/error-display'
|
||||
import { MdDeleteForever } from 'react-icons/md'
|
||||
import SettingCard from '@renderer/components/base/base-setting-card'
|
||||
import SettingItem from '@renderer/components/base/base-setting-item'
|
||||
@ -146,7 +146,7 @@ const DNS: React.FC = () => {
|
||||
await restartCore()
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
showErrorSync(e, 'DNS配置保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Button, Divider, Input, Select, SelectItem, Switch, Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure, Spinner, Chip } from '@heroui/react'
|
||||
import BasePage from '@renderer/components/base/base-page'
|
||||
import { toast } from '@renderer/components/base/toast'
|
||||
import { showError } from '@renderer/utils/error-display'
|
||||
import SettingCard from '@renderer/components/base/base-setting-card'
|
||||
import SettingItem from '@renderer/components/base/base-setting-item'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
@ -14,7 +15,6 @@ import {
|
||||
restartCore,
|
||||
startSubStoreBackendServer,
|
||||
triggerSysProxy,
|
||||
showDetailedError,
|
||||
fetchMihomoTags,
|
||||
installSpecificMihomoCore,
|
||||
clearMihomoVersionCache
|
||||
@ -306,11 +306,7 @@ const Mihomo: React.FC = () => {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e)
|
||||
console.error('Core restart failed:', errorMessage)
|
||||
|
||||
if (errorMessage.includes('配置检查失败') || errorMessage.includes('Profile Check Failed')) {
|
||||
await showDetailedError(t('mihomo.error.profileCheckFailed'), errorMessage)
|
||||
} else {
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
await showError(errorMessage, t('mihomo.error.profileCheckFailed'))
|
||||
} finally {
|
||||
PubSub.publish('mihomo-core-changed')
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Button, Divider, Input, Switch } from '@heroui/react'
|
||||
import BasePage from '@renderer/components/base/base-page'
|
||||
import { toast } from '@renderer/components/base/toast'
|
||||
import { showErrorSync } from '@renderer/utils/error-display'
|
||||
import SettingCard from '@renderer/components/base/base-setting-card'
|
||||
import SettingItem from '@renderer/components/base/base-setting-item'
|
||||
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
|
||||
@ -71,7 +71,7 @@ const Sniffer: React.FC = () => {
|
||||
await restartCore()
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
showErrorSync(e, '嵅探配置保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Button, Input, Tab, Tabs } from '@heroui/react'
|
||||
import BasePage from '@renderer/components/base/base-page'
|
||||
import { toast } from '@renderer/components/base/toast'
|
||||
import { showErrorSync } from '@renderer/utils/error-display'
|
||||
import SettingCard from '@renderer/components/base/base-setting-card'
|
||||
import SettingItem from '@renderer/components/base/base-setting-item'
|
||||
import PacEditorModal from '@renderer/components/sysproxy/pac-editor-modal'
|
||||
@ -106,7 +106,7 @@ const Sysproxy: React.FC = () => {
|
||||
} catch (e) {
|
||||
setValues({ ...values, enable: previousState })
|
||||
setChanged(true)
|
||||
toast.error(String(e))
|
||||
showErrorSync(e, '系统代理设置失败')
|
||||
|
||||
await patchAppConfig({ sysProxy: { enable: false } })
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Button, Input, Switch, Tab, Tabs } from '@heroui/react'
|
||||
import BasePage from '@renderer/components/base/base-page'
|
||||
import { toast } from '@renderer/components/base/toast'
|
||||
import { showErrorSync } from '@renderer/utils/error-display'
|
||||
import SettingCard from '@renderer/components/base/base-setting-card'
|
||||
import SettingItem from '@renderer/components/base/base-setting-item'
|
||||
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
|
||||
@ -113,7 +113,7 @@ const Tun: React.FC = () => {
|
||||
new Notification(t('tun.notifications.firewallResetSuccess'))
|
||||
await restartCore()
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
showErrorSync(e, '防火墙设置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -134,7 +134,7 @@ const Tun: React.FC = () => {
|
||||
new Notification(t('tun.notifications.coreAuthSuccess'))
|
||||
await restartCore()
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
showErrorSync(e, '内核授权失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
52
src/renderer/src/utils/error-display.ts
Normal file
52
src/renderer/src/utils/error-display.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { toast } from '@renderer/components/base/toast'
|
||||
|
||||
const DETAILED_ERROR_KEYWORDS = [
|
||||
'yaml',
|
||||
'YAML',
|
||||
'config',
|
||||
'profile',
|
||||
'parse',
|
||||
'syntax',
|
||||
'invalid',
|
||||
'failed to',
|
||||
'connection refused',
|
||||
'ECONNREFUSED',
|
||||
'ETIMEDOUT',
|
||||
'ENOTFOUND',
|
||||
'certificate',
|
||||
'SSL',
|
||||
'TLS',
|
||||
'Permission denied',
|
||||
'Access denied',
|
||||
'配置',
|
||||
'解析',
|
||||
'失败',
|
||||
'权限',
|
||||
'证书'
|
||||
]
|
||||
|
||||
function shouldShowDetailedError(message: string): boolean {
|
||||
if (message.length > 80) return true
|
||||
if (message.includes('\n')) return true
|
||||
return DETAILED_ERROR_KEYWORDS.some((keyword) => message.includes(keyword))
|
||||
}
|
||||
|
||||
export async function showError(error: unknown, title?: string): Promise<void> {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (shouldShowDetailedError(message)) {
|
||||
toast.detailedError(message, title || '错误')
|
||||
} else {
|
||||
toast.error(message, title)
|
||||
}
|
||||
}
|
||||
|
||||
export function showErrorSync(error: unknown, title?: string): void {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (shouldShowDetailedError(message)) {
|
||||
toast.detailedError(message, title || '错误')
|
||||
} else {
|
||||
toast.error(message, title)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user