generated from zhongyidian/yx-react-template
Initial commit
This commit is contained in:
commit
6b1e803320
158 files changed
+16461
No files matched your search
+45
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { ToastProvider, ToastViewport } from "@/components/ui/toast";
|
||||
import {
|
||||
CourseCanvas,
|
||||
registerAppPortalRoot,
|
||||
useCourseCanvas,
|
||||
} from "@/course/canvas";
|
||||
import { OrientationGuard } from "@/course/orientation";
|
||||
import { GameAudioProvider } from "@/utils/audio";
|
||||
import { Navigation } from "./components/business";
|
||||
|
||||
function CourseShell() {
|
||||
const { portalContainer } = useCourseCanvas();
|
||||
|
||||
useEffect(() => registerAppPortalRoot(portalContainer), [portalContainer]);
|
||||
|
||||
return (
|
||||
<GameAudioProvider>
|
||||
<ToastProvider>
|
||||
<Navigation />
|
||||
<main className="flex-1 overflow-auto p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
<ToastViewport container={portalContainer} />
|
||||
</ToastProvider>
|
||||
</GameAudioProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<OrientationGuard />
|
||||
<CourseCanvas
|
||||
className="bg-gray-900"
|
||||
canvasClassName="flex flex-col overflow-hidden bg-white"
|
||||
>
|
||||
<CourseShell />
|
||||
</CourseCanvas>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* API 客户端配置
|
||||
* 可以根据项目需求使用 axios、fetch 或其他 HTTP 客户端
|
||||
*/
|
||||
|
||||
import type { ApiError, ApiResponse } from "./client.types";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api";
|
||||
|
||||
/**
|
||||
* 基础请求函数
|
||||
*/
|
||||
export async function request<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ApiResponse<T>;
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw {
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
code: 500,
|
||||
} as ApiError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 请求
|
||||
*/
|
||||
export function get<T>(endpoint: string, options?: RequestInit) {
|
||||
return request<T>(endpoint, { ...options, method: "GET" });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求
|
||||
*/
|
||||
export function post<T>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
options?: RequestInit,
|
||||
) {
|
||||
return request<T>(endpoint, {
|
||||
...options,
|
||||
method: "POST",
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 请求
|
||||
*/
|
||||
export function put<T>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
options?: RequestInit,
|
||||
) {
|
||||
return request<T>(endpoint, {
|
||||
...options,
|
||||
method: "PUT",
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 请求
|
||||
*/
|
||||
export function del<T>(endpoint: string, options?: RequestInit) {
|
||||
return request<T>(endpoint, { ...options, method: "DELETE" });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
code?: number;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: number;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { del, get, post, put, request } from "./client.ts";
|
||||
export type { ApiError, ApiResponse } from "./client.types";
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 应用配置
|
||||
* 修改此文件后需要重启开发服务
|
||||
*/
|
||||
const env = (import.meta as ImportMeta & { env?: Record<string, string> }).env;
|
||||
|
||||
export const appConfig = {
|
||||
/** 应用标题 */
|
||||
title: "React Vite App",
|
||||
|
||||
/** 网站描述 */
|
||||
description: "易班 H5 课程模板",
|
||||
|
||||
/** Favicon 路径(相对于 public 目录) */
|
||||
favicon: "./vite.svg",
|
||||
|
||||
/** 课程配置 */
|
||||
course: {
|
||||
id: env?.VITE_COURSE_ID ?? "course-demo",
|
||||
name: env?.VITE_COURSE_NAME ?? "标准课程页示例",
|
||||
},
|
||||
|
||||
/** 本地存储配置 */
|
||||
storage: {
|
||||
prefix: "yiban-course-template",
|
||||
},
|
||||
|
||||
/** 视口配置 */
|
||||
viewport: {
|
||||
width: "device-width",
|
||||
initialScale: 1.0,
|
||||
minimumScale: 1.0,
|
||||
maximumScale: 1.0,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -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";
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { CourseCanvasContextValue } from "./CourseCanvas.types";
|
||||
|
||||
export const CanvasContext = createContext<CourseCanvasContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function useCourseCanvas(): CourseCanvasContextValue {
|
||||
const context = useContext(CanvasContext);
|
||||
if (!context) {
|
||||
throw new Error("useCourseCanvas must be used within CourseCanvas");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CanvasContext } from "./CanvasContext";
|
||||
import type {
|
||||
CourseCanvasContextValue,
|
||||
CourseCanvasProps,
|
||||
CourseCanvasViewport,
|
||||
} from "./CourseCanvas.types";
|
||||
import { useCanvasScale } from "./useCanvasScale";
|
||||
|
||||
const DEFAULT_WIDTH = 1920;
|
||||
const DEFAULT_HEIGHT = 1080;
|
||||
|
||||
export function CourseCanvas({
|
||||
children,
|
||||
width = DEFAULT_WIDTH,
|
||||
height = DEFAULT_HEIGHT,
|
||||
maxScale = 1,
|
||||
className,
|
||||
canvasClassName,
|
||||
portalClassName,
|
||||
}: CourseCanvasProps) {
|
||||
const scale = useCanvasScale({ width, height, maxScale });
|
||||
const frameRef = useRef<HTMLDivElement>(null);
|
||||
const portalContainer = useRef<HTMLDivElement>(null);
|
||||
const [viewport, setViewport] = useState<CourseCanvasViewport>({
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
originLeft: 0,
|
||||
originTop: 0,
|
||||
});
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const frame = frameRef.current;
|
||||
if (!frame) return;
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const committedScale = rect.width > 0 ? rect.width / width : scale;
|
||||
const next = {
|
||||
width,
|
||||
height,
|
||||
scale: committedScale,
|
||||
originLeft: rect.left,
|
||||
originTop: rect.top,
|
||||
};
|
||||
setViewport((current) =>
|
||||
current.width === next.width &&
|
||||
current.height === next.height &&
|
||||
current.scale === next.scale &&
|
||||
current.originLeft === next.originLeft &&
|
||||
current.originTop === next.originTop
|
||||
? current
|
||||
: next,
|
||||
);
|
||||
}, [height, scale, width]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current;
|
||||
let animationFrame = requestAnimationFrame(measure);
|
||||
const scheduleMeasure = () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
animationFrame = requestAnimationFrame(measure);
|
||||
};
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(scheduleMeasure);
|
||||
|
||||
if (frame) observer?.observe(frame);
|
||||
observer?.observe(document.documentElement);
|
||||
window.addEventListener("resize", scheduleMeasure);
|
||||
window.visualViewport?.addEventListener("resize", scheduleMeasure);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", scheduleMeasure);
|
||||
window.visualViewport?.removeEventListener("resize", scheduleMeasure);
|
||||
};
|
||||
}, [measure]);
|
||||
|
||||
const value = useMemo<CourseCanvasContextValue>(
|
||||
() => ({ width, height, scale, portalContainer, viewport }),
|
||||
[height, scale, viewport, width],
|
||||
);
|
||||
|
||||
return (
|
||||
<CanvasContext.Provider value={value}>
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={frameRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: width * scale,
|
||||
height: height * scale,
|
||||
flex: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={canvasClassName}
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
height,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "left top",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
ref={portalContainer}
|
||||
data-course-portal-root=""
|
||||
className={portalClassName}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CanvasContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
|
||||
export interface CourseCanvasViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
originLeft: number;
|
||||
originTop: number;
|
||||
}
|
||||
|
||||
export interface CourseCanvasContextValue {
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
portalContainer: RefObject<HTMLDivElement | null>;
|
||||
viewport: CourseCanvasViewport;
|
||||
}
|
||||
|
||||
export interface CourseCanvasProps {
|
||||
children: ReactNode;
|
||||
width?: number;
|
||||
height?: number;
|
||||
maxScale?: number;
|
||||
className?: string;
|
||||
canvasClassName?: string;
|
||||
portalClassName?: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
let portalRoot: RefObject<HTMLDivElement | null> | null = null;
|
||||
|
||||
export function registerAppPortalRoot(
|
||||
container: RefObject<HTMLDivElement | null>,
|
||||
): () => void {
|
||||
portalRoot = container;
|
||||
return () => {
|
||||
if (portalRoot === container) portalRoot = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function getAppPortalRoot(): HTMLDivElement | null {
|
||||
return portalRoot?.current ?? null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { CanvasContext, useCourseCanvas } from "./CanvasContext";
|
||||
export { CourseCanvas } from "./CourseCanvas";
|
||||
export type {
|
||||
CourseCanvasContextValue,
|
||||
CourseCanvasProps,
|
||||
CourseCanvasViewport,
|
||||
} from "./CourseCanvas.types";
|
||||
export { getAppPortalRoot, registerAppPortalRoot } from "./canvasPortal";
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface CanvasScaleOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
maxScale: number;
|
||||
}
|
||||
|
||||
function getViewportSize() {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
width: window.visualViewport?.width ?? window.innerWidth,
|
||||
height: window.visualViewport?.height ?? window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateScale({
|
||||
width,
|
||||
height,
|
||||
maxScale,
|
||||
}: CanvasScaleOptions): number {
|
||||
if (typeof window === "undefined") return maxScale;
|
||||
const viewport = getViewportSize();
|
||||
if (!viewport) return maxScale;
|
||||
return Math.min(viewport.width / width, viewport.height / height, maxScale);
|
||||
}
|
||||
|
||||
export function useCanvasScale({
|
||||
width,
|
||||
height,
|
||||
maxScale,
|
||||
}: CanvasScaleOptions): number {
|
||||
const [scale, setScale] = useState(() =>
|
||||
calculateScale({ width, height, maxScale }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const updateScale = () =>
|
||||
setScale(calculateScale({ width, height, maxScale }));
|
||||
updateScale();
|
||||
window.addEventListener("resize", updateScale);
|
||||
window.addEventListener("orientationchange", updateScale);
|
||||
window.visualViewport?.addEventListener("resize", updateScale);
|
||||
window.visualViewport?.addEventListener("scroll", updateScale);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateScale);
|
||||
window.removeEventListener("orientationchange", updateScale);
|
||||
window.visualViewport?.removeEventListener("resize", updateScale);
|
||||
window.visualViewport?.removeEventListener("scroll", updateScale);
|
||||
};
|
||||
}, [height, maxScale, width]);
|
||||
|
||||
return scale;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
.draggable {
|
||||
@apply touch-none select-none;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
@apply z-10 cursor-grabbing;
|
||||
}
|
||||
|
||||
.droppable {
|
||||
@apply transition-colors;
|
||||
}
|
||||
|
||||
.over {
|
||||
@apply bg-blue-50;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
@apply pointer-events-none opacity-50;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
CollisionDetection,
|
||||
DndContextProps,
|
||||
DragCancelEvent,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
UniqueIdentifier,
|
||||
} from "@dnd-kit/core";
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type DragId = UniqueIdentifier;
|
||||
|
||||
export interface DragOverlayRenderState {
|
||||
activeId: DragId;
|
||||
activeData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type DragDropClassName<State> = string | ((state: State) => string | undefined);
|
||||
|
||||
export interface DragDropProviderProps {
|
||||
children: ReactNode;
|
||||
scale?: number;
|
||||
renderOverlay?: (state: DragOverlayRenderState) => ReactNode;
|
||||
collisionDetection?: CollisionDetection;
|
||||
onDragStart?: (event: DragStartEvent) => void;
|
||||
onDragOver?: (event: DragOverEvent) => void;
|
||||
onDragEnd?: (event: DragEndEvent) => void;
|
||||
onDragCancel?: (event: DragCancelEvent) => void;
|
||||
options?: Omit<
|
||||
DndContextProps,
|
||||
| "children"
|
||||
| "collisionDetection"
|
||||
| "sensors"
|
||||
| "onDragStart"
|
||||
| "onDragOver"
|
||||
| "onDragEnd"
|
||||
| "onDragCancel"
|
||||
>;
|
||||
}
|
||||
|
||||
export interface DraggableState {
|
||||
isDragging: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface DraggableProps
|
||||
extends Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"children" | "className" | "id"
|
||||
> {
|
||||
id: DragId;
|
||||
children: ReactNode | ((state: DraggableState) => ReactNode);
|
||||
disabled?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
className?: DragDropClassName<DraggableState>;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export interface DroppableState {
|
||||
isOver: boolean;
|
||||
disabled: boolean;
|
||||
activeId: DragId | null;
|
||||
canDrop: boolean;
|
||||
}
|
||||
|
||||
export interface DroppableProps
|
||||
extends Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"children" | "className" | "id"
|
||||
> {
|
||||
id: DragId;
|
||||
children: ReactNode | ((state: DroppableState) => ReactNode);
|
||||
disabled?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
activeClassName?: string;
|
||||
canDrop?: (activeId: DragId) => boolean;
|
||||
className?: DragDropClassName<DroppableState>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Transform } from "@dnd-kit/utilities";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface DragDropContextValue {
|
||||
scale: number;
|
||||
inverseScale: number;
|
||||
hasOverlay: boolean;
|
||||
getScaledTransform: (transform: Transform | null) => Transform | null;
|
||||
}
|
||||
|
||||
const DragDropContext = createContext<DragDropContextValue>({
|
||||
scale: 1,
|
||||
inverseScale: 1,
|
||||
hasOverlay: false,
|
||||
getScaledTransform: (transform) => transform,
|
||||
});
|
||||
|
||||
export const DragDropContextProvider = DragDropContext.Provider;
|
||||
|
||||
export function useDragDropContext() {
|
||||
return useContext(DragDropContext);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
DragCancelEvent,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { Transform } from "@dnd-kit/utilities";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Portal } from "@/components/ui/portal";
|
||||
import { pointerWithinWithFallback } from "./collision";
|
||||
import type {
|
||||
DragDropProviderProps,
|
||||
DragOverlayRenderState,
|
||||
} from "./DragDrop.types";
|
||||
import { DragDropContextProvider } from "./DragDropContext";
|
||||
|
||||
export function DragDropProvider({
|
||||
children,
|
||||
scale = 1,
|
||||
renderOverlay,
|
||||
collisionDetection = pointerWithinWithFallback,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
onDragCancel,
|
||||
options,
|
||||
}: DragDropProviderProps) {
|
||||
const safeScale = scale || 1;
|
||||
const inverseScale = 1 / safeScale;
|
||||
const hasOverlay = renderOverlay != null;
|
||||
const [overlayState, setOverlayState] =
|
||||
useState<DragOverlayRenderState | null>(null);
|
||||
const activationDistance = Math.max(2, 6 / safeScale);
|
||||
const touchTolerance = Math.max(4, 8 / safeScale);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: activationDistance,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 120,
|
||||
tolerance: touchTolerance,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
scale: safeScale,
|
||||
inverseScale,
|
||||
hasOverlay,
|
||||
getScaledTransform: (transform: Transform | null) =>
|
||||
transform
|
||||
? {
|
||||
...transform,
|
||||
x: transform.x * inverseScale,
|
||||
y: transform.y * inverseScale,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
[hasOverlay, inverseScale, safeScale],
|
||||
);
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
if (renderOverlay) {
|
||||
setOverlayState({
|
||||
activeId: event.active.id,
|
||||
activeData: event.active.data.current,
|
||||
});
|
||||
}
|
||||
|
||||
onDragStart?.(event);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setOverlayState(null);
|
||||
onDragEnd?.(event);
|
||||
};
|
||||
|
||||
const handleDragCancel = (event: DragCancelEvent) => {
|
||||
setOverlayState(null);
|
||||
onDragCancel?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContextProvider value={contextValue}>
|
||||
<DndContext
|
||||
collisionDetection={collisionDetection}
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
{...options}
|
||||
>
|
||||
{children}
|
||||
{renderOverlay && typeof document !== "undefined" && (
|
||||
<Portal container={document.body}>
|
||||
<DragOverlay adjustScale={false} dropAnimation={null}>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${safeScale})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
{overlayState ? renderOverlay(overlayState) : null}
|
||||
</div>
|
||||
</DragOverlay>
|
||||
</Portal>
|
||||
)}
|
||||
</DndContext>
|
||||
</DragDropContextProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@/lib/cn";
|
||||
import styles from "./DragDrop.module.css";
|
||||
import type { DraggableProps } from "./DragDrop.types";
|
||||
import { useDragDropContext } from "./DragDropContext";
|
||||
|
||||
export function Draggable({
|
||||
id,
|
||||
children,
|
||||
disabled = false,
|
||||
data,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: DraggableProps) {
|
||||
const { getScaledTransform, hasOverlay } = useDragDropContext();
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id,
|
||||
data,
|
||||
disabled,
|
||||
});
|
||||
const scaledTransform = getScaledTransform(transform);
|
||||
const state = {
|
||||
isDragging,
|
||||
disabled,
|
||||
};
|
||||
const resolvedClassName =
|
||||
typeof className === "function" ? className(state) : className;
|
||||
const resolvedChildren =
|
||||
typeof children === "function" ? children(state) : children;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
styles.draggable,
|
||||
isDragging && styles.dragging,
|
||||
disabled && styles.disabled,
|
||||
resolvedClassName,
|
||||
)}
|
||||
style={{
|
||||
...style,
|
||||
transform: hasOverlay
|
||||
? style?.transform
|
||||
: CSS.Translate.toString(scaledTransform),
|
||||
}}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
{...props}
|
||||
>
|
||||
{resolvedChildren}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useDndContext, useDroppable } from "@dnd-kit/core";
|
||||
import { cn } from "@/lib/cn";
|
||||
import styles from "./DragDrop.module.css";
|
||||
import type { DroppableProps, DroppableState } from "./DragDrop.types";
|
||||
|
||||
export function Droppable({
|
||||
id,
|
||||
children,
|
||||
disabled = false,
|
||||
data,
|
||||
className,
|
||||
activeClassName,
|
||||
canDrop,
|
||||
...props
|
||||
}: DroppableProps) {
|
||||
const { active } = useDndContext();
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id,
|
||||
data,
|
||||
disabled,
|
||||
});
|
||||
const activeId = active?.id ?? null;
|
||||
const state: DroppableState = {
|
||||
isOver,
|
||||
disabled,
|
||||
activeId,
|
||||
canDrop: activeId != null ? (canDrop?.(activeId) ?? true) : false,
|
||||
};
|
||||
const resolvedClassName =
|
||||
typeof className === "function" ? className(state) : className;
|
||||
const resolvedChildren =
|
||||
typeof children === "function" ? children(state) : children;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
styles.droppable,
|
||||
isOver && className == null && !activeClassName && styles.over,
|
||||
isOver && activeClassName,
|
||||
disabled && styles.disabled,
|
||||
resolvedClassName,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{resolvedChildren}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
type CollisionDetection,
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
} from "@dnd-kit/core";
|
||||
|
||||
export const pointerWithinWithFallback: CollisionDetection = (args) => {
|
||||
const pointerCollisions = pointerWithin(args);
|
||||
|
||||
if (args.pointerCoordinates) {
|
||||
return pointerCollisions;
|
||||
}
|
||||
|
||||
return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export { pointerWithinWithFallback } from "./collision";
|
||||
export type {
|
||||
DragDropProviderProps,
|
||||
DraggableProps,
|
||||
DraggableState,
|
||||
DragId,
|
||||
DragOverlayRenderState,
|
||||
DroppableProps,
|
||||
DroppableState,
|
||||
} from "./DragDrop.types";
|
||||
export { DragDropProvider } from "./DragDropProvider";
|
||||
export { Draggable } from "./Draggable";
|
||||
export { Droppable } from "./Droppable";
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import styles from "./GuideDialog.module.css";
|
||||
import type { GuideDialogRenderState } from "./GuideDialog.types";
|
||||
|
||||
export interface DefaultGuideDialogPanelProps {
|
||||
dialog: GuideDialogRenderState;
|
||||
}
|
||||
|
||||
export function DefaultGuideDialogPanel({
|
||||
dialog,
|
||||
}: DefaultGuideDialogPanelProps) {
|
||||
const { character, options, progress, status, step, actions, labels } =
|
||||
dialog;
|
||||
|
||||
return (
|
||||
<>
|
||||
{character && (
|
||||
<div className={styles.character}>
|
||||
<div className={styles.avatar}>{character.avatar ?? "🙂"}</div>
|
||||
<div>
|
||||
{character.name && (
|
||||
<div className={styles.name}>{character.name}</div>
|
||||
)}
|
||||
{character.mood && (
|
||||
<div className="text-xs text-gray-400">{character.mood}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.title && <h2 className={styles.title}>{step.title}</h2>}
|
||||
{step.description && (
|
||||
<div className={styles.description}>{step.description}</div>
|
||||
)}
|
||||
|
||||
{options.length > 0 && (
|
||||
<div className={styles.options}>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
variant="outline"
|
||||
onClick={() => actions.selectOption(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.counter}>
|
||||
{progress.current} / {progress.total}
|
||||
</span>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="ghost" size="sm" onClick={actions.skip}>
|
||||
{labels.skip}
|
||||
</Button>
|
||||
{!status.isFirst && (
|
||||
<Button variant="secondary" size="sm" onClick={actions.prev}>
|
||||
{labels.prev}
|
||||
</Button>
|
||||
)}
|
||||
{!status.hasOptions && (
|
||||
<Button size="sm" onClick={actions.primary}>
|
||||
{labels.primary}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
.mask {
|
||||
@apply pointer-events-auto absolute inset-0 block h-full w-full border-0 p-0;
|
||||
animation: dialog-fade-in 220ms ease-out both;
|
||||
}
|
||||
|
||||
.panelContainer {
|
||||
@apply pointer-events-auto absolute;
|
||||
animation: dialog-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transition:
|
||||
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 180ms ease;
|
||||
will-change: left, top, transform, opacity;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply pointer-events-auto absolute w-[520px] rounded-3xl bg-white p-6 text-gray-900 shadow-2xl;
|
||||
animation: dialog-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transition:
|
||||
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 180ms ease;
|
||||
will-change: left, top, transform, opacity;
|
||||
}
|
||||
|
||||
.measurePanel {
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
visibility: hidden;
|
||||
animation: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.character {
|
||||
@apply mb-4 flex items-center gap-3;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@apply flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 text-3xl;
|
||||
}
|
||||
|
||||
.name {
|
||||
@apply text-sm font-semibold text-blue-600;
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
|
||||
.description {
|
||||
@apply mt-3 text-base leading-7 text-gray-600;
|
||||
}
|
||||
|
||||
.options {
|
||||
@apply mt-5 grid gap-2;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@apply mt-6 flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.counter {
|
||||
@apply text-xs text-gray-400;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
@keyframes dialog-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 12px, 0) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mask,
|
||||
.panel,
|
||||
.panelContainer {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Portal } from "@/components/ui/portal";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { DefaultGuideDialogPanel } from "./DefaultGuideDialogPanel";
|
||||
import styles from "./GuideDialog.module.css";
|
||||
import type { GuideDialogLayout, GuideDialogProps } from "./GuideDialog.types";
|
||||
|
||||
const DEFAULT_PANEL_MAX_WIDTH = 560;
|
||||
const VIEWPORT_MARGIN = 32;
|
||||
const BOTTOM_OFFSET = 72;
|
||||
const TOP_OFFSET = 72;
|
||||
|
||||
interface CoordinateContext {
|
||||
portalTarget: HTMLElement;
|
||||
scale: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function resolveContainer(container?: GuideDialogProps["container"]) {
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof container === "function") {
|
||||
return container();
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function getCoordinateContext({
|
||||
container,
|
||||
viewport,
|
||||
}: Pick<GuideDialogProps, "container" | "viewport">): CoordinateContext {
|
||||
const portalTarget = resolveContainer(container) ?? document.body;
|
||||
|
||||
if (viewport) {
|
||||
return {
|
||||
portalTarget,
|
||||
scale: viewport.scale ?? 1,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
};
|
||||
}
|
||||
|
||||
const rect = portalTarget.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
portalTarget,
|
||||
scale: 1,
|
||||
width: rect.width || window.innerWidth,
|
||||
height: rect.height || window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function getPanelPosition(
|
||||
placement: "top" | "center" | "bottom",
|
||||
panel: { width: number; height: number },
|
||||
context: CoordinateContext,
|
||||
) {
|
||||
const left = context.width / 2 - panel.width / 2;
|
||||
const topMap = {
|
||||
top: TOP_OFFSET,
|
||||
center: context.height / 2 - panel.height / 2,
|
||||
bottom: context.height - panel.height - BOTTOM_OFFSET,
|
||||
};
|
||||
const maxLeft = Math.max(
|
||||
context.width - panel.width - VIEWPORT_MARGIN,
|
||||
VIEWPORT_MARGIN,
|
||||
);
|
||||
const maxTop = Math.max(
|
||||
context.height - panel.height - VIEWPORT_MARGIN,
|
||||
VIEWPORT_MARGIN,
|
||||
);
|
||||
|
||||
return {
|
||||
left: clamp(left, VIEWPORT_MARGIN, maxLeft),
|
||||
top: clamp(topMap[placement], VIEWPORT_MARGIN, maxTop),
|
||||
};
|
||||
}
|
||||
|
||||
export function GuideDialog({
|
||||
state,
|
||||
actions,
|
||||
children,
|
||||
container,
|
||||
viewport,
|
||||
theme,
|
||||
closeOnOverlayClick = false,
|
||||
closeOnEsc = true,
|
||||
onStepChange,
|
||||
}: GuideDialogProps) {
|
||||
const { open, steps, current } = state;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [layout, setLayout] = useState<GuideDialogLayout | null>(null);
|
||||
|
||||
const step = steps[current];
|
||||
const stepKey = step ? `${current}:${step.id}` : "";
|
||||
const zIndex = theme?.zIndex ?? 1000;
|
||||
const panelMaxWidth = theme?.panelMaxWidth ?? DEFAULT_PANEL_MAX_WIDTH;
|
||||
const isLayoutReady = !!layout && layout.stepKey === stepKey;
|
||||
const activeLayout = isLayoutReady ? layout : null;
|
||||
|
||||
const closeAsFinish = useCallback(() => {
|
||||
actions.finish();
|
||||
}, [actions]);
|
||||
|
||||
const closeAsSkip = useCallback(() => {
|
||||
actions.skip();
|
||||
}, [actions]);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
if (!open || !step || typeof document === "undefined") {
|
||||
setLayout(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const context = getCoordinateContext({ container, viewport });
|
||||
const panelElement = panelRef.current;
|
||||
const panelRect = panelElement?.getBoundingClientRect();
|
||||
const panelSize = {
|
||||
width: panelElement
|
||||
? Math.max(
|
||||
panelRect?.width ?? 0,
|
||||
panelElement.scrollWidth * context.scale,
|
||||
) / context.scale
|
||||
: panelMaxWidth,
|
||||
height: panelElement
|
||||
? Math.max(
|
||||
panelRect?.height ?? 0,
|
||||
panelElement.scrollHeight * context.scale,
|
||||
) / context.scale
|
||||
: 220,
|
||||
};
|
||||
const position = getPanelPosition(
|
||||
step.placement ?? "bottom",
|
||||
panelSize,
|
||||
context,
|
||||
);
|
||||
|
||||
setLayout({
|
||||
stepKey,
|
||||
portalTarget: context.portalTarget,
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
panelStyle: {
|
||||
left: `${position.left}px`,
|
||||
top: `${position.top}px`,
|
||||
maxWidth: `${panelMaxWidth}px`,
|
||||
},
|
||||
});
|
||||
}, [container, open, panelMaxWidth, step, stepKey, viewport]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
}, [measure]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !step) return;
|
||||
onStepChange?.(current, step);
|
||||
}, [current, open, onStepChange, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleResize = () => measure();
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (closeOnEsc && event.key === "Escape") {
|
||||
closeAsSkip();
|
||||
}
|
||||
};
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(() => measure());
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
if (panelRef.current) resizeObserver?.observe(panelRef.current);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [closeAsSkip, closeOnEsc, measure, open]);
|
||||
|
||||
if (!open || !step || typeof document === "undefined") return null;
|
||||
|
||||
const isFirst = current === 0;
|
||||
const isLast = current === steps.length - 1;
|
||||
const options = step.options ?? [];
|
||||
const hasOptions = options.length > 0;
|
||||
const primaryAction = isLast ? closeAsFinish : actions.next;
|
||||
const dialog = {
|
||||
step,
|
||||
character: step.character,
|
||||
options,
|
||||
progress: {
|
||||
index: current,
|
||||
current: current + 1,
|
||||
total: steps.length,
|
||||
},
|
||||
status: {
|
||||
isFirst,
|
||||
isLast,
|
||||
hasOptions,
|
||||
},
|
||||
actions: {
|
||||
prev: actions.prev,
|
||||
next: actions.next,
|
||||
primary: primaryAction,
|
||||
skip: closeAsSkip,
|
||||
finish: closeAsFinish,
|
||||
selectOption: actions.selectOption,
|
||||
},
|
||||
labels: {
|
||||
prev: step.prevLabel ?? "上一步",
|
||||
next: step.nextLabel ?? "继续",
|
||||
skip: step.skipLabel ?? "跳过",
|
||||
done: step.doneLabel ?? "完成",
|
||||
primary: isLast ? (step.doneLabel ?? "完成") : (step.nextLabel ?? "继续"),
|
||||
},
|
||||
};
|
||||
const panelContent = children ? (
|
||||
children(dialog)
|
||||
) : (
|
||||
<DefaultGuideDialogPanel dialog={dialog} />
|
||||
);
|
||||
const panelClassName = children ? styles.panelContainer : styles.panel;
|
||||
|
||||
if (!activeLayout) {
|
||||
return (
|
||||
<Portal
|
||||
container={getCoordinateContext({ container, viewport }).portalTarget}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
panelClassName,
|
||||
styles.measurePanel,
|
||||
theme?.panelClassName,
|
||||
)}
|
||||
style={{
|
||||
maxWidth: `${panelMaxWidth}px`,
|
||||
}}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const overlayClick = (event: MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
if (closeOnOverlayClick) {
|
||||
closeAsSkip();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal container={activeLayout.portalTarget}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(styles.mask, theme?.overlayClassName)}
|
||||
onClick={overlayClick}
|
||||
style={{
|
||||
zIndex,
|
||||
background: theme?.overlayColor ?? "rgb(15 23 42 / 0.58)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
key={`dialog-${stepKey}`}
|
||||
ref={panelRef}
|
||||
aria-modal="true"
|
||||
className={cn(panelClassName, theme?.panelClassName)}
|
||||
role="dialog"
|
||||
style={{
|
||||
zIndex: zIndex + 1,
|
||||
...activeLayout.panelStyle,
|
||||
}}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
export type GuideDialogPlacement = "top" | "center" | "bottom";
|
||||
|
||||
export interface GuideDialogCharacter {
|
||||
name?: ReactNode;
|
||||
avatar?: ReactNode;
|
||||
mood?: string;
|
||||
}
|
||||
|
||||
export interface GuideDialogOption {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
nextId?: string;
|
||||
finish?: boolean;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface GuideDialogStep {
|
||||
id: string;
|
||||
character?: GuideDialogCharacter;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
placement?: GuideDialogPlacement;
|
||||
nextLabel?: string;
|
||||
prevLabel?: string;
|
||||
skipLabel?: string;
|
||||
doneLabel?: string;
|
||||
options?: GuideDialogOption[];
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface GuideDialogTheme {
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
zIndex?: number;
|
||||
panelMaxWidth?: number;
|
||||
overlayColor?: string;
|
||||
}
|
||||
|
||||
export interface GuideDialogState {
|
||||
open: boolean;
|
||||
steps: GuideDialogStep[];
|
||||
current: number;
|
||||
}
|
||||
|
||||
export interface GuideDialogActions {
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
skip: () => void;
|
||||
finish: () => void;
|
||||
selectOption: (optionId: string) => void;
|
||||
}
|
||||
|
||||
export interface GuideDialogRenderState {
|
||||
step: GuideDialogStep;
|
||||
character?: GuideDialogCharacter;
|
||||
options: GuideDialogOption[];
|
||||
progress: {
|
||||
index: number;
|
||||
current: number;
|
||||
total: number;
|
||||
};
|
||||
status: {
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
hasOptions: boolean;
|
||||
};
|
||||
actions: {
|
||||
prev: () => void;
|
||||
next: () => void;
|
||||
primary: () => void;
|
||||
skip: () => void;
|
||||
finish: () => void;
|
||||
selectOption: (optionId: string) => void;
|
||||
};
|
||||
labels: {
|
||||
prev: string;
|
||||
next: string;
|
||||
skip: string;
|
||||
done: string;
|
||||
primary: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuideDialogProps {
|
||||
state: GuideDialogState;
|
||||
actions: GuideDialogActions;
|
||||
children?: (dialog: GuideDialogRenderState) => ReactNode;
|
||||
container?: HTMLElement | null | (() => HTMLElement | null);
|
||||
theme?: GuideDialogTheme;
|
||||
viewport?: {
|
||||
width: number;
|
||||
height: number;
|
||||
scale?: number;
|
||||
};
|
||||
closeOnOverlayClick?: boolean;
|
||||
closeOnEsc?: boolean;
|
||||
onStepChange?: (index: number, step: GuideDialogStep) => void;
|
||||
}
|
||||
|
||||
export interface GuideDialogLayout {
|
||||
stepKey: string;
|
||||
portalTarget: HTMLElement;
|
||||
width: number;
|
||||
height: number;
|
||||
panelStyle: CSSProperties;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type { DefaultGuideDialogPanelProps } from "./DefaultGuideDialogPanel";
|
||||
export { DefaultGuideDialogPanel } from "./DefaultGuideDialogPanel";
|
||||
export { GuideDialog } from "./GuideDialog";
|
||||
export type {
|
||||
GuideDialogActions,
|
||||
GuideDialogCharacter,
|
||||
GuideDialogLayout,
|
||||
GuideDialogOption,
|
||||
GuideDialogPlacement,
|
||||
GuideDialogProps,
|
||||
GuideDialogRenderState,
|
||||
GuideDialogState,
|
||||
GuideDialogStep,
|
||||
GuideDialogTheme,
|
||||
} from "./GuideDialog.types";
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import styles from "./GuideSpotlight.module.css";
|
||||
import type { GuideSpotlightRenderState } from "./GuideSpotlight.types";
|
||||
|
||||
export interface DefaultGuideSpotlightPanelProps {
|
||||
spotlight: GuideSpotlightRenderState;
|
||||
}
|
||||
|
||||
export function DefaultGuideSpotlightPanel({
|
||||
spotlight,
|
||||
}: DefaultGuideSpotlightPanelProps) {
|
||||
const { step, progress, status, actions, labels } = spotlight;
|
||||
|
||||
return (
|
||||
<>
|
||||
{step.title && <h2 className={styles.title}>{step.title}</h2>}
|
||||
{step.description && (
|
||||
<div className={styles.description}>{step.description}</div>
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.counter}>
|
||||
{progress.current} / {progress.total}
|
||||
</span>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="ghost" size="sm" onClick={actions.skip}>
|
||||
{labels.skip}
|
||||
</Button>
|
||||
{!status.isFirst && (
|
||||
<Button variant="secondary" size="sm" onClick={actions.prev}>
|
||||
{labels.prev}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={actions.primary}>
|
||||
{labels.primary}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
.mask {
|
||||
@apply pointer-events-auto absolute inset-0 block h-full w-full border-0 p-0;
|
||||
animation: spotlight-fade-in 220ms ease-out both;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
@apply absolute border-2 border-white shadow-[0_0_0_1px_rgba(255,255,255,0.35),0_0_28px_rgba(255,255,255,0.5),0_0_56px_rgba(59,130,246,0.22)];
|
||||
pointer-events: none;
|
||||
animation: spotlight-highlight-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both,
|
||||
spotlight-pulse 2400ms ease-in-out 360ms infinite;
|
||||
transition:
|
||||
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
width 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
height 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-radius 260ms ease;
|
||||
will-change: top, left, width, height, transform, opacity;
|
||||
}
|
||||
|
||||
.blocker {
|
||||
@apply pointer-events-auto absolute bg-transparent;
|
||||
}
|
||||
|
||||
.panelContainer {
|
||||
@apply pointer-events-auto absolute;
|
||||
animation: spotlight-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transition:
|
||||
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 180ms ease;
|
||||
will-change: left, top, transform, opacity;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply pointer-events-auto absolute w-80 rounded-xl bg-white p-5 text-gray-900 shadow-2xl;
|
||||
animation: spotlight-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transition:
|
||||
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 180ms ease;
|
||||
will-change: left, top, transform, opacity;
|
||||
}
|
||||
|
||||
.measurePanel {
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
visibility: hidden;
|
||||
animation: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.missingPanel {
|
||||
@apply pointer-events-auto absolute left-1/2 top-1/2 w-96 max-w-[calc(100%-32px)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-5 text-gray-900 shadow-2xl;
|
||||
animation: spotlight-missing-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.missingBadge {
|
||||
@apply mb-3 inline-flex rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700;
|
||||
}
|
||||
|
||||
.missingSelector {
|
||||
@apply mt-3 block rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-700;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply text-lg font-semibold;
|
||||
}
|
||||
|
||||
.description {
|
||||
@apply mt-2 text-sm leading-6 text-gray-600;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@apply mt-5 flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.counter {
|
||||
@apply text-xs text-gray-400;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
@keyframes spotlight-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spotlight-highlight-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spotlight-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(255 255 255 / 35%),
|
||||
0 0 28px rgb(255 255 255 / 50%),
|
||||
0 0 56px rgb(59 130 246 / 22%);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(255 255 255 / 48%),
|
||||
0 0 36px rgb(255 255 255 / 68%),
|
||||
0 0 72px rgb(59 130 246 / 32%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spotlight-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 10px, 0) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spotlight-missing-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(-50%, calc(-50% + 10px), 0) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(-50%, -50%, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mask,
|
||||
.highlight,
|
||||
.panelContainer,
|
||||
.panel,
|
||||
.missingPanel {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
import {
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Portal } from "@/components/ui/portal";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { DefaultGuideSpotlightPanel } from "./DefaultGuideSpotlightPanel";
|
||||
import styles from "./GuideSpotlight.module.css";
|
||||
import type {
|
||||
GuideSpotlightProps,
|
||||
Rect,
|
||||
SpotlightLayout,
|
||||
SpotlightStep,
|
||||
} from "./GuideSpotlight.types";
|
||||
|
||||
const DEFAULT_OFFSET = 16;
|
||||
const DEFAULT_PADDING = 8;
|
||||
const DEFAULT_RADIUS = 12;
|
||||
const VIEWPORT_MARGIN = 16;
|
||||
|
||||
interface CoordinateContext {
|
||||
portalTarget: HTMLElement;
|
||||
originLeft: number;
|
||||
originTop: number;
|
||||
scale: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function resolveContainer(container?: GuideSpotlightProps["container"]) {
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof container === "function") {
|
||||
return container();
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function getCoordinateContext({
|
||||
container,
|
||||
viewport,
|
||||
}: Pick<GuideSpotlightProps, "container" | "viewport">): CoordinateContext {
|
||||
const portalTarget = resolveContainer(container) ?? document.body;
|
||||
|
||||
if (viewport) {
|
||||
return {
|
||||
portalTarget,
|
||||
originLeft: viewport.originLeft ?? 0,
|
||||
originTop: viewport.originTop ?? 0,
|
||||
scale: viewport.scale ?? 1,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
};
|
||||
}
|
||||
|
||||
const rect = portalTarget.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
portalTarget,
|
||||
originLeft: rect.left,
|
||||
originTop: rect.top,
|
||||
scale: 1,
|
||||
width: rect.width || window.innerWidth,
|
||||
height: rect.height || window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function toCanvasRect(rect: DOMRect, context: CoordinateContext): Rect {
|
||||
const top = (rect.top - context.originTop) / context.scale;
|
||||
const left = (rect.left - context.originLeft) / context.scale;
|
||||
const width = rect.width / context.scale;
|
||||
const height = rect.height / context.scale;
|
||||
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
};
|
||||
}
|
||||
|
||||
function getTargetSelector(step: SpotlightStep) {
|
||||
if (typeof step.target === "string") {
|
||||
return step.target;
|
||||
}
|
||||
|
||||
if (step.target.dataGuide) {
|
||||
return `[data-guide="${step.target.dataGuide}"]`;
|
||||
}
|
||||
|
||||
return step.target.selector ?? "";
|
||||
}
|
||||
|
||||
function getTargetElement(step: SpotlightStep) {
|
||||
const selector = getTargetSelector(step);
|
||||
if (!selector) return null;
|
||||
return document.querySelector<HTMLElement>(selector);
|
||||
}
|
||||
|
||||
function getHighlightRect(
|
||||
element: HTMLElement,
|
||||
padding: number,
|
||||
context: CoordinateContext,
|
||||
): Rect {
|
||||
const rect = toCanvasRect(element.getBoundingClientRect(), context);
|
||||
const top = Math.floor(Math.max(rect.top - padding, 0));
|
||||
const left = Math.floor(Math.max(rect.left - padding, 0));
|
||||
const right = Math.ceil(Math.min(rect.right + padding, context.width));
|
||||
const bottom = Math.ceil(Math.min(rect.bottom + padding, context.height));
|
||||
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
right,
|
||||
bottom,
|
||||
};
|
||||
}
|
||||
|
||||
function getOverflowScore(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
context: CoordinateContext,
|
||||
) {
|
||||
const overflowLeft = Math.max(VIEWPORT_MARGIN - left, 0);
|
||||
const overflowTop = Math.max(VIEWPORT_MARGIN - top, 0);
|
||||
const overflowRight = Math.max(
|
||||
left + width + VIEWPORT_MARGIN - context.width,
|
||||
0,
|
||||
);
|
||||
const overflowBottom = Math.max(
|
||||
top + height + VIEWPORT_MARGIN - context.height,
|
||||
0,
|
||||
);
|
||||
|
||||
return overflowLeft + overflowTop + overflowRight + overflowBottom;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function getRoundedRectPath(rect: Rect, radius: number) {
|
||||
const right = rect.left + rect.width;
|
||||
const bottom = rect.top + rect.height;
|
||||
const safeRadius = Math.min(radius, rect.width / 2, rect.height / 2);
|
||||
|
||||
if (safeRadius <= 0) {
|
||||
return `M ${rect.left} ${rect.top} H ${right} V ${bottom} H ${rect.left} Z`;
|
||||
}
|
||||
|
||||
return [
|
||||
`M ${rect.left + safeRadius} ${rect.top}`,
|
||||
`H ${right - safeRadius}`,
|
||||
`Q ${right} ${rect.top} ${right} ${rect.top + safeRadius}`,
|
||||
`V ${bottom - safeRadius}`,
|
||||
`Q ${right} ${bottom} ${right - safeRadius} ${bottom}`,
|
||||
`H ${rect.left + safeRadius}`,
|
||||
`Q ${rect.left} ${bottom} ${rect.left} ${bottom - safeRadius}`,
|
||||
`V ${rect.top + safeRadius}`,
|
||||
`Q ${rect.left} ${rect.top} ${rect.left + safeRadius} ${rect.top}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function getPanelPosition(
|
||||
target: Rect,
|
||||
panel: { width: number; height: number },
|
||||
placement: SpotlightStep["placement"],
|
||||
offset: number,
|
||||
context: CoordinateContext,
|
||||
) {
|
||||
const positions = {
|
||||
top: {
|
||||
left: target.left + target.width / 2 - panel.width / 2,
|
||||
top: target.top - panel.height - offset,
|
||||
},
|
||||
bottom: {
|
||||
left: target.left + target.width / 2 - panel.width / 2,
|
||||
top: target.bottom + offset,
|
||||
},
|
||||
left: {
|
||||
left: target.left - panel.width - offset,
|
||||
top: target.top + target.height / 2 - panel.height / 2,
|
||||
},
|
||||
right: {
|
||||
left: target.right + offset,
|
||||
top: target.top + target.height / 2 - panel.height / 2,
|
||||
},
|
||||
};
|
||||
|
||||
const preferred = placement && placement !== "auto" ? placement : "bottom";
|
||||
const ordered = [
|
||||
preferred,
|
||||
...(["bottom", "top", "right", "left"] as const).filter(
|
||||
(item) => item !== preferred,
|
||||
),
|
||||
];
|
||||
|
||||
const best = ordered
|
||||
.map((item) => ({
|
||||
placement: item,
|
||||
...positions[item],
|
||||
score: getOverflowScore(
|
||||
positions[item].left,
|
||||
positions[item].top,
|
||||
panel.width,
|
||||
panel.height,
|
||||
context,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.score - b.score)[0];
|
||||
|
||||
const maxLeft = Math.max(
|
||||
context.width - panel.width - VIEWPORT_MARGIN,
|
||||
VIEWPORT_MARGIN,
|
||||
);
|
||||
const maxTop = Math.max(
|
||||
context.height - panel.height - VIEWPORT_MARGIN,
|
||||
VIEWPORT_MARGIN,
|
||||
);
|
||||
|
||||
return {
|
||||
left: clamp(best.left, VIEWPORT_MARGIN, maxLeft),
|
||||
top: clamp(best.top, VIEWPORT_MARGIN, maxTop),
|
||||
};
|
||||
}
|
||||
|
||||
export function GuideSpotlight({
|
||||
state,
|
||||
actions,
|
||||
children,
|
||||
container,
|
||||
viewport,
|
||||
theme,
|
||||
closeOnOverlayClick = false,
|
||||
closeOnEsc = true,
|
||||
skipIfTargetMissing = true,
|
||||
onStepChange,
|
||||
}: GuideSpotlightProps) {
|
||||
const { open, steps, current } = state;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [layout, setLayout] = useState<SpotlightLayout | null>(null);
|
||||
|
||||
const step = steps[current];
|
||||
const stepKey = step ? `${current}:${step.id}` : "";
|
||||
const offset = theme?.offset ?? DEFAULT_OFFSET;
|
||||
const radius = step?.radius ?? theme?.radius ?? DEFAULT_RADIUS;
|
||||
const padding = step?.padding ?? DEFAULT_PADDING;
|
||||
const zIndex = theme?.zIndex ?? 1000;
|
||||
const panelMaxWidth = theme?.panelMaxWidth ?? 320;
|
||||
const isLayoutReady = !!layout && layout.stepKey === stepKey;
|
||||
const highlightRect = isLayoutReady ? layout.highlightRect : null;
|
||||
const targetMissingSelector = isLayoutReady
|
||||
? layout.targetMissingSelector
|
||||
: null;
|
||||
|
||||
const closeAsFinish = useCallback(() => {
|
||||
actions.finish();
|
||||
}, [actions]);
|
||||
|
||||
const closeAsSkip = useCallback(() => {
|
||||
actions.skip();
|
||||
}, [actions]);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
if (!open || !step || typeof document === "undefined") {
|
||||
setLayout(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selector = getTargetSelector(step);
|
||||
const context = getCoordinateContext({ container, viewport });
|
||||
const target = getTargetElement(step);
|
||||
if (!target) {
|
||||
console.warn(`[GuideSpotlight] target not found: ${selector}`);
|
||||
|
||||
if (skipIfTargetMissing) {
|
||||
if (current >= steps.length - 1) {
|
||||
closeAsFinish();
|
||||
} else {
|
||||
actions.next();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLayout({
|
||||
stepKey,
|
||||
portalTarget: context.portalTarget,
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
highlightRect: null,
|
||||
panelStyle: {
|
||||
maxWidth: `${panelMaxWidth}px`,
|
||||
},
|
||||
targetMissingSelector: selector,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHighlightRect = getHighlightRect(target, padding, context);
|
||||
const panelElement = panelRef.current;
|
||||
const panelRect = panelElement?.getBoundingClientRect();
|
||||
const panelSize = {
|
||||
width: panelElement
|
||||
? Math.max(
|
||||
panelRect?.width ?? 0,
|
||||
panelElement.scrollWidth * context.scale,
|
||||
) / context.scale
|
||||
: panelMaxWidth,
|
||||
height: panelElement
|
||||
? Math.max(
|
||||
panelRect?.height ?? 0,
|
||||
panelElement.scrollHeight * context.scale,
|
||||
) / context.scale
|
||||
: 180,
|
||||
};
|
||||
const position = getPanelPosition(
|
||||
nextHighlightRect,
|
||||
panelSize,
|
||||
step.placement ?? "auto",
|
||||
offset,
|
||||
context,
|
||||
);
|
||||
|
||||
setLayout({
|
||||
stepKey,
|
||||
portalTarget: context.portalTarget,
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
highlightRect: nextHighlightRect,
|
||||
panelStyle: {
|
||||
left: `${position.left}px`,
|
||||
top: `${position.top}px`,
|
||||
maxWidth: `${panelMaxWidth}px`,
|
||||
},
|
||||
targetMissingSelector: null,
|
||||
});
|
||||
}, [
|
||||
actions,
|
||||
closeAsFinish,
|
||||
container,
|
||||
current,
|
||||
offset,
|
||||
open,
|
||||
padding,
|
||||
panelMaxWidth,
|
||||
skipIfTargetMissing,
|
||||
step,
|
||||
stepKey,
|
||||
steps.length,
|
||||
viewport,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
}, [measure]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !step) return;
|
||||
onStepChange?.(current, step);
|
||||
}, [current, open, onStepChange, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const scrollKeys = new Set([
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"End",
|
||||
"Home",
|
||||
"PageDown",
|
||||
"PageUp",
|
||||
" ",
|
||||
]);
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const previousOverscrollBehavior = document.body.style.overscrollBehavior;
|
||||
const preventScroll = (event: Event) => event.preventDefault();
|
||||
const preventKeyboardScroll = (event: KeyboardEvent) => {
|
||||
if (scrollKeys.has(event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
document.body.style.overscrollBehavior = "none";
|
||||
window.addEventListener("wheel", preventScroll, {
|
||||
capture: true,
|
||||
passive: false,
|
||||
});
|
||||
window.addEventListener("touchmove", preventScroll, {
|
||||
capture: true,
|
||||
passive: false,
|
||||
});
|
||||
window.addEventListener("keydown", preventKeyboardScroll, true);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.body.style.overscrollBehavior = previousOverscrollBehavior;
|
||||
window.removeEventListener("wheel", preventScroll, true);
|
||||
window.removeEventListener("touchmove", preventScroll, true);
|
||||
window.removeEventListener("keydown", preventKeyboardScroll, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleResize = () => measure();
|
||||
const handleScroll = () => measure();
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (closeOnEsc && event.key === "Escape") {
|
||||
closeAsSkip();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
const target = step ? getTargetElement(step) : null;
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(() => measure());
|
||||
if (target) resizeObserver?.observe(target);
|
||||
if (panelRef.current) resizeObserver?.observe(panelRef.current);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [closeAsSkip, closeOnEsc, measure, open, step]);
|
||||
|
||||
if (!open || !step || typeof document === "undefined") return null;
|
||||
|
||||
const maskStyle = {
|
||||
zIndex,
|
||||
};
|
||||
const isFirst = current === 0;
|
||||
const isLast = current === steps.length - 1;
|
||||
|
||||
const overlayClick = (event: MouseEvent<SVGPathElement>) => {
|
||||
event.stopPropagation();
|
||||
if (closeOnOverlayClick) {
|
||||
closeAsSkip();
|
||||
}
|
||||
};
|
||||
|
||||
const primaryAction = isLast ? closeAsFinish : actions.next;
|
||||
const spotlight = {
|
||||
step,
|
||||
progress: {
|
||||
index: current,
|
||||
current: current + 1,
|
||||
total: steps.length,
|
||||
},
|
||||
status: {
|
||||
isFirst,
|
||||
isLast,
|
||||
},
|
||||
actions: {
|
||||
prev: actions.prev,
|
||||
next: actions.next,
|
||||
primary: primaryAction,
|
||||
skip: closeAsSkip,
|
||||
finish: closeAsFinish,
|
||||
},
|
||||
labels: {
|
||||
prev: step.prevLabel ?? "上一步",
|
||||
next: step.nextLabel ?? "下一步",
|
||||
skip: step.skipLabel ?? "跳过",
|
||||
done: step.doneLabel ?? "完成",
|
||||
primary: isLast
|
||||
? (step.doneLabel ?? "完成")
|
||||
: (step.nextLabel ?? "下一步"),
|
||||
},
|
||||
highlight: {
|
||||
rect: highlightRect,
|
||||
targetMissingSelector,
|
||||
},
|
||||
};
|
||||
const panelContent = children ? (
|
||||
children(spotlight)
|
||||
) : (
|
||||
<DefaultGuideSpotlightPanel spotlight={spotlight} />
|
||||
);
|
||||
|
||||
const panelClassName = children ? styles.panelContainer : styles.panel;
|
||||
|
||||
if (!highlightRect && !targetMissingSelector) {
|
||||
return (
|
||||
<Portal
|
||||
container={getCoordinateContext({ container, viewport }).portalTarget}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
panelClassName,
|
||||
styles.measurePanel,
|
||||
theme?.panelClassName,
|
||||
step.panelClassName,
|
||||
)}
|
||||
style={{
|
||||
maxWidth: `${panelMaxWidth}px`,
|
||||
}}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const activeLayout = layout;
|
||||
if (!activeLayout) return null;
|
||||
|
||||
if (targetMissingSelector) {
|
||||
return (
|
||||
<Portal container={activeLayout.portalTarget}>
|
||||
<div
|
||||
aria-modal="true"
|
||||
className={styles.missingPanel}
|
||||
role="dialog"
|
||||
style={{ zIndex: zIndex + 3 }}
|
||||
>
|
||||
<div className={styles.missingBadge}>DEV</div>
|
||||
<h2 className={styles.title}>引导目标未找到</h2>
|
||||
<p className={styles.description}>
|
||||
请检查当前步骤的 data-guide 或 selector 是否和页面元素一致。
|
||||
</p>
|
||||
<code className={styles.missingSelector}>
|
||||
{targetMissingSelector}
|
||||
</code>
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.counter}>
|
||||
{current + 1} / {steps.length}
|
||||
</span>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="ghost" size="sm" onClick={closeAsSkip}>
|
||||
跳过
|
||||
</Button>
|
||||
<Button size="sm" onClick={primaryAction}>
|
||||
{isLast ? "完成" : "下一步"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
if (!highlightRect) return null;
|
||||
|
||||
const activeRect = highlightRect;
|
||||
|
||||
return (
|
||||
<Portal container={activeLayout.portalTarget}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn(styles.mask, theme?.overlayClassName)}
|
||||
style={maskStyle}
|
||||
viewBox={`0 0 ${activeLayout.width} ${activeLayout.height}`}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: click area must follow the SVG cutout path so the target hole stays interactive. */}
|
||||
<path
|
||||
d={`M 0 0 H ${activeLayout.width} V ${activeLayout.height} H 0 Z ${getRoundedRectPath(activeRect, radius)}`}
|
||||
fill={theme?.overlayColor ?? "rgb(0 0 0 / 0.6)"}
|
||||
fillRule="evenodd"
|
||||
onClick={overlayClick}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{!step.allowTargetClick && (
|
||||
<div
|
||||
className={styles.blocker}
|
||||
style={{
|
||||
zIndex: zIndex + 1,
|
||||
top: `${activeRect.top}px`,
|
||||
left: `${activeRect.left}px`,
|
||||
width: `${activeRect.width}px`,
|
||||
height: `${activeRect.height}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
key={`highlight-${stepKey}`}
|
||||
className={cn(styles.highlight, theme?.highlightClassName)}
|
||||
style={{
|
||||
zIndex: zIndex + 2,
|
||||
top: `${activeRect.top}px`,
|
||||
left: `${activeRect.left}px`,
|
||||
width: `${activeRect.width}px`,
|
||||
height: `${activeRect.height}px`,
|
||||
borderRadius: `${radius}px`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
key={`panel-${stepKey}`}
|
||||
ref={panelRef}
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
panelClassName,
|
||||
theme?.panelClassName,
|
||||
step.panelClassName,
|
||||
)}
|
||||
role="dialog"
|
||||
style={{
|
||||
zIndex: zIndex + 3,
|
||||
...activeLayout.panelStyle,
|
||||
}}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
export type SpotlightPlacement = "top" | "bottom" | "left" | "right" | "auto";
|
||||
|
||||
export interface SpotlightTarget {
|
||||
selector?: string;
|
||||
dataGuide?: string;
|
||||
}
|
||||
|
||||
export interface SpotlightStep {
|
||||
id: string;
|
||||
target: string | SpotlightTarget;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
placement?: SpotlightPlacement;
|
||||
allowTargetClick?: boolean;
|
||||
padding?: number;
|
||||
radius?: number;
|
||||
panelClassName?: string;
|
||||
nextLabel?: string;
|
||||
prevLabel?: string;
|
||||
doneLabel?: string;
|
||||
skipLabel?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface SpotlightLayout {
|
||||
stepKey: string;
|
||||
portalTarget: HTMLElement;
|
||||
width: number;
|
||||
height: number;
|
||||
highlightRect: Rect | null;
|
||||
panelStyle: CSSProperties;
|
||||
targetMissingSelector: string | null;
|
||||
}
|
||||
|
||||
export interface GuideSpotlightTheme {
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
highlightClassName?: string;
|
||||
zIndex?: number;
|
||||
panelMaxWidth?: number;
|
||||
offset?: number;
|
||||
radius?: number;
|
||||
overlayColor?: string;
|
||||
}
|
||||
|
||||
export interface GuideSpotlightState {
|
||||
open: boolean;
|
||||
steps: SpotlightStep[];
|
||||
current: number;
|
||||
}
|
||||
|
||||
export interface GuideSpotlightActions {
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
skip: () => void;
|
||||
finish: () => void;
|
||||
}
|
||||
|
||||
export interface GuideSpotlightRenderState {
|
||||
step: SpotlightStep;
|
||||
progress: {
|
||||
index: number;
|
||||
current: number;
|
||||
total: number;
|
||||
};
|
||||
status: {
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
};
|
||||
actions: {
|
||||
prev: () => void;
|
||||
next: () => void;
|
||||
primary: () => void;
|
||||
skip: () => void;
|
||||
finish: () => void;
|
||||
};
|
||||
labels: {
|
||||
prev: string;
|
||||
next: string;
|
||||
skip: string;
|
||||
done: string;
|
||||
primary: string;
|
||||
};
|
||||
highlight: {
|
||||
rect: Rect | null;
|
||||
targetMissingSelector: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuideSpotlightProps {
|
||||
state: GuideSpotlightState;
|
||||
actions: GuideSpotlightActions;
|
||||
children?: (spotlight: GuideSpotlightRenderState) => ReactNode;
|
||||
container?: HTMLElement | null | (() => HTMLElement | null);
|
||||
viewport?: {
|
||||
width: number;
|
||||
height: number;
|
||||
scale?: number;
|
||||
originLeft?: number;
|
||||
originTop?: number;
|
||||
};
|
||||
theme?: GuideSpotlightTheme;
|
||||
closeOnOverlayClick?: boolean;
|
||||
closeOnEsc?: boolean;
|
||||
skipIfTargetMissing?: boolean;
|
||||
onStepChange?: (index: number, step: SpotlightStep) => void;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type { DefaultGuideSpotlightPanelProps } from "./DefaultGuideSpotlightPanel";
|
||||
export { DefaultGuideSpotlightPanel } from "./DefaultGuideSpotlightPanel";
|
||||
export { GuideSpotlight } from "./GuideSpotlight";
|
||||
export type {
|
||||
GuideSpotlightActions,
|
||||
GuideSpotlightProps,
|
||||
GuideSpotlightRenderState,
|
||||
GuideSpotlightState,
|
||||
GuideSpotlightTheme,
|
||||
Rect,
|
||||
SpotlightLayout,
|
||||
SpotlightPlacement,
|
||||
SpotlightStep,
|
||||
SpotlightTarget,
|
||||
} from "./GuideSpotlight.types";
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { OrientationGuardProps } from "./OrientationGuard.types";
|
||||
import { useOrientation } from "./useOrientation";
|
||||
|
||||
function DefaultLandscapePrompt() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-2000 flex items-center justify-center bg-gray-950/80 px-8 text-white">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-full w-full text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">请旋转屏幕</h1>
|
||||
<p className="mt-3 text-base text-gray-400">
|
||||
为获得最佳体验,请横屏浏览
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrientationGuard({ renderPrompt }: OrientationGuardProps) {
|
||||
const isPortrait = useOrientation();
|
||||
|
||||
if (!isPortrait) return null;
|
||||
if (renderPrompt) return <>{renderPrompt()}</>;
|
||||
|
||||
return <DefaultLandscapePrompt />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface OrientationGuardProps {
|
||||
/** 竖屏时渲染的自定义提示;不传则用内置的横屏提示 */
|
||||
renderPrompt?: () => ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { OrientationGuard } from "./OrientationGuard";
|
||||
export type { OrientationGuardProps } from "./OrientationGuard.types";
|
||||
export { useOrientation } from "./useOrientation";
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function getIsPortrait() {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.innerHeight > window.innerWidth;
|
||||
}
|
||||
|
||||
export function useOrientation() {
|
||||
const [isPortrait, setIsPortrait] = useState(getIsPortrait);
|
||||
|
||||
useEffect(() => {
|
||||
const updateOrientation = () => setIsPortrait(getIsPortrait());
|
||||
window.addEventListener("resize", updateOrientation);
|
||||
window.addEventListener("orientationchange", updateOrientation);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateOrientation);
|
||||
window.removeEventListener("orientationchange", updateOrientation);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isPortrait;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* 合并 className,并处理 Tailwind 冲突。
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import "./utils/vconsole";
|
||||
import "./styles/index.css";
|
||||
import { router } from "./router/index.tsx";
|
||||
import { initializeYibanSession } from "./utils/yiban";
|
||||
|
||||
initializeYibanSession();
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) {
|
||||
throw new Error("Failed to find the root element");
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold">关于我们</h1>
|
||||
<p className="text-lg text-gray-600">这是关于页面</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link to="/">
|
||||
<Button variant="secondary">返回首页</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCourseCanvas } from "@/course/canvas";
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragDropProviderProps,
|
||||
Draggable,
|
||||
Droppable,
|
||||
} from "@/course/interactions/drag-drop";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type {
|
||||
DragDemoItem,
|
||||
DragDemoZone,
|
||||
DragDropDemoResult,
|
||||
} from "./DragDropDemo.types";
|
||||
|
||||
const dragItems = [
|
||||
{
|
||||
id: "apple",
|
||||
label: "苹果",
|
||||
description: "富含维生素的水果",
|
||||
icon: "🍎",
|
||||
tone: "rose",
|
||||
targetZoneId: "fruit-zone",
|
||||
},
|
||||
{
|
||||
id: "banana",
|
||||
label: "香蕉",
|
||||
description: "常见的热带水果",
|
||||
icon: "🍌",
|
||||
tone: "lime",
|
||||
targetZoneId: "fruit-zone",
|
||||
},
|
||||
{
|
||||
id: "car",
|
||||
label: "汽车",
|
||||
description: "陆地出行工具",
|
||||
icon: "🚗",
|
||||
tone: "blue",
|
||||
targetZoneId: "traffic-zone",
|
||||
},
|
||||
{
|
||||
id: "bike",
|
||||
label: "自行车",
|
||||
description: "绿色低碳出行工具",
|
||||
icon: "🚲",
|
||||
tone: "cyan",
|
||||
targetZoneId: "traffic-zone",
|
||||
},
|
||||
{
|
||||
id: "book",
|
||||
label: "书本",
|
||||
description: "阅读和学习资料",
|
||||
icon: "📘",
|
||||
tone: "amber",
|
||||
targetZoneId: "study-zone",
|
||||
},
|
||||
{
|
||||
id: "pencil",
|
||||
label: "铅笔",
|
||||
description: "书写和绘画工具",
|
||||
icon: "✏️",
|
||||
tone: "indigo",
|
||||
targetZoneId: "study-zone",
|
||||
},
|
||||
] satisfies DragDemoItem[];
|
||||
|
||||
const dropZones = [
|
||||
{
|
||||
id: "fruit-zone",
|
||||
title: "水果类",
|
||||
description: "把可以食用的水果放到这里",
|
||||
icon: "🍓",
|
||||
tone: "emerald",
|
||||
},
|
||||
{
|
||||
id: "traffic-zone",
|
||||
title: "交通工具",
|
||||
description: "把出行相关物品放到这里",
|
||||
icon: "🚌",
|
||||
tone: "sky",
|
||||
},
|
||||
{
|
||||
id: "study-zone",
|
||||
title: "学习用品",
|
||||
description: "把学习和书写用品放到这里",
|
||||
icon: "🎒",
|
||||
tone: "violet",
|
||||
},
|
||||
] satisfies DragDemoZone[];
|
||||
|
||||
const dragItemClassNames = {
|
||||
rose: "border-rose-200 bg-rose-50 text-rose-700 shadow-rose-100",
|
||||
blue: "border-blue-200 bg-blue-50 text-blue-700 shadow-blue-100",
|
||||
amber: "border-amber-200 bg-amber-50 text-amber-700 shadow-amber-100",
|
||||
lime: "border-lime-200 bg-lime-50 text-lime-700 shadow-lime-100",
|
||||
cyan: "border-cyan-200 bg-cyan-50 text-cyan-700 shadow-cyan-100",
|
||||
indigo: "border-indigo-200 bg-indigo-50 text-indigo-700 shadow-indigo-100",
|
||||
};
|
||||
|
||||
const zoneIconClassNames = {
|
||||
emerald: "bg-emerald-100 text-emerald-600",
|
||||
sky: "bg-sky-100 text-sky-600",
|
||||
violet: "bg-violet-100 text-violet-600",
|
||||
};
|
||||
|
||||
const zoneOverClassNames = {
|
||||
emerald:
|
||||
"border-emerald-400 bg-emerald-50 shadow-[0_0_0_6px_rgba(16,185,129,0.10),0_18px_45px_rgba(16,185,129,0.16)]",
|
||||
sky: "border-sky-400 bg-sky-50 shadow-[0_0_0_6px_rgba(14,165,233,0.10),0_18px_45px_rgba(14,165,233,0.16)]",
|
||||
violet:
|
||||
"border-violet-400 bg-violet-50 shadow-[0_0_0_6px_rgba(139,92,246,0.10),0_18px_45px_rgba(139,92,246,0.16)]",
|
||||
};
|
||||
|
||||
const findDragItem = (itemId: string) =>
|
||||
dragItems.find((item) => item.id === itemId);
|
||||
|
||||
const getZoneTotal = (zoneId: string) =>
|
||||
dragItems.filter((item) => item.targetZoneId === zoneId).length;
|
||||
|
||||
const renderDragItem = (item: DragDemoItem, className?: string) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-16 items-center gap-3 rounded-2xl border px-4 font-medium shadow-sm select-none",
|
||||
dragItemClassNames[item.tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="flex size-10 items-center justify-center rounded-full bg-white/70 text-xl shadow-sm">
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">{item.label}</span>
|
||||
<span className="mt-0.5 block truncate text-xs opacity-70">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPlacedItem = (item: DragDemoItem) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium shadow-sm",
|
||||
dragItemClassNames[item.tone],
|
||||
)}
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center rounded-full bg-white/70 text-base shadow-sm">
|
||||
{item.icon}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function DragDropDemo() {
|
||||
const { scale } = useCourseCanvas();
|
||||
const [dropResult, setDropResult] = useState<DragDropDemoResult>(null);
|
||||
const [placedItemIds, setPlacedItemIds] = useState<Record<string, string[]>>(
|
||||
{},
|
||||
);
|
||||
const placedIds = Object.values(placedItemIds).flat();
|
||||
const placedCount = placedIds.length;
|
||||
const progress = Math.round((placedCount / dragItems.length) * 100);
|
||||
const isCompleted = placedCount === dragItems.length;
|
||||
|
||||
const handleDragEnd: DragDropProviderProps["onDragEnd"] = (event) => {
|
||||
if (isCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draggedId = String(event.active.id);
|
||||
const droppedId = event.over ? String(event.over.id) : null;
|
||||
const draggedItem = findDragItem(draggedId);
|
||||
|
||||
if (!droppedId || !draggedItem) {
|
||||
setDropResult({
|
||||
type: "error",
|
||||
message: "没有放入目标区域,请再试一次。",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (placedIds.includes(draggedId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (draggedItem.targetZoneId !== droppedId) {
|
||||
setDropResult({
|
||||
type: "error",
|
||||
message: `还不对:${draggedItem.label} 不属于这个分类。`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPlacedItemIds = {
|
||||
...placedItemIds,
|
||||
[droppedId]: [...(placedItemIds[droppedId] ?? []), draggedId],
|
||||
};
|
||||
const nextPlacedCount = Object.values(nextPlacedItemIds).flat().length;
|
||||
|
||||
setPlacedItemIds(nextPlacedItemIds);
|
||||
setDropResult({
|
||||
type: "success",
|
||||
message:
|
||||
nextPlacedCount === dragItems.length
|
||||
? "全部分类完成,做得很好!"
|
||||
: `答对了:${draggedItem.label} 已放入正确分类。`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDragStart = () => {
|
||||
if (!isCompleted) {
|
||||
setDropResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragCancel = () => {
|
||||
if (!isCompleted) {
|
||||
setDropResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setDropResult(null);
|
||||
setPlacedItemIds({});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-3xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white shadow-sm">
|
||||
<div className="border-slate-200 border-b bg-white/80 px-6 py-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-sky-600">
|
||||
Interactive Course Demo
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold text-slate-950">
|
||||
Drag and Drop 分类挑战
|
||||
</h2>
|
||||
<p className="max-w-2xl text-slate-500">
|
||||
将左侧物品拖入右侧正确分类。拖拽预览使用自定义
|
||||
DragOverlay,释放区根据是否匹配给出颜色反馈。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500">
|
||||
完成 {placedCount}/{dragItems.length}
|
||||
</div>
|
||||
<div className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500">
|
||||
scale {scale.toFixed(3)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-sky-400 to-emerald-400 transition-[width] duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<DragDropProvider
|
||||
scale={scale}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
renderOverlay={({ activeId }) => {
|
||||
const activeItem = findDragItem(String(activeId));
|
||||
|
||||
return activeItem
|
||||
? renderDragItem(
|
||||
activeItem,
|
||||
"pointer-events-none w-[300px] shadow-xl ring-4 ring-white/70",
|
||||
)
|
||||
: null;
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-5 lg:grid-cols-[320px_1fr]">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-800">
|
||||
待分类物品
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
拖动物品到右侧对应区域
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500">
|
||||
{dragItems.length - placedCount} left
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{dragItems
|
||||
.filter((item) => !placedIds.includes(item.id))
|
||||
.map((item) => (
|
||||
<Draggable
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
disabled={isCompleted}
|
||||
>
|
||||
{renderDragItem(item)}
|
||||
</Draggable>
|
||||
))}
|
||||
{isCompleted && (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
|
||||
所有物品都已完成分类
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{dropZones.map((zone) => {
|
||||
const zonePlacedIds = placedItemIds[zone.id] ?? [];
|
||||
const placedItems = zonePlacedIds
|
||||
.map((itemId) => findDragItem(itemId))
|
||||
.filter((item): item is DragDemoItem => Boolean(item));
|
||||
const zoneTotal = getZoneTotal(zone.id);
|
||||
|
||||
return (
|
||||
<Droppable
|
||||
key={zone.id}
|
||||
id={zone.id}
|
||||
canDrop={(activeId) => {
|
||||
const activeItem = findDragItem(String(activeId));
|
||||
|
||||
return (
|
||||
activeItem?.targetZoneId === zone.id &&
|
||||
!zonePlacedIds.includes(String(activeId))
|
||||
);
|
||||
}}
|
||||
className={({ isOver, canDrop }) =>
|
||||
cn(
|
||||
"min-h-80 rounded-3xl border-2 border-dashed border-slate-300 bg-white p-5 transition-[background-color,border-color,box-shadow] duration-150",
|
||||
isOver && canDrop && zoneOverClassNames[zone.tone],
|
||||
isOver &&
|
||||
!canDrop &&
|
||||
"border-rose-300 bg-rose-50 shadow-[0_0_0_6px_rgba(244,63,94,0.08)]",
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-3 flex size-12 items-center justify-center rounded-2xl text-xl",
|
||||
zoneIconClassNames[zone.tone],
|
||||
)}
|
||||
>
|
||||
{zone.icon}
|
||||
</div>
|
||||
<div className="font-semibold text-slate-800">
|
||||
{zone.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-slate-400">
|
||||
{zone.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500">
|
||||
{placedItems.length}/{zoneTotal}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto grid gap-2">
|
||||
{placedItems.length > 0 ? (
|
||||
placedItems.map((item) => (
|
||||
<div key={item.id}>{renderPlacedItem(item)}</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-5 text-center text-sm text-slate-400">
|
||||
等待放入正确物品
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Droppable>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
|
||||
{dropResult && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex items-center justify-between gap-3 rounded-2xl border bg-white px-4 py-3 text-sm shadow-sm",
|
||||
dropResult.type === "success"
|
||||
? "border-emerald-200 text-emerald-700"
|
||||
: "border-rose-200 text-rose-600",
|
||||
)}
|
||||
>
|
||||
<span>{dropResult.message}</span>
|
||||
<Button size="sm" variant="ghost" onClick={handleRetry}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type DragDemoItemTone =
|
||||
| "rose"
|
||||
| "blue"
|
||||
| "amber"
|
||||
| "lime"
|
||||
| "cyan"
|
||||
| "indigo";
|
||||
export type DragDemoZoneTone = "emerald" | "sky" | "violet";
|
||||
|
||||
export interface DragDemoItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
tone: DragDemoItemTone;
|
||||
targetZoneId: string;
|
||||
}
|
||||
|
||||
export interface DragDemoZone {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
tone: DragDemoZoneTone;
|
||||
}
|
||||
|
||||
export type DragDropDemoResult = {
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null;
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { GuideSpotlight } from "@/components/common";
|
||||
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 { getAppPortalRoot, useCourseCanvas } from "@/course/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
CourseCategory,
|
||||
CourseFlowProgress,
|
||||
CourseResource,
|
||||
CourseResourceCategory,
|
||||
} from "./CourseDemo.types";
|
||||
|
||||
export const courseResources: CourseResource[] = [
|
||||
{
|
||||
id: "policy-video",
|
||||
title: "资助政策微课",
|
||||
description: "了解国家助学政策和申请条件",
|
||||
category: "policy",
|
||||
icon: "🎬",
|
||||
},
|
||||
{
|
||||
id: "policy-guide",
|
||||
title: "申请流程图",
|
||||
description: "按步骤核对材料与办理节点",
|
||||
category: "policy",
|
||||
icon: "🗺️",
|
||||
},
|
||||
{
|
||||
id: "practice-case",
|
||||
title: "诚信申请案例",
|
||||
description: "判断案例中的规范与风险",
|
||||
category: "practice",
|
||||
icon: "🧩",
|
||||
},
|
||||
{
|
||||
id: "practice-checklist",
|
||||
title: "材料自查清单",
|
||||
description: "动手检查课程中的模拟材料",
|
||||
category: "practice",
|
||||
icon: "✅",
|
||||
},
|
||||
{
|
||||
id: "support-qa",
|
||||
title: "常见问题答疑",
|
||||
description: "查找填报和审核中的常见问题",
|
||||
category: "support",
|
||||
icon: "💬",
|
||||
},
|
||||
{
|
||||
id: "support-contact",
|
||||
title: "校园咨询渠道",
|
||||
description: "联系辅导员与学生资助中心",
|
||||
category: "support",
|
||||
icon: "☎️",
|
||||
},
|
||||
];
|
||||
|
||||
export const courseCategories: CourseCategory[] = [
|
||||
{
|
||||
id: "policy",
|
||||
title: "政策学习",
|
||||
description: "帮助理解政策与办理流程",
|
||||
icon: "📚",
|
||||
},
|
||||
{
|
||||
id: "practice",
|
||||
title: "互动实践",
|
||||
description: "需要判断、检查或动手完成",
|
||||
icon: "🧠",
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
title: "学习支持",
|
||||
description: "遇到问题时可以随时查阅",
|
||||
icon: "🤝",
|
||||
},
|
||||
];
|
||||
|
||||
export function createEmptyProgress(): CourseFlowProgress {
|
||||
return {
|
||||
placed: { policy: [], practice: [], support: [] },
|
||||
completed: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseProgress(content: string): CourseFlowProgress | null {
|
||||
try {
|
||||
const value = JSON.parse(content) as Partial<CourseFlowProgress>;
|
||||
if (!value.placed || typeof value.placed !== "object") return null;
|
||||
const placed = Object.fromEntries(
|
||||
courseCategories.map(({ id }) => [
|
||||
id,
|
||||
Array.isArray(value.placed?.[id])
|
||||
? value.placed[id].filter((item): item is string =>
|
||||
courseResources.some(
|
||||
(resource) => resource.id === item && resource.category === id,
|
||||
),
|
||||
)
|
||||
: [],
|
||||
]),
|
||||
) as Record<CourseResourceCategory, string[]>;
|
||||
const placedCount = Object.values(placed).flat().length;
|
||||
return {
|
||||
placed,
|
||||
completed: placedCount === courseResources.length,
|
||||
updatedAt: value.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineDialog } from "@/components/common";
|
||||
|
||||
export const courseDemoDialog = defineDialog({
|
||||
id: "resource-map-opening",
|
||||
showOnce: false,
|
||||
steps: [
|
||||
{
|
||||
id: "welcome",
|
||||
character: { name: "小易", avatar: "🧭", mood: "ready" },
|
||||
title: "欢迎进入学生资助政策微课",
|
||||
description:
|
||||
"这节课不需要记住组件名称。你将通过一次真实的资源分类任务,认识政策学习、互动实践和学习支持之间的关系。",
|
||||
nextLabel: "了解任务",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
id: "choose-path",
|
||||
character: { name: "小易", avatar: "✨", mood: "thinking" },
|
||||
title: "选择你的学习方式",
|
||||
description: "第一次体验可以先查看操作引导,熟悉拖拽后也可以直接开始。",
|
||||
options: [
|
||||
{ id: "guided", label: "先听操作说明", nextId: "guided-start" },
|
||||
{ id: "direct", label: "直接开始分类", nextId: "direct-start" },
|
||||
],
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
id: "guided-start",
|
||||
character: { name: "小易", avatar: "🗺️", mood: "guide" },
|
||||
title: "按资源用途判断分类",
|
||||
description:
|
||||
"关闭对话后点击“查看操作引导”,页面会依次介绍资源区、分类区和进度区。",
|
||||
doneLabel: "进入页面",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
id: "direct-start",
|
||||
character: { name: "小易", avatar: "🎯", mood: "active" },
|
||||
title: "准备开始",
|
||||
description:
|
||||
"阅读卡片说明,将六张资源卡放入正确区域。错误分类可以立即重试,正确结果会自动保存。",
|
||||
doneLabel: "开始挑战",
|
||||
placement: "bottom",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineSpotlight } from "@/components/common";
|
||||
|
||||
export const courseDemoSpotlight = defineSpotlight({
|
||||
id: "resource-map",
|
||||
showOnce: false,
|
||||
steps: [
|
||||
{
|
||||
id: "course-resources",
|
||||
title: "先阅读资源卡片",
|
||||
description: "每张卡片都包含用途说明。按住卡片,将它拖向右侧的正确分类。",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "course-categories",
|
||||
title: "放入对应学习阶段",
|
||||
description:
|
||||
"政策材料用于理解知识,互动资源用于练习,答疑与咨询属于学习支持。",
|
||||
placement: "top",
|
||||
padding: 12,
|
||||
},
|
||||
{
|
||||
id: "course-progress",
|
||||
title: "自动记录学习进度",
|
||||
description:
|
||||
"每次正确分类都会保存;刷新页面后仍可继续,完成时会展示本节学习结果。",
|
||||
placement: "left",
|
||||
doneLabel: "开始分类",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
GuideDialogRenderState,
|
||||
GuideSpotlightRenderState,
|
||||
} from "@/components/common";
|
||||
|
||||
export type CourseResourceCategory = "policy" | "practice" | "support";
|
||||
|
||||
export interface CourseResource {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: CourseResourceCategory;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface CourseCategory {
|
||||
id: CourseResourceCategory;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface CourseFlowProgress {
|
||||
placed: Record<CourseResourceCategory, string[]>;
|
||||
completed: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CourseDemoGuideDialogPanelProps {
|
||||
dialog: GuideDialogRenderState;
|
||||
}
|
||||
|
||||
export interface CourseDemoGuideSpotlightPanelProps {
|
||||
spotlight: GuideSpotlightRenderState;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GuideDialog } from "@/components/common";
|
||||
import { CourseDemoGuideDialogPanel } from "./CourseDemoGuideDialogPanel";
|
||||
|
||||
export function CourseDemoGuideDialog() {
|
||||
return (
|
||||
<GuideDialog
|
||||
theme={{
|
||||
panelMaxWidth: 820,
|
||||
overlayColor: "rgb(2 6 23 / 0.68)",
|
||||
}}
|
||||
>
|
||||
{(dialog) => <CourseDemoGuideDialogPanel dialog={dialog} />}
|
||||
</GuideDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { CourseDemoGuideDialogPanelProps } from "./CourseDemo.types";
|
||||
|
||||
function DialogProgress({ dialog }: CourseDemoGuideDialogPanelProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-cyan-100">
|
||||
{Array.from({ length: dialog.progress.total }).map((_, index) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static progress marks match dialog step order.
|
||||
key={index}
|
||||
className={
|
||||
index === dialog.progress.index
|
||||
? "h-2 w-8 rounded-full bg-current"
|
||||
: "h-2 w-2 rounded-full bg-current/24"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogActions({ dialog }: CourseDemoGuideDialogPanelProps) {
|
||||
const { actions, labels, status } = dialog;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.skip}
|
||||
className="rounded-full border border-white/18 bg-white/10 px-4 py-2 text-sm font-bold text-white/82 backdrop-blur transition hover:bg-white/18"
|
||||
>
|
||||
{labels.skip}
|
||||
</button>
|
||||
{!status.isFirst && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.prev}
|
||||
className="rounded-full border border-white/18 bg-white/10 px-4 py-2 text-sm font-bold text-white/82 backdrop-blur transition hover:bg-white/18"
|
||||
>
|
||||
{labels.prev}
|
||||
</button>
|
||||
)}
|
||||
{!status.hasOptions && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.primary}
|
||||
className="rounded-full bg-cyan-300 px-6 py-2 text-sm font-black text-slate-950 shadow-xl shadow-cyan-300/30 transition hover:bg-cyan-200"
|
||||
>
|
||||
{labels.primary}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CourseDemoGuideDialogPanel({
|
||||
dialog,
|
||||
}: CourseDemoGuideDialogPanelProps) {
|
||||
const { character, options, step } = dialog;
|
||||
|
||||
return (
|
||||
<div className="relative w-[820px] text-white">
|
||||
<div className="absolute -top-20 left-12 flex h-36 w-36 items-center justify-center rounded-[42px] border-[6px] border-cyan-200 bg-slate-950 text-6xl shadow-[0_0_48px_rgba(103,232,249,0.45)]">
|
||||
{character?.avatar ?? "🙂"}
|
||||
</div>
|
||||
<div className="absolute left-36 top-8 h-0 w-0 border-y-[22px] border-r-[42px] border-y-transparent border-r-cyan-200" />
|
||||
|
||||
<div className="overflow-hidden rounded-[42px] border-[6px] border-cyan-200 bg-slate-950/94 shadow-[0_32px_100px_rgba(8,47,73,0.62)] backdrop-blur">
|
||||
<div className="absolute -right-24 -top-24 h-72 w-72 rounded-full bg-cyan-400/20 blur-3xl" />
|
||||
<div className="absolute -bottom-28 left-20 h-72 w-72 rounded-full bg-blue-500/20 blur-3xl" />
|
||||
<div className="relative grid grid-cols-[170px_1fr] gap-5 p-7 pt-10">
|
||||
<div className="flex flex-col justify-end pt-20">
|
||||
<div className="rounded-3xl bg-white/10 p-4 backdrop-blur">
|
||||
<p className="text-xs font-black uppercase tracking-[0.28em] text-cyan-200">
|
||||
Guide Dialog
|
||||
</p>
|
||||
{character?.name && (
|
||||
<p className="mt-3 text-2xl font-black">{character.name}</p>
|
||||
)}
|
||||
{character?.mood && (
|
||||
<p className="mt-1 text-sm font-semibold text-cyan-100/70">
|
||||
{character.mood}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<DialogProgress dialog={dialog} />
|
||||
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-black text-cyan-100">
|
||||
{dialog.progress.current} / {dialog.progress.total}
|
||||
</span>
|
||||
</div>
|
||||
{step.title && (
|
||||
<h3 className="mt-5 text-4xl font-black leading-tight">
|
||||
{step.title}
|
||||
</h3>
|
||||
)}
|
||||
{step.description && (
|
||||
<div className="mt-4 text-xl font-semibold leading-9 text-cyan-50/86">
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{options.length > 0 && (
|
||||
<div className="mt-6 grid gap-3">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => dialog.actions.selectOption(option.id)}
|
||||
className="group flex items-center justify-between rounded-2xl border border-cyan-200/24 bg-white/10 px-5 py-4 text-left text-base font-black text-white transition hover:border-cyan-200 hover:bg-cyan-200 hover:text-slate-950"
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<span className="text-cyan-200 transition group-hover:translate-x-1 group-hover:text-slate-950">
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-7 flex items-center justify-end">
|
||||
<DialogActions dialog={dialog} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GuideSpotlight } from "@/components/common";
|
||||
import { CourseDemoGuideSpotlightPanel } from "./CourseDemoGuideSpotlightPanel";
|
||||
|
||||
export function CourseDemoGuideSpotlight() {
|
||||
return (
|
||||
<GuideSpotlight
|
||||
theme={{
|
||||
panelMaxWidth: 620,
|
||||
offset: 20,
|
||||
}}
|
||||
>
|
||||
{(spotlight) => <CourseDemoGuideSpotlightPanel spotlight={spotlight} />}
|
||||
</GuideSpotlight>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { CourseDemoGuideSpotlightPanelProps } from "./CourseDemo.types";
|
||||
|
||||
function GuideActions({
|
||||
spotlight,
|
||||
className,
|
||||
}: CourseDemoGuideSpotlightPanelProps & { className: string }) {
|
||||
const { actions, labels, status } = spotlight;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.skip}
|
||||
className="rounded-full bg-white/12 px-4 py-2 text-sm font-semibold text-current backdrop-blur transition hover:bg-white/22"
|
||||
>
|
||||
跳过
|
||||
</button>
|
||||
{!status.isFirst && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.prev}
|
||||
className="rounded-full bg-white/12 px-4 py-2 text-sm font-semibold text-current backdrop-blur transition hover:bg-white/22"
|
||||
>
|
||||
{labels.prev}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.primary}
|
||||
className={cn(
|
||||
"rounded-full px-5 py-2 text-sm font-black text-white shadow-xl transition",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{labels.primary}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressMarks({ spotlight }: CourseDemoGuideSpotlightPanelProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Array.from({ length: spotlight.progress.total }).map((_, index) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static progress marks match spotlight step order.
|
||||
key={index}
|
||||
className={
|
||||
index === spotlight.progress.index
|
||||
? "h-2 w-8 rounded-full bg-current"
|
||||
: "h-2 w-2 rounded-full bg-current/20"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutGuidePanel({ spotlight }: CourseDemoGuideSpotlightPanelProps) {
|
||||
const { progress, step } = spotlight;
|
||||
|
||||
return (
|
||||
<div className="relative w-[720px] text-slate-950">
|
||||
<div className="absolute -top-8 left-10 h-8 w-[460px] border-t-4 border-dashed border-amber-300" />
|
||||
<div className="absolute -top-8 left-[455px] h-8 w-8 rounded-full border-4 border-amber-300 bg-white shadow-[0_0_28px_rgba(251,191,36,0.7)]" />
|
||||
<div className="absolute -top-4 left-[484px] h-0 w-0 border-y-[14px] border-l-[30px] border-y-transparent border-l-amber-300" />
|
||||
|
||||
<div className="relative ml-0 rounded-[36px] border-[6px] border-white bg-amber-50/92 px-8 py-6 shadow-[0_28px_80px_rgba(120,53,15,0.28)] backdrop-blur">
|
||||
<div className="absolute -right-8 -top-8 flex h-20 w-20 rotate-12 items-center justify-center rounded-full bg-amber-300 text-4xl shadow-xl shadow-amber-300/40">
|
||||
🔍
|
||||
</div>
|
||||
<div className="absolute -bottom-5 left-20 h-10 w-10 rotate-45 border-b-[6px] border-r-[6px] border-white bg-amber-50/92" />
|
||||
<div className="max-w-[610px]">
|
||||
<p className="text-sm font-black uppercase tracking-[0.24em] text-amber-600">
|
||||
Map Pointer · {progress.current}/{progress.total}
|
||||
</p>
|
||||
{step.title && (
|
||||
<h3 className="mt-3 text-4xl font-black leading-tight text-slate-900">
|
||||
{step.title}
|
||||
</h3>
|
||||
)}
|
||||
{step.description && (
|
||||
<div className="mt-3 text-xl font-semibold leading-9 text-slate-700">
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between text-amber-700">
|
||||
<ProgressMarks spotlight={spotlight} />
|
||||
<GuideActions
|
||||
spotlight={spotlight}
|
||||
className="bg-amber-500 shadow-amber-500/35 hover:bg-amber-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionGuidePanel({ spotlight }: CourseDemoGuideSpotlightPanelProps) {
|
||||
const { progress, step } = spotlight;
|
||||
|
||||
return (
|
||||
<div className="relative w-[660px] text-white">
|
||||
<div className="absolute -left-16 top-12 h-28 w-28 rounded-full border-[10px] border-fuchsia-300/80 bg-fuchsia-500/20 shadow-[0_0_40px_rgba(217,70,239,0.7)]" />
|
||||
<div className="absolute -left-3 top-[88px] h-1.5 w-28 bg-linear-to-r from-fuchsia-300 to-transparent" />
|
||||
<div className="absolute -left-24 top-[104px] h-0 w-0 border-y-[18px] border-r-[38px] border-y-transparent border-r-fuchsia-300" />
|
||||
|
||||
<div className="relative ml-8 overflow-hidden rounded-[34px] bg-slate-950 shadow-[0_30px_90px_rgba(76,29,149,0.5)] ring-4 ring-fuchsia-300/45">
|
||||
<div className="absolute -right-20 -top-20 h-56 w-56 rounded-full bg-fuchsia-500/30 blur-3xl" />
|
||||
<div className="absolute -bottom-20 left-10 h-52 w-52 rounded-full bg-violet-500/30 blur-3xl" />
|
||||
<div className="relative grid grid-cols-[1fr_170px] gap-4 p-6">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.28em] text-fuchsia-200">
|
||||
Signal Route · {progress.current}/{progress.total}
|
||||
</p>
|
||||
{step.title && (
|
||||
<h3 className="mt-3 text-3xl font-black leading-tight">
|
||||
{step.title}
|
||||
</h3>
|
||||
)}
|
||||
{step.description && (
|
||||
<div className="mt-3 text-base leading-7 text-fuchsia-50/78">
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 flex items-center justify-between gap-4 text-fuchsia-100">
|
||||
<ProgressMarks spotlight={spotlight} />
|
||||
<GuideActions
|
||||
spotlight={spotlight}
|
||||
className="bg-fuchsia-500 shadow-fuchsia-500/35 hover:bg-fuchsia-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-[28px] border border-white/10 bg-white/10 p-4 backdrop-blur">
|
||||
<div className="absolute -left-4 top-1/2 h-8 w-8 -translate-y-1/2 rotate-45 border-b border-l border-white/10 bg-white/10" />
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div className="text-5xl font-black leading-none text-fuchsia-200">
|
||||
01
|
||||
</div>
|
||||
<div className="space-y-2 text-sm font-bold text-white/88">
|
||||
<div className="rounded-full bg-white/12 px-3 py-2">
|
||||
写入缓存
|
||||
</div>
|
||||
<div className="rounded-full bg-white/12 px-3 py-2">
|
||||
读取进度
|
||||
</div>
|
||||
<div className="rounded-full bg-white/12 px-3 py-2">
|
||||
本地预览
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsGuidePanel({ spotlight }: CourseDemoGuideSpotlightPanelProps) {
|
||||
const { progress, step } = spotlight;
|
||||
|
||||
return (
|
||||
<div className="relative w-[700px] text-slate-950">
|
||||
<div className="absolute -right-8 top-1/2 h-32 w-32 -translate-y-1/2 rounded-full border-[12px] border-lime-300 bg-white/80 shadow-[0_0_44px_rgba(190,242,100,0.85)]" />
|
||||
<div className="absolute -right-28 top-1/2 h-[5px] w-32 -translate-y-1/2 bg-linear-to-l from-lime-300 to-transparent" />
|
||||
<div className="absolute -right-36 top-1/2 h-0 w-0 -translate-y-1/2 border-y-[16px] border-l-[34px] border-y-transparent border-l-lime-300" />
|
||||
|
||||
<div className="relative mr-16 overflow-hidden rounded-[38px] bg-lime-300 shadow-[0_30px_90px_rgba(63,98,18,0.32)]">
|
||||
<div className="grid grid-cols-[190px_1fr]">
|
||||
<div className="flex flex-col justify-between p-6">
|
||||
<div>
|
||||
<div className="text-6xl font-black leading-none">GO</div>
|
||||
<p className="mt-3 text-sm font-black uppercase tracking-[0.18em]">
|
||||
Action Target
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold">
|
||||
{progress.current} / {progress.total} · 现在试试课程交互入口
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-l-[34px] bg-slate-950 p-7 text-white">
|
||||
<p className="text-xs font-black uppercase tracking-[0.28em] text-lime-300">
|
||||
Control Deck
|
||||
</p>
|
||||
{step.title && (
|
||||
<h3 className="mt-3 text-3xl font-black leading-tight">
|
||||
{step.title}
|
||||
</h3>
|
||||
)}
|
||||
{step.description && (
|
||||
<div className="mt-3 text-base leading-7 text-slate-300">
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 flex items-center justify-between text-lime-200">
|
||||
<ProgressMarks spotlight={spotlight} />
|
||||
<GuideActions
|
||||
spotlight={spotlight}
|
||||
className="bg-lime-400 text-slate-950 shadow-lime-400/30 hover:bg-lime-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CourseDemoGuideSpotlightPanel({
|
||||
spotlight,
|
||||
}: CourseDemoGuideSpotlightPanelProps) {
|
||||
switch (spotlight.step.id) {
|
||||
case "course-layout":
|
||||
return <LayoutGuidePanel spotlight={spotlight} />;
|
||||
case "session-fallback":
|
||||
return <SessionGuidePanel spotlight={spotlight} />;
|
||||
case "course-actions":
|
||||
return <ActionsGuidePanel spotlight={spotlight} />;
|
||||
default:
|
||||
return <LayoutGuidePanel spotlight={spotlight} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Overlay } from "@/components/ui/overlay";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragDropProviderProps,
|
||||
Draggable,
|
||||
Droppable,
|
||||
} from "@/course/interactions/drag-drop";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useUserStore } from "@/stores";
|
||||
import { getUserStorage, setUserStorage } from "@/utils/yiban";
|
||||
import {
|
||||
courseCategories,
|
||||
courseResources,
|
||||
createEmptyProgress,
|
||||
parseProgress,
|
||||
} from "./CourseDemo.data";
|
||||
import { courseDemoDialog } from "./CourseDemo.dialog";
|
||||
import { courseDemoSpotlight } from "./CourseDemo.spotlight";
|
||||
import type { CourseFlowProgress } from "./CourseDemo.types";
|
||||
import { CourseDemoGuideDialog } from "./CourseDemoGuideDialog";
|
||||
import { CourseDemoGuideSpotlight } from "./CourseDemoGuideSpotlight";
|
||||
|
||||
const LESSON_ID = "course-flow";
|
||||
const PROGRESS_KEY = "resource-classification";
|
||||
|
||||
export default function CourseDemo() {
|
||||
const { scale } = useCourseCanvas();
|
||||
const toast = useToast();
|
||||
const { courseId, userId, yibanConnectionStatus } = useUserStore();
|
||||
const [progress, setProgress] =
|
||||
useState<CourseFlowProgress>(createEmptyProgress);
|
||||
const [restored, setRestored] = useState(false);
|
||||
const [completionOpen, setCompletionOpen] = useState(false);
|
||||
const placedIds = Object.values(progress.placed).flat();
|
||||
const progressPercent = Math.round(
|
||||
(placedIds.length / courseResources.length) * 100,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getUserStorage(LESSON_ID, PROGRESS_KEY)
|
||||
.then((result) => {
|
||||
if (!active || result.status !== "success") return;
|
||||
const savedProgress = parseProgress(result.content);
|
||||
if (savedProgress) setProgress(savedProgress);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) toast.warning("暂时无法恢复进度,将从本节开始学习。");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setRestored(true);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [toast]);
|
||||
|
||||
const persistProgress = async (nextProgress: CourseFlowProgress) => {
|
||||
try {
|
||||
await setUserStorage(
|
||||
LESSON_ID,
|
||||
PROGRESS_KEY,
|
||||
JSON.stringify(nextProgress),
|
||||
);
|
||||
} catch {
|
||||
toast.warning("学习结果已保留在当前页面,远端同步稍后重试。");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd: DragDropProviderProps["onDragEnd"] = (event) => {
|
||||
const resource = courseResources.find(
|
||||
(item) => item.id === String(event.active.id),
|
||||
);
|
||||
const categoryId = event.over ? String(event.over.id) : null;
|
||||
if (!resource || !categoryId) {
|
||||
toast.error("请把资源放入一个分类区域。");
|
||||
return;
|
||||
}
|
||||
if (resource.category !== categoryId) {
|
||||
toast.error(`“${resource.title}”不属于这个分类,再想一想。`);
|
||||
return;
|
||||
}
|
||||
if (placedIds.includes(resource.id)) return;
|
||||
|
||||
const placed = {
|
||||
...progress.placed,
|
||||
[resource.category]: [...progress.placed[resource.category], resource.id],
|
||||
};
|
||||
const completed =
|
||||
Object.values(placed).flat().length === courseResources.length;
|
||||
const nextProgress = {
|
||||
placed,
|
||||
completed,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setProgress(nextProgress);
|
||||
void persistProgress(nextProgress);
|
||||
toast.success(
|
||||
completed
|
||||
? "全部资源分类正确,本节练习已完成。"
|
||||
: `分类正确:${resource.title}`,
|
||||
);
|
||||
if (completed) setCompletionOpen(true);
|
||||
};
|
||||
|
||||
const resetProgress = () => {
|
||||
const nextProgress = createEmptyProgress();
|
||||
setProgress(nextProgress);
|
||||
setCompletionOpen(false);
|
||||
void persistProgress(nextProgress);
|
||||
toast.info("练习已重置,可以重新完成分类。");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-7xl space-y-6 px-6 py-6 text-left">
|
||||
<header className="grid gap-6 rounded-3xl bg-gradient-to-br from-slate-950 via-blue-950 to-indigo-950 p-7 text-white shadow-xl lg:grid-cols-[1fr_360px] lg:items-center">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-blue-200">
|
||||
学生资助政策 · 互动学习
|
||||
</p>
|
||||
<h1 className="text-4xl font-bold">建立你的课程资源地图</h1>
|
||||
<p className="max-w-3xl leading-7 text-blue-100">
|
||||
阅读每张资源卡片,将它拖入“政策学习、互动实践、学习支持”中的正确位置。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<Button onClick={() => courseDemoDialog.start()}>
|
||||
进入课程导入
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => courseDemoSpotlight.start()}
|
||||
>
|
||||
查看操作引导
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-2xl border border-white/15 bg-white/10 p-5 backdrop-blur"
|
||||
data-guide="course-progress"
|
||||
>
|
||||
<div className="flex items-center justify-between text-sm text-blue-100">
|
||||
<span>本节进度</span>
|
||||
<strong>{progressPercent}%</strong>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/15">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-cyan-300 to-emerald-300 transition-[width] duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-xs text-blue-100">
|
||||
<span>
|
||||
已完成 {placedIds.length}/{courseResources.length}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{restored ? "进度已恢复" : "正在恢复进度"}
|
||||
</span>
|
||||
<span>scale {scale.toFixed(3)}</span>
|
||||
<span className="text-right">{yibanConnectionStatus}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<DragDropProvider
|
||||
scale={scale}
|
||||
onDragEnd={handleDragEnd}
|
||||
renderOverlay={({ activeId }) => {
|
||||
const resource = courseResources.find(
|
||||
(item) => item.id === String(activeId),
|
||||
);
|
||||
return resource ? (
|
||||
<ResourceCard resource={resource} overlay />
|
||||
) : null;
|
||||
}}
|
||||
>
|
||||
<section className="grid gap-5 lg:grid-cols-[360px_1fr]">
|
||||
<aside
|
||||
className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm"
|
||||
data-guide="course-resources"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-blue-600">
|
||||
待分类资源
|
||||
</p>
|
||||
<h2 className="text-xl font-bold text-slate-950">资源卡片</h2>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={resetProgress}>
|
||||
重新开始
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
||||
{courseResources
|
||||
.filter((resource) => !placedIds.includes(resource.id))
|
||||
.map((resource) => (
|
||||
<Draggable
|
||||
key={resource.id}
|
||||
id={resource.id}
|
||||
data={{
|
||||
id: resource.id,
|
||||
title: resource.title,
|
||||
category: resource.category,
|
||||
}}
|
||||
>
|
||||
<ResourceCard resource={resource} />
|
||||
</Draggable>
|
||||
))}
|
||||
{progress.completed && (
|
||||
<p className="rounded-2xl bg-emerald-50 p-5 text-center text-sm font-medium text-emerald-700">
|
||||
所有资源都已进入正确分类。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
className="grid gap-4 md:grid-cols-3"
|
||||
data-guide="course-categories"
|
||||
>
|
||||
{courseCategories.map((category) => (
|
||||
<Droppable key={category.id} id={category.id}>
|
||||
{({ isOver }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[410px] flex-col rounded-3xl border-2 border-dashed bg-white p-5 shadow-sm transition",
|
||||
isOver
|
||||
? "border-blue-500 bg-blue-50 shadow-blue-100"
|
||||
: "border-slate-200",
|
||||
)}
|
||||
>
|
||||
<div className="text-3xl">{category.icon}</div>
|
||||
<h2 className="mt-3 text-xl font-bold text-slate-950">
|
||||
{category.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
{category.description}
|
||||
</p>
|
||||
<div className="mt-5 space-y-3">
|
||||
{progress.placed[category.id].map((resourceId) => {
|
||||
const resource = courseResources.find(
|
||||
(item) => item.id === resourceId,
|
||||
);
|
||||
return resource ? (
|
||||
<ResourceCard
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
placed
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-auto pt-5 text-xs font-medium text-slate-400">
|
||||
拖到这里完成分类
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
))}
|
||||
</main>
|
||||
</section>
|
||||
</DragDropProvider>
|
||||
|
||||
<footer className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-sm text-slate-500">
|
||||
<span>
|
||||
课程 {courseId ?? "本地预览"} · 学员 {userId ?? "访客"}
|
||||
</span>
|
||||
<span>每次正确分类都会自动保存,刷新页面后继续学习。</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<Overlay
|
||||
open={completionOpen}
|
||||
onClose={() => setCompletionOpen(false)}
|
||||
container={getAppPortalRoot}
|
||||
contentClassName="flex items-center justify-center p-8"
|
||||
>
|
||||
<div className="w-[560px] max-w-full rounded-3xl bg-white p-8 text-center shadow-2xl">
|
||||
<div className="text-5xl">🎉</div>
|
||||
<p className="mt-4 text-sm font-semibold text-emerald-600">
|
||||
本节完成
|
||||
</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-slate-950">
|
||||
资源地图已建立
|
||||
</h2>
|
||||
<p className="mt-3 leading-7 text-slate-600">
|
||||
你已经能够区分政策材料、互动练习和学习支持资源。学习结果已保存,可以进入下一节课程。
|
||||
</p>
|
||||
<Button className="mt-6" onClick={() => setCompletionOpen(false)}>
|
||||
继续学习
|
||||
</Button>
|
||||
</div>
|
||||
</Overlay>
|
||||
<CourseDemoGuideDialog />
|
||||
<CourseDemoGuideSpotlight />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceCard({
|
||||
resource,
|
||||
overlay = false,
|
||||
placed = false,
|
||||
}: {
|
||||
resource: (typeof courseResources)[number];
|
||||
overlay?: boolean;
|
||||
placed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm",
|
||||
overlay &&
|
||||
"pointer-events-none w-[330px] shadow-xl ring-4 ring-blue-100",
|
||||
placed && "border-emerald-200 bg-emerald-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-xl">
|
||||
{resource.icon}
|
||||
</span>
|
||||
<span>
|
||||
<strong className="block text-sm text-slate-900">
|
||||
{resource.title}
|
||||
</strong>
|
||||
<span className="mt-1 block text-xs leading-5 text-slate-500">
|
||||
{resource.description}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { GuideSpotlight } from "@/components/common";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppStore, useSpotlightStore } from "@/stores";
|
||||
|
||||
export default function Home() {
|
||||
const { count, increment, reset } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
useSpotlightStore.getState().startSpotlight({
|
||||
courseId: "template-home",
|
||||
spotlightId: "intro",
|
||||
steps: [
|
||||
{
|
||||
id: "counter",
|
||||
target: { dataGuide: "counter-actions" },
|
||||
title: "状态管理示例",
|
||||
description: "这里演示 Zustand 的基础用法。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
id: "about-link",
|
||||
target: { dataGuide: "about-link" },
|
||||
title: "路由跳转",
|
||||
description: "点击这里可以跳转到关于页面。",
|
||||
placement: "top",
|
||||
},
|
||||
],
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold">首页</h1>
|
||||
<p className="text-lg text-gray-600">欢迎来到首页</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center justify-center gap-4"
|
||||
data-guide="counter-actions"
|
||||
>
|
||||
<Button onClick={increment}>计数:{count}</Button>
|
||||
<Button variant="secondary" onClick={reset}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link to="/about" data-guide="about-link">
|
||||
<Button>关于我们</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<GuideSpotlight />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as About } from "./About";
|
||||
export { default as Components } from "./Components";
|
||||
export { default as CourseDemo } from "./CourseDemo";
|
||||
export { default as Home } from "./Home";
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createHashRouter } from "react-router-dom";
|
||||
import App from "../App.tsx";
|
||||
import { RouteError, RouteFallback } from "../components/common";
|
||||
import { lazyRoute } from "../utils";
|
||||
|
||||
export const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <App />,
|
||||
errorElement: <RouteError />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: lazyRoute(() => import("../pages/Home")),
|
||||
HydrateFallback: RouteFallback,
|
||||
},
|
||||
{
|
||||
path: "about",
|
||||
lazy: lazyRoute(() => import("../pages/About")),
|
||||
HydrateFallback: RouteFallback,
|
||||
},
|
||||
{
|
||||
path: "components",
|
||||
lazy: lazyRoute(() => import("../pages/Components")),
|
||||
HydrateFallback: RouteFallback,
|
||||
},
|
||||
{
|
||||
path: "course-demo",
|
||||
lazy: lazyRoute(() => import("../pages/CourseDemo")),
|
||||
HydrateFallback: RouteFallback,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user