feat: 完善课程模板通用能力

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 5 committed 2026-09-08 14:07:42 +08:00
1 parent 1e02c7835e
commit a641d0e2ea
70 files changed
+2868 -205

No files matched your search

@@ -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";
@@ -1,21 +1,15 @@
import { useCallback } from "react";
import {
type GuideDialogProps,
GuideDialog as RegistryGuideDialog,
} from "@/components/course-interactions/guide-dialog";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
import { GuideDialog as RegistryGuideDialog } from "@/course/interactions/guide-dialog";
import { useDialogStore } from "@/stores";
import { getAppPortalRoot } from "../canvasPortal";
import type { GuideDialogProps } from "./GuideDialog.types";
export function GuideDialog({
children,
onFinish,
onSkip,
...props
}: Omit<GuideDialogProps, "actions" | "state" | "viewport"> & {
onFinish?: () => void;
onSkip?: () => void;
}) {
}: GuideDialogProps) {
const {
open,
steps,
@@ -1,5 +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 {
GuideDialogProps,
GuideDialogRenderState,
GuideDialogTheme,
} from "@/components/course-interactions/guide-dialog";
} from "@/course/interactions/guide-dialog";
@@ -1,11 +1,8 @@
import { useCallback } from "react";
import {
type GuideSpotlightProps,
GuideSpotlight as RegistryGuideSpotlight,
} from "@/components/course-interactions/guide-spotlight";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
import { GuideSpotlight as RegistryGuideSpotlight } from "@/course/interactions/guide-spotlight";
import { useSpotlightStore } from "@/stores";
import { getAppPortalRoot } from "../canvasPortal";
import type { GuideSpotlightProps } from "./GuideSpotlight.types";
export function GuideSpotlight({
children,
@@ -14,10 +11,7 @@ export function GuideSpotlight({
container = getAppPortalRoot,
viewport,
...props
}: Omit<GuideSpotlightProps, "actions" | "state"> & {
onFinish?: () => void;
onSkip?: () => void;
}) {
}: GuideSpotlightProps) {
const {
open,
steps,
@@ -1,7 +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 {
GuideSpotlightProps,
GuideSpotlightRenderState,
GuideSpotlightTheme,
Rect,
SpotlightLayout,
} from "@/components/course-interactions/guide-spotlight";
} from "@/course/interactions/guide-spotlight";
@@ -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,67 @@
import { Overlay } from "@/components/ui/overlay";
import { getAppPortalRoot } from "@/course/canvas";
import type { VideoOverlayProps } from "./VideoOverlay.types";
import { VideoPlayer } from "./VideoPlayer";
/** 视频弹层:支持全屏过场与窗口播放,由调用方决定关闭和流程推进策略 */
export function VideoOverlay({
open,
src,
poster,
autoPlay = true,
mode = "fullscreen",
windowWidth = 1200,
windowHeight = 675,
dismissible = false,
lockScroll = true,
skipLabel,
onClose,
onEnded,
onSkip,
}: VideoOverlayProps) {
return (
<Overlay
open={open}
onClose={onClose}
container={getAppPortalRoot}
variant="dim"
closeOnEscape={dismissible}
closeOnBackgroundClick={dismissible}
lockScroll={lockScroll}
className="z-50"
contentClassName="flex items-center justify-center"
>
<div
style={
mode === "window"
? { width: windowWidth, height: windowHeight }
: undefined
}
className={
mode === "window"
? "relative overflow-hidden rounded-lg bg-black shadow-2xl"
: "absolute inset-0 h-full w-full bg-black"
}
>
<VideoPlayer
src={src}
poster={poster}
autoPlay={autoPlay}
mode={mode}
allowFullscreenToggle={false}
onEnded={onEnded}
/>
{skipLabel && (
<button
type="button"
className="absolute right-14 top-10 z-10 rounded-full bg-black/55 px-10 py-4 text-[30px] text-white/90 hover:bg-black/75"
onClick={onSkip}
>
{skipLabel}
</button>
)}
</div>
</Overlay>
);
}
@@ -0,0 +1,28 @@
export interface VideoOverlayProps {
/** 是否显示 */
open: boolean;
/** 可直接交给 video 元素的视频 URL */
src: string;
/** 封面图 */
poster?: string;
/** 自动播放 */
autoPlay?: boolean;
/** 播放模式 */
mode?: "fullscreen" | "window";
/** 窗口模式下的宽度(px) */
windowWidth?: number;
/** 窗口模式下的高度(px) */
windowHeight?: number;
/** 是否允许点击背景或按 Escape 关闭 */
dismissible?: boolean;
/** 弹层打开时是否锁定页面滚动 */
lockScroll?: boolean;
/** 跳过按钮文案,不传则不显示 */
skipLabel?: string;
/** 关闭回调 */
onClose?: () => void;
/** 播放结束回调 */
onEnded?: () => void;
/** 点击跳过回调 */
onSkip?: () => void;
}
@@ -0,0 +1,134 @@
@reference "tailwindcss";
/* 播放器容器 */
.player {
@apply relative flex items-center justify-center overflow-hidden bg-black;
}
.playerFullscreen {
@apply absolute inset-0 h-full w-full;
}
.playerWindow {
@apply relative mx-auto;
}
/* 视频元素 */
.video {
@apply h-full w-full object-contain;
}
/* 中央播放按钮(暂停时显示) */
.centerPlayButton {
@apply absolute flex h-20 w-20 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-opacity duration-300;
}
.centerPlayButton:hover {
@apply bg-black/70;
}
/* 控制条容器 */
.controls {
@apply absolute bottom-0 left-0 right-0 flex flex-col bg-gradient-to-t from-black/80 to-transparent px-4 pb-3 pt-12 transition-opacity duration-300;
}
.controlsHidden {
@apply pointer-events-none opacity-0;
}
/* 小进度条(控件隐藏时显示) */
.miniProgress {
@apply pointer-events-none absolute bottom-0 left-0 right-0 h-1 bg-white/20 opacity-100 transition-opacity duration-300;
}
.miniProgressHidden {
@apply opacity-0;
}
.miniProgressBar {
@apply h-full w-full origin-left bg-red-500;
transform: scaleX(0);
will-change: transform;
}
/* 进度条 */
.progressContainer {
@apply relative mb-2 h-1.5 cursor-pointer rounded-full bg-white/30;
touch-action: none;
}
.progressBar,
.progressBuffered {
@apply absolute left-0 top-0 h-full w-full origin-left rounded-full;
transform: scaleX(0);
will-change: transform;
}
.progressBar {
@apply bg-red-500;
}
.progressBuffered {
@apply bg-white/30;
}
.progressThumb {
@apply absolute left-0 top-1/2 h-3.5 w-3.5 rounded-full bg-red-500 opacity-0 shadow-md transition-opacity duration-200;
transform: translate3d(0, -50%, 0);
will-change: transform;
}
.progressContainer:hover .progressThumb,
.progressDragging .progressThumb {
@apply opacity-100;
}
.progressDragging {
@apply cursor-grabbing;
}
/* 时间显示 */
.timeDisplay {
@apply text-xs text-white/90 tabular-nums;
}
/* 按钮 */
.controlButton {
@apply flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-white/90 transition-colors hover:text-white;
}
.controlButton:disabled {
@apply cursor-not-allowed opacity-50;
}
/* 倍速选择器 */
.rateSelector {
@apply flex cursor-pointer items-center rounded px-2 py-0.5 text-xs text-white/90 transition-colors hover:bg-white/20 hover:text-white;
}
/* 音量滑块 */
.volumeSlider {
@apply h-1 w-16 cursor-pointer appearance-none rounded-full bg-white/30;
}
.volumeSlider::-webkit-slider-thumb {
@apply h-3 w-3 appearance-none rounded-full bg-white shadow-md;
}
.volumeSlider::-moz-range-thumb {
@apply h-3 w-3 rounded-full border-0 bg-white shadow-md;
}
/* 底部控制行 */
.controlRow {
@apply flex items-center gap-2;
}
/* 加载状态 */
.loading {
@apply absolute inset-0 flex items-center justify-center bg-black/30;
}
.spinner {
@apply h-10 w-10 animate-spin rounded-full border-4 border-white/30 border-t-white;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,50 @@
export interface VideoPlayerRef {
play: () => void;
pause: () => void;
seek: (time: number) => void;
setVolume: (volume: number) => void;
setPlaybackRate: (rate: number) => void;
getCurrentTime: () => number;
getDuration: () => number;
}
export interface VideoPlayerProps {
/** 视频源 URL */
src: string;
/** 封面图 */
poster?: string;
/** 自动播放 */
autoPlay?: boolean;
/** 循环播放 */
loop?: boolean;
/** 默认静音 */
muted?: boolean;
/** 预加载策略 */
preload?: "none" | "metadata" | "auto";
/** 初始播放速度 */
playbackRate?: number;
/** 显示模式:全屏覆盖或窗口 */
mode?: "fullscreen" | "window";
/** 是否允许在全屏覆盖与窗口模式之间切换 */
allowFullscreenToggle?: boolean;
/** 自定义 className */
className?: string;
// 事件回调
/** 播放开始时触发 */
onPlay?: () => void;
/** 暂停时触发 */
onPause?: () => void;
/** 播放结束时触发 */
onEnded?: () => void;
/** 时间更新时触发 */
onTimeUpdate?: (currentTime: number) => void;
/** 进度加载时触发 */
onProgress?: (buffered: TimeRanges | null) => void;
/** 音量变化时触发 */
onVolumeChange?: (volume: number, muted: boolean) => void;
/** 播放速度变化时触发 */
onPlaybackRateChange?: (rate: number) => void;
/** 全屏状态变化时触发 */
onFullscreenChange?: (isFullscreen: boolean) => void;
}
+4
View File
@@ -0,0 +1,4 @@
export { VideoOverlay } from "./VideoOverlay";
export type { VideoOverlayProps } from "./VideoOverlay.types";
export { VideoPlayer } from "./VideoPlayer";
export type { VideoPlayerProps, VideoPlayerRef } from "./VideoPlayer.types";
-16
View File
@@ -1,16 +0,0 @@
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;
}
+15
View File
@@ -15,6 +15,13 @@ export type {
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,
@@ -31,4 +38,12 @@ export type {
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";