Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
import { useRef, useState } from "react";
|
||
import { GuideSpotlight } from "@/components/common";
|
||
import { getAppPortalRoot } from "@/components/common/canvasPortal";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Overlay, type OverlayProps } from "@/components/ui/overlay";
|
||
import { Portal } from "@/components/ui/portal";
|
||
import { useToast } from "@/components/ui/toast";
|
||
import { useCourseCanvas } from "@/course-runtime/canvas";
|
||
import { useSpotlightStore } from "@/stores";
|
||
import { DragDropDemo } from "./DragDropDemo";
|
||
|
||
type OverlayDemoKey =
|
||
| "dim"
|
||
| "body"
|
||
| "custom"
|
||
| "light"
|
||
| "gradient"
|
||
| "image"
|
||
| "locked";
|
||
|
||
const overlayCards: Array<{
|
||
key: OverlayDemoKey;
|
||
title: string;
|
||
description: string;
|
||
buttonText: string;
|
||
overlayProps?: Pick<
|
||
OverlayProps,
|
||
| "variant"
|
||
| "backgroundImage"
|
||
| "backgroundClassName"
|
||
| "closeOnBackgroundClick"
|
||
>;
|
||
}> = [
|
||
{
|
||
key: "dim",
|
||
title: "课程画布遮罩",
|
||
description: "挂载到 App 画布,适合课程弹窗、确认提示和答题反馈。",
|
||
buttonText: "打开画布 Overlay",
|
||
overlayProps: {},
|
||
},
|
||
{
|
||
key: "body",
|
||
title: "body 挂载",
|
||
description: "默认挂载到 body,适合作为浏览器视口全局层。",
|
||
buttonText: "打开 body Overlay",
|
||
overlayProps: {},
|
||
},
|
||
{
|
||
key: "custom",
|
||
title: "ref 容器挂载",
|
||
description:
|
||
"通过 container={ref} 挂到上方蓝色虚线容器内,适合局部弹层测试。",
|
||
buttonText: "打开 ref Overlay",
|
||
overlayProps: { backgroundClassName: "bg-black/50" },
|
||
},
|
||
{
|
||
key: "light",
|
||
title: "浅色背景",
|
||
description: "适合浅色视觉风格或需要弱化遮罩的课程页面。",
|
||
buttonText: "打开浅色 Overlay",
|
||
overlayProps: { variant: "light" },
|
||
},
|
||
{
|
||
key: "gradient",
|
||
title: "Tailwind 自定义背景",
|
||
description: "通过 backgroundClassName 使用 Tailwind class 控制背景层。",
|
||
buttonText: "打开渐变 Overlay",
|
||
overlayProps: {
|
||
variant: "transparent",
|
||
backgroundClassName:
|
||
"bg-gradient-to-br from-blue-950/80 via-indigo-900/70 to-purple-900/80",
|
||
},
|
||
},
|
||
{
|
||
key: "image",
|
||
title: "背景图片示例",
|
||
description: "backgroundImage 接收合法的 CSS background-image 值。",
|
||
buttonText: "打开图片背景 Overlay",
|
||
overlayProps: {
|
||
variant: "transparent",
|
||
backgroundImage:
|
||
"linear-gradient(135deg, rgb(30 58 138 / 0.88), rgb(88 28 135 / 0.8))",
|
||
backgroundClassName: "bg-cover bg-center",
|
||
},
|
||
},
|
||
{
|
||
key: "locked",
|
||
title: "禁止点击背景关闭",
|
||
description: "适合必须点击按钮确认的课程提示或关键操作。",
|
||
buttonText: "打开锁定 Overlay",
|
||
overlayProps: {
|
||
closeOnBackgroundClick: false,
|
||
backgroundClassName: "bg-black/70",
|
||
},
|
||
},
|
||
];
|
||
|
||
export default function Components() {
|
||
const customRootRef = useRef<HTMLDivElement>(null);
|
||
const [portalVisible, setPortalVisible] = useState(false);
|
||
const [customPortalVisible, setCustomPortalVisible] = useState(false);
|
||
const [activeOverlay, setActiveOverlay] = useState<OverlayDemoKey | null>(
|
||
null,
|
||
);
|
||
const currentOverlay = overlayCards.find(
|
||
(item) => item.key === activeOverlay,
|
||
);
|
||
const isCustomOverlay = currentOverlay?.key === "custom";
|
||
const isBodyOverlay = currentOverlay?.key === "body";
|
||
const overlayContainer = isBodyOverlay
|
||
? undefined
|
||
: isCustomOverlay
|
||
? customRootRef
|
||
: getAppPortalRoot;
|
||
const toast = useToast();
|
||
const { scale } = useCourseCanvas();
|
||
|
||
const startGuideSpotlight = () => {
|
||
useSpotlightStore.getState().startSpotlight({
|
||
courseId: "components-playground",
|
||
spotlightId: "spotlight-demo",
|
||
steps: [
|
||
{
|
||
id: "guide-card",
|
||
target: { dataGuide: "spotlight-card" },
|
||
title: "引导目标区域",
|
||
description:
|
||
"通过 data-guide 标记页面元素,GuideSpotlight 会自动定位并高亮目标。",
|
||
placement: "bottom",
|
||
},
|
||
{
|
||
id: "guide-actions",
|
||
target: { dataGuide: "spotlight-actions" },
|
||
title: "引导操作按钮",
|
||
description: "可以为不同步骤设置 placement、文案和高亮间距。",
|
||
placement: "top",
|
||
padding: 12,
|
||
},
|
||
{
|
||
id: "guide-note",
|
||
target: { dataGuide: "spotlight-note" },
|
||
title: "完成引导",
|
||
description:
|
||
"示例页中可以手动触发引导,便于调试课程页面的新手提示流程。",
|
||
placement: "left",
|
||
doneLabel: "知道了",
|
||
},
|
||
],
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="mx-auto max-w-6xl space-y-10 px-6 py-10 text-left">
|
||
<header className="space-y-3 text-center">
|
||
<p className="text-sm font-medium text-blue-600">
|
||
Components Playground
|
||
</p>
|
||
<h1 className="text-4xl font-bold text-gray-900">组件示例页</h1>
|
||
<p className="text-gray-600">
|
||
用于验证组件在模板中正常使用,展示与组件库(yuxiang-kit)集成效果。
|
||
</p>
|
||
<div className="mx-auto mt-4 max-w-2xl rounded-2xl border border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 p-4 text-left">
|
||
<p className="text-sm font-semibold text-blue-700">
|
||
📐 本模板使用固定 1920 × 1080 设计画布 + CSS transform 整体缩放
|
||
</p>
|
||
<p className="mt-1 text-sm text-blue-600">
|
||
当前缩放比例 scale = {scale.toFixed(3)}。 DragDrop 等组件通过 scale
|
||
参数修正坐标, GuideSpotlight 通过 viewport + container
|
||
定位画布内元素。
|
||
</p>
|
||
</div>
|
||
</header>
|
||
|
||
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||
<div className="mb-5 space-y-2">
|
||
<h2 className="text-2xl font-semibold text-gray-900">Button</h2>
|
||
<p className="text-gray-600">现有按钮组件的 variant 和 size 示例。</p>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Button>Primary</Button>
|
||
<Button variant="secondary">Secondary</Button>
|
||
<Button variant="outline">Outline</Button>
|
||
<Button variant="ghost">Ghost</Button>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Button size="sm">Small</Button>
|
||
<Button size="md">Medium</Button>
|
||
<Button size="lg">Large</Button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||
<div className="mb-5 space-y-2">
|
||
<h2 className="text-2xl font-semibold text-gray-900">Toast</h2>
|
||
<p className="text-gray-600">
|
||
通过 useToast 显示轻提示,支持 info、success、warning、error
|
||
四种类型。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button onClick={() => toast.info("这是一条普通提示。")}>
|
||
普通提示
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => toast.success("操作成功。")}
|
||
>
|
||
成功提示
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => toast.warning("请注意检查。")}
|
||
>
|
||
警告提示
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
className="text-red-600"
|
||
onClick={() => toast.error("网络异常,请稍后重试。")}
|
||
>
|
||
错误提示
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() =>
|
||
toast.show({
|
||
message: "这条提示使用了单条 className 覆盖。",
|
||
type: "success",
|
||
className: "rounded-full border-0 bg-black/80 px-8 text-white",
|
||
})
|
||
}
|
||
>
|
||
自定义样式提示
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||
<div className="mb-5 space-y-2">
|
||
<h2 className="text-2xl font-semibold text-gray-900">
|
||
GuideSpotlight
|
||
</h2>
|
||
<p className="text-gray-600">
|
||
引导组件会根据 data-guide 或 selector
|
||
高亮页面元素,适合课程新手引导、功能提示和操作说明。
|
||
</p>
|
||
</div>
|
||
|
||
<div
|
||
className="grid gap-4 rounded-xl border border-dashed border-blue-300 bg-blue-50 p-5 md:grid-cols-[1fr_auto] md:items-center"
|
||
data-guide="spotlight-card"
|
||
>
|
||
<div className="space-y-2">
|
||
<p className="text-sm font-medium text-blue-600">Guide Demo</p>
|
||
<h3 className="text-xl font-semibold text-gray-900">
|
||
课程引导示例区域
|
||
</h3>
|
||
<p className="text-sm text-gray-600" data-guide="spotlight-note">
|
||
点击右侧按钮后,会依次高亮这个卡片、操作按钮和这段说明文字。
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-3" data-guide="spotlight-actions">
|
||
<Button onClick={startGuideSpotlight}>开始引导</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() =>
|
||
useSpotlightStore
|
||
.getState()
|
||
.resetSpotlight("components-playground", "spotlight-demo")
|
||
}
|
||
>
|
||
重置引导记录
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<DragDropDemo />
|
||
|
||
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||
<div className="mb-5 space-y-2">
|
||
<h2 className="text-2xl font-semibold text-gray-900">Portal</h2>
|
||
<p className="text-gray-600">
|
||
Portal 默认挂载到 document.body,也可以通过 container
|
||
指定自定义挂载位置。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mb-5 flex flex-wrap gap-3">
|
||
<Button onClick={() => setPortalVisible((visible) => !visible)}>
|
||
{portalVisible ? "隐藏 body Portal" : "显示 body Portal"}
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => setCustomPortalVisible((visible) => !visible)}
|
||
>
|
||
{customPortalVisible ? "隐藏 ref Portal" : "显示 ref Portal"}
|
||
</Button>
|
||
</div>
|
||
|
||
<div
|
||
ref={customRootRef}
|
||
className="relative h-44 overflow-hidden rounded-xl border-2 border-dashed border-blue-300 bg-blue-50 p-4"
|
||
>
|
||
<p className="text-sm text-blue-700">
|
||
这是自定义 ref 容器。Portal 或 Overlay 都可以通过 container
|
||
挂载到这个容器里。
|
||
</p>
|
||
</div>
|
||
|
||
{portalVisible && (
|
||
<Portal>
|
||
<div className="fixed right-8 bottom-8 z-60 rounded-xl bg-blue-600 px-5 py-4 text-white shadow-xl">
|
||
这是通过 Portal 挂载到 body 的内容
|
||
</div>
|
||
</Portal>
|
||
)}
|
||
|
||
{customPortalVisible && (
|
||
<Portal container={customRootRef}>
|
||
<div className="absolute right-4 bottom-4 rounded-xl bg-purple-600 px-5 py-4 text-white shadow-xl">
|
||
这是通过 container={ref} 挂载的内容
|
||
</div>
|
||
</Portal>
|
||
)}
|
||
</section>
|
||
|
||
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||
<div className="mb-5 space-y-2">
|
||
<h2 className="text-2xl font-semibold text-gray-900">Overlay</h2>
|
||
<p className="text-gray-600">
|
||
Overlay 内部已使用 Portal,可同时测试背景、关闭行为和挂载位置。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
{overlayCards.map((item) => (
|
||
<div
|
||
key={item.key}
|
||
className="rounded-xl border border-gray-200 p-4"
|
||
>
|
||
<h3 className="mb-2 text-lg font-semibold text-gray-900">
|
||
{item.title}
|
||
</h3>
|
||
<p className="mb-4 min-h-12 text-sm text-gray-600">
|
||
{item.description}
|
||
</p>
|
||
<Button onClick={() => setActiveOverlay(item.key)}>
|
||
{item.buttonText}
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{currentOverlay && (
|
||
<Overlay
|
||
open={Boolean(currentOverlay)}
|
||
onClose={() => setActiveOverlay(null)}
|
||
container={overlayContainer}
|
||
className={isCustomOverlay ? "z-10" : undefined}
|
||
contentClassName="flex items-center justify-center p-8"
|
||
{...currentOverlay.overlayProps}
|
||
>
|
||
<div className="w-full max-w-xl rounded-3xl bg-white p-8 text-center shadow-2xl">
|
||
<p className="mb-2 text-sm font-medium text-blue-600">
|
||
Overlay Demo
|
||
</p>
|
||
<h3 className="mb-3 text-2xl font-bold text-gray-900">
|
||
{currentOverlay.title}
|
||
</h3>
|
||
<p className="mb-6 text-gray-600">{currentOverlay.description}</p>
|
||
<div className="flex justify-center gap-3">
|
||
<Button onClick={() => setActiveOverlay(null)}>关闭</Button>
|
||
<Button variant="outline" onClick={() => setActiveOverlay(null)}>
|
||
模拟继续学习
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Overlay>
|
||
)}
|
||
<GuideSpotlight />
|
||
</div>
|
||
);
|
||
}
|