feat: add detailed error toast with copy functionality and refactor error handling

This commit is contained in:
xmk23333 2025-12-09 23:32:13 +08:00
parent 972d2fe946
commit 34fdd21878
11 changed files with 151 additions and 36 deletions

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react' import React, { useEffect, useState, useCallback } from 'react'
import { createPortal } from 'react-dom' 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' type ToastType = 'success' | 'error' | 'warning' | 'info'
@ -11,6 +11,7 @@ interface ToastData {
message: string message: string
duration?: number duration?: number
exiting?: boolean exiting?: boolean
detailed?: boolean
} }
type ToastListener = (toasts: ToastData[]) => void type ToastListener = (toasts: ToastData[]) => void
@ -38,11 +39,18 @@ const removeToast = (id: string): void => {
notifyListeners() 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 = { export const toast = {
success: (message: string, title?: string): void => addToast('success', message, title), success: (message: string, title?: string): void => addToast('success', message, title),
error: (message: string, title?: string): void => addToast('error', message, title, 1800), error: (message: string, title?: string): void => addToast('error', message, title, 1800),
warning: (message: string, title?: string): void => addToast('warning', message, title), 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<{ const ToastItem: React.FC<{
@ -50,6 +58,7 @@ const ToastItem: React.FC<{
onRemove: (id: string) => void onRemove: (id: string) => void
}> = ({ data, onRemove }) => { }> = ({ data, onRemove }) => {
useEffect(() => { useEffect(() => {
if (data.detailed) return
const duration = data.duration || 3500 const duration = data.duration || 3500
const exitTimer = setTimeout(() => markExiting(data.id), duration - 200) const exitTimer = setTimeout(() => markExiting(data.id), duration - 200)
const removeTimer = setTimeout(() => onRemove(data.id), duration) const removeTimer = setTimeout(() => onRemove(data.id), duration)
@ -57,7 +66,7 @@ const ToastItem: React.FC<{
clearTimeout(exitTimer) clearTimeout(exitTimer)
clearTimeout(removeTimer) 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 }> = { const theme: Record<ToastType, { icon: React.ReactNode; bg: string; iconBg: string }> = {
success: { success: {
@ -85,6 +94,66 @@ const ToastItem: React.FC<{
const { icon, iconBg } = theme[data.type] const { icon, iconBg } = theme[data.type]
const duration = data.duration || 3500 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 ( return (
<div <div
className={` className={`
@ -109,10 +178,7 @@ const ToastItem: React.FC<{
</p> </p>
</div> </div>
<button <button
onClick={() => { onClick={handleClose}
markExiting(data.id)
setTimeout(() => onRemove(data.id), 150)
}}
className="flex-shrink-0 p-1 rounded-full hover:bg-default-200/60 transition-colors" className="flex-shrink-0 p-1 rounded-full hover:bg-default-200/60 transition-colors"
> >
<IoClose className="text-base text-foreground-400" /> <IoClose className="text-base text-foreground-400" />

View File

@ -1,5 +1,5 @@
import React, { createContext, useContext, ReactNode } from 'react' 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 useSWR from 'swr'
import { getAppConfig, patchAppConfig as patch } from '@renderer/utils/ipc' import { getAppConfig, patchAppConfig as patch } from '@renderer/utils/ipc'
@ -18,7 +18,7 @@ export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children
try { try {
await patch(value) await patch(value)
} catch (e) { } catch (e) {
toast.error(String(e)) await showError(e, '更新应用配置失败')
} finally { } finally {
mutateAppConfig() mutateAppConfig()
} }

View File

@ -1,5 +1,5 @@
import React, { createContext, useContext, ReactNode } from 'react' 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 useSWR from 'swr'
import { getControledMihomoConfig, patchControledMihomoConfig as patch } from '@renderer/utils/ipc' import { getControledMihomoConfig, patchControledMihomoConfig as patch } from '@renderer/utils/ipc'
@ -23,7 +23,7 @@ export const ControledMihomoConfigProvider: React.FC<{ children: ReactNode }> =
try { try {
await patch(value) await patch(value)
} catch (e) { } catch (e) {
toast.error(String(e)) await showError(e, '更新内核配置失败')
} finally { } finally {
mutateControledMihomoConfig() mutateControledMihomoConfig()
} }

View File

@ -1,5 +1,5 @@
import React, { createContext, useContext, ReactNode } from 'react' 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 useSWR from 'swr'
import { import {
getOverrideConfig, getOverrideConfig,
@ -29,7 +29,7 @@ export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ chil
try { try {
await set(config) await set(config)
} catch (e) { } catch (e) {
toast.error(String(e)) await showError(e, '保存覆写配置失败')
} finally { } finally {
mutateOverrideConfig() mutateOverrideConfig()
} }
@ -39,7 +39,7 @@ export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ chil
try { try {
await add(item) await add(item)
} catch (e) { } catch (e) {
toast.error(String(e)) await showError(e, '添加覆写失败')
} finally { } finally {
mutateOverrideConfig() mutateOverrideConfig()
} }
@ -49,7 +49,7 @@ export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ chil
try { try {
await remove(id) await remove(id)
} catch (e) { } catch (e) {
toast.error(String(e)) await showError(e, '删除覆写失败')
} finally { } finally {
mutateOverrideConfig() mutateOverrideConfig()
} }
@ -59,7 +59,7 @@ export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ chil
try { try {
await update(item) await update(item)
} catch (e) { } catch (e) {
toast.error(String(e)) await showError(e, '更新覆写失败')
} finally { } finally {
mutateOverrideConfig() mutateOverrideConfig()
} }

View File

@ -1,4 +1,5 @@
import React, { createContext, ReactNode, useContext } from 'react' import React, { createContext, ReactNode, useContext } from 'react'
import { showError } from '@renderer/utils/error-display'
import { toast } from '@renderer/components/base/toast' import { toast } from '@renderer/components/base/toast'
import useSWR from 'swr' import useSWR from 'swr'
import { import {
@ -33,7 +34,7 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
try { try {
await set(config) await set(config)
} catch (e) { } catch (e) {
toast.error(String(e)) await showError(e, '保存配置失败')
} finally { } finally {
mutateProfileConfig() mutateProfileConfig()
window.electron.ipcRenderer.send('updateTrayMenu') window.electron.ipcRenderer.send('updateTrayMenu')
@ -44,7 +45,7 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
try { try {
await add(item) await add(item)
} catch (e) { } catch (e) {
toast.error(String(e)) await showError(e, '添加配置失败')
} finally { } finally {
mutateProfileConfig() mutateProfileConfig()
window.electron.ipcRenderer.send('updateTrayMenu') window.electron.ipcRenderer.send('updateTrayMenu')
@ -55,7 +56,7 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
try { try {
await remove(id) await remove(id)
} catch (e) { } catch (e) {
toast.error(String(e)) await showError(e, '删除配置失败')
} finally { } finally {
mutateProfileConfig() mutateProfileConfig()
window.electron.ipcRenderer.send('updateTrayMenu') window.electron.ipcRenderer.send('updateTrayMenu')
@ -66,7 +67,7 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
try { try {
await update(item) await update(item)
} catch (e) { } catch (e) {
toast.error(String(e)) await showError(e, '更新配置失败')
} finally { } finally {
mutateProfileConfig() mutateProfileConfig()
window.electron.ipcRenderer.send('updateTrayMenu') window.electron.ipcRenderer.send('updateTrayMenu')
@ -108,7 +109,7 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
if (errorMsg.includes('reply was never sent')) { if (errorMsg.includes('reply was never sent')) {
setTimeout(() => mutateProfileConfig(), 1000) setTimeout(() => mutateProfileConfig(), 1000)
} else { } else {
toast.error(errorMsg, '切换配置失败') await showError(errorMsg, '切换配置失败')
mutateProfileConfig() mutateProfileConfig()
} }
} finally { } finally {

View File

@ -1,6 +1,6 @@
import { Button, Tab, Input, Switch, Tabs, Divider } from '@heroui/react' import { Button, Tab, Input, Switch, Tabs, Divider } from '@heroui/react'
import BasePage from '@renderer/components/base/base-page' 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 { MdDeleteForever } from 'react-icons/md'
import SettingCard from '@renderer/components/base/base-setting-card' import SettingCard from '@renderer/components/base/base-setting-card'
import SettingItem from '@renderer/components/base/base-setting-item' import SettingItem from '@renderer/components/base/base-setting-item'
@ -146,7 +146,7 @@ const DNS: React.FC = () => {
await restartCore() await restartCore()
} }
} catch (e) { } catch (e) {
toast.error(String(e)) showErrorSync(e, 'DNS配置保存失败')
} }
} }

View File

@ -1,6 +1,7 @@
import { Button, Divider, Input, Select, SelectItem, Switch, Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure, Spinner, Chip } from '@heroui/react' 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 BasePage from '@renderer/components/base/base-page'
import { toast } from '@renderer/components/base/toast' import { toast } from '@renderer/components/base/toast'
import { showError } from '@renderer/utils/error-display'
import SettingCard from '@renderer/components/base/base-setting-card' import SettingCard from '@renderer/components/base/base-setting-card'
import SettingItem from '@renderer/components/base/base-setting-item' import SettingItem from '@renderer/components/base/base-setting-item'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
@ -14,7 +15,6 @@ import {
restartCore, restartCore,
startSubStoreBackendServer, startSubStoreBackendServer,
triggerSysProxy, triggerSysProxy,
showDetailedError,
fetchMihomoTags, fetchMihomoTags,
installSpecificMihomoCore, installSpecificMihomoCore,
clearMihomoVersionCache clearMihomoVersionCache
@ -306,11 +306,7 @@ const Mihomo: React.FC = () => {
const errorMessage = e instanceof Error ? e.message : String(e) const errorMessage = e instanceof Error ? e.message : String(e)
console.error('Core restart failed:', errorMessage) console.error('Core restart failed:', errorMessage)
if (errorMessage.includes('配置检查失败') || errorMessage.includes('Profile Check Failed')) { await showError(errorMessage, t('mihomo.error.profileCheckFailed'))
await showDetailedError(t('mihomo.error.profileCheckFailed'), errorMessage)
} else {
toast.error(errorMessage)
}
} finally { } finally {
PubSub.publish('mihomo-core-changed') PubSub.publish('mihomo-core-changed')
} }

View File

@ -1,6 +1,6 @@
import { Button, Divider, Input, Switch } from '@heroui/react' import { Button, Divider, Input, Switch } from '@heroui/react'
import BasePage from '@renderer/components/base/base-page' 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 SettingCard from '@renderer/components/base/base-setting-card'
import SettingItem from '@renderer/components/base/base-setting-item' import SettingItem from '@renderer/components/base/base-setting-item'
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config' import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
@ -71,7 +71,7 @@ const Sniffer: React.FC = () => {
await restartCore() await restartCore()
} }
} catch (e) { } catch (e) {
toast.error(String(e)) showErrorSync(e, '嵅探配置保存失败')
} }
} }

View File

@ -1,6 +1,6 @@
import { Button, Input, Tab, Tabs } from '@heroui/react' import { Button, Input, Tab, Tabs } from '@heroui/react'
import BasePage from '@renderer/components/base/base-page' 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 SettingCard from '@renderer/components/base/base-setting-card'
import SettingItem from '@renderer/components/base/base-setting-item' import SettingItem from '@renderer/components/base/base-setting-item'
import PacEditorModal from '@renderer/components/sysproxy/pac-editor-modal' import PacEditorModal from '@renderer/components/sysproxy/pac-editor-modal'
@ -106,7 +106,7 @@ const Sysproxy: React.FC = () => {
} catch (e) { } catch (e) {
setValues({ ...values, enable: previousState }) setValues({ ...values, enable: previousState })
setChanged(true) setChanged(true)
toast.error(String(e)) showErrorSync(e, '系统代理设置失败')
await patchAppConfig({ sysProxy: { enable: false } }) await patchAppConfig({ sysProxy: { enable: false } })
} }

View File

@ -1,6 +1,6 @@
import { Button, Input, Switch, Tab, Tabs } from '@heroui/react' import { Button, Input, Switch, Tab, Tabs } from '@heroui/react'
import BasePage from '@renderer/components/base/base-page' 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 SettingCard from '@renderer/components/base/base-setting-card'
import SettingItem from '@renderer/components/base/base-setting-item' import SettingItem from '@renderer/components/base/base-setting-item'
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config' import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
@ -113,7 +113,7 @@ const Tun: React.FC = () => {
new Notification(t('tun.notifications.firewallResetSuccess')) new Notification(t('tun.notifications.firewallResetSuccess'))
await restartCore() await restartCore()
} catch (e) { } catch (e) {
toast.error(String(e)) showErrorSync(e, '防火墙设置失败')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -134,7 +134,7 @@ const Tun: React.FC = () => {
new Notification(t('tun.notifications.coreAuthSuccess')) new Notification(t('tun.notifications.coreAuthSuccess'))
await restartCore() await restartCore()
} catch (e) { } catch (e) {
toast.error(String(e)) showErrorSync(e, '内核授权失败')
} }
}} }}
> >

View 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)
}
}