[新增] 集成 Sonner 通知库,添加 Toaster 组件并在 InlineMessage 中使用

This commit is contained in:
ryan
2026-06-01 10:41:54 +08:00
parent f034b73a47
commit 65f899d334
15 changed files with 1775 additions and 4792 deletions
@@ -1,35 +1,44 @@
import { cn } from '@/lib/utils/cn'; 'use client';
import { useEffect } from 'react';
import { toast } from 'sonner';
type InlineMessageTone = 'info' | 'success' | 'danger'; type InlineMessageTone = 'info' | 'success' | 'danger';
const toneClasses: Record<InlineMessageTone, string> = {
info: 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]',
success:
'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]',
danger:
'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]',
};
interface InlineMessageProps { interface InlineMessageProps {
tone?: InlineMessageTone; tone?: InlineMessageTone;
message: string; message: string;
className?: string; className?: string;
onClear?: () => void;
} }
export function InlineMessage({ export function InlineMessage({
tone = 'info', tone = 'info',
message, message,
className, onClear,
}: InlineMessageProps) { }: InlineMessageProps) {
return ( useEffect(() => {
<div if (!message) return;
className={cn(
'rounded-2xl border px-4 py-3 text-sm leading-6', const options = {
toneClasses[tone], position: 'bottom-right' as const,
className, };
)}
> if (tone === 'success') {
{message} toast.success(message, options);
</div> } else if (tone === 'danger') {
); toast.error(message, options);
} else {
toast(message, options);
}
if (onClear) {
const timer = setTimeout(() => {
onClear();
}, 0);
return () => clearTimeout(timer);
}
}, [tone, message, onClear]);
return null;
} }
@@ -5,6 +5,7 @@ import { type ReactNode, useState } from 'react';
import { AuthProvider } from '@/components/providers/auth-provider'; import { AuthProvider } from '@/components/providers/auth-provider';
import { ThemeProvider } from '@/components/providers/theme-provider'; import { ThemeProvider } from '@/components/providers/theme-provider';
import { Toaster } from '@/components/ui/sonner';
interface AppProvidersProps { interface AppProvidersProps {
children: ReactNode; children: ReactNode;
@@ -30,7 +31,10 @@ export function AppProviders({ children }: AppProvidersProps) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider> <ThemeProvider>
<AuthProvider>{children}</AuthProvider> <AuthProvider>
{children}
<Toaster />
</AuthProvider>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
); );
@@ -0,0 +1,29 @@
'use client';
import { useTheme } from '@/components/providers/theme-provider';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
export function Toaster({ ...props }: ToasterProps) {
const { resolvedTheme } = useTheme();
return (
<Sonner
theme={resolvedTheme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-[var(--surface-elevated)] group-[.toaster]:text-[var(--foreground-primary)] group-[.toaster]:border-[var(--border-default)] group-[.toaster]:shadow-lg group-[.toaster]:rounded-2xl group-[.toaster]:font-sans',
description: 'group-[.toast]:text-[var(--foreground-secondary)]',
actionButton:
'group-[.toast]:bg-[var(--primary)] group-[.toast]:text-[var(--primary-foreground)]',
cancelButton:
'group-[.toast]:bg-[var(--muted)] group-[.toast]:text-[var(--muted-foreground)]',
},
}}
{...props}
/>
);
}
@@ -208,7 +208,11 @@ export function ApplyLogsPage() {
/> />
{feedback ? ( {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} /> <InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null} ) : null}
<AppCard <AppCard
@@ -547,7 +547,11 @@ export function ConfigVersionsPage() {
/> />
{feedback ? ( {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} /> <InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null} ) : null}
{publishPreview ? ( {publishPreview ? (
@@ -90,7 +90,11 @@ export function DnsAccountsPage() {
/> />
{feedback ? ( {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} /> <InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null} ) : null}
<AppCard title="DNS 账号列表"> <AppCard title="DNS 账号列表">
@@ -702,7 +702,11 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
/> />
{feedback ? ( {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} /> <InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null} ) : null}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@@ -207,7 +207,11 @@ export function NodesPage() {
/> />
{feedback ? ( {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} /> <InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null} ) : null}
<AppCard <AppCard
@@ -83,7 +83,11 @@ export function OriginsPage() {
/> />
{feedback ? ( {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} /> <InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null} ) : null}
<AppCard <AppCard
@@ -612,7 +612,11 @@ export function PerformancePage() {
/> />
{feedback ? ( {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} /> <InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null} ) : null}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@@ -220,7 +220,11 @@ export function ProxyRoutesPage() {
/> />
{feedback ? ( {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} /> <InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null} ) : null}
<div className="grid gap-4 xl:grid-cols-4"> <div className="grid gap-4 xl:grid-cols-4">
@@ -323,7 +323,11 @@ export function WAFIPGroupsPage() {
/> />
{feedback ? ( {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} /> <InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null} ) : null}
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]"> <div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
+1
View File
@@ -34,6 +34,7 @@
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"shadcn": "^4.1.0", "shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zod": "^3.24.2", "zod": "^3.24.2",
+1640 -4761
View File
File diff suppressed because it is too large Load Diff
@@ -1 +1,26 @@
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
const toastMock = vi.fn((msg) => {
const el = document.createElement('div');
el.textContent = typeof msg === 'string' ? msg : '';
document.body.appendChild(el);
return 'toast-id';
});
vi.mock('sonner', () => ({
toast: Object.assign(toastMock, {
success: vi.fn((msg) => {
const el = document.createElement('div');
el.textContent = typeof msg === 'string' ? msg : '';
document.body.appendChild(el);
return 'toast-id';
}),
error: vi.fn((msg) => {
const el = document.createElement('div');
el.textContent = typeof msg === 'string' ? msg : '';
document.body.appendChild(el);
return 'toast-id';
}),
}),
}));