generated from zhongyidian/yx-react-template
Initial commit
This commit is contained in:
commit
6b1e803320
158 files changed
+16461
No files matched your search
@@ -0,0 +1,15 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
.navigation {
|
||||
@apply flex gap-4 p-4 bg-gray-100 border-b border-gray-300;
|
||||
}
|
||||
|
||||
.link {
|
||||
@apply text-blue-600 no-underline px-4 py-2 rounded transition-colors;
|
||||
@apply hover:bg-gray-200;
|
||||
}
|
||||
|
||||
.active {
|
||||
@apply bg-blue-100 text-blue-800 font-medium;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { cn } from "@/lib/cn";
|
||||
import styles from "./Navigation.module.css";
|
||||
import type { NavItem, NavigationProps } from "./Navigation.types";
|
||||
|
||||
const defaultItems: NavItem[] = [
|
||||
{ label: "首页", path: "/" },
|
||||
{ label: "关于", path: "/about" },
|
||||
{ label: "组件示例", path: "/components" },
|
||||
{ label: "课程示例", path: "/course-demo" },
|
||||
];
|
||||
|
||||
export function Navigation({
|
||||
items = defaultItems,
|
||||
className,
|
||||
}: NavigationProps) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<nav className={cn(styles.navigation, className)}>
|
||||
{items.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(styles.link, isActive && styles.active)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface NavItem {
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface NavigationProps {
|
||||
items?: NavItem[];
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Navigation } from "./Navigation.tsx";
|
||||
export type { NavItem, NavigationProps } from "./Navigation.types";
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { NavItem, NavigationProps } from "./Navigation";
|
||||
export { Navigation } from "./Navigation";
|
||||
@@ -0,0 +1,9 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
.container {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
@apply relative h-full z-10;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getStaticImage } from "@/utils/assets";
|
||||
import styles from "./BackgroundContainer.module.css";
|
||||
import type { BackgroundContainerProps } from "./BackgroundContainer.types";
|
||||
|
||||
const DIRECT_URL_PATTERN = /^(?:[a-z][a-z\d+.-]*:|\/|\.\.?\/)/i;
|
||||
|
||||
function resolveBackgroundImage(src: string): string {
|
||||
return DIRECT_URL_PATTERN.test(src) ? src : getStaticImage(src) || src;
|
||||
}
|
||||
|
||||
export function BackgroundContainer({
|
||||
src,
|
||||
fit = "cover",
|
||||
position = "center",
|
||||
repeat = "no-repeat",
|
||||
as: Component = "div",
|
||||
className,
|
||||
contentClassName,
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: BackgroundContainerProps) {
|
||||
const imageSrc = resolveBackgroundImage(src);
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cn(styles.container, className)}
|
||||
style={{
|
||||
backgroundImage: `url(${JSON.stringify(imageSrc)})`,
|
||||
backgroundSize: fit,
|
||||
backgroundPosition: position,
|
||||
backgroundRepeat: repeat,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn(styles.content, contentClassName)}>{children}</div>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type BackgroundContainerElement = "div" | "section" | "main" | "article";
|
||||
export type BackgroundContainerFit = "cover" | "contain";
|
||||
export type BackgroundContainerRepeat =
|
||||
| "no-repeat"
|
||||
| "repeat"
|
||||
| "repeat-x"
|
||||
| "repeat-y";
|
||||
|
||||
export interface BackgroundContainerProps
|
||||
extends Omit<HTMLAttributes<HTMLElement>, "children"> {
|
||||
/** 静态资源 key(相对 src/assets/images/)或可直接使用的图片 URL */
|
||||
src: string;
|
||||
fit?: BackgroundContainerFit;
|
||||
position?: string;
|
||||
repeat?: BackgroundContainerRepeat;
|
||||
as?: BackgroundContainerElement;
|
||||
contentClassName?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { BackgroundContainer } from "./BackgroundContainer";
|
||||
export type {
|
||||
BackgroundContainerElement,
|
||||
BackgroundContainerFit,
|
||||
BackgroundContainerProps,
|
||||
BackgroundContainerRepeat,
|
||||
} from "./BackgroundContainer.types";
|
||||
@@ -0,0 +1,44 @@
|
||||
import { appConfig } from "@/app.config";
|
||||
import { useDialogStore } from "@/stores";
|
||||
import type {
|
||||
GuideDialogStep,
|
||||
StartDialogOptions,
|
||||
} from "@/stores/types/dialog.types";
|
||||
|
||||
export interface DefineDialogOptions {
|
||||
id: string;
|
||||
steps: GuideDialogStep[];
|
||||
showOnce?: boolean;
|
||||
initialStep?: number;
|
||||
}
|
||||
|
||||
export interface GuideDialogController {
|
||||
id: string;
|
||||
steps: GuideDialogStep[];
|
||||
start: (options?: Pick<StartDialogOptions, "initialStep">) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function defineDialog({
|
||||
id,
|
||||
steps,
|
||||
showOnce = true,
|
||||
initialStep = 0,
|
||||
}: DefineDialogOptions): GuideDialogController {
|
||||
return {
|
||||
id,
|
||||
steps,
|
||||
start: (options) => {
|
||||
useDialogStore.getState().startDialog({
|
||||
courseId: appConfig.course.id,
|
||||
dialogId: id,
|
||||
showOnce,
|
||||
steps,
|
||||
initialStep: options?.initialStep ?? initialStep,
|
||||
});
|
||||
},
|
||||
reset: () => {
|
||||
useDialogStore.getState().resetDialog(appConfig.course.id, id);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback } from "react";
|
||||
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
|
||||
import { GuideDialog as RegistryGuideDialog } from "@/course/interactions/guide-dialog";
|
||||
import { useDialogStore } from "@/stores";
|
||||
import type { GuideDialogProps } from "./GuideDialog.types";
|
||||
|
||||
export function GuideDialog({
|
||||
children,
|
||||
onFinish,
|
||||
onSkip,
|
||||
...props
|
||||
}: GuideDialogProps) {
|
||||
const {
|
||||
open,
|
||||
steps,
|
||||
current,
|
||||
nextStep,
|
||||
prevStep,
|
||||
selectOption,
|
||||
finishDialog,
|
||||
skipDialog,
|
||||
} = useDialogStore();
|
||||
const { viewport } = useCourseCanvas();
|
||||
|
||||
const finish = useCallback(() => {
|
||||
finishDialog();
|
||||
onFinish?.();
|
||||
}, [finishDialog, onFinish]);
|
||||
|
||||
const skip = useCallback(() => {
|
||||
skipDialog();
|
||||
onSkip?.();
|
||||
}, [onSkip, skipDialog]);
|
||||
|
||||
return (
|
||||
<RegistryGuideDialog
|
||||
state={{
|
||||
open,
|
||||
steps,
|
||||
current,
|
||||
}}
|
||||
actions={{
|
||||
next: nextStep,
|
||||
prev: prevStep,
|
||||
skip,
|
||||
finish,
|
||||
selectOption,
|
||||
}}
|
||||
container={getAppPortalRoot}
|
||||
viewport={viewport}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</RegistryGuideDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { GuideDialogProps as RegistryGuideDialogProps } from "@/course/interactions/guide-dialog";
|
||||
|
||||
export interface GuideDialogProps
|
||||
extends Omit<RegistryGuideDialogProps, "actions" | "state" | "viewport"> {
|
||||
onFinish?: () => void;
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
export type {
|
||||
GuideDialogRenderState,
|
||||
GuideDialogTheme,
|
||||
} from "@/course/interactions/guide-dialog";
|
||||
@@ -0,0 +1,11 @@
|
||||
export { GuideDialog } from "./GuideDialog";
|
||||
export type {
|
||||
DefineDialogOptions,
|
||||
GuideDialogController,
|
||||
} from "./GuideDialog.helpers";
|
||||
export { defineDialog } from "./GuideDialog.helpers";
|
||||
export type {
|
||||
GuideDialogProps,
|
||||
GuideDialogRenderState,
|
||||
GuideDialogTheme,
|
||||
} from "./GuideDialog.types";
|
||||
@@ -0,0 +1,57 @@
|
||||
import { appConfig } from "@/app.config";
|
||||
import { useSpotlightStore } from "@/stores";
|
||||
import type {
|
||||
SpotlightStep,
|
||||
StartSpotlightOptions,
|
||||
} from "@/stores/types/spotlight.types";
|
||||
|
||||
type DefineSpotlightStep = Omit<SpotlightStep, "target"> & {
|
||||
target?: SpotlightStep["target"];
|
||||
};
|
||||
|
||||
export interface DefineSpotlightOptions {
|
||||
id: string;
|
||||
steps: DefineSpotlightStep[];
|
||||
showOnce?: boolean;
|
||||
initialStep?: number;
|
||||
}
|
||||
|
||||
export interface SpotlightController {
|
||||
id: string;
|
||||
steps: SpotlightStep[];
|
||||
start: (options?: Pick<StartSpotlightOptions, "initialStep">) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
function normalizeSteps(steps: DefineSpotlightStep[]): SpotlightStep[] {
|
||||
return steps.map((step) => ({
|
||||
...step,
|
||||
target: step.target ?? { dataGuide: step.id },
|
||||
}));
|
||||
}
|
||||
|
||||
export function defineSpotlight({
|
||||
id,
|
||||
steps,
|
||||
showOnce = true,
|
||||
initialStep = 0,
|
||||
}: DefineSpotlightOptions): SpotlightController {
|
||||
const normalizedSteps = normalizeSteps(steps);
|
||||
|
||||
return {
|
||||
id,
|
||||
steps: normalizedSteps,
|
||||
start: (options) => {
|
||||
useSpotlightStore.getState().startSpotlight({
|
||||
courseId: appConfig.course.id,
|
||||
spotlightId: id,
|
||||
showOnce,
|
||||
steps: normalizedSteps,
|
||||
initialStep: options?.initialStep ?? initialStep,
|
||||
});
|
||||
},
|
||||
reset: () => {
|
||||
useSpotlightStore.getState().resetSpotlight(appConfig.course.id, id);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback } from "react";
|
||||
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
|
||||
import { GuideSpotlight as RegistryGuideSpotlight } from "@/course/interactions/guide-spotlight";
|
||||
import { useSpotlightStore } from "@/stores";
|
||||
import type { GuideSpotlightProps } from "./GuideSpotlight.types";
|
||||
|
||||
export function GuideSpotlight({
|
||||
children,
|
||||
onFinish,
|
||||
onSkip,
|
||||
container = getAppPortalRoot,
|
||||
viewport,
|
||||
...props
|
||||
}: GuideSpotlightProps) {
|
||||
const {
|
||||
open,
|
||||
steps,
|
||||
current,
|
||||
nextStep,
|
||||
prevStep,
|
||||
finishSpotlight,
|
||||
skipSpotlight,
|
||||
} = useSpotlightStore();
|
||||
const { viewport: canvasViewport } = useCourseCanvas();
|
||||
|
||||
const finish = useCallback(() => {
|
||||
finishSpotlight();
|
||||
onFinish?.();
|
||||
}, [finishSpotlight, onFinish]);
|
||||
|
||||
const skip = useCallback(() => {
|
||||
skipSpotlight();
|
||||
onSkip?.();
|
||||
}, [onSkip, skipSpotlight]);
|
||||
|
||||
return (
|
||||
<RegistryGuideSpotlight
|
||||
state={{
|
||||
open,
|
||||
steps,
|
||||
current,
|
||||
}}
|
||||
actions={{
|
||||
next: nextStep,
|
||||
prev: prevStep,
|
||||
skip,
|
||||
finish,
|
||||
}}
|
||||
container={container}
|
||||
viewport={viewport ?? canvasViewport}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</RegistryGuideSpotlight>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { GuideSpotlightProps as RegistryGuideSpotlightProps } from "@/course/interactions/guide-spotlight";
|
||||
|
||||
export interface GuideSpotlightProps
|
||||
extends Omit<RegistryGuideSpotlightProps, "actions" | "state"> {
|
||||
onFinish?: () => void;
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
export type {
|
||||
GuideSpotlightRenderState,
|
||||
GuideSpotlightTheme,
|
||||
Rect,
|
||||
SpotlightLayout,
|
||||
} from "@/course/interactions/guide-spotlight";
|
||||
@@ -0,0 +1,11 @@
|
||||
export { GuideSpotlight } from "./GuideSpotlight";
|
||||
export type {
|
||||
DefineSpotlightOptions,
|
||||
SpotlightController,
|
||||
} from "./GuideSpotlight.helpers";
|
||||
export { defineSpotlight } from "./GuideSpotlight.helpers";
|
||||
export type {
|
||||
GuideSpotlightProps,
|
||||
GuideSpotlightRenderState,
|
||||
GuideSpotlightTheme,
|
||||
} from "./GuideSpotlight.types";
|
||||
@@ -0,0 +1,34 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
/* 不设 position:组件无绝对定位子元素,留空以便调用方用 className 自行定位 */
|
||||
.button {
|
||||
/* hover/按下缩放倍率由组件通过内联 --hover-scale / --press-scale 变量传入 */
|
||||
--hover-scale: 1.05;
|
||||
--press-scale: 0.95;
|
||||
transition: transform 0.18s ease-out;
|
||||
|
||||
@apply m-0 box-border cursor-pointer select-none appearance-none overflow-hidden rounded-md border-0 bg-transparent p-0 align-middle hover:brightness-110;
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500;
|
||||
@apply disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: scale(var(--hover-scale));
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: scale(var(--press-scale));
|
||||
}
|
||||
|
||||
.image {
|
||||
@apply block h-full w-full object-cover;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.button,
|
||||
.button:hover,
|
||||
.button:active {
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type CSSProperties, forwardRef } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getStaticImage } from "@/utils/assets";
|
||||
import styles from "./ImageButton.module.css";
|
||||
import type { ImageButtonProps } from "./ImageButton.types";
|
||||
|
||||
// 静态资源 key vs 外部 URL 的解析;未命中时回退原样交给 <img>
|
||||
function resolveImage(src: string): string {
|
||||
return /^(https?:|data:|blob:)/.test(src) ? src : getStaticImage(src) || src;
|
||||
}
|
||||
|
||||
export const ImageButton = forwardRef<HTMLButtonElement, ImageButtonProps>(
|
||||
(
|
||||
{
|
||||
src,
|
||||
alt = "",
|
||||
width,
|
||||
height,
|
||||
hoverScale = 1.05,
|
||||
pressScale = 0.95,
|
||||
className,
|
||||
style,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const imageSrc = resolveImage(src);
|
||||
// hover/按下缩放由 CSS 过渡实现,倍率通过内联变量传入(见 ImageButton.module.css)
|
||||
const scaleVars = {
|
||||
"--hover-scale": hoverScale,
|
||||
"--press-scale": pressScale,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={cn(styles.button, className)}
|
||||
style={{ ...scaleVars, width, height, ...style }}
|
||||
{...rest}
|
||||
>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
className={styles.image}
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ImageButton.displayName = "ImageButton";
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
|
||||
export interface ImageButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** 图片:静态资源 key(相对 src/assets/images/)或外部 http(s)/data/blob URL */
|
||||
src: string;
|
||||
alt?: string;
|
||||
/** 显示尺寸(数字按 px,字符串原样透传)。图片按钮通常应有确定尺寸 */
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
/** hover 放大倍率,默认 1.05 */
|
||||
hoverScale?: number;
|
||||
/** 按下缩小倍率,默认 0.95 */
|
||||
pressScale?: number;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ImageButton } from "./ImageButton";
|
||||
export type { ImageButtonProps } from "./ImageButton.types";
|
||||
@@ -0,0 +1,39 @@
|
||||
import { isRouteErrorResponse, Link, useRouteError } from "react-router-dom";
|
||||
|
||||
export function RouteError() {
|
||||
const error = useRouteError();
|
||||
const message = isRouteErrorResponse(error)
|
||||
? `${error.status} ${error.statusText}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "页面加载失败";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center p-8">
|
||||
<div className="w-full max-w-xl rounded-3xl border border-rose-200 bg-white p-8 text-center shadow-xl">
|
||||
<p className="text-sm font-semibold text-rose-600">页面运行异常</p>
|
||||
<h1 className="mt-3 text-2xl font-bold text-gray-950">
|
||||
当前示例无法加载
|
||||
</h1>
|
||||
<p className="mt-3 break-words text-sm leading-6 text-gray-600">
|
||||
{message}
|
||||
</p>
|
||||
<div className="mt-6 flex justify-center gap-3">
|
||||
<Link
|
||||
to="/"
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
返回首页
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function RouteFallback() {
|
||||
return (
|
||||
<div className="flex min-h-[200px] items-center justify-center text-gray-500">
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { RouteError } from "./RouteError";
|
||||
export { RouteFallback } from "./RouteFallback";
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Overlay } from "@/components/ui/overlay";
|
||||
import { getAppPortalRoot } from "@/course/canvas";
|
||||
import type { VideoOverlayProps } from "./VideoOverlay.types";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
|
||||
/** 视频弹层:支持全屏过场与窗口播放,由调用方决定关闭和流程推进策略 */
|
||||
export function VideoOverlay({
|
||||
open,
|
||||
src,
|
||||
poster,
|
||||
autoPlay = true,
|
||||
mode = "fullscreen",
|
||||
windowWidth = 1200,
|
||||
windowHeight = 675,
|
||||
dismissible = false,
|
||||
lockScroll = true,
|
||||
skipLabel,
|
||||
onClose,
|
||||
onEnded,
|
||||
onSkip,
|
||||
}: VideoOverlayProps) {
|
||||
return (
|
||||
<Overlay
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
container={getAppPortalRoot}
|
||||
variant="dim"
|
||||
closeOnEscape={dismissible}
|
||||
closeOnBackgroundClick={dismissible}
|
||||
lockScroll={lockScroll}
|
||||
className="z-50"
|
||||
contentClassName="flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
style={
|
||||
mode === "window"
|
||||
? { width: windowWidth, height: windowHeight }
|
||||
: undefined
|
||||
}
|
||||
className={
|
||||
mode === "window"
|
||||
? "relative overflow-hidden rounded-lg bg-black shadow-2xl"
|
||||
: "absolute inset-0 h-full w-full bg-black"
|
||||
}
|
||||
>
|
||||
<VideoPlayer
|
||||
src={src}
|
||||
poster={poster}
|
||||
autoPlay={autoPlay}
|
||||
mode={mode}
|
||||
allowFullscreenToggle={false}
|
||||
onEnded={onEnded}
|
||||
/>
|
||||
|
||||
{skipLabel && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-14 top-10 z-10 rounded-full bg-black/55 px-10 py-4 text-[30px] text-white/90 hover:bg-black/75"
|
||||
onClick={onSkip}
|
||||
>
|
||||
{skipLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface VideoOverlayProps {
|
||||
/** 是否显示 */
|
||||
open: boolean;
|
||||
/** 可直接交给 video 元素的视频 URL */
|
||||
src: string;
|
||||
/** 封面图 */
|
||||
poster?: string;
|
||||
/** 自动播放 */
|
||||
autoPlay?: boolean;
|
||||
/** 播放模式 */
|
||||
mode?: "fullscreen" | "window";
|
||||
/** 窗口模式下的宽度(px) */
|
||||
windowWidth?: number;
|
||||
/** 窗口模式下的高度(px) */
|
||||
windowHeight?: number;
|
||||
/** 是否允许点击背景或按 Escape 关闭 */
|
||||
dismissible?: boolean;
|
||||
/** 弹层打开时是否锁定页面滚动 */
|
||||
lockScroll?: boolean;
|
||||
/** 跳过按钮文案,不传则不显示 */
|
||||
skipLabel?: string;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 播放结束回调 */
|
||||
onEnded?: () => void;
|
||||
/** 点击跳过回调 */
|
||||
onSkip?: () => void;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
/* 播放器容器 */
|
||||
.player {
|
||||
@apply relative flex items-center justify-center overflow-hidden bg-black;
|
||||
}
|
||||
|
||||
.playerFullscreen {
|
||||
@apply absolute inset-0 h-full w-full;
|
||||
}
|
||||
|
||||
.playerWindow {
|
||||
@apply relative mx-auto;
|
||||
}
|
||||
|
||||
/* 视频元素 */
|
||||
.video {
|
||||
@apply h-full w-full object-contain;
|
||||
}
|
||||
|
||||
/* 中央播放按钮(暂停时显示) */
|
||||
.centerPlayButton {
|
||||
@apply absolute flex h-20 w-20 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-opacity duration-300;
|
||||
}
|
||||
|
||||
.centerPlayButton:hover {
|
||||
@apply bg-black/70;
|
||||
}
|
||||
|
||||
/* 控制条容器 */
|
||||
.controls {
|
||||
@apply absolute bottom-0 left-0 right-0 flex flex-col bg-gradient-to-t from-black/80 to-transparent px-4 pb-3 pt-12 transition-opacity duration-300;
|
||||
}
|
||||
|
||||
.controlsHidden {
|
||||
@apply pointer-events-none opacity-0;
|
||||
}
|
||||
|
||||
/* 小进度条(控件隐藏时显示) */
|
||||
.miniProgress {
|
||||
@apply pointer-events-none absolute bottom-0 left-0 right-0 h-1 bg-white/20 opacity-100 transition-opacity duration-300;
|
||||
}
|
||||
|
||||
.miniProgressHidden {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.miniProgressBar {
|
||||
@apply h-full w-full origin-left bg-red-500;
|
||||
transform: scaleX(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.progressContainer {
|
||||
@apply relative mb-2 h-1.5 cursor-pointer rounded-full bg-white/30;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.progressBar,
|
||||
.progressBuffered {
|
||||
@apply absolute left-0 top-0 h-full w-full origin-left rounded-full;
|
||||
transform: scaleX(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
@apply bg-red-500;
|
||||
}
|
||||
|
||||
.progressBuffered {
|
||||
@apply bg-white/30;
|
||||
}
|
||||
|
||||
.progressThumb {
|
||||
@apply absolute left-0 top-1/2 h-3.5 w-3.5 rounded-full bg-red-500 opacity-0 shadow-md transition-opacity duration-200;
|
||||
transform: translate3d(0, -50%, 0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.progressContainer:hover .progressThumb,
|
||||
.progressDragging .progressThumb {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.progressDragging {
|
||||
@apply cursor-grabbing;
|
||||
}
|
||||
|
||||
/* 时间显示 */
|
||||
.timeDisplay {
|
||||
@apply text-xs text-white/90 tabular-nums;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.controlButton {
|
||||
@apply flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-white/90 transition-colors hover:text-white;
|
||||
}
|
||||
|
||||
.controlButton:disabled {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
}
|
||||
|
||||
/* 倍速选择器 */
|
||||
.rateSelector {
|
||||
@apply flex cursor-pointer items-center rounded px-2 py-0.5 text-xs text-white/90 transition-colors hover:bg-white/20 hover:text-white;
|
||||
}
|
||||
|
||||
/* 音量滑块 */
|
||||
.volumeSlider {
|
||||
@apply h-1 w-16 cursor-pointer appearance-none rounded-full bg-white/30;
|
||||
}
|
||||
|
||||
.volumeSlider::-webkit-slider-thumb {
|
||||
@apply h-3 w-3 appearance-none rounded-full bg-white shadow-md;
|
||||
}
|
||||
|
||||
.volumeSlider::-moz-range-thumb {
|
||||
@apply h-3 w-3 rounded-full border-0 bg-white shadow-md;
|
||||
}
|
||||
|
||||
/* 底部控制行 */
|
||||
.controlRow {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading {
|
||||
@apply absolute inset-0 flex items-center justify-center bg-black/30;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@apply h-10 w-10 animate-spin rounded-full border-4 border-white/30 border-t-white;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
export interface VideoPlayerRef {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (time: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
getCurrentTime: () => number;
|
||||
getDuration: () => number;
|
||||
}
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
/** 视频源 URL */
|
||||
src: string;
|
||||
/** 封面图 */
|
||||
poster?: string;
|
||||
/** 自动播放 */
|
||||
autoPlay?: boolean;
|
||||
/** 循环播放 */
|
||||
loop?: boolean;
|
||||
/** 默认静音 */
|
||||
muted?: boolean;
|
||||
/** 预加载策略 */
|
||||
preload?: "none" | "metadata" | "auto";
|
||||
/** 初始播放速度 */
|
||||
playbackRate?: number;
|
||||
/** 显示模式:全屏覆盖或窗口 */
|
||||
mode?: "fullscreen" | "window";
|
||||
/** 是否允许在全屏覆盖与窗口模式之间切换 */
|
||||
allowFullscreenToggle?: boolean;
|
||||
/** 自定义 className */
|
||||
className?: string;
|
||||
|
||||
// 事件回调
|
||||
/** 播放开始时触发 */
|
||||
onPlay?: () => void;
|
||||
/** 暂停时触发 */
|
||||
onPause?: () => void;
|
||||
/** 播放结束时触发 */
|
||||
onEnded?: () => void;
|
||||
/** 时间更新时触发 */
|
||||
onTimeUpdate?: (currentTime: number) => void;
|
||||
/** 进度加载时触发 */
|
||||
onProgress?: (buffered: TimeRanges | null) => void;
|
||||
/** 音量变化时触发 */
|
||||
onVolumeChange?: (volume: number, muted: boolean) => void;
|
||||
/** 播放速度变化时触发 */
|
||||
onPlaybackRateChange?: (rate: number) => void;
|
||||
/** 全屏状态变化时触发 */
|
||||
onFullscreenChange?: (isFullscreen: boolean) => void;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { VideoOverlay } from "./VideoOverlay";
|
||||
export type { VideoOverlayProps } from "./VideoOverlay.types";
|
||||
export { VideoPlayer } from "./VideoPlayer";
|
||||
export type { VideoPlayerProps, VideoPlayerRef } from "./VideoPlayer.types";
|
||||
@@ -0,0 +1,49 @@
|
||||
export type { ButtonProps } from "@/components/ui/button";
|
||||
export { Button } from "@/components/ui/button";
|
||||
export type { OverlayProps } from "@/components/ui/overlay";
|
||||
export { Overlay } from "@/components/ui/overlay";
|
||||
export type { PortalContainer, PortalProps } from "@/components/ui/portal";
|
||||
export { Portal } from "@/components/ui/portal";
|
||||
export type {
|
||||
ToastContextValue,
|
||||
ToastItem as ToastItemData,
|
||||
ToastOptions,
|
||||
ToastProviderProps,
|
||||
ToastRenderApi,
|
||||
ToastType,
|
||||
ToastViewportClassNames,
|
||||
ToastViewportProps,
|
||||
} from "@/components/ui/toast";
|
||||
export { ToastProvider, ToastViewport, useToast } from "@/components/ui/toast";
|
||||
export type {
|
||||
BackgroundContainerElement,
|
||||
BackgroundContainerFit,
|
||||
BackgroundContainerProps,
|
||||
BackgroundContainerRepeat,
|
||||
} from "./BackgroundContainer";
|
||||
export { BackgroundContainer } from "./BackgroundContainer";
|
||||
export type {
|
||||
DefineDialogOptions,
|
||||
GuideDialogController,
|
||||
GuideDialogProps,
|
||||
GuideDialogRenderState,
|
||||
GuideDialogTheme,
|
||||
} from "./GuideDialog";
|
||||
export { defineDialog, GuideDialog } from "./GuideDialog";
|
||||
export type {
|
||||
DefineSpotlightOptions,
|
||||
GuideSpotlightProps,
|
||||
GuideSpotlightRenderState,
|
||||
GuideSpotlightTheme,
|
||||
SpotlightController,
|
||||
} from "./GuideSpotlight";
|
||||
export { defineSpotlight, GuideSpotlight } from "./GuideSpotlight";
|
||||
export type { ImageButtonProps } from "./ImageButton";
|
||||
export { ImageButton } from "./ImageButton";
|
||||
export { RouteError, RouteFallback } from "./RouteFallback";
|
||||
export type {
|
||||
VideoOverlayProps,
|
||||
VideoPlayerProps,
|
||||
VideoPlayerRef,
|
||||
} from "./Video";
|
||||
export { VideoOverlay, VideoPlayer } from "./Video";
|
||||
@@ -0,0 +1,48 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
.button {
|
||||
@apply relative m-0 box-border inline-flex cursor-pointer select-none appearance-none items-center justify-center overflow-hidden rounded-md border border-transparent bg-transparent p-0 align-middle font-medium leading-none transition-colors;
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset;
|
||||
@apply disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.primary {
|
||||
@apply bg-blue-600 text-white;
|
||||
}
|
||||
|
||||
.primary::before {
|
||||
@apply pointer-events-none absolute inset-0 rounded-[inherit] bg-black/0 transition-colors;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.primary:hover::before {
|
||||
@apply bg-black/10;
|
||||
}
|
||||
|
||||
.content {
|
||||
@apply relative z-10 inline-flex items-center justify-center;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
@apply bg-gray-200 text-gray-900 hover:bg-gray-300;
|
||||
}
|
||||
|
||||
.outline {
|
||||
@apply border-gray-300 bg-transparent hover:bg-gray-100;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
@apply bg-transparent hover:bg-gray-100;
|
||||
}
|
||||
|
||||
.sm {
|
||||
@apply h-8 px-3 text-sm;
|
||||
}
|
||||
|
||||
.md {
|
||||
@apply h-10 px-4 text-base;
|
||||
}
|
||||
|
||||
.lg {
|
||||
@apply h-12 px-6 text-lg;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import styles from "./Button.module.css";
|
||||
import type { ButtonProps } from "./Button.types";
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ children, className, variant = "primary", size = "md", ...props },
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(styles.button, styles[variant], styles[size], className)}
|
||||
{...props}
|
||||
>
|
||||
<span className={styles.content}>{children}</span>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "outline" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Button } from "./Button";
|
||||
export type { ButtonProps } from "./Button.types";
|
||||
@@ -0,0 +1,41 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
.overlay {
|
||||
@apply pointer-events-auto inset-0 z-50 overscroll-contain;
|
||||
}
|
||||
|
||||
.fixed {
|
||||
@apply fixed;
|
||||
}
|
||||
|
||||
.absolute {
|
||||
@apply absolute;
|
||||
}
|
||||
|
||||
.background {
|
||||
@apply absolute inset-0 cursor-default appearance-none border-0 p-0;
|
||||
}
|
||||
|
||||
.background:disabled {
|
||||
@apply cursor-default;
|
||||
}
|
||||
|
||||
.dim {
|
||||
@apply bg-black/60;
|
||||
}
|
||||
|
||||
.light {
|
||||
@apply bg-white/60;
|
||||
}
|
||||
|
||||
.transparent {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.content {
|
||||
@apply pointer-events-none absolute inset-0;
|
||||
}
|
||||
|
||||
.contentInner {
|
||||
@apply pointer-events-auto;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { CSSProperties, MouseEvent } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Portal } from "@/components/ui/portal";
|
||||
import { cn } from "@/lib/cn";
|
||||
import styles from "./Overlay.module.css";
|
||||
import type { OverlayProps } from "./Overlay.types";
|
||||
|
||||
export function Overlay({
|
||||
open,
|
||||
children,
|
||||
variant = "dim",
|
||||
backgroundImage,
|
||||
backgroundClassName,
|
||||
closeOnBackgroundClick = true,
|
||||
closeOnEscape = true,
|
||||
lockScroll = true,
|
||||
onClose,
|
||||
container,
|
||||
disabledPortal = false,
|
||||
className,
|
||||
contentClassName,
|
||||
fixed,
|
||||
...props
|
||||
}: OverlayProps) {
|
||||
const isFixed = fixed ?? container == null;
|
||||
useEffect(() => {
|
||||
if (!open || !closeOnEscape) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [closeOnEscape, onClose, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !lockScroll || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [lockScroll, open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const backgroundImageStyle: CSSProperties | undefined = backgroundImage
|
||||
? { backgroundImage }
|
||||
: undefined;
|
||||
|
||||
const handleBackgroundClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.currentTarget.blur();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal container={container} disabled={disabledPortal}>
|
||||
<div
|
||||
className={cn(
|
||||
styles.overlay,
|
||||
isFixed ? styles.fixed : styles.absolute,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭弹层背景"
|
||||
className={cn(
|
||||
styles.background,
|
||||
styles[variant],
|
||||
backgroundClassName,
|
||||
)}
|
||||
disabled={!closeOnBackgroundClick}
|
||||
style={backgroundImageStyle}
|
||||
onClick={handleBackgroundClick}
|
||||
/>
|
||||
<div className={cn(styles.content, contentClassName)}>
|
||||
<div className={styles.contentInner}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import type { PortalContainer } from "@/components/ui/portal";
|
||||
|
||||
export interface OverlayProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
||||
open: boolean;
|
||||
children?: ReactNode;
|
||||
variant?: "dim" | "light" | "transparent";
|
||||
backgroundImage?: string;
|
||||
backgroundClassName?: string;
|
||||
closeOnBackgroundClick?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
lockScroll?: boolean;
|
||||
onClose?: () => void;
|
||||
container?: PortalContainer;
|
||||
disabledPortal?: boolean;
|
||||
contentClassName?: string;
|
||||
fixed?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Overlay } from "./Overlay";
|
||||
export type { OverlayProps } from "./Overlay.types";
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import type { PortalContainer, PortalProps } from "./Portal.types";
|
||||
|
||||
function resolveContainer(container?: PortalContainer) {
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof container === "function") {
|
||||
return container();
|
||||
}
|
||||
|
||||
if ("current" in container) {
|
||||
return container.current;
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
export function Portal({ children, container, disabled = false }: PortalProps) {
|
||||
if (disabled || typeof document === "undefined") {
|
||||
return children;
|
||||
}
|
||||
|
||||
return createPortal(children, resolveContainer(container) ?? document.body);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
|
||||
export type PortalContainer =
|
||||
| Element
|
||||
| null
|
||||
| RefObject<Element | null>
|
||||
| (() => Element | null);
|
||||
|
||||
export interface PortalProps {
|
||||
children: ReactNode;
|
||||
container?: PortalContainer;
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Portal } from "./Portal";
|
||||
export type { PortalContainer, PortalProps } from "./Portal.types";
|
||||
@@ -0,0 +1,36 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
.container {
|
||||
@apply pointer-events-none absolute top-1/2 left-1/2 z-60 flex w-auto max-w-[calc(100%-80px)] -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-3;
|
||||
}
|
||||
|
||||
.customToast {
|
||||
@apply pointer-events-auto;
|
||||
}
|
||||
|
||||
.toast {
|
||||
@apply pointer-events-auto inline-flex max-w-[520px] items-center justify-center rounded-lg bg-black/75 px-6 py-3 text-center text-white shadow-[0_8px_24px_rgba(0,0,0,0.18)] backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.content {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.message {
|
||||
@apply text-sm font-medium leading-6 text-current;
|
||||
}
|
||||
|
||||
.close {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.info,
|
||||
.success,
|
||||
.warning,
|
||||
.error {
|
||||
@apply text-white;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { PortalContainer } from "@/components/ui/portal";
|
||||
|
||||
export type ToastType = "info" | "success" | "warning" | "error";
|
||||
|
||||
export interface ToastOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: ToastType;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface ToastItem
|
||||
extends Required<Omit<ToastOptions, "className" | "data">> {
|
||||
id: string;
|
||||
className?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface ToastProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface ToastContextValue {
|
||||
toasts: ToastItem[];
|
||||
show: (options: ToastOptions | string) => string;
|
||||
success: (
|
||||
message: string,
|
||||
options?: Omit<ToastOptions, "message" | "type">,
|
||||
) => string;
|
||||
error: (
|
||||
message: string,
|
||||
options?: Omit<ToastOptions, "message" | "type">,
|
||||
) => string;
|
||||
warning: (
|
||||
message: string,
|
||||
options?: Omit<ToastOptions, "message" | "type">,
|
||||
) => string;
|
||||
info: (
|
||||
message: string,
|
||||
options?: Omit<ToastOptions, "message" | "type">,
|
||||
) => string;
|
||||
remove: (id: string) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export interface ToastRenderApi {
|
||||
remove: (id: string) => void;
|
||||
}
|
||||
|
||||
export interface ToastViewportClassNames {
|
||||
container?: string;
|
||||
item?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
close?: string;
|
||||
}
|
||||
|
||||
export interface ToastViewportProps {
|
||||
container?: PortalContainer;
|
||||
className?: string;
|
||||
classNames?: ToastViewportClassNames;
|
||||
renderToast?: (toast: ToastItem, api: ToastRenderApi) => ReactNode;
|
||||
}
|
||||
|
||||
export interface ToastItemProps {
|
||||
toast: ToastItem;
|
||||
classNames?: ToastViewportClassNames;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import styles from "./Toast.module.css";
|
||||
import type { ToastItemProps } from "./Toast.types";
|
||||
|
||||
export function ToastItem({ toast, classNames, onRemove }: ToastItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
styles.toast,
|
||||
styles[toast.type],
|
||||
classNames?.item,
|
||||
toast.className,
|
||||
)}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<div className={cn(styles.title, classNames?.title)}>{toast.title}</div>
|
||||
<div className={cn(styles.message, classNames?.message)}>
|
||||
{toast.message}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(styles.close, classNames?.close)}
|
||||
onClick={() => onRemove(toast.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type {
|
||||
ToastContextValue,
|
||||
ToastItem,
|
||||
ToastOptions,
|
||||
ToastProviderProps,
|
||||
ToastType,
|
||||
} from "./Toast.types";
|
||||
|
||||
const DEFAULT_DURATION = 2400;
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
function createToastId() {
|
||||
return `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function getDefaultTitle(type: ToastType) {
|
||||
const titleMap: Record<ToastType, string> = {
|
||||
info: "提示",
|
||||
success: "成功",
|
||||
warning: "注意",
|
||||
error: "错误",
|
||||
};
|
||||
|
||||
return titleMap[type];
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: ToastProviderProps) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const timersRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
const timer = timersRef.current.get(id);
|
||||
if (timer) {
|
||||
window.clearTimeout(timer);
|
||||
timersRef.current.delete(id);
|
||||
}
|
||||
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
timersRef.current.forEach((timer) => {
|
||||
window.clearTimeout(timer);
|
||||
});
|
||||
timersRef.current.clear();
|
||||
setToasts([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timersRef.current.forEach((timer) => {
|
||||
window.clearTimeout(timer);
|
||||
});
|
||||
timersRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const show = useCallback(
|
||||
(options: ToastOptions | string) => {
|
||||
const normalizedOptions: ToastOptions =
|
||||
typeof options === "string" ? { message: options } : options;
|
||||
const type = normalizedOptions.type ?? "info";
|
||||
const id = createToastId();
|
||||
const toast: ToastItem = {
|
||||
id,
|
||||
type,
|
||||
title: normalizedOptions.title ?? getDefaultTitle(type),
|
||||
message: normalizedOptions.message,
|
||||
duration: normalizedOptions.duration ?? DEFAULT_DURATION,
|
||||
className: normalizedOptions.className,
|
||||
data: normalizedOptions.data,
|
||||
};
|
||||
|
||||
setToasts((current) => [...current, toast]);
|
||||
|
||||
if (toast.duration > 0) {
|
||||
const timer = window.setTimeout(() => remove(id), toast.duration);
|
||||
timersRef.current.set(id, timer);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
[remove],
|
||||
);
|
||||
|
||||
const contextValue = useMemo<ToastContextValue>(
|
||||
() => ({
|
||||
toasts,
|
||||
show,
|
||||
success: (message, options) =>
|
||||
show({ ...options, message, type: "success" }),
|
||||
error: (message, options) => show({ ...options, message, type: "error" }),
|
||||
warning: (message, options) =>
|
||||
show({ ...options, message, type: "warning" }),
|
||||
info: (message, options) => show({ ...options, message, type: "info" }),
|
||||
remove,
|
||||
clear,
|
||||
}),
|
||||
[clear, remove, show, toasts],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within ToastProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Portal } from "@/components/ui/portal";
|
||||
import { cn } from "@/lib/cn";
|
||||
import styles from "./Toast.module.css";
|
||||
import type { ToastViewportProps } from "./Toast.types";
|
||||
import { ToastItem } from "./ToastItem";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
export function ToastViewport({
|
||||
container,
|
||||
className,
|
||||
classNames,
|
||||
renderToast,
|
||||
}: ToastViewportProps) {
|
||||
const { toasts, remove } = useToast();
|
||||
|
||||
if (toasts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal container={container}>
|
||||
<div className={cn(styles.container, classNames?.container, className)}>
|
||||
{toasts.map((toast) =>
|
||||
renderToast ? (
|
||||
<div key={toast.id} className={styles.customToast}>
|
||||
{renderToast(toast, { remove })}
|
||||
</div>
|
||||
) : (
|
||||
<ToastItem
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
classNames={classNames}
|
||||
onRemove={remove}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
ToastContextValue,
|
||||
ToastItem,
|
||||
ToastOptions,
|
||||
ToastProviderProps,
|
||||
ToastRenderApi,
|
||||
ToastType,
|
||||
ToastViewportClassNames,
|
||||
ToastViewportProps,
|
||||
} from "./Toast.types";
|
||||
export { ToastProvider, useToast } from "./ToastProvider";
|
||||
export { ToastViewport } from "./ToastViewport";
|
||||
Reference in new issue
Block a user