feat:init
This commit is contained in:
1 parent
53d0eeac6b
commit
ae0c050010
494 files changed
+17701
-2318
No files matched your search
@@ -1,147 +0,0 @@
|
||||
# Alert 组件使用说明
|
||||
|
||||
## 简介
|
||||
|
||||
`Alert` 是一个通用的提示框组件,类似于 Ant Design 的 Alert,用于向用户显示重要的提示信息。
|
||||
|
||||
## 特性
|
||||
|
||||
- 🎨 支持 4 种类型:`warning`、`info`、`success`、`error`
|
||||
- 🎭 可选显示/隐藏图标
|
||||
- ❌ 可选是否可关闭
|
||||
- 🎯 居中模态显示
|
||||
- ✨ 优雅的动画效果
|
||||
|
||||
## 基本用法
|
||||
|
||||
```tsx
|
||||
import { Alert } from "@/components";
|
||||
|
||||
function MyComponent() {
|
||||
const [showAlert, setShowAlert] = useState(false);
|
||||
|
||||
return (
|
||||
<Alert
|
||||
isOpen={showAlert}
|
||||
onClose={() => setShowAlert(false)}
|
||||
message="这是一条提示信息"
|
||||
type="warning"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 | 必填 |
|
||||
| -------- | -------------- | --------------------------------------------- | ----------- | ---- |
|
||||
| isOpen | 是否显示 Alert | `boolean` | - | ✅ |
|
||||
| onClose | 关闭回调函数 | `() => void` | - | ✅ |
|
||||
| message | 提示内容 | `string \| ReactNode` | - | ✅ |
|
||||
| type | 提示类型 | `'warning' \| 'info' \| 'success' \| 'error'` | `'warning'` | ❌ |
|
||||
| showIcon | 是否显示图标 | `boolean` | `true` | ❌ |
|
||||
| closable | 是否可关闭 | `boolean` | `true` | ❌ |
|
||||
|
||||
## 示例
|
||||
|
||||
### 警告提示
|
||||
|
||||
```tsx
|
||||
<Alert
|
||||
isOpen={showWarning}
|
||||
onClose={() => setShowWarning(false)}
|
||||
message="该名师工作室数字睿教正在筹划中"
|
||||
type="warning"
|
||||
/>
|
||||
```
|
||||
|
||||
### 成功提示
|
||||
|
||||
```tsx
|
||||
<Alert
|
||||
isOpen={showSuccess}
|
||||
onClose={() => setShowSuccess(false)}
|
||||
message="操作成功!"
|
||||
type="success"
|
||||
/>
|
||||
```
|
||||
|
||||
### 错误提示
|
||||
|
||||
```tsx
|
||||
<Alert
|
||||
isOpen={showError}
|
||||
onClose={() => setShowError(false)}
|
||||
message="操作失败,请稍后重试"
|
||||
type="error"
|
||||
/>
|
||||
```
|
||||
|
||||
### 信息提示
|
||||
|
||||
```tsx
|
||||
<Alert
|
||||
isOpen={showInfo}
|
||||
onClose={() => setShowInfo(false)}
|
||||
message="这是一条普通信息"
|
||||
type="info"
|
||||
/>
|
||||
```
|
||||
|
||||
### 自定义内容
|
||||
|
||||
```tsx
|
||||
<Alert
|
||||
isOpen={showCustom}
|
||||
onClose={() => setShowCustom(false)}
|
||||
message={
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">自定义标题</h3>
|
||||
<p className="text-sm mt-1">这是自定义的内容</p>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
/>
|
||||
```
|
||||
|
||||
### 不可关闭的提示
|
||||
|
||||
```tsx
|
||||
<Alert
|
||||
isOpen={showPermanent}
|
||||
onClose={() => {}}
|
||||
message="这是一条不可关闭的提示"
|
||||
type="info"
|
||||
closable={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 无图标提示
|
||||
|
||||
```tsx
|
||||
<Alert
|
||||
isOpen={showNoIcon}
|
||||
onClose={() => setShowNoIcon(false)}
|
||||
message="这是一条没有图标的提示"
|
||||
type="info"
|
||||
showIcon={false}
|
||||
/>
|
||||
```
|
||||
|
||||
## 样式说明
|
||||
|
||||
每种类型都有对应的颜色主题:
|
||||
|
||||
- **warning**: 黄色系(适用于警告信息)
|
||||
- **info**: 蓝色系(适用于一般信息)
|
||||
- **success**: 绿色系(适用于成功提示)
|
||||
- **error**: 红色系(适用于错误提示)
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. Alert 组件会创建一个全屏半透明遮罩层
|
||||
2. 点击遮罩层可以关闭 Alert(当 `closable` 为 `true` 时)
|
||||
3. Alert 的 z-index 为 50,确保在大多数元素之上
|
||||
4. 支持动画效果,通过 `animate-scale-in` 类实现
|
||||
@@ -1,152 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type AlertType = "warning" | "info" | "success" | "error";
|
||||
|
||||
interface AlertProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
message: string | ReactNode;
|
||||
type?: AlertType;
|
||||
showIcon?: boolean;
|
||||
closable?: boolean;
|
||||
}
|
||||
|
||||
const alertStyles: Record<
|
||||
AlertType,
|
||||
{
|
||||
bg: string;
|
||||
border: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
}
|
||||
> = {
|
||||
warning: {
|
||||
bg: "bg-yellow-50",
|
||||
border: "border-yellow-400",
|
||||
text: "text-yellow-800",
|
||||
icon: "text-yellow-400",
|
||||
},
|
||||
info: {
|
||||
bg: "bg-blue-50",
|
||||
border: "border-blue-400",
|
||||
text: "text-blue-800",
|
||||
icon: "text-blue-400",
|
||||
},
|
||||
success: {
|
||||
bg: "bg-green-50",
|
||||
border: "border-green-400",
|
||||
text: "text-green-800",
|
||||
icon: "text-green-400",
|
||||
},
|
||||
error: {
|
||||
bg: "bg-red-50",
|
||||
border: "border-red-400",
|
||||
text: "text-red-800",
|
||||
icon: "text-red-400",
|
||||
},
|
||||
};
|
||||
|
||||
const icons = {
|
||||
warning: (
|
||||
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
success: (
|
||||
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export const Alert = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
message,
|
||||
type = "warning",
|
||||
showIcon = true,
|
||||
closable = true,
|
||||
}: AlertProps) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const styles = alertStyles[type];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 半透明背景 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={closable ? onClose : undefined}
|
||||
/>
|
||||
|
||||
{/* Alert 提示框 */}
|
||||
<div
|
||||
className={`relative ${styles.bg} border-l-4 ${styles.border} px-6 py-4 rounded-md shadow-lg max-w-md mx-4 animate-scale-in`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{/* 图标 */}
|
||||
{showIcon && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className={styles.icon}>{icons[type]}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容 */}
|
||||
<div className={`${showIcon ? "ml-3" : ""} flex-1`}>
|
||||
{typeof message === "string" ? (
|
||||
<p className={`text-lg font-medium ${styles.text}`}>{message}</p>
|
||||
) : (
|
||||
message
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
{closable && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`ml-4 flex-shrink-0 ${styles.icon
|
||||
.replace("text-", "text-")
|
||||
.replace("400", "600")} hover:${styles.icon
|
||||
.replace("text-", "text-")
|
||||
.replace("400", "800")} transition-colors`}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface BackgroundImageProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
src: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
backgroundSize?: "cover" | "contain" | "auto" | string;
|
||||
backgroundPosition?: string;
|
||||
backgroundRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y";
|
||||
placeholder?: string;
|
||||
fallback?: string;
|
||||
onLoad?: () => void;
|
||||
onError?: () => void;
|
||||
children?: React.ReactNode;
|
||||
overlay?: boolean;
|
||||
overlayColor?: string;
|
||||
overlayOpacity?: number;
|
||||
overflowHidden?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function BackgroundImage({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
width,
|
||||
height,
|
||||
backgroundSize = "cover",
|
||||
backgroundPosition = "center",
|
||||
backgroundRepeat = "no-repeat",
|
||||
placeholder,
|
||||
fallback,
|
||||
onLoad,
|
||||
onError,
|
||||
children,
|
||||
overlay = false,
|
||||
overlayColor = "rgba(0, 0, 0, 0.5)",
|
||||
overlayOpacity = 0.5,
|
||||
overflowHidden = true,
|
||||
style,
|
||||
...restProps
|
||||
}: BackgroundImageProps) {
|
||||
const [imageSrc, setImageSrc] = useState(src);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const loadedImagesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// 预加载图片
|
||||
const preloadImage = (url: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject();
|
||||
img.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
// 统一的加载逻辑
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadImage = async () => {
|
||||
if (!imageSrc || !isMounted) return;
|
||||
|
||||
// 检查是否已经加载过这个图片
|
||||
if (loadedImagesRef.current.has(imageSrc)) {
|
||||
setIsLoading(false);
|
||||
setIsLoaded(true);
|
||||
setHasError(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await preloadImage(imageSrc);
|
||||
if (isMounted) {
|
||||
loadedImagesRef.current.add(imageSrc);
|
||||
setIsLoading(false);
|
||||
setIsLoaded(true);
|
||||
setHasError(false);
|
||||
onLoad?.();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
setHasError(true);
|
||||
if (fallback && imageSrc !== fallback) {
|
||||
setImageSrc(fallback);
|
||||
} else {
|
||||
onError?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadImage();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [imageSrc]); // 只在imageSrc改变时执行
|
||||
|
||||
// 当src改变时更新imageSrc
|
||||
useEffect(() => {
|
||||
if (src !== imageSrc) {
|
||||
setImageSrc(src);
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
setIsLoaded(false);
|
||||
}
|
||||
}, [src]);
|
||||
|
||||
const backgroundStyle = {
|
||||
width,
|
||||
height,
|
||||
backgroundImage: isLoaded
|
||||
? `url(${imageSrc})`
|
||||
: placeholder
|
||||
? `url(${placeholder})`
|
||||
: undefined,
|
||||
backgroundSize,
|
||||
backgroundPosition,
|
||||
backgroundRepeat,
|
||||
position: "relative" as const,
|
||||
...style,
|
||||
};
|
||||
|
||||
const overlayStyle = {
|
||||
backgroundColor: overlayColor,
|
||||
opacity: overlayOpacity,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
"relative",
|
||||
overflowHidden && "overflow-hidden",
|
||||
className
|
||||
)}
|
||||
style={backgroundStyle}
|
||||
{...restProps}
|
||||
>
|
||||
{/* 加载占位符 */}
|
||||
{isLoading && !placeholder && (
|
||||
<div className="absolute inset-0 bg-gray-200 flex items-center justify-center">
|
||||
<div className="animate-pulse bg-gray-300 rounded w-full h-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{hasError && (
|
||||
<div className="absolute inset-0 bg-gray-100 flex items-center justify-center">
|
||||
<div className="text-gray-400 text-center">
|
||||
<svg
|
||||
className="w-8 h-8 mx-auto mb-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">背景图片加载失败</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 遮罩层 */}
|
||||
{overlay && isLoaded && (
|
||||
<div className="absolute inset-0" style={overlayStyle} />
|
||||
)}
|
||||
|
||||
{/* 内容 */}
|
||||
{children && (
|
||||
<div className="relative z-10 w-full h-full">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
# 组件创建规范
|
||||
|
||||
## 目录结构
|
||||
|
||||
采用模块化结构组织组件:
|
||||
|
||||
```
|
||||
src/components/
|
||||
├── video/
|
||||
│ ├── index.ts # 导出入口
|
||||
│ ├── video.tsx # 主组件
|
||||
│ ├── video.types.ts # 类型定义
|
||||
│ ├── video.hooks.ts # 组件专用 hooks(可选)
|
||||
│ └── video.utils.ts # 工具函数(可选)
|
||||
├── button/
|
||||
│ ├── index.ts
|
||||
│ └── button.tsx
|
||||
├── ui/ # 基础 UI 组件
|
||||
│ ├── button.tsx
|
||||
│ └── dialog.tsx
|
||||
└── index.ts # 统一导出所有组件
|
||||
```
|
||||
|
||||
## 命名规范
|
||||
|
||||
| 类型 | 规范 | 示例 |
|
||||
| ---------- | ------------------ | ------------------ |
|
||||
| 文件名 | kebab-case | `video-player.tsx` |
|
||||
| 目录名 | kebab-case | `video-player/` |
|
||||
| 组件名 | PascalCase | `VideoPlayer` |
|
||||
| Props 接口 | `{组件名}Props` | `VideoPlayerProps` |
|
||||
| Ref 接口 | `{组件名}Ref` | `VideoPlayerRef` |
|
||||
| 导出类型 | `{组件名}{类型名}` | `VideoAnchor` |
|
||||
|
||||
## 文件结构顺序
|
||||
|
||||
每个组件文件按以下顺序组织:
|
||||
|
||||
1. **Imports** - 导入语句
|
||||
2. **Types/Interfaces** - 类型定义(需导出)
|
||||
3. **Constants** - 常量定义
|
||||
4. **Helper Functions** - 辅助函数
|
||||
5. **Component** - 组件定义
|
||||
6. **displayName** - 设置显示名称
|
||||
7. **Export** - 默认导出
|
||||
|
||||
## 组件模板
|
||||
|
||||
````tsx
|
||||
import React, {
|
||||
forwardRef,
|
||||
useRef,
|
||||
useState,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** 组件配置项 */
|
||||
export interface ComponentNameProps {
|
||||
/** 必填属性说明 */
|
||||
requiredProp: string;
|
||||
/** 可选属性说明 */
|
||||
optionalProp?: boolean;
|
||||
/** 子元素 */
|
||||
children?: React.ReactNode;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 组件 Ref 暴露的方法 */
|
||||
export interface ComponentNameRef {
|
||||
/** 方法说明 */
|
||||
methodName: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ComponentName - 组件简要说明
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ComponentName requiredProp="value" />
|
||||
* ```
|
||||
*/
|
||||
const ComponentName = forwardRef<ComponentNameRef, ComponentNameProps>(
|
||||
({ requiredProp, optionalProp = false, className, children }, ref) => {
|
||||
// 状态定义
|
||||
const [state, setState] = useState<string>("");
|
||||
|
||||
// Ref 定义
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
methodName: () => {
|
||||
// 实现
|
||||
},
|
||||
}));
|
||||
|
||||
return <div className={cn("base-class", className)}>{children}</div>;
|
||||
}
|
||||
);
|
||||
|
||||
ComponentName.displayName = "ComponentName";
|
||||
|
||||
export default ComponentName;
|
||||
````
|
||||
|
||||
## index.ts 导出模板
|
||||
|
||||
```ts
|
||||
export { default } from "./component-name";
|
||||
export { default as ComponentName } from "./component-name";
|
||||
export type { ComponentNameProps, ComponentNameRef } from "./component-name";
|
||||
```
|
||||
|
||||
## 类型规范
|
||||
|
||||
- **必须**为所有 props、state、函数参数和返回值声明类型
|
||||
- **禁止**使用 `any`,使用 `unknown` 或泛型替代
|
||||
- **导出**所有公共接口和类型
|
||||
- 使用 `React.ReactNode` 表示子元素类型
|
||||
- 使用 `React.CSSProperties` 表示样式对象
|
||||
|
||||
## 样式规范
|
||||
|
||||
- 使用 `cn()` 工具函数合并类名
|
||||
- 优先使用 Tailwind CSS 类名
|
||||
- 支持通过 `className` prop 自定义样式
|
||||
- 避免内联样式,除非是动态计算值
|
||||
|
||||
```tsx
|
||||
// ✅ 推荐
|
||||
<div className={cn("flex items-center", isActive && "bg-blue-500", className)}>
|
||||
|
||||
// ❌ 避免
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **单一职责** - 每个组件只做一件事,保持在 200 行以内
|
||||
2. **使用 forwardRef** - 需要暴露 ref 的组件必须使用
|
||||
3. **设置 displayName** - 便于 React DevTools 调试
|
||||
4. **提取逻辑** - 复杂逻辑提取到自定义 hooks
|
||||
5. **组合优于继承** - 使用 children 和 render props
|
||||
6. **早返回** - 避免深层嵌套,使用 early return
|
||||
|
||||
## 文件拆分指南
|
||||
|
||||
| 文件 | 用途 | 何时创建 |
|
||||
| ---------------- | -------------- | -------------------- |
|
||||
| `*.tsx` | 主组件 | 必须 |
|
||||
| `*.types.ts` | 类型定义 | 类型超过 30 行 |
|
||||
| `*.hooks.ts` | 组件专用 hooks | 有可复用的状态逻辑 |
|
||||
| `*.utils.ts` | 工具函数 | 有纯函数逻辑需要提取 |
|
||||
| `*.constants.ts` | 常量定义 | 有多个常量需要共享 |
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import { useState } from "react";
|
||||
|
||||
type DisplayMode = "modal" | "embedded";
|
||||
type EmbeddedPosition =
|
||||
| "bottom-right"
|
||||
| "bottom-left"
|
||||
| "top-right"
|
||||
| "top-left";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
mode?: DisplayMode;
|
||||
position?: EmbeddedPosition;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
showMask?: boolean;
|
||||
}
|
||||
|
||||
export const Modal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
mode = "modal",
|
||||
position = "bottom-right",
|
||||
width,
|
||||
height,
|
||||
showMask = true,
|
||||
}: ModalProps) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
// 嵌入式客服对话框模式
|
||||
if (mode === "embedded") {
|
||||
const getPosition = () => {
|
||||
switch (position) {
|
||||
case "bottom-right":
|
||||
return "bottom-6 right-6";
|
||||
case "bottom-left":
|
||||
return "bottom-6 left-6";
|
||||
case "top-right":
|
||||
return "top-6 right-6";
|
||||
case "top-left":
|
||||
return "top-6 left-6";
|
||||
}
|
||||
};
|
||||
|
||||
const defaultWidth = width || "400px";
|
||||
const defaultHeight = height || "600px";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"fixed z-[9999] bg-transparent overflow-hidden border border-transparent",
|
||||
"transition-all duration-300 ease-out",
|
||||
"animate-scale-in",
|
||||
getPosition(),
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
width: defaultWidth,
|
||||
height: defaultHeight,
|
||||
maxHeight: "calc(100vh - 3rem)",
|
||||
maxWidth: "calc(100vw - 3rem)",
|
||||
}}
|
||||
>
|
||||
{/* 关闭/最小化按钮 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 z-10 w-7 h-7 flex items-center justify-center bg-white hover:bg-gray-100 rounded-full transition-colors shadow-sm border border-gray-200"
|
||||
title="最小化"
|
||||
>
|
||||
<span className="text-gray-500 text-lg font-bold leading-none">
|
||||
−
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="h-full w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 传统弹窗模式
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 背景遮罩 */}
|
||||
{showMask && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<div
|
||||
className={clsx(
|
||||
"relative bg-white rounded-lg shadow-xl max-w-7xl max-h-[100vh] w-full mx-4",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
width: width,
|
||||
height: height,
|
||||
}}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-10 w-8 h-8 flex items-center justify-center bg-gray-200 hover:bg-gray-300 rounded-full transition-colors"
|
||||
>
|
||||
<span className="text-gray-600 text-xl font-bold">×</span>
|
||||
</button>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<div className="h-full">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IframeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
title?: string;
|
||||
mode?: DisplayMode;
|
||||
position?: EmbeddedPosition;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
showMask?: boolean;
|
||||
}
|
||||
|
||||
export const IframeModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
url,
|
||||
title,
|
||||
mode = "modal",
|
||||
position = "bottom-right",
|
||||
width,
|
||||
height,
|
||||
showMask = true,
|
||||
}: IframeModalProps) => {
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorType, setErrorType] = useState<
|
||||
"network" | "security" | "unknown"
|
||||
>("unknown");
|
||||
|
||||
const handleIframeError = () => {
|
||||
setHasError(true);
|
||||
setIsLoading(false);
|
||||
setErrorType("network");
|
||||
};
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
setIsLoading(false);
|
||||
setHasError(false);
|
||||
|
||||
// 监听iframe内部的错误
|
||||
const iframe = document.querySelector("iframe");
|
||||
if (iframe) {
|
||||
try {
|
||||
iframe.contentWindow?.addEventListener("error", (event) => {
|
||||
console.error("Iframe内部错误:", event);
|
||||
if (
|
||||
event.message?.includes("VideoDecoder") ||
|
||||
event.message?.includes("WebCodecs")
|
||||
) {
|
||||
setErrorType("security");
|
||||
setHasError(true);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
console.log("无法访问iframe内容,这是正常的跨域限制");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={mode}
|
||||
position={position}
|
||||
width={width}
|
||||
height={height}
|
||||
showMask={showMask}
|
||||
className={mode === "modal" ? "max-w-5xl" : ""}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
{title && mode === "modal" && (
|
||||
<div className="px-6 py-4 border-b border-transparent">
|
||||
<h3 className="text-lg font-semibold text-gray-800">{title}</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 relative">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-transparent">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">正在加载...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasError ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-transparent">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 text-6xl mb-4">⚠️</div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">
|
||||
{errorType === "security" ? "页面功能受限" : "页面加载失败"}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{errorType === "security"
|
||||
? "该页面使用了高级视频处理功能,在嵌入模式下可能无法正常工作"
|
||||
: "由于安全策略限制,无法在此窗口中显示该页面"}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{errorType === "security"
|
||||
? "建议在支持的浏览器中直接访问该页面"
|
||||
: "请检查网络连接或稍后重试"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
src={url}
|
||||
className="w-full h-full border-0 bg-transparent"
|
||||
title={title || "嵌入页面"}
|
||||
allow="microphone; camera; autoplay; fullscreen; encrypted-media; picture-in-picture; web-share; accelerometer; gyroscope; magnetometer; payment; usb; serial; bluetooth; xr-spatial-tracking"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
loading="lazy"
|
||||
onError={handleIframeError}
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
|
||||
/**
|
||||
* 横屏提示组件
|
||||
* 在移动端竖屏时显示提示,引导用户横屏观看
|
||||
*/
|
||||
export function RotateHint() {
|
||||
const { isMobilePortrait } = useScale();
|
||||
|
||||
if (!isMobilePortrait) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background:
|
||||
"rgba(0,0,0,0.55) radial-gradient(circle at center, rgba(255,255,255,0.1), rgba(0,0,0,0.6))",
|
||||
color: "#fff",
|
||||
zIndex: 9999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
padding: "24px",
|
||||
fontSize: "20px",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
请横屏观看,以获得最佳体验
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import mammoth from "mammoth";
|
||||
|
||||
interface WordViewerProps {
|
||||
/** Word 文档的路径 */
|
||||
docPath: string;
|
||||
/** 容器的类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const WordViewer = ({ docPath, className = "" }: WordViewerProps) => {
|
||||
const [htmlContent, setHtmlContent] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const loadDocument = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
// 获取 Word 文档
|
||||
const response = await fetch(docPath);
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载文档");
|
||||
}
|
||||
|
||||
// 转换为 ArrayBuffer
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
// 使用 mammoth 转换为 HTML
|
||||
const result = await mammoth.convertToHtml({
|
||||
arrayBuffer: arrayBuffer,
|
||||
});
|
||||
|
||||
setHtmlContent(result.value);
|
||||
|
||||
// 如果有警告信息,可以在控制台输出
|
||||
if (result.messages.length > 0) {
|
||||
console.warn("文档转换警告:", result.messages);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("加载文档失败:", err);
|
||||
setError(err instanceof Error ? err.message : "加载文档失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDocument();
|
||||
}, [docPath]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`flex justify-center items-center ${className}`}>
|
||||
<div className="text-[#82B2D6] text-2xl font-bold">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`flex justify-center items-center ${className}`}>
|
||||
<div className="text-red-500 text-2xl font-bold">加载失败: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col p-4 ${className}`}>
|
||||
{/* 头部工具栏 */}
|
||||
{/* <div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-[#82B2D6] text-2xl font-bold">工作室活动文稿</h3>
|
||||
<a
|
||||
href={docPath}
|
||||
download="工作室活动文稿.docx"
|
||||
className="px-6 py-2 bg-[#82B2D6] text-white rounded-lg hover:bg-[#6a9bc4] transition-colors text-sm font-medium"
|
||||
>
|
||||
下载文档
|
||||
</a>
|
||||
</div> */}
|
||||
|
||||
{/* 文档内容区域 custom-scrollbar*/}
|
||||
<div className="flex-1 overflow-y-auto bg-white rounded-lg px-4 py-0 ">
|
||||
<div
|
||||
className="word-content"
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
color: "#333",
|
||||
lineHeight: "1.8",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 内联样式 - 美化文档内容 */}
|
||||
<style>{`
|
||||
.word-content h1 {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin: 1em 0 0.5em;
|
||||
color: #1a1a1a;
|
||||
border-bottom: 2px solid #82B2D6;
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.word-content h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
margin: 0.8em 0 0.4em;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.word-content h3 {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
margin: 0.6em 0 0.3em;
|
||||
color: #3a3a3a;
|
||||
}
|
||||
|
||||
.word-content p {
|
||||
margin: 0.8em 0;
|
||||
text-align: justify;
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
||||
/* 表格内的段落不需要缩进 */
|
||||
.word-content table p {
|
||||
text-indent: 0;
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
|
||||
.word-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 1.5em auto;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.word-content ul,
|
||||
.word-content ol {
|
||||
margin: 0.8em 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.word-content li {
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
|
||||
.word-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.word-content table th,
|
||||
.word-content table td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.8em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.word-content table th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 表格第一列(标题列)固定宽度 */
|
||||
.word-content table tr td:first-child,
|
||||
.word-content table tr th:first-child {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
white-space: normal;
|
||||
font-weight: 600;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.word-content strong,
|
||||
.word-content b {
|
||||
font-weight: bold;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.word-content em,
|
||||
.word-content i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.word-content a {
|
||||
color: #82B2D6;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.word-content a:hover {
|
||||
color: #6a9bc4;
|
||||
}
|
||||
|
||||
.word-content blockquote {
|
||||
border-left: 4px solid #82B2D6;
|
||||
padding-left: 1em;
|
||||
margin: 1em 0;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
import { X } from "lucide-react";
|
||||
import { WordViewer } from "./WordViewer";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface WordViewerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
docPath: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const WordViewerModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
docPath,
|
||||
title = "活动文稿",
|
||||
}: WordViewerModalProps) => {
|
||||
// 监听 ESC 键关闭弹窗
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// 阻止背景滚动
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "unset";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<div className="relative w-[90vw] h-[90vh] bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-[#82B2D6] to-[#6a9bc4] text-white">
|
||||
<h2 className="text-2xl font-bold">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/20 rounded-lg transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 文档内容区域 */}
|
||||
<div className="h-[calc(100%-64px)] overflow-hidden">
|
||||
<WordViewer docPath={docPath} className="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Button, ImageButton } from "./button";
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: "Components/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["primary", "secondary", "ghost"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: "primary",
|
||||
children: "按钮",
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
variant: "secondary",
|
||||
children: "次要按钮",
|
||||
},
|
||||
};
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
variant: "ghost",
|
||||
children: "幽灵按钮",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
children: "禁用状态",
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
loading: true,
|
||||
children: "加载中...",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithImage: Story = {
|
||||
render: () => (
|
||||
<div className="flex gap-4">
|
||||
<ImageButton
|
||||
src="/path/to/button-image.png"
|
||||
hoverSrc="/path/to/button-image-hover.png"
|
||||
alt="图片按钮"
|
||||
width={120}
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Playground: Story = {
|
||||
args: {
|
||||
variant: "primary",
|
||||
children: "点击我",
|
||||
className: "px-8 py-3 text-lg",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { forwardRef, useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BaseButtonProps, ImageButtonProps } from "./button.types";
|
||||
|
||||
/**
|
||||
* 基础按钮组件
|
||||
*/
|
||||
const Button = forwardRef<HTMLButtonElement, BaseButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className = "",
|
||||
variant = "primary",
|
||||
disabled = false,
|
||||
loading = false,
|
||||
type = "button",
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const baseClasses =
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none";
|
||||
|
||||
const variantClasses = {
|
||||
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type as "button" | "submit" | "reset" | undefined}
|
||||
className={cn(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
"px-4 py-2",
|
||||
className
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? "加载中..." : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
/**
|
||||
* 图片按钮组件
|
||||
*/
|
||||
const ImageButton = forwardRef<HTMLButtonElement, ImageButtonProps>(
|
||||
(
|
||||
{
|
||||
src,
|
||||
alt,
|
||||
hoverSrc,
|
||||
width = "auto",
|
||||
height = "auto",
|
||||
className = "",
|
||||
disabled = false,
|
||||
loading = false,
|
||||
type = "button",
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [imageSrc, setImageSrc] = useState(src);
|
||||
|
||||
useEffect(() => {
|
||||
if (hoverSrc) {
|
||||
const img = new Image();
|
||||
img.src = hoverSrc;
|
||||
}
|
||||
}, [hoverSrc]);
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setIsHovered(true);
|
||||
if (hoverSrc) {
|
||||
setImageSrc(hoverSrc);
|
||||
}
|
||||
onMouseEnter?.(e);
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setIsHovered(false);
|
||||
setImageSrc(src);
|
||||
onMouseLeave?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type as "button" | "submit" | "reset" | undefined}
|
||||
className={cn(
|
||||
"transition-opacity hover:opacity-90 active:opacity-80",
|
||||
"focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn(
|
||||
"object-contain transition-transform",
|
||||
isHovered && "transform scale-105"
|
||||
)}
|
||||
draggable="false"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ImageButton.displayName = "ImageButton";
|
||||
|
||||
export { Button, ImageButton };
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
/** 基础按钮属性 */
|
||||
export interface BaseButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** 是否显示加载状态 */
|
||||
loading?: boolean;
|
||||
/** 按钮变体 */
|
||||
variant?: "primary" | "secondary" | "ghost";
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** 子元素 */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 图片按钮属性 */
|
||||
export interface ImageButtonProps extends Omit<BaseButtonProps, "children"> {
|
||||
/** 图片URL */
|
||||
src: string;
|
||||
/** 图片替代文本 */
|
||||
alt: string;
|
||||
/** 图片宽度 */
|
||||
width?: number | string;
|
||||
/** 图片高度 */
|
||||
height?: number | string;
|
||||
/** 悬停时显示的图片URL */
|
||||
hoverSrc?: string;
|
||||
}
|
||||
|
||||
export type ButtonProps = BaseButtonProps | ImageButtonProps;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./button";
|
||||
export * from "./button.types";
|
||||
@@ -0,0 +1,92 @@
|
||||
import { cn, getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
|
||||
// 原始尺寸(保持比例)
|
||||
const ORIGINAL_SIZES = {
|
||||
read: { width: 116, height: 140 },
|
||||
unread: { width: 120, height: 121 },
|
||||
} as const;
|
||||
|
||||
const Cursor = ({
|
||||
className,
|
||||
onClick,
|
||||
read = false,
|
||||
size = 1,
|
||||
}: {
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
/** 已阅读 / 已完成 状态;true 时显示预留的 div,由你自行实现效果 */
|
||||
read?: boolean;
|
||||
/** 尺寸缩放比例,默认为 1(原始大小) */
|
||||
size?: number;
|
||||
}) => {
|
||||
// 根据 size 计算实际尺寸,保持原始比例
|
||||
const baseSize = read ? ORIGINAL_SIZES.read : ORIGINAL_SIZES.unread;
|
||||
const width = baseSize.width * size;
|
||||
const height = baseSize.height * size;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-pointer select-none",
|
||||
read ? "" : "animate-[spin_3s_linear_infinite]",
|
||||
"flex justify-center items-center pl-0",
|
||||
"transition-all duration-300 hover:scale-105 hover:brightness-110 hover:contrast-110 active:scale-95",
|
||||
className
|
||||
)}
|
||||
onTouchEnd={(e) => {
|
||||
e.preventDefault(); // 防止 Safari 触发两次
|
||||
onClick();
|
||||
}}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
WebkitTapHighlightColor: "transparent",
|
||||
WebkitTouchCallout: "none",
|
||||
WebkitUserSelect: "none",
|
||||
touchAction: "manipulation",
|
||||
backgroundImage: getBackgroundImage(
|
||||
read ? "components/point_" : "components/border"
|
||||
),
|
||||
backgroundSize: "contain",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
onClick={onClick}
|
||||
draggable={false}
|
||||
>
|
||||
{read ? (
|
||||
// 已阅读 / 已完成 状态
|
||||
// <div
|
||||
// className="absolute -bottom-1 text-[#cf9c4d] text-2xl font-bold"
|
||||
// style={{
|
||||
// // 细一点的描边,避免把内部金色填充盖掉
|
||||
// // WebkitTextStroke: "0.5px #ffffff",
|
||||
// // 用多层白色阴影模拟更厚的外轮廓
|
||||
// textShadow: [
|
||||
// "0 0 2px #ffffff",
|
||||
// "0 0 4px #ffffff",
|
||||
// "0 0 6px rgba(0,0,0,0.35)",
|
||||
// ].join(", "),
|
||||
// }}
|
||||
// >
|
||||
// 已浏览
|
||||
// </div>
|
||||
<></>
|
||||
) : (
|
||||
// 未阅读 / 未完成 状态:保持当前效果
|
||||
<img
|
||||
src={getStaticImage("components/point.png")}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
objectPosition: "center",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cursor;
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { cn, getBackgroundImage } from "@/lib/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
export interface ExhibitDescriptionNarrationProps {
|
||||
/** 描述文本 */
|
||||
text?: string;
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 是否自动播放语音 */
|
||||
autoPlay?: boolean;
|
||||
/** 额外的自定义类 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 展品描述播报组件
|
||||
* - 显示展品描述文本
|
||||
* - 支持文本转语音(TTS)播报
|
||||
* - 提供播放/暂停控制
|
||||
*/
|
||||
const ExhibitDescriptionNarration = ({
|
||||
text,
|
||||
visible = true,
|
||||
autoPlay = false,
|
||||
className,
|
||||
}: ExhibitDescriptionNarrationProps) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
|
||||
const synthRef = useRef<typeof window.speechSynthesis | null>(null);
|
||||
|
||||
// 初始化语音合成
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && "speechSynthesis" in window) {
|
||||
synthRef.current = window.speechSynthesis;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 播放语音
|
||||
const playSpeech = useCallback(() => {
|
||||
if (!text || !synthRef.current) return;
|
||||
|
||||
// 如果已有正在播放的语音,先停止
|
||||
if (utteranceRef.current) {
|
||||
synthRef.current.cancel();
|
||||
}
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = "zh-CN";
|
||||
utterance.rate = 0.9; // 语速稍慢
|
||||
utterance.pitch = 1; // 音调
|
||||
utterance.volume = 1; // 音量
|
||||
|
||||
utterance.onstart = () => {
|
||||
setIsPlaying(true);
|
||||
setIsPaused(false);
|
||||
};
|
||||
|
||||
utterance.onend = () => {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
};
|
||||
|
||||
utterance.onerror = () => {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
};
|
||||
|
||||
utteranceRef.current = utterance;
|
||||
synthRef.current.speak(utterance);
|
||||
}, [text]);
|
||||
|
||||
// 暂停语音
|
||||
const pauseSpeech = useCallback(() => {
|
||||
if (synthRef.current && isPlaying) {
|
||||
synthRef.current.pause();
|
||||
setIsPaused(true);
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
// 继续播放语音
|
||||
const resumeSpeech = useCallback(() => {
|
||||
if (synthRef.current && isPaused) {
|
||||
synthRef.current.resume();
|
||||
setIsPaused(false);
|
||||
}
|
||||
}, [isPaused]);
|
||||
|
||||
// 停止语音
|
||||
const stopSpeech = useCallback(() => {
|
||||
if (synthRef.current) {
|
||||
synthRef.current.cancel();
|
||||
setIsPlaying(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 自动播放
|
||||
useEffect(() => {
|
||||
if (visible && autoPlay && text && synthRef.current) {
|
||||
// 延迟一下确保组件已渲染
|
||||
const timer = setTimeout(() => {
|
||||
playSpeech();
|
||||
}, 300);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
stopSpeech();
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, autoPlay, text]);
|
||||
|
||||
// 组件卸载或不可见时停止语音
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
stopSpeech();
|
||||
}
|
||||
return () => {
|
||||
stopSpeech();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible]);
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={cn(
|
||||
"max-w-[500px] px-12 py-14",
|
||||
"shadow-black/60",
|
||||
"relative overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* <img
|
||||
src={borderImg}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
|
||||
/> */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none box-border"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
border: "40px solid transparent",
|
||||
borderImageSource: getBackgroundImage(
|
||||
"components/narration/b.png"
|
||||
),
|
||||
borderImageSlice: "40 fill",
|
||||
borderImageRepeat: "stretch round",
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex flex-col gap-4">
|
||||
{/* 文本内容 */}
|
||||
<div className="flex-1 text-white text-2xl leading-12 tracking-wide font-medium">
|
||||
{text}
|
||||
</div>
|
||||
|
||||
{/* 控制按钮 */}
|
||||
{synthRef.current && (
|
||||
<div className="flex items-center justify-end gap-2 flex-shrink-0">
|
||||
{!isPlaying && !isPaused && (
|
||||
<button
|
||||
onClick={playSpeech}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
|
||||
aria-label="播放"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{isPlaying && !isPaused && (
|
||||
<button
|
||||
onClick={pauseSpeech}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
|
||||
aria-label="暂停"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M5.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75A.75.75 0 007.25 3h-1.5zM12.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75a.75.75 0 00-.75-.75h-1.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{isPaused && (
|
||||
<button
|
||||
onClick={resumeSpeech}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
|
||||
aria-label="继续播放"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{(isPlaying || isPaused) && (
|
||||
<button
|
||||
onClick={stopSpeech}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
|
||||
aria-label="停止"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M5.25 3A2.25 2.25 0 003 5.25v9.5A2.25 2.25 0 005.25 17h9.5A2.25 2.25 0 0017 14.75v-9.5A2.25 2.25 0 0014.75 3h-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExhibitDescriptionNarration;
|
||||
@@ -0,0 +1,579 @@
|
||||
import { cn, getStaticImage } from "@/lib/utils";
|
||||
import {
|
||||
MouseEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Image } from "@/components";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import ExhibitDescriptionNarration from "./ExhibitDescriptionNarration";
|
||||
|
||||
export interface ExhibitOverlayProps {
|
||||
/** 展品图片地址,可以是静态资源路径或完整 URL */
|
||||
src: string;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 是否可见,默认 true */
|
||||
visible?: boolean;
|
||||
/** 额外的自定义类 */
|
||||
className?: string;
|
||||
/** 展品描述文本 */
|
||||
description?: string;
|
||||
/** 是否自动播放描述语音,默认 false */
|
||||
autoPlayDescription?: boolean;
|
||||
/** 自定义点击区域配置 */
|
||||
clickableAreas?: Array<{
|
||||
/** 区域名称 */
|
||||
name: string;
|
||||
/** 相对于图片的位置和大小(百分比) */
|
||||
position: { left: number; top: number; width: number; height: number };
|
||||
/** 点击回调 */
|
||||
onClick: () => void;
|
||||
}>;
|
||||
}
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
/**
|
||||
* 展品通用遮罩层
|
||||
* - 背景蒙版:components/蒙版遮罩.png
|
||||
* - 返回按钮:components/返回.png
|
||||
* - 传入展品图片地址进行展示
|
||||
* - 支持缩放、拖拽、双击重置
|
||||
* - 使用屏幕坐标系统,简单可靠
|
||||
*/
|
||||
const ExhibitOverlay = ({
|
||||
src,
|
||||
onClose,
|
||||
visible = true,
|
||||
className,
|
||||
description,
|
||||
autoPlayDescription = false,
|
||||
clickableAreas,
|
||||
}: ExhibitOverlayProps) => {
|
||||
const maskSrc = getStaticImage("components/蒙版遮罩");
|
||||
const backSrc = getStaticImage("components/关闭");
|
||||
|
||||
// 获取缩放参数,用于视觉对齐
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// SSR 兼容:只在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 允许传入相对路径或绝对 URL
|
||||
const exhibitSrc =
|
||||
src.startsWith("http://") ||
|
||||
src.startsWith("https://") ||
|
||||
src.startsWith("/")
|
||||
? src
|
||||
: getStaticImage(src);
|
||||
|
||||
const handleClose = (e: MouseEvent<HTMLImageElement>) => {
|
||||
e.stopPropagation();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
// ====== 缩放 / 拖拽状态 ======
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewElRef = useRef<HTMLDivElement | null>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const pointers = useRef<Map<number, Point>>(new Map());
|
||||
const lastDistance = useRef<number | null>(null);
|
||||
// 拖拽初始状态:记录 pointerDown 时的状态
|
||||
const dragStartRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startTransform: { scale: number; x: number; y: number };
|
||||
} | null>(null);
|
||||
const transformRef = useRef({ scale: 1, x: 0, y: 0 });
|
||||
|
||||
const MIN_SCALE = 0.8;
|
||||
const MAX_SCALE = 3;
|
||||
|
||||
// 针对当前 scale 预计算 clamp
|
||||
const clampScale = useCallback(
|
||||
(next: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, next)),
|
||||
[]
|
||||
);
|
||||
|
||||
const applyTransformStyle = useCallback(() => {
|
||||
const el = viewElRef.current;
|
||||
if (!el) return;
|
||||
const { scale, x, y } = transformRef.current;
|
||||
// 使用 translate3d 触发 GPU 加速
|
||||
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||
el.style.transformOrigin = "0 0";
|
||||
el.style.touchAction = "none";
|
||||
}, []);
|
||||
|
||||
const scheduleUpdate = useCallback(() => {
|
||||
if (frameRef.current) return;
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
applyTransformStyle();
|
||||
frameRef.current = null;
|
||||
});
|
||||
}, [applyTransformStyle]);
|
||||
|
||||
const resetTransform = useCallback(() => {
|
||||
transformRef.current = { scale: 1, x: 0, y: 0 };
|
||||
pointers.current.clear();
|
||||
lastDistance.current = null;
|
||||
dragStartRef.current = null;
|
||||
// 释放所有指针捕获
|
||||
if (containerRef.current) {
|
||||
pointers.current.forEach((_, pointerId) => {
|
||||
try {
|
||||
containerRef.current?.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
});
|
||||
}
|
||||
applyTransformStyle();
|
||||
}, [applyTransformStyle]);
|
||||
|
||||
// 关闭或切换时重置
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
resetTransform();
|
||||
} else {
|
||||
// 不可见时清理所有状态和指针捕获
|
||||
transformRef.current = { scale: 1, x: 0, y: 0 };
|
||||
pointers.current.clear();
|
||||
lastDistance.current = null;
|
||||
dragStartRef.current = null;
|
||||
if (containerRef.current) {
|
||||
pointers.current.forEach((_, pointerId) => {
|
||||
try {
|
||||
containerRef.current?.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
});
|
||||
}
|
||||
// 取消待处理的动画帧
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, src]);
|
||||
|
||||
// 组件卸载时清理资源
|
||||
useEffect(() => {
|
||||
const containerEl = containerRef.current;
|
||||
const pointersMap = pointers.current;
|
||||
const frameId = frameRef.current;
|
||||
|
||||
return () => {
|
||||
// 清理所有指针捕获
|
||||
if (containerEl) {
|
||||
pointersMap.forEach((_, pointerId) => {
|
||||
try {
|
||||
containerEl.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
});
|
||||
}
|
||||
// 取消待处理的动画帧
|
||||
if (frameId) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 缩放函数:origin 是屏幕坐标,需要转换为相对于 viewElRef 的坐标
|
||||
const applyZoom = useCallback(
|
||||
(factor: number, origin: Point) => {
|
||||
if (!viewElRef.current || !containerRef.current) return;
|
||||
|
||||
const prev = transformRef.current;
|
||||
const nextScale = clampScale(prev.scale * factor);
|
||||
// 边界检查:避免除零
|
||||
if (prev.scale === 0) return;
|
||||
|
||||
// 获取 viewElRef 相对于视口的位置
|
||||
const viewRect = viewElRef.current.getBoundingClientRect();
|
||||
// 将屏幕坐标转换为相对于 viewElRef 的坐标
|
||||
const relativeOrigin = {
|
||||
x: origin.x - viewRect.left,
|
||||
y: origin.y - viewRect.top,
|
||||
};
|
||||
|
||||
const scaleChange = nextScale / prev.scale;
|
||||
// 以相对坐标 origin 为基准,保持该点视觉位置不动
|
||||
const nextX =
|
||||
(prev.x - relativeOrigin.x) * scaleChange + relativeOrigin.x;
|
||||
const nextY =
|
||||
(prev.y - relativeOrigin.y) * scaleChange + relativeOrigin.y;
|
||||
transformRef.current = { scale: nextScale, x: nextX, y: nextY };
|
||||
scheduleUpdate();
|
||||
},
|
||||
[clampScale, scheduleUpdate]
|
||||
);
|
||||
|
||||
// 滚轮缩放:直接使用屏幕坐标
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent<HTMLDivElement>) => {
|
||||
const delta = e.deltaY;
|
||||
const factor = delta < 0 ? 1.05 : 0.95;
|
||||
// 直接使用屏幕坐标作为缩放中心
|
||||
applyZoom(factor, { x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[applyZoom]
|
||||
);
|
||||
|
||||
// 计算两点距离
|
||||
const getDistance = useCallback((p1: Point, p2: Point) => {
|
||||
const dx = p1.x - p2.x;
|
||||
const dy = p1.y - p2.y;
|
||||
return Math.hypot(dx, dy);
|
||||
}, []);
|
||||
|
||||
// 指针按下:记录初始状态
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current || !viewElRef.current) return;
|
||||
try {
|
||||
containerRef.current.setPointerCapture(e.pointerId);
|
||||
} catch (err) {
|
||||
console.warn("Failed to capture pointer:", err);
|
||||
}
|
||||
|
||||
// 记录屏幕坐标
|
||||
const point = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
};
|
||||
pointers.current.set(e.pointerId, point);
|
||||
|
||||
// 单指拖拽:记录初始状态
|
||||
if (pointers.current.size === 1) {
|
||||
dragStartRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startTransform: { ...transformRef.current },
|
||||
};
|
||||
} else if (pointers.current.size === 2) {
|
||||
// 双指缩放:记录初始距离
|
||||
const values = Array.from(pointers.current.values());
|
||||
if (values.length === 2) {
|
||||
const [p1, p2] = values;
|
||||
if (p1 && p2) {
|
||||
lastDistance.current = getDistance(p1, p2);
|
||||
dragStartRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[getDistance]
|
||||
);
|
||||
|
||||
/**
|
||||
* 单指拖拽处理
|
||||
* - 只关心当前 pointer 相对于按下时的位移
|
||||
* - 在这里统一做全局 scale 补偿,保持「手指移动 = 屏幕上移动」
|
||||
*/
|
||||
const handleSingleDrag = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const dragStart = dragStartRef.current;
|
||||
if (!dragStart) return;
|
||||
|
||||
// 1. 计算屏幕坐标系下的位移
|
||||
const dxScreen = e.clientX - dragStart.startX;
|
||||
const dyScreen = e.clientY - dragStart.startY;
|
||||
|
||||
// 2. 映射到设计稿坐标系(补偿全局 scale)
|
||||
const dx = dxScreen / scale;
|
||||
const dy = dyScreen / scale;
|
||||
|
||||
// 3. 应用到 transform(设计稿坐标)
|
||||
const nextX = dragStart.startTransform.x + dx;
|
||||
const nextY = dragStart.startTransform.y + dy;
|
||||
|
||||
transformRef.current.x = nextX;
|
||||
transformRef.current.y = nextY;
|
||||
|
||||
// 统一交给 rAF 的 scheduleUpdate,减少直接 DOM 操作次数
|
||||
scheduleUpdate();
|
||||
},
|
||||
[scale, scheduleUpdate]
|
||||
);
|
||||
|
||||
/**
|
||||
* 双指缩放处理
|
||||
* - 使用两指间距离变化计算缩放因子
|
||||
* - 使用两指中点作为缩放中心
|
||||
*/
|
||||
const handlePinchZoom = useCallback(() => {
|
||||
if (pointers.current.size !== 2) return;
|
||||
const values = Array.from(pointers.current.values());
|
||||
const [p1, p2] = values;
|
||||
if (!p1 || !p2) return;
|
||||
|
||||
const distance = getDistance(p1, p2);
|
||||
if (!lastDistance.current || lastDistance.current <= 0) {
|
||||
lastDistance.current = distance;
|
||||
return;
|
||||
}
|
||||
|
||||
const factor = distance / lastDistance.current;
|
||||
const center = {
|
||||
x: (p1.x + p2.x) * 0.5,
|
||||
y: (p1.y + p2.y) * 0.5,
|
||||
};
|
||||
|
||||
applyZoom(factor, center);
|
||||
lastDistance.current = distance;
|
||||
}, [getDistance, applyZoom]);
|
||||
|
||||
// 指针移动:更新指针位置,然后按当前手势类型分发给拖拽 / 缩放处理
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!pointers.current.has(e.pointerId)) return;
|
||||
|
||||
// 更新当前 pointer 的屏幕坐标
|
||||
pointers.current.set(e.pointerId, {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
|
||||
// 优先处理双指缩放
|
||||
if (pointers.current.size === 2) {
|
||||
handlePinchZoom();
|
||||
return;
|
||||
}
|
||||
|
||||
// 单指拖拽
|
||||
if (pointers.current.size === 1) {
|
||||
handleSingleDrag(e);
|
||||
}
|
||||
},
|
||||
[handlePinchZoom, handleSingleDrag]
|
||||
);
|
||||
|
||||
// 指针抬起
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
// 释放指针捕获
|
||||
if (containerRef.current) {
|
||||
try {
|
||||
containerRef.current.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
}
|
||||
|
||||
pointers.current.delete(e.pointerId);
|
||||
|
||||
if (pointers.current.size < 2) {
|
||||
lastDistance.current = null;
|
||||
}
|
||||
|
||||
// 如果剩余单指,重新记录拖拽初始状态
|
||||
if (pointers.current.size === 1) {
|
||||
const remaining = Array.from(pointers.current.values())[0];
|
||||
const remainingId = Array.from(pointers.current.keys())[0];
|
||||
if (remaining && remainingId !== undefined) {
|
||||
dragStartRef.current = {
|
||||
pointerId: remainingId,
|
||||
startX: remaining.x,
|
||||
startY: remaining.y,
|
||||
startTransform: { ...transformRef.current },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
dragStartRef.current = null;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 双击重置
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
resetTransform();
|
||||
}, [resetTransform]);
|
||||
|
||||
// 容器尺寸:基于设计稿尺寸的 90%
|
||||
const containerSize = useMemo(
|
||||
() => Math.min(DESIGN_WIDTH, DESIGN_HEIGHT) * 0.9,
|
||||
[DESIGN_WIDTH, DESIGN_HEIGHT]
|
||||
);
|
||||
|
||||
// 视觉对齐样式:确保与 App 内容对齐
|
||||
const overlayStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const scaledContainerStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
overflow: "hidden",
|
||||
}),
|
||||
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
|
||||
);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
key="exhibit-overlay"
|
||||
style={overlayStyle}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<div style={scaledContainerStyle}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center",
|
||||
"bg-black/70",
|
||||
className
|
||||
)}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
{/* 背景蒙版 */}
|
||||
{maskSrc && (
|
||||
<Image
|
||||
src={maskSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 返回按钮 */}
|
||||
{onClose && (
|
||||
<Image
|
||||
src={backSrc}
|
||||
onClick={handleClose}
|
||||
alt="返回"
|
||||
className="absolute top-5 right-5 z-20"
|
||||
button
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 w-full h-full">
|
||||
{/* 展品描述播报 - 固定在左侧 */}
|
||||
{description && (
|
||||
<ExhibitDescriptionNarration
|
||||
text={description}
|
||||
visible={visible}
|
||||
autoPlay={autoPlayDescription}
|
||||
className="absolute left-30 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 展品内容 - 在剩余空间中居中 */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
// 有描述时:从描述右侧30px开始,到右边界,在这个区域内居中
|
||||
left: description ? "520px" : "0", // 30 + 450 + 30
|
||||
right: "0",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex items-center justify-center"
|
||||
style={{
|
||||
maxWidth: containerSize,
|
||||
maxHeight: containerSize,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div
|
||||
ref={viewElRef}
|
||||
className="max-w-full max-h-full select-none relative"
|
||||
style={{
|
||||
transform: "translate3d(0px, 0px, 0) scale(1)",
|
||||
transformOrigin: "0 0",
|
||||
willChange: "transform",
|
||||
// iOS 微信浏览器优化
|
||||
WebkitTransform: "translate3d(0px, 0px, 0) scale(1)",
|
||||
WebkitBackfaceVisibility: "hidden",
|
||||
backfaceVisibility: "hidden",
|
||||
// 强制硬件加速
|
||||
WebkitPerspective: "1000",
|
||||
perspective: "1000",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={exhibitSrc}
|
||||
alt="展品"
|
||||
className="max-w-full max-h-full object-contain drop-shadow-2xl"
|
||||
/>
|
||||
{/* 可点击区域 */}
|
||||
{clickableAreas &&
|
||||
clickableAreas.map((area, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute cursor-pointer transition-all duration-200 hover:bg-white/10"
|
||||
style={{
|
||||
left: `${area.position.left}%`,
|
||||
top: `${area.position.top}%`,
|
||||
width: `${area.position.width}%`,
|
||||
height: `${area.position.height}%`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
area.onClick();
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
// 阻止拖拽事件
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
// 直接使用 Portal 渲染到 document.body
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default ExhibitOverlay;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as ExhibitOverlay } from "./ExhibitOverlay";
|
||||
export type { ExhibitOverlayProps } from "./ExhibitOverlay";
|
||||
export { default as ExhibitDescriptionNarration } from "./ExhibitDescriptionNarration";
|
||||
export type { ExhibitDescriptionNarrationProps } from "./ExhibitDescriptionNarration";
|
||||
@@ -0,0 +1,526 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn, getStaticImage } from "@/lib/utils";
|
||||
import { Image, DragFill } from "@/components";
|
||||
import type { DragFillResult } from "@/components";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
|
||||
export interface BambooPuzzleGameProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 完成回调 */
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
type PieceId = "A" | "B" | "C" | "D" | "E" | "F";
|
||||
type ContainerType = "horizontal" | "leftPillar" | "rightPillar";
|
||||
|
||||
interface Piece {
|
||||
id: PieceId;
|
||||
src: string;
|
||||
correctContainer: ContainerType;
|
||||
}
|
||||
|
||||
// 根据效果图:A和B是立柱(可以放在左右任意一边),C是横杆(必须放在横杆容器)
|
||||
const pieces: Piece[] = [
|
||||
{
|
||||
id: "A",
|
||||
src: "huangyanpei/reflection/scene1/9_008",
|
||||
correctContainer: "leftPillar",
|
||||
},
|
||||
{
|
||||
id: "B",
|
||||
src: "huangyanpei/reflection/scene1/9_0013",
|
||||
correctContainer: "horizontal",
|
||||
},
|
||||
{
|
||||
id: "C",
|
||||
src: "huangyanpei/reflection/scene1/9_009",
|
||||
correctContainer: "rightPillar",
|
||||
},
|
||||
{
|
||||
id: "D",
|
||||
src: "huangyanpei/reflection/scene1/9_0010",
|
||||
correctContainer: "horizontal",
|
||||
},
|
||||
{
|
||||
id: "E",
|
||||
src: "huangyanpei/reflection/scene1/9_0011",
|
||||
correctContainer: "horizontal",
|
||||
},
|
||||
{
|
||||
id: "F",
|
||||
src: "huangyanpei/reflection/scene1/9_0012",
|
||||
correctContainer: "horizontal",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 毛竹拼单杠游戏组件
|
||||
* - 使用蒙版遮罩作为背景
|
||||
* - 使用 DragFill 组件实现拖拽功能
|
||||
*/
|
||||
const BambooPuzzleGame = ({
|
||||
visible = true,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: BambooPuzzleGameProps) => {
|
||||
const maskSrc = getStaticImage("components/蒙版遮罩");
|
||||
const backSrc = getStaticImage("components/关闭");
|
||||
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [value, setValue] = useState<DragFillResult[]>([]);
|
||||
const [errorContainers, setErrorContainers] = useState<Set<ContainerType>>(
|
||||
new Set()
|
||||
);
|
||||
const [shakeKey, setShakeKey] = useState(0);
|
||||
const completedRef = useRef(false);
|
||||
|
||||
// SSR 兼容
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 重置游戏状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setValue([]);
|
||||
setErrorContainers(new Set());
|
||||
setShakeKey(0);
|
||||
completedRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 检查是否完成 - 三个都拼了才验证
|
||||
useEffect(() => {
|
||||
if (value.length === 3) {
|
||||
const leftPillar = value.find((r) => r.blankId === "leftPillar");
|
||||
const rightPillar = value.find((r) => r.blankId === "rightPillar");
|
||||
const horizontal = value.find((r) => r.blankId === "horizontal");
|
||||
|
||||
// 验证规则:
|
||||
// 1. C 必须在横杆容器
|
||||
// 2. A 和 B 可以在左右任意立柱(但必须各占一个)
|
||||
const isCorrect =
|
||||
horizontal?.optionValue === "C" &&
|
||||
leftPillar &&
|
||||
rightPillar &&
|
||||
(leftPillar.optionValue === "A" || leftPillar.optionValue === "B") &&
|
||||
(rightPillar.optionValue === "A" || rightPillar.optionValue === "B") &&
|
||||
leftPillar.optionValue !== rightPillar.optionValue;
|
||||
|
||||
if (isCorrect && !completedRef.current) {
|
||||
// 拼对了,调用完成回调(只调用一次)
|
||||
completedRef.current = true;
|
||||
onComplete?.();
|
||||
} else {
|
||||
// 拼错了,找出错误的容器
|
||||
const errors = new Set<ContainerType>();
|
||||
|
||||
// 检查横杆容器:必须是 C
|
||||
if (horizontal?.optionValue !== "C") {
|
||||
errors.add("horizontal");
|
||||
}
|
||||
|
||||
// 检查立柱容器:必须是 A 或 B,且不能相同
|
||||
if (
|
||||
!leftPillar ||
|
||||
(leftPillar.optionValue !== "A" && leftPillar.optionValue !== "B")
|
||||
) {
|
||||
errors.add("leftPillar");
|
||||
}
|
||||
if (
|
||||
!rightPillar ||
|
||||
(rightPillar.optionValue !== "A" && rightPillar.optionValue !== "B")
|
||||
) {
|
||||
errors.add("rightPillar");
|
||||
}
|
||||
// 如果两个立柱相同,都标记为错误
|
||||
if (
|
||||
leftPillar &&
|
||||
rightPillar &&
|
||||
leftPillar.optionValue === rightPillar.optionValue
|
||||
) {
|
||||
errors.add("leftPillar");
|
||||
errors.add("rightPillar");
|
||||
}
|
||||
|
||||
// 显示错误效果:变红并抖动
|
||||
setErrorContainers(errors);
|
||||
setShakeKey((prev) => prev + 1); // 触发动画重新播放
|
||||
|
||||
// 1.5秒后清除错误状态和错误的填充
|
||||
setTimeout(() => {
|
||||
setErrorContainers(new Set());
|
||||
// 清除错误的填充
|
||||
setValue((prev) =>
|
||||
prev.filter((r) => !errors.has(r.blankId as ContainerType))
|
||||
);
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
}, [value, onComplete]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(e: React.MouseEvent<HTMLImageElement>) => {
|
||||
e.stopPropagation();
|
||||
onClose?.();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
// 转换为 DragFill 的 options
|
||||
const options = useMemo(
|
||||
() =>
|
||||
pieces.map((piece) => ({
|
||||
value: piece.id,
|
||||
label: piece.id,
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
// 转换为 DragFill 的 blanks
|
||||
const blanks = useMemo(
|
||||
() => [
|
||||
{ id: "horizontal", label: "横杆容器" },
|
||||
{ id: "leftPillar", label: "立柱容器" },
|
||||
{ id: "rightPillar", label: "立柱容器" },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// 样式
|
||||
const overlayStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const scaledContainerStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
overflow: "hidden",
|
||||
}),
|
||||
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
|
||||
);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
key="bamboo-puzzle-game"
|
||||
style={overlayStyle}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<div style={scaledContainerStyle}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col items-center",
|
||||
"bg-black/70"
|
||||
)}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
{/* 背景蒙版 */}
|
||||
{maskSrc && (
|
||||
<Image
|
||||
src={maskSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 返回按钮 */}
|
||||
{onClose && (
|
||||
<Image
|
||||
src={backSrc}
|
||||
onClick={handleClose}
|
||||
alt="返回"
|
||||
className="absolute top-5 right-5 z-20"
|
||||
button
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 游戏内容 */}
|
||||
<div className="relative z-10 w-full h-full flex flex-col">
|
||||
{/* 标题 */}
|
||||
<div className="mt-8 mb-4 text-center">
|
||||
<h1
|
||||
className={cn([
|
||||
"text-5xl font-bold mb-2 text-white",
|
||||
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]",
|
||||
])}
|
||||
>
|
||||
毛竹拼单杠
|
||||
</h1>
|
||||
<p className="text-4xl text-white mt-10">
|
||||
请将下方的竹子拖到正确位置,拼出单杠!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 拼图区域 - 空白容器 */}
|
||||
<div
|
||||
className="flex-1 w-full relative"
|
||||
style={{ minHeight: "400px" }}
|
||||
>
|
||||
{/* 横杆容器 - 上方居中 */}
|
||||
<motion.div
|
||||
key={`horizontal-${shakeKey}`}
|
||||
data-blank-id="horizontal"
|
||||
className={cn(
|
||||
"absolute border-2 border-dashed rounded-4xl",
|
||||
"flex flex-col items-center justify-center transition-all duration-200",
|
||||
errorContainers.has("horizontal")
|
||||
? "border-red-500 bg-red-500/20"
|
||||
: "border-white/60 bg-white/5"
|
||||
)}
|
||||
animate={
|
||||
errorContainers.has("horizontal")
|
||||
? {
|
||||
x: [0, -15, 15, -15, 15, -10, 10, 0],
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}
|
||||
: { x: 0 }
|
||||
}
|
||||
style={{
|
||||
left: `${DESIGN_WIDTH * 0.15}px`,
|
||||
top: `${DESIGN_HEIGHT * 0.02}px`,
|
||||
width: `${DESIGN_WIDTH * 0.7}px`,
|
||||
height: `${DESIGN_HEIGHT * 0.2}px`,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const filled = value.find(
|
||||
(r) => r.blankId === "horizontal"
|
||||
);
|
||||
const piece = filled
|
||||
? pieces.find((p) => p.id === filled.optionValue)
|
||||
: null;
|
||||
return piece ? (
|
||||
<Image
|
||||
src={getStaticImage(piece.src)}
|
||||
alt={piece.id}
|
||||
className="w-full h-full object-contain p-2"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="text-white/30 text-4xl">
|
||||
拖拽横杆到这里
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</motion.div>
|
||||
|
||||
{/* 左立柱容器 */}
|
||||
<motion.div
|
||||
key={`leftPillar-${shakeKey}`}
|
||||
data-blank-id="leftPillar"
|
||||
className={cn(
|
||||
"absolute border-2 border-dashed rounded-4xl",
|
||||
"flex flex-col items-center justify-center transition-all duration-200",
|
||||
errorContainers.has("leftPillar")
|
||||
? "border-red-500 bg-red-500/20"
|
||||
: "border-white/60 bg-white/5"
|
||||
)}
|
||||
animate={
|
||||
errorContainers.has("leftPillar")
|
||||
? {
|
||||
x: [0, -15, 15, -15, 15, -10, 10, 0],
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}
|
||||
: { x: 0 }
|
||||
}
|
||||
style={{
|
||||
left: `${DESIGN_WIDTH * 0.15}px`,
|
||||
top: `${DESIGN_HEIGHT * 0.14}px`,
|
||||
width: `${DESIGN_WIDTH * 0.12}px`,
|
||||
height: `${DESIGN_HEIGHT * 0.32}px`,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const filled = value.find(
|
||||
(r) => r.blankId === "leftPillar"
|
||||
);
|
||||
const piece = filled
|
||||
? pieces.find((p) => p.id === filled.optionValue)
|
||||
: null;
|
||||
return piece ? (
|
||||
<Image
|
||||
src={getStaticImage(piece.src)}
|
||||
alt={piece.id}
|
||||
className="w-full h-full object-contain p-2"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="text-white/30 text-xs">
|
||||
拖拽立柱到这里
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</motion.div>
|
||||
|
||||
{/* 右立柱容器 */}
|
||||
<motion.div
|
||||
key={`rightPillar-${shakeKey}`}
|
||||
data-blank-id="rightPillar"
|
||||
className={cn(
|
||||
"absolute border-2 border-dashed rounded-4xl",
|
||||
"flex flex-col items-center justify-center transition-all duration-200",
|
||||
errorContainers.has("rightPillar")
|
||||
? "border-red-500 bg-red-500/20"
|
||||
: "border-white/60 bg-white/5"
|
||||
)}
|
||||
animate={
|
||||
errorContainers.has("rightPillar")
|
||||
? {
|
||||
x: [0, -15, 15, -15, 15, -10, 10, 0],
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}
|
||||
: { x: 0 }
|
||||
}
|
||||
style={{
|
||||
left: `${DESIGN_WIDTH * 0.74}px`,
|
||||
top: `${DESIGN_HEIGHT * 0.14}px`,
|
||||
width: `${DESIGN_WIDTH * 0.12}px`,
|
||||
height: `${DESIGN_HEIGHT * 0.32}px`,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const filled = value.find(
|
||||
(r) => r.blankId === "rightPillar"
|
||||
);
|
||||
const piece = filled
|
||||
? pieces.find((p) => p.id === filled.optionValue)
|
||||
: null;
|
||||
return piece ? (
|
||||
<Image
|
||||
src={getStaticImage(piece.src)}
|
||||
alt={piece.id}
|
||||
className="w-full h-full object-contain p-2"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="text-white/30 text-xs">
|
||||
拖拽立柱到这里
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 道具区域 - 使用 DragFill 组件 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full"
|
||||
style={{
|
||||
paddingLeft: `${DESIGN_WIDTH * 0.05}px`,
|
||||
paddingBottom: `${DESIGN_HEIGHT * 0.05}px`,
|
||||
width: `${DESIGN_WIDTH * 0.9}px`,
|
||||
}}
|
||||
>
|
||||
<div className="text-[#243204] mb-20 text-4xl font-blod">
|
||||
你的毛竹道具
|
||||
</div>
|
||||
<DragFill
|
||||
options={options}
|
||||
blanks={blanks}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
allowDuplicate={false}
|
||||
layout="horizontal-options"
|
||||
hideLabels={true}
|
||||
showSubmitButton={false}
|
||||
className="w-full"
|
||||
renderOption={(option, isUsed) => {
|
||||
const piece = pieces.find((p) => p.id === option.value);
|
||||
if (!piece) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center",
|
||||
" h-24 rounded-lg transition-all duration-200",
|
||||
"cursor-move select-none",
|
||||
isUsed
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:scale-110"
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={getStaticImage(piece.src)}
|
||||
alt={piece.id}
|
||||
className="w-full h-full object-contain pointer-events-none"
|
||||
draggable={false}
|
||||
objectFit="contain"
|
||||
/>
|
||||
<span className="text-white text-base mt-1 font-medium">
|
||||
{piece.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
renderDragging={(option) => {
|
||||
const piece = pieces.find((p) => p.id === option.value);
|
||||
if (!piece) return null;
|
||||
return (
|
||||
<div className="w-24 h-24">
|
||||
<Image
|
||||
src={getStaticImage(piece.src)}
|
||||
alt={piece.id}
|
||||
className="w-full h-full object-contain"
|
||||
draggable={false}
|
||||
objectFit="contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
renderBlank={() => {
|
||||
// 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位
|
||||
return <div className="hidden" />;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default BambooPuzzleGame;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as BambooPuzzleGame } from "./BambooPuzzleGame";
|
||||
export type { BambooPuzzleGameProps } from "./BambooPuzzleGame";
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { ReactNode, useState, Children, isValidElement } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn, getBackgroundImage } from "@/lib/utils";
|
||||
import { Overlay } from "@/components/overlay";
|
||||
import { useVideoContextSafe } from "@/contexts/VideoContext";
|
||||
|
||||
interface GuideStepsProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
currentStep?: number;
|
||||
onStepChange?: (step: number) => void;
|
||||
/** 每个步骤对应的高亮选择器数组,索引对应步骤序号 */
|
||||
highlightSelectors?: (string | string[])[];
|
||||
/** 控制是否显示遮罩层(包括背景蒙版和 Overlay 遮罩,默认:true,保持向后兼容) */
|
||||
showMask?: boolean;
|
||||
/** 自定义 z-index,用于控制层级(默认:50) */
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
interface GuideStepsItemProps {
|
||||
children: ReactNode;
|
||||
speaker?: "guide" | "student";
|
||||
}
|
||||
|
||||
const GuideSteps = ({
|
||||
children,
|
||||
className,
|
||||
visible,
|
||||
onVisibleChange,
|
||||
currentStep: controlledStep,
|
||||
onStepChange,
|
||||
highlightSelectors,
|
||||
showMask = true,
|
||||
zIndex = 50,
|
||||
}: GuideStepsProps) => {
|
||||
const [internalStep, setInternalStep] = useState(0);
|
||||
const [internalVisible, setInternalVisible] = useState(true);
|
||||
|
||||
// 安全地获取视频上下文(如果不在 VideoProvider 中,返回 null)
|
||||
const videoContext = useVideoContextSafe();
|
||||
|
||||
// 收集每个步骤的内容和 speaker 信息
|
||||
const stepsData = Children.toArray(children).map((child) => {
|
||||
if (
|
||||
isValidElement<GuideStepsItemProps>(child) &&
|
||||
child.type === GuideStepsItem
|
||||
) {
|
||||
return {
|
||||
content: child.props.children,
|
||||
speaker: child.props.speaker || "guide", // 默认为 "guide" 保持向后兼容
|
||||
};
|
||||
}
|
||||
// 如果不是 GuideStepsItem,保持原有行为
|
||||
return {
|
||||
content: child,
|
||||
speaker: "guide" as const,
|
||||
};
|
||||
});
|
||||
|
||||
const steps = stepsData.map((step) => step.content);
|
||||
const totalSteps = steps.length;
|
||||
|
||||
// 使用受控或非受控模式
|
||||
const isControlled = controlledStep !== undefined;
|
||||
const currentStep = isControlled ? controlledStep : internalStep;
|
||||
const baseVisible = visible !== undefined ? visible : internalVisible;
|
||||
|
||||
// 如果视频正在播放或内容不可见,则不显示 GuideSteps
|
||||
// 如果不在 VideoProvider 中(videoContext 为 null),则正常显示
|
||||
const isVisible =
|
||||
baseVisible &&
|
||||
(!videoContext ||
|
||||
(!videoContext.isVideoPlaying && videoContext.isContentVisible));
|
||||
|
||||
// 获取当前步骤的 speaker
|
||||
const currentSpeaker = stepsData[currentStep]?.speaker || "guide";
|
||||
|
||||
const updateStep = (newStep: number) => {
|
||||
if (!isControlled) {
|
||||
setInternalStep(newStep);
|
||||
}
|
||||
onStepChange?.(newStep);
|
||||
};
|
||||
|
||||
const updateVisible = (newVisible: boolean) => {
|
||||
if (visible === undefined) {
|
||||
setInternalVisible(newVisible);
|
||||
}
|
||||
onVisibleChange?.(newVisible);
|
||||
};
|
||||
|
||||
const handleNext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡
|
||||
if (currentStep < totalSteps - 1) {
|
||||
updateStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextClick = () => {
|
||||
if (currentStep < totalSteps - 1) {
|
||||
updateStep(currentStep + 1);
|
||||
} else {
|
||||
// 最后一步,隐藏组件
|
||||
updateVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前步骤的高亮选择器
|
||||
const currentHighlightSelectors =
|
||||
highlightSelectors && highlightSelectors[currentStep]
|
||||
? highlightSelectors[currentStep]
|
||||
: undefined;
|
||||
|
||||
// 根据 speaker 选择背景图片
|
||||
const currentBackgroundPath =
|
||||
currentSpeaker === "student" ? "components/引导板2" : "components/引导板";
|
||||
|
||||
// GuideSteps 内容
|
||||
const guideStepsContent = (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
key="guide-steps-container"
|
||||
className={cn(
|
||||
"fixed inset-0 flex items-center justify-center",
|
||||
className
|
||||
)}
|
||||
style={{ zIndex: zIndex + 1 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
{/* 引导板容器和背景 - 作为整体切换,确保 padding 和背景同步 */}
|
||||
{/* 只在 isVisible 为 true 时才渲染,避免阻塞 exit 动画 */}
|
||||
{isVisible && (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`container-${currentSpeaker}`}
|
||||
className={cn(
|
||||
"absolute bottom-0 w-[1764px] h-[656px] pt-75 pb-10",
|
||||
currentSpeaker === "student" ? "pl-50 pr-40" : "pr-80"
|
||||
)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
{/* 背景层 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage(currentBackgroundPath),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 引导文本区域 - 只在步骤变化时动画 */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`text-${currentStep}`}
|
||||
className="relative h-full py-20 pl-25 cursor-pointer select-none z-10"
|
||||
onClick={handleTextClick}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<div className="text-[#4e402e] text-4xl font-bold leading-15">
|
||||
{steps[currentStep]}
|
||||
</div>
|
||||
|
||||
{/* 下一步提示 - 最后一页不显示 */}
|
||||
{currentStep < totalSteps - 1 && (
|
||||
<div className="absolute bottom-15 right-4 flex items-center gap-2">
|
||||
<span
|
||||
onClick={handleNext}
|
||||
className="text-[#8B7355] text-2xl font-medium cursor-pointer hover:text-[#6d5a43] transition-colors select-none"
|
||||
>
|
||||
继续
|
||||
</span>
|
||||
<span className="text-[#8B7355] text-2xl animate-pulse">
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
// 始终使用 Overlay 包裹,showMask 由 Overlay 处理
|
||||
return (
|
||||
<Overlay
|
||||
selectors={currentHighlightSelectors || []}
|
||||
visible={isVisible}
|
||||
zIndex={zIndex}
|
||||
showMask={showMask}
|
||||
>
|
||||
{guideStepsContent}
|
||||
</Overlay>
|
||||
);
|
||||
};
|
||||
|
||||
const GuideStepsItem = ({ children }: GuideStepsItemProps) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export { GuideSteps, GuideStepsItem };
|
||||
@@ -0,0 +1 @@
|
||||
export { GuideSteps, GuideStepsItem } from "./GuideSteps";
|
||||
@@ -1,32 +1,10 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ImageProps } from "./image.types";
|
||||
|
||||
interface ImageProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
style?: React.CSSProperties;
|
||||
src: string;
|
||||
children?: React.ReactNode;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
objectFit?: "cover" | "contain" | "fill" | "none" | "scale-down";
|
||||
objectPosition?: string;
|
||||
loading?: "lazy" | "eager";
|
||||
placeholder?: string;
|
||||
fallback?: string;
|
||||
onLoad?: () => void;
|
||||
onError?: () => void;
|
||||
draggable?: boolean;
|
||||
quality?: number;
|
||||
blur?: boolean;
|
||||
button?: boolean;
|
||||
}
|
||||
|
||||
export default function Image({
|
||||
style,
|
||||
export function Image({
|
||||
src,
|
||||
children,
|
||||
alt,
|
||||
alt = "",
|
||||
className,
|
||||
width,
|
||||
height,
|
||||
@@ -38,9 +16,9 @@ export default function Image({
|
||||
onLoad,
|
||||
onError,
|
||||
draggable = false,
|
||||
quality: _quality = 80,
|
||||
blur = false,
|
||||
button = false,
|
||||
style,
|
||||
...restProps
|
||||
}: ImageProps) {
|
||||
const [imageSrc, setImageSrc] = useState(src);
|
||||
@@ -79,7 +57,11 @@ export default function Image({
|
||||
|
||||
// 懒加载处理
|
||||
useEffect(() => {
|
||||
if (loading === "lazy" && imgRef.current) {
|
||||
if (
|
||||
loading === "lazy" &&
|
||||
typeof IntersectionObserver !== "undefined" &&
|
||||
imgRef.current
|
||||
) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
@@ -98,23 +80,25 @@ export default function Image({
|
||||
}, [loading, src]);
|
||||
|
||||
const imageStyle: React.CSSProperties = {
|
||||
width: width,
|
||||
height: height,
|
||||
width,
|
||||
height,
|
||||
objectFit,
|
||||
objectPosition,
|
||||
filter: blur ? "blur(4px)" : "none",
|
||||
transition: "filter 0.3s ease",
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
"relative",
|
||||
width === "100%" ? "block" : "inline-block",
|
||||
button &&
|
||||
"cursor-pointer transition-all duration-300 hover:scale-105 hover:brightness-110",
|
||||
"cursor-pointer transition-all duration-300 hover:scale-105 hover:brightness-110 hover:contrast-110 active:scale-95",
|
||||
className
|
||||
)}
|
||||
style={{ width, height, ...style }}
|
||||
style={{ width, height }}
|
||||
{...restProps}
|
||||
>
|
||||
{/* 加载占位符 */}
|
||||
@@ -124,6 +108,7 @@ export default function Image({
|
||||
style={{
|
||||
backgroundImage: `url(${placeholder})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
>
|
||||
<div className="animate-pulse bg-gray-300 rounded w-full h-full" />
|
||||
@@ -169,12 +154,12 @@ export default function Image({
|
||||
draggable={draggable}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
"transition-opacity duration-300",
|
||||
isLoaded ? "opacity-100" : "opacity-0"
|
||||
isLoaded ? "opacity-100" : "opacity-0",
|
||||
"w-full h-full"
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
export interface ImageProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 图片地址 */
|
||||
src: string;
|
||||
/** 图片替代文本 */
|
||||
alt?: string;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** 图片宽度 */
|
||||
width?: number | string;
|
||||
/** 图片高度 */
|
||||
height?: number | string;
|
||||
/** 图片填充方式 */
|
||||
objectFit?: "cover" | "contain" | "fill" | "none" | "scale-down";
|
||||
/** 图片位置 */
|
||||
objectPosition?: string;
|
||||
/** 加载方式 */
|
||||
loading?: "lazy" | "eager";
|
||||
/** 占位图 */
|
||||
placeholder?: string;
|
||||
/** 加载失败时的回退图片 */
|
||||
fallback?: string;
|
||||
/** 图片加载完成回调 */
|
||||
onLoad?: () => void;
|
||||
/** 图片加载失败回调 */
|
||||
onError?: () => void;
|
||||
/** 是否可拖拽 */
|
||||
draggable?: boolean;
|
||||
/** 模糊效果 */
|
||||
blur?: boolean;
|
||||
/** 图片质量(仅用于提示) */
|
||||
quality?: number;
|
||||
/** 是否启用按钮样式(hover 和 active 效果) */
|
||||
button?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./image";
|
||||
export * from "./image.types";
|
||||
+73
-5
@@ -1,5 +1,73 @@
|
||||
export { default as Image } from "./Image";
|
||||
export { default as BackgroundImage } from "./BackgroundImage";
|
||||
export { WordViewer } from "./WordViewer";
|
||||
export { WordViewerModal } from "./WordViewerModal";
|
||||
export { Alert } from "./Alert";
|
||||
// Video component
|
||||
export { default as Video } from "./video";
|
||||
export type { VideoProps, VideoRef, VideoAnchor, AnchorContext } from "./video";
|
||||
|
||||
// Button components
|
||||
export { Button, ImageButton } from "./button";
|
||||
export type { BaseButtonProps, ImageButtonProps } from "./button";
|
||||
|
||||
// Image component
|
||||
export { Image } from "./image";
|
||||
export type { ImageProps } from "./image";
|
||||
|
||||
// Title
|
||||
export { Title } from "./title";
|
||||
|
||||
// Guide components
|
||||
export { GuideSteps, GuideStepsItem } from "./guide";
|
||||
|
||||
// Exhibit overlay
|
||||
export { ExhibitOverlay } from "./exhibit";
|
||||
export type { ExhibitOverlayProps } from "./exhibit";
|
||||
|
||||
// Overlay component
|
||||
export { Overlay } from "./overlay";
|
||||
export type { OverlayProps } from "./overlay";
|
||||
|
||||
// Spotlight component
|
||||
export { Spotlight } from "./spotlight";
|
||||
|
||||
// Quiz panel components
|
||||
export {
|
||||
QuizPanel,
|
||||
SingleChoiceQuizPanel,
|
||||
MultipleChoiceQuizPanel,
|
||||
} from "./layout/quiz";
|
||||
export type {
|
||||
QuizPanelProps,
|
||||
SingleChoiceQuizPanelProps,
|
||||
MultipleChoiceQuizPanelProps,
|
||||
} from "./layout/quiz";
|
||||
|
||||
//Cursor
|
||||
export { default as Cursor } from "@/components/cursor";
|
||||
|
||||
// Quiz components
|
||||
export { SingleChoice, MultipleChoice, DragFill } from "./quiz";
|
||||
export type {
|
||||
SingleChoiceProps,
|
||||
SingleChoiceOption,
|
||||
MultipleChoiceProps,
|
||||
MultipleChoiceOption,
|
||||
DragFillProps,
|
||||
DragFillOption,
|
||||
DragFillBlank,
|
||||
DragFillResult,
|
||||
} from "./quiz";
|
||||
|
||||
// Narration component
|
||||
export { Narration } from "./narration";
|
||||
export type { NarrationProps } from "./narration";
|
||||
|
||||
// Toast component
|
||||
export { toast, Toast, useToast, ToastProvider } from "./toast";
|
||||
export type {
|
||||
ToastType,
|
||||
ToastPosition,
|
||||
ToastOptions,
|
||||
ToastInstance,
|
||||
} from "./toast";
|
||||
|
||||
// Game components
|
||||
export { BambooPuzzleGame } from "./game";
|
||||
export type { BambooPuzzleGameProps } from "./game";
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Outlet, useMatches, useLocation } from "react-router-dom";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { getPublicPath } from "@/lib/utils";
|
||||
import type { VideoRouteHandle } from "@/router/types";
|
||||
import Video, { type VideoRef } from "@/components/video";
|
||||
import { VideoProvider } from "@/contexts/VideoContext";
|
||||
|
||||
/**
|
||||
* 故居布局组件
|
||||
* 用于所有故居页面的统一布局容器
|
||||
* 支持视频前置播放和平滑过渡动画
|
||||
* 渲染子路由内容
|
||||
*/
|
||||
const ResidenceLayout = () => {
|
||||
const matches = useMatches();
|
||||
const location = useLocation();
|
||||
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
const [isVideoFadingOut, setIsVideoFadingOut] = useState(false);
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
const previousPathRef = useRef<string>("");
|
||||
|
||||
// 获取当前路由的视频路径
|
||||
const videoSrc = useMemo(() => {
|
||||
const currentMatch = matches[matches.length - 1];
|
||||
return (currentMatch?.handle as VideoRouteHandle | undefined)?.videoSrc;
|
||||
}, [matches]);
|
||||
|
||||
// 判断是否是前进导航(而不是后退)
|
||||
const isForwardNavigation = useMemo(() => {
|
||||
const currentPath = location.pathname;
|
||||
const previousPath = previousPathRef.current || "";
|
||||
|
||||
// 提取场景编号(例如:/module/chenyun/place5 -> 5)
|
||||
const getCurrentPlaceNumber = (path: string) => {
|
||||
const match = path.match(/place(\d+)/);
|
||||
return match && match[1] ? parseInt(match[1], 10) : 0;
|
||||
};
|
||||
|
||||
const currentPlace = getCurrentPlaceNumber(currentPath);
|
||||
const previousPlace = getCurrentPlaceNumber(previousPath);
|
||||
|
||||
// 如果当前场景编号大于前一个场景编号,说明是前进
|
||||
// 如果是首次加载(previousPath为空),也视为前进
|
||||
return !previousPath || currentPlace > previousPlace;
|
||||
}, [location.pathname]);
|
||||
|
||||
// 处理视频播放完成(自然结束)
|
||||
const handleVideoEnded = () => {
|
||||
// 先让内容层开始淡入
|
||||
setIsContentVisible(true);
|
||||
// 开始视频层淡出动画
|
||||
setIsVideoFadingOut(true);
|
||||
// 然后延迟移除视频层,让视频层淡出动画完成
|
||||
setTimeout(() => {
|
||||
setIsVideoPlaying(false);
|
||||
setIsVideoFadingOut(false);
|
||||
}, 500); // 与 CSS transition 时间一致
|
||||
};
|
||||
|
||||
// 处理跳过操作
|
||||
const handleSkip = () => {
|
||||
// 立即暂停视频
|
||||
videoRef.current?.pause();
|
||||
// 立即显示内容层
|
||||
setIsContentVisible(true);
|
||||
// 立即移除视频层,不等待淡出动画
|
||||
setIsVideoPlaying(false);
|
||||
setIsVideoFadingOut(false);
|
||||
};
|
||||
|
||||
// 处理视频加载错误
|
||||
const handleVideoError = () => {
|
||||
// 视频加载失败,直接显示内容
|
||||
setIsVideoPlaying(false);
|
||||
setIsContentVisible(true);
|
||||
setIsVideoFadingOut(false);
|
||||
};
|
||||
|
||||
// 路由变化时重置状态
|
||||
useEffect(() => {
|
||||
// 只有在前进导航且有视频时才播放视频
|
||||
if (videoSrc && isForwardNavigation) {
|
||||
// 有视频且是前进,重置状态准备播放
|
||||
setIsVideoPlaying(true);
|
||||
setIsContentVisible(false);
|
||||
setIsVideoFadingOut(false);
|
||||
} else {
|
||||
// 没有视频或是后退,直接显示内容
|
||||
setIsVideoPlaying(false);
|
||||
setIsContentVisible(true);
|
||||
setIsVideoFadingOut(false);
|
||||
}
|
||||
|
||||
// 更新前一个路径
|
||||
previousPathRef.current = location.pathname;
|
||||
}, [location.pathname, videoSrc, isForwardNavigation]);
|
||||
|
||||
// 当视频元素渲染后,重置并确保播放
|
||||
useEffect(() => {
|
||||
if (isVideoPlaying && videoRef.current?.video && videoSrc) {
|
||||
const video = videoRef.current.video;
|
||||
video.currentTime = 0;
|
||||
video.play().catch(() => {
|
||||
// 自动播放失败,直接显示内容
|
||||
handleVideoError();
|
||||
});
|
||||
}
|
||||
}, [isVideoPlaying, videoSrc]);
|
||||
|
||||
return (
|
||||
<VideoProvider
|
||||
isVideoPlaying={isVideoPlaying}
|
||||
isContentVisible={isContentVisible}
|
||||
>
|
||||
<div className="relative w-full h-full">
|
||||
{/* 内容层 - 始终渲染,根据状态控制显示 */}
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
isContentVisible
|
||||
? "z-50 opacity-100" // 显示时在上层
|
||||
: "z-10 opacity-0 pointer-events-none" // 隐藏时在下层且不可交互
|
||||
} transition-opacity duration-300 ease-in`}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* 视频层 - 只在播放时渲染,z-index 动态调整 */}
|
||||
{isVideoPlaying && (
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
isVideoFadingOut
|
||||
? "z-40 opacity-0 pointer-events-none" // 淡出时移到下层
|
||||
: "z-50 opacity-100" // 播放时在上层
|
||||
} bg-black transition-opacity duration-500 ease-out`}
|
||||
>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
src={videoSrc ? getPublicPath(videoSrc) : undefined}
|
||||
autoPlay
|
||||
playsInline
|
||||
onEnded={handleVideoEnded}
|
||||
onError={handleVideoError}
|
||||
showSkipButton={true}
|
||||
skipText="跳过"
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</VideoProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResidenceLayout;
|
||||
Whitespace-only changes.
@@ -0,0 +1,72 @@
|
||||
import { Image } from "@/components";
|
||||
import { cn, getStaticImage } from "@/lib/utils";
|
||||
|
||||
export interface AlertProps {
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 副标题 */
|
||||
subtitle: string;
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert 组件 - 提示弹窗
|
||||
* @returns
|
||||
*/
|
||||
const Alert = ({
|
||||
title,
|
||||
subtitle,
|
||||
visible = true,
|
||||
onMaskClick,
|
||||
onClose,
|
||||
}: AlertProps) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center">
|
||||
{/* 蒙版背景 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${getStaticImage("components/蒙版遮罩")})`,
|
||||
}}
|
||||
onClick={onMaskClick || onClose}
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="relative w-full z-10 flex flex-col items-center justify-center gap-10">
|
||||
{/* 标题和副标题容器 - 带深色背景条 */}
|
||||
<div className="w-full bg-black/70 px-20 py-20 flex flex-col items-center gap-4">
|
||||
{/* 标题 */}
|
||||
<div
|
||||
className={cn([
|
||||
"absolute -top-10",
|
||||
"text-white text-7xl font-bold",
|
||||
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]",
|
||||
])}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{/* 副标题 */}
|
||||
<div className="text-white text-5xl font-medium tracking-widest">
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮:去了解 */}
|
||||
<Image
|
||||
src={getStaticImage("components/去了解")}
|
||||
onClick={onClose}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Image } from "@/components";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { ClueData } from "./types";
|
||||
import DialogueContent from "./DialogueContent";
|
||||
|
||||
export interface ClueContentProps {
|
||||
/** 线索数据 */
|
||||
clue: ClueData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索内容组件
|
||||
* 根据线索类型渲染不同的内容
|
||||
*/
|
||||
const ClueContent = ({ clue }: ClueContentProps) => {
|
||||
switch (clue.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<h2 className="text-4xl font-bold text-[#896a27] mb-4 text-center">
|
||||
{clue.title}
|
||||
</h2>
|
||||
{typeof clue.content === "string" ? (
|
||||
<div className="text-3xl leading-16 text-[#896a27] whitespace-pre-line text-center">
|
||||
{clue.content}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-3xl leading-16 text-[#896a27] text-center">
|
||||
{clue.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "map":
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Image
|
||||
src={getStaticImage(clue.mapImage)}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
alt="地图"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "audio":
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="text-5xl leading-relaxed text-white whitespace-pre-line text-center ">
|
||||
{clue.text}
|
||||
</div>
|
||||
<Image
|
||||
className="absolute left-0 bottom-10 w-full "
|
||||
src={getStaticImage(clue.audioImage)}
|
||||
alt="录音"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "dialogue":
|
||||
return <DialogueContent clue={clue} />;
|
||||
|
||||
case "custom":
|
||||
return <>{clue.content}</>;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default ClueContent;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Image } from "@/components";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { DialogueClueData } from "./types";
|
||||
|
||||
export interface DialogueContentProps {
|
||||
/** 对话线索数据 */
|
||||
clue: DialogueClueData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话内容组件
|
||||
* 依次显示多条对话,所有对话同时展示,带有动画效果
|
||||
*/
|
||||
const DialogueContent = ({ clue }: DialogueContentProps) => {
|
||||
const [visibleIndices, setVisibleIndices] = useState<number[]>([]);
|
||||
|
||||
// 依次显示对话:先显示第一条,延迟后显示第二条
|
||||
useEffect(() => {
|
||||
if (clue.dialogues.length === 0) return;
|
||||
|
||||
// 立即显示第一条对话
|
||||
setVisibleIndices([0]);
|
||||
|
||||
// 延迟显示后续对话
|
||||
if (clue.dialogues.length > 1) {
|
||||
const timer = setTimeout(() => {
|
||||
setVisibleIndices((prev) => [...prev, 1]);
|
||||
}, 1500); // 第一条显示1.5秒后显示第二条
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [clue.dialogues.length]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center gap-30 relative px-10">
|
||||
{clue.dialogues.map((dialogue, index) => {
|
||||
const isVisible = visibleIndices.includes(index);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 30, scale: 0.95 }}
|
||||
animate={
|
||||
isVisible
|
||||
? { opacity: 1, y: 0, scale: 1 }
|
||||
: { opacity: 0, y: 30, scale: 0.95 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1], // 自定义缓动函数,更流畅
|
||||
}}
|
||||
className="flex items-center gap-8 w-full max-w-7xl"
|
||||
>
|
||||
{/* 角色头像 */}
|
||||
{dialogue.avatar && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={
|
||||
isVisible ? { opacity: 1, x: 0 } : { opacity: 0, x: -20 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.1,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="flex-shrink-0 flex flex-col items-center"
|
||||
>
|
||||
<Image
|
||||
src={getStaticImage(dialogue.avatar)}
|
||||
className="w-60 h-60 object-contain"
|
||||
alt={dialogue.character}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={
|
||||
isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 10 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: 0.3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="mt-3 px-5 py-2 bg-gray-800/90 rounded-lg"
|
||||
>
|
||||
<span className="text-white text-base font-medium">
|
||||
{dialogue.character}
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 对话气泡 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={isVisible ? { opacity: 1, x: 0 } : { opacity: 0, x: 20 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.2,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="flex-1 relative"
|
||||
>
|
||||
<div className="bg-gray-800/95 rounded-3xl px-20 py-8 shadow-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={isVisible ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: 0.4,
|
||||
}}
|
||||
className="text-white text-4xl leading-relaxed"
|
||||
>
|
||||
{dialogue.text}
|
||||
</motion.div>
|
||||
</div>
|
||||
{/* 对话气泡箭头 - 指向左侧头像 */}
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3">
|
||||
<div className="w-0 h-0 border-t-[12px] border-t-transparent border-r-[12px] border-r-gray-800/95 border-b-[12px] border-b-transparent" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogueContent;
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useState, useMemo, MouseEvent, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn, getStaticImage } from "@/lib/utils";
|
||||
import { Image } from "@/components";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import ClueContent from "./ClueContent";
|
||||
import { ClueData } from "./types";
|
||||
|
||||
export interface ClueOverlayProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 线索数据(如果提供,会优先使用) */
|
||||
clue?: ClueData;
|
||||
/** 自定义内容(文本或其他内容,当 clue 未提供时使用) */
|
||||
children?: ReactNode;
|
||||
/** 额外的自定义类 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索展示框组件
|
||||
* - 中间内容区域可自定义(文本或其他内容)
|
||||
* - 使用背景资源:components/layout/纸(地图和音频类型除外)
|
||||
*/
|
||||
const ClueOverlay = ({
|
||||
visible = true,
|
||||
onClose,
|
||||
clue,
|
||||
children,
|
||||
className,
|
||||
}: ClueOverlayProps) => {
|
||||
const maskSrc = getStaticImage("components/蒙版遮罩");
|
||||
const backSrc = getStaticImage("components/关闭");
|
||||
const paperBgSrc = getStaticImage("components/layout/纸");
|
||||
|
||||
// 获取缩放参数,用于视觉对齐
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 判断是否需要显示纸张背景(地图、音频和对话类型不显示纸张背景)
|
||||
const shouldShowPaperBg =
|
||||
clue?.type !== "map" && clue?.type !== "audio" && clue?.type !== "dialogue";
|
||||
|
||||
// SSR 兼容:只在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleClose = (e: MouseEvent<HTMLImageElement>) => {
|
||||
e.stopPropagation();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
// 视觉对齐样式:确保与 App 内容对齐
|
||||
const overlayStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const scaledContainerStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
overflow: "hidden",
|
||||
}),
|
||||
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
|
||||
);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
key="clue-overlay"
|
||||
style={overlayStyle}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<div style={scaledContainerStyle}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center",
|
||||
"bg-black/70",
|
||||
className
|
||||
)}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
{/* 背景蒙版 */}
|
||||
{maskSrc && (
|
||||
<Image
|
||||
src={maskSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
{onClose && (
|
||||
<Image
|
||||
src={backSrc}
|
||||
onClick={handleClose}
|
||||
alt="关闭"
|
||||
className="absolute top-5 right-5 z-20"
|
||||
button
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="relative z-10 flex items-center justify-center w-full h-full">
|
||||
{shouldShowPaperBg ? (
|
||||
// 文本类型:显示纸张背景
|
||||
<div
|
||||
className="relative"
|
||||
style={{ minWidth: "600px", minHeight: "400px" }}
|
||||
>
|
||||
{/* 纸张背景图片 */}
|
||||
<Image
|
||||
src={paperBgSrc}
|
||||
className="w-full h-full object-contain pointer-events-none select-none"
|
||||
alt=""
|
||||
aria-hidden
|
||||
/>
|
||||
{/* 自定义内容 - 绝对定位在纸张上方 */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ padding: "60px 80px" }}
|
||||
>
|
||||
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||
{clue ? <ClueContent clue={clue} /> : children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 地图和音频类型:直接显示内容,无纸张背景
|
||||
<div className="max-w-full max-h-full w-full flex items-center justify-center">
|
||||
{clue ? <ClueContent clue={clue} /> : children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
// 直接使用 Portal 渲染到 document.body
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default ClueOverlay;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
/** 线索类型 */
|
||||
export type ClueType = "text" | "map" | "audio" | "dialogue" | "custom";
|
||||
|
||||
/** 文本线索数据 */
|
||||
export interface TextClueData {
|
||||
type: "text";
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 内容文本(支持字符串或 ReactNode,字符串可使用 \n 换行) */
|
||||
content: string | ReactNode;
|
||||
}
|
||||
|
||||
/** 地图线索数据 */
|
||||
export interface MapClueData {
|
||||
type: "map";
|
||||
/** 地图图片资源路径 */
|
||||
mapImage: string;
|
||||
}
|
||||
|
||||
/** 录音线索数据 */
|
||||
export interface AudioClueData {
|
||||
type: "audio";
|
||||
/** 文本内容 */
|
||||
text: string;
|
||||
/** 录音图片资源路径 */
|
||||
audioImage: string;
|
||||
}
|
||||
|
||||
/** 对话线索数据 */
|
||||
export interface DialogueClueData {
|
||||
type: "dialogue";
|
||||
/** 对话列表 */
|
||||
dialogues: Array<{
|
||||
/** 角色名称 */
|
||||
character: string;
|
||||
/** 角色头像资源路径 */
|
||||
avatar?: string;
|
||||
/** 对话文本 */
|
||||
text: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** 自定义线索数据 */
|
||||
export interface CustomClueData {
|
||||
type: "custom";
|
||||
/** 自定义内容 */
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
/** 线索数据联合类型 */
|
||||
export type ClueData =
|
||||
| TextClueData
|
||||
| MapClueData
|
||||
| AudioClueData
|
||||
| DialogueClueData
|
||||
| CustomClueData;
|
||||
@@ -0,0 +1,18 @@
|
||||
export { default as ResidenceLayout } from "./ResidenceLayout";
|
||||
export { default as ClueOverlay } from "./clue";
|
||||
export type { ClueOverlayProps } from "./clue";
|
||||
export { default as Alert } from "./alert";
|
||||
export type { AlertProps } from "./alert";
|
||||
export { default as SuccessAlert } from "./success-alert";
|
||||
export type { SuccessAlertProps } from "./success-alert";
|
||||
export { DragQuizPanel } from "./quiz";
|
||||
export type { DragQuizPanelProps } from "./quiz";
|
||||
export type {
|
||||
ClueData,
|
||||
ClueType,
|
||||
TextClueData,
|
||||
MapClueData,
|
||||
AudioClueData,
|
||||
DialogueClueData,
|
||||
CustomClueData,
|
||||
} from "./clue/types";
|
||||
@@ -0,0 +1,129 @@
|
||||
import { DragFill, type DragFillResult } from "@/components";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DragQuizPanelProps } from "./types";
|
||||
|
||||
/** 拖拽题面板:使用 DragFill 组件 */
|
||||
const DragQuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
title,
|
||||
questionPrefix,
|
||||
questionSuffix,
|
||||
options,
|
||||
blanks,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
}: DragQuizPanelProps) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<div className="relative w-[1728px] h-[972px] flex flex-col items-center pt-12 pb-14 gap-10">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1600px] min-h-[220px] rounded-[32px] bg-black/90 px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
<div className="text-[32px] leading-relaxed text-white">
|
||||
{title && <div className="mb-4">{title}</div>}
|
||||
<div>
|
||||
{questionPrefix && <span>{questionPrefix}</span>}
|
||||
{blanks.map((blank, index) => {
|
||||
const filledResult = value.find((r) => r.blankId === blank.id);
|
||||
const filledValue = filledResult?.optionValue || null;
|
||||
return (
|
||||
<span key={blank.id}>
|
||||
<span
|
||||
data-blank-id={blank.id}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center min-w-[140px] h-[56px] px-6 mx-3",
|
||||
"rounded-xl border-2 transition-all duration-300 shadow-[0_4px_16px_rgba(0,0,0,0.2)]",
|
||||
filledValue
|
||||
? "border-[#f0be75] bg-gradient-to-br from-[#3a3020] to-[#2a2520] backdrop-blur-sm"
|
||||
: "border-[#d4c5a9]/60 bg-white/10 backdrop-blur-sm hover:border-[#d4c5a9]"
|
||||
)}
|
||||
>
|
||||
{filledValue ? (
|
||||
<span className="text-[#f0be75] font-semibold flex items-center gap-3">
|
||||
<span className="text-[28px]">{filledValue}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-full bg-[#8b4513]/80 hover:bg-[#a0522d] text-white text-sm transition-all hover:scale-110 shadow-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(
|
||||
value.filter((r) => r.blankId !== blank.id)
|
||||
);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-white/40 text-[24px] font-light">
|
||||
____
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{index < blanks.length - 1 && (
|
||||
<span className="text-white/80 mx-1">、</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{questionSuffix && <span>{questionSuffix}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用 DragFill 组件 */}
|
||||
<div className="w-[1500px] mt-16">
|
||||
<DragFill
|
||||
options={options}
|
||||
blanks={blanks}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hideLabels
|
||||
showSubmitButton={false}
|
||||
layout="vertical"
|
||||
className="w-full"
|
||||
renderOption={(option, isUsed) => (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-[110px] px-10 flex items-center justify-center",
|
||||
"rounded-[32px] transition-all duration-300",
|
||||
"text-[32px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]",
|
||||
isUsed
|
||||
? "border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50"
|
||||
: "border-[#d4c5a9] bg-gradient-to-br from-[#4a3f35] to-[#3a2f25] text-[#f5e6d3] cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)] hover:scale-[1.02]"
|
||||
)}
|
||||
>
|
||||
{!isUsed && (
|
||||
<div className="absolute inset-0 rounded-[32px] bg-gradient-to-br from-[#d4c5a9]/20 to-transparent pointer-events-none" />
|
||||
)}
|
||||
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)]">
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
renderBlank={(_blank) => {
|
||||
// 返回一个隐藏元素,实际的空白区域在题干中
|
||||
// 但需要通过 data-blank-id 让 DragFill 能找到它
|
||||
return <div className="hidden" />;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-6 text-center text-[26px] text-[#ff4d4f]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DragQuizPanel;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useState } from "react";
|
||||
import { MultipleChoice } from "@/components";
|
||||
import { getBackgroundImage } from "@/lib/utils";
|
||||
import type { MultipleChoiceQuizPanelProps } from "./types";
|
||||
|
||||
/**
|
||||
* 多选题面板组件
|
||||
*
|
||||
* 内部管理多选状态,通过提交按钮提交并验证答案
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MultipleChoiceQuizPanel
|
||||
* visible={true}
|
||||
* title="这是一道多选题"
|
||||
* options={[
|
||||
* { value: "A", label: "选项A" },
|
||||
* { value: "B", label: "选项B" },
|
||||
* { value: "C", label: "选项C" },
|
||||
* ]}
|
||||
* correctAnswer={["A", "B"]}
|
||||
* errorMessage="很抱歉,你选错了!请你再思考一下吧!"
|
||||
* minSelections={1}
|
||||
* maxSelections={2}
|
||||
* minSelectionsErrorMessage="请至少选择 1 项才能提交"
|
||||
* onSubmit={(values, isCorrect) => {
|
||||
* console.log("用户选择:", values, "是否正确:", isCorrect);
|
||||
* }}
|
||||
* onError={(error) => {
|
||||
* console.log("错误信息:", error);
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const MultipleChoiceQuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
title,
|
||||
options,
|
||||
correctAnswer,
|
||||
errorMessage,
|
||||
minSelectionsErrorMessage,
|
||||
onSubmit,
|
||||
onError,
|
||||
disabled = false,
|
||||
minSelections,
|
||||
maxSelections,
|
||||
submitText = "提交",
|
||||
}: MultipleChoiceQuizPanelProps) => {
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
// 处理选项点击
|
||||
const handleOptionChange = (values: string[]) => {
|
||||
if (disabled) return;
|
||||
setSelectedValues(values);
|
||||
setError("");
|
||||
};
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = () => {
|
||||
if (disabled) return;
|
||||
|
||||
// 检查最小选择数量
|
||||
if (minSelections !== undefined && selectedValues.length < minSelections) {
|
||||
const errorMsg =
|
||||
minSelectionsErrorMessage || `请至少选择 ${minSelections} 项`;
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证答案
|
||||
let isCorrect: boolean | undefined = undefined;
|
||||
if (correctAnswer !== undefined) {
|
||||
// 比较两个数组是否相等(不考虑顺序)
|
||||
const correctSet = new Set(correctAnswer);
|
||||
const selectedSet = new Set(selectedValues);
|
||||
|
||||
isCorrect =
|
||||
correctSet.size === selectedSet.size &&
|
||||
[...correctSet].every((val) => selectedSet.has(val));
|
||||
|
||||
if (isCorrect) {
|
||||
// 答题成功:清空状态,执行 onSubmit(外部控制关闭窗口)
|
||||
const submittedValues = [...selectedValues];
|
||||
setSelectedValues([]);
|
||||
setError("");
|
||||
onSubmit?.(submittedValues, true);
|
||||
} else {
|
||||
// 答题失败:清空状态,执行 onError,允许重新选择
|
||||
setSelectedValues([]);
|
||||
const errorMsg = errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
}
|
||||
} else {
|
||||
// 没有提供正确答案,直接提交
|
||||
const submittedValues = [...selectedValues];
|
||||
setSelectedValues([]);
|
||||
setError("");
|
||||
onSubmit?.(submittedValues, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<div className="relative w-[1728px] h-[972px] flex flex-col items-center py-8">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1600px] min-h-[200px] max-h-[280px] rounded-[32px] bg-black px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] flex-shrink-0">
|
||||
<div className="text-[34px] leading-relaxed text-white">{title}</div>
|
||||
</div>
|
||||
|
||||
{/* 选项区域 - 可滚动 */}
|
||||
<div className="mt-8 w-[1500px] flex-1 overflow-y-auto overflow-x-hidden px-4 min-h-0 quiz-scrollbar">
|
||||
<MultipleChoice
|
||||
options={options}
|
||||
value={selectedValues}
|
||||
disabled={disabled}
|
||||
minSelections={minSelections}
|
||||
maxSelections={maxSelections}
|
||||
showSubmitButton={false}
|
||||
onChange={handleOptionChange}
|
||||
renderOption={(option, isSelected) => {
|
||||
const isDisabled =
|
||||
disabled ||
|
||||
(!isSelected &&
|
||||
maxSelections !== undefined &&
|
||||
selectedValues.length >= maxSelections);
|
||||
|
||||
// 处理选项点击
|
||||
const handleClick = () => {
|
||||
if (isDisabled) return;
|
||||
|
||||
let newValues: string[];
|
||||
if (isSelected) {
|
||||
// 取消选择
|
||||
newValues = selectedValues.filter((v) => v !== option.value);
|
||||
// 检查最小选择数量限制
|
||||
if (
|
||||
minSelections !== undefined &&
|
||||
newValues.length < minSelections
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 选择
|
||||
// 检查最大选择数量限制
|
||||
if (
|
||||
maxSelections !== undefined &&
|
||||
selectedValues.length >= maxSelections
|
||||
) {
|
||||
return;
|
||||
}
|
||||
newValues = [...selectedValues, option.value];
|
||||
}
|
||||
|
||||
handleOptionChange(newValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={option.value} className="mb-4">
|
||||
<div
|
||||
className="w-full max-w-[1431px] min-h-[120px] h-auto py-4 flex items-center px-16 text-[28px] leading-relaxed text-[#fdf5e6] bg-cover bg-center bg-no-repeat transition-all duration-150"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage(
|
||||
isSelected
|
||||
? "components/quiz/选中"
|
||||
: "components/quiz/未选择"
|
||||
),
|
||||
filter: isSelected ? undefined : "brightness(0.96)",
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
cursor: isDisabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={(e) => {
|
||||
if (isSelected || isDisabled) return;
|
||||
(
|
||||
e.currentTarget as HTMLDivElement
|
||||
).style.backgroundImage =
|
||||
getBackgroundImage("components/quiz/选中");
|
||||
(e.currentTarget as HTMLDivElement).style.filter = "";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (isSelected || isDisabled) return;
|
||||
(
|
||||
e.currentTarget as HTMLDivElement
|
||||
).style.backgroundImage = getBackgroundImage(
|
||||
"components/quiz/未选择"
|
||||
);
|
||||
(e.currentTarget as HTMLDivElement).style.filter =
|
||||
"brightness(0.96)";
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮和错误信息 - 固定在底部,不滚动 */}
|
||||
<div className="w-[1500px] flex-shrink-0 mt-4">
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mb-4 text-center text-[26px] text-[#ff4d4f]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
disabled ||
|
||||
selectedValues.length === 0 ||
|
||||
(minSelections !== undefined &&
|
||||
selectedValues.length < minSelections)
|
||||
}
|
||||
className="px-30 py-4 text-[28px] font-medium text-white bg-[#b4915a] rounded-[40px] transition-all duration-200 hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
|
||||
>
|
||||
{submitText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultipleChoiceQuizPanel;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { SingleChoice } from "@/components";
|
||||
import { getBackgroundImage } from "@/lib/utils";
|
||||
import type { QuizPanelProps } from "./types";
|
||||
|
||||
/** 单选题展示面板:题干使用“底板”背景,点击即选,hover 高亮 */
|
||||
const QuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
title,
|
||||
options,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
}: QuizPanelProps) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<div className="relative w-[1728px] h-[972px] flex flex-col items-center justify-center">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1600px] min-h-[260px] rounded-[32px] bg-black px-20 py-14 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
<div className="text-[34px] leading-relaxed text-white">{title}</div>
|
||||
</div>
|
||||
|
||||
{/* 选项区域 */}
|
||||
<div className="mt-16 w-[1500px]">
|
||||
<SingleChoice
|
||||
options={options}
|
||||
value={value}
|
||||
renderOption={(option, isSelected) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="mb-6 cursor-pointer"
|
||||
onClick={() => onChange?.(option.value)}
|
||||
>
|
||||
<div
|
||||
className="w-[1431px] h-[156px] flex items-center px-16 text-[28px] leading-snug text-[#fdf5e6] bg-cover bg-center bg-no-repeat transition-all duration-150"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage(
|
||||
isSelected
|
||||
? "components/quiz/选中"
|
||||
: "components/quiz/未选择"
|
||||
),
|
||||
filter: isSelected ? undefined : "brightness(0.96)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isSelected) return;
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundImage =
|
||||
getBackgroundImage("components/quiz/选中");
|
||||
(e.currentTarget as HTMLDivElement).style.filter = "";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (isSelected) return;
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundImage =
|
||||
getBackgroundImage("components/quiz/未选择");
|
||||
(e.currentTarget as HTMLDivElement).style.filter =
|
||||
"brightness(0.96)";
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-6 text-center text-[26px] text-[#ff4d4f]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuizPanel;
|
||||
@@ -0,0 +1,211 @@
|
||||
# Quiz Panel 组件
|
||||
|
||||
用于展示单选题和多选题的面板组件,内部管理选择状态,通过 `onSubmit` 回调通知外部。
|
||||
|
||||
## 组件列表
|
||||
|
||||
- **SingleChoiceQuizPanel** - 单选题面板
|
||||
- **MultipleChoiceQuizPanel** - 多选题面板
|
||||
- **QuizPanel** - 旧版组件(已废弃,保留以保持向后兼容)
|
||||
|
||||
## SingleChoiceQuizPanel - 单选题面板
|
||||
|
||||
### 功能特性
|
||||
|
||||
- ✅ 内部管理选择状态
|
||||
- ✅ 点击选项后自动提交
|
||||
- ✅ 支持答案验证(可选)
|
||||
- ✅ 自动显示错误提示
|
||||
- ✅ 提交后禁用交互
|
||||
|
||||
### 使用方法
|
||||
|
||||
```tsx
|
||||
import { SingleChoiceQuizPanel } from "@/components";
|
||||
|
||||
function MyComponent() {
|
||||
const [showQuiz, setShowQuiz] = useState(false);
|
||||
|
||||
return (
|
||||
<SingleChoiceQuizPanel
|
||||
visible={showQuiz}
|
||||
title="这是一道单选题:以下哪个选项是正确的?"
|
||||
options={[
|
||||
{ value: "A", label: "选项A" },
|
||||
{ value: "B", label: "选项B" },
|
||||
{ value: "C", label: "选项C" },
|
||||
]}
|
||||
correctAnswer="B"
|
||||
errorMessage="再想一想,注意题干中的时间和提示信息哦~"
|
||||
onSubmit={(value, isCorrect) => {
|
||||
console.log("用户选择:", value);
|
||||
console.log("是否正确:", isCorrect);
|
||||
if (isCorrect) {
|
||||
// 选择正确,可以关闭面板或进入下一步
|
||||
setShowQuiz(false);
|
||||
}
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.log("错误信息:", error);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| visible | boolean | - | 是否可见(必填) |
|
||||
| zIndex | number | 10000 | z-index 层级 |
|
||||
| title | string | - | 题目(必填) |
|
||||
| options | SingleChoiceOption[] | - | 选项列表(必填) |
|
||||
| correctAnswer | string | - | 正确答案(可选,用于验证) |
|
||||
| errorMessage | string | "很抱歉,你选错了!请你再思考一下吧!" | 自定义错误提示信息(答案错误时显示) |
|
||||
| onSubmit | (value: string, isCorrect: boolean) => void | - | 提交回调 |
|
||||
| onError | (error: string) => void | - | 错误回调 |
|
||||
| disabled | boolean | false | 是否禁用 |
|
||||
|
||||
### 行为说明
|
||||
|
||||
1. **点击选项**:立即触发提交,如果提供了 `correctAnswer`,会自动验证
|
||||
2. **答案正确**:调用 `onSubmit(value, true)`,面板进入已提交状态
|
||||
3. **答案错误**:显示错误提示,调用 `onError(error)` 和 `onSubmit(value, false)`
|
||||
4. **未提供正确答案**:直接调用 `onSubmit(value, true)`,不进行验证
|
||||
|
||||
## MultipleChoiceQuizPanel - 多选题面板
|
||||
|
||||
### 功能特性
|
||||
|
||||
- ✅ 内部管理多选状态
|
||||
- ✅ 支持最小/最大选择数量限制
|
||||
- ✅ 通过提交按钮提交
|
||||
- ✅ 支持答案验证(可选)
|
||||
- ✅ 自动显示错误提示
|
||||
- ✅ 提交后禁用交互
|
||||
|
||||
### 使用方法
|
||||
|
||||
```tsx
|
||||
import { MultipleChoiceQuizPanel } from "@/components";
|
||||
|
||||
function MyComponent() {
|
||||
const [showQuiz, setShowQuiz] = useState(false);
|
||||
|
||||
return (
|
||||
<MultipleChoiceQuizPanel
|
||||
visible={showQuiz}
|
||||
title="这是一道多选题:以下哪些选项是正确的?(可多选)"
|
||||
options={[
|
||||
{ value: "A", label: "选项A" },
|
||||
{ value: "B", label: "选项B" },
|
||||
{ value: "C", label: "选项C" },
|
||||
{ value: "D", label: "选项D" },
|
||||
]}
|
||||
correctAnswer={["A", "B"]}
|
||||
errorMessage="很抱歉,你选错了!请你再思考一下吧!"
|
||||
minSelections={1}
|
||||
maxSelections={3}
|
||||
minSelectionsErrorMessage="请至少选择 1 项才能提交"
|
||||
submitText="确认提交"
|
||||
onSubmit={(values, isCorrect) => {
|
||||
console.log("用户选择:", values);
|
||||
console.log("是否正确:", isCorrect);
|
||||
if (isCorrect) {
|
||||
setShowQuiz(false);
|
||||
}
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.log("错误信息:", error);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| visible | boolean | - | 是否可见(必填) |
|
||||
| zIndex | number | 10000 | z-index 层级 |
|
||||
| title | string | - | 题目(必填) |
|
||||
| options | MultipleChoiceOption[] | - | 选项列表(必填) |
|
||||
| correctAnswer | string[] | - | 正确答案数组(可选,用于验证) |
|
||||
| errorMessage | string | "很抱歉,你选错了!请你再思考一下吧!" | 自定义错误提示信息(答案错误时显示) |
|
||||
| minSelectionsErrorMessage | string | `请至少选择 ${minSelections} 项` | 自定义最小选择数量不足时的提示信息 |
|
||||
| onSubmit | (values: string[], isCorrect: boolean) => void | - | 提交回调 |
|
||||
| onError | (error: string) => void | - | 错误回调 |
|
||||
| disabled | boolean | false | 是否禁用 |
|
||||
| minSelections | number | - | 最小选择数量 |
|
||||
| maxSelections | number | - | 最大选择数量 |
|
||||
| submitText | string | "提交" | 提交按钮文本 |
|
||||
|
||||
### 行为说明
|
||||
|
||||
1. **选择选项**:点击选项进行多选,受 `minSelections` 和 `maxSelections` 限制
|
||||
2. **点击提交**:验证最小选择数量,如果提供了 `correctAnswer`,会验证答案
|
||||
3. **答案正确**:调用 `onSubmit(values, true)`,面板进入已提交状态
|
||||
4. **答案错误**:显示错误提示,调用 `onError(error)` 和 `onSubmit(values, false)`
|
||||
5. **未提供正确答案**:直接调用 `onSubmit(values, true)`,不进行验证
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从旧版 QuizPanel 迁移
|
||||
|
||||
**旧版用法:**
|
||||
```tsx
|
||||
<QuizPanel
|
||||
visible={showQuiz}
|
||||
title="题目"
|
||||
options={options}
|
||||
value={quizAnswer}
|
||||
error={quizError}
|
||||
onChange={(val) => {
|
||||
setQuizAnswer(val);
|
||||
if (val === "B") {
|
||||
setQuizError("");
|
||||
} else {
|
||||
setQuizError("很抱歉,你选错了!");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
**新版用法:**
|
||||
```tsx
|
||||
<SingleChoiceQuizPanel
|
||||
visible={showQuiz}
|
||||
title="题目"
|
||||
options={options}
|
||||
correctAnswer="B"
|
||||
onSubmit={(value, isCorrect) => {
|
||||
if (isCorrect) {
|
||||
// 选择正确
|
||||
} else {
|
||||
// 选择错误,错误信息已由组件内部显示
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### 主要变化
|
||||
|
||||
1. **不再需要 `onChange`**:组件内部管理选择状态
|
||||
2. **不再需要 `value` 和 `error`**:组件内部管理
|
||||
3. **使用 `onSubmit`**:替代 `onChange`,返回用户选择和是否正确
|
||||
4. **使用 `correctAnswer`**:让组件自动验证答案
|
||||
5. **使用 `onError`**:可选,用于处理错误信息
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 两个组件都使用内部状态管理,不需要外部传入 `value`
|
||||
2. 提交后组件会进入禁用状态,防止重复提交
|
||||
3. 如果提供了 `correctAnswer`,组件会自动验证并显示错误信息
|
||||
4. 多选题的答案比较不考虑顺序,只要选择的选项和正确答案一致即可
|
||||
5. 旧版 `QuizPanel` 仍然可用,但建议迁移到新组件
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from "react";
|
||||
import { SingleChoice } from "@/components";
|
||||
import { getBackgroundImage } from "@/lib/utils";
|
||||
import type { SingleChoiceQuizPanelProps } from "./types";
|
||||
|
||||
/**
|
||||
* 单选题面板组件
|
||||
*
|
||||
* 内部管理选择状态,点击选项后自动提交并验证答案
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SingleChoiceQuizPanel
|
||||
* visible={true}
|
||||
* title="这是一道单选题"
|
||||
* options={[
|
||||
* { value: "A", label: "选项A" },
|
||||
* { value: "B", label: "选项B" },
|
||||
* ]}
|
||||
* correctAnswer="B"
|
||||
* errorMessage="再想一想,注意题干中的时间和提示信息哦~"
|
||||
* onSubmit={(value, isCorrect) => {
|
||||
* console.log("用户选择:", value, "是否正确:", isCorrect);
|
||||
* }}
|
||||
* onError={(error) => {
|
||||
* console.log("错误信息:", error);
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const SingleChoiceQuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
title,
|
||||
options,
|
||||
correctAnswer,
|
||||
errorMessage,
|
||||
onSubmit,
|
||||
onError,
|
||||
disabled = false,
|
||||
}: SingleChoiceQuizPanelProps) => {
|
||||
const [selectedValue, setSelectedValue] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
// 处理选项点击
|
||||
const handleOptionClick = (value: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
setSelectedValue(value);
|
||||
setError("");
|
||||
|
||||
// 验证答案
|
||||
const isCorrect =
|
||||
correctAnswer !== undefined ? value === correctAnswer : undefined;
|
||||
|
||||
// 如果提供了正确答案,进行验证
|
||||
if (correctAnswer !== undefined) {
|
||||
if (isCorrect) {
|
||||
// 答题成功:清空状态,执行 onSubmit(外部控制关闭窗口)
|
||||
setSelectedValue("");
|
||||
setError("");
|
||||
onSubmit?.(value, true);
|
||||
} else {
|
||||
// 答题失败:清空状态,执行 onError,允许重新选择
|
||||
setSelectedValue("");
|
||||
const errorMsg = errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
}
|
||||
} else {
|
||||
// 没有提供正确答案,直接提交
|
||||
setSelectedValue("");
|
||||
setError("");
|
||||
onSubmit?.(value, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<div className="relative w-[1728px] h-[972px] flex flex-col items-center justify-center">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1600px] min-h-[260px] rounded-[32px] bg-black px-20 py-14 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
<div className="text-[34px] leading-relaxed text-white">{title}</div>
|
||||
</div>
|
||||
|
||||
{/* 选项区域 */}
|
||||
<div className="mt-16 w-[1500px]">
|
||||
<SingleChoice
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
disabled={disabled}
|
||||
renderOption={(option, isSelected) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="mb-6 cursor-pointer"
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
>
|
||||
<div
|
||||
className="w-[1431px] h-[156px] flex items-center px-16 text-[28px] leading-snug text-[#fdf5e6] bg-cover bg-center bg-no-repeat transition-all duration-150"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage(
|
||||
isSelected
|
||||
? "components/quiz/选中"
|
||||
: "components/quiz/未选择"
|
||||
),
|
||||
filter: isSelected ? undefined : "brightness(0.96)",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isSelected || disabled) return;
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundImage =
|
||||
getBackgroundImage("components/quiz/选中");
|
||||
(e.currentTarget as HTMLDivElement).style.filter = "";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (isSelected || disabled) return;
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundImage =
|
||||
getBackgroundImage("components/quiz/未选择");
|
||||
(e.currentTarget as HTMLDivElement).style.filter =
|
||||
"brightness(0.96)";
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-6 text-center text-[26px] text-[#ff4d4f]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleChoiceQuizPanel;
|
||||
@@ -0,0 +1,13 @@
|
||||
// 新组件
|
||||
export { default as SingleChoiceQuizPanel } from "./SingleChoiceQuizPanel";
|
||||
export { default as MultipleChoiceQuizPanel } from "./MultipleChoiceQuizPanel";
|
||||
export { default as DragQuizPanel } from "./DragQuizPanel";
|
||||
export type {
|
||||
SingleChoiceQuizPanelProps,
|
||||
MultipleChoiceQuizPanelProps,
|
||||
DragQuizPanelProps,
|
||||
} from "./types";
|
||||
|
||||
// 保留旧组件以保持向后兼容
|
||||
export { default as QuizPanel } from "./QuizPanal";
|
||||
export type { QuizPanelProps } from "./types";
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
SingleChoiceOption,
|
||||
MultipleChoiceOption,
|
||||
DragFillResult,
|
||||
DragFillBlank,
|
||||
} from "@/components";
|
||||
|
||||
/** 拖拽题面板配置 */
|
||||
export interface DragQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
/** 题目标题(可选) */
|
||||
title?: string;
|
||||
/** 题干前缀文本(空白区域之前的文本) */
|
||||
questionPrefix?: string;
|
||||
/** 题干后缀文本(空白区域之后的文本) */
|
||||
questionSuffix?: string;
|
||||
/** 选项列表 */
|
||||
options: { value: string; label: string }[];
|
||||
/** 空白区域列表 */
|
||||
blanks: DragFillBlank[];
|
||||
/** 当前值 */
|
||||
value: DragFillResult[];
|
||||
/** 错误提示信息 */
|
||||
error?: string;
|
||||
/** 值变化回调 */
|
||||
onChange: (value: DragFillResult[]) => void;
|
||||
}
|
||||
|
||||
/** 单选题面板配置 */
|
||||
export interface SingleChoiceQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
/** 题目 */
|
||||
title: string;
|
||||
/** 选项列表 */
|
||||
options: SingleChoiceOption[];
|
||||
/** 正确答案(可选,用于验证) */
|
||||
correctAnswer?: string;
|
||||
/** 自定义错误提示信息(答案错误时显示) */
|
||||
errorMessage?: string;
|
||||
/** 提交回调,返回用户选择和是否正确 */
|
||||
onSubmit?: (value: string, isCorrect: boolean) => void;
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** 多选题面板配置 */
|
||||
export interface MultipleChoiceQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
/** 题目 */
|
||||
title: string;
|
||||
/** 选项列表 */
|
||||
options: MultipleChoiceOption[];
|
||||
/** 正确答案数组(可选,用于验证) */
|
||||
correctAnswer?: string[];
|
||||
/** 自定义错误提示信息(答案错误时显示) */
|
||||
errorMessage?: string;
|
||||
/** 自定义最小选择数量不足时的提示信息 */
|
||||
minSelectionsErrorMessage?: string;
|
||||
/** 提交回调,返回用户选择和是否正确 */
|
||||
onSubmit?: (values: string[], isCorrect: boolean) => void;
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 最小选择数量 */
|
||||
minSelections?: number;
|
||||
/** 最大选择数量 */
|
||||
maxSelections?: number;
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
}
|
||||
|
||||
/** @deprecated 使用 SingleChoiceQuizPanelProps 替代 */
|
||||
export interface QuizPanelProps {
|
||||
visible: boolean;
|
||||
zIndex?: number;
|
||||
title: string;
|
||||
options: SingleChoiceOption[];
|
||||
value: string;
|
||||
error?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Image } from "@/components";
|
||||
import { getStaticImage, getBackgroundImage } from "@/lib/utils";
|
||||
import type { MouseEvent } from "react";
|
||||
|
||||
export interface SuccessAlertProps {
|
||||
/** 标题 */
|
||||
title?: string;
|
||||
/** 内容文本 */
|
||||
content?: string;
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
/** 继续按钮回调 */
|
||||
onContinue?: () => void;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SuccessAlert 组件 - 成功提示弹窗
|
||||
* 使用 components/正确版 作为背景
|
||||
*/
|
||||
const SuccessAlert = ({
|
||||
title = "恭喜你, 选择正确!",
|
||||
content,
|
||||
visible = true,
|
||||
onMaskClick,
|
||||
onContinue,
|
||||
onClose,
|
||||
}: SuccessAlertProps) => {
|
||||
if (!visible) return null;
|
||||
|
||||
const handleContinue = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// 只调用 onContinue,它会处理关闭逻辑
|
||||
if (onContinue) {
|
||||
onContinue();
|
||||
} else if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
|
||||
{/* 蒙版背景 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage("components/蒙版遮罩"),
|
||||
}}
|
||||
onClick={onMaskClick || onClose}
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<div
|
||||
className="relative w-full h-full z-10 flex flex-col items-center justify-center pointer-events-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 文本内容 */}
|
||||
<div className="relative z-10 w-full h-[619px] flex flex-col items-center justify-center gap-8 pt-20 pointer-events-none">
|
||||
{/* 背景图片 - 使用 components/正确版 */}
|
||||
<img
|
||||
className="absolute inset-0 top-0 -z-10"
|
||||
src={getStaticImage("components/正确版")}
|
||||
alt="正确版"
|
||||
/>
|
||||
{/* 标题 */}
|
||||
{title && (
|
||||
<div className="text-[#ffbe57] text-6xl font-bold text-center">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容文本 */}
|
||||
{content && (
|
||||
<div className="text-white text-4xl font-medium max-w-[1600px] leading-relaxed px-20">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 继续按钮 */}
|
||||
<div className="mt-8 relative z-20 pointer-events-auto">
|
||||
<div
|
||||
className="w-[414px] h-[109px] bg-cover bg-center bg-no-repeat cursor-pointer hover:scale-105 hover:brightness-110 transition-all duration-300"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
${getBackgroundImage("components/继续前进")},
|
||||
${getBackgroundImage("components/按钮")}
|
||||
`,
|
||||
}}
|
||||
onClick={handleContinue}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuccessAlert;
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface NarrationProps {
|
||||
/** 旁白文本 */
|
||||
text: string;
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 语音播报的音频文件路径(可选) */
|
||||
audioSrc?: string;
|
||||
/** 自定义样式类名 */
|
||||
className?: string;
|
||||
/** 是否启用逐字动画,默认 true */
|
||||
enableAnimation?: boolean;
|
||||
/** 文字动画速度(毫秒/字),默认 100ms */
|
||||
animationSpeed?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narration - 旁白组件
|
||||
*
|
||||
* 功能:
|
||||
* - 文字逐字显示动画
|
||||
* - 可选语音播报
|
||||
* - 关闭按钮
|
||||
*/
|
||||
const Narration = ({
|
||||
text,
|
||||
visible,
|
||||
onClose,
|
||||
audioSrc,
|
||||
className,
|
||||
enableAnimation = true,
|
||||
animationSpeed = 100,
|
||||
}: NarrationProps) => {
|
||||
const [displayedText, setDisplayedText] = useState("");
|
||||
const [isAnimating, setIsAnimating] = useState(true);
|
||||
const [audio, setAudio] = useState<HTMLAudioElement | null>(null);
|
||||
const [isSkipped, setIsSkipped] = useState(false);
|
||||
|
||||
// 文字动画效果
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setDisplayedText("");
|
||||
setIsAnimating(true);
|
||||
setIsSkipped(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已经跳过,不再执行动画
|
||||
if (isSkipped) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果不启用动画,直接显示全部文本
|
||||
if (!enableAnimation) {
|
||||
setDisplayedText(text);
|
||||
setIsAnimating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = 0;
|
||||
setDisplayedText("");
|
||||
setIsAnimating(true);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (currentIndex < text.length) {
|
||||
setDisplayedText(text.slice(0, currentIndex + 1));
|
||||
currentIndex++;
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, animationSpeed);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [text, visible, enableAnimation, animationSpeed, isSkipped]);
|
||||
|
||||
// 跳过动画
|
||||
const handleSkip = () => {
|
||||
setDisplayedText(text);
|
||||
setIsAnimating(false);
|
||||
setIsSkipped(true);
|
||||
};
|
||||
|
||||
// 语音播报
|
||||
useEffect(() => {
|
||||
if (!visible || !audioSrc) return;
|
||||
|
||||
const audioElement = new Audio(audioSrc);
|
||||
audioElement.play().catch((error) => {
|
||||
console.warn("Audio playback failed:", error);
|
||||
});
|
||||
setAudio(audioElement);
|
||||
|
||||
return () => {
|
||||
audioElement.pause();
|
||||
audioElement.currentTime = 0;
|
||||
};
|
||||
}, [visible, audioSrc]);
|
||||
|
||||
// 停止音频
|
||||
const handleClose = () => {
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80 z-50">
|
||||
<div
|
||||
className={cn(
|
||||
"w-[1600px] bg-black/90 rounded-[32px] px-20 py-16",
|
||||
"shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]",
|
||||
"relative",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* 跳过按钮 - 只在动画进行中显示 */}
|
||||
{isAnimating && enableAnimation && (
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="absolute top-8 right-8 px-6 py-2 rounded-full bg-black/60 text-white text-xl backdrop-blur-sm border border-white/30 hover:bg-black/75 transition-colors"
|
||||
>
|
||||
跳过
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="text-white text-3xl leading-relaxed space-y-8">
|
||||
<p className="whitespace-pre-wrap">{displayedText}</p>
|
||||
</div>
|
||||
|
||||
{/* 关闭按钮 - 只在动画完成后显示 */}
|
||||
{!isAnimating && (
|
||||
<div className="flex justify-center mt-14">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-12 py-2 bg-gradient-to-br from-[#d4c5a9] to-[#b8a88a] text-[#4e402e] text-3xl font-bold rounded-full border-3 border-[#8B7355] shadow-lg hover:scale-105 transition-transform"
|
||||
>
|
||||
已知晓
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Narration;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Narration } from "./Narration";
|
||||
export type { NarrationProps } from "./Narration";
|
||||
@@ -0,0 +1,514 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import { getBlockingDivs } from "@/components/spotlight/utils";
|
||||
|
||||
export interface OverlayProps {
|
||||
/** CSS 选择器,支持多个元素(逗号分隔或数组) */
|
||||
selectors: string | string[];
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 子元素内容 */
|
||||
children?: ReactNode;
|
||||
/** 遮罩层背景色(默认:rgba(0, 0, 0, 0.5)) */
|
||||
maskColor?: string;
|
||||
/** 高亮边框颜色(默认:#3b82f6) */
|
||||
highlightColor?: string;
|
||||
/** 高亮边框宽度(默认:3px) */
|
||||
highlightWidth?: number;
|
||||
/** 高亮圆角(默认:8px) */
|
||||
borderRadius?: number;
|
||||
/** 是否显示阴影/光晕效果 */
|
||||
showShadow?: boolean;
|
||||
/** 自定义样式类名 */
|
||||
className?: string;
|
||||
/** 自定义样式 */
|
||||
style?: React.CSSProperties;
|
||||
/** z-index 层级(默认:40,低于 GuideSteps 的 z-50) */
|
||||
zIndex?: number;
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
/** 是否显示遮罩层(默认:true) */
|
||||
showMask?: boolean;
|
||||
}
|
||||
|
||||
interface ElementRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay 组件 - 用于高亮页面中的特定元素
|
||||
*
|
||||
* 功能:
|
||||
* - 通过 CSS 选择器定位目标元素(支持多个)
|
||||
* - 计算元素位置并创建高亮区域
|
||||
* - 支持自定义样式(边框、阴影、遮罩颜色等)
|
||||
* - 支持 children,可以包裹其他组件(如 GuideSteps)
|
||||
* - 使用 Portal 渲染到 document.body,确保层级正确
|
||||
*/
|
||||
const Overlay = ({
|
||||
selectors,
|
||||
visible = true,
|
||||
children,
|
||||
maskColor = "rgba(0, 0, 0, 0.5)",
|
||||
highlightColor = "#3b82f6",
|
||||
highlightWidth = 3,
|
||||
borderRadius = 8,
|
||||
showShadow = false,
|
||||
className,
|
||||
style,
|
||||
zIndex = 40,
|
||||
onMaskClick,
|
||||
showMask = true,
|
||||
}: OverlayProps) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [elementRects, setElementRects] = useState<ElementRect[]>([]);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const mutationObserverRef = useRef<MutationObserver | null>(null);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
const elementsCacheRef = useRef<Element[]>([]);
|
||||
const prevRectsRef = useRef<ElementRect[]>([]);
|
||||
|
||||
// 获取缩放参数,用于视觉对齐
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
|
||||
// 生成唯一的 mask ID
|
||||
const maskId = useMemo(
|
||||
() => `overlay-mask-${Math.random().toString(36).substr(2, 9)}`,
|
||||
[]
|
||||
);
|
||||
|
||||
// 防抖函数
|
||||
const debounce = useCallback((fn: () => void, delay: number) => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
debounceTimerRef.current = window.setTimeout(fn, delay);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// SSR 兼容:只在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 获取所有匹配的元素(带缓存)
|
||||
const getElements = useCallback(() => {
|
||||
if (typeof document === "undefined") return [];
|
||||
|
||||
const selectorArray =
|
||||
typeof selectors === "string" ? [selectors] : selectors;
|
||||
const elements: Element[] = [];
|
||||
|
||||
selectorArray.forEach((selector) => {
|
||||
try {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
elements.push(...Array.from(matches));
|
||||
} catch (error) {
|
||||
console.warn(`Invalid selector: ${selector}`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 缓存元素列表
|
||||
elementsCacheRef.current = elements;
|
||||
return elements;
|
||||
}, [selectors]);
|
||||
|
||||
// 计算元素位置(带浅比较优化)
|
||||
const calculateRects = useCallback(() => {
|
||||
const elements =
|
||||
elementsCacheRef.current.length > 0
|
||||
? elementsCacheRef.current
|
||||
: getElements();
|
||||
const rects: ElementRect[] = [];
|
||||
|
||||
elements.forEach((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
rects.push({
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 浅比较,避免不必要的更新
|
||||
const hasChanged =
|
||||
rects.length !== prevRectsRef.current.length ||
|
||||
rects.some((rect, index) => {
|
||||
const prev = prevRectsRef.current[index];
|
||||
return (
|
||||
!prev ||
|
||||
rect.x !== prev.x ||
|
||||
rect.y !== prev.y ||
|
||||
rect.width !== prev.width ||
|
||||
rect.height !== prev.height
|
||||
);
|
||||
});
|
||||
|
||||
if (hasChanged) {
|
||||
prevRectsRef.current = rects;
|
||||
setElementRects(rects);
|
||||
}
|
||||
}, [getElements]);
|
||||
|
||||
// 使用 requestAnimationFrame 批量更新位置
|
||||
const scheduleUpdate = useCallback(() => {
|
||||
if (rafRef.current) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
calculateRects();
|
||||
rafRef.current = null;
|
||||
});
|
||||
}, [calculateRects]);
|
||||
|
||||
// 监听窗口变化(带防抖优化)
|
||||
useEffect(() => {
|
||||
if (!visible || !mounted) return;
|
||||
|
||||
const debouncedUpdate = debounce(scheduleUpdate, 150);
|
||||
const handleResize = debouncedUpdate;
|
||||
const handleScroll = debouncedUpdate;
|
||||
|
||||
window.addEventListener("resize", handleResize, { passive: true });
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
// 初始计算
|
||||
scheduleUpdate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [visible, mounted, scheduleUpdate, debounce]);
|
||||
|
||||
// 使用 ResizeObserver 监听元素尺寸变化(带防抖优化)
|
||||
useEffect(() => {
|
||||
if (!visible || !mounted) return;
|
||||
|
||||
const elements = getElements();
|
||||
if (elements.length === 0) return;
|
||||
|
||||
const debouncedUpdate = debounce(scheduleUpdate, 150);
|
||||
const observer = new ResizeObserver(debouncedUpdate);
|
||||
|
||||
elements.forEach((element) => {
|
||||
observer.observe(element);
|
||||
});
|
||||
|
||||
resizeObserverRef.current = observer;
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
};
|
||||
}, [visible, mounted, getElements, scheduleUpdate, debounce]);
|
||||
|
||||
// 使用 MutationObserver 监听 DOM 变化(优化:只监听目标元素及其父级,带防抖)
|
||||
useEffect(() => {
|
||||
if (!visible || !mounted) return;
|
||||
|
||||
const elements =
|
||||
elementsCacheRef.current.length > 0
|
||||
? elementsCacheRef.current
|
||||
: getElements();
|
||||
|
||||
if (elements.length === 0) return;
|
||||
|
||||
const debouncedUpdate = debounce(scheduleUpdate, 200);
|
||||
const observer = new MutationObserver(debouncedUpdate);
|
||||
|
||||
// 只监听目标元素及其父级,而不是整个 body
|
||||
const observedNodes = new Set<Node>();
|
||||
elements.forEach((element) => {
|
||||
let current: Node | null = element;
|
||||
while (current && current !== document.body) {
|
||||
if (!observedNodes.has(current)) {
|
||||
observer.observe(current, {
|
||||
childList: true,
|
||||
subtree: false,
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style"],
|
||||
});
|
||||
observedNodes.add(current);
|
||||
}
|
||||
current = current.parentNode;
|
||||
}
|
||||
});
|
||||
|
||||
mutationObserverRef.current = observer;
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mutationObserverRef.current = null;
|
||||
};
|
||||
}, [visible, mounted, getElements, scheduleUpdate, debounce]);
|
||||
|
||||
// 生成 SVG mask 的挖洞路径(使用 useMemo 缓存,转换为设计稿坐标)
|
||||
const maskHoles = useMemo(() => {
|
||||
if (elementRects.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const padding = highlightWidth + borderRadius;
|
||||
const holes: string[] = [];
|
||||
|
||||
elementRects.forEach((rect) => {
|
||||
// 将屏幕坐标转换为设计稿坐标
|
||||
const designX = (rect.x - offsetX) / scale;
|
||||
const designY = (rect.y - offsetY) / scale;
|
||||
const designWidth = rect.width / scale;
|
||||
const designHeight = rect.height / scale;
|
||||
const designPadding = padding / scale;
|
||||
const designR = borderRadius / scale;
|
||||
|
||||
const x = designX - designPadding;
|
||||
const y = designY - designPadding;
|
||||
const width = designWidth + designPadding * 2;
|
||||
const height = designHeight + designPadding * 2;
|
||||
const r = designR;
|
||||
|
||||
// 创建圆角矩形路径(用于挖洞)
|
||||
const path =
|
||||
`M ${x} ${y + r} ` +
|
||||
`A ${r} ${r} 0 0 1 ${x + r} ${y} ` +
|
||||
`L ${x + width - r} ${y} ` +
|
||||
`A ${r} ${r} 0 0 1 ${x + width} ${y + r} ` +
|
||||
`L ${x + width} ${y + height - r} ` +
|
||||
`A ${r} ${r} 0 0 1 ${x + width - r} ${y + height} ` +
|
||||
`L ${x + r} ${y + height} ` +
|
||||
`A ${r} ${r} 0 0 1 ${x} ${y + height - r} Z`;
|
||||
|
||||
holes.push(path);
|
||||
});
|
||||
|
||||
return holes;
|
||||
}, [elementRects, highlightWidth, borderRadius, scale, offsetX, offsetY]);
|
||||
|
||||
// 生成阻止点击的 div 块(使用 Spotlight 的共享工具函数)
|
||||
// 因为 SVG mask 不能阻止点击,需要使用 div 块覆盖非高亮区域
|
||||
const blockingDivs = useMemo(() => {
|
||||
if (elementRects.length === 0) return [];
|
||||
|
||||
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 0;
|
||||
const viewportHeight =
|
||||
typeof window !== "undefined" ? window.innerHeight : 0;
|
||||
|
||||
const padding = highlightWidth + borderRadius;
|
||||
// 计算带 padding 的矩形区域(转换为 Spotlight 需要的格式)
|
||||
const paddedRects = elementRects.map((rect) => ({
|
||||
top: rect.y - padding,
|
||||
left: rect.x - padding,
|
||||
width: rect.width + padding * 2,
|
||||
height: rect.height + padding * 2,
|
||||
}));
|
||||
|
||||
return getBlockingDivs(paddedRects, viewportWidth, viewportHeight);
|
||||
}, [elementRects, highlightWidth, borderRadius]);
|
||||
|
||||
// 缓存视口尺寸(使用设计稿尺寸,应用缩放)
|
||||
const viewportSize = useMemo(
|
||||
() => ({
|
||||
width: DESIGN_WIDTH,
|
||||
height: DESIGN_HEIGHT,
|
||||
}),
|
||||
[DESIGN_WIDTH, DESIGN_HEIGHT]
|
||||
);
|
||||
|
||||
// 缩放容器样式(应用 ScaleContext 的缩放)- 必须在条件返回之前
|
||||
const scaledContainerStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
overflow: "hidden",
|
||||
pointerEvents: "auto", // 允许点击事件穿透
|
||||
}),
|
||||
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
|
||||
);
|
||||
|
||||
// 渲染逻辑:
|
||||
// 1. 如果有高亮元素,显示遮罩和高亮边框
|
||||
// 2. 如果有 children,显示遮罩(即使没有高亮元素,用于包裹其他组件)
|
||||
// 3. 如果 showMask 为 false,则不显示遮罩
|
||||
const hasHighlightElements = elementRects.length > 0;
|
||||
const shouldRenderMask =
|
||||
showMask &&
|
||||
visible &&
|
||||
mounted &&
|
||||
(hasHighlightElements || children !== undefined);
|
||||
const shouldRenderChildren = visible && mounted && children !== undefined;
|
||||
|
||||
if (!shouldRenderMask && !shouldRenderChildren) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 外层容器样式(全屏,不缩放)
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex,
|
||||
pointerEvents: "none",
|
||||
...style,
|
||||
};
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{visible && mounted && (
|
||||
<motion.div
|
||||
key="overlay"
|
||||
style={overlayStyle}
|
||||
className={className}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
{/* 阻止点击层 - 使用 fixed 定位覆盖非高亮区域(集成自 Spotlight) */}
|
||||
{hasHighlightElements &&
|
||||
blockingDivs.map((block, index) => (
|
||||
<div
|
||||
key={`block-${index}`}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: `${block.top}px`,
|
||||
left:
|
||||
typeof block.left === "number"
|
||||
? `${block.left}px`
|
||||
: block.left,
|
||||
width:
|
||||
typeof block.width === "number"
|
||||
? `${block.width}px`
|
||||
: block.width,
|
||||
height:
|
||||
typeof block.height === "number"
|
||||
? `${block.height}px`
|
||||
: block.height,
|
||||
pointerEvents: "auto",
|
||||
zIndex: zIndex - 1, // 在遮罩层下方,但能阻止点击
|
||||
}}
|
||||
onClick={onMaskClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div style={scaledContainerStyle}>
|
||||
{/* SVG 遮罩层 */}
|
||||
{shouldRenderMask && (
|
||||
<svg
|
||||
width={viewportSize.width}
|
||||
height={viewportSize.height}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 0,
|
||||
pointerEvents: "none", // SVG 不处理点击,由 blocking divs 处理
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<mask id={maskId}>
|
||||
{/* 全屏白色区域(显示遮罩) */}
|
||||
<rect
|
||||
width={viewportSize.width}
|
||||
height={viewportSize.height}
|
||||
fill="white"
|
||||
/>
|
||||
{/* 黑色区域(挖洞,隐藏遮罩)- 只在有高亮元素时显示 */}
|
||||
{hasHighlightElements &&
|
||||
maskHoles.map((hole, _index) => (
|
||||
<path key={_index} d={hole} fill="black" />
|
||||
))}
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
width={viewportSize.width}
|
||||
height={viewportSize.height}
|
||||
fill={maskColor}
|
||||
mask={`url(#${maskId})`}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* children 内容 */}
|
||||
{shouldRenderChildren && (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 高亮边框层 - 只在有高亮元素时显示 */}
|
||||
{hasHighlightElements &&
|
||||
elementRects.map((rect) => {
|
||||
// 将屏幕坐标转换为设计稿坐标(考虑缩放和偏移)
|
||||
const designX = (rect.x - offsetX) / scale;
|
||||
const designY = (rect.y - offsetY) / scale;
|
||||
const designWidth = rect.width / scale;
|
||||
const designHeight = rect.height / scale;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${rect.x}-${rect.y}-${rect.width}-${rect.height}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${designX}px`,
|
||||
top: `${designY}px`,
|
||||
width: `${designWidth}px`,
|
||||
height: `${designHeight}px`,
|
||||
border: `${
|
||||
highlightWidth / scale
|
||||
}px solid ${highlightColor}`,
|
||||
borderRadius: `${borderRadius / scale}px`,
|
||||
pointerEvents: "none",
|
||||
zIndex: 2,
|
||||
boxShadow: showShadow
|
||||
? `0 0 0 ${
|
||||
(highlightWidth * 2) / scale
|
||||
}px ${highlightColor}40, 0 0 ${
|
||||
(highlightWidth * 4) / scale
|
||||
}px ${highlightColor}60`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default Overlay;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Overlay } from "./Overlay";
|
||||
export type { OverlayProps } from "./Overlay";
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import DragFill from "./drag-fill";
|
||||
import type { DragFillProps } from "./drag-fill.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const meta: Meta<typeof DragFill> = {
|
||||
title: "Components/Quiz/DragFill",
|
||||
component: DragFill,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
options: {
|
||||
control: { type: "object" },
|
||||
description: "可拖拽的选项列表",
|
||||
},
|
||||
blanks: {
|
||||
control: { type: "object" },
|
||||
description: "空白区域列表",
|
||||
},
|
||||
value: {
|
||||
control: { type: "object" },
|
||||
description: "当前填充结果(受控模式)",
|
||||
},
|
||||
defaultValue: {
|
||||
control: { type: "object" },
|
||||
description: "默认填充结果",
|
||||
},
|
||||
disabled: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否禁用整个组件",
|
||||
},
|
||||
allowDuplicate: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否允许重复选择同一选项",
|
||||
},
|
||||
showSubmitButton: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否显示提交按钮",
|
||||
},
|
||||
submitText: {
|
||||
control: { type: "text" },
|
||||
description: "提交按钮文本",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DragFill>;
|
||||
|
||||
// 基础示例
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D"],
|
||||
blanks: ["空白1", "空白2", "空白3"],
|
||||
onSubmit: (results) => {
|
||||
console.log("填充结果:", results);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 使用选项和空白对象
|
||||
export const WithObjects: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: "opt1", label: "选项一" },
|
||||
{ value: "opt2", label: "选项二" },
|
||||
{ value: "opt3", label: "选项三(禁用)", disabled: true },
|
||||
{ value: "opt4", label: "选项四" },
|
||||
],
|
||||
blanks: [
|
||||
{ id: "blank1", label: "第一个空白", required: true },
|
||||
{ id: "blank2", label: "第二个空白" },
|
||||
{ id: "blank3", label: "第三个空白(必填)", required: true },
|
||||
],
|
||||
onSubmit: (results) => {
|
||||
console.log("填充结果:", results);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 受控模式
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState<any[]>([]);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DragFill
|
||||
options={["选项A", "选项B", "选项C"]}
|
||||
blanks={["空白1", "空白2"]}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={(results) => {
|
||||
alert(`提交的结果: ${JSON.stringify(results)}`);
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
当前填充: {JSON.stringify(value)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// 允许重复选择
|
||||
export const AllowDuplicate: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
blanks: ["空白1", "空白2", "空白3"],
|
||||
allowDuplicate: true,
|
||||
onSubmit: (results) => {
|
||||
console.log("填充结果:", results);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 禁用状态
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
blanks: ["空白1", "空白2"],
|
||||
disabled: true,
|
||||
defaultValue: [{ blankId: "blank1", optionValue: "选项A" }],
|
||||
},
|
||||
};
|
||||
|
||||
// 不显示提交按钮
|
||||
export const WithoutSubmitButton: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
blanks: ["空白1", "空白2"],
|
||||
showSubmitButton: false,
|
||||
onChange: (results) => {
|
||||
console.log("填充变化:", results);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 自定义样式
|
||||
export const CustomStyled: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D"],
|
||||
blanks: ["空白1", "空白2"],
|
||||
className: "max-w-2xl mx-auto p-6 bg-gray-50 rounded-xl",
|
||||
optionClassName: "text-lg font-semibold",
|
||||
blankClassName: "min-h-[100px]",
|
||||
submitText: "确认答案",
|
||||
onSubmit: (results) => {
|
||||
console.log("填充结果:", results);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 自定义渲染
|
||||
export const CustomRender: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DragFill
|
||||
options={["选项A", "选项B", "选项C"]}
|
||||
blanks={["空白1", "空白2"]}
|
||||
renderOption={(option, isUsed) => (
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 rounded-lg border-2 transition-all cursor-move",
|
||||
isUsed
|
||||
? "border-gray-200 bg-gray-100 opacity-50"
|
||||
: "border-blue-300 bg-blue-50 hover:border-blue-500"
|
||||
)}
|
||||
draggable={!isUsed}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">📦</span>
|
||||
<span className="font-medium">{option.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
renderBlank={(blank, filledValue) => (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-[100px] p-6 rounded-lg border-2 border-dashed flex items-center justify-center",
|
||||
filledValue
|
||||
? "border-green-400 bg-green-50"
|
||||
: "border-purple-300 bg-purple-50"
|
||||
)}
|
||||
>
|
||||
{filledValue ? (
|
||||
<div className="text-green-700 font-bold text-lg">
|
||||
✓ {filledValue}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-purple-500 text-center">
|
||||
<div className="text-3xl mb-2">⬇️</div>
|
||||
<div>{blank.label || "拖拽到这里"}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onSubmit={(results) => {
|
||||
console.log("填充结果:", results);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
import { useState, useRef, useEffect, useCallback, forwardRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
DragFillProps,
|
||||
DragFillOption,
|
||||
DragFillBlank,
|
||||
DragFillResult,
|
||||
} from "./drag-fill.types";
|
||||
|
||||
/**
|
||||
* DragFill - 拖拽填空组件(使用鼠标事件实现)
|
||||
*
|
||||
* 用于问答场景的拖拽填空功能,支持将选项拖拽到空白处进行填充
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DragFill
|
||||
* options={["选项1", "选项2", "选项3"]}
|
||||
* blanks={["空白1", "空白2"]}
|
||||
* onSubmit={(results) => console.log(results)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
(
|
||||
{
|
||||
options,
|
||||
blanks,
|
||||
value: controlledValue,
|
||||
defaultValue = [],
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
allowDuplicate = false,
|
||||
layout = "vertical",
|
||||
hideLabels = false,
|
||||
className,
|
||||
optionClassName,
|
||||
blankClassName,
|
||||
submitText = "提交",
|
||||
showSubmitButton = true,
|
||||
renderOption,
|
||||
renderBlank,
|
||||
renderDragging,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// 标准化选项数据
|
||||
const normalizedOptions: DragFillOption[] = options.map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return { value: option, label: option };
|
||||
}
|
||||
return option;
|
||||
});
|
||||
|
||||
// 标准化空白区域数据
|
||||
const normalizedBlanks: DragFillBlank[] = blanks.map((blank, index) => {
|
||||
if (typeof blank === "string") {
|
||||
return { id: `blank-${index}`, label: blank };
|
||||
}
|
||||
return blank;
|
||||
});
|
||||
|
||||
// 受控/非受控模式
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const [internalValue, setInternalValue] = useState<DragFillResult[]>(
|
||||
defaultValue || []
|
||||
);
|
||||
const currentValue = isControlled ? controlledValue : internalValue;
|
||||
|
||||
// 拖拽状态
|
||||
const [draggingOption, setDraggingOption] = useState<string | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [hoveredBlank, setHoveredBlank] = useState<string | null>(null);
|
||||
const dragOffsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const blankRefsMap = useRef<Map<string, HTMLElement>>(new Map());
|
||||
|
||||
// 检查选项是否已被使用
|
||||
const isOptionUsed = useCallback(
|
||||
(optionValue: string): boolean => {
|
||||
if (allowDuplicate) return false;
|
||||
return currentValue.some(
|
||||
(result) => result.optionValue === optionValue
|
||||
);
|
||||
},
|
||||
[allowDuplicate, currentValue]
|
||||
);
|
||||
|
||||
// 处理鼠标移动
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!draggingOption) return;
|
||||
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
|
||||
// 检测是否悬停在空白区域上
|
||||
let foundBlank: string | null = null;
|
||||
|
||||
// 首先检查组件内的空白区域
|
||||
blankRefsMap.current.forEach((element, blankId) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom
|
||||
) {
|
||||
foundBlank = blankId;
|
||||
}
|
||||
});
|
||||
|
||||
// 如果没找到,尝试查找页面中所有带 data-blank-id 的元素
|
||||
if (!foundBlank) {
|
||||
normalizedBlanks.forEach((blank) => {
|
||||
const element = document.querySelector(
|
||||
`[data-blank-id="${blank.id}"]`
|
||||
);
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom
|
||||
) {
|
||||
foundBlank = blank.id;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setHoveredBlank(foundBlank);
|
||||
},
|
||||
[draggingOption, normalizedBlanks]
|
||||
);
|
||||
|
||||
// 处理鼠标释放
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!draggingOption) return;
|
||||
|
||||
// 如果悬停在空白区域上,执行放置
|
||||
if (hoveredBlank) {
|
||||
const optionValue = draggingOption;
|
||||
|
||||
// 检查是否允许重复
|
||||
if (!allowDuplicate) {
|
||||
const isAlreadyUsed = currentValue.some(
|
||||
(result) => result.optionValue === optionValue
|
||||
);
|
||||
if (isAlreadyUsed) {
|
||||
// 如果已使用,先移除旧的填充
|
||||
const newValue = currentValue.filter(
|
||||
(result) => result.optionValue !== optionValue
|
||||
);
|
||||
// 添加新的填充
|
||||
const updatedValue = [
|
||||
...newValue,
|
||||
{ blankId: hoveredBlank, optionValue },
|
||||
];
|
||||
if (!isControlled) {
|
||||
setInternalValue(updatedValue);
|
||||
}
|
||||
onChange?.(updatedValue);
|
||||
setDraggingOption(null);
|
||||
setDragPosition(null);
|
||||
setHoveredBlank(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查空白区域是否已有填充
|
||||
const existingIndex = currentValue.findIndex(
|
||||
(result) => result.blankId === hoveredBlank
|
||||
);
|
||||
|
||||
let updatedValue: DragFillResult[];
|
||||
if (existingIndex >= 0) {
|
||||
// 替换现有填充
|
||||
updatedValue = [...currentValue];
|
||||
updatedValue[existingIndex] = { blankId: hoveredBlank, optionValue };
|
||||
} else {
|
||||
// 添加新填充
|
||||
updatedValue = [
|
||||
...currentValue,
|
||||
{ blankId: hoveredBlank, optionValue },
|
||||
];
|
||||
}
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(updatedValue);
|
||||
}
|
||||
onChange?.(updatedValue);
|
||||
}
|
||||
|
||||
setDraggingOption(null);
|
||||
setDragPosition(null);
|
||||
setHoveredBlank(null);
|
||||
}, [
|
||||
draggingOption,
|
||||
hoveredBlank,
|
||||
allowDuplicate,
|
||||
currentValue,
|
||||
isControlled,
|
||||
onChange,
|
||||
]);
|
||||
|
||||
// 添加全局鼠标事件监听
|
||||
useEffect(() => {
|
||||
if (draggingOption) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [draggingOption, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// 处理鼠标按下开始拖拽
|
||||
const handleMouseDown = (e: React.MouseEvent, optionValue: string) => {
|
||||
if (disabled) return;
|
||||
const option = normalizedOptions.find((opt) => opt.value === optionValue);
|
||||
if (option?.disabled) return;
|
||||
|
||||
// 检查是否已使用
|
||||
if (!allowDuplicate && isOptionUsed(optionValue)) return;
|
||||
|
||||
// 获取实际的元素(如果有自定义渲染,使用第一个子元素)
|
||||
const wrapperElement = e.currentTarget as HTMLElement;
|
||||
const actualElement = (
|
||||
renderOption ? wrapperElement.firstElementChild : wrapperElement
|
||||
) as HTMLElement;
|
||||
|
||||
if (!actualElement) return;
|
||||
|
||||
const rect = actualElement.getBoundingClientRect();
|
||||
|
||||
// 记录偏移量和元素尺寸
|
||||
dragOffsetRef.current = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
};
|
||||
|
||||
setDraggingOption(optionValue);
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
// 移除填充
|
||||
const handleRemoveFill = (blankId: string) => {
|
||||
if (disabled) return;
|
||||
const updatedValue = currentValue.filter(
|
||||
(result) => result.blankId !== blankId
|
||||
);
|
||||
if (!isControlled) {
|
||||
setInternalValue(updatedValue);
|
||||
}
|
||||
onChange?.(updatedValue);
|
||||
};
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = () => {
|
||||
if (disabled) return;
|
||||
// 检查必填项
|
||||
const requiredBlanks = normalizedBlanks.filter((blank) => blank.required);
|
||||
const filledBlankIds = currentValue.map((result) => result.blankId);
|
||||
const allRequiredFilled = requiredBlanks.every((blank) =>
|
||||
filledBlankIds.includes(blank.id)
|
||||
);
|
||||
if (!allRequiredFilled) {
|
||||
return;
|
||||
}
|
||||
onSubmit?.(currentValue);
|
||||
};
|
||||
|
||||
// 获取空白区域的填充值
|
||||
const getBlankFill = (blankId: string): string | null => {
|
||||
const result = currentValue.find((result) => result.blankId === blankId);
|
||||
return result ? result.optionValue : null;
|
||||
};
|
||||
|
||||
// 默认渲染选项
|
||||
const defaultRenderOption = (option: DragFillOption, isUsed: boolean) => {
|
||||
const isDragging = draggingOption === option.value;
|
||||
return (
|
||||
<div
|
||||
onMouseDown={(e) => handleMouseDown(e, option.value)}
|
||||
className={cn(
|
||||
"px-6 py-4 border-2 rounded-lg transition-all select-none",
|
||||
!disabled &&
|
||||
!option.disabled &&
|
||||
!isUsed &&
|
||||
"cursor-move hover:border-orange-400 hover:bg-orange-50",
|
||||
isDragging
|
||||
? "opacity-50 border-orange-500 bg-orange-100"
|
||||
: "border-gray-300 bg-white text-gray-700",
|
||||
(disabled || option.disabled || isUsed) &&
|
||||
"opacity-50 cursor-not-allowed",
|
||||
optionClassName
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 8h16M4 16h16"
|
||||
/>
|
||||
</svg>
|
||||
<span>{option.label}</span>
|
||||
{isUsed && !allowDuplicate && (
|
||||
<span className="ml-auto text-xs text-gray-500">已使用</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 默认渲染空白区域
|
||||
const defaultRenderBlank = (
|
||||
blank: DragFillBlank,
|
||||
filledValue: string | null
|
||||
) => {
|
||||
const isHovered = hoveredBlank === blank.id;
|
||||
const option = filledValue
|
||||
? normalizedOptions.find((opt) => opt.value === filledValue)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
blankRefsMap.current.set(blank.id, el);
|
||||
} else {
|
||||
blankRefsMap.current.delete(blank.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"min-h-[80px] px-6 py-4 border-2 border-dashed rounded-lg transition-all",
|
||||
"flex items-center justify-between",
|
||||
isHovered && draggingOption
|
||||
? "border-orange-500 bg-orange-50"
|
||||
: filledValue
|
||||
? "border-green-500 bg-green-50"
|
||||
: "border-gray-300 bg-gray-50",
|
||||
blank.required && !filledValue && "border-red-300",
|
||||
blankClassName
|
||||
)}
|
||||
>
|
||||
{filledValue ? (
|
||||
<>
|
||||
<span className="text-green-700 font-medium">
|
||||
{option?.label || filledValue}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={() => handleRemoveFill(blank.id)}
|
||||
className="text-red-500 hover:text-red-700 transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-500">
|
||||
{blank.label || "拖拽选项到这里"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex gap-6 w-full h-full",
|
||||
layout === "horizontal" ? "flex-row items-start" : "flex-col",
|
||||
layout === "horizontal-options" && "flex-col",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* 选项区域 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3",
|
||||
layout === "horizontal" && "flex-1",
|
||||
layout === "horizontal-options" && "w-full flex-1"
|
||||
)}
|
||||
>
|
||||
{!hideLabels && (
|
||||
<h3 className="text-lg font-semibold text-gray-700">选项</h3>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
layout === "horizontal-options"
|
||||
? "flex flex-row gap-6 flex-wrap w-full"
|
||||
: cn(
|
||||
"grid gap-x-12 gap-y-16",
|
||||
normalizedOptions.length > 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-1"
|
||||
)
|
||||
)}
|
||||
>
|
||||
{normalizedOptions.map((option) => {
|
||||
const isUsed = isOptionUsed(option.value);
|
||||
const isDragging = draggingOption === option.value;
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onMouseDown={(e) => {
|
||||
// 只有在没有自定义渲染或者不是已使用的情况下才处理拖拽
|
||||
if (!disabled && !option.disabled && !isUsed) {
|
||||
handleMouseDown(e, option.value);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
renderOption && "select-none flex-1",
|
||||
isDragging && "opacity-50"
|
||||
)}
|
||||
>
|
||||
{renderOption
|
||||
? renderOption(option, isUsed)
|
||||
: defaultRenderOption(option, isUsed)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 空白区域 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3",
|
||||
layout === "horizontal" && "flex-1"
|
||||
)}
|
||||
>
|
||||
{!hideLabels && (
|
||||
<h3 className="text-lg font-semibold text-gray-700">填空</h3>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{normalizedBlanks.map((blank) => {
|
||||
const filledValue = getBlankFill(blank.id);
|
||||
const isHovered = hoveredBlank === blank.id && !!draggingOption;
|
||||
return (
|
||||
<div
|
||||
key={blank.id}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
// 如果使用自定义渲染,需要找到实际的空白区域元素
|
||||
// 默认使用第一个子元素
|
||||
const targetEl = renderBlank
|
||||
? (el.firstElementChild as HTMLElement)
|
||||
: el;
|
||||
if (targetEl) {
|
||||
blankRefsMap.current.set(blank.id, targetEl);
|
||||
}
|
||||
} else {
|
||||
blankRefsMap.current.delete(blank.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderBlank
|
||||
? renderBlank(blank, filledValue, isHovered)
|
||||
: defaultRenderBlank(blank, filledValue)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
{showSubmitButton && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"px-6 py-3 rounded-lg font-medium transition-colors",
|
||||
"bg-orange-500 text-white hover:bg-orange-600",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2"
|
||||
)}
|
||||
>
|
||||
{submitText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 拖拽中的浮动元素 - 匹配选项框样式 */}
|
||||
{draggingOption &&
|
||||
dragPosition &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed pointer-events-none z-[99999]"
|
||||
style={{
|
||||
left: dragPosition.x,
|
||||
top: dragPosition.y,
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const option = normalizedOptions.find(
|
||||
(opt) => opt.value === draggingOption
|
||||
);
|
||||
if (!option) return null;
|
||||
if (renderDragging) {
|
||||
return renderDragging(option);
|
||||
}
|
||||
return (
|
||||
<span className="text-[28px] font-bold text-[#f0be75] drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]">
|
||||
{option.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DragFill.displayName = "DragFill";
|
||||
|
||||
export default DragFill;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
/** 选项数据 */
|
||||
export interface DragFillOption {
|
||||
/** 选项值 */
|
||||
value: string;
|
||||
/** 选项显示文本 */
|
||||
label: string;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** 空白区域数据 */
|
||||
export interface DragFillBlank {
|
||||
/** 空白区域ID */
|
||||
id: string;
|
||||
/** 空白区域显示文本/提示 */
|
||||
label?: string;
|
||||
/** 是否必填 */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/** 填充结果 */
|
||||
export interface DragFillResult {
|
||||
/** 空白区域ID */
|
||||
blankId: string;
|
||||
/** 填充的选项值 */
|
||||
optionValue: string;
|
||||
}
|
||||
|
||||
/** 拖拽填空组件配置项 */
|
||||
export interface DragFillProps {
|
||||
/** 可拖拽的选项列表 */
|
||||
options: DragFillOption[] | string[];
|
||||
/** 空白区域列表 */
|
||||
blanks: DragFillBlank[] | string[];
|
||||
/** 当前填充结果(受控模式) */
|
||||
value?: DragFillResult[];
|
||||
/** 默认填充结果 */
|
||||
defaultValue?: DragFillResult[];
|
||||
/** 填充结果变化时的回调 */
|
||||
onChange?: (results: DragFillResult[]) => void;
|
||||
/** 提交时的回调 */
|
||||
onSubmit?: (results: DragFillResult[]) => void;
|
||||
/** 是否禁用整个组件 */
|
||||
disabled?: boolean;
|
||||
/** 是否允许重复选择同一选项 */
|
||||
allowDuplicate?: boolean;
|
||||
/** 布局方向:vertical(上下)、horizontal(左右)或 horizontal-options(选项水平排列) */
|
||||
layout?: "vertical" | "horizontal" | "horizontal-options";
|
||||
/** 是否隐藏"选项"和"填空"标签 */
|
||||
hideLabels?: boolean;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** 选项容器的自定义类名 */
|
||||
optionClassName?: string;
|
||||
/** 空白区域容器的自定义类名 */
|
||||
blankClassName?: string;
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
/** 是否显示提交按钮 */
|
||||
showSubmitButton?: boolean;
|
||||
/** 自定义渲染选项 */
|
||||
renderOption?: (option: DragFillOption, isUsed: boolean) => ReactNode;
|
||||
/** 自定义渲染空白区域 */
|
||||
renderBlank?: (
|
||||
blank: DragFillBlank,
|
||||
filledValue: string | null,
|
||||
isHovered: boolean
|
||||
) => ReactNode;
|
||||
/** 自定义渲染拖拽中的元素 */
|
||||
renderDragging?: (option: DragFillOption) => ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { default } from "./drag-fill";
|
||||
export { default as DragFill } from "./drag-fill";
|
||||
export type {
|
||||
DragFillProps,
|
||||
DragFillOption,
|
||||
DragFillBlank,
|
||||
DragFillResult,
|
||||
} from "./drag-fill.types";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Single Choice component
|
||||
export { SingleChoice, default as SingleChoiceDefault } from "./single-choice";
|
||||
export type {
|
||||
SingleChoiceProps,
|
||||
SingleChoiceOption,
|
||||
} from "./single-choice";
|
||||
|
||||
// Multiple Choice component
|
||||
export { MultipleChoice } from "./multiple-choice";
|
||||
export type {
|
||||
MultipleChoiceProps,
|
||||
MultipleChoiceOption,
|
||||
} from "./multiple-choice";
|
||||
|
||||
// Drag Fill component
|
||||
export { DragFill } from "./drag-fill";
|
||||
export type {
|
||||
DragFillProps,
|
||||
DragFillOption,
|
||||
DragFillBlank,
|
||||
DragFillResult,
|
||||
} from "./drag-fill";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default } from "./multiple-choice";
|
||||
export { default as MultipleChoice } from "./multiple-choice";
|
||||
export type {
|
||||
MultipleChoiceProps,
|
||||
MultipleChoiceOption,
|
||||
} from "./multiple-choice.types";
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import MultipleChoice from "./multiple-choice";
|
||||
import type { MultipleChoiceProps } from "./multiple-choice.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const meta: Meta<typeof MultipleChoice> = {
|
||||
title: "Components/Quiz/MultipleChoice",
|
||||
component: MultipleChoice,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
options: {
|
||||
control: { type: "object" },
|
||||
description: "选项列表,可以是字符串数组或选项对象数组",
|
||||
},
|
||||
value: {
|
||||
control: { type: "object" },
|
||||
description: "当前选中的值数组(受控模式)",
|
||||
},
|
||||
defaultValue: {
|
||||
control: { type: "object" },
|
||||
description: "默认选中的值数组",
|
||||
},
|
||||
disabled: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否禁用整个组件",
|
||||
},
|
||||
minSelections: {
|
||||
control: { type: "number" },
|
||||
description: "最小选择数量",
|
||||
},
|
||||
maxSelections: {
|
||||
control: { type: "number" },
|
||||
description: "最大选择数量",
|
||||
},
|
||||
showSubmitButton: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否显示提交按钮",
|
||||
},
|
||||
submitText: {
|
||||
control: { type: "text" },
|
||||
description: "提交按钮文本",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MultipleChoice>;
|
||||
|
||||
// 基础示例
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D", "选项E"],
|
||||
onSubmit: (values) => {
|
||||
console.log("选中的值:", values);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 使用选项对象
|
||||
export const WithOptionObjects: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: "opt1", label: "选项一" },
|
||||
{ value: "opt2", label: "选项二" },
|
||||
{ value: "opt3", label: "选项三(禁用)", disabled: true },
|
||||
{ value: "opt4", label: "选项四" },
|
||||
{ value: "opt5", label: "选项五" },
|
||||
],
|
||||
onSubmit: (values) => {
|
||||
console.log("选中的值:", values);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 受控模式
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [values, setValues] = useState<string[]>([]);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MultipleChoice
|
||||
options={["选项A", "选项B", "选项C", "选项D"]}
|
||||
value={values}
|
||||
onChange={setValues}
|
||||
onSubmit={(vals) => {
|
||||
alert(`提交的值: ${vals.join(", ")}`);
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
当前选中: {values.length > 0 ? values.join(", ") : "未选择"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// 限制最大选择数量
|
||||
export const WithMaxSelections: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D", "选项E"],
|
||||
maxSelections: 2,
|
||||
onSubmit: (values) => {
|
||||
console.log("选中的值:", values);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 限制最小和最大选择数量
|
||||
export const WithMinMaxSelections: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D", "选项E"],
|
||||
minSelections: 2,
|
||||
maxSelections: 4,
|
||||
onSubmit: (values) => {
|
||||
console.log("选中的值:", values);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 禁用状态
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
disabled: true,
|
||||
defaultValue: ["选项A"],
|
||||
},
|
||||
};
|
||||
|
||||
// 不显示提交按钮
|
||||
export const WithoutSubmitButton: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
showSubmitButton: false,
|
||||
onChange: (values) => {
|
||||
console.log("值变化:", values);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 自定义样式
|
||||
export const CustomStyled: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D"],
|
||||
className: "max-w-md mx-auto p-6 bg-gray-50 rounded-xl",
|
||||
optionClassName: "text-lg font-semibold",
|
||||
submitText: "确认答案",
|
||||
maxSelections: 3,
|
||||
onSubmit: (values) => {
|
||||
console.log("选中的值:", values);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 自定义渲染选项
|
||||
export const CustomRender: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<MultipleChoice
|
||||
options={["选项A", "选项B", "选项C", "选项D"]}
|
||||
maxSelections={2}
|
||||
renderOption={(option, isSelected) => (
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 rounded-lg border-2 transition-all cursor-pointer",
|
||||
isSelected
|
||||
? "border-purple-500 bg-purple-100"
|
||||
: "border-gray-200 bg-white"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"w-6 h-6 rounded border-2 flex items-center justify-center text-lg",
|
||||
isSelected
|
||||
? "border-purple-500 bg-purple-500 text-white"
|
||||
: "border-gray-300"
|
||||
)}
|
||||
>
|
||||
{isSelected && "✓"}
|
||||
</div>
|
||||
<span className="font-medium">{option.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
onSubmit={(values) => {
|
||||
console.log("选中的值:", values);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState, useEffect, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
MultipleChoiceProps,
|
||||
MultipleChoiceOption,
|
||||
} from "./multiple-choice.types";
|
||||
|
||||
/**
|
||||
* MultipleChoice - 多选组件
|
||||
*
|
||||
* 用于问答场景的多选功能,支持选项列表、多选状态管理、最小/最大选择数量限制和提交回调
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MultipleChoice
|
||||
* options={["选项A", "选项B", "选项C"]}
|
||||
* maxSelections={2}
|
||||
* onSubmit={(values) => console.log(values)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
|
||||
(
|
||||
{
|
||||
options,
|
||||
value: controlledValue,
|
||||
defaultValue = [],
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
minSelections,
|
||||
maxSelections,
|
||||
className,
|
||||
optionClassName,
|
||||
submitText = "提交",
|
||||
showSubmitButton = true,
|
||||
renderOption,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// 标准化选项数据
|
||||
const normalizedOptions: MultipleChoiceOption[] = options.map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return { value: option, label: option };
|
||||
}
|
||||
return option;
|
||||
});
|
||||
|
||||
// 受控/非受控模式
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const [internalValue, setInternalValue] = useState<string[]>(
|
||||
defaultValue || []
|
||||
);
|
||||
const currentValue = isControlled ? controlledValue : internalValue;
|
||||
|
||||
// 处理选项点击
|
||||
const handleOptionClick = (optionValue: string) => {
|
||||
if (disabled) return;
|
||||
const option = normalizedOptions.find((opt) => opt.value === optionValue);
|
||||
if (option?.disabled) return;
|
||||
|
||||
const isSelected = currentValue.includes(optionValue);
|
||||
let newValue: string[];
|
||||
|
||||
if (isSelected) {
|
||||
// 取消选择
|
||||
newValue = currentValue.filter((v) => v !== optionValue);
|
||||
// 检查最小选择数量限制
|
||||
if (
|
||||
minSelections !== undefined &&
|
||||
newValue.length < minSelections
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 选择
|
||||
// 检查最大选择数量限制
|
||||
if (
|
||||
maxSelections !== undefined &&
|
||||
currentValue.length >= maxSelections
|
||||
) {
|
||||
return;
|
||||
}
|
||||
newValue = [...currentValue, optionValue];
|
||||
}
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = () => {
|
||||
if (disabled) return;
|
||||
// 检查最小选择数量
|
||||
if (
|
||||
minSelections !== undefined &&
|
||||
currentValue.length < minSelections
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onSubmit?.(currentValue);
|
||||
};
|
||||
|
||||
// 检查是否可以提交
|
||||
const canSubmit =
|
||||
currentValue.length > 0 &&
|
||||
(minSelections === undefined ||
|
||||
currentValue.length >= minSelections);
|
||||
|
||||
// 默认渲染选项
|
||||
const defaultRenderOption = (
|
||||
option: MultipleChoiceOption,
|
||||
isSelected: boolean
|
||||
) => {
|
||||
const isDisabled =
|
||||
disabled ||
|
||||
option.disabled ||
|
||||
(!isSelected &&
|
||||
maxSelections !== undefined &&
|
||||
currentValue.length >= maxSelections);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-6 py-4 border-2 rounded-lg cursor-pointer transition-all",
|
||||
"hover:border-green-400 hover:bg-green-50",
|
||||
isSelected
|
||||
? "border-green-500 bg-green-100 text-green-700"
|
||||
: "border-gray-300 bg-white text-gray-700",
|
||||
isDisabled && "opacity-50 cursor-not-allowed",
|
||||
optionClassName
|
||||
)}
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
|
||||
isSelected
|
||||
? "border-green-500 bg-green-500"
|
||||
: "border-gray-300 bg-white"
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg
|
||||
className="w-3 h-3 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex flex-col gap-4", className)}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{normalizedOptions.map((option) => {
|
||||
const isSelected = currentValue.includes(option.value);
|
||||
return (
|
||||
<div key={option.value}>
|
||||
{renderOption
|
||||
? renderOption(option, isSelected)
|
||||
: defaultRenderOption(option, isSelected)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showSubmitButton && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{minSelections !== undefined && (
|
||||
<div className="text-sm text-gray-500">
|
||||
至少选择 {minSelections} 项
|
||||
{maxSelections !== undefined &&
|
||||
`,最多选择 ${maxSelections} 项`}
|
||||
{maxSelections === undefined && "(无上限)"}
|
||||
</div>
|
||||
)}
|
||||
{maxSelections !== undefined && minSelections === undefined && (
|
||||
<div className="text-sm text-gray-500">
|
||||
最多选择 {maxSelections} 项
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {currentValue.length} 项
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !canSubmit}
|
||||
className={cn(
|
||||
"px-6 py-3 rounded-lg font-medium transition-colors",
|
||||
"bg-green-500 text-white hover:bg-green-600",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
)}
|
||||
>
|
||||
{submitText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MultipleChoice.displayName = "MultipleChoice";
|
||||
|
||||
export default MultipleChoice;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
/** 选项数据 */
|
||||
export interface MultipleChoiceOption {
|
||||
/** 选项值 */
|
||||
value: string;
|
||||
/** 选项显示文本 */
|
||||
label: string;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** 多选组件配置项 */
|
||||
export interface MultipleChoiceProps {
|
||||
/** 选项列表 */
|
||||
options: MultipleChoiceOption[] | string[];
|
||||
/** 当前选中的值数组(受控模式) */
|
||||
value?: string[];
|
||||
/** 默认选中的值数组 */
|
||||
defaultValue?: string[];
|
||||
/** 选中值变化时的回调 */
|
||||
onChange?: (values: string[]) => void;
|
||||
/** 提交时的回调 */
|
||||
onSubmit?: (values: string[]) => void;
|
||||
/** 是否禁用整个组件 */
|
||||
disabled?: boolean;
|
||||
/** 最小选择数量 */
|
||||
minSelections?: number;
|
||||
/** 最大选择数量 */
|
||||
maxSelections?: number;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** 选项容器的自定义类名 */
|
||||
optionClassName?: string;
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
/** 是否显示提交按钮 */
|
||||
showSubmitButton?: boolean;
|
||||
/** 自定义渲染选项 */
|
||||
renderOption?: (
|
||||
option: MultipleChoiceOption,
|
||||
isSelected: boolean
|
||||
) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default } from "./single-choice";
|
||||
export { default as SingleChoice } from "./single-choice";
|
||||
export type {
|
||||
SingleChoiceProps,
|
||||
SingleChoiceOption,
|
||||
} from "./single-choice.types";
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import SingleChoice from "./single-choice";
|
||||
import type { SingleChoiceProps } from "./single-choice.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const meta: Meta<typeof SingleChoice> = {
|
||||
title: "Components/Quiz/SingleChoice",
|
||||
component: SingleChoice,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
options: {
|
||||
control: { type: "object" },
|
||||
description: "选项列表,可以是字符串数组或选项对象数组",
|
||||
},
|
||||
value: {
|
||||
control: { type: "text" },
|
||||
description: "当前选中的值(受控模式)",
|
||||
},
|
||||
defaultValue: {
|
||||
control: { type: "text" },
|
||||
description: "默认选中的值",
|
||||
},
|
||||
disabled: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否禁用整个组件",
|
||||
},
|
||||
showSubmitButton: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否显示提交按钮",
|
||||
},
|
||||
submitText: {
|
||||
control: { type: "text" },
|
||||
description: "提交按钮文本",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SingleChoice>;
|
||||
|
||||
// 基础示例
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D"],
|
||||
onSubmit: (value) => {
|
||||
console.log("选中的值:", value);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 使用选项对象
|
||||
export const WithOptionObjects: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: "opt1", label: "选项一" },
|
||||
{ value: "opt2", label: "选项二" },
|
||||
{ value: "opt3", label: "选项三(禁用)", disabled: true },
|
||||
{ value: "opt4", label: "选项四" },
|
||||
],
|
||||
onSubmit: (value) => {
|
||||
console.log("选中的值:", value);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 受控模式
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState<string>("");
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SingleChoice
|
||||
options={["选项A", "选项B", "选项C"]}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={(val) => {
|
||||
alert(`提交的值: ${val}`);
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
当前选中: {value || "未选择"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// 禁用状态
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
disabled: true,
|
||||
defaultValue: "选项A",
|
||||
},
|
||||
};
|
||||
|
||||
// 不显示提交按钮
|
||||
export const WithoutSubmitButton: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
showSubmitButton: false,
|
||||
onChange: (value) => {
|
||||
console.log("值变化:", value);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 自定义样式
|
||||
export const CustomStyled: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
className: "max-w-md mx-auto p-6 bg-gray-50 rounded-xl",
|
||||
optionClassName: "text-lg font-semibold",
|
||||
submitText: "确认答案",
|
||||
onSubmit: (value) => {
|
||||
console.log("选中的值:", value);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 自定义渲染选项
|
||||
export const CustomRender: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<SingleChoice
|
||||
options={["选项A", "选项B", "选项C"]}
|
||||
renderOption={(option, isSelected) => (
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 rounded-lg border-2 transition-all cursor-pointer",
|
||||
isSelected
|
||||
? "border-purple-500 bg-purple-100"
|
||||
: "border-gray-200 bg-white"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"w-5 h-5 rounded-full border-2 flex items-center justify-center",
|
||||
isSelected
|
||||
? "border-purple-500 bg-purple-500"
|
||||
: "border-gray-300"
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="w-2 h-2 rounded-full bg-white" />
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium">{option.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
onSubmit={(value) => {
|
||||
console.log("选中的值:", value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
SingleChoiceProps,
|
||||
SingleChoiceOption,
|
||||
} from "./single-choice.types";
|
||||
|
||||
/**
|
||||
* SingleChoice - 单选组件
|
||||
*
|
||||
* 用于问答场景的单选功能,支持选项列表、选中状态管理和提交回调
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SingleChoice
|
||||
* options={["选项A", "选项B", "选项C"]}
|
||||
* onSubmit={(value) => console.log(value)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const SingleChoice = forwardRef<HTMLDivElement, SingleChoiceProps>(
|
||||
(
|
||||
{
|
||||
options,
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
className,
|
||||
optionClassName,
|
||||
submitText: _submitText = "提交", // 保留入参但不使用
|
||||
showSubmitButton: _showSubmitButton = false, // 保留入参但不使用
|
||||
renderOption,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// 标准化选项数据
|
||||
const normalizedOptions: SingleChoiceOption[] = options.map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return { value: option, label: option };
|
||||
}
|
||||
return option;
|
||||
});
|
||||
|
||||
// 受控/非受控模式
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const [internalValue, setInternalValue] = useState<string>(
|
||||
defaultValue || ""
|
||||
);
|
||||
const currentValue = isControlled ? controlledValue : internalValue;
|
||||
|
||||
// 处理选项点击
|
||||
const handleOptionClick = (optionValue: string) => {
|
||||
if (disabled) return;
|
||||
if (
|
||||
normalizedOptions.find((opt) => opt.value === optionValue)?.disabled
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(optionValue);
|
||||
}
|
||||
onChange?.(optionValue);
|
||||
// 单选题:点击即触发提交回调
|
||||
onSubmit?.(optionValue);
|
||||
};
|
||||
|
||||
// 默认渲染选项
|
||||
const defaultRenderOption = (
|
||||
option: SingleChoiceOption,
|
||||
isSelected: boolean
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-6 py-4 border-2 rounded-lg cursor-pointer transition-all",
|
||||
"hover:border-blue-400 hover:bg-blue-50",
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-100 text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-700",
|
||||
option.disabled && "opacity-50 cursor-not-allowed",
|
||||
optionClassName
|
||||
)}
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex flex-col gap-4", className)}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{normalizedOptions.map((option) => {
|
||||
const isSelected = currentValue === option.value;
|
||||
return (
|
||||
<div key={option.value}>
|
||||
{renderOption
|
||||
? renderOption(option, isSelected)
|
||||
: defaultRenderOption(option, isSelected)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 提交按钮移除:点击选项即提交 */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SingleChoice.displayName = "SingleChoice";
|
||||
|
||||
export default SingleChoice;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
/** 选项数据 */
|
||||
export interface SingleChoiceOption {
|
||||
/** 选项值 */
|
||||
value: string;
|
||||
/** 选项显示文本 */
|
||||
label: string;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** 单选组件配置项 */
|
||||
export interface SingleChoiceProps {
|
||||
/** 选项列表 */
|
||||
options: SingleChoiceOption[] | string[];
|
||||
/** 当前选中的值 */
|
||||
value?: string;
|
||||
/** 默认选中的值 */
|
||||
defaultValue?: string;
|
||||
/** 选中值变化时的回调 */
|
||||
onChange?: (value: string) => void;
|
||||
/** 提交时的回调 */
|
||||
onSubmit?: (value: string) => void;
|
||||
/** 是否禁用整个组件 */
|
||||
disabled?: boolean;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** 选项容器的自定义类名 */
|
||||
optionClassName?: string;
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
/** 是否显示提交按钮 */
|
||||
showSubmitButton?: boolean;
|
||||
/** 自定义渲染选项 */
|
||||
renderOption?: (option: SingleChoiceOption, isSelected: boolean) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { getBlockingDivs, type Rect } from "./utils";
|
||||
|
||||
interface SpotlightProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 目标元素选择器(支持单个或多个) */
|
||||
targetSelector?: string | string[] | null;
|
||||
/** 额外内边距 */
|
||||
padding?: number;
|
||||
/** 圆角 */
|
||||
borderRadius?: number;
|
||||
}
|
||||
|
||||
const Spotlight: React.FC<SpotlightProps> = ({
|
||||
visible = false,
|
||||
targetSelector = null,
|
||||
padding = 8,
|
||||
borderRadius = 16,
|
||||
}) => {
|
||||
const [rects, setRects] = useState<Rect[]>([]);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
if (!visible || !targetSelector) {
|
||||
setRects([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectors = Array.isArray(targetSelector)
|
||||
? targetSelector
|
||||
: [targetSelector];
|
||||
const pad = Math.max(0, padding);
|
||||
const newRects: Rect[] = [];
|
||||
|
||||
selectors.forEach((selector) => {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
elements.forEach((el) => {
|
||||
const htmlEl = el as HTMLElement;
|
||||
const r = htmlEl.getBoundingClientRect();
|
||||
// 使用视口坐标(fixed层),不叠加滚动偏移
|
||||
newRects.push({
|
||||
top: r.top - pad,
|
||||
left: r.left - pad,
|
||||
width: r.width + pad * 2,
|
||||
height: r.height + pad * 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
setRects(newRects);
|
||||
}, [visible, targetSelector, padding]);
|
||||
|
||||
useEffect(() => {
|
||||
// 初始测量
|
||||
requestAnimationFrame(measure);
|
||||
}, [visible, targetSelector, padding, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => measure();
|
||||
const onScroll = () => measure();
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
window.addEventListener("scroll", onScroll, {
|
||||
passive: true,
|
||||
} as AddEventListenerOptions);
|
||||
|
||||
// 使用 ResizeObserver 监听目标元素尺寸变化
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
measure();
|
||||
});
|
||||
|
||||
// 观察所有目标元素
|
||||
if (visible && targetSelector) {
|
||||
const selectors = Array.isArray(targetSelector)
|
||||
? targetSelector
|
||||
: [targetSelector];
|
||||
|
||||
selectors.forEach((selector) => {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
elements.forEach((el) => {
|
||||
resizeObserver.observe(el);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 初始测量(延迟一帧确保 DOM 已渲染)
|
||||
requestAnimationFrame(measure);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [measure, visible, targetSelector]);
|
||||
|
||||
if (!visible || rects.length === 0) return null;
|
||||
|
||||
// 生成唯一ID用于SVG mask
|
||||
const maskId = `spotlight-mask-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// 计算阻止点击的区域(使用共享工具函数)
|
||||
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 0;
|
||||
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 0;
|
||||
const blockingDivs = getBlockingDivs(rects, viewportWidth, viewportHeight);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
zIndex: 100000,
|
||||
}}
|
||||
>
|
||||
{/* SVG 视觉遮罩 */}
|
||||
<svg
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<mask id={maskId}>
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="white" />
|
||||
{rects.map((rect, index) => (
|
||||
<rect
|
||||
key={index}
|
||||
x={rect.left}
|
||||
y={rect.top}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
rx={borderRadius}
|
||||
ry={borderRadius}
|
||||
fill="black"
|
||||
/>
|
||||
))}
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="rgba(0, 0, 0, 0.6)"
|
||||
mask={`url(#${maskId})`}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* 阻止点击层 - 使用多个div块覆盖非孔区域 */}
|
||||
{blockingDivs.map((block, index) => (
|
||||
<div
|
||||
key={`block-${index}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${block.top}px`,
|
||||
left:
|
||||
typeof block.left === "number" ? `${block.left}px` : block.left,
|
||||
width:
|
||||
typeof block.width === "number"
|
||||
? `${block.width}px`
|
||||
: block.width,
|
||||
height:
|
||||
typeof block.height === "number"
|
||||
? `${block.height}px`
|
||||
: block.height,
|
||||
pointerEvents: "auto",
|
||||
// backgroundColor: "rgba(255, 0, 0, 0.1)", // 调试用
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 为每个孔添加高亮边框 */}
|
||||
{rects.map((rect, index) => (
|
||||
<div
|
||||
key={`border-${index}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${rect.top}px`,
|
||||
left: `${rect.left}px`,
|
||||
width: `${rect.width}px`,
|
||||
height: `${rect.height}px`,
|
||||
borderRadius: `${borderRadius}px`,
|
||||
border: "2px solid rgba(255, 255, 255, 0.3)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default Spotlight;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Spotlight } from "./Spotlight";
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 计算阻止点击的区域(分成多个矩形块)
|
||||
* 用于 Spotlight 和 Overlay 组件
|
||||
*/
|
||||
export interface BlockingDiv {
|
||||
top: number;
|
||||
left: number;
|
||||
width: string | number;
|
||||
height: string | number;
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据高亮区域计算需要阻止点击的 div 块
|
||||
* @param rects 高亮区域的矩形列表(视口坐标)
|
||||
* @param viewportWidth 视口宽度
|
||||
* @param viewportHeight 视口高度
|
||||
* @returns 阻止点击的 div 块列表
|
||||
*/
|
||||
export function getBlockingDivs(
|
||||
rects: Rect[],
|
||||
viewportWidth: number,
|
||||
viewportHeight: number
|
||||
): BlockingDiv[] {
|
||||
if (rects.length === 0) return [];
|
||||
|
||||
const blocks: BlockingDiv[] = [];
|
||||
|
||||
// 找出所有孔的边界
|
||||
const minTop = Math.min(...rects.map((r) => r.top));
|
||||
const maxBottom = Math.max(...rects.map((r) => r.top + r.height));
|
||||
const minLeft = Math.min(...rects.map((r) => r.left));
|
||||
const maxRight = Math.max(...rects.map((r) => r.left + r.width));
|
||||
|
||||
// 顶部大块
|
||||
if (minTop > 0) {
|
||||
blocks.push({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: minTop,
|
||||
});
|
||||
}
|
||||
|
||||
// 底部大块
|
||||
if (maxBottom < viewportHeight) {
|
||||
blocks.push({
|
||||
top: maxBottom,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: viewportHeight - maxBottom,
|
||||
});
|
||||
}
|
||||
|
||||
// 左侧大块(中间部分)
|
||||
if (minLeft > 0) {
|
||||
blocks.push({
|
||||
top: minTop,
|
||||
left: 0,
|
||||
width: minLeft,
|
||||
height: maxBottom - minTop,
|
||||
});
|
||||
}
|
||||
|
||||
// 右侧大块(中间部分)
|
||||
if (maxRight < viewportWidth) {
|
||||
blocks.push({
|
||||
top: minTop,
|
||||
left: maxRight,
|
||||
width: viewportWidth - maxRight,
|
||||
height: maxBottom - minTop,
|
||||
});
|
||||
}
|
||||
|
||||
// 对于多个孔的情况,填充孔之间的间隙
|
||||
if (rects.length > 1) {
|
||||
// 按位置排序
|
||||
const sortedByTop = [...rects].sort((a, b) => a.top - b.top);
|
||||
const sortedByLeft = [...rects].sort((a, b) => a.left - b.left);
|
||||
|
||||
// 填充垂直间隙
|
||||
for (let i = 0; i < sortedByTop.length - 1; i++) {
|
||||
const current = sortedByTop[i]!;
|
||||
const next = sortedByTop[i + 1]!;
|
||||
const gap = next.top - (current.top + current.height);
|
||||
|
||||
if (gap > 0) {
|
||||
blocks.push({
|
||||
top: current.top + current.height,
|
||||
left: Math.max(current.left, next.left),
|
||||
width: Math.min(current.width, next.width),
|
||||
height: gap,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 填充水平间隙
|
||||
for (let i = 0; i < sortedByLeft.length - 1; i++) {
|
||||
const current = sortedByLeft[i]!;
|
||||
const next = sortedByLeft[i + 1]!;
|
||||
const gap = next.left - (current.left + current.width);
|
||||
|
||||
if (gap > 0) {
|
||||
blocks.push({
|
||||
top: Math.max(current.top, next.top),
|
||||
left: current.left + current.width,
|
||||
width: gap,
|
||||
height: Math.min(current.height, next.height),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cn, getBackgroundImage } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 两个标题(主标题、副标题)
|
||||
* title: 主标题
|
||||
* subtitle: 副标题
|
||||
*/
|
||||
interface TitleProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
className?: string;
|
||||
subtitleClassName?: string;
|
||||
}
|
||||
|
||||
const Title = ({
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
subtitleClassName,
|
||||
}: TitleProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[1168px] h-[149px] bg-cover bg-center bg-no-repeat relative",
|
||||
"flex justify-center items-center",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage(`components/title`),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-5 left-1/2 -translate-x-1/2 text-white text-5xl font-bold",
|
||||
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{/* 副标题 */}
|
||||
<div
|
||||
className={cn("text-[#5C3A1E] text-5xl font-bold", subtitleClassName)}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Title };
|
||||
@@ -0,0 +1,222 @@
|
||||
# Toast 提示组件
|
||||
|
||||
通用的 Toast 提示组件,支持多种类型、位置和自定义配置。无需 Provider 包裹,可直接使用。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 支持 4 种类型:success、error、warning、info
|
||||
- ✅ 支持 7 个位置:top-left、top-center、top-right、center、bottom-left、bottom-center、bottom-right
|
||||
- ✅ 自动消失和手动关闭
|
||||
- ✅ 多个提示同时显示
|
||||
- ✅ 流畅的动画效果(基于 framer-motion)
|
||||
- ✅ 支持自定义图标和样式
|
||||
- ✅ TypeScript 类型支持
|
||||
- ✅ 无需 Provider,直接使用
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 基本使用
|
||||
|
||||
```tsx
|
||||
import { toast } from "@/components";
|
||||
|
||||
// 显示成功提示
|
||||
toast.success("操作成功!");
|
||||
|
||||
// 显示错误提示
|
||||
toast.error("操作失败,请重试");
|
||||
|
||||
// 显示警告提示
|
||||
toast.warning("请注意");
|
||||
|
||||
// 显示信息提示
|
||||
toast.info("这是一条提示信息");
|
||||
```
|
||||
|
||||
### 全局配置(可选)
|
||||
|
||||
```tsx
|
||||
import { toast } from "@/components";
|
||||
|
||||
// 配置默认位置和最大显示数量
|
||||
toast.config({
|
||||
defaultPosition: "top-right",
|
||||
maxCount: 5,
|
||||
});
|
||||
```
|
||||
|
||||
## 详细用法
|
||||
|
||||
### 1. 显示自定义提示
|
||||
|
||||
```tsx
|
||||
import { toast } from "@/components";
|
||||
|
||||
toast.show({
|
||||
type: "info",
|
||||
title: "自定义标题",
|
||||
message: "这是一条自定义提示信息",
|
||||
duration: 3000, // 3秒后自动关闭,0 表示不自动关闭
|
||||
closable: true, // 是否显示关闭按钮
|
||||
position: "center", // 位置:center 表示屏幕中间
|
||||
onClose: () => {
|
||||
console.log("提示已关闭");
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 使用 ReactNode 作为消息内容
|
||||
|
||||
```tsx
|
||||
import { toast } from "@/components";
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
<strong>操作成功!</strong>
|
||||
<p>您的数据已保存</p>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
### 3. 自定义图标
|
||||
|
||||
```tsx
|
||||
import { toast } from "@/components";
|
||||
import { CheckCircle } from "lucide-react";
|
||||
|
||||
toast.show({
|
||||
type: "success",
|
||||
message: "自定义图标",
|
||||
icon: <CheckCircle className="w-6 h-6" />,
|
||||
});
|
||||
```
|
||||
|
||||
### 4. 手动关闭提示
|
||||
|
||||
```tsx
|
||||
import { toast } from "@/components";
|
||||
|
||||
// 显示提示并保存 ID
|
||||
const toastId = toast.success("这条提示可以手动关闭");
|
||||
|
||||
// 3秒后手动关闭
|
||||
setTimeout(() => {
|
||||
toast.close(toastId);
|
||||
}, 3000);
|
||||
|
||||
// 关闭所有提示
|
||||
toast.closeAll();
|
||||
```
|
||||
|
||||
### 5. 在非 React 组件中使用
|
||||
|
||||
```tsx
|
||||
// 可以在任何地方使用,包括工具函数、事件处理器等
|
||||
import { toast } from "@/components";
|
||||
|
||||
export function handleApiError(error: Error) {
|
||||
toast.error(`请求失败: ${error.message}`);
|
||||
}
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
### toast 对象
|
||||
|
||||
| 方法 | 说明 | 参数 |
|
||||
| -------- | -------------- | --------------------------------------------------------------------------------- |
|
||||
| config | 全局配置 | `{ defaultPosition?: ToastPosition, maxCount?: number }` |
|
||||
| show | 显示自定义提示 | `ToastOptions` |
|
||||
| success | 显示成功提示 | `message: string \| ReactNode, options?: Omit<ToastOptions, "type" \| "message">` |
|
||||
| error | 显示错误提示 | `message: string \| ReactNode, options?: Omit<ToastOptions, "type" \| "message">` |
|
||||
| warning | 显示警告提示 | `message: string \| ReactNode, options?: Omit<ToastOptions, "type" \| "message">` |
|
||||
| info | 显示信息提示 | `message: string \| ReactNode, options?: Omit<ToastOptions, "type" \| "message">` |
|
||||
| close | 关闭指定提示 | `id: string` |
|
||||
| closeAll | 关闭所有提示 | - |
|
||||
|
||||
### ToastOptions
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
| --------- | ------------------- | ------ | ---------------------------------------- |
|
||||
| type | ToastType | "info" | 提示类型:success、error、warning、info |
|
||||
| title | string | - | 标题 |
|
||||
| message | string \| ReactNode | - | 消息内容(必填) |
|
||||
| duration | number | 3000 | 持续时间(毫秒),0 表示不自动关闭 |
|
||||
| closable | boolean | true | 是否显示关闭按钮 |
|
||||
| icon | ReactNode | - | 自定义图标 |
|
||||
| position | ToastPosition | - | 位置(继承自全局配置的 defaultPosition) |
|
||||
| onClose | () => void | - | 关闭回调 |
|
||||
| className | string | - | 自定义类名 |
|
||||
|
||||
### toast.config() 配置项
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
| --------------- | ------------- | ----------- | ------------ |
|
||||
| defaultPosition | ToastPosition | "top-right" | 默认位置 |
|
||||
| maxCount | number | 5 | 最大显示数量 |
|
||||
|
||||
## 类型定义
|
||||
|
||||
```typescript
|
||||
type ToastType = "success" | "error" | "warning" | "info";
|
||||
type ToastPosition =
|
||||
| "top-left"
|
||||
| "top-center"
|
||||
| "top-right"
|
||||
| "center"
|
||||
| "bottom-left"
|
||||
| "bottom-center"
|
||||
| "bottom-right";
|
||||
```
|
||||
|
||||
## 样式定制
|
||||
|
||||
组件使用 Tailwind CSS,支持通过 `className` 属性自定义样式。默认支持深色模式。
|
||||
|
||||
## 向后兼容
|
||||
|
||||
为了保持向后兼容,仍然支持旧的 `useToast` Hook 和 `ToastProvider`,但不推荐使用:
|
||||
|
||||
```tsx
|
||||
// 旧方式(不推荐,但仍然支持)
|
||||
import { useToast, ToastProvider } from "@/components";
|
||||
|
||||
// 新方式(推荐)
|
||||
import { toast } from "@/components";
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **无需 Provider**:直接使用 `toast` 对象,无需在应用根组件包裹 Provider
|
||||
2. **自动初始化**:首次调用时会自动创建 Toast 容器
|
||||
3. **位置分组**:提示会自动根据位置分组显示
|
||||
4. **数量限制**:超过 `maxCount` 数量的提示会被自动移除(保留最新的)
|
||||
5. **动画效果**:提示关闭时会先触发动画,然后从 DOM 中移除
|
||||
6. **SSR 支持**:组件会自动检测运行环境,在服务端不会渲染
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从旧版本迁移
|
||||
|
||||
**旧版本(需要 Provider):**
|
||||
|
||||
```tsx
|
||||
// App.tsx
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>;
|
||||
|
||||
// Component.tsx
|
||||
const toast = useToast();
|
||||
toast.success("成功!");
|
||||
```
|
||||
|
||||
**新版本(直接使用):**
|
||||
|
||||
```tsx
|
||||
// 任何地方直接使用
|
||||
import { toast } from "@/components";
|
||||
toast.success("成功!");
|
||||
```
|
||||
|
||||
只需删除 `ToastProvider` 包裹,并将 `useToast()` 改为直接使用 `toast` 对象即可。
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
X,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToastInstance } from "./types";
|
||||
|
||||
export interface ToastProps {
|
||||
toast: ToastInstance;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Toast 图标映射 */
|
||||
const iconMap = {
|
||||
success: CheckCircle2,
|
||||
error: AlertCircle,
|
||||
warning: AlertTriangle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
/** Toast 颜色映射 */
|
||||
const colorMap = {
|
||||
success: {
|
||||
bg: "bg-green-50 dark:bg-green-900/20",
|
||||
border: "border-green-200 dark:border-green-800",
|
||||
icon: "text-green-600 dark:text-green-400",
|
||||
title: "text-green-900 dark:text-green-100",
|
||||
message: "text-green-800 dark:text-green-200",
|
||||
},
|
||||
error: {
|
||||
bg: "bg-red-50 dark:bg-red-900/20",
|
||||
border: "border-red-200 dark:border-red-800",
|
||||
icon: "text-red-600 dark:text-red-400",
|
||||
title: "text-red-900 dark:text-red-100",
|
||||
message: "text-red-800 dark:text-red-200",
|
||||
},
|
||||
warning: {
|
||||
bg: "bg-yellow-50 dark:bg-yellow-900/20",
|
||||
border: "border-yellow-200 dark:border-yellow-800",
|
||||
icon: "text-yellow-600 dark:text-yellow-400",
|
||||
title: "text-yellow-900 dark:text-yellow-100",
|
||||
message: "text-yellow-800 dark:text-yellow-200",
|
||||
},
|
||||
info: {
|
||||
bg: "bg-blue-50 dark:bg-blue-900/20",
|
||||
border: "border-blue-200 dark:border-blue-800",
|
||||
icon: "text-blue-600 dark:text-blue-400",
|
||||
title: "text-blue-900 dark:text-blue-100",
|
||||
message: "text-blue-800 dark:text-blue-200",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Toast 组件
|
||||
*/
|
||||
export const Toast = ({ toast, onClose }: ToastProps) => {
|
||||
const {
|
||||
id,
|
||||
type = "info",
|
||||
title,
|
||||
message,
|
||||
duration = 3000,
|
||||
closable = true,
|
||||
icon,
|
||||
visible,
|
||||
} = toast;
|
||||
|
||||
const IconComponent = iconMap[type];
|
||||
const colors = colorMap[type];
|
||||
|
||||
// 自动关闭
|
||||
useEffect(() => {
|
||||
if (duration > 0 && visible) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose(id);
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [duration, id, onClose, visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
|
||||
className={cn(
|
||||
"relative flex items-start gap-3 p-4 rounded-lg shadow-lg border backdrop-blur-sm",
|
||||
"min-w-[320px] max-w-[420px]",
|
||||
colors.bg,
|
||||
colors.border,
|
||||
toast.className
|
||||
)}
|
||||
>
|
||||
{/* 图标 */}
|
||||
<div className={cn("flex-shrink-0 mt-0.5", colors.icon)}>
|
||||
{icon || <IconComponent className="w-5 h-5" />}
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && (
|
||||
<div className={cn("text-sm font-semibold mb-1", colors.title)}>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("text-sm", colors.message)}>{message}</div>
|
||||
</div>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
{closable && (
|
||||
<button
|
||||
onClick={() => onClose(id)}
|
||||
className={cn(
|
||||
"flex-shrink-0 p-1 rounded-md transition-colors",
|
||||
"hover:bg-black/5 dark:hover:bg-white/10",
|
||||
"text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
)}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { usePageScale } from "@/hooks/usePageScale";
|
||||
import { Toast } from "./Toast";
|
||||
import type { ToastInstance, ToastPosition } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: ToastInstance[];
|
||||
defaultPosition: ToastPosition;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast 容器组件
|
||||
* 负责渲染所有 Toast 到指定位置
|
||||
*/
|
||||
export const ToastContainer = ({
|
||||
toasts,
|
||||
defaultPosition,
|
||||
onClose,
|
||||
}: ToastContainerProps) => {
|
||||
// 获取页面缩放信息
|
||||
const {
|
||||
offsetX,
|
||||
offsetY,
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
windowWidth,
|
||||
windowHeight,
|
||||
} = usePageScale();
|
||||
|
||||
// 按位置分组
|
||||
const toastsByPosition = toasts.reduce((acc, toast) => {
|
||||
const position = toast.position || defaultPosition;
|
||||
if (!acc[position]) {
|
||||
acc[position] = [];
|
||||
}
|
||||
acc[position].push(toast);
|
||||
return acc;
|
||||
}, {} as Record<ToastPosition, ToastInstance[]>);
|
||||
|
||||
// 计算中心位置(基于缩放后的实际尺寸)
|
||||
const centerX = offsetX + scaledWidth / 2;
|
||||
const centerY = offsetY + scaledHeight / 2;
|
||||
|
||||
// 位置样式映射
|
||||
const getPositionStyle = (position: ToastPosition): React.CSSProperties => {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
switch (position) {
|
||||
case "top-left":
|
||||
return {
|
||||
...baseStyle,
|
||||
top: `${offsetY + 16}px`,
|
||||
left: `${offsetX + 16}px`,
|
||||
};
|
||||
case "top-center":
|
||||
return {
|
||||
...baseStyle,
|
||||
top: `${offsetY + 16}px`,
|
||||
left: `${centerX}px`,
|
||||
transform: "translateX(-50%)",
|
||||
};
|
||||
case "top-right":
|
||||
return {
|
||||
...baseStyle,
|
||||
top: `${offsetY + 16}px`,
|
||||
right: `${windowWidth - offsetX - scaledWidth + 16}px`,
|
||||
};
|
||||
case "center":
|
||||
return {
|
||||
...baseStyle,
|
||||
top: `${centerY}px`,
|
||||
left: `${centerX}px`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
};
|
||||
case "bottom-left":
|
||||
return {
|
||||
...baseStyle,
|
||||
bottom: `${windowHeight - offsetY - scaledHeight + 16}px`,
|
||||
left: `${offsetX + 16}px`,
|
||||
};
|
||||
case "bottom-center":
|
||||
return {
|
||||
...baseStyle,
|
||||
bottom: `${windowHeight - offsetY - scaledHeight + 16}px`,
|
||||
left: `${centerX}px`,
|
||||
transform: "translateX(-50%)",
|
||||
};
|
||||
case "bottom-right":
|
||||
return {
|
||||
...baseStyle,
|
||||
bottom: `${windowHeight - offsetY - scaledHeight + 16}px`,
|
||||
right: `${windowWidth - offsetX - scaledWidth + 16}px`,
|
||||
};
|
||||
default:
|
||||
return baseStyle;
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{Object.entries(toastsByPosition).map(([position, positionToasts]) => (
|
||||
<div key={position} style={getPositionStyle(position as ToastPosition)}>
|
||||
<div className="flex flex-col gap-2 pointer-events-auto">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{positionToasts.map((toast) => (
|
||||
<Toast key={toast.id} toast={toast} onClose={onClose} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import { ToastContainer } from "./ToastContainer";
|
||||
import type { ToastInstance, ToastOptions, ToastPosition } from "./types";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface ToastConfig {
|
||||
defaultPosition?: ToastPosition;
|
||||
maxCount?: number;
|
||||
}
|
||||
|
||||
class ToastManagerClass {
|
||||
private toasts: ToastInstance[] = [];
|
||||
private containerRoot: Root | null = null;
|
||||
private configOptions: Required<ToastConfig> = {
|
||||
defaultPosition: "top-right",
|
||||
maxCount: 5,
|
||||
};
|
||||
private listeners: Set<() => void> = new Set();
|
||||
|
||||
/**
|
||||
* 初始化容器
|
||||
*/
|
||||
private initContainer() {
|
||||
if (typeof window === "undefined") return;
|
||||
if (this.containerRoot) return;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.id = "toast-container";
|
||||
document.body.appendChild(container);
|
||||
|
||||
this.containerRoot = createRoot(container);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Toast 容器
|
||||
*/
|
||||
private render() {
|
||||
if (!this.containerRoot) return;
|
||||
|
||||
this.containerRoot.render(
|
||||
<ToastContainer
|
||||
toasts={this.toasts}
|
||||
defaultPosition={this.configOptions.defaultPosition}
|
||||
onClose={this.close.bind(this)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知所有监听器
|
||||
*/
|
||||
private notify() {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一 ID
|
||||
*/
|
||||
private generateId(): string {
|
||||
return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局配置
|
||||
*/
|
||||
config(options: ToastConfig) {
|
||||
this.configOptions = {
|
||||
...this.configOptions,
|
||||
...options,
|
||||
};
|
||||
// 如果容器已初始化,更新渲染
|
||||
if (this.containerRoot) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 Toast
|
||||
*/
|
||||
show(options: ToastOptions): string {
|
||||
this.initContainer();
|
||||
|
||||
const id = this.generateId();
|
||||
const position = options.position || this.configOptions.defaultPosition;
|
||||
|
||||
const newToast: ToastInstance = {
|
||||
id,
|
||||
visible: true,
|
||||
type: options.type || "info",
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
duration: options.duration ?? 3000,
|
||||
closable: options.closable ?? true,
|
||||
icon: options.icon,
|
||||
position,
|
||||
onClose: options.onClose,
|
||||
className: options.className,
|
||||
};
|
||||
|
||||
this.toasts = this.toasts.filter((t) => t.visible);
|
||||
this.toasts.push(newToast);
|
||||
// 限制最大数量
|
||||
this.toasts = this.toasts.slice(-this.configOptions.maxCount);
|
||||
|
||||
this.notify();
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示成功提示
|
||||
*/
|
||||
success(
|
||||
message: string | ReactNode,
|
||||
options?: Omit<ToastOptions, "type" | "message">
|
||||
): string {
|
||||
return this.show({ ...options, type: "success", message });
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误提示
|
||||
*/
|
||||
error(
|
||||
message: string | ReactNode,
|
||||
options?: Omit<ToastOptions, "type" | "message">
|
||||
): string {
|
||||
return this.show({ ...options, type: "error", message });
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示警告提示
|
||||
*/
|
||||
warning(
|
||||
message: string | ReactNode,
|
||||
options?: Omit<ToastOptions, "type" | "message">
|
||||
): string {
|
||||
return this.show({ ...options, type: "warning", message });
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示信息提示
|
||||
*/
|
||||
info(
|
||||
message: string | ReactNode,
|
||||
options?: Omit<ToastOptions, "type" | "message">
|
||||
): string {
|
||||
return this.show({ ...options, type: "info", message });
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭指定 Toast
|
||||
*/
|
||||
close(id: string) {
|
||||
this.toasts = this.toasts.map((toast) => {
|
||||
if (toast.id === id) {
|
||||
toast.onClose?.();
|
||||
return { ...toast, visible: false };
|
||||
}
|
||||
return toast;
|
||||
});
|
||||
|
||||
// 延迟移除,等待动画完成
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter((toast) => toast.id !== id);
|
||||
this.notify();
|
||||
}, 300);
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有 Toast
|
||||
*/
|
||||
closeAll() {
|
||||
this.toasts.forEach((toast) => {
|
||||
toast.onClose?.();
|
||||
});
|
||||
|
||||
this.toasts = this.toasts.map((toast) => ({
|
||||
...toast,
|
||||
visible: false,
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
this.toasts = [];
|
||||
this.notify();
|
||||
}, 300);
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅状态变化(用于 Hook)
|
||||
*/
|
||||
subscribe(listener: () => void) {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 toasts(用于 Hook)
|
||||
*/
|
||||
getToasts(): ToastInstance[] {
|
||||
return this.toasts;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局单例
|
||||
export const toastManager = new ToastManagerClass();
|
||||
|
||||
// 导出全局 toast 对象
|
||||
export const toast = {
|
||||
config: toastManager.config.bind(toastManager),
|
||||
show: toastManager.show.bind(toastManager),
|
||||
success: toastManager.success.bind(toastManager),
|
||||
error: toastManager.error.bind(toastManager),
|
||||
warning: toastManager.warning.bind(toastManager),
|
||||
info: toastManager.info.bind(toastManager),
|
||||
close: toastManager.close.bind(toastManager),
|
||||
closeAll: toastManager.closeAll.bind(toastManager),
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { Toast } from "./Toast";
|
||||
import type { ToastInstance, ToastOptions, ToastPosition } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ToastContextValue {
|
||||
show: (options: ToastOptions) => string;
|
||||
success: (
|
||||
message: string | ReactNode,
|
||||
options?: Omit<ToastOptions, "type" | "message">
|
||||
) => string;
|
||||
error: (
|
||||
message: string | ReactNode,
|
||||
options?: Omit<ToastOptions, "type" | "message">
|
||||
) => string;
|
||||
warning: (
|
||||
message: string | ReactNode,
|
||||
options?: Omit<ToastOptions, "type" | "message">
|
||||
) => string;
|
||||
info: (
|
||||
message: string | ReactNode,
|
||||
options?: Omit<ToastOptions, "type" | "message">
|
||||
) => string;
|
||||
close: (id: string) => void;
|
||||
closeAll: () => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export const useToastContext = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within ToastProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode;
|
||||
/** 默认位置 */
|
||||
defaultPosition?: ToastPosition;
|
||||
/** 最大显示数量 */
|
||||
maxCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast Provider 组件
|
||||
*
|
||||
* @deprecated 已废弃,请直接使用 `toast` 对象,无需 Provider
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 新方式(推荐)
|
||||
* import { toast } from "@/components";
|
||||
* toast.success("操作成功!");
|
||||
*
|
||||
* // 旧方式(已废弃)
|
||||
* <ToastProvider>
|
||||
* <App />
|
||||
* </ToastProvider>
|
||||
* ```
|
||||
*/
|
||||
export const ToastProvider = ({
|
||||
children,
|
||||
defaultPosition = "top-right",
|
||||
maxCount = 5,
|
||||
}: ToastProviderProps) => {
|
||||
const [toasts, setToasts] = useState<ToastInstance[]>([]);
|
||||
|
||||
// 生成唯一 ID
|
||||
const generateId = () =>
|
||||
`toast-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
// 显示 Toast
|
||||
const show = useCallback(
|
||||
(options: ToastOptions): string => {
|
||||
const id = generateId();
|
||||
const position = options.position || defaultPosition;
|
||||
|
||||
const newToast: ToastInstance = {
|
||||
id,
|
||||
visible: true,
|
||||
type: options.type || "info",
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
duration: options.duration ?? 3000,
|
||||
closable: options.closable ?? true,
|
||||
icon: options.icon,
|
||||
position,
|
||||
onClose: options.onClose,
|
||||
className: options.className,
|
||||
};
|
||||
|
||||
setToasts((prev) => {
|
||||
const filtered = prev.filter((t) => t.visible);
|
||||
const newToasts = [...filtered, newToast];
|
||||
// 限制最大数量
|
||||
return newToasts.slice(-maxCount);
|
||||
});
|
||||
|
||||
return id;
|
||||
},
|
||||
[defaultPosition, maxCount]
|
||||
);
|
||||
|
||||
// 关闭 Toast
|
||||
const close = useCallback((id: string) => {
|
||||
setToasts((prev) =>
|
||||
prev.map((toast) => {
|
||||
if (toast.id === id) {
|
||||
// 触发关闭回调
|
||||
toast.onClose?.();
|
||||
return { ...toast, visible: false };
|
||||
}
|
||||
return toast;
|
||||
})
|
||||
);
|
||||
|
||||
// 延迟移除,等待动画完成
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
// 关闭所有 Toast
|
||||
const closeAll = useCallback(() => {
|
||||
setToasts((prev) =>
|
||||
prev.map((toast) => {
|
||||
toast.onClose?.();
|
||||
return { ...toast, visible: false };
|
||||
})
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
setToasts([]);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
// 便捷方法
|
||||
const success = useCallback(
|
||||
(
|
||||
message: string | ReactNode,
|
||||
options?: Omit<ToastOptions, "type" | "message">
|
||||
) => {
|
||||
return show({ ...options, type: "success", message });
|
||||
},
|
||||
[show]
|
||||
);
|
||||
|
||||
const error = useCallback(
|
||||
(
|
||||
message: string | ReactNode,
|
||||
options?: Omit<ToastOptions, "type" | "message">
|
||||
) => {
|
||||
return show({ ...options, type: "error", message });
|
||||
},
|
||||
[show]
|
||||
);
|
||||
|
||||
const warning = useCallback(
|
||||
(
|
||||
message: string | ReactNode,
|
||||
options?: Omit<ToastOptions, "type" | "message">
|
||||
) => {
|
||||
return show({ ...options, type: "warning", message });
|
||||
},
|
||||
[show]
|
||||
);
|
||||
|
||||
const info = useCallback(
|
||||
(
|
||||
message: string | ReactNode,
|
||||
options?: Omit<ToastOptions, "type" | "message">
|
||||
) => {
|
||||
return show({ ...options, type: "info", message });
|
||||
},
|
||||
[show]
|
||||
);
|
||||
|
||||
// 按位置分组
|
||||
const toastsByPosition = toasts.reduce((acc, toast) => {
|
||||
const position = toast.position || defaultPosition;
|
||||
if (!acc[position]) {
|
||||
acc[position] = [];
|
||||
}
|
||||
acc[position].push(toast);
|
||||
return acc;
|
||||
}, {} as Record<ToastPosition, ToastInstance[]>);
|
||||
|
||||
// 位置样式映射
|
||||
const positionClasses: Record<ToastPosition, string> = {
|
||||
"top-left": "top-4 left-4",
|
||||
"top-center": "top-4 left-1/2 -translate-x-1/2",
|
||||
"top-right": "top-4 right-4",
|
||||
center: "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||
"bottom-left": "bottom-4 left-4",
|
||||
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
|
||||
"bottom-right": "bottom-4 right-4",
|
||||
};
|
||||
|
||||
const value: ToastContextValue = {
|
||||
show,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
close,
|
||||
closeAll,
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
{typeof window !== "undefined" &&
|
||||
createPortal(
|
||||
<>
|
||||
{Object.entries(toastsByPosition).map(
|
||||
([position, positionToasts]) => (
|
||||
<div
|
||||
key={position}
|
||||
className={cn(
|
||||
"fixed z-[9999] pointer-events-none",
|
||||
positionClasses[position as ToastPosition]
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2 pointer-events-auto">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{positionToasts.map((toast) => (
|
||||
<Toast key={toast.id} toast={toast} onClose={close} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
// 全局 toast 对象(推荐使用)
|
||||
export { toast } from "./ToastManager";
|
||||
|
||||
// Toast 组件
|
||||
export { Toast } from "./Toast";
|
||||
|
||||
// Hook(向后兼容,推荐直接使用 toast 对象)
|
||||
export { useToast } from "./useToast";
|
||||
|
||||
// 类型导出
|
||||
export type {
|
||||
ToastType,
|
||||
ToastPosition,
|
||||
ToastOptions,
|
||||
ToastInstance,
|
||||
} from "./types";
|
||||
|
||||
// 废弃的 Provider(保留以保持向后兼容,但不推荐使用)
|
||||
export { ToastProvider, useToastContext } from "./ToastProvider";
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
/** Toast 类型 */
|
||||
export type ToastType = "success" | "error" | "warning" | "info";
|
||||
|
||||
/** Toast 位置 */
|
||||
export type ToastPosition =
|
||||
| "top-left"
|
||||
| "top-center"
|
||||
| "top-right"
|
||||
| "center"
|
||||
| "bottom-left"
|
||||
| "bottom-center"
|
||||
| "bottom-right";
|
||||
|
||||
/** Toast 配置项 */
|
||||
export interface ToastOptions {
|
||||
/** 提示类型 */
|
||||
type?: ToastType;
|
||||
/** 标题 */
|
||||
title?: string;
|
||||
/** 内容 */
|
||||
message: string | ReactNode;
|
||||
/** 持续时间(毫秒),0 表示不自动关闭 */
|
||||
duration?: number;
|
||||
/** 是否显示关闭按钮 */
|
||||
closable?: boolean;
|
||||
/** 自定义图标 */
|
||||
icon?: ReactNode;
|
||||
/** 位置 */
|
||||
position?: ToastPosition;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Toast 实例 */
|
||||
export interface ToastInstance extends ToastOptions {
|
||||
/** 唯一 ID */
|
||||
id: string;
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toastManager } from "./ToastManager";
|
||||
import type { ToastInstance, ToastOptions } from "./types";
|
||||
|
||||
/**
|
||||
* useToast Hook
|
||||
*
|
||||
* @deprecated 推荐直接使用 `toast` 对象,无需 Hook
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 推荐方式:直接使用 toast 对象
|
||||
* import { toast } from "@/components";
|
||||
* toast.success("操作成功!");
|
||||
*
|
||||
* // 旧方式(仍然支持,但不推荐)
|
||||
* const toast = useToast();
|
||||
* toast.success("操作成功!");
|
||||
* ```
|
||||
*/
|
||||
export const useToast = () => {
|
||||
const [, forceUpdate] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = toastManager.subscribe(() => {
|
||||
forceUpdate({});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// 返回全局 toast 对象(向后兼容)
|
||||
return {
|
||||
show: toastManager.show.bind(toastManager),
|
||||
success: toastManager.success.bind(toastManager),
|
||||
error: toastManager.error.bind(toastManager),
|
||||
warning: toastManager.warning.bind(toastManager),
|
||||
info: toastManager.info.bind(toastManager),
|
||||
close: toastManager.close.bind(toastManager),
|
||||
closeAll: toastManager.closeAll.bind(toastManager),
|
||||
};
|
||||
};
|
||||
|
||||
export type { ToastOptions };
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default } from "./video";
|
||||
export { default as Video } from "./video";
|
||||
export type {
|
||||
VideoProps,
|
||||
VideoRef,
|
||||
VideoAnchor,
|
||||
AnchorContext,
|
||||
} from "./video.types";
|
||||
@@ -0,0 +1,649 @@
|
||||
import React, {
|
||||
useRef,
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
VideoProps,
|
||||
VideoRef,
|
||||
VideoAnchor,
|
||||
AnchorContext,
|
||||
} from "./video.types";
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { VideoProps, VideoRef, VideoAnchor, AnchorContext };
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (seconds: number): string => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) {
|
||||
return `${h}:${m.toString().padStart(2, "0")}:${s
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Video - 支持锚点交互的视频组件
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Video
|
||||
* src="/video.mp4"
|
||||
* anchors={[
|
||||
* {
|
||||
* id: "intro",
|
||||
* time: 5,
|
||||
* render: ({ resume }) => <button onClick={resume}>继续</button>,
|
||||
* },
|
||||
* ]}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const Video = forwardRef<VideoRef, VideoProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
anchors = [],
|
||||
onTimeUpdate,
|
||||
children,
|
||||
showSkipButton = false,
|
||||
skipText = "跳过",
|
||||
onSkip,
|
||||
skipButtonClassName,
|
||||
controls = false, // 默认不使用原生控制条
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const controlsRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const controlsTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// 已触发过的锚点 ID(用于 once 逻辑)
|
||||
const [triggeredOnceIds, setTriggeredOnceIds] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
// 当前激活的锚点 ID
|
||||
const [activeAnchorId, setActiveAnchorId] = useState<string | null>(null);
|
||||
// 已隐藏的锚点 ID(afterResume: 'hide')
|
||||
const [hiddenAnchorIds, setHiddenAnchorIds] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
// 已移除的锚点 ID(afterResume: 'remove')
|
||||
const [removedAnchorIds, setRemovedAnchorIds] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const isHandlingAnchor = useRef(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isVideoReady, setIsVideoReady] = useState(false);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
video: videoRef.current,
|
||||
play: async () => {
|
||||
await videoRef.current?.play();
|
||||
},
|
||||
pause: () => {
|
||||
videoRef.current?.pause();
|
||||
},
|
||||
}));
|
||||
|
||||
// 初始化视频信息
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(video.duration);
|
||||
setIsMuted(video.muted);
|
||||
setVolume(video.volume);
|
||||
setIsVideoReady(true);
|
||||
};
|
||||
|
||||
const handleLoadedData = () => {
|
||||
setIsVideoReady(true);
|
||||
};
|
||||
|
||||
// 如果视频已经有尺寸,立即设置为ready
|
||||
if (video.videoWidth > 0 && video.videoHeight > 0) {
|
||||
setIsVideoReady(true);
|
||||
}
|
||||
|
||||
video.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
video.addEventListener("loadeddata", handleLoadedData);
|
||||
return () => {
|
||||
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
video.removeEventListener("loadeddata", handleLoadedData);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 全屏状态监听
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
"fullscreenchange",
|
||||
handleFullscreenChange
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 控制条自动隐藏逻辑
|
||||
const resetControlsTimer = useCallback(() => {
|
||||
if (controlsTimerRef.current) {
|
||||
clearTimeout(controlsTimerRef.current);
|
||||
}
|
||||
setShowControls(true);
|
||||
if (isPlaying) {
|
||||
controlsTimerRef.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
}, 3000);
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
resetControlsTimer();
|
||||
return () => {
|
||||
if (controlsTimerRef.current) {
|
||||
clearTimeout(controlsTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [resetControlsTimer]);
|
||||
|
||||
// 取消静音
|
||||
const unmute = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 创建 resume 函数
|
||||
const createResume = useCallback((anchor: VideoAnchor) => {
|
||||
return () => {
|
||||
const afterResume = anchor.afterResume ?? "hide";
|
||||
|
||||
if (afterResume === "hide") {
|
||||
setHiddenAnchorIds((prev) => new Set(prev).add(anchor.id));
|
||||
setActiveAnchorId(null);
|
||||
} else if (afterResume === "remove") {
|
||||
setRemovedAnchorIds((prev) => new Set(prev).add(anchor.id));
|
||||
setActiveAnchorId(null);
|
||||
}
|
||||
// 'keep' 模式:不清除 activeAnchorId,UI 保持可见
|
||||
|
||||
isHandlingAnchor.current = false;
|
||||
videoRef.current?.play();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTimeUpdate = (
|
||||
e: React.SyntheticEvent<HTMLVideoElement, Event>
|
||||
): void => {
|
||||
if (onTimeUpdate) {
|
||||
onTimeUpdate(e);
|
||||
}
|
||||
|
||||
const video = e.currentTarget;
|
||||
const currentTime = video.currentTime;
|
||||
setCurrentTime(currentTime);
|
||||
|
||||
if (isHandlingAnchor.current) return;
|
||||
|
||||
for (const anchor of anchors) {
|
||||
// 检查是否启用
|
||||
if (anchor.enabled === false) continue;
|
||||
// 检查是否已移除
|
||||
if (removedAnchorIds.has(anchor.id)) continue;
|
||||
// 检查 once 逻辑
|
||||
if (anchor.once && triggeredOnceIds.has(anchor.id)) continue;
|
||||
// 检查是否已隐藏(隐藏的锚点不再触发)
|
||||
if (hiddenAnchorIds.has(anchor.id)) continue;
|
||||
// keep 模式下,如果当前锚点已激活,跳过(避免重复触发暂停)
|
||||
if (activeAnchorId === anchor.id) continue;
|
||||
|
||||
const timeDiff = currentTime - anchor.time;
|
||||
|
||||
if (timeDiff >= 0 && timeDiff < 0.5) {
|
||||
console.log(`Anchor triggered: ${anchor.id} at ${anchor.time}s`);
|
||||
|
||||
// 暂停视频 - Safari 兼容:确保暂停生效
|
||||
video.pause();
|
||||
// 双重保险:延迟再次确认暂停
|
||||
requestAnimationFrame(() => {
|
||||
if (!video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
});
|
||||
isHandlingAnchor.current = true;
|
||||
|
||||
// 标记为已触发(用于 once 逻辑)
|
||||
if (anchor.once) {
|
||||
setTriggeredOnceIds((prev) => new Set(prev).add(anchor.id));
|
||||
}
|
||||
|
||||
// 设置当前激活的锚点
|
||||
setActiveAnchorId(anchor.id);
|
||||
|
||||
// 触发回调(如埋点)
|
||||
anchor.onTrigger?.();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Seek 时重置隐藏状态(非 once 的锚点可以重新触发)
|
||||
const handleSeeked = (): void => {
|
||||
if (!videoRef.current) return;
|
||||
const currentTime = videoRef.current.currentTime;
|
||||
|
||||
// 清除当前时间之后的隐藏状态(允许重新触发)
|
||||
setHiddenAnchorIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of next) {
|
||||
const anchor = anchors.find((a) => a.id === id);
|
||||
if (anchor && anchor.time > currentTime) {
|
||||
next.delete(id);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
// 重置锚点处理锁
|
||||
if (activeAnchorId) {
|
||||
setActiveAnchorId(null);
|
||||
}
|
||||
isHandlingAnchor.current = false;
|
||||
};
|
||||
|
||||
// 获取当前激活的锚点
|
||||
const activeAnchor = activeAnchorId
|
||||
? anchors.find((a) => a.id === activeAnchorId)
|
||||
: null;
|
||||
|
||||
// 统一处理 onEnded:更新播放状态并透传给外部
|
||||
const handleEnded: React.VideoHTMLAttributes<HTMLVideoElement>["onEnded"] =
|
||||
(e) => {
|
||||
setIsPlaying(false);
|
||||
props.onEnded?.(e);
|
||||
};
|
||||
|
||||
const handlePlay: React.VideoHTMLAttributes<HTMLVideoElement>["onPlay"] = (
|
||||
e
|
||||
) => {
|
||||
setIsPlaying(true);
|
||||
props.onPlay?.(e);
|
||||
};
|
||||
|
||||
const handlePause: React.VideoHTMLAttributes<HTMLVideoElement>["onPause"] =
|
||||
(e) => {
|
||||
setIsPlaying(false);
|
||||
props.onPause?.(e);
|
||||
};
|
||||
|
||||
const handleSkipClick = (): void => {
|
||||
// 优先暂停当前视频
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
onSkip?.();
|
||||
// 触发与自然结束相同的逻辑
|
||||
if (props.onEnded) {
|
||||
// 调用时不关心事件内容,保持类型兼容
|
||||
(props.onEnded as (e: any) => void)({} as any);
|
||||
}
|
||||
};
|
||||
|
||||
// 播放/暂停切换
|
||||
const togglePlayPause = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
}
|
||||
resetControlsTimer();
|
||||
};
|
||||
|
||||
// 进度条点击/拖拽
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// 如果正在拖拽,不处理点击事件
|
||||
if (isDragging) return;
|
||||
if (!videoRef.current || !progressRef.current) return;
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
const newTime = percent * duration;
|
||||
videoRef.current.currentTime = newTime;
|
||||
resetControlsTimer();
|
||||
};
|
||||
|
||||
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
if (!videoRef.current || !progressRef.current) return;
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const percent = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width)
|
||||
);
|
||||
const newTime = percent * duration;
|
||||
videoRef.current.currentTime = newTime;
|
||||
};
|
||||
|
||||
// 全局鼠标移动和释放事件处理拖拽
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!videoRef.current || !progressRef.current) return;
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const percent = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width)
|
||||
);
|
||||
const newTime = percent * duration;
|
||||
videoRef.current.currentTime = newTime;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging, duration]);
|
||||
|
||||
// 音量控制
|
||||
const toggleMute = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !videoRef.current.muted;
|
||||
setIsMuted(videoRef.current.muted);
|
||||
}
|
||||
resetControlsTimer();
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (videoRef.current) {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
videoRef.current.volume = newVolume;
|
||||
setVolume(newVolume);
|
||||
setIsMuted(newVolume === 0);
|
||||
videoRef.current.muted = newVolume === 0;
|
||||
}
|
||||
resetControlsTimer();
|
||||
};
|
||||
|
||||
// 全屏切换
|
||||
const toggleFullscreen = () => {
|
||||
if (!videoRef.current) return;
|
||||
if (!document.fullscreenElement) {
|
||||
videoRef.current.requestFullscreen?.();
|
||||
} else {
|
||||
document.exitFullscreen?.();
|
||||
}
|
||||
resetControlsTimer();
|
||||
};
|
||||
|
||||
// 获取可见的锚点(用于在进度条上显示)
|
||||
const visibleAnchors = anchors.filter((anchor) => {
|
||||
if (anchor.enabled === false) return false;
|
||||
if (removedAnchorIds.has(anchor.id)) return false;
|
||||
if (anchor.once && triggeredOnceIds.has(anchor.id)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 计算进度百分比
|
||||
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative group w-full h-full", className)}
|
||||
onMouseMove={resetControlsTimer}
|
||||
onMouseLeave={() => {
|
||||
if (isPlaying) {
|
||||
setShowControls(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-cover"
|
||||
controls={false}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onSeeked={handleSeeked}
|
||||
onEnded={handleEnded}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
{...props}
|
||||
/>
|
||||
{/* 跳过按钮 */}
|
||||
{showSkipButton && isPlaying && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkipClick}
|
||||
className={cn(
|
||||
"absolute top-8 right-8 z-20 px-6 py-2 rounded-full bg-black/60 text-white text-xl",
|
||||
"backdrop-blur-sm border border-white/30",
|
||||
"hover:bg-black/75 transition-colors",
|
||||
skipButtonClassName
|
||||
)}
|
||||
>
|
||||
{skipText}
|
||||
</button>
|
||||
)}
|
||||
{/* 自定义控制条 */}
|
||||
{isVideoReady && controls && (
|
||||
<div
|
||||
ref={controlsRef}
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 z-10",
|
||||
"bg-gradient-to-t from-black/80 via-black/60 to-transparent",
|
||||
"transition-opacity duration-300",
|
||||
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
style={{ top: "auto" }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 进度条容器 */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
className="relative h-2 bg-white/20 cursor-pointer group/progress hover:h-3 transition-all"
|
||||
onClick={handleProgressClick}
|
||||
onMouseDown={handleProgressMouseDown}
|
||||
>
|
||||
{/* 已播放进度 */}
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full bg-white transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
{/* 锚点标记 */}
|
||||
{visibleAnchors.map((anchor) => {
|
||||
const anchorPercent =
|
||||
duration > 0 ? (anchor.time / duration) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={anchor.id}
|
||||
className="absolute top-1/2 w-3 h-3 rounded-full bg-white border-2 border-white/80 shadow-md z-10"
|
||||
style={{
|
||||
left: `${anchorPercent}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
title={`锚点: ${formatTime(anchor.time)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* 悬停时的进度预览 */}
|
||||
<div className="absolute inset-0 opacity-0 group-hover/progress:opacity-100 transition-opacity">
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full bg-white/30 transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 控制按钮区域 */}
|
||||
<div className="flex items-center gap-4 px-4 py-3">
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlayPause}
|
||||
className="flex-shrink-0 w-10 h-10 flex items-center justify-center text-white hover:text-white/80 transition-colors"
|
||||
aria-label={isPlaying ? "暂停" : "播放"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 时间显示 */}
|
||||
<div className="flex items-center gap-2 text-white text-sm min-w-[120px]">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span className="text-white/60">/</span>
|
||||
<span className="text-white/60">{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* 音量控制 */}
|
||||
<div className="flex items-center gap-2 flex-1 max-w-[150px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMute}
|
||||
className="flex-shrink-0 w-8 h-8 flex items-center justify-center text-white hover:text-white/80 transition-colors"
|
||||
aria-label={isMuted ? "取消静音" : "静音"}
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
|
||||
</svg>
|
||||
) : volume < 0.5 ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="flex-1 h-1 bg-white/20 rounded-full appearance-none cursor-pointer accent-white"
|
||||
style={{
|
||||
background: `linear-gradient(to right, white 0%, white ${
|
||||
(isMuted ? 0 : volume) * 100
|
||||
}%, rgba(255,255,255,0.2) ${
|
||||
(isMuted ? 0 : volume) * 100
|
||||
}%, rgba(255,255,255,0.2) 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 全屏按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
className="flex-shrink-0 w-8 h-8 flex items-center justify-center text-white hover:text-white/80 transition-colors"
|
||||
aria-label={isFullscreen ? "退出全屏" : "全屏"}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 锚点渲染层 - Safari 兼容:直接使用 pointer-events-auto */}
|
||||
{activeAnchor && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
{activeAnchor.render({
|
||||
resume: createResume(activeAnchor),
|
||||
unmute,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* 额外的 children(如果需要) */}
|
||||
{children && (
|
||||
<div className="absolute inset-0" style={{ pointerEvents: "auto" }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Video.displayName = "Video";
|
||||
|
||||
export default Video;
|
||||
@@ -0,0 +1,54 @@
|
||||
import type React from "react";
|
||||
|
||||
/** 锚点上下文,提供给锚点渲染函数 */
|
||||
export interface AnchorContext {
|
||||
/** 恢复播放 */
|
||||
resume: () => void;
|
||||
/** 取消静音 */
|
||||
unmute: () => void;
|
||||
}
|
||||
|
||||
/** 视频锚点配置 */
|
||||
export interface VideoAnchor {
|
||||
/** 唯一标识 */
|
||||
id: string;
|
||||
/** 触发时间(秒) */
|
||||
time: number;
|
||||
/** 返回要渲染的 TSX */
|
||||
render: (ctx: AnchorContext) => React.ReactNode;
|
||||
/** 是否启用,默认 true */
|
||||
enabled?: boolean;
|
||||
/** 是否只触发一次(seek 回来不再触发),默认 false */
|
||||
once?: boolean;
|
||||
/** 恢复后的行为,默认 'hide' */
|
||||
afterResume?: "hide" | "remove" | "keep";
|
||||
/** 锚点触发时的回调(如埋点) */
|
||||
onTrigger?: () => void;
|
||||
}
|
||||
|
||||
/** Video 组件 Props */
|
||||
export interface VideoProps
|
||||
extends React.VideoHTMLAttributes<HTMLVideoElement> {
|
||||
/** 锚点配置列表 */
|
||||
anchors?: VideoAnchor[];
|
||||
/** 时间更新回调 */
|
||||
onTimeUpdate?: (e: React.SyntheticEvent<HTMLVideoElement, Event>) => void;
|
||||
/** 是否显示跳过按钮(默认 false) */
|
||||
showSkipButton?: boolean;
|
||||
/** 跳过按钮文案(默认 "跳过") */
|
||||
skipText?: string;
|
||||
/** 点击跳过时的回调 */
|
||||
onSkip?: () => void;
|
||||
/** 自定义跳过按钮样式 */
|
||||
skipButtonClassName?: string;
|
||||
}
|
||||
|
||||
/** Video 组件 Ref 暴露的方法 */
|
||||
export interface VideoRef {
|
||||
/** 原生 video 元素 */
|
||||
video: HTMLVideoElement | null;
|
||||
/** 播放视频 */
|
||||
play: () => Promise<void>;
|
||||
/** 暂停视频 */
|
||||
pause: () => void;
|
||||
}
|
||||
Reference in new issue
Block a user