Initial commit

This commit is contained in:
zhongyidian committed 2026-09-08 14:19:44 +08:00
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";
+2
View File
@@ -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;
}
+4
View File
@@ -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";
+49
View File
@@ -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;
}
+23
View File
@@ -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";
+6
View File
@@ -0,0 +1,6 @@
import type { ButtonHTMLAttributes } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
}
+2
View File
@@ -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;
}
+98
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
export { Overlay } from "./Overlay";
export type { OverlayProps } from "./Overlay.types";
+26
View File
@@ -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);
}
+13
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
export { Portal } from "./Portal";
export type { PortalContainer, PortalProps } from "./Portal.types";
+36
View File
@@ -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;
}
+72
View File
@@ -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;
}
+30
View File
@@ -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>
);
}
+127
View File
@@ -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;
}
+40
View File
@@ -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>
);
}
+12
View File
@@ -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";