feat: 模板适配层、易班能力与全局状态

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.8 committed 2026-08-18 13:39:42 +08:00
1 parent 3995656175
commit d3ae0a8ca7
45 files changed
+6931 -179

No files matched your search

@@ -1,36 +0,0 @@
@reference "tailwindcss";
.button {
@apply inline-flex items-center justify-center rounded-md font-medium transition-colors;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2;
@apply disabled:pointer-events-none disabled:opacity-50;
}
.primary {
@apply bg-blue-600 text-white hover:bg-blue-700;
}
.secondary {
@apply bg-gray-200 text-gray-900 hover:bg-gray-300;
}
.outline {
@apply border 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;
}
-22
View File
@@ -1,22 +0,0 @@
import { type ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "@/utils";
import styles from "./Button.module.css";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "primary", size = "md", ...props }, ref) => {
return (
<button
ref={ref}
className={cn(styles.button, styles[variant], styles[size], className)}
{...props}
/>
);
},
);
Button.displayName = "Button";
-2
View File
@@ -1,2 +0,0 @@
export type { ButtonProps } from "./Button.tsx";
export { Button } from "./Button.tsx";
@@ -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,62 @@
import { useCallback } from "react";
import {
type GuideDialogProps,
GuideDialog as RegistryGuideDialog,
} from "@/components/course-interactions/guide-dialog";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { useDialogStore } from "@/stores";
import { getAppPortalRoot } from "../canvasPortal";
export function GuideDialog({
children,
onFinish,
onSkip,
...props
}: Omit<GuideDialogProps, "actions" | "state" | "viewport"> & {
onFinish?: () => void;
onSkip?: () => void;
}) {
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,5 @@
export type {
GuideDialogProps,
GuideDialogRenderState,
GuideDialogTheme,
} from "@/components/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,62 @@
import { useCallback } from "react";
import {
type GuideSpotlightProps,
GuideSpotlight as RegistryGuideSpotlight,
} from "@/components/course-interactions/guide-spotlight";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { useSpotlightStore } from "@/stores";
import { getAppPortalRoot } from "../canvasPortal";
export function GuideSpotlight({
children,
onFinish,
onSkip,
container = getAppPortalRoot,
viewport,
...props
}: Omit<GuideSpotlightProps, "actions" | "state"> & {
onFinish?: () => void;
onSkip?: () => void;
}) {
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,7 @@
export type {
GuideSpotlightProps,
GuideSpotlightRenderState,
GuideSpotlightTheme,
Rect,
SpotlightLayout,
} from "@/components/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,25 @@
export function LandscapePrompt() {
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>
);
}
@@ -0,0 +1 @@
export { LandscapePrompt } from "./LandscapePrompt";
@@ -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";
+16
View File
@@ -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;
}
+35 -2
View File
@@ -1,2 +1,35 @@
export type { ButtonProps } from "./Button";
export { Button } from "./Button";
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 {
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 { LandscapePrompt } from "./LandscapePrompt";
export { RouteError, RouteFallback } from "./RouteFallback";