ocdp v1
This commit is contained in:
45
frontend/src/shared/components/feedback/EmptyState.tsx
Normal file
45
frontend/src/shared/components/feedback/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
33
frontend/src/shared/components/feedback/EmptyStateSimple.tsx
Normal file
33
frontend/src/shared/components/feedback/EmptyStateSimple.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
38
frontend/src/shared/components/feedback/ErrorState.tsx
Normal file
38
frontend/src/shared/components/feedback/ErrorState.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
45
frontend/src/shared/components/feedback/LoadingState.tsx
Normal file
45
frontend/src/shared/components/feedback/LoadingState.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
28
frontend/src/shared/components/feedback/ToastContext.ts
Normal file
28
frontend/src/shared/components/feedback/ToastContext.ts
Normal 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);
|
||||
|
||||
270
frontend/src/shared/components/feedback/ToastProvider.tsx
Normal file
270
frontend/src/shared/components/feedback/ToastProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
18
frontend/src/shared/components/feedback/index.ts
Normal file
18
frontend/src/shared/components/feedback/index.ts
Normal 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";
|
||||
|
||||
|
||||
14
frontend/src/shared/components/feedback/useToast.ts
Normal file
14
frontend/src/shared/components/feedback/useToast.ts
Normal 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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user