This commit is contained in:
mangomqy
2025-11-13 02:54:06 +00:00
commit c5e51ed069
254 changed files with 54901 additions and 0 deletions

View File

@ -0,0 +1,45 @@
/**
* 统一的 EmptyState 组件
* 用于显示空状态
*/
import React from "react";
import type { LucideIcon } from "lucide-react";
import { Button } from "../ui/Button";
export interface EmptyStateProps {
icon?: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
icon?: LucideIcon;
};
}
export const EmptyState: React.FC<EmptyStateProps> = ({
icon: Icon,
title,
description,
action,
}) => {
return (
<div className="text-center py-16 bg-gray-800/30 border border-gray-700 border-dashed rounded-lg">
{Icon && <Icon className="w-16 h-16 mx-auto mb-4 text-gray-600" />}
<h3 className="text-lg font-semibold text-gray-400 mb-2">{title}</h3>
{description && (
<p className="text-sm text-gray-500 mb-6">{description}</p>
)}
{action && (
<Button
variant="primary"
icon={action.icon}
onClick={action.onClick}
>
{action.label}
</Button>
)}
</div>
);
};

View File

@ -0,0 +1,33 @@
/**
* 配置性界面的空状态组件(无引导按钮)
* 仅提示用户当前没有配置数据
*/
import React from "react";
import type { LucideIcon } from "lucide-react";
interface EmptyStateSimpleProps {
/** 标题 */
title: string;
/** 描述文字 */
description: string;
/** 图标组件 */
Icon: LucideIcon;
/** 自定义样式(可选) */
className?: string;
}
export const EmptyStateSimple: React.FC<EmptyStateSimpleProps> = ({
title,
description,
Icon,
className = "",
}) => {
return (
<div className={`bg-gray-800/30 border border-gray-700 rounded-lg p-8 text-center ${className}`}>
<Icon className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-300 mb-2">{title}</h3>
<p className="text-gray-400">{description}</p>
</div>
);
};

View File

@ -0,0 +1,54 @@
/**
* 使用性界面的空状态组件(带引导按钮)
* 当缺少配置时,引导用户前往对应的配置页面
*/
import React from "react";
import { useNavigate } from "react-router-dom";
import type { LucideIcon } from "lucide-react";
interface EmptyStateWithGuideProps {
/** 标题 */
title: string;
/** 描述文字 */
description: string;
/** 引导按钮文字 */
buttonText: string;
/** 引导目标路径 */
targetPath: string;
/** 图标组件 */
Icon: LucideIcon;
}
export const EmptyStateWithGuide: React.FC<EmptyStateWithGuideProps> = ({
title,
description,
buttonText,
targetPath,
Icon,
}) => {
const navigate = useNavigate();
return (
<div className="bg-yellow-900/20 border border-yellow-700/50 rounded-lg p-6">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<svg className="w-6 h-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-yellow-300 mb-2">{title}</h3>
<p className="text-yellow-200 mb-4">{description}</p>
<button
onClick={() => navigate(targetPath)}
className="inline-flex items-center gap-2 px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition"
>
<Icon className="w-4 h-4" />
{buttonText}
</button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,38 @@
/**
* 统一的 ErrorState 组件
* 用于显示错误状态
*/
import React from "react";
import { AlertCircle } from "lucide-react";
import { Button } from "../ui/Button";
import { ButtonText, ErrorText } from "../../constants";
export interface ErrorStateProps {
title?: string;
message: string;
onRetry?: () => void;
retryLabel?: string;
}
export const ErrorState: React.FC<ErrorStateProps> = ({
title = ErrorText.ERROR_OCCURRED,
message,
onRetry,
retryLabel = ButtonText.RETRY,
}) => {
return (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-500/50 rounded-lg text-red-400">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<div className="flex-1">
{title && <p className="font-medium">{title}</p>}
<p className={title ? "text-sm mt-1" : ""}>{message}</p>
</div>
{onRetry && (
<Button variant="danger" size="sm" onClick={onRetry}>
{retryLabel}
</Button>
)}
</div>
);
};

View File

@ -0,0 +1,45 @@
/**
* 统一的 LoadingState 组件
* 用于显示加载状态
*/
import React from "react";
export interface LoadingStateProps {
message?: string;
size?: "sm" | "md" | "lg";
fullScreen?: boolean;
}
const sizeStyles = {
sm: "w-6 h-6 border-2",
md: "w-12 h-12 border-4",
lg: "w-16 h-16 border-4",
};
export const LoadingState: React.FC<LoadingStateProps> = ({
message = "Loading...",
size = "md",
fullScreen = false,
}) => {
const content = (
<div className="flex flex-col items-center justify-center gap-4">
<div className={`${sizeStyles[size]} border-purple-600 border-t-transparent rounded-full animate-spin`} />
{message && <p className="text-sm text-gray-400">{message}</p>}
</div>
);
if (fullScreen) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-gray-900/80 backdrop-blur-sm z-50">
{content}
</div>
);
}
return (
<div className="flex items-center justify-center py-12">
{content}
</div>
);
};

View File

