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

+16 -10
View File
@@ -1,10 +1,14 @@
import { useEffect } from "react";
import { Outlet } from "react-router-dom";
import { ToastProvider, ToastViewport } from "@/components/ui/toast";
import { CourseCanvas, useCourseCanvas } from "@/course-runtime/canvas";
import { OrientationGuard } from "@/course-runtime/orientation";
import {
CourseCanvas,
registerAppPortalRoot,
useCourseCanvas,
} from "@/course/canvas";
import { OrientationGuard } from "@/course/orientation";
import { GameAudioProvider } from "@/utils/audio";
import { Navigation } from "./components/business";
import { registerAppPortalRoot } from "./components/common/canvasPortal";
function CourseShell() {
const { portalContainer } = useCourseCanvas();
@@ -12,13 +16,15 @@ function CourseShell() {
useEffect(() => registerAppPortalRoot(portalContainer), [portalContainer]);
return (
<ToastProvider>
<Navigation />
<main className="flex-1 overflow-auto p-8">
<Outlet />
</main>
<ToastViewport container={portalContainer} />
</ToastProvider>
<GameAudioProvider>
<ToastProvider>
<Navigation />
<main className="flex-1 overflow-auto p-8">
<Outlet />
</main>
<ToastViewport container={portalContainer} />
</ToastProvider>
</GameAudioProvider>
);
}
@@ -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";
+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";
File renamed without changes.
@@ -5,3 +5,4 @@ export type {
CourseCanvasProps,
CourseCanvasViewport,
} from "./CourseCanvas.types";
export { getAppPortalRoot, registerAppPortalRoot } from "./canvasPortal";
@@ -128,10 +128,21 @@ export function GuideDialog({
}
const context = getCoordinateContext({ container, viewport });
const panelRect = panelRef.current?.getBoundingClientRect();
const panelElement = panelRef.current;
const panelRect = panelElement?.getBoundingClientRect();
const panelSize = {
width: panelRect ? panelRect.width / context.scale : panelMaxWidth,
height: panelRect ? panelRect.height / context.scale : 220,
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",
@@ -239,7 +250,7 @@ export function GuideDialog({
ref={panelRef}
aria-hidden="true"
className={cn(
styles.panel,
panelClassName,
styles.measurePanel,
theme?.panelClassName,
)}
@@ -149,6 +149,7 @@
@media (prefers-reduced-motion: reduce) {
.mask,
.highlight,
.panelContainer,
.panel,
.missingPanel {
animation: none;
@@ -312,10 +312,21 @@ export function GuideSpotlight({
}
const nextHighlightRect = getHighlightRect(target, padding, context);
const panelRect = panelRef.current?.getBoundingClientRect();
const panelElement = panelRef.current;
const panelRect = panelElement?.getBoundingClientRect();
const panelSize = {
width: panelRect ? panelRect.width / context.scale : panelMaxWidth,
height: panelRect ? panelRect.height / context.scale : 180,
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,
@@ -503,7 +514,7 @@ export function GuideSpotlight({
ref={panelRef}
aria-hidden="true"
className={cn(
styles.panel,
panelClassName,
styles.measurePanel,
theme?.panelClassName,
step.panelClassName,
File renamed without changes.
+3 -3
View File
@@ -1,12 +1,12 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useCourseCanvas } from "@/course/canvas";
import {
DragDropProvider,
type DragDropProviderProps,
Draggable,
Droppable,
} from "@/components/course-interactions/drag-drop";
import { Button } from "@/components/ui/button";
import { useCourseCanvas } from "@/course-runtime/canvas";
} from "@/course/interactions/drag-drop";
import { cn } from "@/lib/cn";
import type {
DragDemoItem,
+1 -2
View File
@@ -1,11 +1,10 @@
import { useRef, useState } from "react";
import { GuideSpotlight } from "@/components/common";
import { getAppPortalRoot } from "@/components/common/canvasPortal";
import { Button } from "@/components/ui/button";
import { Overlay, type OverlayProps } from "@/components/ui/overlay";
import { Portal } from "@/components/ui/portal";
import { useToast } from "@/components/ui/toast";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
import { useSpotlightStore } from "@/stores";
import { DragDropDemo } from "./DragDropDemo";
+13
View File
@@ -1,3 +1,8 @@
import type {
GuideDialogRenderState,
GuideSpotlightRenderState,
} from "@/components/common";
export type CourseResourceCategory = "policy" | "practice" | "support";
export interface CourseResource {
@@ -20,3 +25,11 @@ export interface CourseFlowProgress {
completed: boolean;
updatedAt: string;
}
export interface CourseDemoGuideDialogPanelProps {
dialog: GuideDialogRenderState;
}
export interface CourseDemoGuideSpotlightPanelProps {
spotlight: GuideSpotlightRenderState;
}
@@ -1,8 +1,4 @@
import type { GuideDialogRenderState } from "@/components/common";
interface CourseDemoGuideDialogPanelProps {
dialog: GuideDialogRenderState;
}
import type { CourseDemoGuideDialogPanelProps } from "./CourseDemo.types";
function DialogProgress({ dialog }: CourseDemoGuideDialogPanelProps) {
return (
@@ -1,8 +1,5 @@
import type { GuideSpotlightRenderState } from "@/components/common";
interface CourseDemoGuideSpotlightPanelProps {
spotlight: GuideSpotlightRenderState;
}
import { cn } from "@/lib/cn";
import type { CourseDemoGuideSpotlightPanelProps } from "./CourseDemo.types";
function GuideActions({
spotlight,
@@ -31,7 +28,10 @@ function GuideActions({
<button
type="button"
onClick={actions.primary}
className={`rounded-full px-5 py-2 text-sm font-black text-white shadow-xl transition ${className}`}
className={cn(
"rounded-full px-5 py-2 text-sm font-black text-white shadow-xl transition",
className,
)}
>
{labels.primary}
</button>
+5 -6
View File
@@ -1,15 +1,14 @@
import { useEffect, useState } from "react";
import { getAppPortalRoot } from "@/components/common/canvasPortal";
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 "@/components/course-interactions/drag-drop";
import { Button } from "@/components/ui/button";
import { Overlay } from "@/components/ui/overlay";
import { useToast } from "@/components/ui/toast";
import { useCourseCanvas } from "@/course-runtime/canvas";
} from "@/course/interactions/drag-drop";
import { cn } from "@/lib/cn";
import { useUserStore } from "@/stores";
import { getUserStorage, setUserStorage } from "@/utils/yiban";
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef } from "react";
import type { GameAudioManager } from "./audio.types";
import { GameAudioContext } from "./audioContext";
import { createAudioManager } from "./audioManager";
import type { GameAudioProviderProps } from "./GameAudioProvider.types";
import { createHowlerAudioEngine } from "./howlerEngine";
const activeManagers = new Set<GameAudioManager>();
export function GameAudioProvider({
children,
manager: injectedManager,
}: GameAudioProviderProps) {
const managerRef = useRef<GameAudioManager | null>(null);
if (!managerRef.current) {
managerRef.current =
injectedManager ??
createAudioManager({ engine: createHowlerAudioEngine() });
}
const manager = managerRef.current;
useEffect(() => {
activeManagers.add(manager);
const handleVisibilityChange = () => {
manager.handleVisibilityChange(document.hidden);
};
const handlePageHide = () => manager.stopAll();
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("pagehide", handlePageHide);
handleVisibilityChange();
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("pagehide", handlePageHide);
activeManagers.delete(manager);
manager.stopAll();
};
}, [manager]);
return (
<GameAudioContext.Provider value={manager}>
{children}
</GameAudioContext.Provider>
);
}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
for (const manager of activeManagers) manager.stopAll();
activeManagers.clear();
});
}
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import type { GameAudioManager } from "./audio.types";
export interface GameAudioProviderProps {
children: ReactNode;
/** 测试或特殊运行环境可注入自定义 manager。 */
manager?: GameAudioManager;
}
+104
View File
@@ -0,0 +1,104 @@
export type AudioChannel = "voice" | "bgm" | "sfx";
export type AudioSource = string | readonly string[];
export interface AudioPlayOptions {
loop?: boolean;
volume?: number;
rate?: number;
/** 长音频使用 HTML5 Audio 流式加载,短音频默认使用 Web Audio。 */
stream?: boolean;
onEnd?: () => void;
onError?: (result: AudioPlayFailureResult) => void;
}
export type AudioPlayFailureResult =
| { status: "blocked"; error: unknown }
| { status: "load-error"; error: unknown }
| { status: "play-error"; error: unknown };
export type AudioPlayResult =
| { status: "playing" }
| { status: "cancelled" }
| AudioPlayFailureResult;
export type AudioPreloadResult =
| { status: "loaded" }
| { status: "cancelled" }
| { status: "load-error"; error: unknown };
export interface AudioPlaybackHandle {
started: Promise<AudioPlayResult>;
stop: () => void;
}
export interface AudioPreloadHandle {
ready: Promise<AudioPreloadResult>;
release: () => void;
}
export interface AudioPageScope {
readonly id: number;
dispose: () => void;
}
export interface AudioChannelController {
play: (
source: AudioSource,
options?: AudioPlayOptions,
) => AudioPlaybackHandle;
stop: () => void;
}
export interface GameAudioManager {
readonly voice: AudioChannelController;
readonly bgm: AudioChannelController;
readonly sfx: AudioChannelController;
enterPage: (key?: string) => AudioPageScope;
preload: (
source: AudioSource,
options?: Pick<AudioPlayOptions, "stream">,
) => AudioPreloadHandle;
stopPage: () => void;
stopAll: () => void;
setMuted: (muted: boolean) => void;
setVolume: (volume: number) => void;
handleVisibilityChange: (hidden: boolean) => void;
}
export interface AudioEngineClipOptions {
source: readonly string[];
loop: boolean;
volume: number;
rate: number;
stream: boolean;
}
export interface AudioEngineClip {
load: () => Promise<void>;
play: () => Promise<void>;
pause: () => void;
resume: () => Promise<void>;
stop: () => void;
unload: () => void;
configure: (
options: Omit<AudioEngineClipOptions, "source" | "stream">,
) => void;
onEnd: (listener: () => void) => () => void;
}
export interface AudioEngineFactory {
createClip: (options: AudioEngineClipOptions) => AudioEngineClip;
setMuted: (muted: boolean) => void;
setVolume: (volume: number) => void;
}
export interface CreateAudioManagerOptions {
engine: AudioEngineFactory;
channelDefaults?: Partial<
Record<
AudioChannel,
Pick<AudioPlayOptions, "loop" | "volume" | "rate" | "stream">
>
>;
}
+4
View File
@@ -0,0 +1,4 @@
import { createContext } from "react";
import type { GameAudioManager } from "./audio.types";
export const GameAudioContext = createContext<GameAudioManager | null>(null);
+483
View File
@@ -0,0 +1,483 @@
import type {
AudioChannel,
AudioChannelController,
AudioEngineClip,
AudioPageScope,
AudioPlaybackHandle,
AudioPlayFailureResult,
AudioPlayOptions,
AudioPlayResult,
AudioPreloadHandle,
AudioPreloadResult,
AudioSource,
CreateAudioManagerOptions,
GameAudioManager,
} from "./audio.types";
const CHANNELS: readonly AudioChannel[] = ["voice", "bgm", "sfx"];
const DEFAULT_OPTIONS: Record<
AudioChannel,
Required<Pick<AudioPlayOptions, "loop" | "volume" | "rate" | "stream">>
> = {
voice: { loop: false, volume: 1, rate: 1, stream: false },
bgm: { loop: true, volume: 0.45, rate: 1, stream: true },
sfx: { loop: false, volume: 0.8, rate: 1, stream: false },
};
interface PageScopeState {
id: number;
key?: string;
active: boolean;
}
interface ChannelSlot {
generation: number;
current?: PlaybackRecord;
}
interface PlaybackRecord {
id: number;
channel: AudioChannel;
generation: number;
scopeId: number;
clip: AudioEngineClip;
state: "loading" | "playing" | "visibility-paused";
cleaned: boolean;
detachEnd: () => void;
resolveStarted: (result: AudioPlayResult) => void;
startedSettled: boolean;
onEnd?: () => void;
onError?: (result: AudioPlayFailureResult) => void;
}
interface PreloadRecord {
id: number;
key: string;
scopeId: number;
clip: AudioEngineClip;
ready: Promise<AudioPreloadResult>;
released: boolean;
claimed: boolean;
}
function normalizeSource(source: AudioSource): readonly string[] {
const values = typeof source === "string" ? [source] : source;
return values.map((value) => value.trim()).filter(Boolean);
}
function clampVolume(volume: number) {
return Math.min(1, Math.max(0, volume));
}
function createPreloadKey(source: readonly string[], stream: boolean) {
return `${stream ? "stream" : "buffer"}:${JSON.stringify(source)}`;
}
function isPlaybackBlocked(error: unknown) {
if (typeof error === "object" && error !== null && "name" in error) {
const name = String(error.name);
if (name === "NotAllowedError") return true;
}
const message = error instanceof Error ? error.message : String(error);
return /notallowed|autoplay|user (?:gesture|interaction)/i.test(message);
}
export class AudioManager implements GameAudioManager {
readonly voice: AudioChannelController;
readonly bgm: AudioChannelController;
readonly sfx: AudioChannelController;
private readonly slots: Record<AudioChannel, ChannelSlot> = {
voice: { generation: 0 },
bgm: { generation: 0 },
sfx: { generation: 0 },
};
private readonly scopes = new Map<number, PageScopeState>();
private readonly preloads = new Set<PreloadRecord>();
private activeScope?: PageScopeState;
private nextScopeId = 1;
private nextRequestId = 1;
private hidden = false;
constructor(private readonly options: CreateAudioManagerOptions) {
this.voice = this.createChannelController("voice");
this.bgm = this.createChannelController("bgm");
this.sfx = this.createChannelController("sfx");
}
enterPage = (key?: string): AudioPageScope => {
this.activeScope?.active && this.disposeScope(this.activeScope.id);
const scope: PageScopeState = {
id: this.nextScopeId++,
key,
active: true,
};
this.scopes.set(scope.id, scope);
this.activeScope = scope;
return {
id: scope.id,
dispose: () => this.disposeScope(scope.id),
};
};
preload = (
source: AudioSource,
options: Pick<AudioPlayOptions, "stream"> = {},
): AudioPreloadHandle => {
const normalizedSource = normalizeSource(source);
const scope = this.activeScope;
if (!scope?.active || normalizedSource.length === 0) {
return {
ready: Promise.resolve({ status: "cancelled" }),
release: () => undefined,
};
}
const stream = options.stream ?? false;
const clip = this.options.engine.createClip({
source: normalizedSource,
loop: false,
volume: 1,
rate: 1,
stream,
});
const record: PreloadRecord = {
id: this.nextRequestId++,
key: createPreloadKey(normalizedSource, stream),
scopeId: scope.id,
clip,
ready: Promise.resolve({ status: "cancelled" }),
released: false,
claimed: false,
};
record.ready = clip.load().then(
(): AudioPreloadResult =>
record.released || !this.isScopeActive(record.scopeId)
? { status: "cancelled" }
: { status: "loaded" },
(error: unknown): AudioPreloadResult => {
if (record.released || !this.isScopeActive(record.scopeId)) {
return { status: "cancelled" };
}
this.releasePreload(record);
return { status: "load-error", error };
},
);
this.preloads.add(record);
return {
ready: record.ready,
release: () => this.releasePreload(record),
};
};
stopPage = () => {
const scope = this.activeScope;
if (scope?.active) this.stopOwnedByScope(scope.id);
};
stopAll = () => {
for (const channel of CHANNELS) this.stopChannel(channel);
for (const preload of [...this.preloads]) this.releasePreload(preload);
};
setMuted = (muted: boolean) => {
this.options.engine.setMuted(muted);
};
setVolume = (volume: number) => {
this.options.engine.setVolume(clampVolume(volume));
};
handleVisibilityChange = (hidden: boolean) => {
if (this.hidden === hidden) return;
this.hidden = hidden;
if (hidden) {
this.stopChannel("voice");
this.stopChannel("sfx");
const bgm = this.slots.bgm.current;
if (!bgm) return;
if (bgm.state === "playing") {
bgm.clip.pause();
bgm.state = "visibility-paused";
} else if (bgm.state === "loading") {
this.stopChannel("bgm");
}
return;
}
const bgm = this.slots.bgm.current;
if (!bgm || bgm.state !== "visibility-paused" || !this.isCurrent(bgm)) {
return;
}
void this.resumeAfterVisibility(bgm);
};
private createChannelController(
channel: AudioChannel,
): AudioChannelController {
return {
play: (source, options) => this.play(channel, source, options),
stop: () => this.stopChannel(channel),
};
}
private play(
channel: AudioChannel,
source: AudioSource,
options: AudioPlayOptions = {},
): AudioPlaybackHandle {
const normalizedSource = normalizeSource(source);
const scope = this.activeScope;
if (!scope?.active || this.hidden || normalizedSource.length === 0) {
return {
started: Promise.resolve({ status: "cancelled" }),
stop: () => undefined,
};
}
const defaults = {
...DEFAULT_OPTIONS[channel],
...this.options.channelDefaults?.[channel],
};
const resolved = {
loop: options.loop ?? defaults.loop ?? false,
volume: clampVolume(options.volume ?? defaults.volume ?? 1),
rate: options.rate ?? defaults.rate ?? 1,
stream: options.stream ?? defaults.stream ?? false,
};
const slot = this.slots[channel];
slot.generation += 1;
if (slot.current)
this.cleanupPlayback(slot.current, true, { status: "cancelled" });
const preload = this.claimPreload(
normalizedSource,
resolved.stream,
scope.id,
);
const clip =
preload?.clip ??
this.options.engine.createClip({
source: normalizedSource,
...resolved,
});
if (preload) {
clip.configure({
loop: resolved.loop,
volume: resolved.volume,
rate: resolved.rate,
});
}
let resolveStarted: (result: AudioPlayResult) => void = () => undefined;
const started = new Promise<AudioPlayResult>((resolve) => {
resolveStarted = resolve;
});
const record: PlaybackRecord = {
id: this.nextRequestId++,
channel,
generation: slot.generation,
scopeId: scope.id,
clip,
state: "loading",
cleaned: false,
detachEnd: () => undefined,
resolveStarted,
startedSettled: false,
onEnd: options.onEnd,
onError: options.onError,
};
record.detachEnd = clip.onEnd(() => this.handleEnd(record));
slot.current = record;
void this.startPlayback(record, preload?.ready);
return {
started,
stop: () => {
if (!this.isCurrent(record)) return;
this.slots[channel].generation += 1;
this.cleanupPlayback(record, true, { status: "cancelled" });
},
};
}
private async startPlayback(
record: PlaybackRecord,
preloaded?: Promise<AudioPreloadResult>,
) {
try {
if (preloaded) {
const preloadResult = await preloaded;
if (preloadResult.status === "load-error") throw preloadResult.error;
if (preloadResult.status === "cancelled") {
this.cleanupPlayback(record, true, { status: "cancelled" });
return;
}
} else {
await record.clip.load();
}
} catch (error) {
this.failPlayback(record, { status: "load-error", error });
return;
}
if (!this.isCurrent(record) || this.hidden) {
this.cleanupPlayback(record, true, { status: "cancelled" });
return;
}
try {
await record.clip.play();
} catch (error) {
this.failPlayback(record, {
status: isPlaybackBlocked(error) ? "blocked" : "play-error",
error,
});
return;
}
if (!this.isCurrent(record) || this.hidden) {
this.cleanupPlayback(record, true, { status: "cancelled" });
return;
}
record.state = "playing";
this.settleStarted(record, { status: "playing" });
}
private async resumeAfterVisibility(record: PlaybackRecord) {
try {
await record.clip.resume();
} catch (error) {
this.failPlayback(record, {
status: isPlaybackBlocked(error) ? "blocked" : "play-error",
error,
});
return;
}
if (!this.hidden && this.isCurrent(record)) {
record.state = "playing";
} else {
this.cleanupPlayback(record, true, { status: "cancelled" });
}
}
private handleEnd(record: PlaybackRecord) {
if (!this.isCurrent(record)) return;
const onEnd = record.onEnd;
this.cleanupPlayback(record, false);
onEnd?.();
}
private failPlayback(record: PlaybackRecord, result: AudioPlayFailureResult) {
if (!this.isCurrent(record)) {
this.cleanupPlayback(record, true, { status: "cancelled" });
return;
}
const onError = record.onError;
this.cleanupPlayback(record, true, result);
onError?.(result);
}
private settleStarted(record: PlaybackRecord, result: AudioPlayResult) {
if (record.startedSettled) return;
record.startedSettled = true;
record.resolveStarted(result);
}
private cleanupPlayback(
record: PlaybackRecord,
stop: boolean,
result?: AudioPlayResult,
) {
if (record.cleaned) return;
record.cleaned = true;
record.detachEnd();
if (stop) record.clip.stop();
record.clip.unload();
const slot = this.slots[record.channel];
if (slot.current === record) slot.current = undefined;
if (result) this.settleStarted(record, result);
}
private stopChannel(channel: AudioChannel) {
const slot = this.slots[channel];
slot.generation += 1;
if (slot.current) {
this.cleanupPlayback(slot.current, true, { status: "cancelled" });
}
}
private disposeScope(scopeId: number) {
const scope = this.scopes.get(scopeId);
if (!scope?.active) return;
scope.active = false;
this.stopOwnedByScope(scopeId);
this.scopes.delete(scopeId);
if (this.activeScope?.id === scopeId) this.activeScope = undefined;
}
private stopOwnedByScope(scopeId: number) {
for (const channel of CHANNELS) {
const record = this.slots[channel].current;
if (record?.scopeId !== scopeId) continue;
this.slots[channel].generation += 1;
this.cleanupPlayback(record, true, { status: "cancelled" });
}
for (const preload of [...this.preloads]) {
if (preload.scopeId === scopeId) this.releasePreload(preload);
}
}
private isScopeActive(scopeId: number) {
return this.scopes.get(scopeId)?.active === true;
}
private isCurrent(record: PlaybackRecord) {
const slot = this.slots[record.channel];
return (
!record.cleaned &&
this.isScopeActive(record.scopeId) &&
slot.generation === record.generation &&
slot.current === record
);
}
private claimPreload(
source: readonly string[],
stream: boolean,
scopeId: number,
) {
const key = createPreloadKey(source, stream);
const preload = [...this.preloads].find(
(candidate) =>
candidate.key === key &&
candidate.scopeId === scopeId &&
!candidate.released &&
!candidate.claimed,
);
if (!preload) return undefined;
preload.claimed = true;
this.preloads.delete(preload);
return preload;
}
private releasePreload(record: PreloadRecord) {
if (record.released || record.claimed) return;
record.released = true;
this.preloads.delete(record);
record.clip.stop();
record.clip.unload();
}
}
export function createAudioManager(options: CreateAudioManagerOptions) {
return new AudioManager(options);
}
+151
View File
@@ -0,0 +1,151 @@
import { Howl, Howler } from "howler";
import type {
AudioEngineClip,
AudioEngineClipOptions,
AudioEngineFactory,
} from "./audio.types";
function toError(value: unknown, fallback: string) {
return value instanceof Error ? value : new Error(String(value || fallback));
}
class HowlerAudioClip implements AudioEngineClip {
private readonly howl: Howl;
private readonly endListeners = new Set<() => void>();
private readonly pendingRejectors = new Set<(error: Error) => void>();
private loadPromise?: Promise<void>;
private soundId?: number;
private loop: boolean;
private disposed = false;
constructor(options: AudioEngineClipOptions) {
this.loop = options.loop;
this.howl = new Howl({
src: [...options.source],
autoplay: false,
html5: options.stream,
loop: options.loop,
preload: false,
rate: options.rate,
volume: options.volume,
});
this.howl.on("end", this.handleEnd);
}
load = () => {
if (this.disposed) return Promise.reject(new Error("Audio clip unloaded"));
if (this.howl.state() === "loaded") return Promise.resolve();
if (this.loadPromise) return this.loadPromise;
this.loadPromise = new Promise<void>((resolve, reject) => {
const rejectPending = (error: Error) => {
cleanup();
reject(error);
};
const handleLoad = () => {
cleanup();
resolve();
};
const handleLoadError = (_id: number, error: unknown) => {
rejectPending(toError(error, "Audio failed to load"));
};
const cleanup = () => {
this.pendingRejectors.delete(rejectPending);
this.howl.off("load", handleLoad);
this.howl.off("loaderror", handleLoadError);
};
this.pendingRejectors.add(rejectPending);
this.howl.once("load", handleLoad);
this.howl.once("loaderror", handleLoadError);
this.howl.load();
});
return this.loadPromise;
};
play = () => this.startSound();
pause = () => {
if (this.soundId !== undefined) this.howl.pause(this.soundId);
};
resume = () => {
if (this.soundId === undefined) {
return Promise.reject(new Error("Audio has not started"));
}
return this.startSound(this.soundId);
};
stop = () => {
if (this.soundId !== undefined) this.howl.stop(this.soundId);
};
unload = () => {
if (this.disposed) return;
this.disposed = true;
const error = new Error("Audio clip unloaded");
for (const reject of [...this.pendingRejectors]) reject(error);
this.pendingRejectors.clear();
this.endListeners.clear();
this.howl.off();
this.howl.unload();
};
configure = (options: Omit<AudioEngineClipOptions, "source" | "stream">) => {
if (this.disposed) return;
this.loop = options.loop;
this.howl.loop(options.loop);
this.howl.volume(options.volume);
this.howl.rate(options.rate);
};
onEnd = (listener: () => void) => {
this.endListeners.add(listener);
return () => this.endListeners.delete(listener);
};
private startSound(soundId?: number) {
if (this.disposed) return Promise.reject(new Error("Audio clip unloaded"));
return new Promise<void>((resolve, reject) => {
let expectedId = soundId;
const rejectPending = (error: Error) => {
cleanup();
reject(error);
};
const handlePlay = (playedId: number) => {
if (expectedId !== undefined && playedId !== expectedId) return;
cleanup();
resolve();
};
const handlePlayError = (playedId: number, error: unknown) => {
if (expectedId !== undefined && playedId !== expectedId) return;
rejectPending(toError(error, "Audio failed to play"));
};
const cleanup = () => {
this.pendingRejectors.delete(rejectPending);
this.howl.off("play", handlePlay);
this.howl.off("playerror", handlePlayError);
};
this.pendingRejectors.add(rejectPending);
this.howl.on("play", handlePlay);
this.howl.on("playerror", handlePlayError);
expectedId = this.howl.play(soundId);
this.soundId = expectedId;
});
}
private handleEnd = (soundId: number) => {
if (this.soundId !== undefined && soundId !== this.soundId) return;
// 循环音频每次回到起点都会触发 end,不应视作播放结束
if (this.loop) return;
for (const listener of this.endListeners) listener();
};
}
export const createHowlerAudioEngine = (): AudioEngineFactory => ({
createClip: (options) => new HowlerAudioClip(options),
setMuted: (muted) => Howler.mute(muted),
setVolume: (volume) => Howler.volume(volume),
});
+21
View File
@@ -0,0 +1,21 @@
export type {
AudioChannel,
AudioChannelController,
AudioEngineClip,
AudioEngineClipOptions,
AudioEngineFactory,
AudioPageScope,
AudioPlaybackHandle,
AudioPlayFailureResult,
AudioPlayOptions,
AudioPlayResult,
AudioPreloadHandle,
AudioPreloadResult,
AudioSource,
CreateAudioManagerOptions,
GameAudioManager,
} from "./audio.types";
export { AudioManager, createAudioManager } from "./audioManager";
export { GameAudioProvider } from "./GameAudioProvider";
export type { GameAudioProviderProps } from "./GameAudioProvider.types";
export { useGameAudio } from "./useGameAudio";
+10
View File
@@ -0,0 +1,10 @@
import { useContext } from "react";
import { GameAudioContext } from "./audioContext";
export function useGameAudio() {
const manager = useContext(GameAudioContext);
if (!manager) {
throw new Error("useGameAudio must be used within GameAudioProvider");
}
return manager;
}
+2
View File
@@ -1,4 +1,6 @@
export * from "./assets";
export * from "./audio";
export * from "./lazyRoute";
export * from "./motion";
export * from "./tailwind";
export * from "./yiban";
+29
View File
@@ -0,0 +1,29 @@
import { useGSAP } from "@gsap/react";
import { gsap } from "gsap";
import { useEffect, useState } from "react";
// GSAP 仅用于时间轴编排、播放控制和 CSS 难以清晰表达的课件动画。
// 普通状态过渡、hover 和简单入场动画优先使用 CSS/Tailwind。
gsap.registerPlugin(useGSAP);
// 项目统一缓动,对齐现有 CSS cubic-bezier(0.16, 1, 0.3, 1)
export const EASE_OUT = "power3.out";
export const EASE_IN_OUT = "power2.inOut";
export { gsap, useGSAP };
// 响应 prefers-reduced-motion,供动画降级
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = () => setReduced(mql.matches);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
return reduced;
}