@ -0,0 +1,28 @@
/**
* Toast Context
* Toast context definition - separated for Fast Refresh compatibility
*/
import { createContext } from "react";
export type ToastOptions = {
variant?: "success" | "error" | "warning" | "info";
title?: string;
description?: string;
durationMs?: number; // 0 = 不自动关闭(需要手动关闭)
id?: string; // 自定义 id你想手动控制唯一性时用
/** 指定"去重/合并"的键;不填则用 variant|title|description 自动生成 */
mergeKey?: string;
};
export type ToastContextValue = {
show: (opts: ToastOptions) => void;
success: (msg: string, opts?: Partial<ToastOptions>) => void;
error: (msg: string, opts?: Partial<ToastOptions>) => void;
warning: (msg: string, opts?: Partial<ToastOptions>) => void;
info: (msg: string, opts?: Partial<ToastOptions>) => void;
remove: (id: string) => void;
};
export const ToastContext = createContext<ToastContextValue | null>(null);

View File

@ -0,0 +1,270 @@
import React, {
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { CheckCircle2, Info, AlertTriangle, X, XCircle } from "lucide-react";
import { ToastContext, type ToastOptions, type ToastContextValue } from "./ToastContext";
/** ===================== 可调默认项 ===================== */
const DEFAULT_DURATION = 4500; // 默认显示时长(毫秒)
const ERROR_DURATION = 6000; // 错误类默认更久
const EXTEND_ON_DUP_MS = 1000; // 合并重复时延长的时间(毫秒)
const PREVENT_DUPLICATES = true; // 开启自动去重与合并计数
const HOVER_PAUSES_DISMISS = true; // 悬停暂停自动关闭
/** ===================================================== */
type ToastVariant = "success" | "error" | "warning" | "info";
type ToastItem = Required<
Omit<ToastOptions, "id" | "mergeKey">
> & {
id: string;
mergeKey: string;
createdAt: number;
startedAt: number; // 本轮计时开始时间(用于计算剩余)
remainingMs: number; // 剩余时间(暂停时更新)
paused: boolean;
count: number; // 合并计数
};
// useToast hook is now exported from ./useToast.ts for Fast Refresh compatibility
const variantStyle: Record<ToastVariant, string> = {
success: "bg-emerald-600 border-emerald-500",
error: "bg-red-600 border-red-500",
warning: "bg-amber-600 border-amber-500",
info: "bg-sky-600 border-sky-500",
};
const variantIcon: Record<ToastVariant, React.ReactNode> = {
success: <CheckCircle2 className="w-5 h-5" />,
error: <XCircle className="w-5 h-5" />,
warning: <AlertTriangle className="w-5 h-5" />,
info: <Info className="w-5 h-5" />,
};
export const ToastProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
// 保存每条 toast 的定时器句柄,避免放进 state
const timers = useRef(new Map<string, number>());
const removeTimer = (id: string) => {
const t = timers.current.get(id);
if (t) {
window.clearTimeout(t);
timers.current.delete(id);
}
};
const remove = useCallback((id: string) => {
removeTimer(id);
setToasts((xs) => xs.filter((t) => t.id !== id));
}, []);
const schedule = (id: string, ms: number) => {
if (ms <= 0) return; // 0 表示不自动关闭
removeTimer(id);
const h = window.setTimeout(() => {
remove(id);
}, ms);
timers.current.set(id, h);
};
const now = () => Date.now();
const makeMergeKey = (opts: ToastOptions) =>
(opts.mergeKey ??
`${opts.variant ?? "info"}|${opts.title ?? ""}|${opts.description ?? ""}`).trim();
const normalizedDuration = (variant?: ToastVariant, provided?: number) => {
if (provided === 0) return 0;
if (typeof provided === "number" && provided > 0) return provided;
return variant === "error" ? ERROR_DURATION : DEFAULT_DURATION;
};
const show = useCallback((opts: ToastOptions) => {
const variant = opts.variant ?? "info";
const durationMs = normalizedDuration(variant, opts.durationMs);
const id = opts.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const mergeKey = makeMergeKey(opts);
setToasts((prev) => {
if (PREVENT_DUPLICATES) {
// 先找是否已存在相同 mergeKey 的 toast
const idx = prev.findIndex((t) => t.mergeKey === mergeKey);
if (idx >= 0) {
const existing = prev[idx];
// 更新计数、时长(延长)、重新计时
const extended = Math.max(
existing.remainingMs,
durationMs > 0 ? durationMs : existing.remainingMs
) + EXTEND_ON_DUP_MS;
const updated: ToastItem = {
...existing,
count: existing.count + 1,
paused: false,
startedAt: now(),
remainingMs: existing.durationMs === 0 ? 0 : extended,
// 如果传入了更大的 durationMs也可以更新基准 duration
durationMs: existing.durationMs === 0 ? 0 : Math.max(existing.durationMs, durationMs),
};
// 重新调度
if (updated.durationMs > 0) {
removeTimer(existing.id);
schedule(existing.id, extended);
}
const next = [...prev];
next[idx] = updated;
return next;
}
}
const item: ToastItem = {
id,
mergeKey,
variant,
title: opts.title ?? "",
description: opts.description ?? "",
durationMs,
createdAt: now(),
startedAt: now(),
remainingMs: durationMs,
paused: false,
count: 1,
};
// 新建 toast 的定时
if (durationMs > 0) schedule(id, durationMs);
return [...prev, item];
});
}, [remove]);
const pause = (id: string) => {
if (!HOVER_PAUSES_DISMISS) return;
setToasts((prev) =>
prev.map((t) => {
if (t.id !== id || t.paused) return t;
// 暂停时计算剩余时间并清计时器
const elapsed = now() - t.startedAt;
const remaining = Math.max(0, t.remainingMs - elapsed);
removeTimer(id);
return { ...t, paused: true, remainingMs: remaining };
})
);
};
const resume = (id: string) => {
if (!HOVER_PAUSES_DISMISS) return;
setToasts((prev) =>
prev.map((t) => {
if (t.id !== id || !t.paused) return t;
const next = { ...t, paused: false, startedAt: now() };
if (next.durationMs > 0 && next.remainingMs > 0) {
schedule(id, next.remainingMs);
}
return next;
})
);
};
const api = useMemo<ToastContextValue>(
() => ({
show,
success: (msg, opts) =>
show({
...opts,
variant: "success",
title: opts?.title ?? "Success",
description: msg,
}),
error: (msg, opts) =>
show({
...opts,
variant: "error",
title: opts?.title ?? "Error",
description: msg,
durationMs: opts?.durationMs ?? ERROR_DURATION,
}),
warning: (msg, opts) =>
show({
...opts,
variant: "warning",
title: opts?.title ?? "Warning",
description: msg,
}),
info: (msg, opts) =>
show({
...opts,
variant: "info",
title: opts?.title ?? "Info",
description: msg,
}),
remove,
}),
[remove, show]
);
return (
<ToastContext.Provider value={api}>
{children}
{/* 容器 - 响应式定位 */}
<div className="fixed z-[200] top-4 sm:top-4 right-2 sm:right-4 left-2 sm:left-auto space-y-2 sm:space-y-3 max-w-sm sm:w-[calc(100vw-2rem)]">
{toasts.map((t) => {
const barEnabled = t.durationMs > 0 && t.remainingMs > 0;
// 估算进度(简单:剩余 / 基准),暂停时保持不动
const elapsed = t.paused ? 0 : Math.max(0, Date.now() - t.startedAt);
const left = Math.max(0, t.remainingMs - elapsed);
const pct = barEnabled ? Math.max(0, Math.min(1, left / Math.max(1, t.durationMs))) : 0;
return (
<div
key={t.id}
className={`border text-white rounded-lg shadow-lg p-2.5 sm:p-3 pt-3 sm:pt-3.5 flex items-start gap-2 sm:gap-3 relative ${variantStyle[t.variant]} animate-fadeIn`}
role="status"
onMouseEnter={() => pause(t.id)}
onMouseLeave={() => resume(t.id)}
>
{/* 顶部进度条(可选、低调) */}
{barEnabled && (
<div
className="absolute left-0 top-0 h-1 bg-white/40 rounded-t-lg transition-[width] duration-200 ease-linear"
style={{ width: `${pct * 100}%` }}
/>
)}
<div className="mt-0.5 flex-shrink-0">{variantIcon[t.variant]}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 sm:gap-2">
{t.title && <div className="font-semibold text-sm sm:text-base">{t.title}</div>}
{t.count > 1 && (
<span className="text-[10px] sm:text-xs px-1 sm:px-1.5 py-[1px] sm:py-[2px] rounded bg-black/20 border border-white/20">
×{t.count}
</span>
)}
</div>
{t.description && (
<div className="text-xs sm:text-sm opacity-90 break-words">{t.description}</div>
)}
</div>
<button
onClick={() => remove(t.id)}
className="opacity-80 hover:opacity-100 transition mt-0.5 flex-shrink-0 p-1"
aria-label="Close"
>
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</button>
</div>
);
})}
</div>
</ToastContext.Provider>
);
};

View File

@ -0,0 +1,18 @@
/**
* Feedback Components - User Feedback and State Display
* 反馈组件 - 用户反馈和状态展示
*/
// State displays
export { LoadingState, type LoadingStateProps } from "./LoadingState";
export { ErrorState, type ErrorStateProps } from "./ErrorState";
export { EmptyState, type EmptyStateProps } from "./EmptyState";
export { EmptyStateSimple } from "./EmptyStateSimple";
export { EmptyStateWithGuide } from "./EmptyStateWithGuide";
// Toast notifications
export { ToastProvider } from "./ToastProvider";
export { useToast } from "./useToast";
export type { ToastOptions } from "./ToastContext";

View File

@ -0,0 +1,14 @@
/**
* useToast Hook
* Toast context hook - separated for Fast Refresh compatibility
*/
import { useContext } from "react";
import { ToastContext } from "./ToastContext";
export const useToast = () => {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within <ToastProvider>");
return ctx;
};