feat: 更新 ESLint 和 Prettier 配置,增强代码质量管理
- 在 .gitignore 中添加 public/audio/* 以忽略音频文件。 - 更新 bun.lock,添加 eslint-config-prettier 和 eslint-plugin-prettier 依赖,确保 ESLint 与 Prettier 的兼容性。 - 修改 eslint.config.ts,整合 Prettier 规则,优化代码风格一致性。 - 在 package.json 中添加 lint:fix 和 format 脚本,简化代码格式化和修复流程。 - 更新 tailwind.config.ts 和其他组件文件,统一代码风格,提升可读性。
This commit is contained in:
1 parent
5d00adc1a5
commit
76c67b492e
216 files changed
+12454
-13976
No files matched your search
+17
-17
@@ -1,30 +1,30 @@
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import router from "./router";
|
||||
import { ScaleProvider, useScale } from "./contexts/ScaleContext";
|
||||
import { RotateHint } from "./components/RotateHint";
|
||||
import { useUserStore } from "./store";
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { Suspense, useEffect } from 'react'
|
||||
import router from './router'
|
||||
import { ScaleProvider, useScale } from './contexts/ScaleContext'
|
||||
import { RotateHint } from './components/RotateHint'
|
||||
import { useUserStore } from './store'
|
||||
|
||||
/**
|
||||
* 应用内容组件
|
||||
* 使用 useScale 获取缩放样式
|
||||
*/
|
||||
function AppContent() {
|
||||
const { scaleStyle } = useScale();
|
||||
const setCourseInfo = useUserStore((state) => state.setCourseInfo);
|
||||
const { scaleStyle } = useScale()
|
||||
const setCourseInfo = useUserStore((state) => state.setCourseInfo)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const courseId = params.get("courseid");
|
||||
const uid = params.get("uid");
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const courseId = params.get('courseid')
|
||||
const uid = params.get('uid')
|
||||
|
||||
if (courseId || uid) {
|
||||
setCourseInfo({
|
||||
courseId: courseId ?? null,
|
||||
uid: uid ?? null,
|
||||
});
|
||||
})
|
||||
}
|
||||
}, [setCourseInfo]);
|
||||
}, [setCourseInfo])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -32,7 +32,7 @@ function AppContent() {
|
||||
<div
|
||||
style={{
|
||||
...scaleStyle,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
@@ -42,7 +42,7 @@ function AppContent() {
|
||||
</div>
|
||||
<RotateHint />
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +54,7 @@ function App() {
|
||||
<ScaleProvider>
|
||||
<AppContent />
|
||||
</ScaleProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App
|
||||
@@ -1,34 +1,33 @@
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import { useScale } from '@/contexts/ScaleContext'
|
||||
|
||||
/**
|
||||
* 横屏提示组件
|
||||
* 在移动端竖屏时显示提示,引导用户横屏观看
|
||||
*/
|
||||
export function RotateHint() {
|
||||
const { isMobilePortrait } = useScale();
|
||||
const { isMobilePortrait } = useScale()
|
||||
|
||||
if (!isMobilePortrait) return null;
|
||||
if (!isMobilePortrait) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
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",
|
||||
'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",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
padding: '24px',
|
||||
fontSize: '20px',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
请横屏观看,以获得最佳体验
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,64 +1,59 @@
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
import { createRoot, Root } from 'react-dom/client'
|
||||
import { motion } from 'framer-motion'
|
||||
import React from 'react'
|
||||
|
||||
export type FlyToPosition =
|
||||
| "center"
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right";
|
||||
export type FlyToPosition = 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
||||
|
||||
export interface FlyToAnimationOptions {
|
||||
/** 起始元素的 ref */
|
||||
fromRef: React.RefObject<HTMLElement | null>;
|
||||
fromRef: React.RefObject<HTMLElement | null>
|
||||
/** 目标元素的 ref */
|
||||
toRef: React.RefObject<HTMLElement | null>;
|
||||
toRef: React.RefObject<HTMLElement | null>
|
||||
/** 目标位置(相对于目标元素) */
|
||||
targetPosition?: FlyToPosition;
|
||||
targetPosition?: FlyToPosition
|
||||
/** 动画持续时间(毫秒) */
|
||||
duration?: number;
|
||||
duration?: number
|
||||
/** 动画缓动函数 */
|
||||
ease?: string | number[];
|
||||
ease?: string | number[]
|
||||
/** 动画完成回调 */
|
||||
onComplete?: () => void;
|
||||
onComplete?: () => void
|
||||
/** 自定义样式 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 缩放比例(从起始到目标) */
|
||||
scale?: { from: number; to: number };
|
||||
scale?: { from: number; to: number }
|
||||
/** 透明度(从起始到目标) */
|
||||
opacity?: number[];
|
||||
opacity?: number[]
|
||||
}
|
||||
|
||||
interface AnimationInstance extends FlyToAnimationOptions {
|
||||
id: string;
|
||||
startPos: { x: number; y: number };
|
||||
targetPos: { x: number; y: number };
|
||||
elementHtml: string;
|
||||
id: string
|
||||
startPos: { x: number; y: number }
|
||||
targetPos: { x: number; y: number }
|
||||
elementHtml: string
|
||||
}
|
||||
|
||||
class FlyToAnimationManagerClass {
|
||||
private animations: Map<string, AnimationInstance> = new Map();
|
||||
private containerRoot: Root | null = null;
|
||||
private animations: Map<string, AnimationInstance> = new Map()
|
||||
private containerRoot: Root | null = null
|
||||
|
||||
// 初始化容器
|
||||
private initContainer() {
|
||||
if (typeof window === "undefined") return;
|
||||
if (this.containerRoot) return;
|
||||
if (typeof window === 'undefined') return
|
||||
if (this.containerRoot) return
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.id = "fly-to-animation-container";
|
||||
document.body.appendChild(container);
|
||||
const container = document.createElement('div')
|
||||
container.id = 'fly-to-animation-container'
|
||||
document.body.appendChild(container)
|
||||
|
||||
this.containerRoot = createRoot(container);
|
||||
this.render();
|
||||
this.containerRoot = createRoot(container)
|
||||
this.render()
|
||||
}
|
||||
|
||||
// 渲染动画容器
|
||||
private render() {
|
||||
if (!this.containerRoot) return;
|
||||
if (!this.containerRoot) return
|
||||
|
||||
const animations = Array.from(this.animations.values());
|
||||
const animations = Array.from(this.animations.values())
|
||||
|
||||
this.containerRoot.render(
|
||||
<>
|
||||
@@ -66,150 +61,140 @@ class FlyToAnimationManagerClass {
|
||||
<FlyToAnimationItem key={animation.id} animation={animation} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// 通知更新
|
||||
private notify() {
|
||||
this.render();
|
||||
this.render()
|
||||
}
|
||||
|
||||
// 创建动画
|
||||
flyTo(options: FlyToAnimationOptions) {
|
||||
const { fromRef, toRef } = options;
|
||||
const { fromRef, toRef } = options
|
||||
|
||||
if (!fromRef?.current || !toRef?.current) {
|
||||
console.warn("FlyToAnimation: fromRef 或 toRef 未设置");
|
||||
return;
|
||||
console.warn('FlyToAnimation: fromRef 或 toRef 未设置')
|
||||
return
|
||||
}
|
||||
|
||||
this.initContainer();
|
||||
this.initContainer()
|
||||
|
||||
// 使用 requestAnimationFrame 确保在下一帧获取准确的位置
|
||||
requestAnimationFrame(() => {
|
||||
const fromRect = fromRef.current!.getBoundingClientRect();
|
||||
const toRect = toRef.current!.getBoundingClientRect();
|
||||
const fromRect = fromRef.current!.getBoundingClientRect()
|
||||
const toRect = toRef.current!.getBoundingClientRect()
|
||||
|
||||
// 计算起始位置(起始元素的中心)
|
||||
const startX = fromRect.left + fromRect.width / 2;
|
||||
const startY = fromRect.top + fromRect.height / 2;
|
||||
const startX = fromRect.left + fromRect.width / 2
|
||||
const startY = fromRect.top + fromRect.height / 2
|
||||
|
||||
// 计算目标位置
|
||||
const targetPosition = options.targetPosition || "center";
|
||||
let targetX = 0;
|
||||
let targetY = 0;
|
||||
const targetPosition = options.targetPosition || 'center'
|
||||
let targetX = 0
|
||||
let targetY = 0
|
||||
|
||||
switch (targetPosition) {
|
||||
case "center":
|
||||
targetX = toRect.left + toRect.width / 2;
|
||||
targetY = toRect.top + toRect.height / 2;
|
||||
break;
|
||||
case "top-left":
|
||||
targetX = toRect.left;
|
||||
targetY = toRect.top;
|
||||
break;
|
||||
case "top-right":
|
||||
targetX = toRect.right;
|
||||
targetY = toRect.top;
|
||||
break;
|
||||
case "bottom-left":
|
||||
targetX = toRect.left;
|
||||
targetY = toRect.bottom;
|
||||
break;
|
||||
case "bottom-right":
|
||||
targetX = toRect.right;
|
||||
targetY = toRect.bottom;
|
||||
break;
|
||||
case 'center':
|
||||
targetX = toRect.left + toRect.width / 2
|
||||
targetY = toRect.top + toRect.height / 2
|
||||
break
|
||||
case 'top-left':
|
||||
targetX = toRect.left
|
||||
targetY = toRect.top
|
||||
break
|
||||
case 'top-right':
|
||||
targetX = toRect.right
|
||||
targetY = toRect.top
|
||||
break
|
||||
case 'bottom-left':
|
||||
targetX = toRect.left
|
||||
targetY = toRect.bottom
|
||||
break
|
||||
case 'bottom-right':
|
||||
targetX = toRect.right
|
||||
targetY = toRect.bottom
|
||||
break
|
||||
}
|
||||
|
||||
// 克隆起始元素的 HTML 内容和样式
|
||||
// 使用 cloneNode 深度克隆,包括所有子元素
|
||||
const clonedElement = fromRef.current!.cloneNode(true) as HTMLElement;
|
||||
const clonedElement = fromRef.current!.cloneNode(true) as HTMLElement
|
||||
|
||||
// 复制计算后的样式到克隆元素,确保尺寸正确
|
||||
const computedStyle = window.getComputedStyle(fromRef.current!);
|
||||
|
||||
const computedStyle = window.getComputedStyle(fromRef.current!)
|
||||
|
||||
// 复制所有样式属性
|
||||
const allStyles = Array.from(computedStyle);
|
||||
const allStyles = Array.from(computedStyle)
|
||||
allStyles.forEach((prop) => {
|
||||
const value = computedStyle.getPropertyValue(prop);
|
||||
const value = computedStyle.getPropertyValue(prop)
|
||||
if (value) {
|
||||
clonedElement.style.setProperty(prop, value);
|
||||
clonedElement.style.setProperty(prop, value)
|
||||
}
|
||||
});
|
||||
|
||||
})
|
||||
|
||||
// 确保克隆元素的尺寸与原始元素一致
|
||||
clonedElement.style.width = `${fromRect.width}px`;
|
||||
clonedElement.style.height = `${fromRect.height}px`;
|
||||
clonedElement.style.position = "absolute";
|
||||
clonedElement.style.left = "0";
|
||||
clonedElement.style.top = "0";
|
||||
clonedElement.style.margin = "0";
|
||||
clonedElement.style.padding = "0";
|
||||
clonedElement.style.width = `${fromRect.width}px`
|
||||
clonedElement.style.height = `${fromRect.height}px`
|
||||
clonedElement.style.position = 'absolute'
|
||||
clonedElement.style.left = '0'
|
||||
clonedElement.style.top = '0'
|
||||
clonedElement.style.margin = '0'
|
||||
clonedElement.style.padding = '0'
|
||||
|
||||
// 获取克隆后的 HTML(只获取内部内容,不包含外层元素)
|
||||
const elementHtml = clonedElement.innerHTML;
|
||||
const elementHtml = clonedElement.innerHTML
|
||||
|
||||
// 创建动画实例
|
||||
const id = `fly-to-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 11)}`;
|
||||
const id = `fly-to-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
|
||||
const instance: AnimationInstance = {
|
||||
id,
|
||||
...options,
|
||||
startPos: { x: startX, y: startY },
|
||||
targetPos: { x: targetX, y: targetY },
|
||||
elementHtml,
|
||||
};
|
||||
}
|
||||
|
||||
this.animations.set(id, instance);
|
||||
this.notify();
|
||||
this.animations.set(id, instance)
|
||||
this.notify()
|
||||
|
||||
// 动画完成后自动清理
|
||||
const duration = options.duration || 1200;
|
||||
const duration = options.duration || 1200
|
||||
setTimeout(() => {
|
||||
this.remove(id);
|
||||
options.onComplete?.();
|
||||
}, duration);
|
||||
});
|
||||
this.remove(id)
|
||||
options.onComplete?.()
|
||||
}, duration)
|
||||
})
|
||||
}
|
||||
|
||||
// 移除动画
|
||||
remove(id: string) {
|
||||
this.animations.delete(id);
|
||||
this.notify();
|
||||
this.animations.delete(id)
|
||||
this.notify()
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局单例
|
||||
export const flyToAnimationManager = new FlyToAnimationManagerClass();
|
||||
export const flyToAnimationManager = new FlyToAnimationManagerClass()
|
||||
|
||||
// 单个动画项组件
|
||||
const FlyToAnimationItem = ({
|
||||
animation,
|
||||
}: {
|
||||
animation: AnimationInstance;
|
||||
}) => {
|
||||
const FlyToAnimationItem = ({ animation }: { animation: AnimationInstance }) => {
|
||||
const {
|
||||
startPos,
|
||||
targetPos,
|
||||
elementHtml,
|
||||
duration = 1200,
|
||||
ease = "easeInOut",
|
||||
className = "",
|
||||
ease = 'easeInOut',
|
||||
className = '',
|
||||
scale = { from: 1, to: 0.3 },
|
||||
opacity = [1, 0.8, 0],
|
||||
} = animation;
|
||||
} = animation
|
||||
|
||||
// 计算 opacity 的 times 数组
|
||||
const opacityTimes =
|
||||
opacity.length > 1
|
||||
? opacity.map((_, i) => i / (opacity.length - 1))
|
||||
: [0, 1];
|
||||
const opacityTimes = opacity.length > 1 ? opacity.map((_, i) => i / (opacity.length - 1)) : [0, 1]
|
||||
|
||||
// 如果 opacity 是数组,使用整个数组;否则使用单个值
|
||||
const animateOpacity =
|
||||
opacity.length > 1 ? opacity : opacity[opacity.length - 1] ?? 0;
|
||||
const animateOpacity = opacity.length > 1 ? opacity : (opacity[opacity.length - 1] ?? 0)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -247,10 +232,7 @@ const FlyToAnimationItem = ({
|
||||
`translate(-50%, -50%) translateX(${x}) translateY(${y}) scale(${scale})`
|
||||
}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: elementHtml }}
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
<div dangerouslySetInnerHTML={{ __html: elementHtml }} style={{ pointerEvents: 'none' }} />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { flyToAnimationManager } from "./FlyToAnimationManager";
|
||||
import type { FlyToAnimationOptions } from "./FlyToAnimationManager";
|
||||
import { flyToAnimationManager } from './FlyToAnimationManager'
|
||||
import type { FlyToAnimationOptions } from './FlyToAnimationManager'
|
||||
|
||||
/**
|
||||
* 飞行动画函数
|
||||
@@ -19,5 +19,5 @@ import type { FlyToAnimationOptions } from "./FlyToAnimationManager";
|
||||
* ```
|
||||
*/
|
||||
export const flyToAnimation = (options: FlyToAnimationOptions) => {
|
||||
flyToAnimationManager.flyTo(options);
|
||||
};
|
||||
flyToAnimationManager.flyTo(options)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// 函数式 API
|
||||
export { flyToAnimation } from "./flyToAnimation";
|
||||
export type { FlyToAnimationOptions } from "./FlyToAnimationManager";
|
||||
export type { FlyToPosition } from "./FlyToAnimationManager";
|
||||
export { flyToAnimation } from './flyToAnimation'
|
||||
export type { FlyToAnimationOptions } from './FlyToAnimationManager'
|
||||
export type { FlyToPosition } from './FlyToAnimationManager'
|
||||
@@ -1,56 +1,56 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Button, ImageButton } from "./button";
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Button, ImageButton } from './button'
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: "Components/Button",
|
||||
title: 'Components/Button',
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["primary", "secondary", "ghost"],
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'ghost'],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default meta;
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof Button>;
|
||||
type Story = StoryObj<typeof Button>
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: "primary",
|
||||
children: "按钮",
|
||||
variant: 'primary',
|
||||
children: '按钮',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
variant: "secondary",
|
||||
children: "次要按钮",
|
||||
variant: 'secondary',
|
||||
children: '次要按钮',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
variant: "ghost",
|
||||
children: "幽灵按钮",
|
||||
variant: 'ghost',
|
||||
children: '幽灵按钮',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
children: "禁用状态",
|
||||
children: '禁用状态',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
loading: true,
|
||||
children: "加载中...",
|
||||
children: '加载中...',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const WithImage: Story = {
|
||||
render: () => (
|
||||
@@ -64,12 +64,12 @@ export const WithImage: Story = {
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
args: {
|
||||
variant: "primary",
|
||||
children: "点击我",
|
||||
className: "px-8 py-3 text-lg",
|
||||
variant: 'primary',
|
||||
children: '点击我',
|
||||
className: 'px-8 py-3 text-lg',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { forwardRef, useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BaseButtonProps, ImageButtonProps } from "./button.types";
|
||||
import { forwardRef, useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BaseButtonProps, ImageButtonProps } from './button.types'
|
||||
|
||||
/**
|
||||
* 基础按钮组件
|
||||
@@ -9,44 +9,39 @@ const Button = forwardRef<HTMLButtonElement, BaseButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className = "",
|
||||
variant = "primary",
|
||||
className = '',
|
||||
variant = 'primary',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
type = "button",
|
||||
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";
|
||||
'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",
|
||||
};
|
||||
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
|
||||
)}
|
||||
type={type as 'button' | 'submit' | 'reset' | undefined}
|
||||
className={cn(baseClasses, variantClasses[variant], 'px-4 py-2', className)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? "加载中..." : children}
|
||||
{loading ? '加载中...' : children}
|
||||
</button>
|
||||
);
|
||||
)
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
Button.displayName = "Button";
|
||||
Button.displayName = 'Button'
|
||||
|
||||
/**
|
||||
* 图片按钮组件
|
||||
@@ -57,50 +52,50 @@ const ImageButton = forwardRef<HTMLButtonElement, ImageButtonProps>(
|
||||
src,
|
||||
alt,
|
||||
hoverSrc,
|
||||
width = "auto",
|
||||
height = "auto",
|
||||
className = "",
|
||||
width = 'auto',
|
||||
height = 'auto',
|
||||
className = '',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
type = "button",
|
||||
type = 'button',
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [imageSrc, setImageSrc] = useState(src);
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [imageSrc, setImageSrc] = useState(src)
|
||||
|
||||
useEffect(() => {
|
||||
if (hoverSrc) {
|
||||
const img = new Image();
|
||||
img.src = hoverSrc;
|
||||
const img = new Image()
|
||||
img.src = hoverSrc
|
||||
}
|
||||
}, [hoverSrc]);
|
||||
}, [hoverSrc])
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setIsHovered(true);
|
||||
setIsHovered(true)
|
||||
if (hoverSrc) {
|
||||
setImageSrc(hoverSrc);
|
||||
setImageSrc(hoverSrc)
|
||||
}
|
||||
onMouseEnter?.(e);
|
||||
};
|
||||
onMouseEnter?.(e)
|
||||
}
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setIsHovered(false);
|
||||
setImageSrc(src);
|
||||
onMouseLeave?.(e);
|
||||
};
|
||||
setIsHovered(false)
|
||||
setImageSrc(src)
|
||||
onMouseLeave?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type as "button" | "submit" | "reset" | undefined}
|
||||
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",
|
||||
'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}
|
||||
@@ -113,17 +108,14 @@ const ImageButton = forwardRef<HTMLButtonElement, ImageButtonProps>(
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn(
|
||||
"object-contain transition-transform",
|
||||
isHovered && "transform scale-105"
|
||||
)}
|
||||
className={cn('object-contain transition-transform', isHovered && 'transform scale-105')}
|
||||
draggable="false"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
)
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
ImageButton.displayName = "ImageButton";
|
||||
ImageButton.displayName = 'ImageButton'
|
||||
|
||||
export { Button, ImageButton };
|
||||
export { Button, ImageButton }
|
||||
@@ -1,30 +1,29 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
/** 基础按钮属性 */
|
||||
export interface BaseButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
export interface BaseButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** 是否显示加载状态 */
|
||||
loading?: boolean;
|
||||
loading?: boolean
|
||||
/** 按钮变体 */
|
||||
variant?: "primary" | "secondary" | "ghost";
|
||||
variant?: 'primary' | 'secondary' | 'ghost'
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 子元素 */
|
||||
children: ReactNode;
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** 图片按钮属性 */
|
||||
export interface ImageButtonProps extends Omit<BaseButtonProps, "children"> {
|
||||
export interface ImageButtonProps extends Omit<BaseButtonProps, 'children'> {
|
||||
/** 图片URL */
|
||||
src: string;
|
||||
src: string
|
||||
/** 图片替代文本 */
|
||||
alt: string;
|
||||
alt: string
|
||||
/** 图片宽度 */
|
||||
width?: number | string;
|
||||
width?: number | string
|
||||
/** 图片高度 */
|
||||
height?: number | string;
|
||||
height?: number | string
|
||||
/** 悬停时显示的图片URL */
|
||||
hoverSrc?: string;
|
||||
hoverSrc?: string
|
||||
}
|
||||
|
||||
export type ButtonProps = BaseButtonProps | ImageButtonProps;
|
||||
export type ButtonProps = BaseButtonProps | ImageButtonProps
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./button";
|
||||
export * from "./button.types";
|
||||
export * from './button'
|
||||
export * from './button.types'
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Image } from "@/components";
|
||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Clue } from "@/store/clueStore";
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Image } from '@/components'
|
||||
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Clue } from '@/store/clueStore'
|
||||
|
||||
export interface ClueBookModalProps {
|
||||
visible: boolean;
|
||||
clues: Clue[];
|
||||
onClose: () => void;
|
||||
getClueImage: (clueId: string) => string;
|
||||
getClueDetailImage: (clueId: string) => string;
|
||||
getClueHintImage?: (clueId: string) => string | null;
|
||||
visible: boolean
|
||||
clues: Clue[]
|
||||
onClose: () => void
|
||||
getClueImage: (clueId: string) => string
|
||||
getClueDetailImage: (clueId: string) => string
|
||||
getClueHintImage?: (clueId: string) => string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,34 +24,34 @@ export const ClueBookModal = ({
|
||||
getClueDetailImage,
|
||||
getClueHintImage,
|
||||
}: ClueBookModalProps) => {
|
||||
const [selectedClueId, setSelectedClueId] = useState<string | null>(null);
|
||||
const [showClueHint, setShowClueHint] = useState(false);
|
||||
const [selectedClueId, setSelectedClueId] = useState<string | null>(null)
|
||||
const [showClueHint, setShowClueHint] = useState(false)
|
||||
|
||||
// 默认选中第一个已收集的线索
|
||||
useEffect(() => {
|
||||
if (visible && clues.length > 0 && !selectedClueId) {
|
||||
const firstCollected = clues.find((c) => c.collected);
|
||||
const firstCollected = clues.find((c) => c.collected)
|
||||
if (firstCollected) {
|
||||
setSelectedClueId(firstCollected.id);
|
||||
setSelectedClueId(firstCollected.id)
|
||||
}
|
||||
}
|
||||
}, [visible, clues, selectedClueId]);
|
||||
}, [visible, clues, selectedClueId])
|
||||
|
||||
// 当切换线索时,关闭线索提示
|
||||
useEffect(() => {
|
||||
setShowClueHint(false);
|
||||
}, [selectedClueId]);
|
||||
setShowClueHint(false)
|
||||
}, [selectedClueId])
|
||||
|
||||
// 检查当前线索是否有提示
|
||||
const hasClueHint = (clueId: string): boolean => {
|
||||
if (!getClueHintImage) return false;
|
||||
const hintImage = getClueHintImage(clueId);
|
||||
return hintImage !== null && hintImage !== undefined;
|
||||
};
|
||||
if (!getClueHintImage) return false
|
||||
const hintImage = getClueHintImage(clueId)
|
||||
return hintImage !== null && hintImage !== undefined
|
||||
}
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
const selectedClue = clues.find((c) => c.id === selectedClueId);
|
||||
const selectedClue = clues.find((c) => c.id === selectedClueId)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center">
|
||||
@@ -67,9 +67,7 @@ export const ClueBookModal = ({
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage(
|
||||
"sunzhongshan/reflection/详情线索簿背景"
|
||||
),
|
||||
backgroundImage: getBackgroundImage('sunzhongshan/reflection/详情线索簿背景'),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -81,8 +79,8 @@ export const ClueBookModal = ({
|
||||
<div className="text-center text-gray-500 mt-8">暂无线索</div>
|
||||
) : (
|
||||
clues.map((clue) => {
|
||||
const isSelected = clue.id === selectedClueId;
|
||||
const isCollected = clue.collected;
|
||||
const isSelected = clue.id === selectedClueId
|
||||
const isCollected = clue.collected
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -95,27 +93,22 @@ export const ClueBookModal = ({
|
||||
// )}
|
||||
onClick={() => {
|
||||
if (isCollected) {
|
||||
setSelectedClueId(clue.id);
|
||||
setSelectedClueId(clue.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 线索缩略图 - 带倾斜效果 */}
|
||||
<Image
|
||||
src={
|
||||
isCollected
|
||||
? getClueImage(clue.id)
|
||||
: getClueImage(clue.id)
|
||||
}
|
||||
src={isCollected ? getClueImage(clue.id) : getClueImage(clue.id)}
|
||||
className={cn(
|
||||
"hover:scale-110 hover:brightness-115 cursor-pointer transition-all duration-300",
|
||||
isSelected &&
|
||||
"scale-115 brightness-115 z-10 grayscale-0",
|
||||
'hover:scale-110 hover:brightness-115 cursor-pointer transition-all duration-300',
|
||||
isSelected && 'scale-115 brightness-115 z-10 grayscale-0',
|
||||
!isCollected &&
|
||||
"opacity-50 grayscale cursor-not-allowed hover:scale-100 hover:brightness-100"
|
||||
'opacity-50 grayscale cursor-not-allowed hover:scale-100 hover:brightness-100'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
@@ -126,37 +119,27 @@ export const ClueBookModal = ({
|
||||
<div className="w-full h-full flex flex-col items-center justify-center relative">
|
||||
{/* 显示线索详情图片 */}
|
||||
<div className="relative h-full text-center">
|
||||
<Image
|
||||
src={getClueDetailImage(selectedClue.id)}
|
||||
className="h-full"
|
||||
/>
|
||||
<Image src={getClueDetailImage(selectedClue.id)} className="h-full" />
|
||||
{/* 线索提示描述(覆盖在详情图片上) */}
|
||||
{showClueHint &&
|
||||
hasClueHint(selectedClue.id) &&
|
||||
getClueHintImage && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center">
|
||||
<div className="relative h-full">
|
||||
<Image
|
||||
src={getClueHintImage(selectedClue.id)!}
|
||||
className="h-full"
|
||||
/>
|
||||
{/* 关闭按钮 */}
|
||||
<Image
|
||||
className="absolute top-0 right-0 z-30"
|
||||
src={getStaticImage(`sunzhongshan/reflection/关闭`)}
|
||||
button
|
||||
onClick={() => setShowClueHint(false)}
|
||||
/>
|
||||
</div>
|
||||
{showClueHint && hasClueHint(selectedClue.id) && getClueHintImage && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center">
|
||||
<div className="relative h-full">
|
||||
<Image src={getClueHintImage(selectedClue.id)!} className="h-full" />
|
||||
{/* 关闭按钮 */}
|
||||
<Image
|
||||
className="absolute top-0 right-0 z-30"
|
||||
src={getStaticImage(`sunzhongshan/reflection/关闭`)}
|
||||
button
|
||||
onClick={() => setShowClueHint(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 线索提示按钮(右下角) */}
|
||||
{hasClueHint(selectedClue.id) && (
|
||||
<div className="absolute bottom-10 right-0 z-10">
|
||||
<Image
|
||||
src={getStaticImage(
|
||||
`sunzhongshan/reflection/内置线索提示按钮`
|
||||
)}
|
||||
src={getStaticImage(`sunzhongshan/reflection/内置线索提示按钮`)}
|
||||
button
|
||||
onClick={() => setShowClueHint(true)}
|
||||
/>
|
||||
@@ -166,7 +149,7 @@ export const ClueBookModal = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 text-4xl">
|
||||
{selectedClue ? "该线索尚未收集" : "请选择一个线索查看详情"}
|
||||
{selectedClue ? '该线索尚未收集' : '请选择一个线索查看详情'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -181,5 +164,5 @@ export const ClueBookModal = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Image } from "@/components";
|
||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Image } from '@/components'
|
||||
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
|
||||
export interface ClueHint {
|
||||
id: string;
|
||||
name: string;
|
||||
id: string
|
||||
name: string
|
||||
position: {
|
||||
top: string;
|
||||
left: string;
|
||||
width: string;
|
||||
height: string;
|
||||
};
|
||||
top: string
|
||||
left: string
|
||||
width: string
|
||||
height: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClueHintModalProps {
|
||||
visible: boolean;
|
||||
backgroundImage: string;
|
||||
clues: ClueHint[];
|
||||
onClose: () => void;
|
||||
sourceContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
visible: boolean
|
||||
backgroundImage: string
|
||||
clues: ClueHint[]
|
||||
onClose: () => void
|
||||
sourceContainerRef?: React.RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,65 +31,55 @@ export const ClueHintModal = ({
|
||||
onClose,
|
||||
sourceContainerRef,
|
||||
}: ClueHintModalProps) => {
|
||||
const modalContainerRef = useRef<HTMLDivElement>(null);
|
||||
const backgroundRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [size, setSize] = useState({ width: "100%", height: "100%" });
|
||||
const modalContainerRef = useRef<HTMLDivElement>(null)
|
||||
const backgroundRef = useRef<HTMLDivElement>(null)
|
||||
const [scale, setScale] = useState(1)
|
||||
const [size, setSize] = useState({ width: '100%', height: '100%' })
|
||||
|
||||
// 计算缩放比例和偏移
|
||||
useEffect(() => {
|
||||
if (
|
||||
!visible ||
|
||||
!sourceContainerRef?.current ||
|
||||
!modalContainerRef.current
|
||||
) {
|
||||
return;
|
||||
if (!visible || !sourceContainerRef?.current || !modalContainerRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const calculateScale = () => {
|
||||
const sourceRect = sourceContainerRef.current!.getBoundingClientRect();
|
||||
const modalRect = modalContainerRef.current!.getBoundingClientRect();
|
||||
const sourceRect = sourceContainerRef.current!.getBoundingClientRect()
|
||||
const modalRect = modalContainerRef.current!.getBoundingClientRect()
|
||||
|
||||
if (sourceRect.width === 0 || sourceRect.height === 0) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// 计算宽高缩放比例,取较小的值以确保完全显示
|
||||
const scaleX = modalRect.width / sourceRect.width;
|
||||
const scaleY = modalRect.height / sourceRect.height;
|
||||
const newScale = Math.min(scaleX, scaleY);
|
||||
const scaleX = modalRect.width / sourceRect.width
|
||||
const scaleY = modalRect.height / sourceRect.height
|
||||
const newScale = Math.min(scaleX, scaleY)
|
||||
|
||||
setScale(newScale);
|
||||
setScale(newScale)
|
||||
setSize({
|
||||
width: `${sourceRect.width}px`,
|
||||
height: `${sourceRect.height}px`,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
// 延迟计算以确保 DOM 已渲染
|
||||
const timer = setTimeout(calculateScale, 0);
|
||||
calculateScale();
|
||||
const timer = setTimeout(calculateScale, 0)
|
||||
calculateScale()
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener("resize", calculateScale);
|
||||
window.addEventListener('resize', calculateScale)
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("resize", calculateScale);
|
||||
};
|
||||
}, [visible, sourceContainerRef]);
|
||||
clearTimeout(timer)
|
||||
window.removeEventListener('resize', calculateScale)
|
||||
}
|
||||
}, [visible, sourceContainerRef])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center"
|
||||
ref={modalContainerRef}
|
||||
>
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center" ref={modalContainerRef}>
|
||||
{/* 蒙版背景 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* 背景图片容器 - 使用缩放匹配源容器尺寸并居中 */}
|
||||
<div
|
||||
@@ -98,19 +88,19 @@ export const ClueHintModal = ({
|
||||
style={{
|
||||
backgroundImage: `url(${backgroundImage})`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "center center",
|
||||
transformOrigin: 'center center',
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
border: "40px solid transparent",
|
||||
borderImageSource: getBackgroundImage("sunzhongshan/reflection/框"),
|
||||
borderImageSlice: "40 fill",
|
||||
borderImageRepeat: "stretch round",
|
||||
border: '40px solid transparent',
|
||||
borderImageSource: getBackgroundImage('sunzhongshan/reflection/框'),
|
||||
borderImageSlice: '40 fill',
|
||||
borderImageRepeat: 'stretch round',
|
||||
}}
|
||||
>
|
||||
{/* 线索标记 */}
|
||||
{clues.map(
|
||||
(clue) =>
|
||||
clue.position.width !== "0%" && (
|
||||
clue.position.width !== '0%' && (
|
||||
<div
|
||||
key={clue.id}
|
||||
className="absolute border-4 border-yellow-400 bg-yellow-400/20 rounded-lg flex items-center justify-center animate-pulse"
|
||||
@@ -137,5 +127,5 @@ export const ClueHintModal = ({
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export { ClueHintModal } from "./ClueHintModal";
|
||||
export type { ClueHintModalProps, ClueHint } from "./ClueHintModal";
|
||||
export { ClueHintModal } from './ClueHintModal'
|
||||
export type { ClueHintModalProps, ClueHint } from './ClueHintModal'
|
||||
|
||||
export { ClueBookModal } from "./ClueBookModal";
|
||||
export type { ClueBookModalProps } from "./ClueBookModal";
|
||||
export { ClueBookModal } from './ClueBookModal'
|
||||
export type { ClueBookModalProps } from './ClueBookModal'
|
||||
@@ -1,73 +1,65 @@
|
||||
import { cn, getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
|
||||
// 原始尺寸(保持比例)
|
||||
const ORIGINAL_SIZES = {
|
||||
read: { width: 116, height: 140 },
|
||||
unread: { width: 120, height: 121 },
|
||||
} as const;
|
||||
} as const
|
||||
|
||||
interface CursorProps {
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
className?: string
|
||||
onClick: () => void
|
||||
/** 已阅读 / 已完成 状态;true 时显示预留的 div,由你自行实现效果 */
|
||||
read?: boolean;
|
||||
read?: boolean
|
||||
/** 尺寸缩放比例,默认为 1(原始大小) */
|
||||
size?: number;
|
||||
size?: number
|
||||
/** 禁用状态,禁用时无法点击但不影响 read 状态显示。当 read=true 时,disabled 不生效 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const Cursor = ({
|
||||
className,
|
||||
onClick,
|
||||
read = false,
|
||||
size = 1,
|
||||
disabled = false,
|
||||
}: CursorProps) => {
|
||||
const baseSize = read ? ORIGINAL_SIZES.read : ORIGINAL_SIZES.unread;
|
||||
const Cursor = ({ className, onClick, read = false, size = 1, disabled = false }: CursorProps) => {
|
||||
const baseSize = read ? ORIGINAL_SIZES.read : ORIGINAL_SIZES.unread
|
||||
// 当 read=true 时,disabled 不生效;只有 read=false 且 disabled=true 时才禁用
|
||||
const isDisabled = disabled && !read;
|
||||
const isDisabled = disabled && !read
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
onClick();
|
||||
};
|
||||
onClick()
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
if (isDisabled) return;
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
};
|
||||
if (isDisabled) return
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"select-none flex justify-center items-center pl-0 transition-all duration-300",
|
||||
'select-none flex justify-center items-center pl-0 transition-all duration-300',
|
||||
isDisabled
|
||||
? "opacity-50"
|
||||
: "hover:scale-105 hover:brightness-110 hover:contrast-110 active:scale-95",
|
||||
!read && "animate-[spin_3s_linear_infinite]",
|
||||
? 'opacity-50'
|
||||
: 'hover:scale-105 hover:brightness-110 hover:contrast-110 active:scale-95',
|
||||
!read && 'animate-[spin_3s_linear_infinite]',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
width: `${baseSize.width * size}px`,
|
||||
height: `${baseSize.height * size}px`,
|
||||
WebkitTapHighlightColor: "transparent",
|
||||
WebkitTouchCallout: "none",
|
||||
WebkitUserSelect: "none",
|
||||
touchAction: isDisabled ? "none" : "manipulation",
|
||||
pointerEvents: "auto",
|
||||
cursor: isDisabled ? "not-allowed" : "pointer",
|
||||
backgroundImage: getBackgroundImage(
|
||||
read ? "components/point_" : "components/border"
|
||||
),
|
||||
backgroundSize: "contain",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
WebkitTouchCallout: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
touchAction: isDisabled ? 'none' : 'manipulation',
|
||||
pointerEvents: 'auto',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
backgroundImage: getBackgroundImage(read ? 'components/point_' : 'components/border'),
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
@@ -75,19 +67,19 @@ const Cursor = ({
|
||||
>
|
||||
{!read && (
|
||||
<img
|
||||
src={getStaticImage("components/point.png")}
|
||||
src={getStaticImage('components/point.png')}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
objectPosition: "center",
|
||||
pointerEvents: "none",
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Cursor;
|
||||
export default Cursor
|
||||
@@ -1,21 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { cn, getBackgroundImage } from "@/lib/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||
import { useEffect } from 'react'
|
||||
import { cn, getBackgroundImage } from '@/lib/utils'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useAudioManager } from '@/hooks/useAudioManager'
|
||||
|
||||
export interface ExhibitDescriptionNarrationProps {
|
||||
/** 描述文本 */
|
||||
text?: string;
|
||||
text?: string
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 是否自动播放音频(默认 true) */
|
||||
autoPlay?: boolean;
|
||||
autoPlay?: boolean
|
||||
/** 音频文件地址 */
|
||||
audioSrc?: string;
|
||||
audioSrc?: string
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
audioChannelId?: string
|
||||
/** 额外的自定义类 */
|
||||
className?: string;
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,30 +34,27 @@ const ExhibitDescriptionNarration = ({
|
||||
}: ExhibitDescriptionNarrationProps) => {
|
||||
// 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥
|
||||
const { currentSrc, isPlaying, play, stop } = useAudioManager(
|
||||
audioChannelId
|
||||
? { mode: "channel", channelId: audioChannelId }
|
||||
: { mode: "global" }
|
||||
);
|
||||
audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' }
|
||||
)
|
||||
|
||||
// 当前组件对应的音频是否正在播放
|
||||
const isCurrentAudioPlaying =
|
||||
audioSrc && currentSrc === audioSrc && isPlaying;
|
||||
const isCurrentAudioPlaying = audioSrc && currentSrc === audioSrc && isPlaying
|
||||
|
||||
// 自动播放音频
|
||||
useEffect(() => {
|
||||
if (visible && autoPlay && audioSrc) {
|
||||
play(audioSrc);
|
||||
play(audioSrc)
|
||||
}
|
||||
}, [visible, autoPlay, audioSrc, play]);
|
||||
}, [visible, autoPlay, audioSrc, play])
|
||||
|
||||
// 组件不可见时停止音频(仅当当前播放的是该组件的 audioSrc)
|
||||
useEffect(() => {
|
||||
if (!visible && audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
stop()
|
||||
}
|
||||
}, [visible, audioSrc, currentSrc, stop]);
|
||||
}, [visible, audioSrc, currentSrc, stop])
|
||||
|
||||
if (!text) return null;
|
||||
if (!text) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -68,9 +65,9 @@ const ExhibitDescriptionNarration = ({
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={cn(
|
||||
"max-w-[580px] px-12 py-14",
|
||||
"shadow-black/60",
|
||||
"relative overflow-hidden",
|
||||
'max-w-[580px] px-12 py-14',
|
||||
'shadow-black/60',
|
||||
'relative overflow-hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -84,12 +81,10 @@ const ExhibitDescriptionNarration = ({
|
||||
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",
|
||||
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">
|
||||
@@ -107,11 +102,7 @@ const ExhibitDescriptionNarration = ({
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
@@ -122,11 +113,7 @@ const ExhibitDescriptionNarration = ({
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
@@ -137,7 +124,7 @@ const ExhibitDescriptionNarration = ({
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ExhibitDescriptionNarration;
|
||||
export default ExhibitDescriptionNarration
|
||||
@@ -1,42 +1,35 @@
|
||||
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";
|
||||
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;
|
||||
src: string
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
onClose?: () => void
|
||||
/** 是否可见,默认 true */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 额外的自定义类 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 展品描述文本 */
|
||||
description?: string;
|
||||
description?: string
|
||||
/** 是否自动播放描述语音,默认 false */
|
||||
autoPlayDescription?: boolean;
|
||||
autoPlayDescription?: boolean
|
||||
/** 展品描述对应的音频地址(可选) */
|
||||
audioSrc?: string;
|
||||
audioSrc?: string
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
audioChannelId?: string
|
||||
/** 点击展示 */
|
||||
onClick?: () => void;
|
||||
onClick?: () => void
|
||||
/** 打开回调 */
|
||||
onOpen?: () => void;
|
||||
onOpen?: () => void
|
||||
}
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
type Point = { x: number; y: number }
|
||||
|
||||
/**
|
||||
* 展品通用遮罩层
|
||||
@@ -58,215 +51,211 @@ const ExhibitOverlay = ({
|
||||
onClick,
|
||||
onOpen,
|
||||
}: ExhibitOverlayProps) => {
|
||||
const maskSrc = getStaticImage("components/蒙版遮罩");
|
||||
const backSrc = getStaticImage("components/close");
|
||||
const maskSrc = getStaticImage('components/蒙版遮罩')
|
||||
const backSrc = getStaticImage('components/close')
|
||||
|
||||
// 获取缩放参数,用于视觉对齐
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
// SSR 兼容:只在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// 允许传入相对路径或绝对 URL
|
||||
const exhibitSrc =
|
||||
src.startsWith("http://") ||
|
||||
src.startsWith("https://") ||
|
||||
src.startsWith("/")
|
||||
src.startsWith('http://') || src.startsWith('https://') || src.startsWith('/')
|
||||
? src
|
||||
: getStaticImage(src);
|
||||
: getStaticImage(src)
|
||||
|
||||
const handleClose = (e: MouseEvent<HTMLImageElement>) => {
|
||||
e.stopPropagation();
|
||||
onClose?.();
|
||||
};
|
||||
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);
|
||||
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 });
|
||||
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;
|
||||
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;
|
||||
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";
|
||||
}, []);
|
||||
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;
|
||||
if (frameRef.current) return
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
applyTransformStyle();
|
||||
frameRef.current = null;
|
||||
});
|
||||
}, [applyTransformStyle]);
|
||||
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;
|
||||
setIsDragging(false);
|
||||
transformRef.current = { scale: 1, x: 0, y: 0 }
|
||||
pointers.current.clear()
|
||||
lastDistance.current = null
|
||||
dragStartRef.current = null
|
||||
setIsDragging(false)
|
||||
// 释放所有指针捕获
|
||||
if (containerRef.current) {
|
||||
pointers.current.forEach((_, pointerId) => {
|
||||
try {
|
||||
containerRef.current?.releasePointerCapture(pointerId);
|
||||
containerRef.current?.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
applyTransformStyle();
|
||||
}, [applyTransformStyle]);
|
||||
applyTransformStyle()
|
||||
}, [applyTransformStyle])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
onOpen?.();
|
||||
onOpen?.()
|
||||
}
|
||||
}, [visible, onOpen]);
|
||||
}, [visible, onOpen])
|
||||
|
||||
// 关闭或切换时重置
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
resetTransform();
|
||||
resetTransform()
|
||||
} else {
|
||||
// 不可见时清理所有状态和指针捕获
|
||||
transformRef.current = { scale: 1, x: 0, y: 0 };
|
||||
pointers.current.clear();
|
||||
lastDistance.current = null;
|
||||
dragStartRef.current = null;
|
||||
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);
|
||||
containerRef.current?.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
// 取消待处理的动画帧
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
frameRef.current = null
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, src]);
|
||||
}, [visible, src])
|
||||
|
||||
// 组件卸载时清理资源
|
||||
useEffect(() => {
|
||||
const containerEl = containerRef.current;
|
||||
const pointersMap = pointers.current;
|
||||
const frameId = frameRef.current;
|
||||
const containerEl = containerRef.current
|
||||
const pointersMap = pointers.current
|
||||
const frameId = frameRef.current
|
||||
|
||||
return () => {
|
||||
// 清理所有指针捕获
|
||||
if (containerEl) {
|
||||
pointersMap.forEach((_, pointerId) => {
|
||||
try {
|
||||
containerEl.releasePointerCapture(pointerId);
|
||||
containerEl.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
// 取消待处理的动画帧
|
||||
if (frameId) {
|
||||
cancelAnimationFrame(frameId);
|
||||
cancelAnimationFrame(frameId)
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 缩放函数:origin 是屏幕坐标,需要转换为相对于 viewElRef 的坐标
|
||||
const applyZoom = useCallback(
|
||||
(factor: number, origin: Point) => {
|
||||
if (!viewElRef.current || !containerRef.current) return;
|
||||
if (!viewElRef.current || !containerRef.current) return
|
||||
|
||||
const prev = transformRef.current;
|
||||
const nextScale = clampScale(prev.scale * factor);
|
||||
const prev = transformRef.current
|
||||
const nextScale = clampScale(prev.scale * factor)
|
||||
// 边界检查:避免除零
|
||||
if (prev.scale === 0) return;
|
||||
if (prev.scale === 0) return
|
||||
|
||||
// 获取 viewElRef 相对于视口的位置
|
||||
const viewRect = viewElRef.current.getBoundingClientRect();
|
||||
const viewRect = viewElRef.current.getBoundingClientRect()
|
||||
// 将屏幕坐标转换为相对于 viewElRef 的坐标
|
||||
const relativeOrigin = {
|
||||
x: origin.x - viewRect.left,
|
||||
y: origin.y - viewRect.top,
|
||||
};
|
||||
}
|
||||
|
||||
const scaleChange = nextScale / prev.scale;
|
||||
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();
|
||||
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;
|
||||
const delta = e.deltaY
|
||||
const factor = delta < 0 ? 1.05 : 0.95
|
||||
// 直接使用屏幕坐标作为缩放中心
|
||||
applyZoom(factor, { x: e.clientX, y: e.clientY });
|
||||
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 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;
|
||||
if (!containerRef.current || !viewElRef.current) return
|
||||
try {
|
||||
containerRef.current.setPointerCapture(e.pointerId);
|
||||
containerRef.current.setPointerCapture(e.pointerId)
|
||||
} catch (err) {
|
||||
console.warn("Failed to capture pointer:", err);
|
||||
console.warn('Failed to capture pointer:', err)
|
||||
}
|
||||
|
||||
// 记录屏幕坐标
|
||||
const point = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
};
|
||||
pointers.current.set(e.pointerId, point);
|
||||
}
|
||||
pointers.current.set(e.pointerId, point)
|
||||
|
||||
// 单指拖拽:记录初始状态
|
||||
if (pointers.current.size === 1) {
|
||||
@@ -275,22 +264,22 @@ const ExhibitOverlay = ({
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startTransform: { ...transformRef.current },
|
||||
};
|
||||
setIsDragging(true);
|
||||
}
|
||||
setIsDragging(true)
|
||||
} else if (pointers.current.size === 2) {
|
||||
// 双指缩放:记录初始距离
|
||||
const values = Array.from(pointers.current.values());
|
||||
const values = Array.from(pointers.current.values())
|
||||
if (values.length === 2) {
|
||||
const [p1, p2] = values;
|
||||
const [p1, p2] = values
|
||||
if (p1 && p2) {
|
||||
lastDistance.current = getDistance(p1, p2);
|
||||
dragStartRef.current = null;
|
||||
lastDistance.current = getDistance(p1, p2)
|
||||
dragStartRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[getDistance]
|
||||
);
|
||||
)
|
||||
|
||||
/**
|
||||
* 单指拖拽处理
|
||||
@@ -299,29 +288,29 @@ const ExhibitOverlay = ({
|
||||
*/
|
||||
const handleSingleDrag = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const dragStart = dragStartRef.current;
|
||||
if (!dragStart) return;
|
||||
const dragStart = dragStartRef.current
|
||||
if (!dragStart) return
|
||||
|
||||
// 1. 计算屏幕坐标系下的位移
|
||||
const dxScreen = e.clientX - dragStart.startX;
|
||||
const dyScreen = e.clientY - dragStart.startY;
|
||||
const dxScreen = e.clientX - dragStart.startX
|
||||
const dyScreen = e.clientY - dragStart.startY
|
||||
|
||||
// 2. 映射到设计稿坐标系(补偿全局 scale)
|
||||
const dx = dxScreen / scale;
|
||||
const dy = dyScreen / scale;
|
||||
const dx = dxScreen / scale
|
||||
const dy = dyScreen / scale
|
||||
|
||||
// 3. 应用到 transform(设计稿坐标)
|
||||
const nextX = dragStart.startTransform.x + dx;
|
||||
const nextY = dragStart.startTransform.y + dy;
|
||||
const nextX = dragStart.startTransform.x + dx
|
||||
const nextY = dragStart.startTransform.y + dy
|
||||
|
||||
transformRef.current.x = nextX;
|
||||
transformRef.current.y = nextY;
|
||||
transformRef.current.x = nextX
|
||||
transformRef.current.y = nextY
|
||||
|
||||
// 统一交给 rAF 的 scheduleUpdate,减少直接 DOM 操作次数
|
||||
scheduleUpdate();
|
||||
scheduleUpdate()
|
||||
},
|
||||
[scale, scheduleUpdate]
|
||||
);
|
||||
)
|
||||
|
||||
/**
|
||||
* 双指缩放处理
|
||||
@@ -329,73 +318,72 @@ const ExhibitOverlay = ({
|
||||
* - 使用两指中点作为缩放中心
|
||||
*/
|
||||
const handlePinchZoom = useCallback(() => {
|
||||
if (pointers.current.size !== 2) return;
|
||||
const values = Array.from(pointers.current.values());
|
||||
const [p1, p2] = values;
|
||||
if (!p1 || !p2) return;
|
||||
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);
|
||||
const distance = getDistance(p1, p2)
|
||||
if (!lastDistance.current || lastDistance.current <= 0) {
|
||||
lastDistance.current = distance;
|
||||
return;
|
||||
lastDistance.current = distance
|
||||
return
|
||||
}
|
||||
|
||||
const factor = distance / lastDistance.current;
|
||||
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]);
|
||||
applyZoom(factor, center)
|
||||
lastDistance.current = distance
|
||||
}, [getDistance, applyZoom])
|
||||
|
||||
// 指针移动:更新指针位置,然后按当前手势类型分发给拖拽 / 缩放处理
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!pointers.current.has(e.pointerId)) return;
|
||||
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;
|
||||
handlePinchZoom()
|
||||
return
|
||||
}
|
||||
|
||||
// 单指拖拽
|
||||
if (pointers.current.size === 1) {
|
||||
handleSingleDrag(e);
|
||||
handleSingleDrag(e)
|
||||
}
|
||||
},
|
||||
[handlePinchZoom, handleSingleDrag]
|
||||
);
|
||||
)
|
||||
|
||||
// 指针抬起
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const dragStart = dragStartRef.current;
|
||||
const dragStart = dragStartRef.current
|
||||
|
||||
// 检测是否是点击(移动距离很小)
|
||||
if (dragStart && dragStart.pointerId === e.pointerId && onClick) {
|
||||
// 计算从按下到抬起的移动距离
|
||||
const dx = e.clientX - dragStart.startX;
|
||||
const dy = e.clientY - dragStart.startY;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const dx = e.clientX - dragStart.startX
|
||||
const dy = e.clientY - dragStart.startY
|
||||
const distance = Math.hypot(dx, dy)
|
||||
// 如果移动距离小于 5px,认为是点击而不是拖拽
|
||||
if (distance < 5) {
|
||||
// 检查 transform 是否改变,如果改变了说明是拖拽
|
||||
const transformChanged =
|
||||
Math.abs(transformRef.current.x - dragStart.startTransform.x) >
|
||||
0.1 ||
|
||||
Math.abs(transformRef.current.y - dragStart.startTransform.y) > 0.1;
|
||||
Math.abs(transformRef.current.x - dragStart.startTransform.x) > 0.1 ||
|
||||
Math.abs(transformRef.current.y - dragStart.startTransform.y) > 0.1
|
||||
|
||||
if (!transformChanged) {
|
||||
onClick();
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,76 +391,76 @@ const ExhibitOverlay = ({
|
||||
// 释放指针捕获
|
||||
if (containerRef.current) {
|
||||
try {
|
||||
containerRef.current.releasePointerCapture(e.pointerId);
|
||||
containerRef.current.releasePointerCapture(e.pointerId)
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
}
|
||||
|
||||
pointers.current.delete(e.pointerId);
|
||||
pointers.current.delete(e.pointerId)
|
||||
|
||||
if (pointers.current.size < 2) {
|
||||
lastDistance.current = null;
|
||||
lastDistance.current = null
|
||||
}
|
||||
|
||||
// 如果剩余单指,重新记录拖拽初始状态
|
||||
if (pointers.current.size === 1) {
|
||||
const remaining = Array.from(pointers.current.values())[0];
|
||||
const remainingId = Array.from(pointers.current.keys())[0];
|
||||
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;
|
||||
dragStartRef.current = null
|
||||
}
|
||||
|
||||
setIsDragging(pointers.current.size > 0);
|
||||
setIsDragging(pointers.current.size > 0)
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
)
|
||||
|
||||
// 双击重置
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
resetTransform();
|
||||
}, [resetTransform]);
|
||||
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",
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
pointerEvents: 'none',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
)
|
||||
|
||||
const scaledContainerStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
overflow: "hidden",
|
||||
transformOrigin: 'top left',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
|
||||
);
|
||||
)
|
||||
|
||||
if (!mounted) return null;
|
||||
if (!mounted) return null
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
@@ -483,16 +471,16 @@ const ExhibitOverlay = ({
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
<div style={scaledContainerStyle}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center",
|
||||
"bg-black/70",
|
||||
'absolute inset-0 flex items-center justify-center',
|
||||
'bg-black/70',
|
||||
className
|
||||
)}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
{/* 背景蒙版 */}
|
||||
{maskSrc && (
|
||||
@@ -532,11 +520,11 @@ const ExhibitOverlay = ({
|
||||
className="absolute top-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{
|
||||
// 有描述时:从描述右侧30px开始,到右边界,在这个区域内居中
|
||||
left: description ? "520px" : "0", // 30 + 450 + 30
|
||||
right: "0",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
left: description ? '520px' : '0', // 30 + 450 + 30
|
||||
right: '0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -545,9 +533,9 @@ const ExhibitOverlay = ({
|
||||
style={{
|
||||
maxWidth: containerSize,
|
||||
maxHeight: containerSize,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
cursor: isDragging ? "grabbing" : "grab",
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
@@ -560,16 +548,16 @@ const ExhibitOverlay = ({
|
||||
ref={viewElRef}
|
||||
className="max-w-full max-h-full select-none relative pointer-events-auto"
|
||||
style={{
|
||||
transform: "translate3d(0px, 0px, 0) scale(1)",
|
||||
transformOrigin: "0 0",
|
||||
willChange: "transform",
|
||||
transform: 'translate3d(0px, 0px, 0) scale(1)',
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
// iOS 微信浏览器优化
|
||||
WebkitTransform: "translate3d(0px, 0px, 0) scale(1)",
|
||||
WebkitBackfaceVisibility: "hidden",
|
||||
backfaceVisibility: "hidden",
|
||||
WebkitTransform: 'translate3d(0px, 0px, 0) scale(1)',
|
||||
WebkitBackfaceVisibility: 'hidden',
|
||||
backfaceVisibility: 'hidden',
|
||||
// 强制硬件加速
|
||||
WebkitPerspective: "1000",
|
||||
perspective: "1000",
|
||||
WebkitPerspective: '1000',
|
||||
perspective: '1000',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
@@ -586,10 +574,10 @@ const ExhibitOverlay = ({
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
)
|
||||
|
||||
// 直接使用 Portal 渲染到 document.body
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
|
||||
export default ExhibitOverlay;
|
||||
export default ExhibitOverlay
|
||||
@@ -1,4 +1,4 @@
|
||||
export { default as ExhibitOverlay } from "./ExhibitOverlay";
|
||||
export type { ExhibitOverlayProps } from "./ExhibitOverlay";
|
||||
export { default as ExhibitDescriptionNarration } from "./ExhibitDescriptionNarration";
|
||||
export type { ExhibitDescriptionNarrationProps } from "./ExhibitDescriptionNarration";
|
||||
export { default as ExhibitOverlay } from './ExhibitOverlay'
|
||||
export type { ExhibitOverlayProps } from './ExhibitOverlay'
|
||||
export { default as ExhibitDescriptionNarration } from './ExhibitDescriptionNarration'
|
||||
export type { ExhibitDescriptionNarrationProps } from './ExhibitDescriptionNarration'
|
||||
@@ -1,15 +1,9 @@
|
||||
import {
|
||||
ReactNode,
|
||||
useState,
|
||||
Children,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn, getBackgroundImage } from "@/lib/utils";
|
||||
import { Overlay } from "@/components/overlay";
|
||||
import { useVideoContextSafe } from "@/contexts/VideoContext";
|
||||
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||
import { ReactNode, useState, Children, isValidElement, useEffect } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { cn, getBackgroundImage } from '@/lib/utils'
|
||||
import { Overlay } from '@/components/overlay'
|
||||
import { useVideoContextSafe } from '@/contexts/VideoContext'
|
||||
import { useAudioManager } from '@/hooks/useAudioManager'
|
||||
|
||||
/**
|
||||
* 使用示例:
|
||||
@@ -20,29 +14,29 @@ import { useAudioManager } from "@/hooks/useAudioManager";
|
||||
* </GuideSteps>
|
||||
*/
|
||||
interface GuideStepsProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
currentStep?: number;
|
||||
onStepChange?: (step: number) => void;
|
||||
children: ReactNode
|
||||
className?: string
|
||||
visible?: boolean
|
||||
onVisibleChange?: (visible: boolean) => void
|
||||
currentStep?: number
|
||||
onStepChange?: (step: number) => void
|
||||
/** 每个步骤对应的高亮选择器数组,索引对应步骤序号 */
|
||||
highlightSelectors?: (string | string[])[];
|
||||
highlightSelectors?: (string | string[])[]
|
||||
/** 控制是否显示遮罩层(包括背景蒙版和 Overlay 遮罩,默认:true,保持向后兼容) */
|
||||
showMask?: boolean;
|
||||
showMask?: boolean
|
||||
/** 自定义 z-index,用于控制层级(默认:50) */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
audioChannelId?: string
|
||||
}
|
||||
|
||||
interface GuideStepsItemProps {
|
||||
children: ReactNode;
|
||||
speaker?: "guide" | "student";
|
||||
children: ReactNode
|
||||
speaker?: 'guide' | 'student'
|
||||
/** 当前步骤对应的音频地址 */
|
||||
audioSrc?: string;
|
||||
audioSrc?: string
|
||||
/** 进入该步骤时是否自动播放音频(有 audioSrc 且未显式设置时,默认自动播放) */
|
||||
autoPlayOnEnter?: boolean;
|
||||
autoPlayOnEnter?: boolean
|
||||
}
|
||||
|
||||
const GuideSteps = ({
|
||||
@@ -57,125 +51,117 @@ const GuideSteps = ({
|
||||
zIndex = 50,
|
||||
audioChannelId,
|
||||
}: GuideStepsProps) => {
|
||||
const [internalStep, setInternalStep] = useState(0);
|
||||
const [internalVisible, setInternalVisible] = useState(true);
|
||||
const [internalStep, setInternalStep] = useState(0)
|
||||
const [internalVisible, setInternalVisible] = useState(true)
|
||||
|
||||
// 安全地获取视频上下文(如果不在 VideoProvider 中,返回 null)
|
||||
const videoContext = useVideoContextSafe();
|
||||
const videoContext = useVideoContextSafe()
|
||||
|
||||
// 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥
|
||||
const { play, stop } = useAudioManager(
|
||||
audioChannelId
|
||||
? { mode: "channel", channelId: audioChannelId }
|
||||
: { mode: "global" }
|
||||
);
|
||||
audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' }
|
||||
)
|
||||
|
||||
// 收集每个步骤的内容和 speaker 信息
|
||||
const stepsData = Children.toArray(children).map((child) => {
|
||||
if (
|
||||
isValidElement<GuideStepsItemProps>(child) &&
|
||||
child.type === GuideStepsItem
|
||||
) {
|
||||
const hasAudio = !!child.props.audioSrc;
|
||||
if (isValidElement<GuideStepsItemProps>(child) && child.type === GuideStepsItem) {
|
||||
const hasAudio = !!child.props.audioSrc
|
||||
return {
|
||||
content: child.props.children,
|
||||
speaker: child.props.speaker || "guide", // 默认为 "guide" 保持向后兼容
|
||||
speaker: child.props.speaker || 'guide', // 默认为 "guide" 保持向后兼容
|
||||
audioSrc: child.props.audioSrc,
|
||||
// 如果没有显式设置 autoPlayOnEnter,则在有 audioSrc 时默认自动播放
|
||||
autoPlayOnEnter:
|
||||
child.props.autoPlayOnEnter !== undefined
|
||||
? child.props.autoPlayOnEnter
|
||||
: hasAudio,
|
||||
};
|
||||
child.props.autoPlayOnEnter !== undefined ? child.props.autoPlayOnEnter : hasAudio,
|
||||
}
|
||||
}
|
||||
// 如果不是 GuideStepsItem,保持原有行为
|
||||
return {
|
||||
content: child,
|
||||
speaker: "guide" as const,
|
||||
speaker: 'guide' as const,
|
||||
audioSrc: undefined,
|
||||
autoPlayOnEnter: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
const steps = stepsData.map((step) => step.content);
|
||||
const totalSteps = steps.length;
|
||||
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;
|
||||
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));
|
||||
(!videoContext || (!videoContext.isVideoPlaying && videoContext.isContentVisible))
|
||||
|
||||
// 获取当前步骤的 speaker
|
||||
const currentSpeaker = stepsData[currentStep]?.speaker || "guide";
|
||||
const currentStepData = stepsData[currentStep];
|
||||
const currentAudioSrc = currentStepData?.audioSrc;
|
||||
const currentAutoPlayOnEnter = currentStepData?.autoPlayOnEnter ?? false;
|
||||
const currentSpeaker = stepsData[currentStep]?.speaker || 'guide'
|
||||
const currentStepData = stepsData[currentStep]
|
||||
const currentAudioSrc = currentStepData?.audioSrc
|
||||
const currentAutoPlayOnEnter = currentStepData?.autoPlayOnEnter ?? false
|
||||
|
||||
// 步骤变化时,按需自动播放音频(依赖稳定值,避免循环触发)
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (!isVisible) return
|
||||
if (currentAutoPlayOnEnter && currentAudioSrc) {
|
||||
play(currentAudioSrc);
|
||||
play(currentAudioSrc)
|
||||
}
|
||||
}, [isVisible, currentStep, currentAutoPlayOnEnter, currentAudioSrc, play]);
|
||||
}, [isVisible, currentStep, currentAutoPlayOnEnter, currentAudioSrc, play])
|
||||
|
||||
const updateStep = (newStep: number) => {
|
||||
if (!isControlled) {
|
||||
setInternalStep(newStep);
|
||||
setInternalStep(newStep)
|
||||
}
|
||||
onStepChange?.(newStep);
|
||||
};
|
||||
onStepChange?.(newStep)
|
||||
}
|
||||
|
||||
const updateVisible = (newVisible: boolean) => {
|
||||
if (visible === undefined) {
|
||||
setInternalVisible(newVisible);
|
||||
setInternalVisible(newVisible)
|
||||
}
|
||||
// 隐藏引导时,停止当前 channel 的音频
|
||||
if (!newVisible) {
|
||||
stop();
|
||||
stop()
|
||||
}
|
||||
onVisibleChange?.(newVisible);
|
||||
};
|
||||
onVisibleChange?.(newVisible)
|
||||
}
|
||||
|
||||
// 受控 visible 场景:当外部把 visible 置为 false 时,也需要停止音频
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
stop();
|
||||
stop()
|
||||
}
|
||||
}, [isVisible, stop]);
|
||||
}, [isVisible, stop])
|
||||
|
||||
const handleNext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡
|
||||
e.stopPropagation() // 阻止事件冒泡
|
||||
if (currentStep < totalSteps - 1) {
|
||||
updateStep(currentStep + 1);
|
||||
updateStep(currentStep + 1)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleTextClick = () => {
|
||||
if (currentStep < totalSteps - 1) {
|
||||
updateStep(currentStep + 1);
|
||||
updateStep(currentStep + 1)
|
||||
} else {
|
||||
// 最后一步,隐藏组件
|
||||
updateVisible(false);
|
||||
updateVisible(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 获取当前步骤的高亮选择器
|
||||
const currentHighlightSelectors =
|
||||
highlightSelectors && highlightSelectors[currentStep]
|
||||
? highlightSelectors[currentStep]
|
||||
: undefined;
|
||||
: undefined
|
||||
|
||||
// 根据 speaker 选择背景图片
|
||||
const currentBackgroundPath =
|
||||
currentSpeaker === "student" ? "components/引导板2" : "components/引导板";
|
||||
currentSpeaker === 'student' ? 'components/引导板2' : 'components/引导板'
|
||||
|
||||
// GuideSteps 内容
|
||||
const guideStepsContent = (
|
||||
@@ -183,15 +169,12 @@ const GuideSteps = ({
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
key="guide-steps-container"
|
||||
className={cn(
|
||||
"fixed inset-0 flex items-center justify-center",
|
||||
className
|
||||
)}
|
||||
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" }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
onClick={handleTextClick}
|
||||
>
|
||||
{/* 引导板容器和背景 - 作为整体切换,确保 padding 和背景同步 */}
|
||||
@@ -201,13 +184,13 @@ const GuideSteps = ({
|
||||
<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"
|
||||
'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" }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
{/* 背景层 */}
|
||||
<div
|
||||
@@ -226,7 +209,7 @@ const GuideSteps = ({
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
<div className="text-[#4e402e] text-4xl font-bold leading-15">
|
||||
{steps[currentStep]}
|
||||
@@ -241,9 +224,7 @@ const GuideSteps = ({
|
||||
>
|
||||
继续
|
||||
</span>
|
||||
<span className="text-[#8B7355] text-2xl animate-pulse">
|
||||
→
|
||||
</span>
|
||||
<span className="text-[#8B7355] text-2xl animate-pulse">→</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
@@ -254,7 +235,7 @@ const GuideSteps = ({
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
)
|
||||
|
||||
// 始终使用 Overlay 包裹,showMask 由 Overlay 处理
|
||||
return (
|
||||
@@ -266,11 +247,11 @@ const GuideSteps = ({
|
||||
>
|
||||
{guideStepsContent}
|
||||
</Overlay>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const GuideStepsItem = ({ children }: GuideStepsItemProps) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export { GuideSteps, GuideStepsItem };
|
||||
export { GuideSteps, GuideStepsItem }
|
||||
@@ -1,35 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Image } from "@/components";
|
||||
import { cn, getStaticImage } from "@/lib/utils";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||
import { useVideoContextSafe } from "@/contexts/VideoContext";
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Image } from '@/components'
|
||||
import { cn, getStaticImage } from '@/lib/utils'
|
||||
import { useScale } from '@/contexts/ScaleContext'
|
||||
import { useAudioManager } from '@/hooks/useAudioManager'
|
||||
import { useVideoContextSafe } from '@/contexts/VideoContext'
|
||||
|
||||
export interface StudyGuideModalProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 标题文案,默认:温馨提示 */
|
||||
title?: string;
|
||||
title?: string
|
||||
/** 主体内容(支持换行、富文本) */
|
||||
children?: ReactNode;
|
||||
children?: ReactNode
|
||||
/** 点击继续按钮 */
|
||||
onContinue?: () => void;
|
||||
onContinue?: () => void
|
||||
/** 点击遮罩是否关闭,默认 true */
|
||||
maskClosable?: boolean;
|
||||
maskClosable?: boolean
|
||||
/** 自定义 zIndex,默认 120 */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 对齐方式,默认 center */
|
||||
ailgn?: "center" | "left" | "right";
|
||||
ailgn?: 'center' | 'left' | 'right'
|
||||
// 垂直对齐方式,默认 center
|
||||
verticalAlign?: "center" | "top" | "bottom";
|
||||
verticalAlign?: 'center' | 'top' | 'bottom'
|
||||
/** 引导语音地址(可选) */
|
||||
audioSrc?: string;
|
||||
audioSrc?: string
|
||||
/** 弹窗出现时是否自动播放引导语音(默认 true) */
|
||||
autoPlayAudio?: boolean;
|
||||
autoPlayAudio?: boolean
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
audioChannelId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,77 +48,76 @@ export interface StudyGuideModalProps {
|
||||
*/
|
||||
export const StudyGuideModal = ({
|
||||
visible = false,
|
||||
title: _title = "温馨提示",
|
||||
title: _title = '温馨提示',
|
||||
children,
|
||||
onContinue,
|
||||
maskClosable: _maskClosable = true,
|
||||
zIndex = 120,
|
||||
ailgn = "center",
|
||||
verticalAlign = "top",
|
||||
ailgn = 'center',
|
||||
verticalAlign = 'top',
|
||||
audioSrc,
|
||||
autoPlayAudio = true,
|
||||
audioChannelId,
|
||||
}: StudyGuideModalProps) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const videoContext = useVideoContextSafe();
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
|
||||
const videoContext = useVideoContextSafe()
|
||||
const { currentSrc, play, stop } = useAudioManager(
|
||||
audioChannelId
|
||||
? {
|
||||
mode: "channel",
|
||||
mode: 'channel',
|
||||
channelId: audioChannelId,
|
||||
}
|
||||
: { mode: "global" }
|
||||
);
|
||||
: { mode: 'global' }
|
||||
)
|
||||
|
||||
// SSR 兼容:只在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// 当视频正在播放或内容未展示时,不应播放引导语音
|
||||
const canPlayAudio =
|
||||
!videoContext ||
|
||||
(!videoContext.isVideoPlaying && videoContext.isContentVisible);
|
||||
!videoContext || (!videoContext.isVideoPlaying && videoContext.isContentVisible)
|
||||
|
||||
// 弹窗显隐联动音频播放/停止
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!mounted) return
|
||||
|
||||
if (visible && autoPlayAudio && audioSrc && canPlayAudio) {
|
||||
play(audioSrc);
|
||||
play(audioSrc)
|
||||
}
|
||||
// 注意:这里不依赖 currentSrc,避免 play() 更新状态后重复触发自动播放
|
||||
}, [visible, mounted, autoPlayAudio, audioSrc, play, canPlayAudio]);
|
||||
}, [visible, mounted, autoPlayAudio, audioSrc, play, canPlayAudio])
|
||||
|
||||
// 关闭弹窗时停止(仅当当前播放的是该弹窗的 audioSrc)
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!mounted) return
|
||||
if (!visible && audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
stop()
|
||||
}
|
||||
}, [visible, mounted, audioSrc, currentSrc, stop]);
|
||||
}, [visible, mounted, audioSrc, currentSrc, stop])
|
||||
|
||||
// 当视频开始播放时,如果当前正在播放该弹窗音频,则停止,避免视频音频与弹窗音频重叠
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!mounted) return
|
||||
if (!canPlayAudio && audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
stop()
|
||||
}
|
||||
}, [mounted, canPlayAudio, audioSrc, currentSrc, stop]);
|
||||
}, [mounted, canPlayAudio, audioSrc, currentSrc, stop])
|
||||
|
||||
if (!visible || !mounted) return null;
|
||||
if (!visible || !mounted) return null
|
||||
|
||||
const handleMaskClick = () => {
|
||||
// if (maskClosable) {
|
||||
// onContinue?.();
|
||||
// }
|
||||
};
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center"
|
||||
style={{ zIndex, pointerEvents: "auto" }}
|
||||
style={{ zIndex, pointerEvents: 'auto' }}
|
||||
>
|
||||
{/* 背景遮罩 */}
|
||||
<div className="absolute inset-0 bg-black/60" onClick={handleMaskClick} />
|
||||
@@ -127,7 +126,7 @@ export const StudyGuideModal = ({
|
||||
<div className="relative w-[1700px] h-[760px] pointer-events-none pt-65">
|
||||
{/* 引导框背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/guide/引导框")}
|
||||
src={getStaticImage('components/guide/引导框')}
|
||||
alt="学习引导背景"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
@@ -135,9 +134,9 @@ export const StudyGuideModal = ({
|
||||
{/* 文本内容 */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 w-[1480px] flex items-center justify-center mx-auto text-white pointer-events-auto",
|
||||
ailgn === "left" && "justify-start",
|
||||
ailgn === "right" && "justify-end"
|
||||
'relative z-10 w-[1480px] flex items-center justify-center mx-auto text-white pointer-events-auto',
|
||||
ailgn === 'left' && 'justify-start',
|
||||
ailgn === 'right' && 'justify-end'
|
||||
)}
|
||||
>
|
||||
{/* {title && (
|
||||
@@ -148,11 +147,11 @@ export const StudyGuideModal = ({
|
||||
{children && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-4xl leading-relaxed whitespace-pre-line overflow-y-auto custom-scrollbar max-h-[300px]",
|
||||
"[text-shadow:_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4)]"
|
||||
'text-4xl leading-relaxed whitespace-pre-line overflow-y-auto custom-scrollbar max-h-[300px]',
|
||||
'[text-shadow:_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4)]'
|
||||
)}
|
||||
style={{
|
||||
marginTop: verticalAlign === "center" ? "85px" : "",
|
||||
marginTop: verticalAlign === 'center' ? '85px' : '',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -163,7 +162,7 @@ export const StudyGuideModal = ({
|
||||
{/* 继续按钮 */}
|
||||
<div className="absolute right-15 bottom-8 z-20 pointer-events-auto">
|
||||
<Image
|
||||
src={getStaticImage("components/guide/继续")}
|
||||
src={getStaticImage('components/guide/继续')}
|
||||
alt="继续"
|
||||
button
|
||||
onClick={onContinue}
|
||||
@@ -171,30 +170,30 @@ export const StudyGuideModal = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
|
||||
// 当 zIndex >= 10000 时,使用 Portal 渲染到 document.body,并应用缩放
|
||||
if (zIndex >= 10000) {
|
||||
const scaledContent = (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
pointerEvents: "auto",
|
||||
transformOrigin: 'top left',
|
||||
pointerEvents: 'auto',
|
||||
zIndex,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
return createPortal(scaledContent, document.body);
|
||||
)
|
||||
return createPortal(scaledContent, document.body)
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
return content
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
export { GuideSteps, GuideStepsItem } from "./GuideSteps";
|
||||
export { StudyGuideModal } from "./StudyGuideModal";
|
||||
export { GuideSteps, GuideStepsItem } from './GuideSteps'
|
||||
export { StudyGuideModal } from './StudyGuideModal'
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ImageProps } from "./image.types";
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ImageProps } from './image.types'
|
||||
|
||||
export function Image({
|
||||
src,
|
||||
alt = "",
|
||||
alt = '',
|
||||
className,
|
||||
width,
|
||||
height,
|
||||
objectFit = "cover",
|
||||
objectPosition = "center",
|
||||
loading = "lazy",
|
||||
objectFit = 'cover',
|
||||
objectPosition = 'center',
|
||||
loading = 'lazy',
|
||||
placeholder,
|
||||
fallback,
|
||||
onLoad,
|
||||
@@ -21,81 +21,77 @@ export function Image({
|
||||
style,
|
||||
...restProps
|
||||
}: ImageProps) {
|
||||
const [imageSrc, setImageSrc] = useState(src);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const [imageSrc, setImageSrc] = useState(src)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
|
||||
// 处理图片加载
|
||||
const handleLoad = () => {
|
||||
setIsLoading(false);
|
||||
setIsLoaded(true);
|
||||
setHasError(false);
|
||||
onLoad?.();
|
||||
};
|
||||
setIsLoading(false)
|
||||
setIsLoaded(true)
|
||||
setHasError(false)
|
||||
onLoad?.()
|
||||
}
|
||||
|
||||
// 处理图片错误
|
||||
const handleError = () => {
|
||||
setIsLoading(false);
|
||||
setHasError(true);
|
||||
setIsLoading(false)
|
||||
setHasError(true)
|
||||
if (fallback && imageSrc !== fallback) {
|
||||
setImageSrc(fallback);
|
||||
setImageSrc(fallback)
|
||||
}
|
||||
onError?.();
|
||||
};
|
||||
onError?.()
|
||||
}
|
||||
|
||||
// 重置状态当src改变时
|
||||
useEffect(() => {
|
||||
if (src !== imageSrc) {
|
||||
setImageSrc(src);
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
setIsLoaded(false);
|
||||
setImageSrc(src)
|
||||
setIsLoading(true)
|
||||
setHasError(false)
|
||||
setIsLoaded(false)
|
||||
}
|
||||
}, [src, imageSrc]);
|
||||
}, [src, imageSrc])
|
||||
|
||||
// 懒加载处理
|
||||
useEffect(() => {
|
||||
if (
|
||||
loading === "lazy" &&
|
||||
typeof IntersectionObserver !== "undefined" &&
|
||||
imgRef.current
|
||||
) {
|
||||
if (loading === 'lazy' && typeof IntersectionObserver !== 'undefined' && imgRef.current) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setImageSrc(src);
|
||||
observer.disconnect();
|
||||
setImageSrc(src)
|
||||
observer.disconnect()
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
)
|
||||
|
||||
observer.observe(imgRef.current);
|
||||
return () => observer.disconnect();
|
||||
observer.observe(imgRef.current)
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
}, [loading, src]);
|
||||
}, [loading, src])
|
||||
|
||||
const imageStyle: React.CSSProperties = {
|
||||
width,
|
||||
height,
|
||||
objectFit,
|
||||
objectPosition,
|
||||
filter: blur ? "blur(4px)" : "none",
|
||||
transition: "filter 0.3s ease",
|
||||
filter: blur ? 'blur(4px)' : 'none',
|
||||
transition: 'filter 0.3s ease',
|
||||
...style,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
width === "100%" ? "block" : "inline-block",
|
||||
'relative',
|
||||
width === '100%' ? 'block' : 'inline-block',
|
||||
button &&
|
||||
"cursor-pointer transition-all duration-300 hover:scale-105 hover:brightness-110 hover:contrast-110 active:scale-95",
|
||||
'cursor-pointer transition-all duration-300 hover:scale-105 hover:brightness-110 hover:contrast-110 active:scale-95',
|
||||
className
|
||||
)}
|
||||
style={{ width, height }}
|
||||
@@ -107,8 +103,8 @@ export function Image({
|
||||
className="w-full h-full bg-gray-200 flex items-center justify-center"
|
||||
style={{
|
||||
backgroundImage: `url(${placeholder})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<div className="animate-pulse bg-gray-300 rounded w-full h-full" />
|
||||
@@ -155,11 +151,11 @@ export function Image({
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
className={cn(
|
||||
"transition-opacity duration-300",
|
||||
isLoaded ? "opacity-100" : "opacity-0",
|
||||
"w-full h-full"
|
||||
'transition-opacity duration-300',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||
'w-full h-full'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,36 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import type { HTMLAttributes } from 'react'
|
||||
|
||||
export interface ImageProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 图片地址 */
|
||||
src: string;
|
||||
src: string
|
||||
/** 图片替代文本 */
|
||||
alt?: string;
|
||||
alt?: string
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 图片宽度 */
|
||||
width?: number | string;
|
||||
width?: number | string
|
||||
/** 图片高度 */
|
||||
height?: number | string;
|
||||
height?: number | string
|
||||
/** 图片填充方式 */
|
||||
objectFit?: "cover" | "contain" | "fill" | "none" | "scale-down";
|
||||
objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
|
||||
/** 图片位置 */
|
||||
objectPosition?: string;
|
||||
objectPosition?: string
|
||||
/** 加载方式 */
|
||||
loading?: "lazy" | "eager";
|
||||
loading?: 'lazy' | 'eager'
|
||||
/** 占位图 */
|
||||
placeholder?: string;
|
||||
placeholder?: string
|
||||
/** 加载失败时的回退图片 */
|
||||
fallback?: string;
|
||||
fallback?: string
|
||||
/** 图片加载完成回调 */
|
||||
onLoad?: () => void;
|
||||
onLoad?: () => void
|
||||
/** 图片加载失败回调 */
|
||||
onError?: () => void;
|
||||
onError?: () => void
|
||||
/** 是否可拖拽 */
|
||||
draggable?: boolean;
|
||||
draggable?: boolean
|
||||
/** 模糊效果 */
|
||||
blur?: boolean;
|
||||
blur?: boolean
|
||||
/** 图片质量(仅用于提示) */
|
||||
quality?: number;
|
||||
quality?: number
|
||||
/** 是否启用按钮样式(hover 和 active 效果) */
|
||||
button?: boolean;
|
||||
button?: boolean
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./image";
|
||||
export * from "./image.types";
|
||||
export * from './image'
|
||||
export * from './image.types'
|
||||
+32
-37
@@ -1,34 +1,34 @@
|
||||
// Video component
|
||||
export { default as Video } from "./video";
|
||||
export type { VideoProps, VideoRef, VideoAnchor, AnchorContext } from "./video";
|
||||
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";
|
||||
export { Button, ImageButton } from './button'
|
||||
export type { BaseButtonProps, ImageButtonProps } from './button'
|
||||
|
||||
// Image component
|
||||
export { Image } from "./image";
|
||||
export type { ImageProps } from "./image";
|
||||
export { Image } from './image'
|
||||
export type { ImageProps } from './image'
|
||||
|
||||
// Title
|
||||
export { Title } from "./title";
|
||||
export { Title } from './title'
|
||||
|
||||
// Guide components
|
||||
export { GuideSteps, GuideStepsItem, StudyGuideModal } from "./guide";
|
||||
export { GuideSteps, GuideStepsItem, StudyGuideModal } from './guide'
|
||||
|
||||
// Exhibit overlay
|
||||
export { ExhibitOverlay } from "./exhibit";
|
||||
export type { ExhibitOverlayProps } from "./exhibit";
|
||||
export { ExhibitOverlay } from './exhibit'
|
||||
export type { ExhibitOverlayProps } from './exhibit'
|
||||
|
||||
// Overlay component
|
||||
export { Overlay } from "./overlay";
|
||||
export type { OverlayProps } from "./overlay";
|
||||
export { Overlay } from './overlay'
|
||||
export type { OverlayProps } from './overlay'
|
||||
|
||||
export { EndingOverlay } from "./overlay";
|
||||
export type { EndingOverlayProps } from "./overlay";
|
||||
export { EndingOverlay } from './overlay'
|
||||
export type { EndingOverlayProps } from './overlay'
|
||||
|
||||
// Spotlight component
|
||||
export { Spotlight } from "./spotlight";
|
||||
export { Spotlight } from './spotlight'
|
||||
|
||||
// Quiz panel components
|
||||
export {
|
||||
@@ -38,7 +38,7 @@ export {
|
||||
DragQuizPanel,
|
||||
MatchQuizPanel,
|
||||
SelectFillQuizPanel,
|
||||
} from "./layout/quiz";
|
||||
} from './layout/quiz'
|
||||
export type {
|
||||
QuizPanelProps,
|
||||
SingleChoiceQuizPanelProps,
|
||||
@@ -46,13 +46,13 @@ export type {
|
||||
DragQuizPanelProps,
|
||||
MatchQuizPanelProps,
|
||||
SelectFillQuizPanelProps,
|
||||
} from "./layout/quiz";
|
||||
} from './layout/quiz'
|
||||
|
||||
//Cursor
|
||||
export { default as Cursor } from "@/components/cursor";
|
||||
export { default as Cursor } from '@/components/cursor'
|
||||
|
||||
// Quiz components
|
||||
export { SingleChoice, MultipleChoice, DragFill } from "./quiz";
|
||||
export { SingleChoice, MultipleChoice, DragFill } from './quiz'
|
||||
export type {
|
||||
SingleChoiceProps,
|
||||
SingleChoiceOption,
|
||||
@@ -62,38 +62,33 @@ export type {
|
||||
DragFillOption,
|
||||
DragFillBlank,
|
||||
DragFillResult,
|
||||
} from "./quiz";
|
||||
} from './quiz'
|
||||
|
||||
// Narration component
|
||||
export { Narration } from "./narration";
|
||||
export type { NarrationProps } from "./narration";
|
||||
export { Narration } from './narration'
|
||||
export type { NarrationProps } from './narration'
|
||||
|
||||
// Clue components
|
||||
export { ClueHintModal, ClueBookModal } from "./clue";
|
||||
export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from "./clue";
|
||||
export { ClueHintModal, ClueBookModal } from './clue'
|
||||
export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from './clue'
|
||||
|
||||
// Animation components
|
||||
export { flyToAnimation } from "./animation";
|
||||
export type { FlyToAnimationOptions, FlyToPosition } from "./animation";
|
||||
export { flyToAnimation } from './animation'
|
||||
export type { FlyToAnimationOptions, FlyToPosition } from './animation'
|
||||
|
||||
// Select component
|
||||
export { Select } from "./select";
|
||||
export type { SelectProps, SelectOption } from "./select";
|
||||
export { Select } from './select'
|
||||
export type { SelectProps, SelectOption } from './select'
|
||||
|
||||
// Letter components
|
||||
export {
|
||||
LetterToChenyun,
|
||||
LetterToSunzhongshan,
|
||||
LetterToHuangyanpei,
|
||||
LetterToLuxun,
|
||||
} from "./letter";
|
||||
export { LetterToChenyun, LetterToSunzhongshan, LetterToHuangyanpei, LetterToLuxun } from './letter'
|
||||
export type {
|
||||
LetterToChenyunProps,
|
||||
LetterToSunzhongshanProps,
|
||||
LetterToHuangyanpeiProps,
|
||||
LetterToLuxunProps,
|
||||
} from "./letter";
|
||||
} from './letter'
|
||||
|
||||
// Puzzle components
|
||||
export { PuzzleSystem, PuzzlePieceDisplay } from "./puzzle";
|
||||
export type { PuzzleSystemProps, PuzzlePieceDisplayProps } from "./puzzle";
|
||||
export { PuzzleSystem, PuzzlePieceDisplay } from './puzzle'
|
||||
export type { PuzzleSystemProps, PuzzlePieceDisplayProps } from './puzzle'
|
||||
@@ -1,21 +1,21 @@
|
||||
import { Outlet, useMatches, useLocation } from "react-router-dom";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { getPublicPath, getStaticImage } from "@/lib/utils";
|
||||
import type { VideoRouteHandle } from "@/router/types";
|
||||
import Video, { type VideoRef } from "@/components/video";
|
||||
import { VideoProvider } from "@/contexts/VideoContext";
|
||||
import { PuzzleBoxProvider, usePuzzleBox } from "@/contexts/PuzzleBoxContext";
|
||||
import { PuzzleAnimationProvider } from "@/contexts/PuzzleAnimationContext";
|
||||
import { Image, PuzzleSystem, flyToAnimation } from "@/components";
|
||||
import { usePuzzleStore } from "@/store/puzzleStore";
|
||||
import PuzzlePiecePortal from "@/components/puzzle/PuzzlePiecePortal";
|
||||
import { Outlet, useMatches, useLocation } from 'react-router-dom'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import type { VideoRouteHandle } from '@/router/types'
|
||||
import Video, { type VideoRef } from '@/components/video'
|
||||
import { VideoProvider } from '@/contexts/VideoContext'
|
||||
import { PuzzleBoxProvider, usePuzzleBox } from '@/contexts/PuzzleBoxContext'
|
||||
import { PuzzleAnimationProvider } from '@/contexts/PuzzleAnimationContext'
|
||||
import { Image, PuzzleSystem, flyToAnimation } from '@/components'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
import PuzzlePiecePortal from '@/components/puzzle/PuzzlePiecePortal'
|
||||
|
||||
/**
|
||||
* 支持拼图盒功能的故居列表
|
||||
* 后续如需为其他故居添加拼图盒,只需在此数组中添加对应的故居名称即可
|
||||
*/
|
||||
const RESIDENCES_WITH_PUZZLE = ["chenyun", "huangyanpei","sunzhongshan","luxun"] as const;
|
||||
const RESIDENCES_WITH_PUZZLE = ['chenyun', 'huangyanpei', 'sunzhongshan', 'luxun'] as const
|
||||
|
||||
/**
|
||||
* 故居布局组件
|
||||
@@ -24,41 +24,41 @@ const RESIDENCES_WITH_PUZZLE = ["chenyun", "huangyanpei","sunzhongshan","luxun"]
|
||||
* 渲染子路由内容
|
||||
*/
|
||||
const ResidenceLayoutContent = () => {
|
||||
const matches = useMatches();
|
||||
const location = useLocation();
|
||||
const { puzzleBoxRef } = usePuzzleBox();
|
||||
const { unlockPuzzle, isPuzzleUnlocked } = usePuzzleStore();
|
||||
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
const [isVideoFadingOut, setIsVideoFadingOut] = useState(false);
|
||||
const [isPuzzleSystemVisible, setIsPuzzleSystemVisible] = useState(false);
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
const previousPathRef = useRef<string>("");
|
||||
const matches = useMatches()
|
||||
const location = useLocation()
|
||||
const { puzzleBoxRef } = usePuzzleBox()
|
||||
const { unlockPuzzle, isPuzzleUnlocked } = usePuzzleStore()
|
||||
const [isVideoPlaying, setIsVideoPlaying] = useState(false)
|
||||
const [isContentVisible, setIsContentVisible] = useState(false)
|
||||
const [isVideoFadingOut, setIsVideoFadingOut] = useState(false)
|
||||
const [isPuzzleSystemVisible, setIsPuzzleSystemVisible] = useState(false)
|
||||
const videoRef = useRef<VideoRef>(null)
|
||||
const previousPathRef = useRef<string>('')
|
||||
|
||||
// 碎片动画相关状态
|
||||
const [showPuzzlePiece, setShowPuzzlePiece] = useState(false);
|
||||
const [puzzlePieceId, setPuzzlePieceId] = useState<number | null>(null);
|
||||
const [isAnimationPlaying, setIsAnimationPlaying] = useState(false);
|
||||
const [isFlying, setIsFlying] = useState(false); // 标记是否正在飞行
|
||||
const puzzlePieceRef = useRef<HTMLDivElement>(null);
|
||||
const animationInProgressRef = useRef<boolean>(false); // 防止重复执行动画
|
||||
const [showPuzzlePiece, setShowPuzzlePiece] = useState(false)
|
||||
const [puzzlePieceId, setPuzzlePieceId] = useState<number | null>(null)
|
||||
const [isAnimationPlaying, setIsAnimationPlaying] = useState(false)
|
||||
const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行
|
||||
const puzzlePieceRef = useRef<HTMLDivElement>(null)
|
||||
const animationInProgressRef = useRef<boolean>(false) // 防止重复执行动画
|
||||
|
||||
// 从路径中提取故居名称(starName),如 'chenyun', 'sunzhongshan' 等
|
||||
// 路径格式:/:moduleName/:starName/:page
|
||||
const residence = useMemo(() => {
|
||||
const pathParts = location.pathname.split("/").filter(Boolean);
|
||||
const pathParts = location.pathname.split('/').filter(Boolean)
|
||||
// 路径格式:moduleName/starName/page,所以 starName 是第二个部分
|
||||
if (pathParts.length >= 2) {
|
||||
return pathParts[1];
|
||||
return pathParts[1]
|
||||
}
|
||||
return "";
|
||||
}, [location.pathname]);
|
||||
return ''
|
||||
}, [location.pathname])
|
||||
|
||||
// 判断当前故居是否支持拼图盒功能
|
||||
const hasPuzzleFeature = useMemo(
|
||||
() => RESIDENCES_WITH_PUZZLE.includes(residence as any),
|
||||
[residence]
|
||||
);
|
||||
)
|
||||
|
||||
// 动画配置常量
|
||||
const ANIMATION_CONFIG = {
|
||||
@@ -69,221 +69,207 @@ const ResidenceLayoutContent = () => {
|
||||
MAX_RETRIES: 3, // 最大重试次数
|
||||
SCALE: { from: 1, to: 0.25 }, // 缩放比例(更小的目标尺寸,效果更明显)
|
||||
OPACITY: [1, 1, 0.6, 0] as number[], // 透明度变化(更平滑的淡出)
|
||||
};
|
||||
}
|
||||
|
||||
// 清理动画状态
|
||||
const cleanupAnimation = () => {
|
||||
setIsAnimationPlaying(false);
|
||||
setShowPuzzlePiece(false);
|
||||
setPuzzlePieceId(null);
|
||||
setIsFlying(false);
|
||||
animationInProgressRef.current = false;
|
||||
};
|
||||
setIsAnimationPlaying(false)
|
||||
setShowPuzzlePiece(false)
|
||||
setPuzzlePieceId(null)
|
||||
setIsFlying(false)
|
||||
animationInProgressRef.current = false
|
||||
}
|
||||
|
||||
// 执行飞行动画
|
||||
const executeFlyAnimation = (pieceId: number) => {
|
||||
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
setIsFlying(true);
|
||||
setIsFlying(true)
|
||||
|
||||
// 使用 requestAnimationFrame 确保元素已完全渲染
|
||||
requestAnimationFrame(() => {
|
||||
if (!puzzlePieceRef.current || !residence) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
flyToAnimation({
|
||||
fromRef: puzzlePieceRef,
|
||||
toRef: puzzleBoxRef,
|
||||
targetPosition: "center",
|
||||
targetPosition: 'center',
|
||||
duration: ANIMATION_CONFIG.FLY_DURATION,
|
||||
scale: ANIMATION_CONFIG.SCALE,
|
||||
opacity: ANIMATION_CONFIG.OPACITY,
|
||||
ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速
|
||||
onComplete: () => {
|
||||
if (residence) {
|
||||
unlockPuzzle(residence, pieceId);
|
||||
unlockPuzzle(residence, pieceId)
|
||||
}
|
||||
cleanupAnimation();
|
||||
cleanupAnimation()
|
||||
},
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
return true;
|
||||
};
|
||||
return true
|
||||
}
|
||||
|
||||
// 等待元素准备就绪并执行动画
|
||||
const waitForElementsAndAnimate = async (
|
||||
pieceId: number,
|
||||
retryCount = 0
|
||||
): Promise<void> => {
|
||||
const waitForElementsAndAnimate = async (pieceId: number, retryCount = 0): Promise<void> => {
|
||||
if (!residence) {
|
||||
cleanupAnimation();
|
||||
return;
|
||||
cleanupAnimation()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查元素是否已准备好
|
||||
if (puzzleBoxRef.current && puzzlePieceRef.current) {
|
||||
const success = executeFlyAnimation(pieceId);
|
||||
if (success) return;
|
||||
const success = executeFlyAnimation(pieceId)
|
||||
if (success) return
|
||||
}
|
||||
|
||||
// 如果元素未准备好且未超过最大重试次数,则重试
|
||||
if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY)
|
||||
);
|
||||
return waitForElementsAndAnimate(pieceId, retryCount + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY))
|
||||
return waitForElementsAndAnimate(pieceId, retryCount + 1)
|
||||
}
|
||||
|
||||
// 超过最大重试次数,直接解锁并清理
|
||||
console.warn("Puzzle animation: 元素未准备好,直接解锁");
|
||||
unlockPuzzle(residence, pieceId);
|
||||
cleanupAnimation();
|
||||
};
|
||||
console.warn('Puzzle animation: 元素未准备好,直接解锁')
|
||||
unlockPuzzle(residence, pieceId)
|
||||
cleanupAnimation()
|
||||
}
|
||||
|
||||
// 处理碎片动画触发(仅支持拼图功能的故居)
|
||||
const handleTriggerPuzzleAnimation = (pieceId: number) => {
|
||||
if (!residence || !hasPuzzleFeature) return;
|
||||
if (!residence || !hasPuzzleFeature) return
|
||||
|
||||
// 防止重复执行动画
|
||||
if (animationInProgressRef.current) return;
|
||||
if (animationInProgressRef.current) return
|
||||
|
||||
// 检查碎片是否已解锁
|
||||
if (isPuzzleUnlocked(residence, pieceId)) return;
|
||||
if (isPuzzleUnlocked(residence, pieceId)) return
|
||||
|
||||
// 标记动画正在进行
|
||||
animationInProgressRef.current = true;
|
||||
animationInProgressRef.current = true
|
||||
|
||||
// 开始动画流程:禁用所有操作
|
||||
setIsAnimationPlaying(true);
|
||||
setPuzzlePieceId(pieceId);
|
||||
setShowPuzzlePiece(true);
|
||||
setIsAnimationPlaying(true)
|
||||
setPuzzlePieceId(pieceId)
|
||||
setShowPuzzlePiece(true)
|
||||
|
||||
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(async () => {
|
||||
// 等待初始出现动画(旋转+放大)完成
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION)
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION))
|
||||
|
||||
// 等待发光动画显示一段时间,让用户看到碎片
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION)
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION))
|
||||
|
||||
// 等待元素准备就绪并执行飞行动画
|
||||
await waitForElementsAndAnimate(pieceId);
|
||||
});
|
||||
});
|
||||
};
|
||||
await waitForElementsAndAnimate(pieceId)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前路由的视频路径
|
||||
const videoSrc = useMemo(() => {
|
||||
const currentMatch = matches[matches.length - 1];
|
||||
return (currentMatch?.handle as VideoRouteHandle | undefined)?.videoSrc;
|
||||
}, [matches]);
|
||||
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 || "";
|
||||
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 match = path.match(/place(\d+)/)
|
||||
return match && match[1] ? parseInt(match[1], 10) : 0
|
||||
}
|
||||
|
||||
const currentPlace = getCurrentPlaceNumber(currentPath);
|
||||
const previousPlace = getCurrentPlaceNumber(previousPath);
|
||||
const currentPlace = getCurrentPlaceNumber(currentPath)
|
||||
const previousPlace = getCurrentPlaceNumber(previousPath)
|
||||
|
||||
// 如果当前场景编号大于前一个场景编号,说明是前进
|
||||
// 如果是首次加载(previousPath为空),也视为前进
|
||||
return !previousPath || currentPlace > previousPlace;
|
||||
}, [location.pathname]);
|
||||
return !previousPath || currentPlace > previousPlace
|
||||
}, [location.pathname])
|
||||
|
||||
// 处理视频播放完成(自然结束)
|
||||
const handleVideoEnded = () => {
|
||||
// 先让内容层开始淡入
|
||||
setIsContentVisible(true);
|
||||
setIsContentVisible(true)
|
||||
// 开始视频层淡出动画
|
||||
setIsVideoFadingOut(true);
|
||||
setIsVideoFadingOut(true)
|
||||
// 然后延迟移除视频层,让视频层淡出动画完成
|
||||
setTimeout(() => {
|
||||
setIsVideoPlaying(false);
|
||||
setIsVideoFadingOut(false);
|
||||
}, 500); // 与 CSS transition 时间一致
|
||||
};
|
||||
setIsVideoPlaying(false)
|
||||
setIsVideoFadingOut(false)
|
||||
}, 500) // 与 CSS transition 时间一致
|
||||
}
|
||||
|
||||
// 处理跳过操作
|
||||
const handleSkip = () => {
|
||||
// 立即暂停视频
|
||||
videoRef.current?.pause();
|
||||
videoRef.current?.pause()
|
||||
// 立即显示内容层
|
||||
setIsContentVisible(true);
|
||||
setIsContentVisible(true)
|
||||
// 立即移除视频层,不等待淡出动画
|
||||
setIsVideoPlaying(false);
|
||||
setIsVideoFadingOut(false);
|
||||
};
|
||||
setIsVideoPlaying(false)
|
||||
setIsVideoFadingOut(false)
|
||||
}
|
||||
|
||||
// 处理视频加载错误
|
||||
const handleVideoError = () => {
|
||||
// 视频加载失败,直接显示内容
|
||||
setIsVideoPlaying(false);
|
||||
setIsContentVisible(true);
|
||||
setIsVideoFadingOut(false);
|
||||
};
|
||||
setIsVideoPlaying(false)
|
||||
setIsContentVisible(true)
|
||||
setIsVideoFadingOut(false)
|
||||
}
|
||||
|
||||
// 路由变化时重置状态
|
||||
useEffect(() => {
|
||||
// 只有在前进导航且有视频时才播放视频
|
||||
if (videoSrc && isForwardNavigation) {
|
||||
// 有视频且是前进,重置状态准备播放
|
||||
setIsVideoPlaying(true);
|
||||
setIsContentVisible(false);
|
||||
setIsVideoFadingOut(false);
|
||||
setIsVideoPlaying(true)
|
||||
setIsContentVisible(false)
|
||||
setIsVideoFadingOut(false)
|
||||
} else {
|
||||
// 没有视频或是后退,直接显示内容
|
||||
setIsVideoPlaying(false);
|
||||
setIsContentVisible(true);
|
||||
setIsVideoFadingOut(false);
|
||||
setIsVideoPlaying(false)
|
||||
setIsContentVisible(true)
|
||||
setIsVideoFadingOut(false)
|
||||
}
|
||||
|
||||
// 更新前一个路径
|
||||
previousPathRef.current = location.pathname;
|
||||
}, [location.pathname, videoSrc, isForwardNavigation]);
|
||||
previousPathRef.current = location.pathname
|
||||
}, [location.pathname, videoSrc, isForwardNavigation])
|
||||
|
||||
// 当视频元素渲染后,重置并确保播放
|
||||
useEffect(() => {
|
||||
if (isVideoPlaying && videoRef.current?.video && videoSrc) {
|
||||
const video = videoRef.current.video;
|
||||
video.currentTime = 0;
|
||||
const video = videoRef.current.video
|
||||
video.currentTime = 0
|
||||
video.play().catch(() => {
|
||||
// 自动播放失败,直接显示内容
|
||||
handleVideoError();
|
||||
});
|
||||
handleVideoError()
|
||||
})
|
||||
}
|
||||
}, [isVideoPlaying, videoSrc]);
|
||||
}, [isVideoPlaying, videoSrc])
|
||||
|
||||
return (
|
||||
<VideoProvider
|
||||
isVideoPlaying={isVideoPlaying}
|
||||
isContentVisible={isContentVisible}
|
||||
>
|
||||
<PuzzleAnimationProvider
|
||||
triggerPuzzleAnimation={handleTriggerPuzzleAnimation}
|
||||
>
|
||||
<VideoProvider isVideoPlaying={isVideoPlaying} isContentVisible={isContentVisible}>
|
||||
<PuzzleAnimationProvider triggerPuzzleAnimation={handleTriggerPuzzleAnimation}>
|
||||
<div className="relative w-full h-full">
|
||||
{/* 内容层 - 始终渲染,根据状态控制显示 */}
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
isContentVisible
|
||||
? "z-50 opacity-100" // 显示时在上层
|
||||
: "z-10 opacity-0 pointer-events-none" // 隐藏时在下层且不可交互
|
||||
? 'z-50 opacity-100' // 显示时在上层
|
||||
: 'z-10 opacity-0 pointer-events-none' // 隐藏时在下层且不可交互
|
||||
} transition-opacity duration-300 ease-in`}
|
||||
>
|
||||
<Outlet />
|
||||
@@ -293,13 +279,13 @@ const ResidenceLayoutContent = () => {
|
||||
ref={puzzleBoxRef}
|
||||
className="absolute left-4 bottom-4 z-[1] cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.stopPropagation()
|
||||
if (residence) {
|
||||
setIsPuzzleSystemVisible(true);
|
||||
setIsPuzzleSystemVisible(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Image src={getStaticImage("components/拼图盒")} button />
|
||||
<Image src={getStaticImage('components/拼图盒')} button />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -309,8 +295,8 @@ const ResidenceLayoutContent = () => {
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
isVideoFadingOut
|
||||
? "z-40 opacity-0 pointer-events-none" // 淡出时移到下层
|
||||
: "z-50 opacity-100" // 播放时在上层
|
||||
? 'z-40 opacity-0 pointer-events-none' // 淡出时移到下层
|
||||
: 'z-50 opacity-100' // 播放时在上层
|
||||
} bg-black transition-opacity duration-500 ease-out`}
|
||||
>
|
||||
<Video
|
||||
@@ -337,24 +323,21 @@ const ResidenceLayoutContent = () => {
|
||||
)}
|
||||
|
||||
{/* 碎片显示 - 用于飞行动画,显示在屏幕正中间(仅支持拼图功能的故居显示) */}
|
||||
{hasPuzzleFeature &&
|
||||
showPuzzlePiece &&
|
||||
puzzlePieceId &&
|
||||
residence && (
|
||||
<PuzzlePiecePortal
|
||||
ref={puzzlePieceRef}
|
||||
residence={residence}
|
||||
pieceId={puzzlePieceId}
|
||||
hidden={isFlying}
|
||||
/>
|
||||
)}
|
||||
{hasPuzzleFeature && showPuzzlePiece && puzzlePieceId && residence && (
|
||||
<PuzzlePiecePortal
|
||||
ref={puzzlePieceRef}
|
||||
residence={residence}
|
||||
pieceId={puzzlePieceId}
|
||||
hidden={isFlying}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 动画遮罩层 - 动画播放时盖住所有元素,防止操作 */}
|
||||
{isAnimationPlaying &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100000] pointer-events-auto"
|
||||
style={{ touchAction: "none" }}
|
||||
style={{ touchAction: 'none' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.preventDefault()}
|
||||
onTouchMove={(e) => e.preventDefault()}
|
||||
@@ -365,15 +348,15 @@ const ResidenceLayoutContent = () => {
|
||||
</div>
|
||||
</PuzzleAnimationProvider>
|
||||
</VideoProvider>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const ResidenceLayout = () => {
|
||||
return (
|
||||
<PuzzleBoxProvider>
|
||||
<ResidenceLayoutContent />
|
||||
</PuzzleBoxProvider>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ResidenceLayout;
|
||||
export default ResidenceLayout
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useScale } from '@/contexts/ScaleContext'
|
||||
|
||||
export interface ScaledPortalProps {
|
||||
/** 是否启用(不启用则直接渲染 children) */
|
||||
enabled?: boolean;
|
||||
enabled?: boolean
|
||||
/** z-index 层级(建议 > ExhibitOverlay 的 9999) */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 子元素 */
|
||||
children: ReactNode;
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,43 +17,37 @@ export interface ScaledPortalProps {
|
||||
* - 使用 Portal 渲染到 document.body,避免被其他 Portal/遮罩层挡住
|
||||
* - 使用 useScale 的 offset/scale 做视觉对齐(与 ExhibitOverlay 一致)
|
||||
*/
|
||||
const ScaledPortal = ({
|
||||
enabled = true,
|
||||
zIndex = 10050,
|
||||
children,
|
||||
}: ScaledPortalProps) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const ScaledPortal = ({ enabled = true, zIndex = 10050, children }: ScaledPortalProps) => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!enabled) return <>{children}</>;
|
||||
if (!mounted) return null;
|
||||
if (!enabled) return <>{children}</>
|
||||
if (!mounted) return null
|
||||
|
||||
const scaled = (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
pointerEvents: "auto",
|
||||
transformOrigin: 'top left',
|
||||
pointerEvents: 'auto',
|
||||
zIndex,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(scaled, document.body);
|
||||
};
|
||||
|
||||
export default ScaledPortal;
|
||||
)
|
||||
|
||||
return createPortal(scaled, document.body)
|
||||
}
|
||||
|
||||
export default ScaledPortal
|
||||
@@ -1,16 +1,16 @@
|
||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
|
||||
export interface ErrorAlertProps {
|
||||
/** 文本 */
|
||||
text: string;
|
||||
text: string
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
onMaskClick?: () => void
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
onClose?: () => void
|
||||
/** 自定义 zIndex */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,18 +24,15 @@ const ErrorAlert = ({
|
||||
onClose,
|
||||
zIndex = 10000,
|
||||
}: ErrorAlertProps) => {
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center" style={{ zIndex }}>
|
||||
{/* 蒙版背景 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${getStaticImage("components/蒙版遮罩")})`,
|
||||
backgroundImage: `url(${getStaticImage('components/蒙版遮罩')})`,
|
||||
}}
|
||||
onClick={onMaskClick || onClose}
|
||||
/>
|
||||
@@ -45,9 +42,7 @@ const ErrorAlert = ({
|
||||
{/* 标题和副标题容器 - 带深色背景条 */}
|
||||
<div className="w-full bg-black/70 px-20 py-20 flex flex-col items-center gap-4">
|
||||
{/* 副标题 */}
|
||||
<div className="text-white text-5xl font-medium tracking-widest">
|
||||
{text}
|
||||
</div>
|
||||
<div className="text-white text-5xl font-medium tracking-widest">{text}</div>
|
||||
</div>
|
||||
|
||||
{/* 继续按钮 */}
|
||||
@@ -55,8 +50,8 @@ const ErrorAlert = ({
|
||||
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/按钮")}
|
||||
${getBackgroundImage('components/继续前进')},
|
||||
${getBackgroundImage('components/按钮')}
|
||||
`,
|
||||
}}
|
||||
onClick={onClose}
|
||||
@@ -64,7 +59,7 @@ const ErrorAlert = ({
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorAlert;
|
||||
export default ErrorAlert
|
||||
@@ -1,27 +1,27 @@
|
||||
import { getStaticImage, getBackgroundImage } from "@/lib/utils";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||
import { getStaticImage, getBackgroundImage } from '@/lib/utils'
|
||||
import type { MouseEvent } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useAudioManager } from '@/hooks/useAudioManager'
|
||||
|
||||
export interface SuccessAlertProps {
|
||||
/** 标题 */
|
||||
title?: string;
|
||||
title?: string
|
||||
/** 内容文本 */
|
||||
content?: string;
|
||||
content?: string
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 成功提示音频地址(可选) */
|
||||
audioSrc?: string;
|
||||
audioSrc?: string
|
||||
/** 弹窗出现时是否自动播放音频(默认 true) */
|
||||
autoPlayAudio?: boolean;
|
||||
autoPlayAudio?: boolean
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
audioChannelId?: string
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
onMaskClick?: () => void
|
||||
/** 继续按钮回调 */
|
||||
onContinue?: () => void;
|
||||
onContinue?: () => void
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +29,7 @@ export interface SuccessAlertProps {
|
||||
* 使用 components/正确版 作为背景
|
||||
*/
|
||||
const SuccessAlert = ({
|
||||
title = "恭喜你, 选择正确!",
|
||||
title = '恭喜你, 选择正确!',
|
||||
content,
|
||||
visible = true,
|
||||
audioSrc,
|
||||
@@ -40,43 +40,41 @@ const SuccessAlert = ({
|
||||
onClose,
|
||||
}: SuccessAlertProps) => {
|
||||
const { currentSrc, play, stop } = useAudioManager(
|
||||
audioChannelId
|
||||
? { mode: "channel", channelId: audioChannelId }
|
||||
: { mode: "global" }
|
||||
);
|
||||
audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' }
|
||||
)
|
||||
|
||||
// 弹窗显示时自动播放音频
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (!autoPlayAudio) return;
|
||||
if (!audioSrc) return;
|
||||
play(audioSrc);
|
||||
}, [visible, autoPlayAudio, audioSrc, play]);
|
||||
if (!visible) return
|
||||
if (!autoPlayAudio) return
|
||||
if (!audioSrc) return
|
||||
play(audioSrc)
|
||||
}, [visible, autoPlayAudio, audioSrc, play])
|
||||
|
||||
// 弹窗关闭时停止音频(仅当当前播放的是该弹窗的 audioSrc)
|
||||
useEffect(() => {
|
||||
if (visible) return;
|
||||
if (visible) return
|
||||
if (audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
stop()
|
||||
}
|
||||
}, [visible, audioSrc, currentSrc, stop]);
|
||||
}, [visible, audioSrc, currentSrc, stop])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
const handleContinue = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// 点击继续时停止音频
|
||||
if (audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
stop()
|
||||
}
|
||||
// 只调用 onContinue,它会处理关闭逻辑
|
||||
if (onContinue) {
|
||||
onContinue();
|
||||
onContinue()
|
||||
} else if (onClose) {
|
||||
onClose();
|
||||
onClose()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
|
||||
@@ -84,7 +82,7 @@ const SuccessAlert = ({
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage("components/蒙版遮罩"),
|
||||
backgroundImage: getBackgroundImage('components/蒙版遮罩'),
|
||||
}}
|
||||
onClick={onMaskClick || onClose}
|
||||
/>
|
||||
@@ -99,15 +97,11 @@ const SuccessAlert = ({
|
||||
{/* 背景图片 - 使用 components/正确版 */}
|
||||
<img
|
||||
className="absolute inset-0 top-0 -z-10"
|
||||
src={getStaticImage("components/正确版")}
|
||||
src={getStaticImage('components/正确版')}
|
||||
alt="正确版"
|
||||
/>
|
||||
{/* 标题 */}
|
||||
{title && (
|
||||
<div className="text-[#ffbe57] text-6xl font-bold text-center">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{title && <div className="text-[#ffbe57] text-6xl font-bold text-center">{title}</div>}
|
||||
|
||||
{/* 内容文本 */}
|
||||
{content && (
|
||||
@@ -123,8 +117,8 @@ const SuccessAlert = ({
|
||||
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/按钮")}
|
||||
${getBackgroundImage('components/继续前进')},
|
||||
${getBackgroundImage('components/按钮')}
|
||||
`,
|
||||
}}
|
||||
onClick={handleContinue}
|
||||
@@ -133,7 +127,7 @@ const SuccessAlert = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default SuccessAlert;
|
||||
export default SuccessAlert
|
||||
@@ -1,31 +1,25 @@
|
||||
import { Image } from "@/components";
|
||||
import { cn, getStaticImage } from "@/lib/utils";
|
||||
import { Image } from '@/components'
|
||||
import { cn, getStaticImage } from '@/lib/utils'
|
||||
|
||||
export interface AlertProps {
|
||||
/** 标题 */
|
||||
title: string;
|
||||
title: string
|
||||
/** 副标题 */
|
||||
subtitle: string;
|
||||
subtitle: string
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
onMaskClick?: () => void
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert 组件 - 提示弹窗
|
||||
* @returns
|
||||
*/
|
||||
const Alert = ({
|
||||
title,
|
||||
subtitle,
|
||||
visible = true,
|
||||
onMaskClick,
|
||||
onClose,
|
||||
}: AlertProps) => {
|
||||
if (!visible) return null;
|
||||
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">
|
||||
@@ -33,7 +27,7 @@ const Alert = ({
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${getStaticImage("components/蒙版遮罩")})`,
|
||||
backgroundImage: `url(${getStaticImage('components/蒙版遮罩')})`,
|
||||
}}
|
||||
onClick={onMaskClick || onClose}
|
||||
/>
|
||||
@@ -45,28 +39,22 @@ const Alert = ({
|
||||
{/* 标题 */}
|
||||
<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)]",
|
||||
'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 className="text-white text-5xl font-medium tracking-widest">{subtitle}</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮:去了解 */}
|
||||
<Image
|
||||
src={getStaticImage("components/去了解")}
|
||||
onClick={onClose}
|
||||
button
|
||||
/>
|
||||
<Image src={getStaticImage('components/去了解')} onClick={onClose} button />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Alert;
|
||||
export default Alert
|
||||
@@ -1,16 +1,16 @@
|
||||
export { default as ResidenceLayout } from "./ResidenceLayout";
|
||||
export { default as ClueOverlay } from "../../pages/chenyun/reflection/clue";
|
||||
export type { ClueOverlayProps } from "../../pages/chenyun/reflection/clue";
|
||||
export { default as ScaledPortal } from "./ScaledPortal";
|
||||
export type { ScaledPortalProps } from "./ScaledPortal";
|
||||
export { default as Alert } from "./alert/title-alert";
|
||||
export type { AlertProps } from "./alert/title-alert";
|
||||
export { default as SuccessAlert } from "./alert/success-alert";
|
||||
export type { SuccessAlertProps } from "./alert/success-alert";
|
||||
export { default as ErrorAlert } from "./alert/error-alert";
|
||||
export type { ErrorAlertProps } from "./alert/error-alert";
|
||||
export { DragQuizPanel } from "./quiz";
|
||||
export type { DragQuizPanelProps } from "./quiz";
|
||||
export { default as ResidenceLayout } from './ResidenceLayout'
|
||||
export { default as ClueOverlay } from '../../pages/chenyun/reflection/clue'
|
||||
export type { ClueOverlayProps } from '../../pages/chenyun/reflection/clue'
|
||||
export { default as ScaledPortal } from './ScaledPortal'
|
||||
export type { ScaledPortalProps } from './ScaledPortal'
|
||||
export { default as Alert } from './alert/title-alert'
|
||||
export type { AlertProps } from './alert/title-alert'
|
||||
export { default as SuccessAlert } from './alert/success-alert'
|
||||
export type { SuccessAlertProps } from './alert/success-alert'
|
||||
export { default as ErrorAlert } from './alert/error-alert'
|
||||
export type { ErrorAlertProps } from './alert/error-alert'
|
||||
export { DragQuizPanel } from './quiz'
|
||||
export type { DragQuizPanelProps } from './quiz'
|
||||
// export type {
|
||||
// ClueData,
|
||||
// ClueType,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { DragFill } from "@/components";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DragQuizPanelProps } from "./types";
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { DragFill } from '@/components'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DragQuizPanelProps } from './types'
|
||||
|
||||
/** 拖拽题面板:使用 DragFill 组件 */
|
||||
const DragQuizPanel = ({
|
||||
@@ -18,25 +18,22 @@ const DragQuizPanel = ({
|
||||
onChange,
|
||||
}: DragQuizPanelProps) => {
|
||||
// 跟踪之前的 visible 状态
|
||||
const prevVisibleRef = useRef(visible);
|
||||
const prevVisibleRef = useRef(visible)
|
||||
|
||||
// 当窗口关闭时,清除拖拽状态
|
||||
useEffect(() => {
|
||||
// 如果从可见变为不可见,清除拖拽状态
|
||||
if (prevVisibleRef.current && !visible && value.length > 0) {
|
||||
onChange([]);
|
||||
onChange([])
|
||||
}
|
||||
prevVisibleRef.current = visible;
|
||||
prevVisibleRef.current = visible
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<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">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1700px] rounded-[32px] bg-black/90 px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
@@ -46,18 +43,18 @@ const DragQuizPanel = ({
|
||||
<div>
|
||||
{questionPrefix && <span>{questionPrefix}</span>}
|
||||
{blanks.map((blank, index) => {
|
||||
const filledResult = value.find((r) => r.blankId === blank.id);
|
||||
const filledValue = filledResult?.optionValue || null;
|
||||
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)]",
|
||||
'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]"
|
||||
? '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 ? (
|
||||
@@ -67,38 +64,28 @@ const DragQuizPanel = ({
|
||||
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)
|
||||
);
|
||||
e.stopPropagation()
|
||||
onChange(value.filter((r) => r.blankId !== blank.id))
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-white/40 text-[24px] font-light">
|
||||
____
|
||||
</span>
|
||||
<span className="text-white/40 text-[24px] font-light">____</span>
|
||||
)}
|
||||
</span>
|
||||
{index < blanks.length - 1 && (
|
||||
<span className="text-white/80 mx-1">
|
||||
{blankSeparators && blankSeparators[index]
|
||||
? blankSeparators[index]
|
||||
: "、"}
|
||||
{blankSeparators && blankSeparators[index] ? blankSeparators[index] : '、'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
})}
|
||||
{questionSuffix && <span>{questionSuffix}</span>}
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-5 text-center text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,12 +104,12 @@ const DragQuizPanel = ({
|
||||
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)]",
|
||||
'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]"
|
||||
? '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 && (
|
||||
@@ -136,13 +123,13 @@ const DragQuizPanel = ({
|
||||
renderBlank={(_blank) => {
|
||||
// 返回一个隐藏元素,实际的空白区域在题干中
|
||||
// 但需要通过 data-blank-id 让 DragFill 能找到它
|
||||
return <div className="hidden" />;
|
||||
return <div className="hidden" />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default DragQuizPanel;
|
||||
export default DragQuizPanel
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MatchQuizPanelProps } from "./types";
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { MatchQuizPanelProps } from './types'
|
||||
|
||||
/**
|
||||
* 连线题面板组件
|
||||
@@ -41,153 +41,141 @@ const MatchQuizPanel = ({
|
||||
onError,
|
||||
disabled = false,
|
||||
}: MatchQuizPanelProps) => {
|
||||
const [matches, setMatches] = useState<Array<{ leftId: string; rightId: string }>>([]);
|
||||
const [selectedLeftId, setSelectedLeftId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string>("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [matches, setMatches] = useState<Array<{ leftId: string; rightId: string }>>([])
|
||||
const [selectedLeftId, setSelectedLeftId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string>('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// 当前错误的连线对,用于做红色高亮+抖动反馈
|
||||
const [errorPairs, setErrorPairs] = useState<
|
||||
Array<{ leftId: string; rightId: string }>
|
||||
>([]);
|
||||
const [errorPairs, setErrorPairs] = useState<Array<{ leftId: string; rightId: string }>>([])
|
||||
|
||||
// 当窗口关闭时,重置状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setMatches([]);
|
||||
setErrorPairs([]);
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
setMatches([])
|
||||
setErrorPairs([])
|
||||
setSelectedLeftId(null)
|
||||
setError('')
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
// 处理左列项点击
|
||||
const handleLeftItemClick = (leftId: string) => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
|
||||
// 如果已经匹配过,先取消匹配
|
||||
const existingMatch = matches.find((m) => m.leftId === leftId);
|
||||
const existingMatch = matches.find((m) => m.leftId === leftId)
|
||||
if (existingMatch) {
|
||||
setMatches(matches.filter((m) => m.leftId !== leftId));
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
return;
|
||||
setMatches(matches.filter((m) => m.leftId !== leftId))
|
||||
setSelectedLeftId(null)
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经选中了另一个左项,取消之前的选中
|
||||
if (selectedLeftId && selectedLeftId !== leftId) {
|
||||
setSelectedLeftId(leftId);
|
||||
setError("");
|
||||
return;
|
||||
setSelectedLeftId(leftId)
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 切换选中状态
|
||||
setSelectedLeftId(selectedLeftId === leftId ? null : leftId);
|
||||
setError("");
|
||||
};
|
||||
setSelectedLeftId(selectedLeftId === leftId ? null : leftId)
|
||||
setError('')
|
||||
}
|
||||
|
||||
// 处理右列项点击
|
||||
const handleRightItemClick = (rightId: string) => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
|
||||
// 如果没有选中左项,直接返回
|
||||
if (!selectedLeftId) return;
|
||||
if (!selectedLeftId) return
|
||||
|
||||
// 如果已经匹配过,先取消匹配
|
||||
const existingMatch = matches.find((m) => m.rightId === rightId);
|
||||
const existingMatch = matches.find((m) => m.rightId === rightId)
|
||||
if (existingMatch) {
|
||||
setMatches(matches.filter((m) => m.rightId !== rightId));
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
return;
|
||||
setMatches(matches.filter((m) => m.rightId !== rightId))
|
||||
setSelectedLeftId(null)
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 创建新的匹配
|
||||
const newMatch = { leftId: selectedLeftId, rightId };
|
||||
const newMatches = [...matches, newMatch];
|
||||
const newMatch = { leftId: selectedLeftId, rightId }
|
||||
const newMatches = [...matches, newMatch]
|
||||
|
||||
// 每次连线后立即校验当前所有连线是否存在错误
|
||||
const wrongMatches =
|
||||
correctMatches?.filter(
|
||||
() => true
|
||||
) && // 保证 correctMatches 存在时才校验
|
||||
correctMatches?.filter(() => true) && // 保证 correctMatches 存在时才校验
|
||||
newMatches.filter(
|
||||
(match) =>
|
||||
!correctMatches?.some(
|
||||
(cm) => cm.leftId === match.leftId && cm.rightId === match.rightId
|
||||
)
|
||||
);
|
||||
!correctMatches?.some((cm) => cm.leftId === match.leftId && cm.rightId === match.rightId)
|
||||
)
|
||||
|
||||
const hasError = !!wrongMatches && wrongMatches.length > 0;
|
||||
const hasError = !!wrongMatches && wrongMatches.length > 0
|
||||
|
||||
if (hasError && wrongMatches) {
|
||||
// 记录错误连线对,用于红色高亮+抖动反馈
|
||||
setErrorPairs(wrongMatches);
|
||||
setMatches(newMatches);
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
setErrorPairs(wrongMatches)
|
||||
setMatches(newMatches)
|
||||
setSelectedLeftId(null)
|
||||
setError('')
|
||||
|
||||
let errorMsg: string;
|
||||
if (typeof errorMessage === "function") {
|
||||
errorMsg = errorMessage(newMatches);
|
||||
let errorMsg: string
|
||||
if (typeof errorMessage === 'function') {
|
||||
errorMsg = errorMessage(newMatches)
|
||||
} else if (errorMessage) {
|
||||
errorMsg = errorMessage;
|
||||
errorMsg = errorMessage
|
||||
} else {
|
||||
errorMsg = "连线不正确,请重新尝试!";
|
||||
errorMsg = '连线不正确,请重新尝试!'
|
||||
}
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
// 短暂展示错误状态(变红+抖动),随后只清空错误连线,保留已答对的连线
|
||||
setTimeout(() => {
|
||||
setMatches((prev) =>
|
||||
prev.filter(
|
||||
(m) =>
|
||||
!wrongMatches.some(
|
||||
(w) => w.leftId === m.leftId && w.rightId === m.rightId
|
||||
)
|
||||
(m) => !wrongMatches.some((w) => w.leftId === m.leftId && w.rightId === m.rightId)
|
||||
)
|
||||
);
|
||||
setErrorPairs([]);
|
||||
}, 600);
|
||||
return;
|
||||
)
|
||||
setErrorPairs([])
|
||||
}, 600)
|
||||
return
|
||||
}
|
||||
|
||||
// 没有错误时,正常记录匹配
|
||||
setMatches(newMatches);
|
||||
setSelectedLeftId(null);
|
||||
setError("");
|
||||
setMatches(newMatches)
|
||||
setSelectedLeftId(null)
|
||||
setError('')
|
||||
|
||||
// 如果没有错误且所有项都已连完,则认为答题成功
|
||||
if (
|
||||
newMatches.length === leftItems.length &&
|
||||
newMatches.length === rightItems.length
|
||||
) {
|
||||
onSubmit?.(newMatches, true);
|
||||
if (newMatches.length === leftItems.length && newMatches.length === rightItems.length) {
|
||||
onSubmit?.(newMatches, true)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 获取左项是否已匹配
|
||||
const isLeftItemMatched = (leftId: string) => {
|
||||
return matches.some((m) => m.leftId === leftId);
|
||||
};
|
||||
return matches.some((m) => m.leftId === leftId)
|
||||
}
|
||||
|
||||
// 获取右项是否已匹配
|
||||
const isRightItemMatched = (rightId: string) => {
|
||||
return matches.some((m) => m.rightId === rightId);
|
||||
};
|
||||
return matches.some((m) => m.rightId === rightId)
|
||||
}
|
||||
|
||||
// 获取左项对应的右项ID
|
||||
const getMatchedRightId = (leftId: string) => {
|
||||
const match = matches.find((m) => m.leftId === leftId);
|
||||
return match?.rightId || null;
|
||||
};
|
||||
const match = matches.find((m) => m.leftId === leftId)
|
||||
return match?.rightId || null
|
||||
}
|
||||
|
||||
// 获取右项对应的左项ID
|
||||
const getMatchedLeftId = (rightId: string) => {
|
||||
const match = matches.find((m) => m.rightId === rightId);
|
||||
return match?.leftId || null;
|
||||
};
|
||||
const match = matches.find((m) => m.rightId === rightId)
|
||||
return match?.leftId || null
|
||||
}
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -215,35 +203,33 @@ const MatchQuizPanel = ({
|
||||
{/* 左列:日期 */}
|
||||
<div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
|
||||
{leftItems.map((item) => {
|
||||
const isMatched = isLeftItemMatched(item.id);
|
||||
const isSelected = selectedLeftId === item.id;
|
||||
const isError = errorPairs.some((p) => p.leftId === item.id);
|
||||
const isMatched = isLeftItemMatched(item.id)
|
||||
const isSelected = selectedLeftId === item.id
|
||||
const isError = errorPairs.some((p) => p.leftId === item.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200",
|
||||
"text-[20px] md:text-[28px] leading-relaxed cursor-pointer",
|
||||
"touch-manipulation", // 优化移动端触摸
|
||||
disabled || isMatched
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
|
||||
'text-[20px] md:text-[28px] leading-relaxed cursor-pointer',
|
||||
'touch-manipulation', // 优化移动端触摸
|
||||
disabled || isMatched ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||
isError
|
||||
? "bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse"
|
||||
? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
|
||||
: isSelected
|
||||
? "bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105"
|
||||
: isMatched
|
||||
? "bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]"
|
||||
: "bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]"
|
||||
? 'bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105'
|
||||
: isMatched
|
||||
? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
|
||||
: 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
|
||||
)}
|
||||
onClick={() => handleLeftItemClick(item.id)}
|
||||
onTouchStart={(e) => {
|
||||
// 移动端触摸优化
|
||||
e.currentTarget.classList.add("scale-105");
|
||||
e.currentTarget.classList.add('scale-105')
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.classList.remove("scale-105");
|
||||
e.currentTarget.classList.remove('scale-105')
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
@@ -258,7 +244,7 @@ const MatchQuizPanel = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -267,18 +253,18 @@ const MatchQuizPanel = ({
|
||||
{/* 绘制连线 */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none overflow-visible">
|
||||
{matches.map((match, index) => {
|
||||
const leftItem = leftItems.find((item) => item.id === match.leftId);
|
||||
const rightItem = rightItems.find((item) => item.id === match.rightId);
|
||||
if (!leftItem || !rightItem) return null;
|
||||
const leftItem = leftItems.find((item) => item.id === match.leftId)
|
||||
const rightItem = rightItems.find((item) => item.id === match.rightId)
|
||||
if (!leftItem || !rightItem) return null
|
||||
|
||||
const leftIndex = leftItems.findIndex((item) => item.id === match.leftId);
|
||||
const rightIndex = rightItems.findIndex((item) => item.id === match.rightId);
|
||||
const leftIndex = leftItems.findIndex((item) => item.id === match.leftId)
|
||||
const rightIndex = rightItems.findIndex((item) => item.id === match.rightId)
|
||||
|
||||
// 计算连线起点和终点(基于项目高度和间距)
|
||||
const itemHeight = 80; // 每个项目的大概高度
|
||||
const gap = 16; // 项目之间的间距
|
||||
const startY = leftIndex * (itemHeight + gap) + itemHeight / 2;
|
||||
const endY = rightIndex * (itemHeight + gap) + itemHeight / 2;
|
||||
const itemHeight = 80 // 每个项目的大概高度
|
||||
const gap = 16 // 项目之间的间距
|
||||
const startY = leftIndex * (itemHeight + gap) + itemHeight / 2
|
||||
const endY = rightIndex * (itemHeight + gap) + itemHeight / 2
|
||||
|
||||
return (
|
||||
<line
|
||||
@@ -292,7 +278,7 @@ const MatchQuizPanel = ({
|
||||
strokeDasharray="5,5"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
);
|
||||
)
|
||||
})}
|
||||
{/* 箭头标记定义 */}
|
||||
<defs>
|
||||
@@ -313,37 +299,37 @@ const MatchQuizPanel = ({
|
||||
{/* 右列:事件 */}
|
||||
<div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
|
||||
{rightItems.map((item) => {
|
||||
const isMatched = isRightItemMatched(item.id);
|
||||
const isError = errorPairs.some((p) => p.rightId === item.id);
|
||||
const isMatched = isRightItemMatched(item.id)
|
||||
const isError = errorPairs.some((p) => p.rightId === item.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200",
|
||||
"text-[20px] md:text-[28px] leading-relaxed",
|
||||
"touch-manipulation", // 优化移动端触摸
|
||||
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
|
||||
'text-[20px] md:text-[28px] leading-relaxed',
|
||||
'touch-manipulation', // 优化移动端触摸
|
||||
disabled || isMatched
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
? 'opacity-60 cursor-not-allowed'
|
||||
: selectedLeftId
|
||||
? "cursor-pointer"
|
||||
: "cursor-default",
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-default',
|
||||
isError
|
||||
? "bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse"
|
||||
? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
|
||||
: isMatched
|
||||
? "bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]"
|
||||
: selectedLeftId
|
||||
? "bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]"
|
||||
: "bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60"
|
||||
? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
|
||||
: selectedLeftId
|
||||
? 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
|
||||
: 'bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60'
|
||||
)}
|
||||
onClick={() => handleRightItemClick(item.id)}
|
||||
onTouchStart={(e) => {
|
||||
if (selectedLeftId) {
|
||||
e.currentTarget.classList.add("scale-105");
|
||||
e.currentTarget.classList.add('scale-105')
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.classList.remove("scale-105");
|
||||
e.currentTarget.classList.remove('scale-105')
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
@@ -358,7 +344,7 @@ const MatchQuizPanel = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -369,8 +355,7 @@ const MatchQuizPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchQuizPanel;
|
||||
)
|
||||
}
|
||||
|
||||
export default MatchQuizPanel
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { MultipleChoice } from "@/components";
|
||||
import type { MultipleChoiceQuizPanelProps } from "./types";
|
||||
import { useState, useEffect } from 'react'
|
||||
import { MultipleChoice } from '@/components'
|
||||
import type { MultipleChoiceQuizPanelProps } from './types'
|
||||
|
||||
/**
|
||||
* 多选题面板组件
|
||||
@@ -44,94 +44,85 @@ const MultipleChoiceQuizPanel = ({
|
||||
disabled = false,
|
||||
minSelections,
|
||||
maxSelections,
|
||||
submitText = "提交",
|
||||
submitText = '提交',
|
||||
}: MultipleChoiceQuizPanelProps) => {
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([])
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
// 当窗口关闭时,清除选中状态和错误状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSelectedValues([]);
|
||||
setError("");
|
||||
setSelectedValues([])
|
||||
setError('')
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
// 处理选项点击
|
||||
const handleOptionChange = (values: string[]) => {
|
||||
if (disabled) return;
|
||||
setSelectedValues(values);
|
||||
setError("");
|
||||
};
|
||||
if (disabled) return
|
||||
setSelectedValues(values)
|
||||
setError('')
|
||||
}
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = () => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
|
||||
// 检查最小选择数量
|
||||
if (minSelections !== undefined && selectedValues.length < minSelections) {
|
||||
const errorMsg =
|
||||
minSelectionsErrorMessage || `请至少选择 ${minSelections} 项`;
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
return;
|
||||
const errorMsg = minSelectionsErrorMessage || `请至少选择 ${minSelections} 项`
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证答案
|
||||
let isCorrect: boolean | undefined = undefined;
|
||||
let isCorrect: boolean | undefined = undefined
|
||||
if (correctAnswer !== undefined) {
|
||||
// 比较两个数组是否相等(不考虑顺序)
|
||||
const correctSet = new Set(correctAnswer);
|
||||
const selectedSet = new Set(selectedValues);
|
||||
const correctSet = new Set(correctAnswer)
|
||||
const selectedSet = new Set(selectedValues)
|
||||
|
||||
isCorrect =
|
||||
correctSet.size === selectedSet.size &&
|
||||
[...correctSet].every((val) => selectedSet.has(val));
|
||||
correctSet.size === selectedSet.size && [...correctSet].every((val) => selectedSet.has(val))
|
||||
|
||||
if (isCorrect) {
|
||||
// 答题成功:清空状态,执行 onSubmit(外部控制关闭窗口)
|
||||
const submittedValues = [...selectedValues];
|
||||
setSelectedValues([]);
|
||||
setError("");
|
||||
onSubmit?.(submittedValues, true);
|
||||
const submittedValues = [...selectedValues]
|
||||
setSelectedValues([])
|
||||
setError('')
|
||||
onSubmit?.(submittedValues, true)
|
||||
} else {
|
||||
// 答题失败:清空状态,执行 onError,允许重新选择
|
||||
const submittedValues = [...selectedValues];
|
||||
setSelectedValues([]);
|
||||
const submittedValues = [...selectedValues]
|
||||
setSelectedValues([])
|
||||
const errorMsg =
|
||||
typeof errorMessage === "function"
|
||||
typeof errorMessage === 'function'
|
||||
? errorMessage(submittedValues)
|
||||
: errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
: errorMessage || '很抱歉,你选错了!请你再思考一下吧!'
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
}
|
||||
} else {
|
||||
// 没有提供正确答案,直接提交
|
||||
const submittedValues = [...selectedValues];
|
||||
setSelectedValues([]);
|
||||
setError("");
|
||||
onSubmit?.(submittedValues, true);
|
||||
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="fixed inset-0 flex items-center justify-center bg-black/40" style={{ zIndex }}>
|
||||
<div className="relative w-[1728px] flex flex-col items-center py-6">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1700px] 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>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-5 text-center text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
|
||||
</div>
|
||||
|
||||
{/* 选项区域 - 可滚动 */}
|
||||
@@ -149,56 +140,48 @@ const MultipleChoiceQuizPanel = ({
|
||||
disabled ||
|
||||
(!isSelected &&
|
||||
maxSelections !== undefined &&
|
||||
selectedValues.length >= maxSelections);
|
||||
selectedValues.length >= maxSelections)
|
||||
|
||||
// 处理选项点击
|
||||
const handleClick = () => {
|
||||
if (isDisabled) return;
|
||||
if (isDisabled) return
|
||||
|
||||
let newValues: string[];
|
||||
let newValues: string[]
|
||||
if (isSelected) {
|
||||
// 取消选择
|
||||
newValues = selectedValues.filter((v) => v !== option.value);
|
||||
newValues = selectedValues.filter((v) => v !== option.value)
|
||||
// 检查最小选择数量限制
|
||||
if (
|
||||
minSelections !== undefined &&
|
||||
newValues.length < minSelections
|
||||
) {
|
||||
return;
|
||||
if (minSelections !== undefined && newValues.length < minSelections) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 选择
|
||||
// 检查最大选择数量限制
|
||||
if (
|
||||
maxSelections !== undefined &&
|
||||
selectedValues.length >= maxSelections
|
||||
) {
|
||||
return;
|
||||
if (maxSelections !== undefined && selectedValues.length >= maxSelections) {
|
||||
return
|
||||
}
|
||||
newValues = [...selectedValues, option.value];
|
||||
newValues = [...selectedValues, option.value]
|
||||
}
|
||||
|
||||
handleOptionChange(newValues);
|
||||
};
|
||||
handleOptionChange(newValues)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={option.value} className="mb-4">
|
||||
<div
|
||||
className={`w-[1500px] flex items-center p-4 text-[28px] leading-relaxed rounded-tr-[50px] rounded-bl-4xl transition-all duration-150 ${
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-pointer"
|
||||
isDisabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
} ${
|
||||
isSelected
|
||||
? "bg-[#d4a574] text-[#5c3e2e] bg-gradient-to-r from-[#ddf3e1] to-[#fbdd97]"
|
||||
: "bg-[#d7b888] text-[#5c3e2e] hover:brightness-110"
|
||||
? 'bg-[#d4a574] text-[#5c3e2e] bg-gradient-to-r from-[#ddf3e1] to-[#fbdd97]'
|
||||
: 'bg-[#d7b888] text-[#5c3e2e] hover:brightness-110'
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -212,8 +195,7 @@ const MultipleChoiceQuizPanel = ({
|
||||
disabled={
|
||||
disabled ||
|
||||
selectedValues.length === 0 ||
|
||||
(minSelections !== undefined &&
|
||||
selectedValues.length < minSelections)
|
||||
(minSelections !== undefined && selectedValues.length < minSelections)
|
||||
}
|
||||
className="px-30 py-4 text-[28px] font-medium text-white bg-[#c2995f] rounded-[40px] transition-all duration-200 hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
|
||||
>
|
||||
@@ -223,7 +205,7 @@ const MultipleChoiceQuizPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default MultipleChoiceQuizPanel;
|
||||
export default MultipleChoiceQuizPanel
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SingleChoice } from "@/components";
|
||||
import { getBackgroundImage } from "@/lib/utils";
|
||||
import type { QuizPanelProps } from "./types";
|
||||
import { SingleChoice } from '@/components'
|
||||
import { getBackgroundImage } from '@/lib/utils'
|
||||
import type { QuizPanelProps } from './types'
|
||||
|
||||
/** 单选题展示面板:题干使用“底板”背景,点击即选,hover 高亮 */
|
||||
const QuizPanel = ({
|
||||
@@ -12,13 +12,10 @@ const QuizPanel = ({
|
||||
error,
|
||||
onChange,
|
||||
}: QuizPanelProps) => {
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<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]">
|
||||
@@ -40,24 +37,21 @@ const QuizPanel = ({
|
||||
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/未选择"
|
||||
isSelected ? 'components/quiz/选中' : 'components/quiz/未选择'
|
||||
),
|
||||
filter: isSelected ? undefined : "brightness(0.96)",
|
||||
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 = "";
|
||||
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)";
|
||||
if (isSelected) return
|
||||
;(e.currentTarget as HTMLDivElement).style.backgroundImage =
|
||||
getBackgroundImage('components/quiz/未选择')
|
||||
;(e.currentTarget as HTMLDivElement).style.filter = 'brightness(0.96)'
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
@@ -67,15 +61,11 @@ const QuizPanel = ({
|
||||
/>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-6 text-center text-[26px] text-[#ff4d4f]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-6 text-center text-[26px] text-[#ff4d4f]">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default QuizPanel;
|
||||
export default QuizPanel
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type {
|
||||
SelectFillQuizPanelProps,
|
||||
SelectFillResult,
|
||||
} from "./types";
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SelectFillQuizPanelProps, SelectFillResult } from './types'
|
||||
|
||||
/**
|
||||
* 选择填空题面板组件
|
||||
@@ -21,131 +18,124 @@ const SelectFillQuizPanel = ({
|
||||
onSubmit,
|
||||
onError,
|
||||
disabled = false,
|
||||
submitText = "提交",
|
||||
submitText = '提交',
|
||||
}: SelectFillQuizPanelProps) => {
|
||||
const [results, setResults] = useState<SelectFillResult[]>([]);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [results, setResults] = useState<SelectFillResult[]>([])
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
// 当前正在填的空的下标(从 0 开始),默认为第一个空
|
||||
const [activeBlankIndex, setActiveBlankIndex] = useState(0);
|
||||
const [activeBlankIndex, setActiveBlankIndex] = useState(0)
|
||||
|
||||
// 关闭时重置内部状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setResults([]);
|
||||
setError("");
|
||||
setActiveBlankIndex(0);
|
||||
setResults([])
|
||||
setError('')
|
||||
setActiveBlankIndex(0)
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
const isAllFilled = blanks.every((blank) =>
|
||||
results.some((r) => r.blankId === blank.id && r.value !== "")
|
||||
);
|
||||
results.some((r) => r.blankId === blank.id && r.value !== '')
|
||||
)
|
||||
|
||||
// 点击下方选项:填入当前激活的空
|
||||
const handleOptionClick = (value: string) => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
|
||||
const currentBlank = blanks[activeBlankIndex];
|
||||
if (!currentBlank) return;
|
||||
const currentBlank = blanks[activeBlankIndex]
|
||||
if (!currentBlank) return
|
||||
|
||||
const newResults: SelectFillResult[] = (() => {
|
||||
const others = results.filter((r) => r.blankId !== currentBlank.id);
|
||||
return [...others, { blankId: currentBlank.id, value }];
|
||||
})();
|
||||
const others = results.filter((r) => r.blankId !== currentBlank.id)
|
||||
return [...others, { blankId: currentBlank.id, value }]
|
||||
})()
|
||||
|
||||
setResults(newResults);
|
||||
setError("");
|
||||
setResults(newResults)
|
||||
setError('')
|
||||
|
||||
// 自动跳到下一个空
|
||||
const nextIndex = (() => {
|
||||
// 优先跳到还未填写的下一个空
|
||||
for (let i = 0; i < blanks.length; i++) {
|
||||
const blankItem = blanks[i]!;
|
||||
const hasValue = newResults.some(
|
||||
(r) => r.blankId === blankItem.id && r.value !== ""
|
||||
);
|
||||
if (!hasValue) return i;
|
||||
const blankItem = blanks[i]!
|
||||
const hasValue = newResults.some((r) => r.blankId === blankItem.id && r.value !== '')
|
||||
if (!hasValue) return i
|
||||
}
|
||||
return activeBlankIndex;
|
||||
})();
|
||||
setActiveBlankIndex(nextIndex);
|
||||
};
|
||||
return activeBlankIndex
|
||||
})()
|
||||
setActiveBlankIndex(nextIndex)
|
||||
}
|
||||
|
||||
// 清空某个空
|
||||
const handleClearBlank = (blankId: string) => {
|
||||
if (disabled) return;
|
||||
setResults(results.filter((r) => r.blankId !== blankId));
|
||||
setError("");
|
||||
const index = blanks.findIndex((b) => b.id === blankId);
|
||||
if (index >= 0) setActiveBlankIndex(index);
|
||||
};
|
||||
if (disabled) return
|
||||
setResults(results.filter((r) => r.blankId !== blankId))
|
||||
setError('')
|
||||
const index = blanks.findIndex((b) => b.id === blankId)
|
||||
if (index >= 0) setActiveBlankIndex(index)
|
||||
}
|
||||
|
||||
// 提交答案
|
||||
const handleSubmit = () => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
|
||||
// 有空未填
|
||||
if (!isAllFilled) {
|
||||
const msg = "请先把所有空都填完再提交哦~";
|
||||
setError(msg);
|
||||
onError?.(msg);
|
||||
return;
|
||||
const msg = '请先把所有空都填完再提交哦~'
|
||||
setError(msg)
|
||||
onError?.(msg)
|
||||
return
|
||||
}
|
||||
|
||||
const filledResults = blanks.map((blank) => {
|
||||
const r = results.find((res) => res.blankId === blank.id);
|
||||
return { blankId: blank.id, value: r?.value ?? "" };
|
||||
});
|
||||
const r = results.find((res) => res.blankId === blank.id)
|
||||
return { blankId: blank.id, value: r?.value ?? '' }
|
||||
})
|
||||
|
||||
let isCorrect = true;
|
||||
let isCorrect = true
|
||||
if (correctAnswers) {
|
||||
for (const r of filledResults) {
|
||||
const expected = correctAnswers[r.blankId];
|
||||
const expected = correctAnswers[r.blankId]
|
||||
if (expected !== undefined && expected !== r.value) {
|
||||
isCorrect = false;
|
||||
break;
|
||||
isCorrect = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!correctAnswers) {
|
||||
onSubmit?.(filledResults, true);
|
||||
return;
|
||||
onSubmit?.(filledResults, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (isCorrect) {
|
||||
setError("");
|
||||
onSubmit?.(filledResults, true);
|
||||
setError('')
|
||||
onSubmit?.(filledResults, true)
|
||||
} else {
|
||||
const msg =
|
||||
errorMessage || "还不太对,再仔细对照题干和提示想一想吧~";
|
||||
setError(msg);
|
||||
const msg = errorMessage || '还不太对,再仔细对照题干和提示想一想吧~'
|
||||
setError(msg)
|
||||
// 错误后清空已填写内容,重新作答
|
||||
setResults([]);
|
||||
setActiveBlankIndex(0);
|
||||
onError?.(msg);
|
||||
setResults([])
|
||||
setActiveBlankIndex(0)
|
||||
onError?.(msg)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getBlankValue = (blankId: string) =>
|
||||
results.find((r) => r.blankId === blankId)?.value ?? "";
|
||||
const getBlankValue = (blankId: string) => results.find((r) => r.blankId === blankId)?.value ?? ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<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-[1700px] rounded-[32px] bg-black 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">
|
||||
{titlePrefix && <span>{titlePrefix}</span>}
|
||||
{blanks.map((blank, index) => {
|
||||
const value = getBlankValue(blank.id);
|
||||
const isActive = index === activeBlankIndex;
|
||||
const value = getBlankValue(blank.id)
|
||||
const isActive = index === activeBlankIndex
|
||||
return (
|
||||
<span key={blank.id}>
|
||||
{blank.prefix && <span>{blank.prefix}</span>}
|
||||
@@ -153,29 +143,29 @@ const SelectFillQuizPanel = ({
|
||||
type="button"
|
||||
className={`inline-flex items-center justify-center min-w-[120px] h-[52px] px-4 mx-2 rounded-xl border-2 text-[28px] transition-all ${
|
||||
disabled
|
||||
? "opacity-60 cursor-not-allowed border-[#d4c5a9]/40"
|
||||
: "cursor-pointer"
|
||||
? 'opacity-60 cursor-not-allowed border-[#d4c5a9]/40'
|
||||
: 'cursor-pointer'
|
||||
} ${
|
||||
value
|
||||
? "border-[#f0be75] bg-gradient-to-br from-[#3a3020] to-[#2a2520] text-[#f0be75]"
|
||||
: "border-[#d4c5a9]/60 bg-white/10 text-white/40"
|
||||
? 'border-[#f0be75] bg-gradient-to-br from-[#3a3020] to-[#2a2520] text-[#f0be75]'
|
||||
: 'border-[#d4c5a9]/60 bg-white/10 text-white/40'
|
||||
} ${
|
||||
isActive && !disabled
|
||||
? "ring-2 ring-[#f0be75] ring-offset-2 ring-offset-black"
|
||||
: ""
|
||||
? 'ring-2 ring-[#f0be75] ring-offset-2 ring-offset-black'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setActiveBlankIndex(index);
|
||||
if (disabled) return
|
||||
setActiveBlankIndex(index)
|
||||
}}
|
||||
>
|
||||
{value || "____"}
|
||||
{value || '____'}
|
||||
{value && !disabled && (
|
||||
<span
|
||||
className="ml-3 text-[22px] text-[#f0be75]/80"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearBlank(blank.id);
|
||||
e.stopPropagation()
|
||||
handleClearBlank(blank.id)
|
||||
}}
|
||||
>
|
||||
×
|
||||
@@ -184,17 +174,13 @@ const SelectFillQuizPanel = ({
|
||||
</button>
|
||||
{blank.suffix && <span>{blank.suffix}</span>}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
})}
|
||||
{titleSuffix && <span>{titleSuffix}</span>}
|
||||
</div>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-6 text-center text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-6 text-center text-[26px] text-[#fcbe53]">{error}</div>}
|
||||
</div>
|
||||
|
||||
{/* 选项区域 */}
|
||||
@@ -207,8 +193,8 @@ const SelectFillQuizPanel = ({
|
||||
disabled={disabled}
|
||||
className={`min-w-[90px] h-[72px] px-6 rounded-2xl text-[28px] font-medium border-2 transition-all ${
|
||||
disabled
|
||||
? "opacity-60 cursor-not-allowed bg-[#d7b888]/60 border-[#d4c5a9]/60 text-[#5c3e2e]/60"
|
||||
: "cursor-pointer bg-[#d7b888] border-[#d4c5a9] text-[#5c3e2e] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]"
|
||||
? 'opacity-60 cursor-not-allowed bg-[#d7b888]/60 border-[#d4c5a9]/60 text-[#5c3e2e]/60'
|
||||
: 'cursor-pointer bg-[#d7b888] border-[#d4c5a9] text-[#5c3e2e] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
|
||||
}`}
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
>
|
||||
@@ -231,9 +217,7 @@ const SelectFillQuizPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectFillQuizPanel;
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectFillQuizPanel
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { SingleChoice } from "@/components";
|
||||
import type { SingleChoiceQuizPanelProps } from "./types";
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SingleChoice } from '@/components'
|
||||
import type { SingleChoiceQuizPanelProps } from './types'
|
||||
|
||||
/**
|
||||
* 单选题面板组件
|
||||
@@ -39,70 +39,61 @@ const SingleChoiceQuizPanel = ({
|
||||
onError,
|
||||
disabled = false,
|
||||
}: SingleChoiceQuizPanelProps) => {
|
||||
const [selectedValue, setSelectedValue] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [selectedValue, setSelectedValue] = useState<string>('')
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
// 当窗口关闭时,重置内部状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSelectedValue("");
|
||||
setError("");
|
||||
setSelectedValue('')
|
||||
setError('')
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
// 处理选项点击
|
||||
const handleOptionClick = (value: string) => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
|
||||
setSelectedValue(value);
|
||||
setError("");
|
||||
setSelectedValue(value)
|
||||
setError('')
|
||||
|
||||
// 验证答案
|
||||
const isCorrect =
|
||||
correctAnswer !== undefined ? value === correctAnswer : undefined;
|
||||
const isCorrect = correctAnswer !== undefined ? value === correctAnswer : undefined
|
||||
|
||||
// 如果提供了正确答案,进行验证
|
||||
if (correctAnswer !== undefined) {
|
||||
if (isCorrect) {
|
||||
// 答题成功:清空状态,执行 onSubmit(外部控制关闭窗口)
|
||||
setSelectedValue("");
|
||||
setError("");
|
||||
onSubmit?.(value, true);
|
||||
setSelectedValue('')
|
||||
setError('')
|
||||
onSubmit?.(value, true)
|
||||
} else {
|
||||
// 答题失败:清空状态,执行 onError,允许重新选择
|
||||
setSelectedValue("");
|
||||
const customError = onWrongSelect?.(value);
|
||||
const errorMsg =
|
||||
customError || errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
setSelectedValue('')
|
||||
const customError = onWrongSelect?.(value)
|
||||
const errorMsg = customError || errorMessage || '很抱歉,你选错了!请你再思考一下吧!'
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
}
|
||||
} else {
|
||||
// 没有提供正确答案,直接提交
|
||||
setSelectedValue("");
|
||||
setError("");
|
||||
onSubmit?.(value, true);
|
||||
setSelectedValue('')
|
||||
setError('')
|
||||
onSubmit?.(value, true)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<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-[1700px] rounded-[32px] bg-black px-20 py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
<div className="text-[34px] leading-relaxed text-white">{title}</div>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-5 text-center text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
|
||||
</div>
|
||||
|
||||
{/* 选项区域 */}
|
||||
@@ -119,13 +110,11 @@ const SingleChoiceQuizPanel = ({
|
||||
>
|
||||
<div
|
||||
className={`w-[1500px] flex items-center p-4 text-[28px] leading-relaxed rounded-tr-[50px] rounded-bl-4xl transition-all duration-150 ${
|
||||
disabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-pointer"
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
} ${
|
||||
isSelected
|
||||
? "bg-[#d4a574] text-[#5c3e2e]"
|
||||
: "bg-[#d7b888] text-[#5c3e2e] hover:bg-gradient-to-r from-[#ddf3e1] to-[#fbdd97]"
|
||||
? 'bg-[#d4a574] text-[#5c3e2e]'
|
||||
: 'bg-[#d7b888] text-[#5c3e2e] hover:bg-gradient-to-r from-[#ddf3e1] to-[#fbdd97]'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
@@ -136,7 +125,7 @@ const SingleChoiceQuizPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default SingleChoiceQuizPanel;
|
||||
export default SingleChoiceQuizPanel
|
||||
@@ -1,9 +1,9 @@
|
||||
// 新组件
|
||||
export { default as SingleChoiceQuizPanel } from "./SingleChoiceQuizPanel";
|
||||
export { default as MultipleChoiceQuizPanel } from "./MultipleChoiceQuizPanel";
|
||||
export { default as DragQuizPanel } from "./DragQuizPanel";
|
||||
export { default as MatchQuizPanel } from "./MatchQuizPanel";
|
||||
export { default as SelectFillQuizPanel } from "./SelectFillQuizPanel";
|
||||
export { default as SingleChoiceQuizPanel } from './SingleChoiceQuizPanel'
|
||||
export { default as MultipleChoiceQuizPanel } from './MultipleChoiceQuizPanel'
|
||||
export { default as DragQuizPanel } from './DragQuizPanel'
|
||||
export { default as MatchQuizPanel } from './MatchQuizPanel'
|
||||
export { default as SelectFillQuizPanel } from './SelectFillQuizPanel'
|
||||
export type {
|
||||
SingleChoiceQuizPanelProps,
|
||||
MultipleChoiceQuizPanelProps,
|
||||
@@ -14,8 +14,8 @@ export type {
|
||||
SelectFillQuizPanelProps,
|
||||
SelectFillBlank,
|
||||
SelectFillResult,
|
||||
} from "./types";
|
||||
} from './types'
|
||||
|
||||
// 保留旧组件以保持向后兼容
|
||||
export { default as QuizPanel } from "./QuizPanal";
|
||||
export type { QuizPanelProps } from "./types";
|
||||
export { default as QuizPanel } from './QuizPanal'
|
||||
export type { QuizPanelProps } from './types'
|
||||
@@ -3,188 +3,188 @@ import {
|
||||
MultipleChoiceOption,
|
||||
DragFillResult,
|
||||
DragFillBlank,
|
||||
} from "@/components";
|
||||
} from '@/components'
|
||||
|
||||
/** 拖拽题面板配置 */
|
||||
export interface DragQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 题目标题(可选) */
|
||||
title?: string;
|
||||
title?: string
|
||||
/** 题干前缀文本(空白区域之前的文本) */
|
||||
questionPrefix?: string;
|
||||
questionPrefix?: string
|
||||
/** 题干后缀文本(空白区域之后的文本) */
|
||||
questionSuffix?: string;
|
||||
questionSuffix?: string
|
||||
/** 选项列表 */
|
||||
options: { value: string; label: string }[];
|
||||
options: { value: string; label: string }[]
|
||||
/** 空白区域列表 */
|
||||
blanks: DragFillBlank[];
|
||||
blanks: DragFillBlank[]
|
||||
/** 空白之间的分隔符数组(可选,如果不提供则使用默认的"、") */
|
||||
blankSeparators?: string[];
|
||||
blankSeparators?: string[]
|
||||
/** 当前值 */
|
||||
value: DragFillResult[];
|
||||
value: DragFillResult[]
|
||||
/** 错误提示信息 */
|
||||
error?: string;
|
||||
error?: string
|
||||
/** 值变化回调 */
|
||||
onChange: (value: DragFillResult[]) => void;
|
||||
onChange: (value: DragFillResult[]) => void
|
||||
}
|
||||
|
||||
/** 单选题面板配置 */
|
||||
export interface SingleChoiceQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 题目 */
|
||||
title: string;
|
||||
title: string
|
||||
/** 选项列表 */
|
||||
options: SingleChoiceOption[];
|
||||
options: SingleChoiceOption[]
|
||||
/** 正确答案(可选,用于验证) */
|
||||
correctAnswer?: string;
|
||||
correctAnswer?: string
|
||||
/** 自定义错误提示信息(答案错误时显示) */
|
||||
errorMessage?: string;
|
||||
errorMessage?: string
|
||||
/** 提交回调,返回用户选择和是否正确 */
|
||||
onSubmit?: (value: string, isCorrect: boolean) => void;
|
||||
onSubmit?: (value: string, isCorrect: boolean) => void
|
||||
/** 答错时回调,可返回自定义解析文案覆盖默认 errorMessage */
|
||||
onWrongSelect?: (value: string) => string | void;
|
||||
onWrongSelect?: (value: string) => string | void
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void;
|
||||
onError?: (error: string) => void
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** 多选题面板配置 */
|
||||
export interface MultipleChoiceQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 题目 */
|
||||
title: string;
|
||||
title: string
|
||||
/** 选项列表 */
|
||||
options: MultipleChoiceOption[];
|
||||
options: MultipleChoiceOption[]
|
||||
/** 正确答案数组(可选,用于验证) */
|
||||
correctAnswer?: string[];
|
||||
correctAnswer?: string[]
|
||||
/** 自定义错误提示信息(答案错误时显示),可以是字符串或函数(根据用户选择返回不同提示) */
|
||||
errorMessage?: string | ((selectedValues: string[]) => string);
|
||||
errorMessage?: string | ((selectedValues: string[]) => string)
|
||||
/** 自定义最小选择数量不足时的提示信息 */
|
||||
minSelectionsErrorMessage?: string;
|
||||
minSelectionsErrorMessage?: string
|
||||
/** 提交回调,返回用户选择和是否正确 */
|
||||
onSubmit?: (values: string[], isCorrect: boolean) => void;
|
||||
onSubmit?: (values: string[], isCorrect: boolean) => void
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void;
|
||||
onError?: (error: string) => void
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
/** 最小选择数量 */
|
||||
minSelections?: number;
|
||||
minSelections?: number
|
||||
/** 最大选择数量 */
|
||||
maxSelections?: number;
|
||||
maxSelections?: number
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
submitText?: string
|
||||
}
|
||||
|
||||
/** 连线题项 */
|
||||
export interface MatchQuizItem {
|
||||
/** 唯一标识 */
|
||||
id: string;
|
||||
id: string
|
||||
/** 显示文本 */
|
||||
label: string;
|
||||
label: string
|
||||
}
|
||||
|
||||
/** 连线题匹配 */
|
||||
export interface MatchQuizMatch {
|
||||
/** 左列项ID */
|
||||
leftId: string;
|
||||
leftId: string
|
||||
/** 右列项ID */
|
||||
rightId: string;
|
||||
rightId: string
|
||||
}
|
||||
|
||||
/** 连线题面板配置 */
|
||||
export interface MatchQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 题目 */
|
||||
title: string;
|
||||
title: string
|
||||
/** 左列项列表(日期) */
|
||||
leftItems: MatchQuizItem[];
|
||||
leftItems: MatchQuizItem[]
|
||||
/** 右列项列表(事件) */
|
||||
rightItems: MatchQuizItem[];
|
||||
rightItems: MatchQuizItem[]
|
||||
/** 正确答案匹配列表 */
|
||||
correctMatches: MatchQuizMatch[];
|
||||
correctMatches: MatchQuizMatch[]
|
||||
/**
|
||||
* 自定义错误提示信息(答案错误时显示)
|
||||
* 可以是固定文案,或根据当前匹配结果返回不同提示
|
||||
*/
|
||||
errorMessage?: string | ((matches: MatchQuizMatch[]) => string);
|
||||
errorMessage?: string | ((matches: MatchQuizMatch[]) => string)
|
||||
/** 提交回调,返回用户匹配和是否正确 */
|
||||
onSubmit?: (matches: MatchQuizMatch[], isCorrect: boolean) => void;
|
||||
onSubmit?: (matches: MatchQuizMatch[], isCorrect: boolean) => void
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void;
|
||||
onError?: (error: string) => void
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** 选择填空题中,每一个空的配置 */
|
||||
export interface SelectFillBlank {
|
||||
/** 唯一标识,用于和答案、结果关联 */
|
||||
id: string;
|
||||
id: string
|
||||
/** 空前面的文本(可选) */
|
||||
prefix?: string;
|
||||
prefix?: string
|
||||
/** 空后面的文本(可选) */
|
||||
suffix?: string;
|
||||
suffix?: string
|
||||
}
|
||||
|
||||
/** 选择填空题的答题结果 */
|
||||
export interface SelectFillResult {
|
||||
blankId: string;
|
||||
value: string;
|
||||
blankId: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** 选择填空题面板配置 */
|
||||
export interface SelectFillQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** z-index 层级 */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 题目前缀整体文本(可选) */
|
||||
titlePrefix?: string;
|
||||
titlePrefix?: string
|
||||
/** 题目后缀整体文本(可选) */
|
||||
titleSuffix?: string;
|
||||
titleSuffix?: string
|
||||
/** 填空位配置(控制有几个空、空前后的说明文本) */
|
||||
blanks: SelectFillBlank[];
|
||||
blanks: SelectFillBlank[]
|
||||
/** 可选项列表(如 0-9 的数字、若干短语等) */
|
||||
options: { value: string; label: string }[];
|
||||
options: { value: string; label: string }[]
|
||||
/**
|
||||
* 正确答案映射
|
||||
* key 为 blankId,value 为该空对应的正确值
|
||||
*/
|
||||
correctAnswers?: Record<string, string>;
|
||||
correctAnswers?: Record<string, string>
|
||||
/** 自定义错误提示信息(答案错误时显示) */
|
||||
errorMessage?: string;
|
||||
errorMessage?: string
|
||||
/**
|
||||
* 提交回调,返回用户选择结果与是否全部正确
|
||||
* 注意:如果未提供 correctAnswers,则 isCorrect 恒为 true
|
||||
*/
|
||||
onSubmit?: (results: SelectFillResult[], isCorrect: boolean) => void;
|
||||
onSubmit?: (results: SelectFillResult[], isCorrect: boolean) => void
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void;
|
||||
onError?: (error: string) => void
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
submitText?: string
|
||||
}
|
||||
|
||||
/** @deprecated 使用 SingleChoiceQuizPanelProps 替代 */
|
||||
export interface QuizPanelProps {
|
||||
visible: boolean;
|
||||
zIndex?: number;
|
||||
title: string;
|
||||
options: SingleChoiceOption[];
|
||||
value: string;
|
||||
error?: string;
|
||||
onChange?: (value: string) => void;
|
||||
visible: boolean
|
||||
zIndex?: number
|
||||
title: string
|
||||
options: SingleChoiceOption[]
|
||||
value: string
|
||||
error?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
@@ -1,69 +1,65 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { Image } from "@/components";
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { getStaticImage } from '@/lib/utils'
|
||||
import { Image } from '@/components'
|
||||
|
||||
export interface LetterToChenyunProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
onClose?: () => void
|
||||
/** 提交回调 */
|
||||
onSubmit?: (values: string[]) => void;
|
||||
onSubmit?: (values: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* LetterToChenyun 组件 - 寄给陈云爷爷的信
|
||||
* 包含飞入和放大动画
|
||||
*/
|
||||
const LetterToChenyun = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: LetterToChenyunProps) => {
|
||||
const LetterToChenyun = ({ visible = false, onClose, onSubmit }: LetterToChenyunProps) => {
|
||||
// 下划线可填写内容
|
||||
const [blankValues, setBlankValues] = useState<string[]>([]);
|
||||
const [blankValues, setBlankValues] = useState<string[]>([])
|
||||
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([]);
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
|
||||
|
||||
// 是否已提交:提交后隐藏编辑信件,展示只读信件
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
// 校验输入:不能为空、每个不超过 10 个字符
|
||||
const validateBlanks = () => {
|
||||
// 计算占位总数,防止有些没被输入过而是 undefined
|
||||
const placeholderCount = (letterContent.match(/____/g) || []).length;
|
||||
const placeholderCount = (letterContent.match(/____/g) || []).length
|
||||
|
||||
const nextErrors: boolean[] = Array(placeholderCount).fill(false);
|
||||
const nextValues: string[] = [...blankValues];
|
||||
let hasError = false;
|
||||
const nextErrors: boolean[] = Array(placeholderCount).fill(false)
|
||||
const nextValues: string[] = [...blankValues]
|
||||
let hasError = false
|
||||
|
||||
for (let i = 0; i < placeholderCount; i++) {
|
||||
const raw = blankValues[i] || "";
|
||||
const v = raw.trim();
|
||||
const raw = blankValues[i] || ''
|
||||
const v = raw.trim()
|
||||
|
||||
// 为空 或 超过 10 个字,都视为不符合要求:清空并标红
|
||||
if (!v || v.length > 10) {
|
||||
nextValues[i] = "";
|
||||
nextErrors[i] = true;
|
||||
hasError = true;
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
setBlankValues(nextValues);
|
||||
setBlankErrors(nextErrors);
|
||||
setBlankValues(nextValues)
|
||||
setBlankErrors(nextErrors)
|
||||
|
||||
return !hasError;
|
||||
};
|
||||
return !hasError
|
||||
}
|
||||
|
||||
// 弹窗关闭后重置内容,方便下次重新填写
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSubmitted(false);
|
||||
setBlankValues([]);
|
||||
setBlankErrors([]);
|
||||
setSubmitted(false)
|
||||
setBlankValues([])
|
||||
setBlankErrors([])
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
// 信件内容模板(根据设计稿)
|
||||
const letterContent = `敬爱的陈云爷爷:
|
||||
@@ -73,7 +69,7 @@ const LetterToChenyun = ({
|
||||
|
||||
作为新时代学生,我会在专业学习中____,像您当年练珠算、搞调查那样精益求精;未来无论从事什么工作,都要把服务群众、____放在首位;面对困难时不退缩,像您当年稳定物价、推进实业那样,用____本领(结合所学专业)为国家发展贡献自己的力量!
|
||||
|
||||
站在新时代的起点,我一定以____为志向,勤学苦练,传承精神,让您的务实担当在青春里扎根,让奋斗在建设祖国的实践中绽放光彩!`;
|
||||
站在新时代的起点,我一定以____为志向,勤学苦练,传承精神,让您的务实担当在青春里扎根,让奋斗在建设祖国的实践中绽放光彩!`
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -96,7 +92,7 @@ const LetterToChenyun = ({
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.6,
|
||||
@@ -105,28 +101,28 @@ const LetterToChenyun = ({
|
||||
<div
|
||||
className="relative w-[1200px] h-full pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ perspective: "1200px" }}
|
||||
style={{ perspective: '1200px' }}
|
||||
>
|
||||
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
|
||||
<motion.div
|
||||
className="relative w-full h-full"
|
||||
initial={false}
|
||||
animate={{ rotateY: submitted ? 180 : 0 }}
|
||||
transition={{ duration: 0.7, ease: "easeInOut" }}
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
transition={{ duration: 0.7, ease: 'easeInOut' }}
|
||||
style={{ transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
{/* 前面:编辑态 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backfaceVisibility: "hidden",
|
||||
backfaceVisibility: 'hidden',
|
||||
// 编辑态展示时才响应事件,避免与背面点击冲突
|
||||
pointerEvents: submitted ? "none" : "auto",
|
||||
pointerEvents: submitted ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{/* 编辑态:信纸背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/letter/信底")}
|
||||
src={getStaticImage('components/letter/信底')}
|
||||
alt="信纸"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
@@ -136,8 +132,8 @@ const LetterToChenyun = ({
|
||||
<div
|
||||
className="text-white text-6xl font-bold tracking-[20px]"
|
||||
style={{
|
||||
writingMode: "vertical-rl",
|
||||
textOrientation: "upright",
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'upright',
|
||||
}}
|
||||
>
|
||||
寄给陈云的信
|
||||
@@ -148,75 +144,62 @@ const LetterToChenyun = ({
|
||||
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0;
|
||||
let foundNiHao = false;
|
||||
return letterContent
|
||||
.split("\n")
|
||||
.map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes("您好")) {
|
||||
foundNiHao = true;
|
||||
}
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao &&
|
||||
line.trim() !== "" &&
|
||||
!line.includes("您好");
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-6"
|
||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast =
|
||||
partIndex === segments.length - 1;
|
||||
if (isLast) {
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-6"
|
||||
style={needsIndent ? { textIndent: '2em' } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++;
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||
blankErrors[currentIndex]
|
||||
? "border-red-500"
|
||||
: "border-[#4e402e]"
|
||||
}`}
|
||||
value={blankValues[currentIndex] ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...blankValues];
|
||||
next[currentIndex] = e.target.value;
|
||||
setBlankValues(next);
|
||||
const currentIndex = placeholderIndex++
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||
blankErrors[currentIndex]
|
||||
? 'border-red-500'
|
||||
: 'border-[#4e402e]'
|
||||
}`}
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = e.target.value
|
||||
setBlankValues(next)
|
||||
|
||||
// 用户再次输入时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors];
|
||||
nextErrs[currentIndex] = false;
|
||||
setBlankErrors(nextErrs);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
// 用户再次输入时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,7 +207,7 @@ const LetterToChenyun = ({
|
||||
{/* 笔的图片 */}
|
||||
<div className="absolute bottom-[150px] -right-30">
|
||||
<img
|
||||
src={getStaticImage("components/letter/笔")}
|
||||
src={getStaticImage('components/letter/笔')}
|
||||
alt="笔"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
@@ -233,11 +216,11 @@ const LetterToChenyun = ({
|
||||
{/* 提交按钮:切换到只读信件态 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage("components/letter/可提交状态")}
|
||||
src={getStaticImage('components/letter/可提交状态')}
|
||||
alt="可提交状态"
|
||||
onClick={() => {
|
||||
if (!validateBlanks()) return;
|
||||
setSubmitted(true);
|
||||
if (!validateBlanks()) return
|
||||
setSubmitted(true)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
@@ -248,25 +231,25 @@ const LetterToChenyun = ({
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transform: "rotateY(180deg)",
|
||||
backfaceVisibility: "hidden",
|
||||
transform: 'rotateY(180deg)',
|
||||
backfaceVisibility: 'hidden',
|
||||
// 只读态展示时才响应事件
|
||||
pointerEvents: submitted ? "auto" : "none",
|
||||
pointerEvents: submitted ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* 只读态:新背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/letter/信")}
|
||||
src={getStaticImage('components/letter/信')}
|
||||
alt="信件"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
<Image
|
||||
src={getStaticImage("components/close")}
|
||||
src={getStaticImage('components/close')}
|
||||
alt="信纸"
|
||||
className="absolute right-38 top-0"
|
||||
button
|
||||
onClick={() => {
|
||||
setSubmitted(false);
|
||||
setSubmitted(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -274,55 +257,42 @@ const LetterToChenyun = ({
|
||||
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0;
|
||||
let foundNiHao = false;
|
||||
return letterContent
|
||||
.split("\n")
|
||||
.map((line, lineIndex) => {
|
||||
if (line.includes("您好")) {
|
||||
foundNiHao = true;
|
||||
}
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
const needsIndent =
|
||||
foundNiHao &&
|
||||
line.trim() !== "" &&
|
||||
!line.includes("您好");
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-6"
|
||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast =
|
||||
partIndex === segments.length - 1;
|
||||
if (isLast) {
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-6"
|
||||
style={needsIndent ? { textIndent: '2em' } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++;
|
||||
const value = blankValues[currentIndex] ?? "";
|
||||
const currentIndex = placeholderIndex++
|
||||
const value = blankValues[currentIndex] ?? ''
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
{value || " "}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{value || ' '}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,10 +300,10 @@ const LetterToChenyun = ({
|
||||
{/* 寄出按钮:关闭窗口 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage("components/letter/寄出")}
|
||||
src={getStaticImage('components/letter/寄出')}
|
||||
alt="寄出"
|
||||
onClick={() => {
|
||||
onSubmit?.(blankValues);
|
||||
onSubmit?.(blankValues)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
@@ -345,7 +315,7 @@ const LetterToChenyun = ({
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default LetterToChenyun;
|
||||
export default LetterToChenyun
|
||||
@@ -1,108 +1,104 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { Image, Select } from "@/components";
|
||||
import { filterInput } from "@/lib/utils/sensitiveWords";
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { getStaticImage } from '@/lib/utils'
|
||||
import { Image, Select } from '@/components'
|
||||
import { filterInput } from '@/lib/utils/sensitiveWords'
|
||||
|
||||
export interface LetterToHuangyanpeiProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
onClose?: () => void
|
||||
/** 提交回调 */
|
||||
onSubmit?: (values: string[]) => void;
|
||||
onSubmit?: (values: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* LetterToHuangyanpei 组件 - 写给黄炎培先生的信
|
||||
* 包含飞入和放大动画
|
||||
*/
|
||||
const LetterToHuangyanpei = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: LetterToHuangyanpeiProps) => {
|
||||
const LetterToHuangyanpei = ({ visible = false, onClose, onSubmit }: LetterToHuangyanpeiProps) => {
|
||||
// 下划线可填写内容
|
||||
const [blankValues, setBlankValues] = useState<string[]>([]);
|
||||
const [blankValues, setBlankValues] = useState<string[]>([])
|
||||
// 每个空的校验状态:true 表示该空当前不符合要求
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([]);
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
|
||||
|
||||
// 是否已提交:提交后隐藏编辑信件,展示只读信件
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
// 填空类型定义:0=填空1(限填),1=填空2(任填),2=填空3(任填),3=填空4(限填)
|
||||
const BLANK_1_ANSWER = "手脑并用";
|
||||
const BLANK_4_ANSWER = "有业者乐业";
|
||||
const BLANK_1_ANSWER = '手脑并用'
|
||||
const BLANK_4_ANSWER = '有业者乐业'
|
||||
|
||||
// 填空1的选项
|
||||
const BLANK_1_OPTIONS = [
|
||||
"手脑并用",
|
||||
"有业者乐业",
|
||||
'手脑并用',
|
||||
'有业者乐业',
|
||||
// "知行合一",
|
||||
// "学以致用",
|
||||
// "理论实践"
|
||||
];
|
||||
]
|
||||
|
||||
// 填空4的选项
|
||||
const BLANK_4_OPTIONS = [
|
||||
"手脑并用",
|
||||
"有业者乐业",
|
||||
'手脑并用',
|
||||
'有业者乐业',
|
||||
// "有业者敬业",
|
||||
// "有业者精业",
|
||||
// "有业者成业",
|
||||
];
|
||||
]
|
||||
|
||||
// 校验输入:确保所有填空符合标准
|
||||
const validateBlanks = () => {
|
||||
// 计算占位总数,防止有些没被输入过而是 undefined
|
||||
const placeholderCount = (letterContent.match(/____/g) || []).length;
|
||||
const placeholderCount = (letterContent.match(/____/g) || []).length
|
||||
|
||||
const nextErrors: boolean[] = Array(placeholderCount).fill(false);
|
||||
const nextValues: string[] = [...blankValues];
|
||||
let hasError = false;
|
||||
const nextErrors: boolean[] = Array(placeholderCount).fill(false)
|
||||
const nextValues: string[] = [...blankValues]
|
||||
let hasError = false
|
||||
|
||||
for (let i = 0; i < placeholderCount; i++) {
|
||||
const raw = nextValues[i] || "";
|
||||
const v = raw.trim();
|
||||
const raw = nextValues[i] || ''
|
||||
const v = raw.trim()
|
||||
|
||||
if (i === 0) {
|
||||
// 填空1:限填,必须选择"手脑并用"
|
||||
if (!v || v !== BLANK_1_ANSWER) {
|
||||
nextValues[i] = "";
|
||||
nextErrors[i] = true;
|
||||
hasError = true;
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
} else if (i === 1 || i === 2) {
|
||||
// 填空2、3:任填,不能为空,不超过20个字
|
||||
if (!v || v.length > 20) {
|
||||
nextValues[i] = "";
|
||||
nextErrors[i] = true;
|
||||
hasError = true;
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
} else if (i === 3) {
|
||||
// 填空4:限填,必须选择"有业者乐业"
|
||||
if (!v || v !== BLANK_4_ANSWER) {
|
||||
nextValues[i] = "";
|
||||
nextErrors[i] = true;
|
||||
hasError = true;
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBlankValues(nextValues);
|
||||
setBlankErrors(nextErrors);
|
||||
setBlankValues(nextValues)
|
||||
setBlankErrors(nextErrors)
|
||||
|
||||
return !hasError;
|
||||
};
|
||||
return !hasError
|
||||
}
|
||||
|
||||
// 弹窗关闭后重置内容,方便下次重新填写
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSubmitted(false);
|
||||
setBlankValues([]);
|
||||
setBlankErrors([]);
|
||||
setSubmitted(false)
|
||||
setBlankValues([])
|
||||
setBlankErrors([])
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
// 信件内容模板(根据设计稿)
|
||||
const letterContent = `敬爱的黄炎培先生:
|
||||
@@ -115,7 +111,7 @@ const LetterToHuangyanpei = ({
|
||||
|
||||
我是____专业的学生,今后,我会在实训课堂上精益求精,像当年中华职校的学长学姐那样,把技能练到极致;我会在志愿服务中践行担当,用所学知识为身边人、为社会做实事;我更会紧跟时代步伐,努力成为____领域的技能人才,为中国制造升级、为民族产业振兴添砖加瓦!
|
||||
|
||||
请您放心,您播下的职教火种,我们会让它在新时代燃烧得更旺;您毕生追求的"无业者有业、____"的愿景,我们会用青春和汗水去实现!`;
|
||||
请您放心,您播下的职教火种,我们会让它在新时代燃烧得更旺;您毕生追求的"无业者有业、____"的愿景,我们会用青春和汗水去实现!`
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -138,7 +134,7 @@ const LetterToHuangyanpei = ({
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.6,
|
||||
@@ -147,28 +143,28 @@ const LetterToHuangyanpei = ({
|
||||
<div
|
||||
className="relative w-[1200px] h-full pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ perspective: "1200px" }}
|
||||
style={{ perspective: '1200px' }}
|
||||
>
|
||||
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
|
||||
<motion.div
|
||||
className="relative w-full h-full"
|
||||
initial={false}
|
||||
animate={{ rotateY: submitted ? 180 : 0 }}
|
||||
transition={{ duration: 0.7, ease: "easeInOut" }}
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
transition={{ duration: 0.7, ease: 'easeInOut' }}
|
||||
style={{ transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
{/* 前面:编辑态 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backfaceVisibility: "hidden",
|
||||
backfaceVisibility: 'hidden',
|
||||
// 编辑态展示时才响应事件,避免与背面点击冲突
|
||||
pointerEvents: submitted ? "none" : "auto",
|
||||
pointerEvents: submitted ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{/* 编辑态:信纸背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/letter/信底")}
|
||||
src={getStaticImage('components/letter/信底')}
|
||||
alt="信纸"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
@@ -178,8 +174,8 @@ const LetterToHuangyanpei = ({
|
||||
<div
|
||||
className="text-white text-6xl font-bold tracking-[20px]"
|
||||
style={{
|
||||
writingMode: "vertical-rl",
|
||||
textOrientation: "upright",
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'upright',
|
||||
}}
|
||||
>
|
||||
写给黄炎培先生的信
|
||||
@@ -190,121 +186,97 @@ const LetterToHuangyanpei = ({
|
||||
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0;
|
||||
let foundNiHao = false;
|
||||
return letterContent
|
||||
.split("\n")
|
||||
.map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes("您好")) {
|
||||
foundNiHao = true;
|
||||
}
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao &&
|
||||
line.trim() !== "" &&
|
||||
!line.includes("您好");
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast =
|
||||
partIndex === segments.length - 1;
|
||||
if (isLast) {
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div key={lineIndex} style={needsIndent ? { textIndent: '2em' } : {}}>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++;
|
||||
const isLimitedFill =
|
||||
currentIndex === 0 || currentIndex === 3; // 填空1和填空4为限填
|
||||
const options =
|
||||
currentIndex === 0
|
||||
? BLANK_1_OPTIONS
|
||||
: BLANK_4_OPTIONS;
|
||||
const currentIndex = placeholderIndex++
|
||||
const isLimitedFill = currentIndex === 0 || currentIndex === 3 // 填空1和填空4为限填
|
||||
const options =
|
||||
currentIndex === 0 ? BLANK_1_OPTIONS : BLANK_4_OPTIONS
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
{isLimitedFill ? (
|
||||
// 限填:下拉选择
|
||||
<Select
|
||||
className="inline"
|
||||
options={options.map((opt) => ({
|
||||
value: opt,
|
||||
label: opt,
|
||||
}))}
|
||||
value={
|
||||
blankValues[currentIndex] ?? ""
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{isLimitedFill ? (
|
||||
// 限填:下拉选择
|
||||
<Select
|
||||
className="inline"
|
||||
options={options.map((opt) => ({
|
||||
value: opt,
|
||||
label: opt,
|
||||
}))}
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
onChange={(selectedValue) => {
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = selectedValue
|
||||
setBlankValues(next)
|
||||
|
||||
// 用户再次选择时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
onChange={(selectedValue) => {
|
||||
const next = [...blankValues];
|
||||
next[currentIndex] = selectedValue;
|
||||
setBlankValues(next);
|
||||
}}
|
||||
placeholder="请选择"
|
||||
error={blankErrors[currentIndex]}
|
||||
zIndex={10000}
|
||||
fitContent={true}
|
||||
/>
|
||||
) : (
|
||||
// 任填:文本输入,限制20个字,敏感词过滤
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||
blankErrors[currentIndex]
|
||||
? 'border-red-500'
|
||||
: 'border-[#4e402e]'
|
||||
}`}
|
||||
autoComplete="off"
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
maxLength={20}
|
||||
onChange={(e) => {
|
||||
const rawValue = e.target.value
|
||||
// 应用敏感词过滤
|
||||
const filteredValue = filterInput(rawValue)
|
||||
|
||||
// 用户再次选择时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors];
|
||||
nextErrs[currentIndex] = false;
|
||||
setBlankErrors(nextErrs);
|
||||
}
|
||||
}}
|
||||
placeholder="请选择"
|
||||
error={blankErrors[currentIndex]}
|
||||
zIndex={10000}
|
||||
fitContent={true}
|
||||
/>
|
||||
) : (
|
||||
// 任填:文本输入,限制20个字,敏感词过滤
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||
blankErrors[currentIndex]
|
||||
? "border-red-500"
|
||||
: "border-[#4e402e]"
|
||||
}`}
|
||||
autoComplete="off"
|
||||
value={
|
||||
blankValues[currentIndex] ?? ""
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = filteredValue
|
||||
setBlankValues(next)
|
||||
|
||||
// 用户再次输入时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
maxLength={20}
|
||||
onChange={(e) => {
|
||||
const rawValue = e.target.value;
|
||||
// 应用敏感词过滤
|
||||
const filteredValue =
|
||||
filterInput(rawValue);
|
||||
|
||||
const next = [...blankValues];
|
||||
next[currentIndex] = filteredValue;
|
||||
setBlankValues(next);
|
||||
|
||||
// 用户再次输入时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors];
|
||||
nextErrs[currentIndex] = false;
|
||||
setBlankErrors(nextErrs);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,7 +284,7 @@ const LetterToHuangyanpei = ({
|
||||
{/* 笔的图片 */}
|
||||
<div className="absolute bottom-[150px] -right-30">
|
||||
<img
|
||||
src={getStaticImage("components/letter/笔")}
|
||||
src={getStaticImage('components/letter/笔')}
|
||||
alt="笔"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
@@ -321,11 +293,11 @@ const LetterToHuangyanpei = ({
|
||||
{/* 提交按钮:切换到只读信件态 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage("components/letter/可提交状态")}
|
||||
src={getStaticImage('components/letter/可提交状态')}
|
||||
alt="可提交状态"
|
||||
onClick={() => {
|
||||
if (!validateBlanks()) return;
|
||||
setSubmitted(true);
|
||||
if (!validateBlanks()) return
|
||||
setSubmitted(true)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
@@ -336,25 +308,25 @@ const LetterToHuangyanpei = ({
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transform: "rotateY(180deg)",
|
||||
backfaceVisibility: "hidden",
|
||||
transform: 'rotateY(180deg)',
|
||||
backfaceVisibility: 'hidden',
|
||||
// 只读态展示时才响应事件
|
||||
pointerEvents: submitted ? "auto" : "none",
|
||||
pointerEvents: submitted ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* 只读态:新背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/letter/信")}
|
||||
src={getStaticImage('components/letter/信')}
|
||||
alt="信件"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
<Image
|
||||
src={getStaticImage("components/close")}
|
||||
src={getStaticImage('components/close')}
|
||||
alt="信纸"
|
||||
className="absolute right-38 top-0"
|
||||
button
|
||||
onClick={() => {
|
||||
setSubmitted(false);
|
||||
setSubmitted(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -362,54 +334,38 @@ const LetterToHuangyanpei = ({
|
||||
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0;
|
||||
let foundNiHao = false;
|
||||
return letterContent
|
||||
.split("\n")
|
||||
.map((line, lineIndex) => {
|
||||
if (line.includes("您好")) {
|
||||
foundNiHao = true;
|
||||
}
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
const needsIndent =
|
||||
foundNiHao &&
|
||||
line.trim() !== "" &&
|
||||
!line.includes("您好");
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast =
|
||||
partIndex === segments.length - 1;
|
||||
if (isLast) {
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div key={lineIndex} style={needsIndent ? { textIndent: '2em' } : {}}>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++;
|
||||
const value = blankValues[currentIndex] ?? "";
|
||||
const currentIndex = placeholderIndex++
|
||||
const value = blankValues[currentIndex] ?? ''
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
{value || " "}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{value || ' '}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -417,12 +373,12 @@ const LetterToHuangyanpei = ({
|
||||
{/* 寄出按钮:关闭窗口 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage("components/letter/寄出")}
|
||||
src={getStaticImage('components/letter/寄出')}
|
||||
alt="寄出"
|
||||
onClick={() => {
|
||||
// 寄出前再次校验,确保所有填空符合标准
|
||||
if (validateBlanks()) {
|
||||
onSubmit?.(blankValues);
|
||||
onSubmit?.(blankValues)
|
||||
}
|
||||
}}
|
||||
button
|
||||
@@ -435,7 +391,7 @@ const LetterToHuangyanpei = ({
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default LetterToHuangyanpei;
|
||||
export default LetterToHuangyanpei
|
||||
@@ -1,103 +1,99 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { Image, Select } from "@/components";
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { getStaticImage } from '@/lib/utils'
|
||||
import { Image, Select } from '@/components'
|
||||
|
||||
export interface LetterToLuxunProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
onClose?: () => void
|
||||
/** 提交回调 */
|
||||
onSubmit?: (values: string[]) => void;
|
||||
onSubmit?: (values: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* LetterToLuxun 组件 - 写给鲁迅先生的信
|
||||
* 包含飞入和放大动画
|
||||
*/
|
||||
const LetterToLuxun = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: LetterToLuxunProps) => {
|
||||
const LetterToLuxun = ({ visible = false, onClose, onSubmit }: LetterToLuxunProps) => {
|
||||
// 下划线可填写内容
|
||||
const [blankValues, setBlankValues] = useState<string[]>([]);
|
||||
const [blankValues, setBlankValues] = useState<string[]>([])
|
||||
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([]);
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
|
||||
|
||||
// 是否已提交:提交后隐藏编辑信件,展示只读信件
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
// 填空类型定义:0=填空1(限填),1=填空2(限填),其他=任填
|
||||
const BLANK_1_ANSWER = "首在立人";
|
||||
const BLANK_2_ANSWER = "批判求真";
|
||||
const BLANK_1_ANSWER = '首在立人'
|
||||
const BLANK_2_ANSWER = '批判求真'
|
||||
|
||||
// 填空1的选项
|
||||
const BLANK_1_OPTIONS = [
|
||||
"首在立人",
|
||||
"批判求真",
|
||||
'首在立人',
|
||||
'批判求真',
|
||||
// 可以添加其他选项
|
||||
];
|
||||
]
|
||||
|
||||
// 填空2的选项
|
||||
const BLANK_2_OPTIONS = [
|
||||
"首在立人",
|
||||
"批判求真",
|
||||
'首在立人',
|
||||
'批判求真',
|
||||
// 可以添加其他选项
|
||||
];
|
||||
]
|
||||
|
||||
// 校验输入:确保所有填空符合标准
|
||||
const validateBlanks = () => {
|
||||
// 计算占位总数,防止有些没被输入过而是 undefined
|
||||
const placeholderCount = (letterContent.match(/____/g) || []).length;
|
||||
const placeholderCount = (letterContent.match(/____/g) || []).length
|
||||
|
||||
const nextErrors: boolean[] = Array(placeholderCount).fill(false);
|
||||
const nextValues: string[] = [...blankValues];
|
||||
let hasError = false;
|
||||
const nextErrors: boolean[] = Array(placeholderCount).fill(false)
|
||||
const nextValues: string[] = [...blankValues]
|
||||
let hasError = false
|
||||
|
||||
for (let i = 0; i < placeholderCount; i++) {
|
||||
const raw = nextValues[i] || "";
|
||||
const v = raw.trim();
|
||||
const raw = nextValues[i] || ''
|
||||
const v = raw.trim()
|
||||
|
||||
if (i === 0) {
|
||||
// 填空1:限填,必须选择"首在立人"
|
||||
if (!v || v !== BLANK_1_ANSWER) {
|
||||
nextValues[i] = "";
|
||||
nextErrors[i] = true;
|
||||
hasError = true;
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
} else if (i === 1) {
|
||||
// 填空2:限填,必须选择"批判求真"
|
||||
if (!v || v !== BLANK_2_ANSWER) {
|
||||
nextValues[i] = "";
|
||||
nextErrors[i] = true;
|
||||
hasError = true;
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
} else {
|
||||
// 其他填空:任填,不能为空,不超过 10 个字
|
||||
if (!v || v.length > 10) {
|
||||
nextValues[i] = "";
|
||||
nextErrors[i] = true;
|
||||
hasError = true;
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBlankValues(nextValues);
|
||||
setBlankErrors(nextErrors);
|
||||
setBlankValues(nextValues)
|
||||
setBlankErrors(nextErrors)
|
||||
|
||||
return !hasError;
|
||||
};
|
||||
return !hasError
|
||||
}
|
||||
|
||||
// 弹窗关闭后重置内容,方便下次重新填写
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSubmitted(false);
|
||||
setBlankValues([]);
|
||||
setBlankErrors([]);
|
||||
setSubmitted(false)
|
||||
setBlankValues([])
|
||||
setBlankErrors([])
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
// 信件内容模板(根据设计稿)
|
||||
const letterContent = `敬爱的鲁迅先生:
|
||||
@@ -108,7 +104,7 @@ const LetterToLuxun = ({
|
||||
|
||||
我是____专业的学生,今后,我会以您"____千夫指"的骨气,在是非面前坚守立场,不随波逐流;我会以您"俯首甘为____"的热忱,参与____活动,用微光温暖他人;我更会记住您"____,走的人多了,便成了路"的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
|
||||
|
||||
请您放心,您播下的"立人"火种,我们会让它在新时代燎原;您毕生追求的"人国"愿景,我们会用青春和奋斗去守护!`;
|
||||
请您放心,您播下的"立人"火种,我们会让它在新时代燎原;您毕生追求的"人国"愿景,我们会用青春和奋斗去守护!`
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -131,7 +127,7 @@ const LetterToLuxun = ({
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.6,
|
||||
@@ -140,28 +136,28 @@ const LetterToLuxun = ({
|
||||
<div
|
||||
className="relative w-[1200px] h-full pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ perspective: "1200px" }}
|
||||
style={{ perspective: '1200px' }}
|
||||
>
|
||||
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
|
||||
<motion.div
|
||||
className="relative w-full h-full"
|
||||
initial={false}
|
||||
animate={{ rotateY: submitted ? 180 : 0 }}
|
||||
transition={{ duration: 0.7, ease: "easeInOut" }}
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
transition={{ duration: 0.7, ease: 'easeInOut' }}
|
||||
style={{ transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
{/* 前面:编辑态 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backfaceVisibility: "hidden",
|
||||
backfaceVisibility: 'hidden',
|
||||
// 编辑态展示时才响应事件,避免与背面点击冲突
|
||||
pointerEvents: submitted ? "none" : "auto",
|
||||
pointerEvents: submitted ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{/* 编辑态:信纸背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/letter/信底")}
|
||||
src={getStaticImage('components/letter/信底')}
|
||||
alt="信纸"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
@@ -171,8 +167,8 @@ const LetterToLuxun = ({
|
||||
<div
|
||||
className="text-white text-6xl font-bold tracking-[20px]"
|
||||
style={{
|
||||
writingMode: "vertical-rl",
|
||||
textOrientation: "upright",
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'upright',
|
||||
}}
|
||||
>
|
||||
写给鲁迅先生的信
|
||||
@@ -183,115 +179,95 @@ const LetterToLuxun = ({
|
||||
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0;
|
||||
let foundNiHao = false;
|
||||
return letterContent
|
||||
.split("\n")
|
||||
.map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes("您好")) {
|
||||
foundNiHao = true;
|
||||
}
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao &&
|
||||
line.trim() !== "" &&
|
||||
!line.includes("您好");
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-2"
|
||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast =
|
||||
partIndex === segments.length - 1;
|
||||
if (isLast) {
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-2"
|
||||
style={needsIndent ? { textIndent: '2em' } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++;
|
||||
const isLimitedFill =
|
||||
currentIndex === 0 || currentIndex === 1; // 填空1和填空2为限填
|
||||
const options =
|
||||
currentIndex === 0
|
||||
? BLANK_1_OPTIONS
|
||||
: BLANK_2_OPTIONS;
|
||||
const currentIndex = placeholderIndex++
|
||||
const isLimitedFill = currentIndex === 0 || currentIndex === 1 // 填空1和填空2为限填
|
||||
const options =
|
||||
currentIndex === 0 ? BLANK_1_OPTIONS : BLANK_2_OPTIONS
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
{isLimitedFill ? (
|
||||
// 限填:下拉选择
|
||||
<Select
|
||||
className="inline"
|
||||
options={options.map((opt) => ({
|
||||
value: opt,
|
||||
label: opt,
|
||||
}))}
|
||||
value={
|
||||
blankValues[currentIndex] ?? ""
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{isLimitedFill ? (
|
||||
// 限填:下拉选择
|
||||
<Select
|
||||
className="inline"
|
||||
options={options.map((opt) => ({
|
||||
value: opt,
|
||||
label: opt,
|
||||
}))}
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
onChange={(selectedValue) => {
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = selectedValue
|
||||
setBlankValues(next)
|
||||
|
||||
// 用户再次选择时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
onChange={(selectedValue) => {
|
||||
const next = [...blankValues];
|
||||
next[currentIndex] = selectedValue;
|
||||
setBlankValues(next);
|
||||
}}
|
||||
placeholder="请选择"
|
||||
error={blankErrors[currentIndex]}
|
||||
zIndex={10000}
|
||||
fitContent={true}
|
||||
/>
|
||||
) : (
|
||||
// 任填:文本输入
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block w-[100px] h-[36px] mx-2 mb-2 align-middle bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||
blankErrors[currentIndex]
|
||||
? 'border-red-500'
|
||||
: 'border-[#4e402e]'
|
||||
}`}
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = e.target.value
|
||||
setBlankValues(next)
|
||||
|
||||
// 用户再次选择时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors];
|
||||
nextErrs[currentIndex] = false;
|
||||
setBlankErrors(nextErrs);
|
||||
}
|
||||
}}
|
||||
placeholder="请选择"
|
||||
error={blankErrors[currentIndex]}
|
||||
zIndex={10000}
|
||||
fitContent={true}
|
||||
/>
|
||||
) : (
|
||||
// 任填:文本输入
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block w-[100px] h-[36px] mx-2 mb-2 align-middle bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||
blankErrors[currentIndex]
|
||||
? "border-red-500"
|
||||
: "border-[#4e402e]"
|
||||
}`}
|
||||
value={
|
||||
blankValues[currentIndex] ?? ""
|
||||
// 用户再次输入时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const next = [...blankValues];
|
||||
next[currentIndex] = e.target.value;
|
||||
setBlankValues(next);
|
||||
|
||||
// 用户再次输入时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors];
|
||||
nextErrs[currentIndex] = false;
|
||||
setBlankErrors(nextErrs);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,7 +275,7 @@ const LetterToLuxun = ({
|
||||
{/* 笔的图片 */}
|
||||
<div className="absolute bottom-[150px] -right-30">
|
||||
<img
|
||||
src={getStaticImage("components/letter/笔")}
|
||||
src={getStaticImage('components/letter/笔')}
|
||||
alt="笔"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
@@ -308,11 +284,11 @@ const LetterToLuxun = ({
|
||||
{/* 提交按钮:切换到只读信件态 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage("components/letter/可提交状态")}
|
||||
src={getStaticImage('components/letter/可提交状态')}
|
||||
alt="可提交状态"
|
||||
onClick={() => {
|
||||
if (!validateBlanks()) return;
|
||||
setSubmitted(true);
|
||||
if (!validateBlanks()) return
|
||||
setSubmitted(true)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
@@ -332,25 +308,25 @@ const LetterToLuxun = ({
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transform: "rotateY(180deg)",
|
||||
backfaceVisibility: "hidden",
|
||||
transform: 'rotateY(180deg)',
|
||||
backfaceVisibility: 'hidden',
|
||||
// 只读态展示时才响应事件
|
||||
pointerEvents: submitted ? "auto" : "none",
|
||||
pointerEvents: submitted ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* 只读态:新背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/letter/信")}
|
||||
src={getStaticImage('components/letter/信')}
|
||||
alt="信件"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
<Image
|
||||
src={getStaticImage("components/close")}
|
||||
src={getStaticImage('components/close')}
|
||||
alt="信纸"
|
||||
className="absolute right-38 top-0"
|
||||
button
|
||||
onClick={() => {
|
||||
setSubmitted(false);
|
||||
setSubmitted(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -358,55 +334,42 @@ const LetterToLuxun = ({
|
||||
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0;
|
||||
let foundNiHao = false;
|
||||
return letterContent
|
||||
.split("\n")
|
||||
.map((line, lineIndex) => {
|
||||
if (line.includes("您好")) {
|
||||
foundNiHao = true;
|
||||
}
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
const needsIndent =
|
||||
foundNiHao &&
|
||||
line.trim() !== "" &&
|
||||
!line.includes("您好");
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-2"
|
||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast =
|
||||
partIndex === segments.length - 1;
|
||||
if (isLast) {
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-2"
|
||||
style={needsIndent ? { textIndent: '2em' } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++;
|
||||
const value = blankValues[currentIndex] ?? "";
|
||||
const currentIndex = placeholderIndex++
|
||||
const value = blankValues[currentIndex] ?? ''
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
{value || " "}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{value || ' '}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -414,10 +377,10 @@ const LetterToLuxun = ({
|
||||
{/* 寄出按钮:关闭窗口 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage("components/letter/寄出")}
|
||||
src={getStaticImage('components/letter/寄出')}
|
||||
alt="寄出"
|
||||
onClick={() => {
|
||||
onSubmit?.(blankValues);
|
||||
onSubmit?.(blankValues)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
@@ -429,7 +392,7 @@ const LetterToLuxun = ({
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default LetterToLuxun;
|
||||
export default LetterToLuxun
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { Image, Select } from "@/components";
|
||||
import { ErrorAlert } from "@/components/layout";
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { getStaticImage } from '@/lib/utils'
|
||||
import { Image, Select } from '@/components'
|
||||
import { ErrorAlert } from '@/components/layout'
|
||||
|
||||
export interface LetterToSunzhongshanProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
onClose?: () => void
|
||||
/** 提交回调 */
|
||||
onSubmit?: (values: string[]) => void;
|
||||
onSubmit?: (values: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,84 +23,84 @@ const LetterToSunzhongshan = ({
|
||||
onSubmit,
|
||||
}: LetterToSunzhongshanProps) => {
|
||||
// 下划线可填写内容
|
||||
const [blankValues, setBlankValues] = useState<string[]>([]);
|
||||
const [blankValues, setBlankValues] = useState<string[]>([])
|
||||
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([]);
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
|
||||
|
||||
// 是否已提交:提交后隐藏编辑信件,展示只读信件
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
// ErrorAlert 相关状态
|
||||
const [showErrorAlert, setShowErrorAlert] = useState(false);
|
||||
const [errorAlertText, setErrorAlertText] = useState("");
|
||||
const [showErrorAlert, setShowErrorAlert] = useState(false)
|
||||
const [errorAlertText, setErrorAlertText] = useState('')
|
||||
|
||||
// 前三个空的正确答案
|
||||
const BLANK_0_ANSWER = "振兴中华";
|
||||
const BLANK_1_ANSWER = "天下为公";
|
||||
const BLANK_2_ANSWER = "与时俱进";
|
||||
const BLANK_0_ANSWER = '振兴中华'
|
||||
const BLANK_1_ANSWER = '天下为公'
|
||||
const BLANK_2_ANSWER = '与时俱进'
|
||||
// 最后一个空的正确答案
|
||||
const LAST_BLANK_ANSWER = "仍需努力";
|
||||
const LAST_BLANK_ANSWER = '仍需努力'
|
||||
|
||||
// 前三个空的选项(所有空都用这三个选项)
|
||||
const SELECT_OPTIONS = [
|
||||
{ value: "振兴中华", label: "振兴中华" },
|
||||
{ value: "天下为公", label: "天下为公" },
|
||||
{ value: "与时俱进", label: "与时俱进" },
|
||||
];
|
||||
{ value: '振兴中华', label: '振兴中华' },
|
||||
{ value: '天下为公', label: '天下为公' },
|
||||
{ value: '与时俱进', label: '与时俱进' },
|
||||
]
|
||||
|
||||
// 校验输入:前三个空必须选择正确答案,最后一个空必须是"仍需努力",其他空不能为空且不超过 10 个字符
|
||||
const validateBlanks = () => {
|
||||
// 计算占位总数,防止有些没被输入过而是 undefined
|
||||
const placeholderCount = (letterContent.match(/____/g) || []).length;
|
||||
const placeholderCount = (letterContent.match(/____/g) || []).length
|
||||
|
||||
const nextErrors: boolean[] = Array(placeholderCount).fill(false);
|
||||
const nextValues: string[] = [...blankValues];
|
||||
let hasError = false;
|
||||
const nextErrors: boolean[] = Array(placeholderCount).fill(false)
|
||||
const nextValues: string[] = [...blankValues]
|
||||
let hasError = false
|
||||
|
||||
for (let i = 0; i < placeholderCount; i++) {
|
||||
const raw = blankValues[i] || "";
|
||||
const v = raw.trim();
|
||||
const raw = blankValues[i] || ''
|
||||
const v = raw.trim()
|
||||
|
||||
if (i < 3) {
|
||||
// 前三个空:必须选择正确答案(已在 onChange 中验证,这里只检查是否为空)
|
||||
if (!v) {
|
||||
nextValues[i] = "";
|
||||
nextErrors[i] = true;
|
||||
hasError = true;
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
} else if (i === placeholderCount - 1) {
|
||||
// 最后一个空:必须是"仍需努力"
|
||||
if (!v || v !== LAST_BLANK_ANSWER) {
|
||||
nextValues[i] = "";
|
||||
nextErrors[i] = true;
|
||||
hasError = true;
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
} else {
|
||||
// 其他空:不能为空,不超过 10 个字
|
||||
if (!v || v.length > 10) {
|
||||
nextValues[i] = "";
|
||||
nextErrors[i] = true;
|
||||
hasError = true;
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBlankValues(nextValues);
|
||||
setBlankErrors(nextErrors);
|
||||
setBlankValues(nextValues)
|
||||
setBlankErrors(nextErrors)
|
||||
|
||||
return !hasError;
|
||||
};
|
||||
return !hasError
|
||||
}
|
||||
|
||||
// 弹窗关闭后重置内容,方便下次重新填写
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSubmitted(false);
|
||||
setBlankValues([]);
|
||||
setBlankErrors([]);
|
||||
setShowErrorAlert(false);
|
||||
setErrorAlertText("");
|
||||
setSubmitted(false)
|
||||
setBlankValues([])
|
||||
setBlankErrors([])
|
||||
setShowErrorAlert(false)
|
||||
setErrorAlertText('')
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
// 信件内容模板(根据设计稿)
|
||||
const letterContent = `敬爱的孙中山先生:
|
||||
@@ -111,7 +111,7 @@ const LetterToSunzhongshan = ({
|
||||
|
||||
我是____专业的学生,今后,我会以您“知难行易”的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您“心系民众”的情怀,参与____,帮身边人解决实际困难;我更会记住您“适乎世界之潮流”的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。
|
||||
|
||||
请您放心,“革命尚未成功,同志____”的嘱托,我们从未忘记;“振兴中华”的初心,我们会用一生践行!`;
|
||||
请您放心,“革命尚未成功,同志____”的嘱托,我们从未忘记;“振兴中华”的初心,我们会用一生践行!`
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -134,7 +134,7 @@ const LetterToSunzhongshan = ({
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.6,
|
||||
@@ -143,28 +143,28 @@ const LetterToSunzhongshan = ({
|
||||
<div
|
||||
className="relative w-[1200px] h-full pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ perspective: "1200px" }}
|
||||
style={{ perspective: '1200px' }}
|
||||
>
|
||||
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
|
||||
<motion.div
|
||||
className="relative w-full h-full"
|
||||
initial={false}
|
||||
animate={{ rotateY: submitted ? 180 : 0 }}
|
||||
transition={{ duration: 0.7, ease: "easeInOut" }}
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
transition={{ duration: 0.7, ease: 'easeInOut' }}
|
||||
style={{ transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
{/* 前面:编辑态 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backfaceVisibility: "hidden",
|
||||
backfaceVisibility: 'hidden',
|
||||
// 编辑态展示时才响应事件,避免与背面点击冲突
|
||||
pointerEvents: submitted ? "none" : "auto",
|
||||
pointerEvents: submitted ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{/* 编辑态:信纸背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/letter/信底")}
|
||||
src={getStaticImage('components/letter/信底')}
|
||||
alt="信纸"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
@@ -174,8 +174,8 @@ const LetterToSunzhongshan = ({
|
||||
<div
|
||||
className="text-white text-6xl font-bold tracking-[20px]"
|
||||
style={{
|
||||
writingMode: "vertical-rl",
|
||||
textOrientation: "upright",
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'upright',
|
||||
}}
|
||||
>
|
||||
写给孙中山先生的信
|
||||
@@ -186,134 +186,117 @@ const LetterToSunzhongshan = ({
|
||||
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0;
|
||||
let foundNiHao = false;
|
||||
return letterContent
|
||||
.split("\n")
|
||||
.map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes("您好")) {
|
||||
foundNiHao = true;
|
||||
}
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao &&
|
||||
line.trim() !== "" &&
|
||||
!line.includes("您好");
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-2"
|
||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast =
|
||||
partIndex === segments.length - 1;
|
||||
if (isLast) {
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-2"
|
||||
style={needsIndent ? { textIndent: '2em' } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++;
|
||||
const isSelectFill = currentIndex < 3; // 前三个空使用 Select
|
||||
const currentIndex = placeholderIndex++
|
||||
const isSelectFill = currentIndex < 3 // 前三个空使用 Select
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
{isSelectFill ? (
|
||||
// 前三个空:下拉选择
|
||||
<Select
|
||||
className="inline"
|
||||
options={SELECT_OPTIONS}
|
||||
value={
|
||||
blankValues[currentIndex] ?? ""
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{isSelectFill ? (
|
||||
// 前三个空:下拉选择
|
||||
<Select
|
||||
className="inline"
|
||||
options={SELECT_OPTIONS}
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
onChange={(selectedValue) => {
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = selectedValue
|
||||
setBlankValues(next)
|
||||
|
||||
// 清除错误状态
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
onChange={(selectedValue) => {
|
||||
const next = [...blankValues];
|
||||
next[currentIndex] = selectedValue;
|
||||
setBlankValues(next);
|
||||
|
||||
// 清除错误状态
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors];
|
||||
nextErrs[currentIndex] = false;
|
||||
setBlankErrors(nextErrs);
|
||||
}
|
||||
// 立即验证选择是否正确
|
||||
// let correctAnswer = "";
|
||||
// if (currentIndex === 0) {
|
||||
// correctAnswer = BLANK_0_ANSWER;
|
||||
// } else if (currentIndex === 1) {
|
||||
// correctAnswer = BLANK_1_ANSWER;
|
||||
// } else if (currentIndex === 2) {
|
||||
// correctAnswer = BLANK_2_ANSWER;
|
||||
// }
|
||||
|
||||
// 立即验证选择是否正确
|
||||
// let correctAnswer = "";
|
||||
// if (currentIndex === 0) {
|
||||
// correctAnswer = BLANK_0_ANSWER;
|
||||
// } else if (currentIndex === 1) {
|
||||
// correctAnswer = BLANK_1_ANSWER;
|
||||
// } else if (currentIndex === 2) {
|
||||
// correctAnswer = BLANK_2_ANSWER;
|
||||
// }
|
||||
// if (
|
||||
// correctAnswer &&
|
||||
// selectedValue !== correctAnswer
|
||||
// ) {
|
||||
// // 选择错误,显示错误提示
|
||||
// setErrorAlertText(
|
||||
// "请选择正确答案"
|
||||
// );
|
||||
// setShowErrorAlert(true);
|
||||
// // 清空选择
|
||||
// next[currentIndex] = "";
|
||||
// setBlankValues(next);
|
||||
// const nextErrs = [...blankErrors];
|
||||
// nextErrs[currentIndex] = true;
|
||||
// setBlankErrors(nextErrs);
|
||||
// }
|
||||
}}
|
||||
placeholder="请选择"
|
||||
error={blankErrors[currentIndex]}
|
||||
zIndex={10000}
|
||||
fitContent={true}
|
||||
/>
|
||||
) : (
|
||||
// 其他空:文本输入
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block w-[110px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||
blankErrors[currentIndex]
|
||||
? 'border-red-500'
|
||||
: 'border-[#4e402e]'
|
||||
}`}
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = e.target.value
|
||||
setBlankValues(next)
|
||||
|
||||
// if (
|
||||
// correctAnswer &&
|
||||
// selectedValue !== correctAnswer
|
||||
// ) {
|
||||
// // 选择错误,显示错误提示
|
||||
// setErrorAlertText(
|
||||
// "请选择正确答案"
|
||||
// );
|
||||
// setShowErrorAlert(true);
|
||||
// // 清空选择
|
||||
// next[currentIndex] = "";
|
||||
// setBlankValues(next);
|
||||
// const nextErrs = [...blankErrors];
|
||||
// nextErrs[currentIndex] = true;
|
||||
// setBlankErrors(nextErrs);
|
||||
// }
|
||||
}}
|
||||
placeholder="请选择"
|
||||
error={blankErrors[currentIndex]}
|
||||
zIndex={10000}
|
||||
fitContent={true}
|
||||
/>
|
||||
) : (
|
||||
// 其他空:文本输入
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block w-[110px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||
blankErrors[currentIndex]
|
||||
? "border-red-500"
|
||||
: "border-[#4e402e]"
|
||||
}`}
|
||||
value={
|
||||
blankValues[currentIndex] ?? ""
|
||||
// 用户再次输入时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const next = [...blankValues];
|
||||
next[currentIndex] = e.target.value;
|
||||
setBlankValues(next);
|
||||
|
||||
// 用户再次输入时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors];
|
||||
nextErrs[currentIndex] = false;
|
||||
setBlankErrors(nextErrs);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,7 +304,7 @@ const LetterToSunzhongshan = ({
|
||||
{/* 笔的图片 */}
|
||||
<div className="absolute bottom-[150px] -right-30">
|
||||
<img
|
||||
src={getStaticImage("components/letter/笔")}
|
||||
src={getStaticImage('components/letter/笔')}
|
||||
alt="笔"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
@@ -330,11 +313,11 @@ const LetterToSunzhongshan = ({
|
||||
{/* 提交按钮:切换到只读信件态 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage("components/letter/可提交状态")}
|
||||
src={getStaticImage('components/letter/可提交状态')}
|
||||
alt="可提交状态"
|
||||
onClick={() => {
|
||||
if (!validateBlanks()) return;
|
||||
setSubmitted(true);
|
||||
if (!validateBlanks()) return
|
||||
setSubmitted(true)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
@@ -354,25 +337,25 @@ const LetterToSunzhongshan = ({
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transform: "rotateY(180deg)",
|
||||
backfaceVisibility: "hidden",
|
||||
transform: 'rotateY(180deg)',
|
||||
backfaceVisibility: 'hidden',
|
||||
// 只读态展示时才响应事件
|
||||
pointerEvents: submitted ? "auto" : "none",
|
||||
pointerEvents: submitted ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* 只读态:新背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/letter/信")}
|
||||
src={getStaticImage('components/letter/信')}
|
||||
alt="信件"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
<Image
|
||||
src={getStaticImage("components/close")}
|
||||
src={getStaticImage('components/close')}
|
||||
alt="信纸"
|
||||
className="absolute right-38 top-0"
|
||||
button
|
||||
onClick={() => {
|
||||
setSubmitted(false);
|
||||
setSubmitted(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -380,55 +363,42 @@ const LetterToSunzhongshan = ({
|
||||
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0;
|
||||
let foundNiHao = false;
|
||||
return letterContent
|
||||
.split("\n")
|
||||
.map((line, lineIndex) => {
|
||||
if (line.includes("您好")) {
|
||||
foundNiHao = true;
|
||||
}
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
const needsIndent =
|
||||
foundNiHao &&
|
||||
line.trim() !== "" &&
|
||||
!line.includes("您好");
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-2"
|
||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast =
|
||||
partIndex === segments.length - 1;
|
||||
if (isLast) {
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-2"
|
||||
style={needsIndent ? { textIndent: '2em' } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++;
|
||||
const value = blankValues[currentIndex] ?? "";
|
||||
const currentIndex = placeholderIndex++
|
||||
const value = blankValues[currentIndex] ?? ''
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
{value || " "}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{value || ' '}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -436,10 +406,10 @@ const LetterToSunzhongshan = ({
|
||||
{/* 寄出按钮:关闭窗口 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage("components/letter/寄出")}
|
||||
src={getStaticImage('components/letter/寄出')}
|
||||
alt="寄出"
|
||||
onClick={() => {
|
||||
onSubmit?.(blankValues);
|
||||
onSubmit?.(blankValues)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
@@ -460,7 +430,7 @@ const LetterToSunzhongshan = ({
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default LetterToSunzhongshan;
|
||||
export default LetterToSunzhongshan
|
||||
@@ -1,11 +1,11 @@
|
||||
export { default as LetterToChenyun } from "./LetterToChenyun";
|
||||
export type { LetterToChenyunProps } from "./LetterToChenyun";
|
||||
export { default as LetterToChenyun } from './LetterToChenyun'
|
||||
export type { LetterToChenyunProps } from './LetterToChenyun'
|
||||
|
||||
export { default as LetterToSunzhongshan } from "./LetterToSunzhongshan";
|
||||
export type { LetterToSunzhongshanProps } from "./LetterToSunzhongshan";
|
||||
export { default as LetterToSunzhongshan } from './LetterToSunzhongshan'
|
||||
export type { LetterToSunzhongshanProps } from './LetterToSunzhongshan'
|
||||
|
||||
export { default as LetterToHuangyanpei } from "./LetterToHuangyanpei";
|
||||
export type { LetterToHuangyanpeiProps } from "./LetterToHuangyanpei";
|
||||
export { default as LetterToHuangyanpei } from './LetterToHuangyanpei'
|
||||
export type { LetterToHuangyanpeiProps } from './LetterToHuangyanpei'
|
||||
|
||||
export { default as LetterToLuxun } from "./LetterToLuxun";
|
||||
export type { LetterToLuxunProps } from "./LetterToLuxun";
|
||||
export { default as LetterToLuxun } from './LetterToLuxun'
|
||||
export type { LetterToLuxunProps } from './LetterToLuxun'
|
||||
@@ -1,26 +1,26 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAudioManager } from '@/hooks/useAudioManager'
|
||||
|
||||
export interface NarrationProps {
|
||||
/** 旁白文本 */
|
||||
text: string;
|
||||
text: string
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
onClose: () => void
|
||||
/** 语音播报的音频文件路径(可选) */
|
||||
audioSrc?: string;
|
||||
audioSrc?: string
|
||||
/** 自定义样式类名 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 是否启用逐字动画,默认 true */
|
||||
enableAnimation?: boolean;
|
||||
enableAnimation?: boolean
|
||||
/** 文字动画速度(毫秒/字),默认 100ms */
|
||||
animationSpeed?: number;
|
||||
animationSpeed?: number
|
||||
/** 覆盖层出现时是否自动播放音频(默认 true) */
|
||||
autoPlayAudio?: boolean;
|
||||
autoPlayAudio?: boolean
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
audioChannelId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,95 +42,95 @@ const Narration = ({
|
||||
autoPlayAudio = true,
|
||||
audioChannelId,
|
||||
}: NarrationProps) => {
|
||||
const [displayedText, setDisplayedText] = useState("");
|
||||
const [isAnimating, setIsAnimating] = useState(true);
|
||||
const [isSkipped, setIsSkipped] = useState(false);
|
||||
const [displayedText, setDisplayedText] = useState('')
|
||||
const [isAnimating, setIsAnimating] = useState(true)
|
||||
const [isSkipped, setIsSkipped] = useState(false)
|
||||
|
||||
// 旁白音频:默认自动播放,支持按 channel 分组互斥
|
||||
const { currentSrc, play, stop } = useAudioManager(
|
||||
audioChannelId
|
||||
? {
|
||||
mode: "channel",
|
||||
mode: 'channel',
|
||||
channelId: audioChannelId,
|
||||
}
|
||||
: { mode: "global" }
|
||||
);
|
||||
: { mode: 'global' }
|
||||
)
|
||||
|
||||
// 文字动画效果
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setDisplayedText("");
|
||||
setIsAnimating(true);
|
||||
setIsSkipped(false);
|
||||
setDisplayedText('')
|
||||
setIsAnimating(true)
|
||||
setIsSkipped(false)
|
||||
// 旁白关闭时,如果当前正在播放该旁白音频,则停止
|
||||
if (audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
stop()
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经跳过,不再执行动画
|
||||
if (isSkipped) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// 如果不启用动画,直接显示全部文本
|
||||
if (!enableAnimation) {
|
||||
setDisplayedText(text);
|
||||
setIsAnimating(false);
|
||||
return;
|
||||
setDisplayedText(text)
|
||||
setIsAnimating(false)
|
||||
return
|
||||
}
|
||||
|
||||
let currentIndex = 0;
|
||||
setDisplayedText("");
|
||||
setIsAnimating(true);
|
||||
let currentIndex = 0
|
||||
setDisplayedText('')
|
||||
setIsAnimating(true)
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (currentIndex < text.length) {
|
||||
setDisplayedText(text.slice(0, currentIndex + 1));
|
||||
currentIndex++;
|
||||
setDisplayedText(text.slice(0, currentIndex + 1))
|
||||
currentIndex++
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
clearInterval(timer);
|
||||
setIsAnimating(false)
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, animationSpeed);
|
||||
}, animationSpeed)
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [text, visible, enableAnimation, animationSpeed, isSkipped, audioSrc, currentSrc, stop]);
|
||||
return () => clearInterval(timer)
|
||||
}, [text, visible, enableAnimation, animationSpeed, isSkipped, audioSrc, currentSrc, stop])
|
||||
|
||||
// 跳过动画
|
||||
const handleSkip = () => {
|
||||
setDisplayedText(text);
|
||||
setIsAnimating(false);
|
||||
setIsSkipped(true);
|
||||
};
|
||||
setDisplayedText(text)
|
||||
setIsAnimating(false)
|
||||
setIsSkipped(true)
|
||||
}
|
||||
|
||||
// 旁白出现时自动播放音频
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (!autoPlayAudio) return;
|
||||
if (!audioSrc) return;
|
||||
if (!visible) return
|
||||
if (!autoPlayAudio) return
|
||||
if (!audioSrc) return
|
||||
|
||||
play(audioSrc);
|
||||
}, [visible, autoPlayAudio, audioSrc, play]);
|
||||
play(audioSrc)
|
||||
}, [visible, autoPlayAudio, audioSrc, play])
|
||||
|
||||
// 停止音频
|
||||
const handleClose = () => {
|
||||
if (audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
stop()
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div className="fixed 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",
|
||||
'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
|
||||
)}
|
||||
>
|
||||
@@ -161,7 +161,7 @@ const Narration = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Narration;
|
||||
export default Narration
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as Narration } from "./Narration";
|
||||
export type { NarrationProps } from "./Narration";
|
||||
export { default as Narration } from './Narration'
|
||||
export type { NarrationProps } from './Narration'
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getStaticImage } from '@/lib/utils'
|
||||
import { useAudioManager } from '@/hooks/useAudioManager'
|
||||
|
||||
export interface EndingOverlayProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** 中间显示的文本内容 */
|
||||
text: string;
|
||||
text: string
|
||||
/** 点击继续回调 */
|
||||
onContinue: () => void;
|
||||
onContinue: () => void
|
||||
/** 文字动画速度(毫秒/字),默认 60ms */
|
||||
animationSpeed?: number;
|
||||
animationSpeed?: number
|
||||
/** 结语对应的音频地址(可选) */
|
||||
audioSrc?: string;
|
||||
audioSrc?: string
|
||||
/** 覆盖层出现时是否自动播放音频(默认 true) */
|
||||
autoPlayAudio?: boolean;
|
||||
autoPlayAudio?: boolean
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
audioChannelId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,62 +32,62 @@ export const EndingOverlay = ({
|
||||
autoPlayAudio = true,
|
||||
audioChannelId,
|
||||
}: EndingOverlayProps) => {
|
||||
const [displayedText, setDisplayedText] = useState("");
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [displayedText, setDisplayedText] = useState('')
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
|
||||
// 结语音频:默认自动播放,支持按 channel 分组互斥
|
||||
const { currentSrc, isPlaying, play, stop } = useAudioManager(
|
||||
audioChannelId
|
||||
? {
|
||||
mode: "channel",
|
||||
mode: 'channel',
|
||||
channelId: audioChannelId,
|
||||
}
|
||||
: { mode: "global" }
|
||||
);
|
||||
: { mode: 'global' }
|
||||
)
|
||||
|
||||
// 文字逐字动画
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setDisplayedText("");
|
||||
setIsAnimating(false);
|
||||
setDisplayedText('')
|
||||
setIsAnimating(false)
|
||||
// 结语关闭时,如果当前正在播放该结语音频,则停止
|
||||
if (audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
stop()
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
let currentIndex = 0;
|
||||
setDisplayedText("");
|
||||
setIsAnimating(true);
|
||||
let currentIndex = 0
|
||||
setDisplayedText('')
|
||||
setIsAnimating(true)
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (currentIndex < text.length) {
|
||||
setDisplayedText(text.slice(0, currentIndex + 1));
|
||||
currentIndex++;
|
||||
setDisplayedText(text.slice(0, currentIndex + 1))
|
||||
currentIndex++
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
clearInterval(timer);
|
||||
setIsAnimating(false)
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, animationSpeed);
|
||||
}, animationSpeed)
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [visible, text, animationSpeed, audioSrc, currentSrc, stop]);
|
||||
return () => clearInterval(timer)
|
||||
}, [visible, text, animationSpeed, audioSrc, currentSrc, stop])
|
||||
|
||||
// 覆盖层显示时自动播放音频
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (!autoPlayAudio) return;
|
||||
if (!audioSrc) return;
|
||||
if (!visible) return
|
||||
if (!autoPlayAudio) return
|
||||
if (!audioSrc) return
|
||||
|
||||
play(audioSrc);
|
||||
}, [visible, autoPlayAudio, audioSrc, play]);
|
||||
play(audioSrc)
|
||||
}, [visible, autoPlayAudio, audioSrc, play])
|
||||
|
||||
// 全部文字出现后自动关闭(如果有音频,需等待音频播放完成)
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (isAnimating) return;
|
||||
if (!text || displayedText !== text) return;
|
||||
if (!visible) return
|
||||
if (isAnimating) return
|
||||
if (!text || displayedText !== text) return
|
||||
|
||||
// 如果有音频,需要等待音频播放完成
|
||||
if (audioSrc) {
|
||||
@@ -95,32 +95,23 @@ export const EndingOverlay = ({
|
||||
if (currentSrc === audioSrc && !isPlaying) {
|
||||
// 音频播放完成后,等待 800ms 再关闭
|
||||
const timer = setTimeout(() => {
|
||||
onContinue();
|
||||
}, 800);
|
||||
return () => clearTimeout(timer);
|
||||
onContinue()
|
||||
}, 800)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
// 音频还在播放,等待播放完成
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// 没有音频,文字动画完成后等待 800ms 关闭
|
||||
const timer = setTimeout(() => {
|
||||
onContinue();
|
||||
}, 800);
|
||||
onContinue()
|
||||
}, 800)
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
visible,
|
||||
isAnimating,
|
||||
displayedText,
|
||||
text,
|
||||
audioSrc,
|
||||
currentSrc,
|
||||
isPlaying,
|
||||
onContinue,
|
||||
]);
|
||||
return () => clearTimeout(timer)
|
||||
}, [visible, isAnimating, displayedText, text, audioSrc, currentSrc, isPlaying, onContinue])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-transparent">
|
||||
@@ -128,7 +119,7 @@ export const EndingOverlay = ({
|
||||
<div
|
||||
className="relative z-10 flex items-center max-w-full transition-all py-50 px-24 h-[825px] bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${getStaticImage("components/overlay/bg")})`,
|
||||
backgroundImage: `url(${getStaticImage('components/overlay/bg')})`,
|
||||
}}
|
||||
>
|
||||
<div className="text-4xl leading-relaxed text-[#7b3f00] font-semibold tracking-wide">
|
||||
@@ -139,5 +130,5 @@ export const EndingOverlay = ({
|
||||
{/* <div className="mt-16 h-4 w-full bg-gradient-to-r from-transparent via-[rgba(0,0,0,0.35)] to-transparent rounded-full" /> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
+174
-214
@@ -1,50 +1,43 @@
|
||||
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";
|
||||
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[];
|
||||
selectors: string | string[]
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 子元素内容 */
|
||||
children?: ReactNode;
|
||||
children?: ReactNode
|
||||
/** 遮罩层背景色(默认:rgba(0, 0, 0, 0.5)) */
|
||||
maskColor?: string;
|
||||
maskColor?: string
|
||||
/** 高亮边框颜色(默认:#3b82f6) */
|
||||
highlightColor?: string;
|
||||
highlightColor?: string
|
||||
/** 高亮边框宽度(默认:3px) */
|
||||
highlightWidth?: number;
|
||||
highlightWidth?: number
|
||||
/** 高亮圆角(默认:8px) */
|
||||
borderRadius?: number;
|
||||
borderRadius?: number
|
||||
/** 是否显示阴影/光晕效果 */
|
||||
showShadow?: boolean;
|
||||
showShadow?: boolean
|
||||
/** 自定义样式类名 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 自定义样式 */
|
||||
style?: React.CSSProperties;
|
||||
style?: React.CSSProperties
|
||||
/** z-index 层级(默认:40,低于 GuideSteps 的 z-50) */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 点击遮罩层回调 */
|
||||
onMaskClick?: () => void;
|
||||
onMaskClick?: () => void
|
||||
/** 是否显示遮罩层(默认:true) */
|
||||
showMask?: boolean;
|
||||
showMask?: boolean
|
||||
}
|
||||
|
||||
interface ElementRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,8 +54,8 @@ const Overlay = ({
|
||||
selectors,
|
||||
visible = true,
|
||||
children,
|
||||
maskColor = "rgba(0, 0, 0, 0.5)",
|
||||
highlightColor = "#3b82f6",
|
||||
maskColor = 'rgba(0, 0, 0, 0.5)',
|
||||
highlightColor = '#3b82f6',
|
||||
highlightWidth = 3,
|
||||
borderRadius = 8,
|
||||
showShadow = false,
|
||||
@@ -72,223 +65,213 @@ const Overlay = ({
|
||||
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 [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();
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
|
||||
|
||||
// 生成唯一的 mask ID
|
||||
const maskId = useMemo(
|
||||
() => `overlay-mask-${Math.random().toString(36).substr(2, 9)}`,
|
||||
[]
|
||||
);
|
||||
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);
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
debounceTimerRef.current = window.setTimeout(fn, delay);
|
||||
};
|
||||
}, []);
|
||||
debounceTimerRef.current = window.setTimeout(fn, delay)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// SSR 兼容:只在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// 获取所有匹配的元素(带缓存)
|
||||
const getElements = useCallback(() => {
|
||||
if (typeof document === "undefined") return [];
|
||||
if (typeof document === 'undefined') return []
|
||||
|
||||
const selectorArray =
|
||||
typeof selectors === "string" ? [selectors] : selectors;
|
||||
const elements: Element[] = [];
|
||||
const selectorArray = typeof selectors === 'string' ? [selectors] : selectors
|
||||
const elements: Element[] = []
|
||||
|
||||
selectorArray.forEach((selector) => {
|
||||
try {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
elements.push(...Array.from(matches));
|
||||
const matches = document.querySelectorAll(selector)
|
||||
elements.push(...Array.from(matches))
|
||||
} catch (error) {
|
||||
console.warn(`Invalid selector: ${selector}`, error);
|
||||
console.warn(`Invalid selector: ${selector}`, error)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// 缓存元素列表
|
||||
elementsCacheRef.current = elements;
|
||||
return elements;
|
||||
}, [selectors]);
|
||||
elementsCacheRef.current = elements
|
||||
return elements
|
||||
}, [selectors])
|
||||
|
||||
// 计算元素位置(带浅比较优化)
|
||||
const calculateRects = useCallback(() => {
|
||||
const elements =
|
||||
elementsCacheRef.current.length > 0
|
||||
? elementsCacheRef.current
|
||||
: getElements();
|
||||
const rects: ElementRect[] = [];
|
||||
const elements = elementsCacheRef.current.length > 0 ? elementsCacheRef.current : getElements()
|
||||
const rects: ElementRect[] = []
|
||||
|
||||
elements.forEach((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
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];
|
||||
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);
|
||||
prevRectsRef.current = rects
|
||||
setElementRects(rects)
|
||||
}
|
||||
}, [getElements]);
|
||||
}, [getElements])
|
||||
|
||||
// 使用 requestAnimationFrame 批量更新位置
|
||||
const scheduleUpdate = useCallback(() => {
|
||||
if (rafRef.current) return;
|
||||
if (rafRef.current) return
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
calculateRects();
|
||||
rafRef.current = null;
|
||||
});
|
||||
}, [calculateRects]);
|
||||
calculateRects()
|
||||
rafRef.current = null
|
||||
})
|
||||
}, [calculateRects])
|
||||
|
||||
// 监听窗口变化(带防抖优化)
|
||||
useEffect(() => {
|
||||
if (!visible || !mounted) return;
|
||||
if (!visible || !mounted) return
|
||||
|
||||
const debouncedUpdate = debounce(scheduleUpdate, 150);
|
||||
const handleResize = debouncedUpdate;
|
||||
const handleScroll = debouncedUpdate;
|
||||
const debouncedUpdate = debounce(scheduleUpdate, 150)
|
||||
const handleResize = debouncedUpdate
|
||||
const handleScroll = debouncedUpdate
|
||||
|
||||
window.addEventListener("resize", handleResize, { passive: true });
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
// 初始计算
|
||||
scheduleUpdate();
|
||||
scheduleUpdate()
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
debounceTimerRef.current = null
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
};
|
||||
}, [visible, mounted, scheduleUpdate, debounce]);
|
||||
}
|
||||
}, [visible, mounted, scheduleUpdate, debounce])
|
||||
|
||||
// 使用 ResizeObserver 监听元素尺寸变化(带防抖优化)
|
||||
useEffect(() => {
|
||||
if (!visible || !mounted) return;
|
||||
if (!visible || !mounted) return
|
||||
|
||||
const elements = getElements();
|
||||
if (elements.length === 0) return;
|
||||
const elements = getElements()
|
||||
if (elements.length === 0) return
|
||||
|
||||
const debouncedUpdate = debounce(scheduleUpdate, 150);
|
||||
const observer = new ResizeObserver(debouncedUpdate);
|
||||
const debouncedUpdate = debounce(scheduleUpdate, 150)
|
||||
const observer = new ResizeObserver(debouncedUpdate)
|
||||
|
||||
elements.forEach((element) => {
|
||||
observer.observe(element);
|
||||
});
|
||||
observer.observe(element)
|
||||
})
|
||||
|
||||
resizeObserverRef.current = observer;
|
||||
resizeObserverRef.current = observer
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
};
|
||||
}, [visible, mounted, getElements, scheduleUpdate, debounce]);
|
||||
observer.disconnect()
|
||||
resizeObserverRef.current = null
|
||||
}
|
||||
}, [visible, mounted, getElements, scheduleUpdate, debounce])
|
||||
|
||||
// 使用 MutationObserver 监听 DOM 变化(优化:只监听目标元素及其父级,带防抖)
|
||||
useEffect(() => {
|
||||
if (!visible || !mounted) return;
|
||||
if (!visible || !mounted) return
|
||||
|
||||
const elements =
|
||||
elementsCacheRef.current.length > 0
|
||||
? elementsCacheRef.current
|
||||
: getElements();
|
||||
const elements = elementsCacheRef.current.length > 0 ? elementsCacheRef.current : getElements()
|
||||
|
||||
if (elements.length === 0) return;
|
||||
if (elements.length === 0) return
|
||||
|
||||
const debouncedUpdate = debounce(scheduleUpdate, 200);
|
||||
const observer = new MutationObserver(debouncedUpdate);
|
||||
const debouncedUpdate = debounce(scheduleUpdate, 200)
|
||||
const observer = new MutationObserver(debouncedUpdate)
|
||||
|
||||
// 只监听目标元素及其父级,而不是整个 body
|
||||
const observedNodes = new Set<Node>();
|
||||
const observedNodes = new Set<Node>()
|
||||
elements.forEach((element) => {
|
||||
let current: Node | null = 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);
|
||||
attributeFilter: ['class', 'style'],
|
||||
})
|
||||
observedNodes.add(current)
|
||||
}
|
||||
current = current.parentNode;
|
||||
current = current.parentNode
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
mutationObserverRef.current = observer;
|
||||
mutationObserverRef.current = observer
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mutationObserverRef.current = null;
|
||||
};
|
||||
}, [visible, mounted, getElements, scheduleUpdate, debounce]);
|
||||
observer.disconnect()
|
||||
mutationObserverRef.current = null
|
||||
}
|
||||
}, [visible, mounted, getElements, scheduleUpdate, debounce])
|
||||
|
||||
// 生成 SVG mask 的挖洞路径(使用 useMemo 缓存,转换为设计稿坐标)
|
||||
const maskHoles = useMemo(() => {
|
||||
if (elementRects.length === 0) {
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
|
||||
const padding = highlightWidth + borderRadius;
|
||||
const holes: string[] = [];
|
||||
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 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 x = designX - designPadding
|
||||
const y = designY - designPadding
|
||||
const width = designWidth + designPadding * 2
|
||||
const height = designHeight + designPadding * 2
|
||||
const r = designR
|
||||
|
||||
// 创建圆角矩形路径(用于挖洞)
|
||||
const path =
|
||||
@@ -299,34 +282,33 @@ const Overlay = ({
|
||||
`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`;
|
||||
`A ${r} ${r} 0 0 1 ${x} ${y + height - r} Z`
|
||||
|
||||
holes.push(path);
|
||||
});
|
||||
holes.push(path)
|
||||
})
|
||||
|
||||
return holes;
|
||||
}, [elementRects, highlightWidth, borderRadius, scale, offsetX, offsetY]);
|
||||
return holes
|
||||
}, [elementRects, highlightWidth, borderRadius, scale, offsetX, offsetY])
|
||||
|
||||
// 生成阻止点击的 div 块(使用 Spotlight 的共享工具函数)
|
||||
// 因为 SVG mask 不能阻止点击,需要使用 div 块覆盖非高亮区域
|
||||
const blockingDivs = useMemo(() => {
|
||||
if (elementRects.length === 0) return [];
|
||||
if (elementRects.length === 0) return []
|
||||
|
||||
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 0;
|
||||
const viewportHeight =
|
||||
typeof window !== "undefined" ? window.innerHeight : 0;
|
||||
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 0
|
||||
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0
|
||||
|
||||
const padding = highlightWidth + borderRadius;
|
||||
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]);
|
||||
return getBlockingDivs(paddedRects, viewportWidth, viewportHeight)
|
||||
}, [elementRects, highlightWidth, borderRadius])
|
||||
|
||||
// 缓存视口尺寸(使用设计稿尺寸,应用缩放)
|
||||
const viewportSize = useMemo(
|
||||
@@ -335,48 +317,45 @@ const Overlay = ({
|
||||
height: DESIGN_HEIGHT,
|
||||
}),
|
||||
[DESIGN_WIDTH, DESIGN_HEIGHT]
|
||||
);
|
||||
)
|
||||
|
||||
// 缩放容器样式(应用 ScaleContext 的缩放)- 必须在条件返回之前
|
||||
const scaledContainerStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
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", // 允许点击事件穿透
|
||||
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 hasHighlightElements = elementRects.length > 0
|
||||
const shouldRenderMask =
|
||||
showMask &&
|
||||
visible &&
|
||||
mounted &&
|
||||
(hasHighlightElements || children !== undefined);
|
||||
const shouldRenderChildren = visible && mounted && children !== undefined;
|
||||
showMask && visible && mounted && (hasHighlightElements || children !== undefined)
|
||||
const shouldRenderChildren = visible && mounted && children !== undefined
|
||||
|
||||
if (!shouldRenderMask && !shouldRenderChildren) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
// 外层容器样式(全屏,不缩放)
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex,
|
||||
pointerEvents: "none",
|
||||
pointerEvents: 'none',
|
||||
...style,
|
||||
};
|
||||
}
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
@@ -388,7 +367,7 @@ const Overlay = ({
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
{/* 阻止点击层 - 使用 fixed 定位覆盖非高亮区域(集成自 Spotlight) */}
|
||||
{hasHighlightElements &&
|
||||
@@ -396,21 +375,12 @@ const Overlay = ({
|
||||
<div
|
||||
key={`block-${index}`}
|
||||
style={{
|
||||
position: "fixed",
|
||||
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",
|
||||
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}
|
||||
@@ -424,25 +394,19 @@ const Overlay = ({
|
||||
width={viewportSize.width}
|
||||
height={viewportSize.height}
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 0,
|
||||
pointerEvents: "none", // SVG 不处理点击,由 blocking divs 处理
|
||||
pointerEvents: 'none', // SVG 不处理点击,由 blocking divs 处理
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<mask id={maskId}>
|
||||
{/* 全屏白色区域(显示遮罩) */}
|
||||
<rect
|
||||
width={viewportSize.width}
|
||||
height={viewportSize.height}
|
||||
fill="white"
|
||||
/>
|
||||
<rect width={viewportSize.width} height={viewportSize.height} fill="white" />
|
||||
{/* 黑色区域(挖洞,隐藏遮罩)- 只在有高亮元素时显示 */}
|
||||
{hasHighlightElements &&
|
||||
maskHoles.map((hole, _index) => (
|
||||
<path key={_index} d={hole} fill="black" />
|
||||
))}
|
||||
maskHoles.map((hole, _index) => <path key={_index} d={hole} fill="black" />)}
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
@@ -458,9 +422,9 @@ const Overlay = ({
|
||||
{shouldRenderChildren && (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
pointerEvents: "auto",
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -471,44 +435,40 @@ const Overlay = ({
|
||||
{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;
|
||||
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",
|
||||
position: 'absolute',
|
||||
left: `${designX}px`,
|
||||
top: `${designY}px`,
|
||||
width: `${designWidth}px`,
|
||||
height: `${designHeight}px`,
|
||||
border: `${
|
||||
highlightWidth / scale
|
||||
}px solid ${highlightColor}`,
|
||||
border: `${highlightWidth / scale}px solid ${highlightColor}`,
|
||||
borderRadius: `${borderRadius / scale}px`,
|
||||
pointerEvents: "none",
|
||||
pointerEvents: 'none',
|
||||
zIndex: 2,
|
||||
boxShadow: showShadow
|
||||
? `0 0 0 ${
|
||||
(highlightWidth * 2) / scale
|
||||
}px ${highlightColor}40, 0 0 ${
|
||||
? `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);
|
||||
};
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
|
||||
export default Overlay;
|
||||
export default Overlay
|
||||
@@ -1,5 +1,5 @@
|
||||
export { default as Overlay } from "./Overlay";
|
||||
export type { OverlayProps } from "./Overlay";
|
||||
export { default as Overlay } from './Overlay'
|
||||
export type { OverlayProps } from './Overlay'
|
||||
|
||||
export { EndingOverlay } from "./EndingOverlay";
|
||||
export type { EndingOverlayProps } from "./EndingOverlay";
|
||||
export { EndingOverlay } from './EndingOverlay'
|
||||
export type { EndingOverlayProps } from './EndingOverlay'
|
||||
@@ -1,51 +1,50 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { usePuzzleStore } from "@/store/puzzleStore";
|
||||
import { Image } from "@/components";
|
||||
import { useMemo, useState } from 'react'
|
||||
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
import { Image } from '@/components'
|
||||
|
||||
/**
|
||||
* 六边形进度条组件
|
||||
* 根据进度值显示不同颜色:100%绿色,50%-99%橙色,0%-49%灰色
|
||||
*/
|
||||
interface HexagonProgressBarProps {
|
||||
progress: number; // 0-100
|
||||
progress: number // 0-100
|
||||
}
|
||||
|
||||
const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
|
||||
const progressPercent = Math.min(100, Math.max(0, progress));
|
||||
const progressText = progressPercent.toFixed(2) === "100.00"
|
||||
? "进度100%"
|
||||
: `进度${Math.round(progressPercent)}%`;
|
||||
const progressPercent = Math.min(100, Math.max(0, progress))
|
||||
const progressText =
|
||||
progressPercent.toFixed(2) === '100.00' ? '进度100%' : `进度${Math.round(progressPercent)}%`
|
||||
|
||||
// 根据进度值确定填充颜色
|
||||
const getProgressColor = () => {
|
||||
if (progressPercent >= 100) {
|
||||
return "#90B86B"; // 浅橄榄绿色(100%完成)
|
||||
return '#90B86B' // 浅橄榄绿色(100%完成)
|
||||
} else if (progressPercent >= 50) {
|
||||
return "#EEB362"; // 橙色(50%-99%)
|
||||
return '#EEB362' // 橙色(50%-99%)
|
||||
} else {
|
||||
return "#E0E0E0"; // 浅灰色(0%-49%)
|
||||
return '#E0E0E0' // 浅灰色(0%-49%)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const fillColor = getProgressColor();
|
||||
const fillColor = getProgressColor()
|
||||
// 六边形路径,细长的水平六边形,左右两端是尖角
|
||||
// 正确的六边形路径:从左顶点开始,顺时针绘制
|
||||
// 左顶点(0%, 50%) -> 左上角(3%, 0%) -> 右上角(97%, 0%) -> 右顶点(100%, 50%) -> 右下角(97%, 100%) -> 左下角(3%, 100%)
|
||||
// 外层路径(包含边框)
|
||||
const outerHexagonPath = "polygon(0% 50%, 3% 0%, 97% 0%, 100% 50%, 97% 100%, 3% 100%)";
|
||||
const outerHexagonPath = 'polygon(0% 50%, 3% 0%, 97% 0%, 100% 50%, 97% 100%, 3% 100%)'
|
||||
// 内层路径(边框内部)
|
||||
const innerHexagonPath = "polygon(0% 50%, 3% 0%, 97% 0%, 100% 50%, 97% 100%, 3% 100%)";
|
||||
const borderWidth = 4; // 增加边框宽度从2px到3px
|
||||
const innerHexagonPath = 'polygon(0% 50%, 3% 0%, 97% 0%, 100% 50%, 97% 100%, 3% 100%)'
|
||||
const borderWidth = 4 // 增加边框宽度从2px到3px
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: "260px", height: "50px" }}>
|
||||
<div className="relative" style={{ width: '260px', height: '50px' }}>
|
||||
{/* 外层:白色边框(使用 clip-path 裁剪形状) */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
clipPath: outerHexagonPath,
|
||||
backgroundColor: "white",
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
/>
|
||||
{/* 内容容器(边框内部区域) */}
|
||||
@@ -63,7 +62,7 @@ const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundColor: "#757575",
|
||||
backgroundColor: '#757575',
|
||||
}}
|
||||
/>
|
||||
{/* 填充层(根据进度百分比从左侧填充) */}
|
||||
@@ -78,7 +77,7 @@ const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundColor: fillColor,
|
||||
transition: "width 0.3s ease, background-color 0.3s ease",
|
||||
transition: 'width 0.3s ease, background-color 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -88,42 +87,42 @@ const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center text-white text-2xl font-bold pointer-events-none"
|
||||
style={{
|
||||
textShadow: "0 2px 4px rgba(0,0,0,0.6), 0 1px 2px rgba(0,0,0,0.8)",
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.6), 0 1px 2px rgba(0,0,0,0.8)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{progressText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export interface MenuPuzzleCenterProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
onClose: () => void
|
||||
/** 选择某个故居后的回调(进入对应的碎片系统) */
|
||||
onSelectResidence: (residence: string) => void;
|
||||
onSelectResidence: (residence: string) => void
|
||||
}
|
||||
|
||||
interface ResidenceConfig {
|
||||
/** 拼图存储用的英文 key(如 chenyun) */
|
||||
residence: string;
|
||||
residence: string
|
||||
/** 显示名称 */
|
||||
displayName: string;
|
||||
displayName: string
|
||||
/** 在大地图上的水平位置(百分比字符串,如 "20%") */
|
||||
left: string;
|
||||
left: string
|
||||
/** 在大地图上的垂直位置(百分比字符串,如 "40%") */
|
||||
top: string;
|
||||
top: string
|
||||
/** 显示用图片路径(相对于 puzzle/home/ 目录) */
|
||||
image: string;
|
||||
image: string
|
||||
/** 是否已经开放拼图(未开放时只展示,不可点击) */
|
||||
hasPuzzle: boolean;
|
||||
hasPuzzle: boolean
|
||||
/** 是否有导图资源 */
|
||||
hasMap?: boolean;
|
||||
hasMap?: boolean
|
||||
/** 导图资源文件名(位于 puzzle/home/map 下) */
|
||||
mapImage?: string;
|
||||
mapImage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,91 +132,87 @@ interface ResidenceConfig {
|
||||
*/
|
||||
const RESIDENCE_LIST: ResidenceConfig[] = [
|
||||
{
|
||||
residence: "maozedong",
|
||||
displayName: "毛泽东旧居",
|
||||
left: "30%",
|
||||
top: "35%",
|
||||
image: "毛泽东",
|
||||
residence: 'maozedong',
|
||||
displayName: '毛泽东旧居',
|
||||
left: '30%',
|
||||
top: '35%',
|
||||
image: '毛泽东',
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: "毛泽东导图",
|
||||
mapImage: '毛泽东导图',
|
||||
},
|
||||
{
|
||||
residence: "sunzhongshan",
|
||||
displayName: "孙中山故居",
|
||||
left: "12%",
|
||||
top: "51%",
|
||||
image: "孙中山",
|
||||
residence: 'sunzhongshan',
|
||||
displayName: '孙中山故居',
|
||||
left: '12%',
|
||||
top: '51%',
|
||||
image: '孙中山',
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: "孙中山导图",
|
||||
mapImage: '孙中山导图',
|
||||
},
|
||||
{
|
||||
residence: "luxun",
|
||||
displayName: "鲁迅故居",
|
||||
left: "26.3%",
|
||||
top: "74%",
|
||||
image: "鲁迅",
|
||||
residence: 'luxun',
|
||||
displayName: '鲁迅故居',
|
||||
left: '26.3%',
|
||||
top: '74%',
|
||||
image: '鲁迅',
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: "鲁迅导图",
|
||||
mapImage: '鲁迅导图',
|
||||
},
|
||||
{
|
||||
residence: "huangyanpei",
|
||||
displayName: "黄炎培故居",
|
||||
left: "44%",
|
||||
top: "67%",
|
||||
image: "黄炎培",
|
||||
residence: 'huangyanpei',
|
||||
displayName: '黄炎培故居',
|
||||
left: '44%',
|
||||
top: '67%',
|
||||
image: '黄炎培',
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: "黄炎培导图",
|
||||
mapImage: '黄炎培导图',
|
||||
},
|
||||
{
|
||||
residence: "nieer",
|
||||
displayName: "聂耳旧居",
|
||||
left: "62%",
|
||||
top: "60%",
|
||||
image: "聂耳",
|
||||
residence: 'nieer',
|
||||
displayName: '聂耳旧居',
|
||||
left: '62%',
|
||||
top: '60%',
|
||||
image: '聂耳',
|
||||
hasPuzzle: true,
|
||||
hasMap: false,
|
||||
},
|
||||
{
|
||||
residence: "zhangleping",
|
||||
displayName: "张乐平故居",
|
||||
left: "73%",
|
||||
top: "33%",
|
||||
image: "张乐平",
|
||||
residence: 'zhangleping',
|
||||
displayName: '张乐平故居',
|
||||
left: '73%',
|
||||
top: '33%',
|
||||
image: '张乐平',
|
||||
hasPuzzle: true,
|
||||
hasMap: false,
|
||||
},
|
||||
{
|
||||
residence: "rongdesheng",
|
||||
displayName: "荣德生旧居",
|
||||
left: "90%",
|
||||
top: "45%",
|
||||
image: "荣德生",
|
||||
residence: 'rongdesheng',
|
||||
displayName: '荣德生旧居',
|
||||
left: '90%',
|
||||
top: '45%',
|
||||
image: '荣德生',
|
||||
hasPuzzle: true,
|
||||
hasMap: false,
|
||||
},
|
||||
{
|
||||
residence: "chenyun",
|
||||
displayName: "陈云故居",
|
||||
left: "80%",
|
||||
top: "76%",
|
||||
image: "陈云",
|
||||
residence: 'chenyun',
|
||||
displayName: '陈云故居',
|
||||
left: '80%',
|
||||
top: '76%',
|
||||
image: '陈云',
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: "陈云导图",
|
||||
mapImage: '陈云导图',
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
const MenuPuzzleCenter = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSelectResidence,
|
||||
}: MenuPuzzleCenterProps) => {
|
||||
const { getPuzzleCollectProgress } = usePuzzleStore();
|
||||
const [activeMapImage, setActiveMapImage] = useState<string | null>(null);
|
||||
const MenuPuzzleCenter = ({ visible, onClose, onSelectResidence }: MenuPuzzleCenterProps) => {
|
||||
const { getPuzzleCollectProgress } = usePuzzleStore()
|
||||
const [activeMapImage, setActiveMapImage] = useState<string | null>(null)
|
||||
|
||||
const residenceProgress = useMemo(
|
||||
() =>
|
||||
@@ -227,37 +222,40 @@ const MenuPuzzleCenter = ({
|
||||
progress: item.hasPuzzle ? getPuzzleCollectProgress(item.residence) : 0,
|
||||
})),
|
||||
[getPuzzleCollectProgress]
|
||||
);
|
||||
)
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-90 flex items-center justify-center"
|
||||
style={{ background: "rgba(0,0,0,0.8)" }}
|
||||
style={{ background: 'rgba(0,0,0,0.8)' }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-full h-full bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: getBackgroundImage("puzzle/home/底图") }}
|
||||
style={{ backgroundImage: getBackgroundImage('puzzle/home/底图') }}
|
||||
>
|
||||
{/* 箭头指示底图 */}
|
||||
<img className="absolute top-84 left-55" src={getStaticImage("puzzle/home/箭头指引")} alt="" />
|
||||
<img
|
||||
className="absolute top-84 left-55"
|
||||
src={getStaticImage('puzzle/home/箭头指引')}
|
||||
alt=""
|
||||
/>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<Image
|
||||
className="absolute right-8 top-8 z-10"
|
||||
src={getStaticImage("components/关闭")}
|
||||
src={getStaticImage('components/关闭')}
|
||||
alt="关闭"
|
||||
button
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
|
||||
{/* 故居列表:按照设计图进行绝对定位 */}
|
||||
<div className="absolute inset-0">
|
||||
{residenceProgress.map(
|
||||
@@ -278,10 +276,7 @@ const MenuPuzzleCenter = ({
|
||||
style={{ left, top }}
|
||||
>
|
||||
{/* 故居图片(资源在 puzzle/home 下,可根据实际名称调整) */}
|
||||
<Image
|
||||
src={getStaticImage(`puzzle/home/${image}`)}
|
||||
alt={displayName}
|
||||
/>
|
||||
<Image src={getStaticImage(`puzzle/home/${image}`)} alt={displayName} />
|
||||
|
||||
{/* 进度展示,仅对已开放拼图的故居显示 */}
|
||||
{hasPuzzle && (
|
||||
@@ -292,20 +287,24 @@ const MenuPuzzleCenter = ({
|
||||
|
||||
{/* 图标 */}
|
||||
<div className="flex justify-center gap-6">
|
||||
<Image src={getStaticImage(`puzzle/home/拼图`)} alt="拼图" button
|
||||
<Image
|
||||
src={getStaticImage(`puzzle/home/拼图`)}
|
||||
alt="拼图"
|
||||
button
|
||||
onClick={(e) => {
|
||||
if (!hasPuzzle) return;
|
||||
e.stopPropagation();
|
||||
onSelectResidence(residence);
|
||||
}} />
|
||||
if (!hasPuzzle) return
|
||||
e.stopPropagation()
|
||||
onSelectResidence(residence)
|
||||
}}
|
||||
/>
|
||||
<Image
|
||||
src={getStaticImage(`puzzle/home/导图`)}
|
||||
alt="导图"
|
||||
button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasMap || !mapImage) return;
|
||||
setActiveMapImage(mapImage);
|
||||
e.stopPropagation()
|
||||
if (!hasMap || !mapImage) return
|
||||
setActiveMapImage(mapImage)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -318,10 +317,10 @@ const MenuPuzzleCenter = ({
|
||||
{activeMapImage && (
|
||||
<div
|
||||
className="fixed inset-0 z-110 flex items-center justify-center"
|
||||
style={{ background: "rgba(0,0,0,0.75)" }}
|
||||
style={{ background: 'rgba(0,0,0,0.75)' }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setActiveMapImage(null);
|
||||
setActiveMapImage(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -329,25 +328,20 @@ const MenuPuzzleCenter = ({
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<Image
|
||||
className="absolute -right-4 -top-8 z-10"
|
||||
src={getStaticImage("components/close")}
|
||||
src={getStaticImage('components/close')}
|
||||
alt="关闭"
|
||||
button
|
||||
onClick={() => setActiveMapImage(null)}
|
||||
/>
|
||||
|
||||
<img
|
||||
src={getStaticImage(`puzzle/home/map/${activeMapImage}`)}
|
||||
alt="导图"
|
||||
/>
|
||||
<img src={getStaticImage(`puzzle/home/map/${activeMapImage}`)} alt="导图" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuPuzzleCenter;
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default MenuPuzzleCenter
|
||||
@@ -1,15 +1,15 @@
|
||||
import { forwardRef } from "react";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { forwardRef } from 'react'
|
||||
import { getStaticImage } from '@/lib/utils'
|
||||
|
||||
export interface PuzzlePieceDisplayProps {
|
||||
/** 故居名称,如 'chenyun' */
|
||||
residence: string;
|
||||
residence: string
|
||||
/** 碎片ID */
|
||||
pieceId: number;
|
||||
pieceId: number
|
||||
/** 是否显示发光动画 */
|
||||
showGlow?: boolean;
|
||||
showGlow?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,16 +19,16 @@ export interface PuzzlePieceDisplayProps {
|
||||
* - 可以被 ref 引用,用于飞行动画
|
||||
*/
|
||||
const PuzzlePieceDisplay = forwardRef<HTMLDivElement, PuzzlePieceDisplayProps>(
|
||||
({ residence, pieceId, showGlow = true, className = "" }, ref) => {
|
||||
({ residence, pieceId, showGlow = true, className = '' }, ref) => {
|
||||
// 碎片图片路径
|
||||
const pieceImage = getStaticImage(`puzzle/${residence}/${pieceId}`);
|
||||
const pieceImage = getStaticImage(`puzzle/${residence}/${pieceId}`)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative inline-block ${className}`}
|
||||
style={{
|
||||
animation: showGlow ? "puzzleGlow 1s ease-in-out infinite" : "none",
|
||||
animation: showGlow ? 'puzzleGlow 1s ease-in-out infinite' : 'none',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@@ -43,10 +43,10 @@ const PuzzlePieceDisplay = forwardRef<HTMLDivElement, PuzzlePieceDisplayProps>(
|
||||
`}</style>
|
||||
<img src={pieceImage} alt={`碎片 ${pieceId}`} className="w-full" />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
PuzzlePieceDisplay.displayName = "PuzzlePieceDisplay";
|
||||
PuzzlePieceDisplay.displayName = 'PuzzlePieceDisplay'
|
||||
|
||||
export default PuzzlePieceDisplay;
|
||||
export default PuzzlePieceDisplay
|
||||
@@ -1,13 +1,13 @@
|
||||
import { forwardRef, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import PuzzlePieceDisplay from "./PuzzlePieceDisplay";
|
||||
import { forwardRef, useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import PuzzlePieceDisplay from './PuzzlePieceDisplay'
|
||||
|
||||
interface PuzzlePiecePortalProps {
|
||||
residence: string;
|
||||
pieceId: number;
|
||||
residence: string
|
||||
pieceId: number
|
||||
/** 是否隐藏(飞行时隐藏原始碎片) */
|
||||
hidden?: boolean;
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,38 +17,38 @@ interface PuzzlePiecePortalProps {
|
||||
*/
|
||||
const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
||||
({ residence, pieceId, hidden = false }, ref) => {
|
||||
const [isAnimating, setIsAnimating] = useState(true);
|
||||
const [isAnimating, setIsAnimating] = useState(true)
|
||||
|
||||
// 当组件首次渲染时,标记动画开始
|
||||
useEffect(() => {
|
||||
setIsAnimating(true);
|
||||
setIsAnimating(true)
|
||||
// 动画完成后,重置状态以便下次出现时重新播放
|
||||
const timer = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 800); // 与动画时长一致
|
||||
return () => clearTimeout(timer);
|
||||
}, [pieceId]); // 当 pieceId 变化时重新播放动画
|
||||
setIsAnimating(false)
|
||||
}, 800) // 与动画时长一致
|
||||
return () => clearTimeout(timer)
|
||||
}, [pieceId]) // 当 pieceId 变化时重新播放动画
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
width: "120px",
|
||||
height: "120px",
|
||||
transform: "translate(-50%, -50%)",
|
||||
perspective: "1000px", // 添加透视,让3D效果更明显
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: '120px',
|
||||
height: '120px',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
perspective: '1000px', // 添加透视,让3D效果更明显
|
||||
zIndex: 99999,
|
||||
pointerEvents: "none",
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
transformStyle: "preserve-3d", // 保持3D变换
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transformStyle: 'preserve-3d', // 保持3D变换
|
||||
}}
|
||||
initial={
|
||||
isAnimating
|
||||
@@ -61,18 +61,18 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
||||
}
|
||||
animate={
|
||||
hidden
|
||||
? { opacity: 0, visibility: "hidden" as const }
|
||||
? { opacity: 0, visibility: 'hidden' as const }
|
||||
: isAnimating
|
||||
? {
|
||||
scale: 1,
|
||||
rotateY: 0,
|
||||
opacity: 1,
|
||||
visibility: "visible" as const,
|
||||
}
|
||||
: {
|
||||
opacity: 1,
|
||||
visibility: "visible" as const,
|
||||
}
|
||||
? {
|
||||
scale: 1,
|
||||
rotateY: 0,
|
||||
opacity: 1,
|
||||
visibility: 'visible' as const,
|
||||
}
|
||||
: {
|
||||
opacity: 1,
|
||||
visibility: 'visible' as const,
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
duration: 0.8, // 稍微延长动画时间,让效果更舒缓
|
||||
@@ -87,23 +87,23 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
||||
},
|
||||
opacity: {
|
||||
duration: 0.6,
|
||||
ease: "easeOut",
|
||||
ease: 'easeOut',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PuzzlePieceDisplay
|
||||
residence={residence}
|
||||
pieceId={pieceId}
|
||||
showGlow={true}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
}}
|
||||
>
|
||||
<PuzzlePieceDisplay
|
||||
residence={residence}
|
||||
pieceId={pieceId}
|
||||
showGlow={true}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
)
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
PuzzlePiecePortal.displayName = "PuzzlePiecePortal";
|
||||
PuzzlePiecePortal.displayName = 'PuzzlePiecePortal'
|
||||
|
||||
export default PuzzlePiecePortal;
|
||||
export default PuzzlePiecePortal
|
||||
@@ -1,32 +1,32 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { cn, getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { usePuzzleStore } from "@/store/puzzleStore";
|
||||
import { getPuzzlePieceData } from "@/config/puzzleData";
|
||||
import { getPuzzlePiecePath, getPuzzleViewBox } from "@/config/puzzlePaths";
|
||||
import { Image } from "@/components";
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
import { getPuzzlePieceData } from '@/config/puzzleData'
|
||||
import { getPuzzlePiecePath, getPuzzleViewBox } from '@/config/puzzlePaths'
|
||||
import { Image } from '@/components'
|
||||
|
||||
/**
|
||||
* 将英文故居名称转换为中文显示名称
|
||||
*/
|
||||
const getResidenceDisplayName = (residence: string): string => {
|
||||
const nameMap: Record<string, string> = {
|
||||
chenyun: "陈云",
|
||||
huangyanpei: "黄炎培",
|
||||
luxun: "鲁迅",
|
||||
sunzhongshan: "孙中山",
|
||||
};
|
||||
chenyun: '陈云',
|
||||
huangyanpei: '黄炎培',
|
||||
luxun: '鲁迅',
|
||||
sunzhongshan: '孙中山',
|
||||
}
|
||||
|
||||
return nameMap[residence] || residence;
|
||||
};
|
||||
return nameMap[residence] || residence
|
||||
}
|
||||
|
||||
export interface PuzzleSystemProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
visible: boolean
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
onClose: () => void
|
||||
/** 故居名称,如 'chenyun' */
|
||||
residence: string;
|
||||
residence: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,24 +42,24 @@ const PuzzlePieceSvg = ({
|
||||
onHover,
|
||||
onLeave,
|
||||
}: {
|
||||
residence: string;
|
||||
pieceId: number;
|
||||
isUnlocked: boolean;
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
onClick: () => void;
|
||||
onHover: (id: number) => void;
|
||||
onLeave: () => void;
|
||||
residence: string
|
||||
pieceId: number
|
||||
isUnlocked: boolean
|
||||
isSelected: boolean
|
||||
isHovered: boolean
|
||||
onClick: () => void
|
||||
onHover: (id: number) => void
|
||||
onLeave: () => void
|
||||
}) => {
|
||||
const pathData = getPuzzlePiecePath(residence, pieceId);
|
||||
const pathData = getPuzzlePiecePath(residence, pieceId)
|
||||
|
||||
if (!pathData) return null;
|
||||
if (!pathData) return null
|
||||
|
||||
// 为每个碎片生成唯一的pattern ID
|
||||
const patternId = `pattern-${residence}-${pieceId}`;
|
||||
const patternId = `pattern-${residence}-${pieceId}`
|
||||
|
||||
// 碎片图片路径
|
||||
const pieceImage = getStaticImage(`puzzle/${residence}/${pieceId}`);
|
||||
const pieceImage = getStaticImage(`puzzle/${residence}/${pieceId}`)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -81,9 +81,9 @@ const PuzzlePieceSvg = ({
|
||||
onMouseEnter={() => (isUnlocked ? onHover(pieceId) : undefined)}
|
||||
onMouseLeave={onLeave}
|
||||
style={{
|
||||
cursor: isUnlocked ? "pointer" : "not-allowed",
|
||||
cursor: isUnlocked ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
className={cn(isUnlocked ? "puzzle-piece" : "")}
|
||||
className={cn(isUnlocked ? 'puzzle-piece' : '')}
|
||||
>
|
||||
<defs>
|
||||
{/* Safari/iOS 对 SVG image 的 CSS filter 支持有限,使用滤镜定义保证生效 */}
|
||||
@@ -116,7 +116,7 @@ const PuzzlePieceSvg = ({
|
||||
preserveAspectRatio="none"
|
||||
filter={isUnlocked ? undefined : `url(#${patternId}-dim)`}
|
||||
style={{
|
||||
transition: "filter 220ms ease",
|
||||
transition: 'filter 220ms ease',
|
||||
}}
|
||||
/>
|
||||
</pattern>
|
||||
@@ -126,14 +126,14 @@ const PuzzlePieceSvg = ({
|
||||
<path
|
||||
d={pathData}
|
||||
fill={`url(#${patternId})`}
|
||||
stroke={isUnlocked ? (isSelected ? "#75d1fb" : "#fff") : "#888"}
|
||||
stroke={isUnlocked ? (isSelected ? '#75d1fb' : '#fff') : '#888'}
|
||||
strokeWidth={isUnlocked ? (isSelected ? 3 : isHovered ? 2.3 : 2) : 1}
|
||||
opacity={isUnlocked ? 1 : 0.8}
|
||||
/>
|
||||
</g>
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图系统组件
|
||||
@@ -143,36 +143,36 @@ const PuzzlePieceSvg = ({
|
||||
* - 点击碎片可跳转到对应页面
|
||||
*/
|
||||
const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { moduleName } = useParams<{ moduleName: string }>();
|
||||
const { isPuzzleUnlocked, getPuzzleCollectProgress } = usePuzzleStore();
|
||||
const [selectedPiece, setSelectedPiece] = useState<number | null>(null);
|
||||
const [hoveredPiece, setHoveredPiece] = useState<number | null>(null);
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{ moduleName: string }>()
|
||||
const { isPuzzleUnlocked, getPuzzleCollectProgress } = usePuzzleStore()
|
||||
const [selectedPiece, setSelectedPiece] = useState<number | null>(null)
|
||||
const [hoveredPiece, setHoveredPiece] = useState<number | null>(null)
|
||||
|
||||
// 当弹窗关闭时重置选中状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSelectedPiece(null);
|
||||
setSelectedPiece(null)
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
// 背景图片
|
||||
const backgroundImage = getStaticImage("puzzle/底图");
|
||||
const backgroundImage = getStaticImage('puzzle/底图')
|
||||
|
||||
// 处理碎片点击
|
||||
const handlePieceClick = (pieceId: number) => {
|
||||
const isUnlocked = isPuzzleUnlocked(residence, pieceId);
|
||||
const isUnlocked = isPuzzleUnlocked(residence, pieceId)
|
||||
|
||||
if (isUnlocked) {
|
||||
// 显示选中效果,更新详情框
|
||||
setSelectedPiece(pieceId);
|
||||
setSelectedPiece(pieceId)
|
||||
} else {
|
||||
// 未解锁的碎片,可以显示提示或不做任何操作
|
||||
// 暂时不做处理,或者可以添加提示
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 处理前往按钮点击
|
||||
const handleGoToPage = () => {
|
||||
@@ -180,40 +180,36 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
// console.log(residence, selectedPiece, moduleName);
|
||||
// const route = getPuzzleRoute(residence, selectedPiece, moduleName);
|
||||
// console.log(route);
|
||||
const route = selectedPieceData?.route;
|
||||
const route = selectedPieceData?.route
|
||||
if (route) {
|
||||
navigate(`/${moduleName}/${residence}/${route}`);
|
||||
onClose();
|
||||
navigate(`/${moduleName}/${residence}/${route}`)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 获取当前选中碎片的数据
|
||||
const selectedPieceData = selectedPiece
|
||||
? getPuzzlePieceData(residence, selectedPiece)
|
||||
: null;
|
||||
const selectedPieceData = selectedPiece ? getPuzzlePieceData(residence, selectedPiece) : null
|
||||
|
||||
// 生成16块碎片(1-16)
|
||||
const pieces = Array.from({ length: 16 }, (_, i) => i + 1);
|
||||
const pieces = Array.from({ length: 16 }, (_, i) => i + 1)
|
||||
// 将悬停的碎片放到最后渲染,保证 hover 层级在最上
|
||||
const renderPieces =
|
||||
hoveredPiece != null
|
||||
? [...pieces.filter((p) => p !== hoveredPiece), hoveredPiece]
|
||||
: pieces;
|
||||
hoveredPiece != null ? [...pieces.filter((p) => p !== hoveredPiece), hoveredPiece] : pieces
|
||||
|
||||
// 获取收集进度
|
||||
const collectProgress = getPuzzleCollectProgress(residence);
|
||||
const collectProgress = getPuzzleCollectProgress(residence)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
style={{
|
||||
background: "rgba(0, 0, 0, 0.8)",
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// 点击背景关闭
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -227,7 +223,7 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
{/* 关闭按钮 */}
|
||||
<Image
|
||||
className="absolute right-8 top-8 z-10"
|
||||
src={getStaticImage("components/关闭")}
|
||||
src={getStaticImage('components/关闭')}
|
||||
alt="关闭"
|
||||
button
|
||||
onClick={onClose}
|
||||
@@ -236,27 +232,27 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
{/* 收集进度 */}
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 top-8 z-10 w-[708px] h-[107px] px-12 flex justify-between items-center"
|
||||
style={{ backgroundImage: getBackgroundImage("puzzle/收集进度") }}
|
||||
style={{ backgroundImage: getBackgroundImage('puzzle/收集进度') }}
|
||||
>
|
||||
<div className="relative">
|
||||
<span
|
||||
className={cn(
|
||||
"text-white text-2xl 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)]"
|
||||
'text-white text-2xl 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)]'
|
||||
)}
|
||||
>
|
||||
{getResidenceDisplayName(residence)}故居碎片
|
||||
</span>
|
||||
<img
|
||||
className="absolute -top-10 left-0 -z-10"
|
||||
src={getStaticImage("puzzle/碎片装饰")}
|
||||
src={getStaticImage('puzzle/碎片装饰')}
|
||||
alt="碎片装饰"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[#490000] text-4xl font-bold">
|
||||
{/* 收集进度 */}
|
||||
{collectProgress.toFixed(2) == "1.00"
|
||||
? "已集齐"
|
||||
{collectProgress.toFixed(2) == '1.00'
|
||||
? '已集齐'
|
||||
: `收集进度${collectProgress.toFixed(2)}%`}
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,12 +267,12 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
viewBox={getPuzzleViewBox(residence)}
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
filter: "drop-shadow(0px 2px 4px rgba(0,0,0,0.3))",
|
||||
overflow: "visible",
|
||||
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.3))',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{renderPieces.map((pieceId) => {
|
||||
const isUnlocked = isPuzzleUnlocked(residence, pieceId);
|
||||
const isUnlocked = isPuzzleUnlocked(residence, pieceId)
|
||||
|
||||
return (
|
||||
<PuzzlePieceSvg
|
||||
@@ -290,7 +286,7 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
onHover={(id) => setHoveredPiece(id)}
|
||||
onLeave={() => setHoveredPiece(null)}
|
||||
/>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
@@ -300,36 +296,30 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
<div
|
||||
className="w-[657px] h-[776px] bg-no-repeat bg-cover p-10 text-center"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage("puzzle/详情框"),
|
||||
backgroundImage: getBackgroundImage('puzzle/详情框'),
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
{selectedPieceData && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-white text-5xl font-bold whitespace-nowrap mt-8 mb-4 space-y-4",
|
||||
"[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)]"
|
||||
'text-white text-5xl font-bold whitespace-nowrap mt-8 mb-4 space-y-4',
|
||||
'[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)]'
|
||||
)}
|
||||
>
|
||||
{/* 空格换行 */}
|
||||
{selectedPieceData.title
|
||||
.split(" ")
|
||||
.map((word: string, index: number) => (
|
||||
<div key={index}>
|
||||
{word}
|
||||
{index <
|
||||
selectedPieceData.title.split(" ").length - 1 &&
|
||||
" "}
|
||||
</div>
|
||||
))}
|
||||
{selectedPieceData.title.split(' ').map((word: string, index: number) => (
|
||||
<div key={index}>
|
||||
{word}
|
||||
{index < selectedPieceData.title.split(' ').length - 1 && ' '}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 对应页面图片 */}
|
||||
<div className="max-h-[500px] min-h-[400px] flex items-center justify-center">
|
||||
<img
|
||||
src={getStaticImage(
|
||||
`puzzle/${selectedPieceData.detailImage}`
|
||||
)}
|
||||
src={getStaticImage(`puzzle/${selectedPieceData.detailImage}`)}
|
||||
alt={selectedPieceData.title}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
@@ -338,7 +328,7 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
{selectedPieceData && (
|
||||
<Image
|
||||
className="mt-4"
|
||||
src={getStaticImage("puzzle/点击前往浏览")}
|
||||
src={getStaticImage('puzzle/点击前往浏览')}
|
||||
alt="前往按钮"
|
||||
button
|
||||
onClick={handleGoToPage}
|
||||
@@ -351,7 +341,7 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default PuzzleSystem;
|
||||
export default PuzzleSystem
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as PuzzleSystem } from "./PuzzleSystem";
|
||||
export type { PuzzleSystemProps } from "./PuzzleSystem";
|
||||
export { default as PuzzlePieceDisplay } from "./PuzzlePieceDisplay";
|
||||
export type { PuzzlePieceDisplayProps } from "./PuzzlePieceDisplay";
|
||||
export { default as PuzzlePiecePortal } from "./PuzzlePiecePortal";
|
||||
|
||||
export { default as PuzzleSystem } from './PuzzleSystem'
|
||||
export type { PuzzleSystemProps } from './PuzzleSystem'
|
||||
export { default as PuzzlePieceDisplay } from './PuzzlePieceDisplay'
|
||||
export type { PuzzlePieceDisplayProps } from './PuzzlePieceDisplay'
|
||||
export { default as PuzzlePiecePortal } from './PuzzlePiecePortal'
|
||||
@@ -1,169 +1,167 @@
|
||||
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";
|
||||
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",
|
||||
title: 'Components/Quiz/DragFill',
|
||||
component: DragFill,
|
||||
tags: ["autodocs"],
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
options: {
|
||||
control: { type: "object" },
|
||||
description: "可拖拽的选项列表",
|
||||
control: { type: 'object' },
|
||||
description: '可拖拽的选项列表',
|
||||
},
|
||||
blanks: {
|
||||
control: { type: "object" },
|
||||
description: "空白区域列表",
|
||||
control: { type: 'object' },
|
||||
description: '空白区域列表',
|
||||
},
|
||||
value: {
|
||||
control: { type: "object" },
|
||||
description: "当前填充结果(受控模式)",
|
||||
control: { type: 'object' },
|
||||
description: '当前填充结果(受控模式)',
|
||||
},
|
||||
defaultValue: {
|
||||
control: { type: "object" },
|
||||
description: "默认填充结果",
|
||||
control: { type: 'object' },
|
||||
description: '默认填充结果',
|
||||
},
|
||||
disabled: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否禁用整个组件",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否禁用整个组件',
|
||||
},
|
||||
allowDuplicate: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否允许重复选择同一选项",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否允许重复选择同一选项',
|
||||
},
|
||||
showSubmitButton: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否显示提交按钮",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否显示提交按钮',
|
||||
},
|
||||
submitText: {
|
||||
control: { type: "text" },
|
||||
description: "提交按钮文本",
|
||||
control: { type: 'text' },
|
||||
description: '提交按钮文本',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DragFill>;
|
||||
export default meta
|
||||
type Story = StoryObj<typeof DragFill>
|
||||
|
||||
// 基础示例
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D"],
|
||||
blanks: ["空白1", "空白2", "空白3"],
|
||||
options: ['选项A', '选项B', '选项C', '选项D'],
|
||||
blanks: ['空白1', '空白2', '空白3'],
|
||||
onSubmit: (results) => {
|
||||
console.log("填充结果:", results);
|
||||
console.log('填充结果:', results)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 使用选项和空白对象
|
||||
export const WithObjects: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: "opt1", label: "选项一" },
|
||||
{ value: "opt2", label: "选项二" },
|
||||
{ value: "opt3", label: "选项三(禁用)", disabled: true },
|
||||
{ value: "opt4", label: "选项四" },
|
||||
{ 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 },
|
||||
{ id: 'blank1', label: '第一个空白', required: true },
|
||||
{ id: 'blank2', label: '第二个空白' },
|
||||
{ id: 'blank3', label: '第三个空白(必填)', required: true },
|
||||
],
|
||||
onSubmit: (results) => {
|
||||
console.log("填充结果:", results);
|
||||
console.log('填充结果:', results)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 受控模式
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState<any[]>([]);
|
||||
const [value, setValue] = useState<any[]>([])
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DragFill
|
||||
options={["选项A", "选项B", "选项C"]}
|
||||
blanks={["空白1", "空白2"]}
|
||||
options={['选项A', '选项B', '选项C']}
|
||||
blanks={['空白1', '空白2']}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={(results) => {
|
||||
alert(`提交的结果: ${JSON.stringify(results)}`);
|
||||
alert(`提交的结果: ${JSON.stringify(results)}`)
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
当前填充: {JSON.stringify(value)}
|
||||
</div>
|
||||
<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"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
blanks: ['空白1', '空白2', '空白3'],
|
||||
allowDuplicate: true,
|
||||
onSubmit: (results) => {
|
||||
console.log("填充结果:", results);
|
||||
console.log('填充结果:', results)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
blanks: ["空白1", "空白2"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
blanks: ['空白1', '空白2'],
|
||||
disabled: true,
|
||||
defaultValue: [{ blankId: "blank1", optionValue: "选项A" }],
|
||||
defaultValue: [{ blankId: 'blank1', optionValue: '选项A' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 不显示提交按钮
|
||||
export const WithoutSubmitButton: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
blanks: ["空白1", "空白2"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
blanks: ['空白1', '空白2'],
|
||||
showSubmitButton: false,
|
||||
onChange: (results) => {
|
||||
console.log("填充变化:", 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: "确认答案",
|
||||
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);
|
||||
console.log('填充结果:', results)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 自定义渲染
|
||||
export const CustomRender: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DragFill
|
||||
options={["选项A", "选项B", "选项C"]}
|
||||
blanks={["空白1", "空白2"]}
|
||||
options={['选项A', '选项B', '选项C']}
|
||||
blanks={['空白1', '空白2']}
|
||||
renderOption={(option, isUsed) => (
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 rounded-lg border-2 transition-all cursor-move",
|
||||
'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"
|
||||
? 'border-gray-200 bg-gray-100 opacity-50'
|
||||
: 'border-blue-300 bg-blue-50 hover:border-blue-500'
|
||||
)}
|
||||
draggable={!isUsed}
|
||||
>
|
||||
@@ -176,29 +174,24 @@ export const CustomRender: Story = {
|
||||
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"
|
||||
'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-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>{blank.label || '拖拽到这里'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onSubmit={(results) => {
|
||||
console.log("填充结果:", results);
|
||||
console.log('填充结果:', results)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
)
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useRef, useEffect, useCallback, forwardRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
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";
|
||||
} from './drag-fill.types'
|
||||
|
||||
/**
|
||||
* DragFill - 拖拽填空组件(使用鼠标事件实现)
|
||||
@@ -33,12 +33,12 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
allowDuplicate = false,
|
||||
layout = "vertical",
|
||||
layout = 'vertical',
|
||||
hideLabels = false,
|
||||
className,
|
||||
optionClassName,
|
||||
blankClassName,
|
||||
submitText = "提交",
|
||||
submitText = '提交',
|
||||
showSubmitButton = true,
|
||||
renderOption,
|
||||
renderBlank,
|
||||
@@ -48,205 +48,180 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
) => {
|
||||
// 标准化选项数据
|
||||
const normalizedOptions: DragFillOption[] = options.map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return { value: option, label: option };
|
||||
if (typeof option === 'string') {
|
||||
return { value: option, label: option }
|
||||
}
|
||||
return option;
|
||||
});
|
||||
return option
|
||||
})
|
||||
|
||||
// 标准化空白区域数据
|
||||
const normalizedBlanks: DragFillBlank[] = blanks.map((blank, index) => {
|
||||
if (typeof blank === "string") {
|
||||
return { id: `blank-${index}`, label: blank };
|
||||
if (typeof blank === 'string') {
|
||||
return { id: `blank-${index}`, label: blank }
|
||||
}
|
||||
return blank;
|
||||
});
|
||||
return blank
|
||||
})
|
||||
|
||||
// 受控/非受控模式
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const [internalValue, setInternalValue] = useState<DragFillResult[]>(
|
||||
defaultValue || []
|
||||
);
|
||||
const currentValue = isControlled ? controlledValue : internalValue;
|
||||
const isControlled = controlledValue !== undefined
|
||||
const [internalValue, setInternalValue] = useState<DragFillResult[]>(defaultValue || [])
|
||||
const currentValue = isControlled ? controlledValue : internalValue
|
||||
|
||||
// 拖拽状态
|
||||
const [draggingOption, setDraggingOption] = useState<string | null>(null);
|
||||
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());
|
||||
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
|
||||
);
|
||||
if (allowDuplicate) return false
|
||||
return currentValue.some((result) => result.optionValue === optionValue)
|
||||
},
|
||||
[allowDuplicate, currentValue]
|
||||
);
|
||||
)
|
||||
|
||||
// 通用的坐标更新逻辑(鼠标/触摸)
|
||||
const updateDragPosition = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
if (!draggingOption) return;
|
||||
if (!draggingOption) return
|
||||
|
||||
setDragPosition({ x: clientX, y: clientY });
|
||||
setDragPosition({ x: clientX, y: clientY })
|
||||
|
||||
// 检测是否悬停在空白区域上
|
||||
let foundBlank: string | null = null;
|
||||
let foundBlank: string | null = null
|
||||
|
||||
// 首先检查组件内的空白区域
|
||||
blankRefsMap.current.forEach((element, blankId) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
) {
|
||||
foundBlank = blankId;
|
||||
foundBlank = blankId
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// 如果没找到,尝试查找页面中所有带 data-blank-id 的元素
|
||||
if (!foundBlank) {
|
||||
normalizedBlanks.forEach((blank) => {
|
||||
const element = document.querySelector(
|
||||
`[data-blank-id="${blank.id}"]`
|
||||
);
|
||||
const element = document.querySelector(`[data-blank-id="${blank.id}"]`)
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
) {
|
||||
foundBlank = blank.id;
|
||||
foundBlank = blank.id
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
setHoveredBlank(foundBlank);
|
||||
setHoveredBlank(foundBlank)
|
||||
},
|
||||
[draggingOption, normalizedBlanks]
|
||||
);
|
||||
)
|
||||
|
||||
// 处理鼠标移动
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
updateDragPosition(e.clientX, e.clientY);
|
||||
updateDragPosition(e.clientX, e.clientY)
|
||||
},
|
||||
[updateDragPosition]
|
||||
);
|
||||
)
|
||||
|
||||
// 处理触摸移动
|
||||
const handleTouchMove = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
const touch = e.touches[0]
|
||||
if (!touch) return
|
||||
// 阻止页面滚动,确保拖拽顺滑
|
||||
e.preventDefault();
|
||||
updateDragPosition(touch.clientX, touch.clientY);
|
||||
e.preventDefault()
|
||||
updateDragPosition(touch.clientX, touch.clientY)
|
||||
},
|
||||
[updateDragPosition]
|
||||
);
|
||||
)
|
||||
|
||||
// 处理释放(鼠标/触摸)
|
||||
const handleRelease = useCallback(() => {
|
||||
if (!draggingOption) return;
|
||||
if (!draggingOption) return
|
||||
|
||||
// 如果悬停在空白区域上,执行放置
|
||||
if (hoveredBlank) {
|
||||
const optionValue = draggingOption;
|
||||
const optionValue = draggingOption
|
||||
|
||||
// 检查是否允许重复
|
||||
if (!allowDuplicate) {
|
||||
const isAlreadyUsed = currentValue.some(
|
||||
(result) => result.optionValue === optionValue
|
||||
);
|
||||
const isAlreadyUsed = currentValue.some((result) => result.optionValue === optionValue)
|
||||
if (isAlreadyUsed) {
|
||||
// 如果已使用,先移除旧的填充
|
||||
const newValue = currentValue.filter(
|
||||
(result) => result.optionValue !== optionValue
|
||||
);
|
||||
const newValue = currentValue.filter((result) => result.optionValue !== optionValue)
|
||||
// 添加新的填充
|
||||
const updatedValue = [
|
||||
...newValue,
|
||||
{ blankId: hoveredBlank, optionValue },
|
||||
];
|
||||
const updatedValue = [...newValue, { blankId: hoveredBlank, optionValue }]
|
||||
if (!isControlled) {
|
||||
setInternalValue(updatedValue);
|
||||
setInternalValue(updatedValue)
|
||||
}
|
||||
onChange?.(updatedValue);
|
||||
setDraggingOption(null);
|
||||
setDragPosition(null);
|
||||
setHoveredBlank(null);
|
||||
return;
|
||||
onChange?.(updatedValue)
|
||||
setDraggingOption(null)
|
||||
setDragPosition(null)
|
||||
setHoveredBlank(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查空白区域是否已有填充
|
||||
const existingIndex = currentValue.findIndex(
|
||||
(result) => result.blankId === hoveredBlank
|
||||
);
|
||||
const existingIndex = currentValue.findIndex((result) => result.blankId === hoveredBlank)
|
||||
|
||||
let updatedValue: DragFillResult[];
|
||||
let updatedValue: DragFillResult[]
|
||||
if (existingIndex >= 0) {
|
||||
// 替换现有填充
|
||||
updatedValue = [...currentValue];
|
||||
updatedValue[existingIndex] = { blankId: hoveredBlank, optionValue };
|
||||
updatedValue = [...currentValue]
|
||||
updatedValue[existingIndex] = { blankId: hoveredBlank, optionValue }
|
||||
} else {
|
||||
// 添加新填充
|
||||
updatedValue = [
|
||||
...currentValue,
|
||||
{ blankId: hoveredBlank, optionValue },
|
||||
];
|
||||
updatedValue = [...currentValue, { blankId: hoveredBlank, optionValue }]
|
||||
}
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(updatedValue);
|
||||
setInternalValue(updatedValue)
|
||||
}
|
||||
onChange?.(updatedValue);
|
||||
onChange?.(updatedValue)
|
||||
}
|
||||
|
||||
setDraggingOption(null);
|
||||
setDragPosition(null);
|
||||
setHoveredBlank(null);
|
||||
}, [
|
||||
draggingOption,
|
||||
hoveredBlank,
|
||||
allowDuplicate,
|
||||
currentValue,
|
||||
isControlled,
|
||||
onChange,
|
||||
]);
|
||||
setDraggingOption(null)
|
||||
setDragPosition(null)
|
||||
setHoveredBlank(null)
|
||||
}, [draggingOption, hoveredBlank, allowDuplicate, currentValue, isControlled, onChange])
|
||||
|
||||
// 添加全局鼠标事件监听
|
||||
useEffect(() => {
|
||||
if (draggingOption) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleRelease);
|
||||
document.addEventListener("touchmove", handleTouchMove, {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleRelease)
|
||||
document.addEventListener('touchmove', handleTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener("touchend", handleRelease);
|
||||
document.addEventListener("touchcancel", handleRelease);
|
||||
})
|
||||
document.addEventListener('touchend', handleRelease)
|
||||
document.addEventListener('touchcancel', handleRelease)
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleRelease);
|
||||
document.removeEventListener("touchmove", handleTouchMove);
|
||||
document.removeEventListener("touchend", handleRelease);
|
||||
document.removeEventListener("touchcancel", handleRelease);
|
||||
};
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleRelease)
|
||||
document.removeEventListener('touchmove', handleTouchMove)
|
||||
document.removeEventListener('touchend', handleRelease)
|
||||
document.removeEventListener('touchcancel', handleRelease)
|
||||
}
|
||||
}
|
||||
}, [draggingOption, handleMouseMove, handleTouchMove, handleRelease]);
|
||||
}, [draggingOption, handleMouseMove, handleTouchMove, handleRelease])
|
||||
|
||||
// 开始拖拽(通用)
|
||||
const startDragging = (
|
||||
@@ -255,98 +230,93 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
clientY: number,
|
||||
currentTarget: HTMLElement
|
||||
) => {
|
||||
if (disabled) return;
|
||||
const option = normalizedOptions.find((opt) => opt.value === optionValue);
|
||||
if (option?.disabled) return;
|
||||
if (disabled) return
|
||||
const option = normalizedOptions.find((opt) => opt.value === optionValue)
|
||||
if (option?.disabled) return
|
||||
|
||||
// 检查是否已使用
|
||||
if (!allowDuplicate && isOptionUsed(optionValue)) return;
|
||||
if (!allowDuplicate && isOptionUsed(optionValue)) return
|
||||
|
||||
// 获取实际的元素(如果有自定义渲染,使用第一个子元素)
|
||||
const wrapperElement = currentTarget;
|
||||
const wrapperElement = currentTarget
|
||||
const actualElement = renderOption
|
||||
? (wrapperElement.firstElementChild as HTMLElement)
|
||||
: wrapperElement;
|
||||
: wrapperElement
|
||||
|
||||
if (!actualElement) return;
|
||||
if (!actualElement) return
|
||||
|
||||
const rect = actualElement.getBoundingClientRect();
|
||||
const rect = actualElement.getBoundingClientRect()
|
||||
|
||||
// 记录偏移量和元素尺寸
|
||||
dragOffsetRef.current = {
|
||||
x: clientX - rect.left,
|
||||
y: clientY - rect.top,
|
||||
};
|
||||
}
|
||||
|
||||
setDraggingOption(optionValue);
|
||||
setDragPosition({ x: clientX, y: clientY });
|
||||
};
|
||||
setDraggingOption(optionValue)
|
||||
setDragPosition({ x: clientX, y: clientY })
|
||||
}
|
||||
|
||||
// 处理鼠标按下开始拖拽
|
||||
const handleMouseDown = (e: React.MouseEvent, optionValue: string) => {
|
||||
startDragging(optionValue, e.clientX, e.clientY, e.currentTarget as HTMLElement);
|
||||
};
|
||||
startDragging(optionValue, e.clientX, e.clientY, e.currentTarget as HTMLElement)
|
||||
}
|
||||
|
||||
// 处理触摸开始拖拽
|
||||
const handleTouchStart = (e: React.TouchEvent, optionValue: string) => {
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
const touch = e.touches[0]
|
||||
if (!touch) return
|
||||
// 防止滚动干扰
|
||||
e.preventDefault();
|
||||
startDragging(optionValue, touch.clientX, touch.clientY, e.currentTarget as HTMLElement);
|
||||
};
|
||||
e.preventDefault()
|
||||
startDragging(optionValue, touch.clientX, touch.clientY, e.currentTarget as HTMLElement)
|
||||
}
|
||||
|
||||
// 移除填充
|
||||
const handleRemoveFill = (blankId: string) => {
|
||||
if (disabled) return;
|
||||
const updatedValue = currentValue.filter(
|
||||
(result) => result.blankId !== blankId
|
||||
);
|
||||
if (disabled) return
|
||||
const updatedValue = currentValue.filter((result) => result.blankId !== blankId)
|
||||
if (!isControlled) {
|
||||
setInternalValue(updatedValue);
|
||||
setInternalValue(updatedValue)
|
||||
}
|
||||
onChange?.(updatedValue);
|
||||
};
|
||||
onChange?.(updatedValue)
|
||||
}
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = () => {
|
||||
if (disabled) return;
|
||||
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)
|
||||
);
|
||||
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;
|
||||
return
|
||||
}
|
||||
onSubmit?.(currentValue);
|
||||
};
|
||||
onSubmit?.(currentValue)
|
||||
}
|
||||
|
||||
// 获取空白区域的填充值
|
||||
const getBlankFill = (blankId: string): string | null => {
|
||||
const result = currentValue.find((result) => result.blankId === blankId);
|
||||
return result ? result.optionValue : 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;
|
||||
const isDragging = draggingOption === option.value
|
||||
return (
|
||||
<div
|
||||
onMouseDown={(e) => handleMouseDown(e, option.value)}
|
||||
onTouchStart={(e) => handleTouchStart(e, option.value)}
|
||||
className={cn(
|
||||
"px-6 py-4 border-2 rounded-lg transition-all select-none",
|
||||
'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",
|
||||
'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",
|
||||
? '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
|
||||
)}
|
||||
>
|
||||
@@ -370,57 +340,45 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
// 默认渲染空白区域
|
||||
const defaultRenderBlank = (
|
||||
blank: DragFillBlank,
|
||||
filledValue: string | null
|
||||
) => {
|
||||
const isHovered = hoveredBlank === blank.id;
|
||||
const option = filledValue
|
||||
? normalizedOptions.find((opt) => opt.value === filledValue)
|
||||
: null;
|
||||
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);
|
||||
blankRefsMap.current.set(blank.id, el)
|
||||
} else {
|
||||
blankRefsMap.current.delete(blank.id);
|
||||
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",
|
||||
'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"
|
||||
? '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",
|
||||
? '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>
|
||||
<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"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -432,95 +390,84 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-500">
|
||||
{blank.label || "拖拽选项到这里"}
|
||||
</span>
|
||||
<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",
|
||||
'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"
|
||||
'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>
|
||||
)}
|
||||
{!hideLabels && <h3 className="text-lg font-semibold text-gray-700">选项</h3>}
|
||||
<div
|
||||
className={cn(
|
||||
layout === "horizontal-options"
|
||||
? optionClassName || "flex flex-row gap-6 flex-wrap w-full"
|
||||
layout === 'horizontal-options'
|
||||
? optionClassName || 'flex flex-row gap-6 flex-wrap w-full'
|
||||
: cn(
|
||||
optionClassName,
|
||||
!optionClassName && "grid gap-x-12 gap-y-16",
|
||||
!optionClassName && 'grid gap-x-12 gap-y-16',
|
||||
!optionClassName && normalizedOptions.length > 2
|
||||
? "grid-cols-2"
|
||||
: !optionClassName && "grid-cols-1"
|
||||
? 'grid-cols-2'
|
||||
: !optionClassName && 'grid-cols-1'
|
||||
)
|
||||
)}
|
||||
>
|
||||
{normalizedOptions.map((option) => {
|
||||
const isUsed = isOptionUsed(option.value);
|
||||
const isDragging = draggingOption === option.value;
|
||||
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);
|
||||
handleMouseDown(e, option.value)
|
||||
}
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (!disabled && !option.disabled && !isUsed) {
|
||||
handleTouchStart(e, option.value);
|
||||
handleTouchStart(e, option.value)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
renderOption && "select-none",
|
||||
!optionClassName && renderOption && "flex-1",
|
||||
isDragging && "opacity-50"
|
||||
renderOption && 'select-none',
|
||||
!optionClassName && renderOption && '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={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">
|
||||
{normalizedBlanks.map((blank) => {
|
||||
const filledValue = getBlankFill(blank.id);
|
||||
const isHovered = hoveredBlank === blank.id && !!draggingOption;
|
||||
const filledValue = getBlankFill(blank.id)
|
||||
const isHovered = hoveredBlank === blank.id && !!draggingOption
|
||||
return (
|
||||
<div
|
||||
key={blank.id}
|
||||
@@ -528,14 +475,12 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
if (el) {
|
||||
// 如果使用自定义渲染,需要找到实际的空白区域元素
|
||||
// 默认使用第一个子元素
|
||||
const targetEl = renderBlank
|
||||
? (el.firstElementChild as HTMLElement)
|
||||
: el;
|
||||
const targetEl = renderBlank ? (el.firstElementChild as HTMLElement) : el
|
||||
if (targetEl) {
|
||||
blankRefsMap.current.set(blank.id, targetEl);
|
||||
blankRefsMap.current.set(blank.id, targetEl)
|
||||
}
|
||||
} else {
|
||||
blankRefsMap.current.delete(blank.id);
|
||||
blankRefsMap.current.delete(blank.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -543,7 +488,7 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
? renderBlank(blank, filledValue, isHovered)
|
||||
: defaultRenderBlank(blank, filledValue)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -554,10 +499,10 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
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"
|
||||
'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}
|
||||
@@ -574,31 +519,29 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
style={{
|
||||
left: dragPosition.x,
|
||||
top: dragPosition.y,
|
||||
transform: "translate(-50%, -50%)",
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const option = normalizedOptions.find(
|
||||
(opt) => opt.value === draggingOption
|
||||
);
|
||||
if (!option) return null;
|
||||
const option = normalizedOptions.find((opt) => opt.value === draggingOption)
|
||||
if (!option) return null
|
||||
if (renderDragging) {
|
||||
return renderDragging(option);
|
||||
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";
|
||||
DragFill.displayName = 'DragFill'
|
||||
|
||||
export default DragFill;
|
||||
export default DragFill
|
||||
@@ -1,73 +1,69 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
/** 选项数据 */
|
||||
export interface DragFillOption {
|
||||
/** 选项值 */
|
||||
value: string;
|
||||
value: string
|
||||
/** 选项显示文本 */
|
||||
label: string;
|
||||
label: string
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** 空白区域数据 */
|
||||
export interface DragFillBlank {
|
||||
/** 空白区域ID */
|
||||
id: string;
|
||||
id: string
|
||||
/** 空白区域显示文本/提示 */
|
||||
label?: string;
|
||||
label?: string
|
||||
/** 是否必填 */
|
||||
required?: boolean;
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
/** 填充结果 */
|
||||
export interface DragFillResult {
|
||||
/** 空白区域ID */
|
||||
blankId: string;
|
||||
blankId: string
|
||||
/** 填充的选项值 */
|
||||
optionValue: string;
|
||||
optionValue: string
|
||||
}
|
||||
|
||||
/** 拖拽填空组件配置项 */
|
||||
export interface DragFillProps {
|
||||
/** 可拖拽的选项列表 */
|
||||
options: DragFillOption[] | string[];
|
||||
options: DragFillOption[] | string[]
|
||||
/** 空白区域列表 */
|
||||
blanks: DragFillBlank[] | string[];
|
||||
blanks: DragFillBlank[] | string[]
|
||||
/** 当前填充结果(受控模式) */
|
||||
value?: DragFillResult[];
|
||||
value?: DragFillResult[]
|
||||
/** 默认填充结果 */
|
||||
defaultValue?: DragFillResult[];
|
||||
defaultValue?: DragFillResult[]
|
||||
/** 填充结果变化时的回调 */
|
||||
onChange?: (results: DragFillResult[]) => void;
|
||||
onChange?: (results: DragFillResult[]) => void
|
||||
/** 提交时的回调 */
|
||||
onSubmit?: (results: DragFillResult[]) => void;
|
||||
onSubmit?: (results: DragFillResult[]) => void
|
||||
/** 是否禁用整个组件 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
/** 是否允许重复选择同一选项 */
|
||||
allowDuplicate?: boolean;
|
||||
allowDuplicate?: boolean
|
||||
/** 布局方向:vertical(上下)、horizontal(左右)或 horizontal-options(选项水平排列) */
|
||||
layout?: "vertical" | "horizontal" | "horizontal-options";
|
||||
layout?: 'vertical' | 'horizontal' | 'horizontal-options'
|
||||
/** 是否隐藏"选项"和"填空"标签 */
|
||||
hideLabels?: boolean;
|
||||
hideLabels?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 选项容器的自定义类名 */
|
||||
optionClassName?: string;
|
||||
optionClassName?: string
|
||||
/** 空白区域容器的自定义类名 */
|
||||
blankClassName?: string;
|
||||
blankClassName?: string
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
submitText?: string
|
||||
/** 是否显示提交按钮 */
|
||||
showSubmitButton?: boolean;
|
||||
showSubmitButton?: boolean
|
||||
/** 自定义渲染选项 */
|
||||
renderOption?: (option: DragFillOption, isUsed: boolean) => ReactNode;
|
||||
renderOption?: (option: DragFillOption, isUsed: boolean) => ReactNode
|
||||
/** 自定义渲染空白区域 */
|
||||
renderBlank?: (
|
||||
blank: DragFillBlank,
|
||||
filledValue: string | null,
|
||||
isHovered: boolean
|
||||
) => ReactNode;
|
||||
renderBlank?: (blank: DragFillBlank, filledValue: string | null, isHovered: boolean) => ReactNode
|
||||
/** 自定义渲染拖拽中的元素 */
|
||||
renderDragging?: (option: DragFillOption) => ReactNode;
|
||||
renderDragging?: (option: DragFillOption) => ReactNode
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
export { default } from "./drag-fill";
|
||||
export { default as DragFill } from "./drag-fill";
|
||||
export { default } from './drag-fill'
|
||||
export { default as DragFill } from './drag-fill'
|
||||
export type {
|
||||
DragFillProps,
|
||||
DragFillOption,
|
||||
DragFillBlank,
|
||||
DragFillResult,
|
||||
} from "./drag-fill.types";
|
||||
|
||||
} from './drag-fill.types'
|
||||
@@ -1,23 +1,11 @@
|
||||
// Single Choice component
|
||||
export { SingleChoice, default as SingleChoiceDefault } from "./single-choice";
|
||||
export type {
|
||||
SingleChoiceProps,
|
||||
SingleChoiceOption,
|
||||
} from "./single-choice";
|
||||
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";
|
||||
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";
|
||||
|
||||
export { DragFill } from './drag-fill'
|
||||
export type { DragFillProps, DragFillOption, DragFillBlank, DragFillResult } from './drag-fill'
|
||||
@@ -1,7 +1,3 @@
|
||||
export { default } from "./multiple-choice";
|
||||
export { default as MultipleChoice } from "./multiple-choice";
|
||||
export type {
|
||||
MultipleChoiceProps,
|
||||
MultipleChoiceOption,
|
||||
} from "./multiple-choice.types";
|
||||
|
||||
export { default } from './multiple-choice'
|
||||
export { default as MultipleChoice } from './multiple-choice'
|
||||
export type { MultipleChoiceProps, MultipleChoiceOption } from './multiple-choice.types'
|
||||
@@ -1,193 +1,188 @@
|
||||
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";
|
||||
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",
|
||||
title: 'Components/Quiz/MultipleChoice',
|
||||
component: MultipleChoice,
|
||||
tags: ["autodocs"],
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
options: {
|
||||
control: { type: "object" },
|
||||
description: "选项列表,可以是字符串数组或选项对象数组",
|
||||
control: { type: 'object' },
|
||||
description: '选项列表,可以是字符串数组或选项对象数组',
|
||||
},
|
||||
value: {
|
||||
control: { type: "object" },
|
||||
description: "当前选中的值数组(受控模式)",
|
||||
control: { type: 'object' },
|
||||
description: '当前选中的值数组(受控模式)',
|
||||
},
|
||||
defaultValue: {
|
||||
control: { type: "object" },
|
||||
description: "默认选中的值数组",
|
||||
control: { type: 'object' },
|
||||
description: '默认选中的值数组',
|
||||
},
|
||||
disabled: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否禁用整个组件",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否禁用整个组件',
|
||||
},
|
||||
minSelections: {
|
||||
control: { type: "number" },
|
||||
description: "最小选择数量",
|
||||
control: { type: 'number' },
|
||||
description: '最小选择数量',
|
||||
},
|
||||
maxSelections: {
|
||||
control: { type: "number" },
|
||||
description: "最大选择数量",
|
||||
control: { type: 'number' },
|
||||
description: '最大选择数量',
|
||||
},
|
||||
showSubmitButton: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否显示提交按钮",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否显示提交按钮',
|
||||
},
|
||||
submitText: {
|
||||
control: { type: "text" },
|
||||
description: "提交按钮文本",
|
||||
control: { type: 'text' },
|
||||
description: '提交按钮文本',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MultipleChoice>;
|
||||
export default meta
|
||||
type Story = StoryObj<typeof MultipleChoice>
|
||||
|
||||
// 基础示例
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D", "选项E"],
|
||||
options: ['选项A', '选项B', '选项C', '选项D', '选项E'],
|
||||
onSubmit: (values) => {
|
||||
console.log("选中的值:", 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: "选项五" },
|
||||
{ value: 'opt1', label: '选项一' },
|
||||
{ value: 'opt2', label: '选项二' },
|
||||
{ value: 'opt3', label: '选项三(禁用)', disabled: true },
|
||||
{ value: 'opt4', label: '选项四' },
|
||||
{ value: 'opt5', label: '选项五' },
|
||||
],
|
||||
onSubmit: (values) => {
|
||||
console.log("选中的值:", values);
|
||||
console.log('选中的值:', values)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 受控模式
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [values, setValues] = useState<string[]>([]);
|
||||
const [values, setValues] = useState<string[]>([])
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MultipleChoice
|
||||
options={["选项A", "选项B", "选项C", "选项D"]}
|
||||
options={['选项A', '选项B', '选项C', '选项D']}
|
||||
value={values}
|
||||
onChange={setValues}
|
||||
onSubmit={(vals) => {
|
||||
alert(`提交的值: ${vals.join(", ")}`);
|
||||
alert(`提交的值: ${vals.join(', ')}`)
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
当前选中: {values.length > 0 ? values.join(", ") : "未选择"}
|
||||
当前选中: {values.length > 0 ? values.join(', ') : '未选择'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 限制最大选择数量
|
||||
export const WithMaxSelections: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D", "选项E"],
|
||||
options: ['选项A', '选项B', '选项C', '选项D', '选项E'],
|
||||
maxSelections: 2,
|
||||
onSubmit: (values) => {
|
||||
console.log("选中的值:", values);
|
||||
console.log('选中的值:', values)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 限制最小和最大选择数量
|
||||
export const WithMinMaxSelections: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D", "选项E"],
|
||||
options: ['选项A', '选项B', '选项C', '选项D', '选项E'],
|
||||
minSelections: 2,
|
||||
maxSelections: 4,
|
||||
onSubmit: (values) => {
|
||||
console.log("选中的值:", values);
|
||||
console.log('选中的值:', values)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
disabled: true,
|
||||
defaultValue: ["选项A"],
|
||||
defaultValue: ['选项A'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 不显示提交按钮
|
||||
export const WithoutSubmitButton: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
showSubmitButton: false,
|
||||
onChange: (values) => {
|
||||
console.log("值变化:", 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: "确认答案",
|
||||
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);
|
||||
console.log('选中的值:', values)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 自定义渲染选项
|
||||
export const CustomRender: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<MultipleChoice
|
||||
options={["选项A", "选项B", "选项C", "选项D"]}
|
||||
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"
|
||||
'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"
|
||||
'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 && "✓"}
|
||||
{isSelected && '✓'}
|
||||
</div>
|
||||
<span className="font-medium">{option.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
onSubmit={(values) => {
|
||||
console.log("选中的值:", values);
|
||||
console.log('选中的值:', values)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
)
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useState, useEffect, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
MultipleChoiceProps,
|
||||
MultipleChoiceOption,
|
||||
} from "./multiple-choice.types";
|
||||
import { useState, useEffect, forwardRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { MultipleChoiceProps, MultipleChoiceOption } from './multiple-choice.types'
|
||||
|
||||
/**
|
||||
* MultipleChoice - 多选组件
|
||||
@@ -32,7 +29,7 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
|
||||
maxSelections,
|
||||
className,
|
||||
optionClassName,
|
||||
submitText = "提交",
|
||||
submitText = '提交',
|
||||
showSubmitButton = true,
|
||||
renderOption,
|
||||
},
|
||||
@@ -40,96 +37,79 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
|
||||
) => {
|
||||
// 标准化选项数据
|
||||
const normalizedOptions: MultipleChoiceOption[] = options.map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return { value: option, label: option };
|
||||
if (typeof option === 'string') {
|
||||
return { value: option, label: option }
|
||||
}
|
||||
return option;
|
||||
});
|
||||
return option
|
||||
})
|
||||
|
||||
// 受控/非受控模式
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const [internalValue, setInternalValue] = useState<string[]>(
|
||||
defaultValue || []
|
||||
);
|
||||
const currentValue = isControlled ? controlledValue : internalValue;
|
||||
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;
|
||||
if (disabled) return
|
||||
const option = normalizedOptions.find((opt) => opt.value === optionValue)
|
||||
if (option?.disabled) return
|
||||
|
||||
const isSelected = currentValue.includes(optionValue);
|
||||
let newValue: string[];
|
||||
const isSelected = currentValue.includes(optionValue)
|
||||
let newValue: string[]
|
||||
|
||||
if (isSelected) {
|
||||
// 取消选择
|
||||
newValue = currentValue.filter((v) => v !== optionValue);
|
||||
newValue = currentValue.filter((v) => v !== optionValue)
|
||||
// 检查最小选择数量限制
|
||||
if (
|
||||
minSelections !== undefined &&
|
||||
newValue.length < minSelections
|
||||
) {
|
||||
return;
|
||||
if (minSelections !== undefined && newValue.length < minSelections) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 选择
|
||||
// 检查最大选择数量限制
|
||||
if (
|
||||
maxSelections !== undefined &&
|
||||
currentValue.length >= maxSelections
|
||||
) {
|
||||
return;
|
||||
if (maxSelections !== undefined && currentValue.length >= maxSelections) {
|
||||
return
|
||||
}
|
||||
newValue = [...currentValue, optionValue];
|
||||
newValue = [...currentValue, optionValue]
|
||||
}
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(newValue);
|
||||
setInternalValue(newValue)
|
||||
}
|
||||
onChange?.(newValue);
|
||||
};
|
||||
onChange?.(newValue)
|
||||
}
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = () => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
// 检查最小选择数量
|
||||
if (
|
||||
minSelections !== undefined &&
|
||||
currentValue.length < minSelections
|
||||
) {
|
||||
return;
|
||||
if (minSelections !== undefined && currentValue.length < minSelections) {
|
||||
return
|
||||
}
|
||||
onSubmit?.(currentValue);
|
||||
};
|
||||
onSubmit?.(currentValue)
|
||||
}
|
||||
|
||||
// 检查是否可以提交
|
||||
const canSubmit =
|
||||
currentValue.length > 0 &&
|
||||
(minSelections === undefined ||
|
||||
currentValue.length >= minSelections);
|
||||
(minSelections === undefined || currentValue.length >= minSelections)
|
||||
|
||||
// 默认渲染选项
|
||||
const defaultRenderOption = (
|
||||
option: MultipleChoiceOption,
|
||||
isSelected: boolean
|
||||
) => {
|
||||
const defaultRenderOption = (option: MultipleChoiceOption, isSelected: boolean) => {
|
||||
const isDisabled =
|
||||
disabled ||
|
||||
option.disabled ||
|
||||
(!isSelected &&
|
||||
maxSelections !== undefined &&
|
||||
currentValue.length >= maxSelections);
|
||||
(!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",
|
||||
'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",
|
||||
? '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)}
|
||||
@@ -137,10 +117,8 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
|
||||
<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"
|
||||
'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 && (
|
||||
@@ -162,21 +140,21 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex flex-col gap-4", className)}>
|
||||
<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);
|
||||
const isSelected = currentValue.includes(option.value)
|
||||
return (
|
||||
<div key={option.value}>
|
||||
{renderOption
|
||||
? renderOption(option, isSelected)
|
||||
: defaultRenderOption(option, isSelected)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -185,27 +163,22 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
|
||||
{minSelections !== undefined && (
|
||||
<div className="text-sm text-gray-500">
|
||||
至少选择 {minSelections} 项
|
||||
{maxSelections !== undefined &&
|
||||
`,最多选择 ${maxSelections} 项`}
|
||||
{maxSelections === undefined && "(无上限)"}
|
||||
{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">最多选择 {maxSelections} 项</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {currentValue.length} 项
|
||||
</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"
|
||||
'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}
|
||||
@@ -213,11 +186,10 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
MultipleChoice.displayName = "MultipleChoice";
|
||||
|
||||
export default MultipleChoice;
|
||||
MultipleChoice.displayName = 'MultipleChoice'
|
||||
|
||||
export default MultipleChoice
|
||||
@@ -1,45 +1,41 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
/** 选项数据 */
|
||||
export interface MultipleChoiceOption {
|
||||
/** 选项值 */
|
||||
value: string;
|
||||
value: string
|
||||
/** 选项显示文本 */
|
||||
label: string;
|
||||
label: string
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** 多选组件配置项 */
|
||||
export interface MultipleChoiceProps {
|
||||
/** 选项列表 */
|
||||
options: MultipleChoiceOption[] | string[];
|
||||
options: MultipleChoiceOption[] | string[]
|
||||
/** 当前选中的值数组(受控模式) */
|
||||
value?: string[];
|
||||
value?: string[]
|
||||
/** 默认选中的值数组 */
|
||||
defaultValue?: string[];
|
||||
defaultValue?: string[]
|
||||
/** 选中值变化时的回调 */
|
||||
onChange?: (values: string[]) => void;
|
||||
onChange?: (values: string[]) => void
|
||||
/** 提交时的回调 */
|
||||
onSubmit?: (values: string[]) => void;
|
||||
onSubmit?: (values: string[]) => void
|
||||
/** 是否禁用整个组件 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
/** 最小选择数量 */
|
||||
minSelections?: number;
|
||||
minSelections?: number
|
||||
/** 最大选择数量 */
|
||||
maxSelections?: number;
|
||||
maxSelections?: number
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 选项容器的自定义类名 */
|
||||
optionClassName?: string;
|
||||
optionClassName?: string
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
submitText?: string
|
||||
/** 是否显示提交按钮 */
|
||||
showSubmitButton?: boolean;
|
||||
showSubmitButton?: boolean
|
||||
/** 自定义渲染选项 */
|
||||
renderOption?: (
|
||||
option: MultipleChoiceOption,
|
||||
isSelected: boolean
|
||||
) => ReactNode;
|
||||
renderOption?: (option: MultipleChoiceOption, isSelected: boolean) => ReactNode
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export { default } from "./single-choice";
|
||||
export { default as SingleChoice } from "./single-choice";
|
||||
export type {
|
||||
SingleChoiceProps,
|
||||
SingleChoiceOption,
|
||||
} from "./single-choice.types";
|
||||
|
||||
export { default } from './single-choice'
|
||||
export { default as SingleChoice } from './single-choice'
|
||||
export type { SingleChoiceProps, SingleChoiceOption } from './single-choice.types'
|
||||
@@ -1,161 +1,152 @@
|
||||
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";
|
||||
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",
|
||||
title: 'Components/Quiz/SingleChoice',
|
||||
component: SingleChoice,
|
||||
tags: ["autodocs"],
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
options: {
|
||||
control: { type: "object" },
|
||||
description: "选项列表,可以是字符串数组或选项对象数组",
|
||||
control: { type: 'object' },
|
||||
description: '选项列表,可以是字符串数组或选项对象数组',
|
||||
},
|
||||
value: {
|
||||
control: { type: "text" },
|
||||
description: "当前选中的值(受控模式)",
|
||||
control: { type: 'text' },
|
||||
description: '当前选中的值(受控模式)',
|
||||
},
|
||||
defaultValue: {
|
||||
control: { type: "text" },
|
||||
description: "默认选中的值",
|
||||
control: { type: 'text' },
|
||||
description: '默认选中的值',
|
||||
},
|
||||
disabled: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否禁用整个组件",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否禁用整个组件',
|
||||
},
|
||||
showSubmitButton: {
|
||||
control: { type: "boolean" },
|
||||
description: "是否显示提交按钮",
|
||||
control: { type: 'boolean' },
|
||||
description: '是否显示提交按钮',
|
||||
},
|
||||
submitText: {
|
||||
control: { type: "text" },
|
||||
description: "提交按钮文本",
|
||||
control: { type: 'text' },
|
||||
description: '提交按钮文本',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SingleChoice>;
|
||||
export default meta
|
||||
type Story = StoryObj<typeof SingleChoice>
|
||||
|
||||
// 基础示例
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C", "选项D"],
|
||||
options: ['选项A', '选项B', '选项C', '选项D'],
|
||||
onSubmit: (value) => {
|
||||
console.log("选中的值:", value);
|
||||
console.log('选中的值:', value)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 使用选项对象
|
||||
export const WithOptionObjects: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: "opt1", label: "选项一" },
|
||||
{ value: "opt2", label: "选项二" },
|
||||
{ value: "opt3", label: "选项三(禁用)", disabled: true },
|
||||
{ value: "opt4", label: "选项四" },
|
||||
{ value: 'opt1', label: '选项一' },
|
||||
{ value: 'opt2', label: '选项二' },
|
||||
{ value: 'opt3', label: '选项三(禁用)', disabled: true },
|
||||
{ value: 'opt4', label: '选项四' },
|
||||
],
|
||||
onSubmit: (value) => {
|
||||
console.log("选中的值:", value);
|
||||
console.log('选中的值:', value)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 受控模式
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState<string>("");
|
||||
const [value, setValue] = useState<string>('')
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SingleChoice
|
||||
options={["选项A", "选项B", "选项C"]}
|
||||
options={['选项A', '选项B', '选项C']}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={(val) => {
|
||||
alert(`提交的值: ${val}`);
|
||||
alert(`提交的值: ${val}`)
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
当前选中: {value || "未选择"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">当前选中: {value || '未选择'}</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
disabled: true,
|
||||
defaultValue: "选项A",
|
||||
defaultValue: '选项A',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 不显示提交按钮
|
||||
export const WithoutSubmitButton: Story = {
|
||||
args: {
|
||||
options: ["选项A", "选项B", "选项C"],
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
showSubmitButton: false,
|
||||
onChange: (value) => {
|
||||
console.log("值变化:", 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: "确认答案",
|
||||
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);
|
||||
console.log('选中的值:', value)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 自定义渲染选项
|
||||
export const CustomRender: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<SingleChoice
|
||||
options={["选项A", "选项B", "选项C"]}
|
||||
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"
|
||||
'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"
|
||||
'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" />
|
||||
)}
|
||||
{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);
|
||||
console.log('选中的值:', value)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
)
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useState, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
SingleChoiceProps,
|
||||
SingleChoiceOption,
|
||||
} from "./single-choice.types";
|
||||
import { useState, forwardRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SingleChoiceProps, SingleChoiceOption } from './single-choice.types'
|
||||
|
||||
/**
|
||||
* SingleChoice - 单选组件
|
||||
@@ -29,7 +26,7 @@ const SingleChoice = forwardRef<HTMLDivElement, SingleChoiceProps>(
|
||||
disabled = false,
|
||||
className,
|
||||
optionClassName,
|
||||
submitText: _submitText = "提交", // 保留入参但不使用
|
||||
submitText: _submitText = '提交', // 保留入参但不使用
|
||||
showSubmitButton: _showSubmitButton = false, // 保留入参但不使用
|
||||
renderOption,
|
||||
},
|
||||
@@ -37,80 +34,73 @@ const SingleChoice = forwardRef<HTMLDivElement, SingleChoiceProps>(
|
||||
) => {
|
||||
// 标准化选项数据
|
||||
const normalizedOptions: SingleChoiceOption[] = options.map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return { value: option, label: option };
|
||||
if (typeof option === 'string') {
|
||||
return { value: option, label: option }
|
||||
}
|
||||
return option;
|
||||
});
|
||||
return option
|
||||
})
|
||||
|
||||
// 受控/非受控模式
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const [internalValue, setInternalValue] = useState<string>(
|
||||
defaultValue || ""
|
||||
);
|
||||
const currentValue = isControlled ? controlledValue : internalValue;
|
||||
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 (disabled) return
|
||||
if (normalizedOptions.find((opt) => opt.value === optionValue)?.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(optionValue);
|
||||
setInternalValue(optionValue)
|
||||
}
|
||||
onChange?.(optionValue);
|
||||
onChange?.(optionValue)
|
||||
// 单选题:点击即触发提交回调
|
||||
onSubmit?.(optionValue);
|
||||
};
|
||||
onSubmit?.(optionValue)
|
||||
}
|
||||
|
||||
// 默认渲染选项
|
||||
const defaultRenderOption = (
|
||||
option: SingleChoiceOption,
|
||||
isSelected: boolean
|
||||
) => {
|
||||
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",
|
||||
'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",
|
||||
? '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 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;
|
||||
const isSelected = currentValue === option.value
|
||||
return (
|
||||
<div key={option.value}>
|
||||
{renderOption
|
||||
? renderOption(option, isSelected)
|
||||
: defaultRenderOption(option, isSelected)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 提交按钮移除:点击选项即提交 */}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
SingleChoice.displayName = "SingleChoice";
|
||||
SingleChoice.displayName = 'SingleChoice'
|
||||
|
||||
export default SingleChoice;
|
||||
export default SingleChoice
|
||||
@@ -1,38 +1,37 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
/** 选项数据 */
|
||||
export interface SingleChoiceOption {
|
||||
/** 选项值 */
|
||||
value: string;
|
||||
value: string
|
||||
/** 选项显示文本 */
|
||||
label: string;
|
||||
label: string
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** 单选组件配置项 */
|
||||
export interface SingleChoiceProps {
|
||||
/** 选项列表 */
|
||||
options: SingleChoiceOption[] | string[];
|
||||
options: SingleChoiceOption[] | string[]
|
||||
/** 当前选中的值 */
|
||||
value?: string;
|
||||
value?: string
|
||||
/** 默认选中的值 */
|
||||
defaultValue?: string;
|
||||
defaultValue?: string
|
||||
/** 选中值变化时的回调 */
|
||||
onChange?: (value: string) => void;
|
||||
onChange?: (value: string) => void
|
||||
/** 提交时的回调 */
|
||||
onSubmit?: (value: string) => void;
|
||||
onSubmit?: (value: string) => void
|
||||
/** 是否禁用整个组件 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 选项容器的自定义类名 */
|
||||
optionClassName?: string;
|
||||
optionClassName?: string
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string;
|
||||
submitText?: string
|
||||
/** 是否显示提交按钮 */
|
||||
showSubmitButton?: boolean;
|
||||
showSubmitButton?: boolean
|
||||
/** 自定义渲染选项 */
|
||||
renderOption?: (option: SingleChoiceOption, isSelected: boolean) => ReactNode;
|
||||
renderOption?: (option: SingleChoiceOption, isSelected: boolean) => ReactNode
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { default } from "./select";
|
||||
export { default as Select } from "./select";
|
||||
export type { SelectProps, SelectOption } from "./select.types";
|
||||
|
||||
export { default } from './select'
|
||||
export { default as Select } from './select'
|
||||
export type { SelectProps, SelectOption } from './select.types'
|
||||
+132
-148
@@ -1,7 +1,7 @@
|
||||
import { forwardRef, useState, useRef, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SelectProps } from "./select.types";
|
||||
import { forwardRef, useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SelectProps } from './select.types'
|
||||
|
||||
/**
|
||||
* Select - 下拉选择组件
|
||||
@@ -26,7 +26,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "请选择",
|
||||
placeholder = '请选择',
|
||||
disabled = false,
|
||||
className,
|
||||
error = false,
|
||||
@@ -37,129 +37,120 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLSpanElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1)
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const measureRef = useRef<HTMLSpanElement>(null)
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
} | null>(null);
|
||||
const [contentWidth, setContentWidth] = useState<number | null>(null);
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
} | null>(null)
|
||||
const [contentWidth, setContentWidth] = useState<number | null>(null)
|
||||
|
||||
// 获取当前选中的选项
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
const selectedOption = options.find((opt) => opt.value === value)
|
||||
|
||||
// 计算内容宽度(用于 fitContent)
|
||||
const calculateContentWidth = useCallback(() => {
|
||||
if (!fitContent || !triggerRef.current) return;
|
||||
if (!fitContent || !triggerRef.current) return
|
||||
|
||||
// 获取触发器的计算样式,用于匹配字体
|
||||
const triggerStyles = window.getComputedStyle(triggerRef.current);
|
||||
const triggerStyles = window.getComputedStyle(triggerRef.current)
|
||||
|
||||
// 创建一个临时元素来测量所有选项的宽度
|
||||
const tempElement = document.createElement("span");
|
||||
tempElement.style.visibility = "hidden";
|
||||
tempElement.style.position = "absolute";
|
||||
tempElement.style.whiteSpace = "nowrap";
|
||||
tempElement.style.fontSize = triggerStyles.fontSize;
|
||||
tempElement.style.fontFamily = triggerStyles.fontFamily;
|
||||
tempElement.style.fontWeight = triggerStyles.fontWeight;
|
||||
tempElement.style.padding = "8px 12px"; // 与选项 padding 一致 (py-2 px-3)
|
||||
document.body.appendChild(tempElement);
|
||||
const tempElement = document.createElement('span')
|
||||
tempElement.style.visibility = 'hidden'
|
||||
tempElement.style.position = 'absolute'
|
||||
tempElement.style.whiteSpace = 'nowrap'
|
||||
tempElement.style.fontSize = triggerStyles.fontSize
|
||||
tempElement.style.fontFamily = triggerStyles.fontFamily
|
||||
tempElement.style.fontWeight = triggerStyles.fontWeight
|
||||
tempElement.style.padding = '8px 12px' // 与选项 padding 一致 (py-2 px-3)
|
||||
document.body.appendChild(tempElement)
|
||||
|
||||
let calculatedWidth = 0;
|
||||
let calculatedWidth = 0
|
||||
|
||||
// 测量所有选项和占位符的宽度
|
||||
const textsToMeasure = [placeholder, ...options.map((opt) => opt.label)];
|
||||
const textsToMeasure = [placeholder, ...options.map((opt) => opt.label)]
|
||||
|
||||
textsToMeasure.forEach((text) => {
|
||||
tempElement.textContent = text;
|
||||
const width = tempElement.offsetWidth;
|
||||
tempElement.textContent = text
|
||||
const width = tempElement.offsetWidth
|
||||
if (width > calculatedWidth) {
|
||||
calculatedWidth = width;
|
||||
calculatedWidth = width
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
document.body.removeChild(tempElement);
|
||||
document.body.removeChild(tempElement)
|
||||
|
||||
// 应用最小和最大宽度限制
|
||||
if (minWidth !== undefined && calculatedWidth < minWidth) {
|
||||
calculatedWidth = minWidth;
|
||||
calculatedWidth = minWidth
|
||||
}
|
||||
if (maxWidth !== undefined && calculatedWidth > maxWidth) {
|
||||
calculatedWidth = maxWidth;
|
||||
calculatedWidth = maxWidth
|
||||
}
|
||||
|
||||
setContentWidth(calculatedWidth);
|
||||
}, [fitContent, options, placeholder, minWidth, maxWidth]);
|
||||
setContentWidth(calculatedWidth)
|
||||
}, [fitContent, options, placeholder, minWidth, maxWidth])
|
||||
|
||||
// 计算下拉菜单位置
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
if (!triggerRef.current) return;
|
||||
if (!triggerRef.current) return
|
||||
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
let width = rect.width;
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
let width = rect.width
|
||||
|
||||
// 如果启用 fitContent,使用计算出的内容宽度
|
||||
if (fitContent && contentWidth !== null) {
|
||||
// width = contentWidth;
|
||||
width = 0;
|
||||
width = 0
|
||||
}
|
||||
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX,
|
||||
width: width,
|
||||
});
|
||||
}, [fitContent, contentWidth]);
|
||||
})
|
||||
}, [fitContent, contentWidth])
|
||||
|
||||
// 打开下拉菜单
|
||||
const handleOpen = useCallback(() => {
|
||||
if (disabled) return;
|
||||
if (disabled) return
|
||||
// 如果启用 fitContent,先计算内容宽度
|
||||
if (fitContent) {
|
||||
calculateContentWidth();
|
||||
calculateContentWidth()
|
||||
}
|
||||
// 使用 setTimeout 确保内容宽度计算完成后再更新位置
|
||||
setTimeout(() => {
|
||||
updateDropdownPosition();
|
||||
setIsOpen(true);
|
||||
setFocusedIndex(
|
||||
value ? options.findIndex((opt) => opt.value === value) : -1
|
||||
);
|
||||
}, 0);
|
||||
}, [
|
||||
disabled,
|
||||
fitContent,
|
||||
calculateContentWidth,
|
||||
updateDropdownPosition,
|
||||
value,
|
||||
options,
|
||||
]);
|
||||
updateDropdownPosition()
|
||||
setIsOpen(true)
|
||||
setFocusedIndex(value ? options.findIndex((opt) => opt.value === value) : -1)
|
||||
}, 0)
|
||||
}, [disabled, fitContent, calculateContentWidth, updateDropdownPosition, value, options])
|
||||
|
||||
// 关闭下拉菜单
|
||||
const handleClose = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setFocusedIndex(-1);
|
||||
}, []);
|
||||
setIsOpen(false)
|
||||
setFocusedIndex(-1)
|
||||
}, [])
|
||||
|
||||
// 选择选项
|
||||
const handleSelect = useCallback(
|
||||
(optionValue: string) => {
|
||||
if (onChange) {
|
||||
onChange(optionValue);
|
||||
onChange(optionValue)
|
||||
}
|
||||
handleClose();
|
||||
handleClose()
|
||||
},
|
||||
[onChange, handleClose]
|
||||
);
|
||||
)
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!isOpen) return
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
@@ -168,98 +159,94 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
handleClose();
|
||||
handleClose()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
updateDropdownPosition();
|
||||
};
|
||||
updateDropdownPosition()
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
updateDropdownPosition();
|
||||
};
|
||||
updateDropdownPosition()
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleResize);
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [isOpen, handleClose, updateDropdownPosition]);
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [isOpen, handleClose, updateDropdownPosition])
|
||||
|
||||
// 键盘导航
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const enabledIndices = options
|
||||
.map((opt, idx) => (!opt.disabled ? idx : -1))
|
||||
.filter((idx) => idx !== -1);
|
||||
.filter((idx) => idx !== -1)
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
setFocusedIndex((prev) => {
|
||||
const currentEnabledIdx = enabledIndices.indexOf(prev);
|
||||
const currentEnabledIdx = enabledIndices.indexOf(prev)
|
||||
const nextEnabledIdx =
|
||||
currentEnabledIdx < enabledIndices.length - 1
|
||||
? currentEnabledIdx + 1
|
||||
: 0;
|
||||
return enabledIndices[nextEnabledIdx] ?? enabledIndices[0] ?? -1;
|
||||
});
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
currentEnabledIdx < enabledIndices.length - 1 ? currentEnabledIdx + 1 : 0
|
||||
return enabledIndices[nextEnabledIdx] ?? enabledIndices[0] ?? -1
|
||||
})
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
setFocusedIndex((prev) => {
|
||||
const currentEnabledIdx = enabledIndices.indexOf(prev);
|
||||
const currentEnabledIdx = enabledIndices.indexOf(prev)
|
||||
const nextEnabledIdx =
|
||||
currentEnabledIdx > 0
|
||||
? currentEnabledIdx - 1
|
||||
: enabledIndices.length - 1;
|
||||
return enabledIndices[nextEnabledIdx] ?? enabledIndices[0] ?? -1;
|
||||
});
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
currentEnabledIdx > 0 ? currentEnabledIdx - 1 : enabledIndices.length - 1
|
||||
return enabledIndices[nextEnabledIdx] ?? enabledIndices[0] ?? -1
|
||||
})
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
if (focusedIndex >= 0 && focusedIndex < options.length) {
|
||||
const option = options[focusedIndex];
|
||||
const option = options[focusedIndex]
|
||||
if (option && !option.disabled) {
|
||||
handleSelect(option.value);
|
||||
handleSelect(option.value)
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
handleClose();
|
||||
break;
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
handleClose()
|
||||
break
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, focusedIndex, options, handleSelect, handleClose]);
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, focusedIndex, options, handleSelect, handleClose])
|
||||
|
||||
// 滚动到选中项
|
||||
useEffect(() => {
|
||||
if (isOpen && focusedIndex >= 0 && dropdownRef.current) {
|
||||
const optionElement = dropdownRef.current.querySelector(
|
||||
`[data-option-index="${focusedIndex}"]`
|
||||
);
|
||||
)
|
||||
if (optionElement) {
|
||||
optionElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
block: 'nearest',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isOpen, focusedIndex]);
|
||||
}, [isOpen, focusedIndex])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -268,9 +255,9 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
<span
|
||||
ref={measureRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
position: 'absolute',
|
||||
visibility: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -279,17 +266,17 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
{/* 触发器 */}
|
||||
<div
|
||||
ref={(node) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(node);
|
||||
if (typeof ref === 'function') {
|
||||
ref(node)
|
||||
} else if (ref) {
|
||||
ref.current = node;
|
||||
ref.current = node
|
||||
}
|
||||
triggerRef.current = node;
|
||||
triggerRef.current = node
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex justify-start w-full h-[36px] mx-2 bg-transparent border-b-2 border-dashed text-[26px] outline-none cursor-pointer",
|
||||
error ? "border-red-500" : "border-[#4e402e]",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
'inline-flex justify-start w-full h-[36px] mx-2 bg-transparent border-b-2 border-dashed text-[26px] outline-none cursor-pointer',
|
||||
error ? 'border-red-500' : 'border-[#4e402e]',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
onClick={handleOpen}
|
||||
@@ -319,29 +306,26 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${
|
||||
dropdownPosition.width > 0 ? dropdownPosition.width : "auto"
|
||||
}px`,
|
||||
minWidth: "80px",
|
||||
width: `${dropdownPosition.width > 0 ? dropdownPosition.width : 'auto'}px`,
|
||||
minWidth: '80px',
|
||||
zIndex: zIndex,
|
||||
}}
|
||||
role="listbox"
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = option.value === value;
|
||||
const isFocused = index === focusedIndex;
|
||||
const isDisabled = option.disabled;
|
||||
const isSelected = option.value === value
|
||||
const isFocused = index === focusedIndex
|
||||
const isDisabled = option.disabled
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
data-option-index={index}
|
||||
className={cn(
|
||||
"px-3 py-2 cursor-pointer text-[1vw] text-[#4e402e] transition-colors text-center whitespace-nowrap",
|
||||
isSelected && "bg-[#4e402e]/10 font-medium",
|
||||
isFocused && !isSelected && "bg-gray-100",
|
||||
isDisabled &&
|
||||
"opacity-50 cursor-not-allowed pointer-events-none"
|
||||
'px-3 py-2 cursor-pointer text-[1vw] text-[#4e402e] transition-colors text-center whitespace-nowrap',
|
||||
isSelected && 'bg-[#4e402e]/10 font-medium',
|
||||
isFocused && !isSelected && 'bg-gray-100',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
)}
|
||||
onClick={() => !isDisabled && handleSelect(option.value)}
|
||||
role="option"
|
||||
@@ -349,16 +333,16 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
Select.displayName = "Select";
|
||||
Select.displayName = 'Select'
|
||||
|
||||
export default Select;
|
||||
export default Select
|
||||
@@ -1,37 +1,37 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Select 选项 */
|
||||
export interface SelectOption {
|
||||
/** 选项值 */
|
||||
value: string;
|
||||
value: string
|
||||
/** 选项标签 */
|
||||
label: string;
|
||||
label: string
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** Select 组件属性 */
|
||||
export interface SelectProps {
|
||||
/** 选项列表 */
|
||||
options: SelectOption[];
|
||||
options: SelectOption[]
|
||||
/** 当前选中的值 */
|
||||
value?: string;
|
||||
value?: string
|
||||
/** 值变化回调 */
|
||||
onChange?: (value: string) => void;
|
||||
onChange?: (value: string) => void
|
||||
/** 占位符文本 */
|
||||
placeholder?: string;
|
||||
placeholder?: string
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
disabled?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 是否显示错误状态 */
|
||||
error?: boolean;
|
||||
error?: boolean
|
||||
/** 下拉菜单的 z-index */
|
||||
zIndex?: number;
|
||||
zIndex?: number
|
||||
/** 下拉菜单宽度是否自适应内容(根据最长的 label 文字宽度) */
|
||||
fitContent?: boolean;
|
||||
fitContent?: boolean
|
||||
/** 下拉菜单最小宽度(当 fitContent 为 true 时生效) */
|
||||
minWidth?: number;
|
||||
minWidth?: number
|
||||
/** 下拉菜单最大宽度(当 fitContent 为 true 时生效) */
|
||||
maxWidth?: number;
|
||||
maxWidth?: number
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { getBlockingDivs, type Rect } from "./utils";
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { getBlockingDivs, type Rect } from './utils'
|
||||
|
||||
interface SpotlightProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 目标元素选择器(支持单个或多个) */
|
||||
targetSelector?: string | string[] | null;
|
||||
targetSelector?: string | string[] | null
|
||||
/** 额外内边距 */
|
||||
padding?: number;
|
||||
padding?: number
|
||||
/** 圆角 */
|
||||
borderRadius?: number;
|
||||
borderRadius?: number
|
||||
}
|
||||
|
||||
const Spotlight: React.FC<SpotlightProps> = ({
|
||||
@@ -19,108 +19,104 @@ const Spotlight: React.FC<SpotlightProps> = ({
|
||||
padding = 8,
|
||||
borderRadius = 16,
|
||||
}) => {
|
||||
const [rects, setRects] = useState<Rect[]>([]);
|
||||
const [rects, setRects] = useState<Rect[]>([])
|
||||
|
||||
const measure = useCallback(() => {
|
||||
if (!visible || !targetSelector) {
|
||||
setRects([]);
|
||||
return;
|
||||
setRects([])
|
||||
return
|
||||
}
|
||||
|
||||
const selectors = Array.isArray(targetSelector)
|
||||
? targetSelector
|
||||
: [targetSelector];
|
||||
const pad = Math.max(0, padding);
|
||||
const newRects: Rect[] = [];
|
||||
const selectors = Array.isArray(targetSelector) ? targetSelector : [targetSelector]
|
||||
const pad = Math.max(0, padding)
|
||||
const newRects: Rect[] = []
|
||||
|
||||
selectors.forEach((selector) => {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
const elements = document.querySelectorAll(selector)
|
||||
elements.forEach((el) => {
|
||||
const htmlEl = el as HTMLElement;
|
||||
const r = htmlEl.getBoundingClientRect();
|
||||
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]);
|
||||
setRects(newRects)
|
||||
}, [visible, targetSelector, padding])
|
||||
|
||||
useEffect(() => {
|
||||
// 初始测量
|
||||
requestAnimationFrame(measure);
|
||||
}, [visible, targetSelector, padding, measure]);
|
||||
requestAnimationFrame(measure)
|
||||
}, [visible, targetSelector, padding, measure])
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => measure();
|
||||
const onScroll = () => measure();
|
||||
const onResize = () => measure()
|
||||
const onScroll = () => measure()
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
window.addEventListener("scroll", onScroll, {
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('scroll', onScroll, {
|
||||
passive: true,
|
||||
} as AddEventListenerOptions);
|
||||
} as AddEventListenerOptions)
|
||||
|
||||
// 使用 ResizeObserver 监听目标元素尺寸变化
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
measure();
|
||||
});
|
||||
measure()
|
||||
})
|
||||
|
||||
// 观察所有目标元素
|
||||
if (visible && targetSelector) {
|
||||
const selectors = Array.isArray(targetSelector)
|
||||
? targetSelector
|
||||
: [targetSelector];
|
||||
const selectors = Array.isArray(targetSelector) ? targetSelector : [targetSelector]
|
||||
|
||||
selectors.forEach((selector) => {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
const elements = document.querySelectorAll(selector)
|
||||
elements.forEach((el) => {
|
||||
resizeObserver.observe(el);
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(el)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 初始测量(延迟一帧确保 DOM 已渲染)
|
||||
requestAnimationFrame(measure);
|
||||
requestAnimationFrame(measure)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [measure, visible, targetSelector]);
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [measure, visible, targetSelector])
|
||||
|
||||
if (!visible || rects.length === 0) return null;
|
||||
if (!visible || rects.length === 0) return null
|
||||
|
||||
// 生成唯一ID用于SVG mask
|
||||
const maskId = `spotlight-mask-${Math.random().toString(36).substr(2, 9)}`;
|
||||
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);
|
||||
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",
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
pointerEvents: 'none',
|
||||
zIndex: 100000,
|
||||
}}
|
||||
>
|
||||
{/* SVG 视觉遮罩 */}
|
||||
<svg
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
@@ -155,19 +151,12 @@ const Spotlight: React.FC<SpotlightProps> = ({
|
||||
<div
|
||||
key={`block-${index}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
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",
|
||||
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)", // 调试用
|
||||
}}
|
||||
/>
|
||||
@@ -178,20 +167,20 @@ const Spotlight: React.FC<SpotlightProps> = ({
|
||||
<div
|
||||
key={`border-${index}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
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",
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Spotlight;
|
||||
export default Spotlight
|
||||
@@ -1 +1 @@
|
||||
export { default as Spotlight } from "./Spotlight";
|
||||
export { default as Spotlight } from './Spotlight'
|
||||
@@ -3,17 +3,17 @@
|
||||
* 用于 Spotlight 和 Overlay 组件
|
||||
*/
|
||||
export interface BlockingDiv {
|
||||
top: number;
|
||||
left: number;
|
||||
width: string | number;
|
||||
height: string | number;
|
||||
top: number
|
||||
left: number
|
||||
width: string | number
|
||||
height: string | number
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,24 +28,24 @@ export function getBlockingDivs(
|
||||
viewportWidth: number,
|
||||
viewportHeight: number
|
||||
): BlockingDiv[] {
|
||||
if (rects.length === 0) return [];
|
||||
if (rects.length === 0) return []
|
||||
|
||||
const blocks: BlockingDiv[] = [];
|
||||
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));
|
||||
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%",
|
||||
width: '100%',
|
||||
height: minTop,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// 底部大块
|
||||
@@ -53,9 +53,9 @@ export function getBlockingDivs(
|
||||
blocks.push({
|
||||
top: maxBottom,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
height: viewportHeight - maxBottom,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// 左侧大块(中间部分)
|
||||
@@ -65,7 +65,7 @@ export function getBlockingDivs(
|
||||
left: 0,
|
||||
width: minLeft,
|
||||
height: maxBottom - minTop,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// 右侧大块(中间部分)
|
||||
@@ -75,20 +75,20 @@ export function getBlockingDivs(
|
||||
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);
|
||||
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);
|
||||
const current = sortedByTop[i]!
|
||||
const next = sortedByTop[i + 1]!
|
||||
const gap = next.top - (current.top + current.height)
|
||||
|
||||
if (gap > 0) {
|
||||
blocks.push({
|
||||
@@ -96,15 +96,15 @@ export function getBlockingDivs(
|
||||
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);
|
||||
const current = sortedByLeft[i]!
|
||||
const next = sortedByLeft[i + 1]!
|
||||
const gap = next.left - (current.left + current.width)
|
||||
|
||||
if (gap > 0) {
|
||||
blocks.push({
|
||||
@@ -112,10 +112,10 @@ export function getBlockingDivs(
|
||||
left: current.left + current.width,
|
||||
width: gap,
|
||||
height: Math.min(current.height, next.height),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
return blocks
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn, getBackgroundImage } from "@/lib/utils";
|
||||
import { cn, getBackgroundImage } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* 两个标题(主标题、副标题)
|
||||
@@ -6,23 +6,18 @@ import { cn, getBackgroundImage } from "@/lib/utils";
|
||||
* subtitle: 副标题
|
||||
*/
|
||||
interface TitleProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
className?: string;
|
||||
subtitleClassName?: string;
|
||||
title: string
|
||||
subtitle: string
|
||||
className?: string
|
||||
subtitleClassName?: string
|
||||
}
|
||||
|
||||
const Title = ({
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
subtitleClassName,
|
||||
}: TitleProps) => {
|
||||
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",
|
||||
'w-[1168px] h-[149px] bg-cover bg-center bg-no-repeat relative',
|
||||
'flex justify-center items-center',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
@@ -31,20 +26,16 @@ const Title = ({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-5 left-1/2 -translate-x-1/2 text-white text-5xl font-bold whitespace-nowrap",
|
||||
"[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)]"
|
||||
'absolute -top-5 left-1/2 -translate-x-1/2 text-white text-5xl font-bold whitespace-nowrap',
|
||||
'[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 className={cn('text-[#5C3A1E] text-5xl font-bold', subtitleClassName)}>{subtitle}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export { Title };
|
||||
export { Title }
|
||||
@@ -1,8 +1,3 @@
|
||||
export { default } from "./video";
|
||||
export { default as Video } from "./video";
|
||||
export type {
|
||||
VideoProps,
|
||||
VideoRef,
|
||||
VideoAnchor,
|
||||
AnchorContext,
|
||||
} from "./video.types";
|
||||
export { default } from './video'
|
||||
export { default as Video } from './video'
|
||||
export type { VideoProps, VideoRef, VideoAnchor, AnchorContext } from './video.types'
|
||||
+362
-413
File diff suppressed because it is too large.
Load diff
@@ -1,54 +1,53 @@
|
||||
import type React from "react";
|
||||
import type React from 'react'
|
||||
|
||||
/** 锚点上下文,提供给锚点渲染函数 */
|
||||
export interface AnchorContext {
|
||||
/** 恢复播放 */
|
||||
resume: () => void;
|
||||
resume: () => void
|
||||
/** 取消静音 */
|
||||
unmute: () => void;
|
||||
unmute: () => void
|
||||
}
|
||||
|
||||
/** 视频锚点配置 */
|
||||
export interface VideoAnchor {
|
||||
/** 唯一标识 */
|
||||
id: string;
|
||||
id: string
|
||||
/** 触发时间(秒) */
|
||||
time: number;
|
||||
time: number
|
||||
/** 返回要渲染的 TSX */
|
||||
render: (ctx: AnchorContext) => React.ReactNode;
|
||||
render: (ctx: AnchorContext) => React.ReactNode
|
||||
/** 是否启用,默认 true */
|
||||
enabled?: boolean;
|
||||
enabled?: boolean
|
||||
/** 是否只触发一次(seek 回来不再触发),默认 false */
|
||||
once?: boolean;
|
||||
once?: boolean
|
||||
/** 恢复后的行为,默认 'hide' */
|
||||
afterResume?: "hide" | "remove" | "keep";
|
||||
afterResume?: 'hide' | 'remove' | 'keep'
|
||||
/** 锚点触发时的回调(如埋点) */
|
||||
onTrigger?: () => void;
|
||||
onTrigger?: () => void
|
||||
}
|
||||
|
||||
/** Video 组件 Props */
|
||||
export interface VideoProps
|
||||
extends React.VideoHTMLAttributes<HTMLVideoElement> {
|
||||
export interface VideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
|
||||
/** 锚点配置列表 */
|
||||
anchors?: VideoAnchor[];
|
||||
anchors?: VideoAnchor[]
|
||||
/** 时间更新回调 */
|
||||
onTimeUpdate?: (e: React.SyntheticEvent<HTMLVideoElement, Event>) => void;
|
||||
onTimeUpdate?: (e: React.SyntheticEvent<HTMLVideoElement, Event>) => void
|
||||
/** 是否显示跳过按钮(默认 false) */
|
||||
showSkipButton?: boolean;
|
||||
showSkipButton?: boolean
|
||||
/** 跳过按钮文案(默认 "跳过") */
|
||||
skipText?: string;
|
||||
skipText?: string
|
||||
/** 点击跳过时的回调 */
|
||||
onSkip?: () => void;
|
||||
onSkip?: () => void
|
||||
/** 自定义跳过按钮样式 */
|
||||
skipButtonClassName?: string;
|
||||
skipButtonClassName?: string
|
||||
}
|
||||
|
||||
/** Video 组件 Ref 暴露的方法 */
|
||||
export interface VideoRef {
|
||||
/** 原生 video 元素 */
|
||||
video: HTMLVideoElement | null;
|
||||
video: HTMLVideoElement | null
|
||||
/** 播放视频 */
|
||||
play: () => Promise<void>;
|
||||
play: () => Promise<void>
|
||||
/** 暂停视频 */
|
||||
pause: () => void;
|
||||
pause: () => void
|
||||
}
|
||||
+203
-209
@@ -5,384 +5,381 @@
|
||||
|
||||
export interface PuzzlePieceData {
|
||||
/** 碎片标题 */
|
||||
title: string;
|
||||
title: string
|
||||
/** 对应的场景图片路径(相对于 puzzle/ 目录) */
|
||||
detailImage: string;
|
||||
detailImage: string
|
||||
// 对应的场景路由
|
||||
route: string;
|
||||
route: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个故居的拼图碎片数据配置(每个故居碎片都是16块)
|
||||
* 格式:{ residence: { pieceId: PuzzlePieceData } }
|
||||
*/
|
||||
export const puzzleDataConfig: Record<
|
||||
string,
|
||||
Record<number, PuzzlePieceData>
|
||||
> = {
|
||||
export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> = {
|
||||
chenyun: {
|
||||
// 碎片1 - 示例数据,请根据实际情况修改
|
||||
1: {
|
||||
title: "水乡少年·艰率成长",
|
||||
detailImage: "chenyun/details/1",
|
||||
route: "place1",
|
||||
title: '水乡少年·艰率成长',
|
||||
detailImage: 'chenyun/details/1',
|
||||
route: 'place1',
|
||||
},
|
||||
// 碎片2
|
||||
2: {
|
||||
title: "陈云就读 青浦县立乙种商业学校",
|
||||
detailImage: "chenyun/details/2",
|
||||
route: "place2",
|
||||
title: '陈云就读 青浦县立乙种商业学校',
|
||||
detailImage: 'chenyun/details/2',
|
||||
route: 'place2',
|
||||
},
|
||||
// 碎片3
|
||||
3: {
|
||||
title: "陈云就读颜安小学",
|
||||
detailImage: "chenyun/details/3",
|
||||
route: "place2",
|
||||
title: '陈云就读颜安小学',
|
||||
detailImage: 'chenyun/details/3',
|
||||
route: 'place2',
|
||||
},
|
||||
// 碎片4
|
||||
4: {
|
||||
title: "陈云在 上海商务印书馆当学徒",
|
||||
detailImage: "chenyun/details/4",
|
||||
route: "place3",
|
||||
title: '陈云在 上海商务印书馆当学徒',
|
||||
detailImage: 'chenyun/details/4',
|
||||
route: 'place3',
|
||||
},
|
||||
// 碎片5
|
||||
5: {
|
||||
title: "陈云加入中国共产党",
|
||||
detailImage: "chenyun/details/5",
|
||||
route: "place3",
|
||||
title: '陈云加入中国共产党',
|
||||
detailImage: 'chenyun/details/5',
|
||||
route: 'place3',
|
||||
},
|
||||
// 碎片6
|
||||
6: {
|
||||
title: "陈云被选为 中央政治局委员、常委",
|
||||
detailImage: "chenyun/details/6",
|
||||
route: "place4",
|
||||
title: '陈云被选为 中央政治局委员、常委',
|
||||
detailImage: 'chenyun/details/6',
|
||||
route: 'place4',
|
||||
},
|
||||
// 碎片7
|
||||
7: {
|
||||
title: "陈云任 西北财经办事处副主任",
|
||||
detailImage: "chenyun/details/7",
|
||||
route: "place5",
|
||||
title: '陈云任 西北财经办事处副主任',
|
||||
detailImage: 'chenyun/details/7',
|
||||
route: 'place5',
|
||||
},
|
||||
// 碎片8
|
||||
8: {
|
||||
title: "陈云主持 陕甘宁边区财政工作",
|
||||
detailImage: "chenyun/details/8",
|
||||
route: "place5",
|
||||
title: '陈云主持 陕甘宁边区财政工作',
|
||||
detailImage: 'chenyun/details/8',
|
||||
route: 'place5',
|
||||
},
|
||||
// 碎片9
|
||||
9: {
|
||||
title: "恢复时期·经济奠基",
|
||||
detailImage: "chenyun/details/9",
|
||||
route: "place6",
|
||||
title: '恢复时期·经济奠基',
|
||||
detailImage: 'chenyun/details/9',
|
||||
route: 'place6',
|
||||
},
|
||||
// 碎片10
|
||||
10: {
|
||||
title: "银元之战",
|
||||
detailImage: "chenyun/details/10",
|
||||
route: "place7",
|
||||
title: '银元之战',
|
||||
detailImage: 'chenyun/details/10',
|
||||
route: 'place7',
|
||||
},
|
||||
// 碎片11
|
||||
11: {
|
||||
title: '陈云起草的关于发展国民 经济"一五"计划报告的手稿',
|
||||
detailImage: "chenyun/details/11",
|
||||
route: "place8",
|
||||
detailImage: 'chenyun/details/11',
|
||||
route: 'place8',
|
||||
},
|
||||
// 碎片12
|
||||
12: {
|
||||
title: "陈云赴宝钢考察",
|
||||
detailImage: "chenyun/details/12",
|
||||
route: "place9",
|
||||
title: '陈云赴宝钢考察',
|
||||
detailImage: 'chenyun/details/12',
|
||||
route: 'place9',
|
||||
},
|
||||
// 碎片13
|
||||
13: {
|
||||
title: "体悟·银元之战",
|
||||
detailImage: "chenyun/details/13",
|
||||
route: "reflection",
|
||||
title: '体悟·银元之战',
|
||||
detailImage: 'chenyun/details/13',
|
||||
route: 'reflection',
|
||||
},
|
||||
// 碎片14
|
||||
14: {
|
||||
title: "体悟·米棉之战",
|
||||
detailImage: "chenyun/details/14",
|
||||
route: "reflection",
|
||||
title: '体悟·米棉之战',
|
||||
detailImage: 'chenyun/details/14',
|
||||
route: 'reflection',
|
||||
},
|
||||
// 碎片15
|
||||
15: {
|
||||
title: "陈云经济思想的核心内容",
|
||||
detailImage: "chenyun/details/15",
|
||||
route: "internalization",
|
||||
title: '陈云经济思想的核心内容',
|
||||
detailImage: 'chenyun/details/15',
|
||||
route: 'internalization',
|
||||
},
|
||||
// 碎片16
|
||||
16: {
|
||||
title: "陈云精神",
|
||||
detailImage: "chenyun/details/16",
|
||||
route: "internalization",
|
||||
title: '陈云精神',
|
||||
detailImage: 'chenyun/details/16',
|
||||
route: 'internalization',
|
||||
},
|
||||
},
|
||||
huangyanpei: {
|
||||
// 碎片1
|
||||
1: {
|
||||
title: "杰出的爱国民主人士黄炎培",
|
||||
detailImage: "huangyanpei/details/1",
|
||||
route: "place1",
|
||||
title: '杰出的爱国民主人士黄炎培',
|
||||
detailImage: 'huangyanpei/details/1',
|
||||
route: 'place1',
|
||||
},
|
||||
// 碎片2
|
||||
2: {
|
||||
title: "南洋公学",
|
||||
detailImage: "huangyanpei/details/2",
|
||||
route: "place2",
|
||||
title: '南洋公学',
|
||||
detailImage: 'huangyanpei/details/2',
|
||||
route: 'place2',
|
||||
},
|
||||
// 碎片3
|
||||
3: {
|
||||
title: "川沙小学堂",
|
||||
detailImage: "huangyanpei/details/3",
|
||||
route: "place3",
|
||||
title: '川沙小学堂',
|
||||
detailImage: 'huangyanpei/details/3',
|
||||
route: 'place3',
|
||||
},
|
||||
// 碎片4
|
||||
4: {
|
||||
title: "开群女学课堂",
|
||||
detailImage: "huangyanpei/details/4",
|
||||
route: "place3",
|
||||
title: '开群女学课堂',
|
||||
detailImage: 'huangyanpei/details/4',
|
||||
route: 'place3',
|
||||
},
|
||||
// 碎片5
|
||||
5: {
|
||||
title: "杨斯盛创建浦东中学",
|
||||
detailImage: "huangyanpei/details/5",
|
||||
route: "place3",
|
||||
title: '杨斯盛创建浦东中学',
|
||||
detailImage: 'huangyanpei/details/5',
|
||||
route: 'place3',
|
||||
},
|
||||
// 碎片6
|
||||
6: {
|
||||
title: "中华职业教育 和中华职业学校",
|
||||
detailImage: "huangyanpei/details/6",
|
||||
route: "place4",
|
||||
title: '中华职业教育 和中华职业学校',
|
||||
detailImage: 'huangyanpei/details/6',
|
||||
route: 'place4',
|
||||
},
|
||||
// 碎片7
|
||||
7: {
|
||||
title: "中华职业教育社会址",
|
||||
detailImage: "huangyanpei/details/7",
|
||||
route: "place4",
|
||||
title: '中华职业教育社会址',
|
||||
detailImage: 'huangyanpei/details/7',
|
||||
route: 'place4',
|
||||
},
|
||||
// 碎片8
|
||||
8: {
|
||||
title: "抗敌后援会成立大会",
|
||||
detailImage: "huangyanpei/details/8",
|
||||
route: "place4",
|
||||
title: '抗敌后援会成立大会',
|
||||
detailImage: 'huangyanpei/details/8',
|
||||
route: 'place4',
|
||||
},
|
||||
// 碎片9
|
||||
9: {
|
||||
title: "中华职校组织义勇军",
|
||||
detailImage: "huangyanpei/details/9",
|
||||
route: "place4",
|
||||
title: '中华职校组织义勇军',
|
||||
detailImage: 'huangyanpei/details/9',
|
||||
route: 'place4',
|
||||
},
|
||||
// 碎片10
|
||||
10: {
|
||||
title: "黄炎培与荣氏兄弟 合作工人教育",
|
||||
detailImage: "huangyanpei/details/10",
|
||||
route: "place4",
|
||||
title: '黄炎培与荣氏兄弟 合作工人教育',
|
||||
detailImage: 'huangyanpei/details/10',
|
||||
route: 'place4',
|
||||
},
|
||||
// 碎片11
|
||||
11: {
|
||||
title: "黄炎培创建民盟",
|
||||
detailImage: "huangyanpei/details/11",
|
||||
route: "place5",
|
||||
title: '黄炎培创建民盟',
|
||||
detailImage: 'huangyanpei/details/11',
|
||||
route: 'place5',
|
||||
},
|
||||
// 碎片12
|
||||
12: {
|
||||
title: "窑洞对话",
|
||||
detailImage: "huangyanpei/details/12",
|
||||
route: "place5",
|
||||
title: '窑洞对话',
|
||||
detailImage: 'huangyanpei/details/12',
|
||||
route: 'place5',
|
||||
},
|
||||
// 碎片13
|
||||
13: {
|
||||
title: "黄炎培参加第一届全国人大",
|
||||
detailImage: "huangyanpei/details/13",
|
||||
route: "place6",
|
||||
title: '黄炎培参加第一届全国人大',
|
||||
detailImage: 'huangyanpei/details/13',
|
||||
route: 'place6',
|
||||
},
|
||||
// 碎片14
|
||||
14: {
|
||||
title: "黄炎培对浦东中学新生的教导",
|
||||
detailImage: "huangyanpei/details/14",
|
||||
route: "reflection2",
|
||||
title: '黄炎培对浦东中学新生的教导',
|
||||
detailImage: 'huangyanpei/details/14',
|
||||
route: 'reflection2',
|
||||
},
|
||||
// 碎片15
|
||||
15: {
|
||||
title: "中华职业学校的立业之本",
|
||||
detailImage: "huangyanpei/details/15",
|
||||
route: "reflection4",
|
||||
title: '中华职业学校的立业之本',
|
||||
detailImage: 'huangyanpei/details/15',
|
||||
route: 'reflection4',
|
||||
},
|
||||
// 碎片16
|
||||
16: {
|
||||
title: "黄炎培的”实业救国“情怀”",
|
||||
detailImage: "huangyanpei/details/16",
|
||||
route: "internalization",
|
||||
title: '黄炎培的”实业救国“情怀”',
|
||||
detailImage: 'huangyanpei/details/16',
|
||||
route: 'internalization',
|
||||
},
|
||||
},
|
||||
sunzhongshan: {
|
||||
1: {
|
||||
title: "革命四杰",
|
||||
detailImage: "sunzhongshan/details/1",
|
||||
route: "place1",
|
||||
title: '革命四杰',
|
||||
detailImage: 'sunzhongshan/details/1',
|
||||
route: 'place1',
|
||||
},
|
||||
2: {
|
||||
title: "郑观应的推荐信郑观应的推荐信",
|
||||
detailImage: "sunzhongshan/details/2",
|
||||
route: "place1",
|
||||
title: '郑观应的推荐信郑观应的推荐信',
|
||||
detailImage: 'sunzhongshan/details/2',
|
||||
route: 'place1',
|
||||
},
|
||||
3: {
|
||||
title: "《申报》刊登孙中山就职 临时大总统消息",
|
||||
detailImage: "sunzhongshan/details/3",
|
||||
route: "place1",
|
||||
title: '《申报》刊登孙中山就职 临时大总统消息',
|
||||
detailImage: 'sunzhongshan/details/3',
|
||||
route: 'place1',
|
||||
},
|
||||
4: {
|
||||
title: "《民立报》报道孙中山解职",
|
||||
detailImage: "sunzhongshan/details/4",
|
||||
route: "place1",
|
||||
title: '《民立报》报道孙中山解职',
|
||||
detailImage: 'sunzhongshan/details/4',
|
||||
route: 'place1',
|
||||
},
|
||||
5: {
|
||||
title: "中国铁路计划图",
|
||||
detailImage: "sunzhongshan/details/5",
|
||||
route: "place2",
|
||||
title: '中国铁路计划图',
|
||||
detailImage: 'sunzhongshan/details/5',
|
||||
route: 'place2',
|
||||
},
|
||||
6: {
|
||||
title: "实业计划",
|
||||
detailImage: "sunzhongshan/details/6",
|
||||
route: "place2",
|
||||
title: '实业计划',
|
||||
detailImage: 'sunzhongshan/details/6',
|
||||
route: 'place2',
|
||||
},
|
||||
7: {
|
||||
title: "护法运动",
|
||||
detailImage: "sunzhongshan/details/7",
|
||||
route: "place2",
|
||||
title: '护法运动',
|
||||
detailImage: 'sunzhongshan/details/7',
|
||||
route: 'place2',
|
||||
},
|
||||
8: {
|
||||
title: "社会主义的精义",
|
||||
detailImage: "sunzhongshan/details/8",
|
||||
route: "place3",
|
||||
title: '社会主义的精义',
|
||||
detailImage: 'sunzhongshan/details/8',
|
||||
route: 'place3',
|
||||
},
|
||||
9: {
|
||||
title: "李大钊给胡适的信",
|
||||
detailImage: "sunzhongshan/details/9",
|
||||
route: "place3",
|
||||
title: '李大钊给胡适的信',
|
||||
detailImage: 'sunzhongshan/details/9',
|
||||
route: 'place3',
|
||||
},
|
||||
10: {
|
||||
title: "《中国国民党第一次全国代表大会宣言》",
|
||||
detailImage: "sunzhongshan/details/10",
|
||||
route: "place3",
|
||||
title: '《中国国民党第一次全国代表大会宣言》',
|
||||
detailImage: 'sunzhongshan/details/10',
|
||||
route: 'place3',
|
||||
},
|
||||
11: {
|
||||
title: "国事遗嘱",
|
||||
detailImage: "sunzhongshan/details/11",
|
||||
route: "place4",
|
||||
title: '国事遗嘱',
|
||||
detailImage: 'sunzhongshan/details/11',
|
||||
route: 'place4',
|
||||
},
|
||||
12: {
|
||||
title: "第二次护法运动推理小游戏",
|
||||
detailImage: "sunzhongshan/details/12",
|
||||
route: "reflection1",
|
||||
title: '第二次护法运动推理小游戏',
|
||||
detailImage: 'sunzhongshan/details/12',
|
||||
route: 'reflection1',
|
||||
},
|
||||
13: {
|
||||
title: "国共合作推理小游戏",
|
||||
detailImage: "sunzhongshan/details/13",
|
||||
route: "reflection2",
|
||||
title: '国共合作推理小游戏',
|
||||
detailImage: 'sunzhongshan/details/13',
|
||||
route: 'reflection2',
|
||||
},
|
||||
14: {
|
||||
title: "抱病北上推理小游戏",
|
||||
detailImage: "sunzhongshan/details/14",
|
||||
route: "reflection3",
|
||||
title: '抱病北上推理小游戏',
|
||||
detailImage: 'sunzhongshan/details/14',
|
||||
route: 'reflection3',
|
||||
},
|
||||
15: {
|
||||
title: "振兴中华对新时代青年的意义",
|
||||
detailImage: "sunzhongshan/details/15",
|
||||
route: "internalization",
|
||||
title: '振兴中华对新时代青年的意义',
|
||||
detailImage: 'sunzhongshan/details/15',
|
||||
route: 'internalization',
|
||||
},
|
||||
16: {
|
||||
title: "学习孙中山的精神",
|
||||
detailImage: "sunzhongshan/details/16",
|
||||
route: "internalization",
|
||||
}
|
||||
title: '学习孙中山的精神',
|
||||
detailImage: 'sunzhongshan/details/16',
|
||||
route: 'internalization',
|
||||
},
|
||||
},
|
||||
luxun: {
|
||||
1: {
|
||||
title: "鲁迅1927年南下广州时的照片",
|
||||
detailImage: "luxun/details/1",
|
||||
route: "place1",
|
||||
title: '鲁迅1927年南下广州时的照片',
|
||||
detailImage: 'luxun/details/1',
|
||||
route: 'place1',
|
||||
},
|
||||
2: {
|
||||
title: "景云里旧居场景图",
|
||||
detailImage: "luxun/details/2",
|
||||
route: "place1",
|
||||
title: '景云里旧居场景图',
|
||||
detailImage: 'luxun/details/2',
|
||||
route: 'place1',
|
||||
},
|
||||
3: {
|
||||
title: "《且介亭杂文》",
|
||||
detailImage: "luxun/details/3",
|
||||
route: "place2",
|
||||
title: '《且介亭杂文》',
|
||||
detailImage: 'luxun/details/3',
|
||||
route: 'place2',
|
||||
},
|
||||
4: {
|
||||
title: "《中国人失掉自信力了吗》",
|
||||
detailImage: "luxun/details/4",
|
||||
route: "place2",
|
||||
title: '《中国人失掉自信力了吗》',
|
||||
detailImage: 'luxun/details/4',
|
||||
route: 'place2',
|
||||
},
|
||||
5: {
|
||||
title: "《南腔北调集》",
|
||||
detailImage: "luxun/details/5",
|
||||
route: "place2",
|
||||
title: '《南腔北调集》',
|
||||
detailImage: 'luxun/details/5',
|
||||
route: 'place2',
|
||||
},
|
||||
6: {
|
||||
title: "《故事新编》",
|
||||
detailImage: "luxun/details/6",
|
||||
route: "place3",
|
||||
title: '《故事新编》',
|
||||
detailImage: 'luxun/details/6',
|
||||
route: 'place3',
|
||||
},
|
||||
7: {
|
||||
title: "《理水》手迹",
|
||||
detailImage: "luxun/details/7",
|
||||
route: "place3",
|
||||
title: '《理水》手迹',
|
||||
detailImage: 'luxun/details/7',
|
||||
route: 'place3',
|
||||
},
|
||||
8: {
|
||||
title: "书架上的翻译著作",
|
||||
detailImage: "luxun/details/8",
|
||||
route: "place3",
|
||||
title: '书架上的翻译著作',
|
||||
detailImage: 'luxun/details/8',
|
||||
route: 'place3',
|
||||
},
|
||||
9: {
|
||||
title: "《海上述林》书册",
|
||||
detailImage: "luxun/details/9",
|
||||
route: "place3",
|
||||
title: '《海上述林》书册',
|
||||
detailImage: 'luxun/details/9',
|
||||
route: 'place3',
|
||||
},
|
||||
10: {
|
||||
title: "书册上的校订批注",
|
||||
detailImage: "luxun/details/10",
|
||||
route: "place3",
|
||||
title: '书册上的校订批注',
|
||||
detailImage: 'luxun/details/10',
|
||||
route: 'place3',
|
||||
},
|
||||
11: {
|
||||
title: "瞿秋白对鲁迅的评价",
|
||||
detailImage: "luxun/details/11",
|
||||
route: "place4",
|
||||
title: '瞿秋白对鲁迅的评价',
|
||||
detailImage: 'luxun/details/11',
|
||||
route: 'place4',
|
||||
},
|
||||
12: {
|
||||
title: "鲁迅赠瞿秋白的对联",
|
||||
detailImage: "luxun/details/12",
|
||||
route: "place4",
|
||||
title: '鲁迅赠瞿秋白的对联',
|
||||
detailImage: 'luxun/details/12',
|
||||
route: 'place4',
|
||||
},
|
||||
13: {
|
||||
title: "冯雪峰与鲁迅谈话的资料图",
|
||||
detailImage: "luxun/details/13",
|
||||
route: "place4",
|
||||
title: '冯雪峰与鲁迅谈话的资料图',
|
||||
detailImage: 'luxun/details/13',
|
||||
route: 'place4',
|
||||
},
|
||||
14: {
|
||||
title: "鲁迅创作历程",
|
||||
detailImage: "luxun/details/14",
|
||||
route: "reflection1",
|
||||
title: '鲁迅创作历程',
|
||||
detailImage: 'luxun/details/14',
|
||||
route: 'reflection1',
|
||||
},
|
||||
15: {
|
||||
title: "鲁迅精神的总结",
|
||||
detailImage: "luxun/details/15",
|
||||
route: "reflection3",
|
||||
title: '鲁迅精神的总结',
|
||||
detailImage: 'luxun/details/15',
|
||||
route: 'reflection3',
|
||||
},
|
||||
16: {
|
||||
title: "鲁迅在木刻上对青年的培养",
|
||||
detailImage: "luxun/details/16",
|
||||
route: "internalization",
|
||||
}
|
||||
title: '鲁迅在木刻上对青年的培养',
|
||||
detailImage: 'luxun/details/16',
|
||||
route: 'internalization',
|
||||
},
|
||||
},
|
||||
// 后续可以添加其他故居的数据
|
||||
// sunzhongshan: { ... },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取碎片的数据配置
|
||||
@@ -390,11 +387,8 @@ export const puzzleDataConfig: Record<
|
||||
* @param pieceId 碎片ID
|
||||
* @returns 碎片数据,如果不存在则返回默认值
|
||||
*/
|
||||
export const getPuzzlePieceData = (
|
||||
residence: string,
|
||||
pieceId: number
|
||||
): PuzzlePieceData | null => {
|
||||
const residenceConfig = puzzleDataConfig[residence];
|
||||
const pieceData = residenceConfig?.[pieceId];
|
||||
return pieceData || null;
|
||||
};
|
||||
export const getPuzzlePieceData = (residence: string, pieceId: number): PuzzlePieceData | null => {
|
||||
const residenceConfig = puzzleDataConfig[residence]
|
||||
const pieceData = residenceConfig?.[pieceId]
|
||||
return pieceData || null
|
||||
}
|
||||
+25
-28
@@ -5,9 +5,9 @@
|
||||
|
||||
export interface PuzzlePathConfig {
|
||||
/** viewBox */
|
||||
viewBox: string;
|
||||
viewBox: string
|
||||
/** 每个碎片的路径数据 */
|
||||
pieces: Record<number, string>;
|
||||
pieces: Record<number, string>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,9 +17,9 @@ export interface PuzzlePathConfig {
|
||||
* 因此这里使用单例配置,而不是按 residence 区分。
|
||||
*/
|
||||
export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
viewBox: "0 0 748.92 747.84",
|
||||
viewBox: '0 0 748.92 747.84',
|
||||
pieces: {
|
||||
1: `M183.45,10.9c1.43,18.24,0.5,50.8,0.77,51.72c-0.08,0.84,0.36,4.83,1.93,7.2c1.57,2.37,4.26,3.11,6.05,3.09
|
||||
1: `M183.45,10.9c1.43,18.24,0.5,50.8,0.77,51.72c-0.08,0.84,0.36,4.83,1.93,7.2c1.57,2.37,4.26,3.11,6.05,3.09
|
||||
c4.55,0.77,11.08-0.12,15.31-2.06c4.24-1.94,8.62-4.28,13.33-4.36c9.76-0.15,16.29,3.39,19.44,9.75
|
||||
c2.45,4.95,3.29,11.07,3.43,15.04c0.14,3.97-1.91,10.2-5.1,15.06c-7.34,8.59-13.38,10.01-25.33,7.75
|
||||
c-3.65-1.19-6.82-3.14-10.27-4.77c-4.72-2.23-10.96-1.46-14.54,0.51s-4.59,7.35-4.63,12.61c-0.13,16.61,0.51,52.67,0.46,54.33
|
||||
@@ -29,7 +29,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c-15.78,0.05-31.55,0.02-47.33,0c-1.97,0-4,0.24-5.86-0.78c-0.8-0.62-1.06-1.45-1-2.43c0-56.64,0-113.27,0-169.9
|
||||
c0-2.6-0.65-5.47,2.57-6.88L5.42,3.69c1.5-1.09,3.23-0.57,4.84-0.57c56.4-0.03,112.8-0.03,169.2,0.02c0.76,0,3.2-0.03,3.99,1.71
|
||||
C184.24,6.59,183.39,10.12,183.45,10.9z`,
|
||||
2: `M188.09,4.59c0.34-0.32,0.67-0.64,1.01-0.97c1.71-1,3.58-0.5,5.36-0.5c57.1-0.03,114.19-0.03,171.29-0.02
|
||||
2: `M188.09,4.59c0.34-0.32,0.67-0.64,1.01-0.97c1.71-1,3.58-0.5,5.36-0.5c57.1-0.03,114.19-0.03,171.29-0.02
|
||||
c2.46,0,5.04-0.47,7.01,1.64l-0.1-0.13c0.86,0.78,0.64,1.83,0.64,2.8c0,8.4-0.1,16.81-0.16,25.22c-0.06,8.83,0.27,17.45,0.16,26.47
|
||||
c-0.01,0.81,0.27,4.82-1.54,7.37c-0.37,0.52-2.22,2.48-4.72,2.88c-3.92,0.62-7.81-1.87-10.2-3.3c-9.85-5.19-22.79-7.85-31.85,0.15
|
||||
c-7.66,6.78-12.92,19.18-10.98,29.22c2.05,10.56,10.29,20.17,21.18,22.88c5.74,1.43,13.37,0.25,18.86-2.32
|
||||
@@ -41,7 +41,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c1.53,0.52,7.65,4.16,12.64,5.39c6.31,1.55,11.68,0.63,13.36,0.19c22.13-5.1,23.79-31.22,15.62-45.19
|
||||
c-2.42-3.51-5.79-6.97-9.12-8.76c-4.83-2.59-9.77-2.78-15.6-1.85c-4.13,0.66-8.56,3.2-12.31,5.02c-3.17,1.54-8.76,3.65-12.39,2.43
|
||||
c-3.94-1.51-5.05-4.02-5.04-8.48c0.03-18.3,0.01-36.6,0.01-54.9C187.63,5.81,187.72,5.17,188.09,4.59z`,
|
||||
3: `M562.57,67.53c2.08,2.53,5.24,4.32,7.69,4.7c2.86,0.45,7.3-0.98,10.44-2.18c4.49-1.68,8.08-4.5,13.08-4.93
|
||||
3: `M562.57,67.53c2.08,2.53,5.24,4.32,7.69,4.7c2.86,0.45,7.3-0.98,10.44-2.18c4.49-1.68,8.08-4.5,13.08-4.93
|
||||
c15.4-1.33,25.8,9.73,25.36,25.36c-0.45,15.95-9.07,27.01-24.1,26.05c-3.22-0.21-6.88-2.19-9.75-3.56
|
||||
c-2.55-1.22-5.45-2.78-8.15-3.67c-8.04-2.65-16.65,1.83-16.75,10.33c-0.21,16.95,0.72,33.3,0.69,50.26c0,1.83-0.01,5.52-0.11,7.34
|
||||
c-0.2,3.59-0.22,5.17-3.79,5.38c-1,0.06-1.99,0.1-2.99,0.1c-17.29,0.01-34.58,0.02-51.87,0c-1.66,0-3.32-0.06-4.96-0.39
|
||||
@@ -53,7 +53,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c3.96,1.03,9.79-0.67,10.89-2.06c2.7-3.42,2.73-8.79,2.72-12.29c-0.07-15.58-0.04-31.15-0.01-46.73c0-1.63-0.41-3.32,0.45-4.88
|
||||
c0.33-0.31,0.66-0.63,0.99-0.94c1.53-1.01,3.24-0.52,4.86-0.53C439.39,3.09,497.12,3.1,554.86,3.1c6.39,0,7.14,0.76,7.14,7.16
|
||||
c0.01,12.15,0.02,24.29-0.02,36.44C561.97,48.31,560.76,65.58,562.57,67.53z`,
|
||||
4: `M684.07,182.22c-8.23,1.12-11.9,6.11-10.48,14.23c1.05,5.97,4.32,11.13,6.04,16.87
|
||||
4: `M684.07,182.22c-8.23,1.12-11.9,6.11-10.48,14.23c1.05,5.97,4.32,11.13,6.04,16.87
|
||||
c4.95,16.46-8.81,33.55-25.83,31.99c-9.08-0.84-18.04-6.51-20.55-16.1c-0.21-0.79-0.93-6.95,0.09-13.43
|
||||
c1.04-6.64,6.43-13.59,6.87-15.13c1.17-4.09,0.81-10.62-0.93-13.19c-2.09-3.09-7.34-4.96-12.48-5c-16.4-0.75-54.38,0.2-56.05,0.14
|
||||
c-6.08-0.25-6.48-0.21-6.57-6.84c-0.08-5.46-0.1-10.92-0.08-16.38c0.06-13.57-0.11-21.8-0.34-34.47c0.01-12.18,6.53-15.92,15.2-11
|
||||
@@ -62,7 +62,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c-0.06-15.07,0.04-29.1,0.07-44.16c0-1.52-0.39-3.11,0.33-4.58c0.1-0.15,0.27-0.4,0.37-0.55c1.68-1.86,3.7-1.18,5.58-1.19
|
||||
C627.4,3.09,684.46,3.1,741.52,3.1c6.07,0,6.19,0.11,6.19,6.07c0.01,55.9-0.04,111.79,0.09,167.69c0.01,3.85-0.89,5.08-4.91,5.03
|
||||
c-17.8-0.22-35.6-0.11-53.4-0.08C687.68,181.81,685.82,181.4,684.07,182.22z`,
|
||||
5: `M69.29,372.08c-1.77-2.35-4.33-2.08-6.82-2.08c-15.96,0-31.92,0-47.88,0c-7.25,0-11.6-2.75-11.6-9.82
|
||||
5: `M69.29,372.08c-1.77-2.35-4.33-2.08-6.82-2.08c-15.96,0-31.92,0-47.88,0c-7.25,0-11.6-2.75-11.6-9.82
|
||||
c0-55.03,0.34-110.13,0.34-165.16c0-6.78,2.49-9.65,9.43-9.65c14.63-0.01,29.47,0,44.1-0.06c0.95,0,10.35-0.97,15-5.9
|
||||
c3.23-3.42,2.88-9.68,2.44-11.28s-3.2-9.48-4.62-13.01c-4.54-11.34-2.28-21.08,6.87-28.52c8.12-6.61,19.64-7.38,27.85-1.86
|
||||
c9.37,6.31,15.08,18.4,10.12,28.93c-1.69,3.58-7.53,18.17-3.55,24.79c2.12,4.55,8.04,6.74,12.41,6.8
|
||||
@@ -73,7 +73,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c-16.01,0.01-20.6,6.43-15.65,21.81c1.83,5.68,3.94,11.28,4.31,17.35c0.65,10.57-5,17.19-13.63,21.85
|
||||
c-8.29,4.48-16.22,3.79-23.73-2.08c-10.33-8.07-13.04-17.15-8.65-29.45c1.84-5.15,4.31-10.11,4.98-15.63
|
||||
C75.09,379.41,74.19,374.92,69.29,372.08z`,
|
||||
6: `M305.86,215.26c0.07-2.85-1.9-8.06-1.9-8.06s-2.16-6.41-3.35-9.49c-3.07-7.94,0.43-12.87,9.72-12.18
|
||||
6: `M305.86,215.26c0.07-2.85-1.9-8.06-1.9-8.06s-2.16-6.41-3.35-9.49c-3.07-7.94,0.43-12.87,9.72-12.18
|
||||
c19.94-0.3,39.89-0.13,59.83-0.22c3.74-0.02,3.73,2.34,3.73,5.01c0,18.12,0.05,36.23,0.08,54.35c0,0.83,0,1.66,0.02,2.49
|
||||
c0.12,5.82,0.35,10.62,3.04,13.08c1.52,1.4,3.75,2.07,6.35,2.06c2.6-0.01,5.56-0.71,8.55-2.06c1.21-0.55,4.88-3.01,6.08-3.58
|
||||
c14.65-6.96,24.68,0.83,31.23,15.5c3.07,13.14-0.85,24.6-14.13,30.22c-0.76,0.32-1.57,0.54-2.36,0.81
|
||||
@@ -89,7 +89,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c10.06,3.98,16.45-2.03,16.66-12.83c0.22-11.11-0.56-19.98-0.55-31.1c0-8.3-0.09-17.51-0.23-25.8c-0.05-3.26,1.61-6.18,5.27-6.15
|
||||
c19.58,0.13,39.17,0.1,58.75,0.05c4.18-0.01,8.08,1,8.99,5.94s-3.48,12.44-5.93,18.89c-3.29,8.68-2.57,19.34,2.35,27.5
|
||||
c5.32,8.83,17.88,14.21,27.17,12.41c10.32-2,19.22-6.53,22.25-17.55C307.5,227.29,307.28,220.48,305.86,215.26z`,
|
||||
7: `M405.35,306.99c13.2,0.78,24.03-6.49,28.08-19.2c4.18-13.14-4.54-33.18-20.07-36.18
|
||||
7: `M405.35,306.99c13.2,0.78,24.03-6.49,28.08-19.2c4.18-13.14-4.54-33.18-20.07-36.18
|
||||
c-5.66-1.1-11.76-0.44-16.9,1.52c-2.48,0.95-6.15,3.96-8.63,4.91c-7.6,2.93-10.83-1.78-10.95-9.88
|
||||
c-0.13-9.14-0.04-18.29-0.05-27.44c0-9.81,0.25-19.63-0.11-29.43c-0.18-4.98,1.63-6.16,6.31-6.08c15.96,0.25,31.93,0.13,47.89,0.07
|
||||
c2.81-0.01,8,0.37,10.75-0.14c10.43-1.93,11.55-8.68,8.13-19.07c-1.61-4.52-3.45-8.98-4.78-13.58
|
||||
@@ -103,7 +103,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c-0.95-7.61,1.9-14.36,4.74-21.12c1.09-2.6,2.15-5.2,2.42-8.07c0.88-9.5-3.4-14.61-13-14.7c-18.11-0.17-36.23-0.07-54.35-0.08
|
||||
c-6.64,0-6.65-0.01-6.65-6.57c0.01-17.45-0.01-34.9,0.05-52.35c-0.47-10.08,2.9-13.59,14.67-8.96
|
||||
C393.57,302.85,401.04,306.93,405.35,306.99z`,
|
||||
8: `M629.94,230.44c2.79,11.07,11.69,17,21.52,18.56c11.58,1.84,20.63-4.03,27.25-12.14
|
||||
8: `M629.94,230.44c2.79,11.07,11.69,17,21.52,18.56c11.58,1.84,20.63-4.03,27.25-12.14
|
||||
c6.03-7.39,6.34-14.84,3.67-23.85c-1.55-5.25-3.72-10.32-5.24-15.58c-2.16-7.49,1.02-12.56,8.65-12.7
|
||||
c19.45-0.35,38.92-0.16,58.38-0.21c2.35-0.01,3.34,1.06,3.2,3.35c-0.05,0.83,0,1.66,0,2.49c0,58.05-0.05,116.1,0.1,174.15
|
||||
c0.01,4.34-1,5.6-5.46,5.52c-17.13-0.28-34.26-0.09-51.39-0.14c-2.14-0.01-4.33-0.17-6.41-0.65c-6.06-1.39-8.94-5.92-7.36-11.92
|
||||
@@ -115,7 +115,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c5.28-1.58,19.8,4.62,22.43,5.63c6.06,2.34,13.76-0.58,16.42-5.16c1.6-2.75,1.67-8.7,1.71-12.62c0.18-16.62-0.42-27.93-0.4-44.55
|
||||
c0.01-12.12-0.51-12.38,11.44-12.38c16.29,0,31.89-0.3,48.18-0.27c12.63,0.02,15.62,5.72,11.53,17.83
|
||||
c-0.43,1.26-4.5,7.61-5.07,9.76C628.81,218.73,628.59,224.07,629.94,230.44z`,
|
||||
9: `M84.53,496.23c-15.47,3.69-25.25,18.2-19.18,34.57c1.32,3.57,3.21,8.61,4.34,12.23
|
||||
9: `M84.53,496.23c-15.47,3.69-25.25,18.2-19.18,34.57c1.32,3.57,3.21,8.61,4.34,12.23
|
||||
c2.6,8.32,0.13,14.79-8.37,16.34c-0.51,0.09-2.99,0.59-6.16,0.79c-7.03,0.45-17.55-0.33-20.05-0.32
|
||||
c-12.31,0.09-11.59-0.64-23.9-0.66c-5.69-0.01-6.59-0.92-6.59-6.71c-0.01-57.89-0.01-115.78,0-173.67c0-5.73,1.55-4.91,7.33-4.96
|
||||
c15.8-0.14,48.31-0.41,48.81-0.39c12.8,0.41,10.63,12.15,7.98,18.03c-2.65,5.88-3.64,13.67-4.71,20
|
||||
@@ -127,7 +127,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c-0.14,2.99-0.39,8.57-0.39,11.56c0,14.97-0.21,29.06-0.27,44.03c-0.03,7.55-2.64,8.64-9.99,8.66c-17.63,0.05-31.79,0.11-49.43,0
|
||||
c-5.91-0.04-11.34-1.3-13.63-7.83c-0.7-5.73,0.3-11.21,2.35-16.57c1.9-4.98,3.19-10.04,3.15-15.49
|
||||
c-0.1-13.19-7.41-22.07-20.48-23.96C94.1,495.68,88.65,495.09,84.53,496.23z`,
|
||||
10: `M279.15,622.5c-10.41,0.05-25.44-10.63-23.06-25.85c0.58-4.52,4.45-12.5,6.79-18.7
|
||||
10: `M279.15,622.5c-10.41,0.05-25.44-10.63-23.06-25.85c0.58-4.52,4.45-12.5,6.79-18.7
|
||||
c4.57-12.09-0.28-19.29-13.16-19.36c-18.79-0.1-37.57-0.13-56.36,0.06c-4.18,0.04-5.46-1.31-5.41-5.44
|
||||
c0.19-17.62,0.09-35.24,0.1-52.87c0-1.33-0.03-2.67,0.15-3.98c1.12-8.23,5.66-10.84,13.25-7.51c3.49,1.53,6.79,3.47,10.49,4.56
|
||||
c11.1,3.29,23.31-0.74,29.65-9.87c7.39-10.63,6.91-25.35-1.16-35.38c-6.76-8.41-15.31-11.09-26.41-6.87
|
||||
@@ -141,7 +141,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c0.05,17.62-0.05,35.25,0.1,52.87c0.03,3.86-1.12,5.28-5.11,5.23c-18.45-0.2-36.91-0.06-55.36-0.22
|
||||
c-13.01-0.11-18.55,7.33-14.2,19.69c1.93,5.49,4.04,10.9,4.59,16.76c1.14,12.15-6.95,21.77-13.23,25.4
|
||||
C288.04,621.84,285.07,622.48,279.15,622.5z`,
|
||||
11: `M500.83,558.56c-0.93-0.24-2.9-0.39-3.85-0.52c-8.34-0.97-10.63-5.7-9.18-12.35c0.67-5.07,3.86-7.4,5.45-11.59
|
||||
11: `M500.83,558.56c-0.93-0.24-2.9-0.39-3.85-0.52c-8.34-0.97-10.63-5.7-9.18-12.35c0.67-5.07,3.86-7.4,5.45-11.59
|
||||
c6.43-16.99-2.21-35.06-18.65-38.69c-5.09-1.12-10.21-1.04-15.06,1.33c-14.07,3.64-21.77,18.71-17.25,34.16
|
||||
c1.21,4.13,3.09,8.06,4.58,12.12c3.42,9.28-0.88,15.47-10.82,15.51c-17.97,0.06-35.93-0.07-53.9,0.14
|
||||
c-4.22,0.05-5.44-1.39-5.38-5.48c0.23-16.8,0.18-33.6,0.23-50.4c0-1.83,0.03-3.67-0.12-5.48c-0.9-10.26-8.89-15.19-18.45-11.38
|
||||
@@ -155,7 +155,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c14.8-5.11,26.57,1.47,30.1,16.78c3.02,13.1-2.26,23.32-15.96,30.86c-7.16,1.34-13.89,0.02-20.22-3.43
|
||||
c-13.4-7.3-21.26-1.61-20.68,13.08c0.67,17.09,0.05,34.24,0.19,51.36c0.04,4.41-1.27,6.14-6,6.08
|
||||
C537.4,558.41,519.11,558.56,500.83,558.56z`,
|
||||
12: `M672.86,356.02c-2.51,6.41,0.79,18.02,13.81,17.95c20.1,0.13,35.75-0.18,55.04-0.12
|
||||
12: `M672.86,356.02c-2.51,6.41,0.79,18.02,13.81,17.95c20.1,0.13,35.75-0.18,55.04-0.12
|
||||
c2.71,0.01,5.6,0.85,5.59,3.82c-0.1,60.36,0.04,119.05,0.02,179.41c0,0.5-0.1,0.99-0.15,1.49c-2.03,2.03-6.17,0.87-8.61,0.87
|
||||
c-17.12,0.05-26.27-0.3-43.38-0.28c-2.15,0-11.08-0.45-13.17,0.28c-8.84-0.48-10.79,8.3-8.45,16.77c1.81,6.57,5.42,11.45,6.51,18.3
|
||||
c2.11,13.13-5.76,25.93-18.87,28.22c-12.96,1.83-24.63-5.18-27.84-18.37c-1.17-5.93-0.47-11.13,1.66-16.63
|
||||
@@ -166,7 +166,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c-0.48-5.35,0.15-50.06,0.08-57.77c0.12-6.36,1.52-7.71,7.82-7.74c15.45-0.08,55.13,1.59,59.91,0.1c8.64-1.2,8.83-10.58,6.17-18.91
|
||||
c-2.18-6.81-4.82-11.61-5.42-18.83c-1.06-12.89,6.43-21.57,18.36-24.77c12.02-3.23,23.2,1.76,28.07,13.54
|
||||
c2.84,3.48,2.84,14.33-0.1,18.72C676.25,349.18,675.38,349.61,672.86,356.02z`,
|
||||
13: `M158.93,683.51c7.6-1.54,8.41-3.24,12.33-4.54c8.68-2.89,12.81,0.42,12.66,9.57
|
||||
13: `M158.93,683.51c7.6-1.54,8.41-3.24,12.33-4.54c8.68-2.89,12.81,0.42,12.66,9.57
|
||||
c-0.33,19.46-0.3,34.43,0.16,52.58c0.03,4.07-1,5.48-3.9,5.47c-19.8-0.07-39.6-0.1-59.4-0.09c-36.94,0.01-73.88-0.05-110.81,0.19
|
||||
c-5.19,0.03-6.37-1.64-6.36-6.56c0.16-57.4,0.22-112.35,0.06-169.76c-0.01-4.91,0.91-7.27,6.13-7.15
|
||||
c14.89,0.4,21.99,0.35,37.35,0.26c7.15-0.04,14.42,0.15,21.69-3.87c2.87-1.59,5.56-5.31,4.74-13.79
|
||||
@@ -175,7 +175,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c0.76,2.79,2.86,4.51,3.28,5.26c3.46,4.47,10.74,4.2,15.7,4.24c17.26,0.14,30.99,0.14,48.25,0.17c7.71,0.01,8.18,2.39,8.19,10.06
|
||||
c0.02,17.26-0.44,31.02-0.06,48.28c0.19,8.42-2.29,17.32-15.1,11.43c-2.43-1.79-8.68-4.5-15.04-6
|
||||
c-18.33-1.94-30.25,13.79-28.97,26.61c0.98,9.84,2.51,19.41,11.03,25.64C142.42,683.83,151.33,685.05,158.93,683.51z`,
|
||||
14: `M385.72,672.62c-5.94-0.44-10.23,1.91-11.61,7.64c-0.65,2.72-0.08,42.36-0.13,59.34
|
||||
14: `M385.72,672.62c-5.94-0.44-10.23,1.91-11.61,7.64c-0.65,2.72-0.08,42.36-0.13,59.34
|
||||
c-0.02,6.16-0.66,6.76-6.97,6.76c-57.57,0.08-115.14,0.08-172.71,0.33c-5.2,0.02-6.43-1.62-6.34-6.55
|
||||
c0.32-17.63,0-33.67,0.07-51.31c0.05-11.57-6.48-17.41-17.48-13.67c-0.98,0.33-4.28,2.83-7.6,3.9c-4.42,1.42-9.07,1.44-10.17,1.44
|
||||
c-3.21-0.01-4.19-0.08-7.28-0.98c-1.74-0.51-4.01-1.36-5.1-2.05c-12.5-7.87-16.11-26.46-6.48-38.65c3.59-4.55,6.97-6.42,13.06-7.76
|
||||
@@ -187,7 +187,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c0,1.33-0.05,9.66,0,10.99c0.41,10.02,6.99,12.08,16.18,8.29c3.53-1.46,7.61-4.38,11.29-5.49c13.88-4.19,27.74,6.89,29.47,21.38
|
||||
c1.71,14.38-7.59,22.68-13.59,26.47c-1.87,0.29-5.38,2.24-10.2,1.61c-4.14-0.54-10.64-5.06-11.97-5.66
|
||||
C390.15,673.37,387.41,672.75,385.72,672.62z`,
|
||||
15: `M532.16,626.84c-13.24-1.71-28.15,9.06-29.54,23.72c-1.05,11.14,2.42,20.46,11.85,26.84
|
||||
15: `M532.16,626.84c-13.24-1.71-28.15,9.06-29.54,23.72c-1.05,11.14,2.42,20.46,11.85,26.84
|
||||
c9.32,6.31,19.4,6.22,28.74,0.56c12.05-7.3,18.75-1.95,18.33,11.04c-0.55,17.28-0.17,34.59-0.04,51.89
|
||||
c0.02,3.53-0.9,4.95-4.81,4.94c-32.42-0.01-64.84,0.31-97.26,0.31c-25.77,0-51.54-0.37-77.31-0.37c-4.22,0-5.41-1.44-5.37-5.51
|
||||
c0.19-17.3,0.08-34.59,0.11-51.89c-0.68-5.66,1.37-12.28,7.2-12.22c4.4,0.05,11.68,4.63,18.71,7.14
|
||||
@@ -199,7 +199,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c-1.06,2.33-4.08,7.38-4.84,11.2c-0.39,1.99-0.93,8.02,0.93,11.13c2.7,4.53,8.52,6.04,11.09,6.11c0.83,0.04,23.75,0.56,41.65,0.54
|
||||
c7.02-0.01,15.74,0,17.73,0.09c1.99,0.09,5.41-1.42,5.12,1.33c-0.28,2.75,0.12,17.35,0,30.44c-0.13,13.77,1.53,28.83-0.11,36.06
|
||||
c-1.51,6.64-11.54,4.01-13.52,3.5C542.63,632.62,538.99,627.12,532.16,626.84z`,
|
||||
16: `M747.08,562.77c0.08,1.99,0.35,7.73,0.35,9.72c0.01,57.38-0.05,110.21-0.06,167.59c0,6.6,0,6.61-6.35,6.61
|
||||
16: `M747.08,562.77c0.08,1.99,0.35,7.73,0.35,9.72c0.01,57.38-0.05,110.21-0.06,167.59c0,6.6,0,6.61-6.35,6.61
|
||||
c-56.72,0.01-113.43-0.03-170.15,0.14c-5.02,0.02-6.59-1.36-6.5-6.45c0.3-16.13,0.08-32.27,0.13-48.4
|
||||
c0.01-3.85-0.24-7.67-1.2-11.37c-1.71-6.57-6.14-9.24-12.77-7.8c-3.1,0.67-5.93,2.1-8.76,3.52c-13.94,7.02-27.51,2.59-33.94-11.02
|
||||
c-5.96-12.61-0.55-25.37,10.34-31.73c4.64-2.88,13.55-4.56,19.08-0.59c1.9,1.36,7.36,3.92,9.54,4.72
|
||||
@@ -209,7 +209,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
c-0.34-4.32-1.87-9.39-3.65-13.36c-1.09-2.43-2.12-3.98-2.87-6.53c-2.54-8.62,0.07-11.89,8.9-13.78
|
||||
c18.77-0.05,35.67-0.2,54.43-0.17C742.23,561.91,745.52,560.97,747.08,562.77z`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取碎片的路径数据
|
||||
@@ -217,13 +217,10 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
||||
* @param pieceId 碎片ID
|
||||
* @returns 路径数据字符串
|
||||
*/
|
||||
export const getPuzzlePiecePath = (
|
||||
_residence: string,
|
||||
pieceId: number
|
||||
): string | null => {
|
||||
export const getPuzzlePiecePath = (_residence: string, pieceId: number): string | null => {
|
||||
// 所有故居共用同一套路径数据;residence 参数保留用于兼容调用方
|
||||
return puzzlePathConfig.pieces[pieceId] ?? null;
|
||||
};
|
||||
return puzzlePathConfig.pieces[pieceId] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取拼图的viewBox
|
||||
@@ -232,5 +229,5 @@ export const getPuzzlePiecePath = (
|
||||
*/
|
||||
export const getPuzzleViewBox = (_residence: string): string => {
|
||||
// 所有故居共用同一套 viewBox;residence 参数保留用于兼容调用方
|
||||
return puzzlePathConfig.viewBox ?? "0 0 748.92 747.84";
|
||||
};
|
||||
return puzzlePathConfig.viewBox ?? '0 0 748.92 747.84'
|
||||
}
|
||||
@@ -1,34 +1,30 @@
|
||||
import { createContext, useContext, ReactNode } from "react";
|
||||
import { createContext, useContext, ReactNode } from 'react'
|
||||
|
||||
interface PuzzleAnimationContextType {
|
||||
/** 触发碎片动画,传递碎片ID */
|
||||
triggerPuzzleAnimation: (pieceId: number) => void;
|
||||
triggerPuzzleAnimation: (pieceId: number) => void
|
||||
}
|
||||
|
||||
const PuzzleAnimationContext = createContext<PuzzleAnimationContextType | null>(
|
||||
null
|
||||
);
|
||||
const PuzzleAnimationContext = createContext<PuzzleAnimationContextType | null>(null)
|
||||
|
||||
export const PuzzleAnimationProvider = ({
|
||||
children,
|
||||
triggerPuzzleAnimation,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
triggerPuzzleAnimation: (pieceId: number) => void;
|
||||
children: ReactNode
|
||||
triggerPuzzleAnimation: (pieceId: number) => void
|
||||
}) => {
|
||||
return (
|
||||
<PuzzleAnimationContext.Provider value={{ triggerPuzzleAnimation }}>
|
||||
{children}
|
||||
</PuzzleAnimationContext.Provider>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const usePuzzleAnimation = () => {
|
||||
const context = useContext(PuzzleAnimationContext);
|
||||
const context = useContext(PuzzleAnimationContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"usePuzzleAnimation must be used within PuzzleAnimationProvider"
|
||||
);
|
||||
throw new Error('usePuzzleAnimation must be used within PuzzleAnimationProvider')
|
||||
}
|
||||
return context;
|
||||
};
|
||||
return context
|
||||
}
|
||||
@@ -1,26 +1,21 @@
|
||||
import { createContext, useContext, useRef, ReactNode } from "react";
|
||||
import { createContext, useContext, useRef, ReactNode } from 'react'
|
||||
|
||||
interface PuzzleBoxContextType {
|
||||
puzzleBoxRef: React.RefObject<HTMLDivElement | null>;
|
||||
puzzleBoxRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
const PuzzleBoxContext = createContext<PuzzleBoxContextType | null>(null);
|
||||
const PuzzleBoxContext = createContext<PuzzleBoxContextType | null>(null)
|
||||
|
||||
export const PuzzleBoxProvider = ({ children }: { children: ReactNode }) => {
|
||||
const puzzleBoxRef = useRef<HTMLDivElement>(null);
|
||||
const puzzleBoxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<PuzzleBoxContext.Provider value={{ puzzleBoxRef }}>
|
||||
{children}
|
||||
</PuzzleBoxContext.Provider>
|
||||
);
|
||||
};
|
||||
return <PuzzleBoxContext.Provider value={{ puzzleBoxRef }}>{children}</PuzzleBoxContext.Provider>
|
||||
}
|
||||
|
||||
export const usePuzzleBox = () => {
|
||||
const context = useContext(PuzzleBoxContext);
|
||||
const context = useContext(PuzzleBoxContext)
|
||||
if (!context) {
|
||||
throw new Error("usePuzzleBox must be used within PuzzleBoxProvider");
|
||||
throw new Error('usePuzzleBox must be used within PuzzleBoxProvider')
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -6,23 +6,23 @@ import {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from "react";
|
||||
} from 'react'
|
||||
|
||||
export interface ScaleContextValue {
|
||||
scale: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
DESIGN_WIDTH: number;
|
||||
DESIGN_HEIGHT: number;
|
||||
scaleStyle: React.CSSProperties;
|
||||
isMobilePortrait: boolean;
|
||||
scale: number
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
DESIGN_WIDTH: number
|
||||
DESIGN_HEIGHT: number
|
||||
scaleStyle: React.CSSProperties
|
||||
isMobilePortrait: boolean
|
||||
}
|
||||
|
||||
const ScaleContext = createContext<ScaleContextValue | null>(null);
|
||||
const ScaleContext = createContext<ScaleContextValue | null>(null)
|
||||
|
||||
// 设计稿尺寸常量
|
||||
const DESIGN_WIDTH = 1920;
|
||||
const DESIGN_HEIGHT = 1080;
|
||||
const DESIGN_WIDTH = 1920
|
||||
const DESIGN_HEIGHT = 1080
|
||||
|
||||
/**
|
||||
* 缩放 Provider
|
||||
@@ -31,71 +31,65 @@ const DESIGN_HEIGHT = 1080;
|
||||
export function ScaleProvider({ children }: { children: ReactNode }) {
|
||||
// 当前窗口尺寸
|
||||
const [windowWidth, setWindowWidth] = useState(
|
||||
typeof window !== "undefined" ? window.innerWidth : DESIGN_WIDTH
|
||||
);
|
||||
typeof window !== 'undefined' ? window.innerWidth : DESIGN_WIDTH
|
||||
)
|
||||
const [windowHeight, setWindowHeight] = useState(
|
||||
typeof window !== "undefined" ? window.innerHeight : DESIGN_HEIGHT
|
||||
);
|
||||
typeof window !== 'undefined' ? window.innerHeight : DESIGN_HEIGHT
|
||||
)
|
||||
|
||||
// 是否移动端竖屏
|
||||
const isMobilePortrait = useMemo(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const ua = window.navigator.userAgent.toLowerCase();
|
||||
const isMobile = /iphone|ipad|ipod|android|mobile/.test(ua);
|
||||
return isMobile && windowHeight > windowWidth;
|
||||
}, [windowHeight, windowWidth]);
|
||||
if (typeof window === 'undefined') return false
|
||||
const ua = window.navigator.userAgent.toLowerCase()
|
||||
const isMobile = /iphone|ipad|ipod|android|mobile/.test(ua)
|
||||
return isMobile && windowHeight > windowWidth
|
||||
}, [windowHeight, windowWidth])
|
||||
|
||||
// 缩放比例
|
||||
const scale = useMemo(() => {
|
||||
const scaleX = windowWidth / DESIGN_WIDTH;
|
||||
const scaleY = windowHeight / DESIGN_HEIGHT;
|
||||
return Math.min(scaleX, scaleY);
|
||||
}, [windowWidth, windowHeight]);
|
||||
const scaleX = windowWidth / DESIGN_WIDTH
|
||||
const scaleY = windowHeight / DESIGN_HEIGHT
|
||||
return Math.min(scaleX, scaleY)
|
||||
}, [windowWidth, windowHeight])
|
||||
|
||||
// 缩放后的实际尺寸
|
||||
const scaledWidth = useMemo(() => DESIGN_WIDTH * scale, [scale]);
|
||||
const scaledHeight = useMemo(() => DESIGN_HEIGHT * scale, [scale]);
|
||||
const scaledWidth = useMemo(() => DESIGN_WIDTH * scale, [scale])
|
||||
const scaledHeight = useMemo(() => DESIGN_HEIGHT * scale, [scale])
|
||||
|
||||
// 居中偏移量
|
||||
const offsetX = useMemo(
|
||||
() => (windowWidth - scaledWidth) / 2,
|
||||
[windowWidth, scaledWidth]
|
||||
);
|
||||
const offsetY = useMemo(
|
||||
() => (windowHeight - scaledHeight) / 2,
|
||||
[windowHeight, scaledHeight]
|
||||
);
|
||||
const offsetX = useMemo(() => (windowWidth - scaledWidth) / 2, [windowWidth, scaledWidth])
|
||||
const offsetY = useMemo(() => (windowHeight - scaledHeight) / 2, [windowHeight, scaledHeight])
|
||||
|
||||
// 缩放样式对象
|
||||
const scaleStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
transformOrigin: 'top left',
|
||||
}),
|
||||
[scale, offsetX, offsetY]
|
||||
);
|
||||
)
|
||||
|
||||
// 更新窗口尺寸(使用 useCallback 优化)
|
||||
const updateWindowSize = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
setWindowWidth(window.innerWidth);
|
||||
setWindowHeight(window.innerHeight);
|
||||
}, []);
|
||||
if (typeof window === 'undefined') return
|
||||
setWindowWidth(window.innerWidth)
|
||||
setWindowHeight(window.innerHeight)
|
||||
}, [])
|
||||
|
||||
// 监听窗口大小变化
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
updateWindowSize();
|
||||
window.addEventListener("resize", updateWindowSize);
|
||||
if (typeof window === 'undefined') return
|
||||
updateWindowSize()
|
||||
window.addEventListener('resize', updateWindowSize)
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateWindowSize);
|
||||
};
|
||||
}, [updateWindowSize]);
|
||||
window.removeEventListener('resize', updateWindowSize)
|
||||
}
|
||||
}, [updateWindowSize])
|
||||
|
||||
// Context 值(使用 useMemo 缓存)
|
||||
const contextValue = useMemo<ScaleContextValue>(
|
||||
@@ -109,22 +103,18 @@ export function ScaleProvider({ children }: { children: ReactNode }) {
|
||||
isMobilePortrait,
|
||||
}),
|
||||
[scale, offsetX, offsetY, scaleStyle, isMobilePortrait]
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<ScaleContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ScaleContext.Provider>
|
||||
);
|
||||
return <ScaleContext.Provider value={contextValue}>{children}</ScaleContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用缩放 Context 的 Hook
|
||||
*/
|
||||
export function useScale() {
|
||||
const context = useContext(ScaleContext);
|
||||
const context = useContext(ScaleContext)
|
||||
if (!context) {
|
||||
throw new Error("useScale must be used within ScaleProvider");
|
||||
throw new Error('useScale must be used within ScaleProvider')
|
||||
}
|
||||
return context;
|
||||
return context
|
||||
}
|
||||
@@ -1,42 +1,42 @@
|
||||
import { createContext, useContext, ReactNode } from "react";
|
||||
import { createContext, useContext, ReactNode } from 'react'
|
||||
|
||||
interface VideoContextValue {
|
||||
/** 视频是否正在播放 */
|
||||
isVideoPlaying: boolean;
|
||||
isVideoPlaying: boolean
|
||||
/** 内容是否可见 */
|
||||
isContentVisible: boolean;
|
||||
isContentVisible: boolean
|
||||
}
|
||||
|
||||
const VideoContext = createContext<VideoContextValue | undefined>(undefined);
|
||||
const VideoContext = createContext<VideoContextValue | undefined>(undefined)
|
||||
|
||||
export const VideoProvider = ({
|
||||
children,
|
||||
isVideoPlaying,
|
||||
isContentVisible,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
isVideoPlaying: boolean;
|
||||
isContentVisible: boolean;
|
||||
children: ReactNode
|
||||
isVideoPlaying: boolean
|
||||
isContentVisible: boolean
|
||||
}) => {
|
||||
return (
|
||||
<VideoContext.Provider value={{ isVideoPlaying, isContentVisible }}>
|
||||
{children}
|
||||
</VideoContext.Provider>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const useVideoContext = () => {
|
||||
const context = useContext(VideoContext);
|
||||
const context = useContext(VideoContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useVideoContext must be used within a VideoProvider");
|
||||
throw new Error('useVideoContext must be used within a VideoProvider')
|
||||
}
|
||||
return context;
|
||||
};
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地获取视频上下文,如果不在 VideoProvider 中则返回 null
|
||||
*/
|
||||
export const useVideoContextSafe = () => {
|
||||
const context = useContext(VideoContext);
|
||||
return context ?? null;
|
||||
};
|
||||
const context = useContext(VideoContext)
|
||||
return context ?? null
|
||||
}
|
||||
+8
-16
@@ -1,16 +1,8 @@
|
||||
export { useDragAndDrop } from "./useDragAndDrop";
|
||||
export { usePageScale } from "./usePageScale";
|
||||
export { useTouch } from "./useTouch";
|
||||
export { useZoomAndDrag } from "./useZoomAndDrag";
|
||||
export { useCursorController } from "./useCursorController";
|
||||
export type {
|
||||
UseTouchOptions,
|
||||
UseTouchReturn,
|
||||
TouchEventData,
|
||||
SwipeDirection,
|
||||
} from "./useTouch";
|
||||
export type {
|
||||
UseZoomAndDragOptions,
|
||||
UseZoomAndDragReturn,
|
||||
} from "./useZoomAndDrag";
|
||||
export type { CursorControllerConfig } from "./useCursorController";
|
||||
export { useDragAndDrop } from './useDragAndDrop'
|
||||
export { usePageScale } from './usePageScale'
|
||||
export { useTouch } from './useTouch'
|
||||
export { useZoomAndDrag } from './useZoomAndDrag'
|
||||
export { useCursorController } from './useCursorController'
|
||||
export type { UseTouchOptions, UseTouchReturn, TouchEventData, SwipeDirection } from './useTouch'
|
||||
export type { UseZoomAndDragOptions, UseZoomAndDragReturn } from './useZoomAndDrag'
|
||||
export type { CursorControllerConfig } from './useCursorController'
|
||||
@@ -1,64 +1,64 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
type AudioMode = "global" | "channel";
|
||||
type AudioMode = 'global' | 'channel'
|
||||
|
||||
interface UseAudioManagerOptions {
|
||||
mode?: AudioMode;
|
||||
channelId?: string;
|
||||
mode?: AudioMode
|
||||
channelId?: string
|
||||
}
|
||||
|
||||
interface AudioState {
|
||||
key: string | null;
|
||||
currentSrc: string | null;
|
||||
isPlaying: boolean;
|
||||
key: string | null
|
||||
currentSrc: string | null
|
||||
isPlaying: boolean
|
||||
}
|
||||
|
||||
// 全局音频池:每个 key(全局或某个 channel)对应一个 HTMLAudioElement
|
||||
const audioPool = new Map<string, HTMLAudioElement>();
|
||||
const audioPool = new Map<string, HTMLAudioElement>()
|
||||
|
||||
// 订阅者列表,用于在任意地方播放/暂停时同步状态
|
||||
const listeners = new Set<(state: AudioState) => void>();
|
||||
const listeners = new Set<(state: AudioState) => void>()
|
||||
|
||||
let globalState: AudioState = {
|
||||
key: null,
|
||||
currentSrc: null,
|
||||
isPlaying: false,
|
||||
};
|
||||
}
|
||||
|
||||
function emit(state: AudioState) {
|
||||
globalState = state;
|
||||
listeners.forEach((listener) => listener(state));
|
||||
globalState = state
|
||||
listeners.forEach((listener) => listener(state))
|
||||
}
|
||||
|
||||
function resolveKey(options?: UseAudioManagerOptions): string {
|
||||
const mode = options?.mode ?? "global";
|
||||
const mode = options?.mode ?? 'global'
|
||||
|
||||
if (mode === "global") {
|
||||
return "__GLOBAL__";
|
||||
if (mode === 'global') {
|
||||
return '__GLOBAL__'
|
||||
}
|
||||
|
||||
// mode === "channel"
|
||||
return `channel:${options?.channelId ?? "__DEFAULT__"}`;
|
||||
return `channel:${options?.channelId ?? '__DEFAULT__'}`
|
||||
}
|
||||
|
||||
function getOrCreateAudio(key: string): HTMLAudioElement {
|
||||
let audio = audioPool.get(key);
|
||||
let audio = audioPool.get(key)
|
||||
if (!audio) {
|
||||
audio = new Audio();
|
||||
audioPool.set(key, audio);
|
||||
audio = new Audio()
|
||||
audioPool.set(key, audio)
|
||||
|
||||
audio.addEventListener("ended", () => {
|
||||
audio.addEventListener('ended', () => {
|
||||
// 某个 key 下的音频自然播放结束时,仅更新该 key 对应的播放状态
|
||||
if (globalState.key === key) {
|
||||
emit({
|
||||
key,
|
||||
currentSrc: globalState.currentSrc,
|
||||
isPlaying: false,
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
return audio;
|
||||
return audio
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,70 +67,70 @@ function getOrCreateAudio(key: string): HTMLAudioElement {
|
||||
* - mode = "channel" + channelId:同一 channel 内互斥,channel 之间互不影响
|
||||
*/
|
||||
export function useAudioManager(options?: UseAudioManagerOptions) {
|
||||
const [state, setState] = useState<AudioState>(globalState);
|
||||
const [state, setState] = useState<AudioState>(globalState)
|
||||
|
||||
// 将 options 展开为基础类型,避免在 useCallback 中依赖对象引用导致每次 render 都重建函数
|
||||
const resolvedMode: AudioMode = options?.mode ?? "global";
|
||||
const resolvedChannelId: string | undefined = options?.channelId;
|
||||
const resolvedMode: AudioMode = options?.mode ?? 'global'
|
||||
const resolvedChannelId: string | undefined = options?.channelId
|
||||
|
||||
useEffect(() => {
|
||||
listeners.add(setState);
|
||||
listeners.add(setState)
|
||||
return () => {
|
||||
listeners.delete(setState);
|
||||
};
|
||||
}, []);
|
||||
listeners.delete(setState)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const play = useCallback(
|
||||
(src: string) => {
|
||||
const key = resolveKey({
|
||||
mode: resolvedMode,
|
||||
channelId: resolvedChannelId,
|
||||
});
|
||||
const audio = getOrCreateAudio(key);
|
||||
})
|
||||
const audio = getOrCreateAudio(key)
|
||||
|
||||
if (audio.src !== src) {
|
||||
audio.src = src;
|
||||
audio.src = src
|
||||
}
|
||||
|
||||
audio.currentTime = 0;
|
||||
audio.currentTime = 0
|
||||
// 捕获浏览器因策略或中断导致的 play() 拒绝,避免控制台 Unhandled promise
|
||||
audio.play().catch(() => {
|
||||
// 可以在这里按需上报或忽略
|
||||
});
|
||||
})
|
||||
|
||||
emit({
|
||||
key,
|
||||
currentSrc: src,
|
||||
isPlaying: true,
|
||||
});
|
||||
})
|
||||
},
|
||||
[resolvedMode, resolvedChannelId]
|
||||
);
|
||||
)
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const key = resolveKey({
|
||||
mode: resolvedMode,
|
||||
channelId: resolvedChannelId,
|
||||
});
|
||||
const audio = audioPool.get(key);
|
||||
})
|
||||
const audio = audioPool.get(key)
|
||||
|
||||
if (!audio) return;
|
||||
if (!audio) return
|
||||
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
|
||||
if (globalState.key === key) {
|
||||
emit({
|
||||
key,
|
||||
currentSrc: globalState.currentSrc,
|
||||
isPlaying: false,
|
||||
});
|
||||
})
|
||||
}
|
||||
}, [resolvedMode, resolvedChannelId]);
|
||||
}, [resolvedMode, resolvedChannelId])
|
||||
|
||||
return {
|
||||
...state,
|
||||
play,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
}
|
||||
+113
-136
@@ -1,27 +1,27 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
/** Cursor 解锁规则 */
|
||||
export interface CursorUnlockRule {
|
||||
/** cursor ID */
|
||||
id: string | number;
|
||||
id: string | number
|
||||
/** 依赖的展品 ID 列表,当这些展品都被访问后,自动解锁此 cursor */
|
||||
dependsOn?: (string | number)[];
|
||||
dependsOn?: (string | number)[]
|
||||
/** 初始是否启用(如果未配置 dependsOn,则使用此值) */
|
||||
initialEnabled?: boolean;
|
||||
initialEnabled?: boolean
|
||||
}
|
||||
|
||||
/** Cursor 控制器配置 */
|
||||
export interface CursorControllerConfig {
|
||||
/** 方式1:使用解锁规则(推荐,更灵活) */
|
||||
unlockRules?: CursorUnlockRule[];
|
||||
unlockRules?: CursorUnlockRule[]
|
||||
/** 方式2:使用依赖映射(简化配置,格式:{ cursorId: [依赖的展品Id列表] }) */
|
||||
dependencies?: Record<string | number, (string | number)[]>;
|
||||
dependencies?: Record<string | number, (string | number)[]>
|
||||
/** 已访问的展品 ID(支持 Set 或数组,内部会自动处理引用问题) */
|
||||
visited?: Set<string | number> | (string | number)[];
|
||||
visited?: Set<string | number> | (string | number)[]
|
||||
/** 初始启用的 cursor ID 列表(向后兼容,如果没有配置 unlockRules 或 dependencies 则使用此值) */
|
||||
initialEnabled?: (string | number)[];
|
||||
initialEnabled?: (string | number)[]
|
||||
/** cursor 完成时的回调函数 */
|
||||
onCursorComplete?: (id: string | number) => void;
|
||||
onCursorComplete?: (id: string | number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,105 +61,93 @@ export interface CursorControllerConfig {
|
||||
* ```
|
||||
*/
|
||||
export const useCursorController = (config: CursorControllerConfig = {}) => {
|
||||
const {
|
||||
unlockRules = [],
|
||||
dependencies = {},
|
||||
visited,
|
||||
initialEnabled = [],
|
||||
} = config;
|
||||
const { unlockRules = [], dependencies = {}, visited, initialEnabled = [] } = config
|
||||
|
||||
// 手动覆盖的状态(优先级高于自动解锁)
|
||||
const [manualOverrides, setManualOverrides] = useState<
|
||||
Map<string | number, boolean>
|
||||
>(new Map());
|
||||
const [manualOverrides, setManualOverrides] = useState<Map<string | number, boolean>>(new Map())
|
||||
|
||||
// 将 visited(Set 或数组)序列化为稳定的 key,用于比较内容变化
|
||||
// 无论传入 Set 还是数组,都转换为数组再序列化,避免引用问题
|
||||
const visitedKey = useMemo(() => {
|
||||
if (!visited) return "";
|
||||
const visitedArray = Array.isArray(visited) ? visited : Array.from(visited);
|
||||
if (visitedArray.length === 0) return "";
|
||||
const sorted = [...visitedArray].sort((a, b) => Number(a) - Number(b));
|
||||
return sorted.join(",");
|
||||
}, [visited]);
|
||||
if (!visited) return ''
|
||||
const visitedArray = Array.isArray(visited) ? visited : Array.from(visited)
|
||||
if (visitedArray.length === 0) return ''
|
||||
const sorted = [...visitedArray].sort((a, b) => Number(a) - Number(b))
|
||||
return sorted.join(',')
|
||||
}, [visited])
|
||||
|
||||
// 使用 ref 存储上一次的 key,避免不必要的重新计算
|
||||
const prevKeyRef = useRef<string>("");
|
||||
const prevKeyRef = useRef<string>('')
|
||||
|
||||
// 使用 ref 存储最新的 visited,避免在依赖中使用对象引用
|
||||
const visitedRef = useRef<Set<string | number>>(new Set());
|
||||
const visitedRef = useRef<Set<string | number>>(new Set())
|
||||
|
||||
// 将 visited(Set 或数组)转换为 Set,并更新 ref
|
||||
// 只在内容变化时更新,避免引用问题
|
||||
useMemo(() => {
|
||||
if (!visited) {
|
||||
visitedRef.current = new Set<string | number>();
|
||||
return;
|
||||
visitedRef.current = new Set<string | number>()
|
||||
return
|
||||
}
|
||||
const newSet = Array.isArray(visited) ? new Set(visited) : visited;
|
||||
visitedRef.current = newSet;
|
||||
const newSet = Array.isArray(visited) ? new Set(visited) : visited
|
||||
visitedRef.current = newSet
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visitedKey]); // 使用 visitedKey 而不是 visited 对象,避免引用问题
|
||||
}, [visitedKey]) // 使用 visitedKey 而不是 visited 对象,避免引用问题
|
||||
|
||||
// 计算初始启用的 cursor
|
||||
const getInitialEnabled = useCallback(() => {
|
||||
const enabled = new Set<string | number>();
|
||||
const currentVisitedSet = visitedRef.current; // 使用 ref 中的最新值
|
||||
const enabled = new Set<string | number>()
|
||||
const currentVisitedSet = visitedRef.current // 使用 ref 中的最新值
|
||||
|
||||
// 优先使用 unlockRules
|
||||
if (unlockRules.length > 0) {
|
||||
unlockRules.forEach((rule) => {
|
||||
if (rule.dependsOn && rule.dependsOn.length > 0) {
|
||||
// 检查依赖是否都满足
|
||||
const allDepsMet = rule.dependsOn.every((dep) =>
|
||||
currentVisitedSet.has(dep)
|
||||
);
|
||||
const allDepsMet = rule.dependsOn.every((dep) => currentVisitedSet.has(dep))
|
||||
if (allDepsMet) {
|
||||
enabled.add(rule.id);
|
||||
enabled.add(rule.id)
|
||||
}
|
||||
} else if (rule.initialEnabled) {
|
||||
enabled.add(rule.id);
|
||||
enabled.add(rule.id)
|
||||
}
|
||||
});
|
||||
return enabled;
|
||||
})
|
||||
return enabled
|
||||
}
|
||||
|
||||
// 其次使用 dependencies
|
||||
if (Object.keys(dependencies).length > 0) {
|
||||
Object.entries(dependencies).forEach(([cursorId, deps]) => {
|
||||
const id = isNaN(Number(cursorId)) ? cursorId : Number(cursorId);
|
||||
if (
|
||||
deps.length === 0 ||
|
||||
deps.every((dep) => currentVisitedSet.has(dep))
|
||||
) {
|
||||
enabled.add(id);
|
||||
const id = isNaN(Number(cursorId)) ? cursorId : Number(cursorId)
|
||||
if (deps.length === 0 || deps.every((dep) => currentVisitedSet.has(dep))) {
|
||||
enabled.add(id)
|
||||
}
|
||||
});
|
||||
initialEnabled.forEach((id) => enabled.add(id));
|
||||
return enabled;
|
||||
})
|
||||
initialEnabled.forEach((id) => enabled.add(id))
|
||||
return enabled
|
||||
}
|
||||
|
||||
// 最后使用 initialEnabled(向后兼容)
|
||||
initialEnabled.forEach((id) => enabled.add(id));
|
||||
return enabled;
|
||||
initialEnabled.forEach((id) => enabled.add(id))
|
||||
return enabled
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [unlockRules, dependencies, visitedKey, initialEnabled]);
|
||||
}, [unlockRules, dependencies, visitedKey, initialEnabled])
|
||||
|
||||
// 将 manualOverrides 序列化为 key
|
||||
const manualOverridesKey = useMemo(() => {
|
||||
if (manualOverrides.size === 0) return "";
|
||||
if (manualOverrides.size === 0) return ''
|
||||
const sorted = Array.from(manualOverrides.entries())
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([id, enabled]) => `${id}:${enabled}`)
|
||||
.join(",");
|
||||
return sorted;
|
||||
}, [manualOverrides]);
|
||||
.join(',')
|
||||
return sorted
|
||||
}, [manualOverrides])
|
||||
|
||||
// 根据解锁规则和 visited 状态计算应该启用的 cursor
|
||||
const calculateEnabledCursors = useCallback(() => {
|
||||
const enabled = new Set<string | number>();
|
||||
const enabled = new Set<string | number>()
|
||||
// 使用 ref 中的最新值,避免依赖问题
|
||||
const currentVisitedSet = visitedRef.current;
|
||||
const currentVisitedSet = visitedRef.current
|
||||
|
||||
// 优先使用 unlockRules
|
||||
if (unlockRules.length > 0) {
|
||||
@@ -167,159 +155,148 @@ export const useCursorController = (config: CursorControllerConfig = {}) => {
|
||||
// 手动覆盖优先
|
||||
if (manualOverrides.has(rule.id)) {
|
||||
if (manualOverrides.get(rule.id)) {
|
||||
enabled.add(rule.id);
|
||||
enabled.add(rule.id)
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// 自动解锁逻辑
|
||||
if (rule.dependsOn && rule.dependsOn.length > 0) {
|
||||
const allDepsMet = rule.dependsOn.every((dep) =>
|
||||
currentVisitedSet.has(dep)
|
||||
);
|
||||
const allDepsMet = rule.dependsOn.every((dep) => currentVisitedSet.has(dep))
|
||||
if (allDepsMet) {
|
||||
enabled.add(rule.id);
|
||||
enabled.add(rule.id)
|
||||
}
|
||||
} else if (rule.initialEnabled) {
|
||||
enabled.add(rule.id);
|
||||
enabled.add(rule.id)
|
||||
}
|
||||
});
|
||||
return enabled;
|
||||
})
|
||||
return enabled
|
||||
}
|
||||
|
||||
// 其次使用 dependencies
|
||||
if (Object.keys(dependencies).length > 0) {
|
||||
const allCursorIds = new Set([
|
||||
...initialEnabled,
|
||||
...Object.keys(dependencies).map((key) =>
|
||||
isNaN(Number(key)) ? key : Number(key)
|
||||
),
|
||||
]);
|
||||
...Object.keys(dependencies).map((key) => (isNaN(Number(key)) ? key : Number(key))),
|
||||
])
|
||||
|
||||
allCursorIds.forEach((id) => {
|
||||
// 手动覆盖优先
|
||||
if (manualOverrides.has(id)) {
|
||||
if (manualOverrides.get(id)) {
|
||||
enabled.add(id);
|
||||
enabled.add(id)
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// 自动解锁逻辑
|
||||
const deps = dependencies[id];
|
||||
const deps = dependencies[id]
|
||||
if (deps && deps.length > 0) {
|
||||
const allDepsMet = deps.every((dep) => currentVisitedSet.has(dep));
|
||||
const allDepsMet = deps.every((dep) => currentVisitedSet.has(dep))
|
||||
if (allDepsMet) {
|
||||
enabled.add(id);
|
||||
enabled.add(id)
|
||||
}
|
||||
} else if (initialEnabled.includes(id)) {
|
||||
enabled.add(id);
|
||||
enabled.add(id)
|
||||
}
|
||||
});
|
||||
return enabled;
|
||||
})
|
||||
return enabled
|
||||
}
|
||||
|
||||
// 最后使用 initialEnabled(向后兼容)
|
||||
initialEnabled.forEach((id) => {
|
||||
if (manualOverrides.has(id)) {
|
||||
if (manualOverrides.get(id)) {
|
||||
enabled.add(id);
|
||||
enabled.add(id)
|
||||
}
|
||||
} else {
|
||||
enabled.add(id);
|
||||
enabled.add(id)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
return enabled;
|
||||
return enabled
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
unlockRules,
|
||||
dependencies,
|
||||
visitedKey,
|
||||
initialEnabled,
|
||||
manualOverridesKey,
|
||||
]);
|
||||
}, [unlockRules, dependencies, visitedKey, initialEnabled, manualOverridesKey])
|
||||
|
||||
const [enabledCursors, setEnabledCursors] =
|
||||
useState<Set<string | number>>(getInitialEnabled);
|
||||
const [enabledCursors, setEnabledCursors] = useState<Set<string | number>>(getInitialEnabled)
|
||||
|
||||
// 当 visited 或 manualOverrides 内容变化时,自动重新计算
|
||||
useEffect(() => {
|
||||
const currentKey = `${visitedKey}|${manualOverridesKey}`;
|
||||
const currentKey = `${visitedKey}|${manualOverridesKey}`
|
||||
// 只在 key 真正变化时才重新计算
|
||||
if (prevKeyRef.current !== currentKey) {
|
||||
prevKeyRef.current = currentKey;
|
||||
setEnabledCursors(calculateEnabledCursors());
|
||||
prevKeyRef.current = currentKey
|
||||
setEnabledCursors(calculateEnabledCursors())
|
||||
}
|
||||
}, [visitedKey, manualOverridesKey, calculateEnabledCursors]);
|
||||
}, [visitedKey, manualOverridesKey, calculateEnabledCursors])
|
||||
|
||||
// 激活 cursor(手动控制,会覆盖自动解锁)
|
||||
const enable = useCallback((ids: string | number | (string | number)[]) => {
|
||||
setManualOverrides((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const idsArray = Array.isArray(ids) ? ids : [ids];
|
||||
const newMap = new Map(prev)
|
||||
const idsArray = Array.isArray(ids) ? ids : [ids]
|
||||
idsArray.forEach((id) => {
|
||||
newMap.set(id, true);
|
||||
});
|
||||
return newMap;
|
||||
});
|
||||
newMap.set(id, true)
|
||||
})
|
||||
return newMap
|
||||
})
|
||||
setEnabledCursors((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
const idsArray = Array.isArray(ids) ? ids : [ids];
|
||||
const newSet = new Set(prev)
|
||||
const idsArray = Array.isArray(ids) ? ids : [ids]
|
||||
idsArray.forEach((id) => {
|
||||
newSet.add(id);
|
||||
});
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
newSet.add(id)
|
||||
})
|
||||
return newSet
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 禁用 cursor
|
||||
const disable = useCallback((ids: string | number | (string | number)[]) => {
|
||||
setManualOverrides((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const idsArray = Array.isArray(ids) ? ids : [ids];
|
||||
const newMap = new Map(prev)
|
||||
const idsArray = Array.isArray(ids) ? ids : [ids]
|
||||
idsArray.forEach((id) => {
|
||||
newMap.set(id, false);
|
||||
});
|
||||
return newMap;
|
||||
});
|
||||
newMap.set(id, false)
|
||||
})
|
||||
return newMap
|
||||
})
|
||||
setEnabledCursors((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
const idsArray = Array.isArray(ids) ? ids : [ids];
|
||||
const newSet = new Set(prev)
|
||||
const idsArray = Array.isArray(ids) ? ids : [ids]
|
||||
idsArray.forEach((id) => {
|
||||
newSet.delete(id);
|
||||
});
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
newSet.delete(id)
|
||||
})
|
||||
return newSet
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 检查是否启用
|
||||
const isEnabled = useCallback(
|
||||
(id: string | number) => {
|
||||
return enabledCursors.has(id);
|
||||
return enabledCursors.has(id)
|
||||
},
|
||||
[enabledCursors]
|
||||
);
|
||||
)
|
||||
|
||||
// 获取所有启用的 cursor
|
||||
const getEnabledCursors = useCallback(() => {
|
||||
return new Set(enabledCursors);
|
||||
}, [enabledCursors]);
|
||||
return new Set(enabledCursors)
|
||||
}, [enabledCursors])
|
||||
|
||||
// 清除手动覆盖,恢复自动解锁
|
||||
const clearManualOverride = useCallback((id: string | number) => {
|
||||
setManualOverrides((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(id);
|
||||
return newMap;
|
||||
});
|
||||
}, []);
|
||||
const newMap = new Map(prev)
|
||||
newMap.delete(id)
|
||||
return newMap
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 重置状态
|
||||
const reset = useCallback(() => {
|
||||
setManualOverrides(new Map());
|
||||
setEnabledCursors(getInitialEnabled());
|
||||
}, [getInitialEnabled]);
|
||||
setManualOverrides(new Map())
|
||||
setEnabledCursors(getInitialEnabled())
|
||||
}, [getInitialEnabled])
|
||||
|
||||
return {
|
||||
enable,
|
||||
@@ -328,5 +305,5 @@ export const useCursorController = (config: CursorControllerConfig = {}) => {
|
||||
getEnabledCursors,
|
||||
clearManualOverride,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
+45
-51
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useScale } from '@/contexts/ScaleContext'
|
||||
|
||||
interface DragState<T = any> {
|
||||
isDragging: boolean;
|
||||
dragData: T | null;
|
||||
mousePosition: { x: number; y: number };
|
||||
isDragging: boolean
|
||||
dragData: T | null
|
||||
mousePosition: { x: number; y: number }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -12,71 +12,71 @@ interface DragState<T = any> {
|
||||
* 支持页面 scale 缩放的拖拽功能
|
||||
*/
|
||||
export function useDragAndDrop<T = any>() {
|
||||
const { scale, offsetX, offsetY } = useScale();
|
||||
const { scale, offsetX, offsetY } = useScale()
|
||||
const [state, setState] = useState<DragState<T>>({
|
||||
isDragging: false,
|
||||
dragData: null,
|
||||
mousePosition: { x: 0, y: 0 },
|
||||
});
|
||||
const isDraggingRef = useRef(false);
|
||||
})
|
||||
const isDraggingRef = useRef(false)
|
||||
|
||||
// 开始拖拽
|
||||
const startDrag = useCallback((data: T, event?: React.MouseEvent) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// 立即获取鼠标初始位置
|
||||
isDraggingRef.current = true;
|
||||
isDraggingRef.current = true
|
||||
setState({
|
||||
isDragging: true,
|
||||
dragData: data,
|
||||
mousePosition: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
})
|
||||
} else {
|
||||
isDraggingRef.current = true;
|
||||
isDraggingRef.current = true
|
||||
setState({
|
||||
isDragging: true,
|
||||
dragData: data,
|
||||
mousePosition: { x: 0, y: 0 },
|
||||
});
|
||||
})
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
// 结束拖拽
|
||||
const endDrag = useCallback(() => {
|
||||
isDraggingRef.current = false;
|
||||
isDraggingRef.current = false
|
||||
setState({
|
||||
isDragging: false,
|
||||
dragData: null,
|
||||
mousePosition: { x: 0, y: 0 },
|
||||
});
|
||||
}, []);
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 监听鼠标移动 - 使用独立的事件监听器
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDraggingRef.current) return;
|
||||
if (!isDraggingRef.current) return
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
mousePosition: { x: e.clientX, y: e.clientY },
|
||||
}));
|
||||
};
|
||||
}))
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// 拖拽结束的逻辑由外部处理
|
||||
// 这里只是提供一个安全机制
|
||||
};
|
||||
}
|
||||
|
||||
// 始终添加事件监听器,在handleMouseMove内部检查拖拽状态
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, []); // 空依赖数组,只在组件挂载/卸载时执行
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, []) // 空依赖数组,只在组件挂载/卸载时执行
|
||||
|
||||
// 将屏幕坐标转换为缩放后的页面坐标
|
||||
const screenToScaledCoords = useCallback(
|
||||
@@ -84,26 +84,23 @@ export function useDragAndDrop<T = any>() {
|
||||
return {
|
||||
x: (screenX - offsetX) / scale,
|
||||
y: (screenY - offsetY) / scale,
|
||||
};
|
||||
}
|
||||
},
|
||||
[scale, offsetX, offsetY]
|
||||
);
|
||||
)
|
||||
|
||||
// 检查点是否在元素内
|
||||
const isPointInElement = useCallback(
|
||||
(pointX: number, pointY: number, element: HTMLElement | null): boolean => {
|
||||
if (!element) return false;
|
||||
if (!element) return false
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
pointX >= rect.left &&
|
||||
pointX <= rect.right &&
|
||||
pointY >= rect.top &&
|
||||
pointY <= rect.bottom
|
||||
);
|
||||
pointX >= rect.left && pointX <= rect.right && pointY >= rect.top && pointY <= rect.bottom
|
||||
)
|
||||
},
|
||||
[]
|
||||
);
|
||||
)
|
||||
|
||||
return {
|
||||
isDragging: state.isDragging,
|
||||
@@ -113,7 +110,7 @@ export function useDragAndDrop<T = any>() {
|
||||
endDrag,
|
||||
screenToScaledCoords,
|
||||
isPointInElement,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,28 +118,25 @@ export function useDragAndDrop<T = any>() {
|
||||
* 用于检测拖拽元素是否悬停在放置区域上
|
||||
*/
|
||||
export function useDropZone() {
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null);
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null)
|
||||
const [isOver, setIsOver] = useState(false)
|
||||
|
||||
// 检查鼠标是否在放置区域内
|
||||
const checkIsOver = useCallback((mouseX: number, mouseY: number) => {
|
||||
if (!dropZoneRef.current) return false;
|
||||
if (!dropZoneRef.current) return false
|
||||
|
||||
const rect = dropZoneRef.current.getBoundingClientRect();
|
||||
const rect = dropZoneRef.current.getBoundingClientRect()
|
||||
const over =
|
||||
mouseX >= rect.left &&
|
||||
mouseX <= rect.right &&
|
||||
mouseY >= rect.top &&
|
||||
mouseY <= rect.bottom;
|
||||
mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom
|
||||
|
||||
// 只有当状态真正改变时才更新
|
||||
setIsOver((prev) => (prev !== over ? over : prev));
|
||||
return over;
|
||||
}, []);
|
||||
setIsOver((prev) => (prev !== over ? over : prev))
|
||||
return over
|
||||
}, [])
|
||||
|
||||
return {
|
||||
dropZoneRef,
|
||||
isOver,
|
||||
checkIsOver,
|
||||
};
|
||||
}
|
||||
}
|
||||
+31
-37
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* 等比例缩放Hook
|
||||
@@ -6,72 +6,66 @@ import { useEffect, useState, useMemo } from "react";
|
||||
*/
|
||||
export function usePageScale() {
|
||||
// 设计稿尺寸
|
||||
const DESIGN_WIDTH = 1920;
|
||||
const DESIGN_HEIGHT = 1080;
|
||||
const DESIGN_WIDTH = 1920
|
||||
const DESIGN_HEIGHT = 1080
|
||||
|
||||
// 当前窗口尺寸
|
||||
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
||||
const [windowHeight, setWindowHeight] = useState(window.innerHeight);
|
||||
const [windowWidth, setWindowWidth] = useState(window.innerWidth)
|
||||
const [windowHeight, setWindowHeight] = useState(window.innerHeight)
|
||||
|
||||
// 是否移动端竖屏(需要旋转横屏展示)
|
||||
const isMobilePortrait = useMemo(() => {
|
||||
const ua = window.navigator.userAgent.toLowerCase();
|
||||
const isMobile = /iphone|ipad|ipod|android|mobile/.test(ua);
|
||||
return isMobile && windowHeight > windowWidth;
|
||||
}, [windowHeight, windowWidth]);
|
||||
const ua = window.navigator.userAgent.toLowerCase()
|
||||
const isMobile = /iphone|ipad|ipod|android|mobile/.test(ua)
|
||||
return isMobile && windowHeight > windowWidth
|
||||
}, [windowHeight, windowWidth])
|
||||
|
||||
// 缩放比例
|
||||
const scale = useMemo(() => {
|
||||
const baseWidth = DESIGN_WIDTH;
|
||||
const baseHeight = DESIGN_HEIGHT;
|
||||
const scaleX = windowWidth / baseWidth;
|
||||
const scaleY = windowHeight / baseHeight;
|
||||
return Math.min(scaleX, scaleY);
|
||||
}, [windowWidth, windowHeight]);
|
||||
const baseWidth = DESIGN_WIDTH
|
||||
const baseHeight = DESIGN_HEIGHT
|
||||
const scaleX = windowWidth / baseWidth
|
||||
const scaleY = windowHeight / baseHeight
|
||||
return Math.min(scaleX, scaleY)
|
||||
}, [windowWidth, windowHeight])
|
||||
|
||||
// 缩放后的实际尺寸
|
||||
const scaledWidth = useMemo(() => DESIGN_WIDTH * scale, [scale]);
|
||||
const scaledHeight = useMemo(() => DESIGN_HEIGHT * scale, [scale]);
|
||||
const scaledWidth = useMemo(() => DESIGN_WIDTH * scale, [scale])
|
||||
const scaledHeight = useMemo(() => DESIGN_HEIGHT * scale, [scale])
|
||||
|
||||
// 居中偏移量
|
||||
const offsetX = useMemo(
|
||||
() => (windowWidth - scaledWidth) / 2,
|
||||
[windowWidth, scaledWidth]
|
||||
);
|
||||
const offsetY = useMemo(
|
||||
() => (windowHeight - scaledHeight) / 2,
|
||||
[windowHeight, scaledHeight]
|
||||
);
|
||||
const offsetX = useMemo(() => (windowWidth - scaledWidth) / 2, [windowWidth, scaledWidth])
|
||||
const offsetY = useMemo(() => (windowHeight - scaledHeight) / 2, [windowHeight, scaledHeight])
|
||||
|
||||
// 缩放样式对象
|
||||
const scaleStyle = useMemo(
|
||||
() => ({
|
||||
position: "absolute" as const,
|
||||
position: 'absolute' as const,
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
transformOrigin: 'top left',
|
||||
}),
|
||||
[scale, offsetX, offsetY]
|
||||
);
|
||||
)
|
||||
|
||||
// 更新窗口尺寸
|
||||
const updateWindowSize = () => {
|
||||
setWindowWidth(window.innerWidth);
|
||||
setWindowHeight(window.innerHeight);
|
||||
};
|
||||
setWindowWidth(window.innerWidth)
|
||||
setWindowHeight(window.innerHeight)
|
||||
}
|
||||
|
||||
// 监听窗口大小变化
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", updateWindowSize);
|
||||
updateWindowSize();
|
||||
window.addEventListener('resize', updateWindowSize)
|
||||
updateWindowSize()
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateWindowSize);
|
||||
};
|
||||
}, []);
|
||||
window.removeEventListener('resize', updateWindowSize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 响应式数据
|
||||
@@ -90,5 +84,5 @@ export function usePageScale() {
|
||||
// 常量
|
||||
DESIGN_WIDTH,
|
||||
DESIGN_HEIGHT,
|
||||
};
|
||||
}
|
||||
}
|
||||
+146
-158
@@ -1,39 +1,39 @@
|
||||
import { useRef, useCallback, useState } from "react";
|
||||
import { useRef, useCallback, useState } from 'react'
|
||||
|
||||
/**
|
||||
* 触摸点信息
|
||||
*/
|
||||
interface TouchPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
timestamp: number;
|
||||
x: number
|
||||
y: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 滑动方向
|
||||
*/
|
||||
export type SwipeDirection = "left" | "right" | "up" | "down";
|
||||
export type SwipeDirection = 'left' | 'right' | 'up' | 'down'
|
||||
|
||||
/**
|
||||
* 手势事件回调参数
|
||||
*/
|
||||
export interface TouchEventData {
|
||||
/** 起始位置 */
|
||||
startPoint: TouchPoint;
|
||||
startPoint: TouchPoint
|
||||
/** 当前位置 */
|
||||
currentPoint: TouchPoint;
|
||||
currentPoint: TouchPoint
|
||||
/** 移动距离 X */
|
||||
deltaX: number;
|
||||
deltaX: number
|
||||
/** 移动距离 Y */
|
||||
deltaY: number;
|
||||
deltaY: number
|
||||
/** 移动的总距离 */
|
||||
distance: number;
|
||||
distance: number
|
||||
/** 移动速度(像素/毫秒) */
|
||||
velocity: number;
|
||||
velocity: number
|
||||
/** 移动方向 */
|
||||
direction: SwipeDirection | null;
|
||||
direction: SwipeDirection | null
|
||||
/** 原始事件 */
|
||||
event: React.TouchEvent | React.MouseEvent;
|
||||
event: React.TouchEvent | React.MouseEvent
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,55 +41,55 @@ export interface TouchEventData {
|
||||
*/
|
||||
export interface UseTouchOptions {
|
||||
/** 是否启用,默认 true */
|
||||
enabled?: boolean;
|
||||
enabled?: boolean
|
||||
|
||||
// ========== 滑动配置 ==========
|
||||
/** 触发滑动的最小距离(像素),默认 10 */
|
||||
swipeThreshold?: number;
|
||||
swipeThreshold?: number
|
||||
/** 触发快速滑动的最小速度(像素/毫秒),默认 0.5 */
|
||||
swipeVelocityThreshold?: number;
|
||||
swipeVelocityThreshold?: number
|
||||
|
||||
// ========== 长按配置 ==========
|
||||
/** 触发长按的时间(毫秒),默认 500 */
|
||||
longPressDelay?: number;
|
||||
longPressDelay?: number
|
||||
/** 长按时允许的最大移动距离(像素),默认 10 */
|
||||
longPressMoveThreshold?: number;
|
||||
longPressMoveThreshold?: number
|
||||
|
||||
// ========== 双击配置 ==========
|
||||
/** 双击的最大时间间隔(毫秒),默认 300 */
|
||||
doubleTapDelay?: number;
|
||||
doubleTapDelay?: number
|
||||
/** 双击的最大移动距离(像素),默认 10 */
|
||||
doubleTapMoveThreshold?: number;
|
||||
doubleTapMoveThreshold?: number
|
||||
|
||||
// ========== 事件回调 ==========
|
||||
/** 触摸开始 */
|
||||
onTouchStart?: (data: TouchEventData) => void;
|
||||
onTouchStart?: (data: TouchEventData) => void
|
||||
/** 触摸移动 */
|
||||
onTouchMove?: (data: TouchEventData) => void;
|
||||
onTouchMove?: (data: TouchEventData) => void
|
||||
/** 触摸结束 */
|
||||
onTouchEnd?: (data: TouchEventData) => void;
|
||||
onTouchEnd?: (data: TouchEventData) => void
|
||||
/** 点击 */
|
||||
onTap?: (data: TouchEventData) => void;
|
||||
onTap?: (data: TouchEventData) => void
|
||||
/** 双击 */
|
||||
onDoubleTap?: (data: TouchEventData) => void;
|
||||
onDoubleTap?: (data: TouchEventData) => void
|
||||
/** 长按 */
|
||||
onLongPress?: (data: TouchEventData) => void;
|
||||
onLongPress?: (data: TouchEventData) => void
|
||||
/** 滑动 */
|
||||
onSwipe?: (data: TouchEventData) => void;
|
||||
onSwipe?: (data: TouchEventData) => void
|
||||
/** 向左滑动 */
|
||||
onSwipeLeft?: (data: TouchEventData) => void;
|
||||
onSwipeLeft?: (data: TouchEventData) => void
|
||||
/** 向右滑动 */
|
||||
onSwipeRight?: (data: TouchEventData) => void;
|
||||
onSwipeRight?: (data: TouchEventData) => void
|
||||
/** 向上滑动 */
|
||||
onSwipeUp?: (data: TouchEventData) => void;
|
||||
onSwipeUp?: (data: TouchEventData) => void
|
||||
/** 向下滑动 */
|
||||
onSwipeDown?: (data: TouchEventData) => void;
|
||||
onSwipeDown?: (data: TouchEventData) => void
|
||||
|
||||
// ========== 其他配置 ==========
|
||||
/** 是否阻止默认行为,默认 false */
|
||||
preventDefault?: boolean;
|
||||
preventDefault?: boolean
|
||||
/** 是否阻止事件冒泡,默认 false */
|
||||
stopPropagation?: boolean;
|
||||
stopPropagation?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,42 +97,42 @@ export interface UseTouchOptions {
|
||||
*/
|
||||
export interface UseTouchReturn {
|
||||
/** 是否正在触摸 */
|
||||
isTouching: boolean;
|
||||
isTouching: boolean
|
||||
/** 触摸事件处理器 */
|
||||
handlers: {
|
||||
onTouchStart: (e: React.TouchEvent) => void;
|
||||
onTouchMove: (e: React.TouchEvent) => void;
|
||||
onTouchEnd: (e: React.TouchEvent) => void;
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
};
|
||||
onTouchStart: (e: React.TouchEvent) => void
|
||||
onTouchMove: (e: React.TouchEvent) => void
|
||||
onTouchEnd: (e: React.TouchEvent) => void
|
||||
onMouseDown: (e: React.MouseEvent) => void
|
||||
}
|
||||
/** 当前触摸数据 */
|
||||
touchData: TouchEventData | null;
|
||||
touchData: TouchEventData | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两点之间的距离
|
||||
*/
|
||||
function getDistance(p1: TouchPoint, p2: TouchPoint): number {
|
||||
const dx = p2.x - p1.x;
|
||||
const dy = p2.y - p1.y;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
const dx = p2.x - p1.x
|
||||
const dy = p2.y - p1.y
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算滑动方向
|
||||
*/
|
||||
function getDirection(deltaX: number, deltaY: number): SwipeDirection | null {
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
const absDeltaX = Math.abs(deltaX)
|
||||
const absDeltaY = Math.abs(deltaY)
|
||||
|
||||
if (absDeltaX < 10 && absDeltaY < 10) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
if (absDeltaX > absDeltaY) {
|
||||
return deltaX > 0 ? "right" : "left";
|
||||
return deltaX > 0 ? 'right' : 'left'
|
||||
} else {
|
||||
return deltaY > 0 ? "down" : "up";
|
||||
return deltaY > 0 ? 'down' : 'up'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,19 +174,19 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
|
||||
onSwipeDown,
|
||||
preventDefault = false,
|
||||
stopPropagation = false,
|
||||
} = options;
|
||||
} = options
|
||||
|
||||
// 状态
|
||||
const [isTouching, setIsTouching] = useState(false);
|
||||
const [touchData, setTouchData] = useState<TouchEventData | null>(null);
|
||||
const [isTouching, setIsTouching] = useState(false)
|
||||
const [touchData, setTouchData] = useState<TouchEventData | null>(null)
|
||||
|
||||
// 引用
|
||||
const startPointRef = useRef<TouchPoint | null>(null);
|
||||
const currentPointRef = useRef<TouchPoint | null>(null);
|
||||
const lastTapTimeRef = useRef<number>(0);
|
||||
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isLongPressTriggeredRef = useRef(false);
|
||||
const isMouseDownRef = useRef(false);
|
||||
const startPointRef = useRef<TouchPoint | null>(null)
|
||||
const currentPointRef = useRef<TouchPoint | null>(null)
|
||||
const lastTapTimeRef = useRef<number>(0)
|
||||
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const isLongPressTriggeredRef = useRef(false)
|
||||
const isMouseDownRef = useRef(false)
|
||||
|
||||
/**
|
||||
* 创建触摸点
|
||||
@@ -198,7 +198,7 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
)
|
||||
|
||||
/**
|
||||
* 创建触摸事件数据
|
||||
@@ -209,12 +209,12 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
|
||||
currentPoint: TouchPoint,
|
||||
event: React.TouchEvent | React.MouseEvent
|
||||
): TouchEventData => {
|
||||
const deltaX = currentPoint.x - startPoint.x;
|
||||
const deltaY = currentPoint.y - startPoint.y;
|
||||
const distance = getDistance(startPoint, currentPoint);
|
||||
const deltaTime = currentPoint.timestamp - startPoint.timestamp;
|
||||
const velocity = deltaTime > 0 ? distance / deltaTime : 0;
|
||||
const direction = getDirection(deltaX, deltaY);
|
||||
const deltaX = currentPoint.x - startPoint.x
|
||||
const deltaY = currentPoint.y - startPoint.y
|
||||
const distance = getDistance(startPoint, currentPoint)
|
||||
const deltaTime = currentPoint.timestamp - startPoint.timestamp
|
||||
const velocity = deltaTime > 0 ? distance / deltaTime : 0
|
||||
const direction = getDirection(deltaX, deltaY)
|
||||
|
||||
return {
|
||||
startPoint,
|
||||
@@ -225,62 +225,59 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
|
||||
velocity,
|
||||
direction,
|
||||
event,
|
||||
};
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
)
|
||||
|
||||
/**
|
||||
* 清除长按定时器
|
||||
*/
|
||||
const clearLongPressTimer = useCallback(() => {
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
clearTimeout(longPressTimerRef.current)
|
||||
longPressTimerRef.current = null
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 处理触摸开始
|
||||
*/
|
||||
const handleStart = useCallback(
|
||||
(x: number, y: number, event: React.TouchEvent | React.MouseEvent) => {
|
||||
if (!enabled) return;
|
||||
if (!enabled) return
|
||||
|
||||
if (preventDefault) event.preventDefault();
|
||||
if (stopPropagation) event.stopPropagation();
|
||||
if (preventDefault) event.preventDefault()
|
||||
if (stopPropagation) event.stopPropagation()
|
||||
|
||||
const point = createTouchPoint(x, y);
|
||||
startPointRef.current = point;
|
||||
currentPointRef.current = point;
|
||||
isLongPressTriggeredRef.current = false;
|
||||
const point = createTouchPoint(x, y)
|
||||
startPointRef.current = point
|
||||
currentPointRef.current = point
|
||||
isLongPressTriggeredRef.current = false
|
||||
|
||||
setIsTouching(true);
|
||||
setIsTouching(true)
|
||||
|
||||
const data = createTouchEventData(point, point, event);
|
||||
setTouchData(data);
|
||||
onTouchStart?.(data);
|
||||
const data = createTouchEventData(point, point, event)
|
||||
setTouchData(data)
|
||||
onTouchStart?.(data)
|
||||
|
||||
// 设置长按定时器
|
||||
if (onLongPress) {
|
||||
clearLongPressTimer();
|
||||
clearLongPressTimer()
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
if (startPointRef.current && currentPointRef.current) {
|
||||
const distance = getDistance(
|
||||
startPointRef.current,
|
||||
currentPointRef.current
|
||||
);
|
||||
const distance = getDistance(startPointRef.current, currentPointRef.current)
|
||||
if (distance < longPressMoveThreshold) {
|
||||
isLongPressTriggeredRef.current = true;
|
||||
isLongPressTriggeredRef.current = true
|
||||
const longPressData = createTouchEventData(
|
||||
startPointRef.current,
|
||||
currentPointRef.current,
|
||||
event
|
||||
);
|
||||
onLongPress(longPressData);
|
||||
)
|
||||
onLongPress(longPressData)
|
||||
}
|
||||
}
|
||||
}, longPressDelay);
|
||||
}, longPressDelay)
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -295,28 +292,28 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
|
||||
longPressMoveThreshold,
|
||||
clearLongPressTimer,
|
||||
]
|
||||
);
|
||||
)
|
||||
|
||||
/**
|
||||
* 处理触摸移动
|
||||
*/
|
||||
const handleMove = useCallback(
|
||||
(x: number, y: number, event: React.TouchEvent | React.MouseEvent) => {
|
||||
if (!enabled || !startPointRef.current) return;
|
||||
if (!enabled || !startPointRef.current) return
|
||||
|
||||
if (preventDefault) event.preventDefault();
|
||||
if (stopPropagation) event.stopPropagation();
|
||||
if (preventDefault) event.preventDefault()
|
||||
if (stopPropagation) event.stopPropagation()
|
||||
|
||||
const point = createTouchPoint(x, y);
|
||||
currentPointRef.current = point;
|
||||
const point = createTouchPoint(x, y)
|
||||
currentPointRef.current = point
|
||||
|
||||
const data = createTouchEventData(startPointRef.current, point, event);
|
||||
setTouchData(data);
|
||||
onTouchMove?.(data);
|
||||
const data = createTouchEventData(startPointRef.current, point, event)
|
||||
setTouchData(data)
|
||||
onTouchMove?.(data)
|
||||
|
||||
// 如果移动距离超过阈值,取消长按
|
||||
if (data.distance > longPressMoveThreshold) {
|
||||
clearLongPressTimer();
|
||||
clearLongPressTimer()
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -329,68 +326,63 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
|
||||
longPressMoveThreshold,
|
||||
clearLongPressTimer,
|
||||
]
|
||||
);
|
||||
)
|
||||
|
||||
/**
|
||||
* 处理触摸结束
|
||||
*/
|
||||
const handleEnd = useCallback(
|
||||
(event: React.TouchEvent | React.MouseEvent) => {
|
||||
if (!enabled || !startPointRef.current || !currentPointRef.current)
|
||||
return;
|
||||
if (!enabled || !startPointRef.current || !currentPointRef.current) return
|
||||
|
||||
if (preventDefault) event.preventDefault();
|
||||
if (stopPropagation) event.stopPropagation();
|
||||
if (preventDefault) event.preventDefault()
|
||||
if (stopPropagation) event.stopPropagation()
|
||||
|
||||
clearLongPressTimer();
|
||||
clearLongPressTimer()
|
||||
|
||||
const data = createTouchEventData(
|
||||
startPointRef.current,
|
||||
currentPointRef.current,
|
||||
event
|
||||
);
|
||||
onTouchEnd?.(data);
|
||||
const data = createTouchEventData(startPointRef.current, currentPointRef.current, event)
|
||||
onTouchEnd?.(data)
|
||||
|
||||
// 判断手势类型
|
||||
const { distance, velocity, direction } = data;
|
||||
const { distance, velocity, direction } = data
|
||||
|
||||
// 长按已触发,不处理其他手势
|
||||
if (isLongPressTriggeredRef.current) {
|
||||
setIsTouching(false);
|
||||
setTouchData(null);
|
||||
startPointRef.current = null;
|
||||
currentPointRef.current = null;
|
||||
return;
|
||||
setIsTouching(false)
|
||||
setTouchData(null)
|
||||
startPointRef.current = null
|
||||
currentPointRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// 滑动手势
|
||||
if (distance > swipeThreshold || velocity > swipeVelocityThreshold) {
|
||||
onSwipe?.(data);
|
||||
if (direction === "left") onSwipeLeft?.(data);
|
||||
if (direction === "right") onSwipeRight?.(data);
|
||||
if (direction === "up") onSwipeUp?.(data);
|
||||
if (direction === "down") onSwipeDown?.(data);
|
||||
onSwipe?.(data)
|
||||
if (direction === 'left') onSwipeLeft?.(data)
|
||||
if (direction === 'right') onSwipeRight?.(data)
|
||||
if (direction === 'up') onSwipeUp?.(data)
|
||||
if (direction === 'down') onSwipeDown?.(data)
|
||||
}
|
||||
// 点击或双击
|
||||
else if (distance < doubleTapMoveThreshold) {
|
||||
const now = Date.now();
|
||||
const timeSinceLastTap = now - lastTapTimeRef.current;
|
||||
const now = Date.now()
|
||||
const timeSinceLastTap = now - lastTapTimeRef.current
|
||||
|
||||
if (timeSinceLastTap < doubleTapDelay && onDoubleTap) {
|
||||
// 双击
|
||||
onDoubleTap(data);
|
||||
lastTapTimeRef.current = 0; // 重置,避免三击触发两次双击
|
||||
onDoubleTap(data)
|
||||
lastTapTimeRef.current = 0 // 重置,避免三击触发两次双击
|
||||
} else {
|
||||
// 单击
|
||||
onTap?.(data);
|
||||
lastTapTimeRef.current = now;
|
||||
onTap?.(data)
|
||||
lastTapTimeRef.current = now
|
||||
}
|
||||
}
|
||||
|
||||
setIsTouching(false);
|
||||
setTouchData(null);
|
||||
startPointRef.current = null;
|
||||
currentPointRef.current = null;
|
||||
setIsTouching(false)
|
||||
setTouchData(null)
|
||||
startPointRef.current = null
|
||||
currentPointRef.current = null
|
||||
},
|
||||
[
|
||||
enabled,
|
||||
@@ -411,66 +403,62 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
|
||||
doubleTapDelay,
|
||||
clearLongPressTimer,
|
||||
]
|
||||
);
|
||||
)
|
||||
|
||||
// 触摸事件处理器
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (e.touches.length === 1) {
|
||||
const touch = e.touches[0];
|
||||
handleStart(touch?.clientX || 0, touch?.clientY || 0, e);
|
||||
const touch = e.touches[0]
|
||||
handleStart(touch?.clientX || 0, touch?.clientY || 0, e)
|
||||
}
|
||||
},
|
||||
[handleStart]
|
||||
);
|
||||
)
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (e.touches.length === 1) {
|
||||
const touch = e.touches[0];
|
||||
handleMove(touch?.clientX || 0, touch?.clientY || 0, e);
|
||||
const touch = e.touches[0]
|
||||
handleMove(touch?.clientX || 0, touch?.clientY || 0, e)
|
||||
}
|
||||
},
|
||||
[handleMove]
|
||||
);
|
||||
)
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
handleEnd(e);
|
||||
handleEnd(e)
|
||||
},
|
||||
[handleEnd]
|
||||
);
|
||||
)
|
||||
|
||||
// 鼠标事件处理器(桌面端支持)
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
isMouseDownRef.current = true;
|
||||
handleStart(e.clientX, e.clientY, e);
|
||||
isMouseDownRef.current = true
|
||||
handleStart(e.clientX, e.clientY, e)
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (isMouseDownRef.current) {
|
||||
handleMove(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
e as unknown as React.MouseEvent
|
||||
);
|
||||
handleMove(event.clientX, event.clientY, e as unknown as React.MouseEvent)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleMouseUp = (event: MouseEvent) => {
|
||||
if (isMouseDownRef.current) {
|
||||
isMouseDownRef.current = false;
|
||||
handleEnd(e as unknown as React.MouseEvent);
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
isMouseDownRef.current = false
|
||||
handleEnd(e as unknown as React.MouseEvent)
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
},
|
||||
[handleStart, handleMove, handleEnd]
|
||||
);
|
||||
)
|
||||
|
||||
return {
|
||||
isTouching,
|
||||
@@ -481,5 +469,5 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
|
||||
onMouseDown: handleMouseDown,
|
||||
},
|
||||
touchData,
|
||||
};
|
||||
}
|
||||
}
|
||||
+162
-169
@@ -1,217 +1,213 @@
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import { useRef, useCallback, useEffect } from 'react'
|
||||
import { useScale } from '@/contexts/ScaleContext'
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
type Point = { x: number; y: number }
|
||||
|
||||
export interface UseZoomAndDragOptions {
|
||||
/** 最小缩放比例,默认 0.8 */
|
||||
minScale?: number;
|
||||
minScale?: number
|
||||
/** 最大缩放比例,默认 3 */
|
||||
maxScale?: number;
|
||||
maxScale?: number
|
||||
/** 是否可见,用于重置状态 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export interface UseZoomAndDragReturn {
|
||||
/** 容器 ref */
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
containerRef: React.RefObject<HTMLDivElement>
|
||||
/** 视图元素 ref */
|
||||
viewElRef: React.RefObject<HTMLDivElement>;
|
||||
viewElRef: React.RefObject<HTMLDivElement>
|
||||
/** 重置变换 */
|
||||
resetTransform: () => void;
|
||||
resetTransform: () => void
|
||||
/** 滚轮事件处理 */
|
||||
handleWheel: (e: React.WheelEvent<HTMLDivElement>) => void;
|
||||
handleWheel: (e: React.WheelEvent<HTMLDivElement>) => void
|
||||
/** 指针按下事件处理 */
|
||||
handlePointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
handlePointerDown: (e: React.PointerEvent<HTMLDivElement>) => void
|
||||
/** 指针移动事件处理 */
|
||||
handlePointerMove: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
handlePointerMove: (e: React.PointerEvent<HTMLDivElement>) => void
|
||||
/** 指针抬起事件处理 */
|
||||
handlePointerUp: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
handlePointerUp: (e: React.PointerEvent<HTMLDivElement>) => void
|
||||
/** 双击事件处理 */
|
||||
handleDoubleClick: () => void;
|
||||
handleDoubleClick: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩放和拖拽 Hook
|
||||
* 提供图片/内容的缩放、拖拽、双击重置功能
|
||||
*/
|
||||
export const useZoomAndDrag = (
|
||||
options: UseZoomAndDragOptions = {}
|
||||
): UseZoomAndDragReturn => {
|
||||
const { minScale = 0.8, maxScale = 3, visible = true } = options;
|
||||
const { scale } = useScale();
|
||||
export const useZoomAndDrag = (options: UseZoomAndDragOptions = {}): UseZoomAndDragReturn => {
|
||||
const { minScale = 0.8, maxScale = 3, visible = true } = options
|
||||
const { scale } = useScale()
|
||||
|
||||
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);
|
||||
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)
|
||||
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 });
|
||||
pointerId: number
|
||||
startX: number
|
||||
startY: number
|
||||
startTransform: { scale: number; x: number; y: number }
|
||||
} | null>(null)
|
||||
const transformRef = useRef({ scale: 1, x: 0, y: 0 })
|
||||
|
||||
// 针对当前 scale 预计算 clamp
|
||||
const clampScale = useCallback(
|
||||
(next: number) => Math.min(maxScale, Math.max(minScale, next)),
|
||||
[minScale, maxScale]
|
||||
);
|
||||
)
|
||||
|
||||
const applyTransformStyle = useCallback(() => {
|
||||
const el = viewElRef.current;
|
||||
if (!el) return;
|
||||
const { scale, x, y } = transformRef.current;
|
||||
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";
|
||||
}, []);
|
||||
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;
|
||||
if (frameRef.current) return
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
applyTransformStyle();
|
||||
frameRef.current = null;
|
||||
});
|
||||
}, [applyTransformStyle]);
|
||||
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;
|
||||
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);
|
||||
containerRef.current?.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
applyTransformStyle();
|
||||
}, [applyTransformStyle]);
|
||||
applyTransformStyle()
|
||||
}, [applyTransformStyle])
|
||||
|
||||
// 关闭或切换时重置
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
resetTransform();
|
||||
resetTransform()
|
||||
} else {
|
||||
// 不可见时清理所有状态和指针捕获
|
||||
transformRef.current = { scale: 1, x: 0, y: 0 };
|
||||
pointers.current.clear();
|
||||
lastDistance.current = null;
|
||||
dragStartRef.current = null;
|
||||
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);
|
||||
containerRef.current?.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
// 取消待处理的动画帧
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
frameRef.current = null
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
// 组件卸载时清理资源
|
||||
useEffect(() => {
|
||||
const containerEl = containerRef.current;
|
||||
const pointersMap = pointers.current;
|
||||
const frameId = frameRef.current;
|
||||
const containerEl = containerRef.current
|
||||
const pointersMap = pointers.current
|
||||
const frameId = frameRef.current
|
||||
|
||||
return () => {
|
||||
// 清理所有指针捕获
|
||||
if (containerEl) {
|
||||
pointersMap.forEach((_, pointerId) => {
|
||||
try {
|
||||
containerEl.releasePointerCapture(pointerId);
|
||||
containerEl.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
// 取消待处理的动画帧
|
||||
if (frameId) {
|
||||
cancelAnimationFrame(frameId);
|
||||
cancelAnimationFrame(frameId)
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 缩放函数:origin 是屏幕坐标,需要转换为相对于 viewElRef 的坐标
|
||||
const applyZoom = useCallback(
|
||||
(factor: number, origin: Point) => {
|
||||
if (!viewElRef.current || !containerRef.current) return;
|
||||
if (!viewElRef.current || !containerRef.current) return
|
||||
|
||||
const prev = transformRef.current;
|
||||
const nextScale = clampScale(prev.scale * factor);
|
||||
const prev = transformRef.current
|
||||
const nextScale = clampScale(prev.scale * factor)
|
||||
// 边界检查:避免除零
|
||||
if (prev.scale === 0) return;
|
||||
if (prev.scale === 0) return
|
||||
|
||||
// 获取 viewElRef 相对于视口的位置
|
||||
const viewRect = viewElRef.current.getBoundingClientRect();
|
||||
const viewRect = viewElRef.current.getBoundingClientRect()
|
||||
// 将屏幕坐标转换为相对于 viewElRef 的坐标
|
||||
const relativeOrigin = {
|
||||
x: origin.x - viewRect.left,
|
||||
y: origin.y - viewRect.top,
|
||||
};
|
||||
}
|
||||
|
||||
const scaleChange = nextScale / prev.scale;
|
||||
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();
|
||||
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;
|
||||
const delta = e.deltaY
|
||||
const factor = delta < 0 ? 1.05 : 0.95
|
||||
// 直接使用屏幕坐标作为缩放中心
|
||||
applyZoom(factor, { x: e.clientX, y: e.clientY });
|
||||
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 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;
|
||||
if (!containerRef.current || !viewElRef.current) return
|
||||
try {
|
||||
containerRef.current.setPointerCapture(e.pointerId);
|
||||
containerRef.current.setPointerCapture(e.pointerId)
|
||||
} catch (err) {
|
||||
console.warn("Failed to capture pointer:", err);
|
||||
console.warn('Failed to capture pointer:', err)
|
||||
}
|
||||
|
||||
// 记录屏幕坐标
|
||||
const point = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
};
|
||||
pointers.current.set(e.pointerId, point);
|
||||
}
|
||||
pointers.current.set(e.pointerId, point)
|
||||
|
||||
// 单指拖拽:记录初始状态
|
||||
if (pointers.current.size === 1) {
|
||||
@@ -220,21 +216,21 @@ export const useZoomAndDrag = (
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startTransform: { ...transformRef.current },
|
||||
};
|
||||
}
|
||||
} else if (pointers.current.size === 2) {
|
||||
// 双指缩放:记录初始距离
|
||||
const values = Array.from(pointers.current.values());
|
||||
const values = Array.from(pointers.current.values())
|
||||
if (values.length === 2) {
|
||||
const [p1, p2] = values;
|
||||
const [p1, p2] = values
|
||||
if (p1 && p2) {
|
||||
lastDistance.current = getDistance(p1, p2);
|
||||
dragStartRef.current = null;
|
||||
lastDistance.current = getDistance(p1, p2)
|
||||
dragStartRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[getDistance]
|
||||
);
|
||||
)
|
||||
|
||||
/**
|
||||
* 单指拖拽处理
|
||||
@@ -243,29 +239,29 @@ export const useZoomAndDrag = (
|
||||
*/
|
||||
const handleSingleDrag = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const dragStart = dragStartRef.current;
|
||||
if (!dragStart) return;
|
||||
const dragStart = dragStartRef.current
|
||||
if (!dragStart) return
|
||||
|
||||
// 1. 计算屏幕坐标系下的位移
|
||||
const dxScreen = e.clientX - dragStart.startX;
|
||||
const dyScreen = e.clientY - dragStart.startY;
|
||||
const dxScreen = e.clientX - dragStart.startX
|
||||
const dyScreen = e.clientY - dragStart.startY
|
||||
|
||||
// 2. 映射到设计稿坐标系(补偿全局 scale)
|
||||
const dx = dxScreen / scale;
|
||||
const dy = dyScreen / scale;
|
||||
const dx = dxScreen / scale
|
||||
const dy = dyScreen / scale
|
||||
|
||||
// 3. 应用到 transform(设计稿坐标)
|
||||
const nextX = dragStart.startTransform.x + dx;
|
||||
const nextY = dragStart.startTransform.y + dy;
|
||||
const nextX = dragStart.startTransform.x + dx
|
||||
const nextY = dragStart.startTransform.y + dy
|
||||
|
||||
transformRef.current.x = nextX;
|
||||
transformRef.current.y = nextY;
|
||||
transformRef.current.x = nextX
|
||||
transformRef.current.y = nextY
|
||||
|
||||
// 统一交给 rAF 的 scheduleUpdate,减少直接 DOM 操作次数
|
||||
scheduleUpdate();
|
||||
scheduleUpdate()
|
||||
},
|
||||
[scale, scheduleUpdate]
|
||||
);
|
||||
)
|
||||
|
||||
/**
|
||||
* 双指缩放处理
|
||||
@@ -273,93 +269,90 @@ export const useZoomAndDrag = (
|
||||
* - 使用两指中点作为缩放中心
|
||||
*/
|
||||
const handlePinchZoom = useCallback(() => {
|
||||
if (pointers.current.size !== 2) return;
|
||||
const values = Array.from(pointers.current.values());
|
||||
const [p1, p2] = values;
|
||||
if (!p1 || !p2) return;
|
||||
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);
|
||||
const distance = getDistance(p1, p2)
|
||||
if (!lastDistance.current || lastDistance.current <= 0) {
|
||||
lastDistance.current = distance;
|
||||
return;
|
||||
lastDistance.current = distance
|
||||
return
|
||||
}
|
||||
|
||||
const factor = distance / lastDistance.current;
|
||||
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]);
|
||||
applyZoom(factor, center)
|
||||
lastDistance.current = distance
|
||||
}, [getDistance, applyZoom])
|
||||
|
||||
// 指针移动:更新指针位置,然后按当前手势类型分发给拖拽 / 缩放处理
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!pointers.current.has(e.pointerId)) return;
|
||||
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;
|
||||
handlePinchZoom()
|
||||
return
|
||||
}
|
||||
|
||||
// 单指拖拽
|
||||
if (pointers.current.size === 1) {
|
||||
handleSingleDrag(e);
|
||||
handleSingleDrag(e)
|
||||
}
|
||||
},
|
||||
[handlePinchZoom, handleSingleDrag]
|
||||
);
|
||||
)
|
||||
|
||||
// 指针抬起
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
// 释放指针捕获
|
||||
if (containerRef.current) {
|
||||
try {
|
||||
containerRef.current.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
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 },
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
} else {
|
||||
dragStartRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 双击重置
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
resetTransform();
|
||||
}, [resetTransform]);
|
||||
resetTransform()
|
||||
}, [resetTransform])
|
||||
|
||||
return {
|
||||
containerRef: containerRef as React.RefObject<HTMLDivElement>,
|
||||
@@ -370,5 +363,5 @@ export const useZoomAndDrag = (
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handleDoubleClick,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
$.extend({
|
||||
getUrlVars: function(){
|
||||
var vars = [], hash;
|
||||
var hashes = window.location.href.slice(window.location.href.indexOf('?') + 1).split('&');
|
||||
for(var i = 0; i < hashes.length; i++)
|
||||
{
|
||||
hash = hashes[i].split('=');
|
||||
vars.push(hash[0]);
|
||||
vars[hash[0]] = hash[1];
|
||||
}
|
||||
return vars;
|
||||
},
|
||||
getUrlVar: function(name){
|
||||
return $.getUrlVars()[name];
|
||||
}
|
||||
});
|
||||
|
||||
var lesson="yuxiang"
|
||||
|
||||
var videoajax = videoAjax();
|
||||
videoajax.setHost("/");
|
||||
|
||||
//设置我的自定义数据(所有的自定义数据都设置在第一个课时ID上,以key来区分不同的类型)
|
||||
function setLtDefine(key,value){
|
||||
if(iscallyb){
|
||||
setUserDefine(1,key,value);
|
||||
}else{
|
||||
sessionStorage.setItem(lesson+"_"+key,value);
|
||||
}
|
||||
}
|
||||
|
||||
var mycstr9 = "";
|
||||
//获取定义数据
|
||||
function getLtDefine(key){
|
||||
if(iscallyb){
|
||||
let cstr = "";
|
||||
if(mycstr9 == ""){
|
||||
let cstr = getUsersDefine(1, emykey);
|
||||
if(cstr == undefined || cstr == null || cstr == "null" || cstr == ""){
|
||||
mycstr9 = getFutureDa(10);
|
||||
setLtDefine(emykey, mycstr9);
|
||||
}else{
|
||||
mycstr9 = cstr;
|
||||
}
|
||||
}
|
||||
cstr = mycstr9;
|
||||
let str = (cstr == undefined || cstr==null || cstr=="null")?"":cstr;
|
||||
getCurrentDa(str);
|
||||
var objitem = getUserDefine(1,key);
|
||||
return (objitem == undefined || objitem==null || objitem=="null")?"":objitem;
|
||||
}else{
|
||||
// let cstr = sessionStorage.getItem(lesson+"_"+emykey);
|
||||
// let str = (cstr == undefined || cstr==null || cstr=="null")?"":cstr;
|
||||
// getCurrentDa(str);
|
||||
var itemvalue = sessionStorage.getItem(lesson+"_"+key);
|
||||
return (itemvalue == undefined || itemvalue==null || itemvalue=="null")?"":itemvalue;
|
||||
}
|
||||
}
|
||||
|
||||
//设置课程笔记10002
|
||||
function setCourseNotepad(collect){
|
||||
setLtDefine(10002,$.toJSON(collect));
|
||||
}
|
||||
//获取课程笔记10002
|
||||
function getCourseNotepad(){
|
||||
return (getLtDefine(10002) == undefined || getLtDefine(10002) == null || getLtDefine(10002) == "") ? ["","","","","","","","",""] : $.parseJSON(getLtDefine(10002));
|
||||
}
|
||||
|
||||
//lessonId(课时唯一ID)
|
||||
export function setUserDefine(lessonId,key,value) {
|
||||
var param = { "lessonId": lesson+"_"+lessonId, "key": key, "content": value };
|
||||
videoajax.addUserDefine(param);
|
||||
}
|
||||
//获取用户自定义数据
|
||||
export function getUserDefine(lessonId,key) {
|
||||
var param = { "lessonId": lesson+"_"+lessonId, "order": key };
|
||||
var content = "";
|
||||
videoajax.getUserDefineInfoByUser(param, function (data) {
|
||||
console.log(data)
|
||||
if (data && data.code == 200) {
|
||||
var json = data.data.value || {};
|
||||
var contents = json[0].content || "";
|
||||
content = contents;
|
||||
}else{
|
||||
content = "";
|
||||
}
|
||||
});
|
||||
return content;
|
||||
}
|
||||
|
||||
//获取【所有】用户自定义数据
|
||||
export function getUsersDefine(lessonId,key) {
|
||||
var param = { "lessonId": lesson+"_"+lessonId, "order": key };
|
||||
var content = "";
|
||||
videoajax.getUserDefineInfo(param, function (data) {
|
||||
if (data && data.code == 200) {
|
||||
//console.info(data);
|
||||
var json = data.data.value || {};
|
||||
if(json.length > 1){
|
||||
if(key == emykey){
|
||||
let mdate = new Date(Math.max.apply(null, json.map(item => new Date(item.content))));
|
||||
content = formatDateFromMilliseconds(mdate);
|
||||
}else{
|
||||
content = json;
|
||||
}
|
||||
}else{
|
||||
content = json[0].content || "";
|
||||
}
|
||||
}else{
|
||||
content = "";
|
||||
}
|
||||
});
|
||||
return content;
|
||||
}
|
||||
|
||||
//根据课时顺序号(所处课程的第几课时)更新对应易班平台上的每个环节进度
|
||||
export function setCourseTimeProgress(order){
|
||||
//var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID
|
||||
var courseid = getLtDefine("10000");
|
||||
console.info("courseid:" + courseid + " order:" + order);
|
||||
if (iscallyb && courseid != undefined && courseid != null && courseid != "") {
|
||||
if (order > 0) {
|
||||
var param2 = { "order": order, "co": courseid, "process": 100 };
|
||||
videoajax.finishCourse(param2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// 声明外部依赖(由运行环境提供)
|
||||
declare const $: any
|
||||
declare const iscallyb: boolean
|
||||
declare const emykey: string
|
||||
declare function videoAjax(): VideoAjax
|
||||
declare function getFutureDa(days: number): string
|
||||
declare function getCurrentDa(str: string): void
|
||||
declare function formatDateFromMilliseconds(date: Date): string
|
||||
|
||||
interface UserDefineParam {
|
||||
lessonId: string
|
||||
key?: string | number
|
||||
order?: string | number
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface VideoAjaxResponseItem {
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface VideoAjaxResponse {
|
||||
code: number
|
||||
data: {
|
||||
value?: VideoAjaxResponseItem[] | string
|
||||
}
|
||||
}
|
||||
|
||||
interface VideoAjax {
|
||||
setHost(host: string): void
|
||||
addUserDefine(param: UserDefineParam): void
|
||||
getUserDefineInfoByUser(
|
||||
param: UserDefineParam,
|
||||
callback: (data: VideoAjaxResponse | undefined) => void
|
||||
): void
|
||||
getUserDefineInfo(
|
||||
param: UserDefineParam,
|
||||
callback: (data: VideoAjaxResponse | undefined) => void
|
||||
): void
|
||||
finishCourse(param: { order: number; co: string; process: number }): void
|
||||
}
|
||||
|
||||
// 扩展 jQuery,获取 URL 参数
|
||||
$.extend({
|
||||
getUrlVars(): string[] & Record<string, string | undefined> {
|
||||
const vars: any[] & Record<string, string | undefined> = [] as any
|
||||
let hash: string[]
|
||||
const queryIndex = window.location.href.indexOf('?')
|
||||
if (queryIndex === -1) return vars
|
||||
const hashes = window.location.href
|
||||
.slice(queryIndex + 1)
|
||||
.split('&')
|
||||
.filter(Boolean)
|
||||
for (let i = 0; i < hashes.length; i++) {
|
||||
const item = hashes[i]
|
||||
if (!item) continue
|
||||
hash = item.split('=')
|
||||
const keyRaw = hash[0] ?? ''
|
||||
const valueRaw = hash[1] ?? ''
|
||||
const key = decodeURIComponent(keyRaw)
|
||||
const value = decodeURIComponent(valueRaw)
|
||||
vars.push(key)
|
||||
vars[key] = value
|
||||
}
|
||||
return vars
|
||||
},
|
||||
getUrlVar(name: string): string | undefined {
|
||||
return ($ as any).getUrlVars()[name]
|
||||
},
|
||||
})
|
||||
|
||||
const LESSON_PREFIX = 'hsgrl'
|
||||
|
||||
const videoajax: VideoAjax = videoAjax()
|
||||
videoajax.setHost('/')
|
||||
|
||||
// 设置我的自定义数据(所有的自定义数据都设置在第一个课时ID上,以key来区分不同的类型)
|
||||
function setLtDefine(key: string | number, value: string): void {
|
||||
if (iscallyb) {
|
||||
setUserDefine(1, key, value)
|
||||
} else {
|
||||
sessionStorage.setItem(`${LESSON_PREFIX}_${key}`, value)
|
||||
}
|
||||
}
|
||||
|
||||
let mycstr9 = ''
|
||||
|
||||
// 获取定义数据(由于底层是异步接口,这里返回 Promise)
|
||||
async function getLtDefine(key: string | number): Promise<string> {
|
||||
if (iscallyb) {
|
||||
if (mycstr9 === '') {
|
||||
const all = await getUsersDefine(1, emykey)
|
||||
if (all === undefined || all === null || all === 'null' || all === '') {
|
||||
mycstr9 = getFutureDa(10)
|
||||
setLtDefine(emykey, mycstr9)
|
||||
} else {
|
||||
mycstr9 = all as string // 这里是 emykey,对应上面 getUsersDefine 的 string 分支
|
||||
}
|
||||
}
|
||||
const str = mycstr9 === undefined || mycstr9 === null || mycstr9 === 'null' ? '' : mycstr9
|
||||
getCurrentDa(str)
|
||||
const objitem = await getUserDefine(1, key)
|
||||
return objitem === undefined || objitem === null || objitem === 'null' ? '' : objitem
|
||||
} else {
|
||||
const itemvalue = sessionStorage.getItem(`${LESSON_PREFIX}_${key}`)
|
||||
return itemvalue === undefined || itemvalue === null || itemvalue === 'null' ? '' : itemvalue
|
||||
}
|
||||
}
|
||||
|
||||
// 设置课程笔记10002
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function setCourseNotepad(collect: string[]): void {
|
||||
// 保持和原逻辑一致:序列化为 JSON 存储
|
||||
setLtDefine(10002, $.toJSON(collect))
|
||||
}
|
||||
|
||||
// 获取课程笔记10002(异步)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function getCourseNotepad(): Promise<string[]> {
|
||||
const raw = await getLtDefine(10002)
|
||||
if (raw === undefined || raw === null || raw === '') {
|
||||
// 原逻辑:返回长度为 9 的空字符串数组
|
||||
return ['', '', '', '', '', '', '', '', '']
|
||||
}
|
||||
return $.parseJSON(raw) as string[]
|
||||
}
|
||||
|
||||
// lessonId(课时唯一ID)
|
||||
export function setUserDefine(
|
||||
lessonId: string | number,
|
||||
key: string | number,
|
||||
value: string
|
||||
): void {
|
||||
const param: UserDefineParam = {
|
||||
lessonId: `${LESSON_PREFIX}_${lessonId}`,
|
||||
key,
|
||||
content: value,
|
||||
}
|
||||
videoajax.addUserDefine(param)
|
||||
}
|
||||
|
||||
// 内部:将回调形式封装为 Promise
|
||||
function getUserDefineAsyncInternal(
|
||||
lessonId: string | number,
|
||||
key: string | number
|
||||
): Promise<string> {
|
||||
const param: UserDefineParam = {
|
||||
lessonId: `${LESSON_PREFIX}_${lessonId}`,
|
||||
order: key,
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let content = ''
|
||||
videoajax.getUserDefineInfoByUser(param, (res) => {
|
||||
// 保留原有 console 行为
|
||||
console.log(res)
|
||||
if (res && res.code === 200) {
|
||||
const json = (res.data.value || []) as VideoAjaxResponseItem[]
|
||||
const contents = json[0]?.content || ''
|
||||
content = contents
|
||||
} else {
|
||||
content = ''
|
||||
}
|
||||
resolve(content)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Promise 版本:获取用户自定义数据
|
||||
export async function getUserDefine(
|
||||
lessonId: string | number,
|
||||
key: string | number
|
||||
): Promise<string> {
|
||||
return getUserDefineAsyncInternal(lessonId, key)
|
||||
}
|
||||
|
||||
// 内部:获取【所有】用户自定义数据(Promise 封装)
|
||||
function getUsersDefineAsyncInternal(
|
||||
lessonId: string | number,
|
||||
key: string | number
|
||||
): Promise<string | VideoAjaxResponseItem[]> {
|
||||
const param: UserDefineParam = {
|
||||
lessonId: `${LESSON_PREFIX}_${lessonId}`,
|
||||
order: key,
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let content: string | VideoAjaxResponseItem[] = ''
|
||||
videoajax.getUserDefineInfo(param, (data) => {
|
||||
if (data && data.code === 200) {
|
||||
const json = (data.data.value || []) as VideoAjaxResponseItem[]
|
||||
if (json.length > 1) {
|
||||
if (key === emykey) {
|
||||
const mdate = new Date(
|
||||
Math.max.apply(
|
||||
null,
|
||||
json.map((item) => new Date(item.content || '').getTime())
|
||||
)
|
||||
)
|
||||
content = formatDateFromMilliseconds(mdate)
|
||||
} else {
|
||||
content = json
|
||||
}
|
||||
} else {
|
||||
content = json[0]?.content || ''
|
||||
}
|
||||
} else {
|
||||
content = ''
|
||||
}
|
||||
resolve(content)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Promise 版本:获取【所有】用户自定义数据
|
||||
export async function getUsersDefine(
|
||||
lessonId: string | number,
|
||||
key: string | number
|
||||
): Promise<string | VideoAjaxResponseItem[]> {
|
||||
return getUsersDefineAsyncInternal(lessonId, key)
|
||||
}
|
||||
|
||||
// 根据课时顺序号(所处课程的第几课时)更新对应易班平台上的每个环节进度(异步)
|
||||
export async function setCourseTimeProgress(order: number): Promise<void> {
|
||||
// var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID
|
||||
const courseid = await getLtDefine('10000')
|
||||
console.info(`courseid:${courseid} order:${order}`)
|
||||
if (iscallyb && courseid !== undefined && courseid !== null && courseid !== '') {
|
||||
if (order > 0) {
|
||||
const param2 = { order, co: courseid, process: 100 }
|
||||
videoajax.finishCourse(param2)
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-29
@@ -1,56 +1,53 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
// 图片资源映射 - 使用静态导入确保构建时包含所有图片
|
||||
const imageModules = import.meta.glob(
|
||||
"../../assets/images/**/*.{png,jpg,jpeg,gif,svg,webp}",
|
||||
{
|
||||
eager: true,
|
||||
}
|
||||
);
|
||||
const imageModules = import.meta.glob('../../assets/images/**/*.{png,jpg,jpeg,gif,svg,webp}', {
|
||||
eager: true,
|
||||
})
|
||||
|
||||
//访问静态图片 - 使用静态导入确保构建后路径正确
|
||||
export const getStaticImage = (path: string) => {
|
||||
try {
|
||||
// 确保路径以正确的格式结尾
|
||||
const imagePath =
|
||||
path.endsWith(".png") ||
|
||||
path.endsWith(".jpg") ||
|
||||
path.endsWith(".jpeg") ||
|
||||
path.endsWith(".gif") ||
|
||||
path.endsWith(".svg") ||
|
||||
path.endsWith(".webp")
|
||||
path.endsWith('.png') ||
|
||||
path.endsWith('.jpg') ||
|
||||
path.endsWith('.jpeg') ||
|
||||
path.endsWith('.gif') ||
|
||||
path.endsWith('.svg') ||
|
||||
path.endsWith('.webp')
|
||||
? `../../assets/images/${path}`
|
||||
: `../../assets/images/${path}.png`;
|
||||
: `../../assets/images/${path}.png`
|
||||
|
||||
// 使用静态导入的模块
|
||||
const module = imageModules[imagePath];
|
||||
if (module && typeof module === "object" && "default" in module) {
|
||||
return module.default as string;
|
||||
const module = imageModules[imagePath]
|
||||
if (module && typeof module === 'object' && 'default' in module) {
|
||||
return module.default as string
|
||||
}
|
||||
|
||||
// 回退到URL方式
|
||||
return new URL(imagePath, import.meta.url).href;
|
||||
return new URL(imagePath, import.meta.url).href
|
||||
} catch (error) {
|
||||
console.error("Failed to load image:", path, error);
|
||||
return "";
|
||||
console.error('Failed to load image:', path, error)
|
||||
return ''
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 获取背景图片的专用函数
|
||||
export const getBackgroundImage = (path: string) => {
|
||||
const imageUrl = getStaticImage(path);
|
||||
return imageUrl ? `url(${imageUrl})` : "none";
|
||||
};
|
||||
const imageUrl = getStaticImage(path)
|
||||
return imageUrl ? `url(${imageUrl})` : 'none'
|
||||
}
|
||||
|
||||
// 获取 public 文件夹中的资源路径(用于 Word 文档等)
|
||||
export const getPublicPath = (path: string) => {
|
||||
// 移除开头的斜杠(如果有)
|
||||
const cleanPath = path.startsWith("/") ? path.slice(1) : path;
|
||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path
|
||||
// 使用 Vite 的 BASE_URL 环境变量来处理基础路径
|
||||
return `${import.meta.env.BASE_URL}${cleanPath}`;
|
||||
};
|
||||
return `${import.meta.env.BASE_URL}${cleanPath}`
|
||||
}
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
// 敏感词库(可根据实际需求扩展)
|
||||
const SENSITIVE_WORDS = [
|
||||
"敏感词1",
|
||||
"敏感词2",
|
||||
"测试",
|
||||
"垃圾",
|
||||
'敏感词1',
|
||||
'敏感词2',
|
||||
'测试',
|
||||
'垃圾',
|
||||
// 可以根据实际需求添加更多敏感词
|
||||
];
|
||||
]
|
||||
|
||||
/**
|
||||
* 检查文本中是否包含敏感词
|
||||
@@ -18,13 +18,11 @@ const SENSITIVE_WORDS = [
|
||||
* @returns 是否包含敏感词
|
||||
*/
|
||||
export function containsSensitiveWord(text: string): boolean {
|
||||
if (!text) return false;
|
||||
if (!text) return false
|
||||
|
||||
const normalizedText = text.toLowerCase().trim();
|
||||
const normalizedText = text.toLowerCase().trim()
|
||||
|
||||
return SENSITIVE_WORDS.some((word) =>
|
||||
normalizedText.includes(word.toLowerCase())
|
||||
);
|
||||
return SENSITIVE_WORDS.some((word) => normalizedText.includes(word.toLowerCase()))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,16 +31,16 @@ export function containsSensitiveWord(text: string): boolean {
|
||||
* @returns 处理后的文本
|
||||
*/
|
||||
export function filterSensitiveWords(text: string): string {
|
||||
if (!text) return text;
|
||||
if (!text) return text
|
||||
|
||||
let result = text;
|
||||
let result = text
|
||||
|
||||
SENSITIVE_WORDS.forEach((word) => {
|
||||
const regex = new RegExp(word, "gi");
|
||||
result = result.replace(regex, "*".repeat(word.length));
|
||||
});
|
||||
const regex = new RegExp(word, 'gi')
|
||||
result = result.replace(regex, '*'.repeat(word.length))
|
||||
})
|
||||
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,10 +49,10 @@ export function filterSensitiveWords(text: string): string {
|
||||
* @returns 过滤后的内容
|
||||
*/
|
||||
export function filterInput(input: string): string {
|
||||
if (!input) return input;
|
||||
if (!input) return input
|
||||
|
||||
// 先进行敏感词过滤
|
||||
const filtered = filterSensitiveWords(input);
|
||||
const filtered = filterSensitiveWords(input)
|
||||
|
||||
return filtered;
|
||||
return filtered
|
||||
}
|
||||
+33
-38
@@ -1,77 +1,72 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import {
|
||||
useChenyunStore,
|
||||
useHuangyanpeiStore,
|
||||
usePuzzleStore,
|
||||
useClueStore,
|
||||
} from "./store";
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
import { useChenyunStore, useHuangyanpeiStore, usePuzzleStore, useClueStore } from './store'
|
||||
|
||||
// 在开发模式的移动端启用 vConsole(需先安装 vconsole 依赖)
|
||||
if (import.meta.env.DEV && typeof window !== "undefined") {
|
||||
const ua = window.navigator.userAgent.toLowerCase();
|
||||
const isMobile = /iphone|ipad|ipod|android|mobile/.test(ua);
|
||||
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||
const ua = window.navigator.userAgent.toLowerCase()
|
||||
const isMobile = /iphone|ipad|ipod|android|mobile/.test(ua)
|
||||
|
||||
if (isMobile) {
|
||||
// 动态加载,避免生产环境打包 & 未安装时报错
|
||||
import("vconsole")
|
||||
import('vconsole')
|
||||
.then((mod) => {
|
||||
const VConsole = (mod as any).default || (mod as any);
|
||||
// eslint-disable-next-line no-new
|
||||
new VConsole();
|
||||
const VConsole = (mod as any).default || (mod as any)
|
||||
|
||||
new VConsole()
|
||||
})
|
||||
.catch(() => {
|
||||
// vconsole 未安装或加载失败时静默忽略
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 全局键盘快捷键:Ctrl+Shift+R 重置所有缓存
|
||||
const setupGlobalKeyboardShortcuts = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
// Ctrl+Shift+R: 重置所有缓存
|
||||
if (event.ctrlKey && event.shiftKey && event.key === "R") {
|
||||
event.preventDefault(); // 阻止默认的页面刷新行为
|
||||
if (event.ctrlKey && event.shiftKey && event.key === 'R') {
|
||||
event.preventDefault() // 阻止默认的页面刷新行为
|
||||
|
||||
// 显示确认对话框
|
||||
const confirmed = window.confirm(
|
||||
"确定要重置所有缓存吗?这将清除所有已收集的拼图碎片、已完成的展品和线索。"
|
||||
);
|
||||
'确定要重置所有缓存吗?这将清除所有已收集的拼图碎片、已完成的展品和线索。'
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
try {
|
||||
// 重置所有store
|
||||
useChenyunStore.getState().resetAll();
|
||||
useHuangyanpeiStore.getState().resetAll();
|
||||
usePuzzleStore.getState().resetAll();
|
||||
useClueStore.getState().resetAll();
|
||||
useChenyunStore.getState().resetAll()
|
||||
useHuangyanpeiStore.getState().resetAll()
|
||||
usePuzzleStore.getState().resetAll()
|
||||
useClueStore.getState().resetAll()
|
||||
|
||||
// 显示成功消息
|
||||
alert("所有缓存已重置!页面将刷新。");
|
||||
window.location.reload();
|
||||
alert('所有缓存已重置!页面将刷新。')
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error("重置缓存时出错:", error);
|
||||
alert("重置缓存失败,请查看控制台错误信息。");
|
||||
console.error('重置缓存时出错:', error)
|
||||
alert('重置缓存失败,请查看控制台错误信息。')
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 设置全局键盘快捷键
|
||||
setupGlobalKeyboardShortcuts();
|
||||
setupGlobalKeyboardShortcuts()
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
const rootElement = document.getElementById('root')
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element not found");
|
||||
throw new Error('Root element not found')
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
)
|
||||
+10
-10
@@ -1,24 +1,24 @@
|
||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { Image } from "@/components";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
import { Image } from '@/components'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const Home = () => {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: getBackgroundImage("home/首页底图") }}
|
||||
style={{ backgroundImage: getBackgroundImage('home/首页底图') }}
|
||||
>
|
||||
<Image
|
||||
onClick={() => {
|
||||
navigate("/menu");
|
||||
navigate('/menu')
|
||||
}}
|
||||
className="absolute bottom-38 left-1/2 -translate-1/2"
|
||||
button
|
||||
src={getStaticImage("home/首页-进入")}
|
||||
src={getStaticImage('home/首页-进入')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Home;
|
||||
export default Home
|
||||
+36
-43
@@ -1,13 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { Image, PuzzleSystem } from "@/components";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import MenuPuzzleCenter from "@/components/puzzle/MenuPuzzleCenter";
|
||||
import { useState } from 'react'
|
||||
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
import { Image, PuzzleSystem } from '@/components'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import MenuPuzzleCenter from '@/components/puzzle/MenuPuzzleCenter'
|
||||
|
||||
const Menu = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isPuzzleCenterVisible, setIsPuzzleCenterVisible] = useState(false);
|
||||
const [activeResidence, setActiveResidence] = useState<string | null>(null);
|
||||
const navigate = useNavigate()
|
||||
const [isPuzzleCenterVisible, setIsPuzzleCenterVisible] = useState(false)
|
||||
const [activeResidence, setActiveResidence] = useState<string | null>(null)
|
||||
|
||||
/**
|
||||
* 类型:1:革命先驱、文化民人、艺术名家、经济巨擘
|
||||
@@ -15,46 +15,46 @@ const Menu = () => {
|
||||
const Category = [
|
||||
{
|
||||
id: 1,
|
||||
name: "革命先驱",
|
||||
moduleName: "revolutionary",
|
||||
image: "menu/模块-革命先驱",
|
||||
name: '革命先驱',
|
||||
moduleName: 'revolutionary',
|
||||
image: 'menu/模块-革命先驱',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "文教名人",
|
||||
moduleName: "cultural",
|
||||
image: "menu/模块-文教名人",
|
||||
name: '文教名人',
|
||||
moduleName: 'cultural',
|
||||
image: 'menu/模块-文教名人',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "艺术名家",
|
||||
moduleName: "artistic",
|
||||
image: "menu/模块-艺术名家",
|
||||
name: '艺术名家',
|
||||
moduleName: 'artistic',
|
||||
image: 'menu/模块-艺术名家',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "经济巨擘",
|
||||
moduleName: "economic",
|
||||
image: "menu/模块-经济巨擘",
|
||||
name: '经济巨擘',
|
||||
moduleName: 'economic',
|
||||
image: 'menu/模块-经济巨擘',
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: getBackgroundImage("menu/故居入口页底图") }}
|
||||
style={{ backgroundImage: getBackgroundImage('menu/故居入口页底图') }}
|
||||
>
|
||||
<Image
|
||||
className="absolute top-10 left-10"
|
||||
src={getStaticImage("menu/首页-返回")}
|
||||
src={getStaticImage('menu/首页-返回')}
|
||||
button
|
||||
onClick={() => navigate("/")}
|
||||
onClick={() => navigate('/')}
|
||||
/>
|
||||
|
||||
{/* 左下角碎片盒按钮 - 进入碎片管理中心 */}
|
||||
<div className="absolute left-4 bottom-4 z-10 cursor-pointer">
|
||||
<Image
|
||||
src={getStaticImage("components/拼图盒")}
|
||||
src={getStaticImage('components/拼图盒')}
|
||||
button
|
||||
onClick={() => setIsPuzzleCenterVisible(true)}
|
||||
/>
|
||||
@@ -64,21 +64,16 @@ const Menu = () => {
|
||||
{Category.map((item) => (
|
||||
<div
|
||||
className="relative w-[241px] h-[282px] bg-cover bg-center bg-no-repeat hover:brightness-110 hover:scale-105 transition-all cursor-pointer"
|
||||
style={{ backgroundImage: getBackgroundImage("menu/模块-通用") }}
|
||||
style={{ backgroundImage: getBackgroundImage('menu/模块-通用') }}
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
navigate(`/entrance/${encodeURIComponent(item.moduleName)}`)
|
||||
}
|
||||
onClick={() => navigate(`/entrance/${encodeURIComponent(item.moduleName)}`)}
|
||||
>
|
||||
<div className="absolute top-15 flex flex-col items-center">
|
||||
<Image key={item.id} src={getStaticImage(item.image)} />
|
||||
|
||||
<div className="relative mt-23">
|
||||
<Image
|
||||
className="absolute bottom-5"
|
||||
src={getStaticImage("menu/未看完")}
|
||||
/>
|
||||
<Image src={getStaticImage("menu/未探索")} />
|
||||
<Image className="absolute bottom-5" src={getStaticImage('menu/未看完')} />
|
||||
<Image src={getStaticImage('menu/未探索')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,8 +85,8 @@ const Menu = () => {
|
||||
visible={isPuzzleCenterVisible}
|
||||
onClose={() => setIsPuzzleCenterVisible(false)}
|
||||
onSelectResidence={(residence) => {
|
||||
setActiveResidence(residence);
|
||||
setIsPuzzleCenterVisible(false);
|
||||
setActiveResidence(residence)
|
||||
setIsPuzzleCenterVisible(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -100,17 +95,15 @@ const Menu = () => {
|
||||
<PuzzleSystem
|
||||
visible={!!activeResidence}
|
||||
onClose={() => {
|
||||
setActiveResidence(null);
|
||||
setActiveResidence(null)
|
||||
// 关闭 PuzzleSystem 后,重新打开碎片管理中心
|
||||
setIsPuzzleCenterVisible(true);
|
||||
setIsPuzzleCenterVisible(true)
|
||||
}}
|
||||
residence={activeResidence}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default Menu
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Home, ArrowLeft } from "lucide-react";
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Home, ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
@@ -8,12 +8,8 @@ export default function NotFound() {
|
||||
<div className="bg-white rounded-xl shadow-lg p-12">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-9xl font-bold text-indigo-600 mb-4">404</h1>
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
页面未找到
|
||||
</h2>
|
||||
<p className="text-gray-600 text-lg">
|
||||
抱歉,您访问的页面不存在或已被移除。
|
||||
</p>
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">页面未找到</h2>
|
||||
<p className="text-gray-600 text-lg">抱歉,您访问的页面不存在或已被移除。</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
@@ -35,5 +31,5 @@ export default function NotFound() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
+49
-50
@@ -1,64 +1,63 @@
|
||||
import { useEffect } from "react";
|
||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Image } from "@/components";
|
||||
import { getUserDefine } from "@/lib/utils/api-common";
|
||||
import { useChenyunStore } from "@/store/chenyunStore";
|
||||
import { useEffect } from 'react'
|
||||
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Image } from '@/components'
|
||||
import { getUserDefine } from '@/lib/utils/api-common'
|
||||
import { useChenyunStore } from '@/store/chenyunStore'
|
||||
|
||||
const CHENYUN_LESSON_ID = "hsgrl_Cheny_duihua";
|
||||
const CHENYUN_KEY = 22;
|
||||
const CHENYUN_LESSON_ID = 'Cheny_duihua'
|
||||
const CHENYUN_KEY = 22
|
||||
|
||||
const deserializeVisitedExhibits = (
|
||||
data: Record<string, number[]> | undefined
|
||||
): Record<string, Set<number>> => {
|
||||
if (!data || typeof data !== "object") return {};
|
||||
const visitedExhibits: Record<string, Set<number>> = {};
|
||||
if (!data || typeof data !== 'object') return {}
|
||||
const visitedExhibits: Record<string, Set<number>> = {}
|
||||
for (const [place, exhibits] of Object.entries(data)) {
|
||||
visitedExhibits[place] = new Set(Array.isArray(exhibits) ? exhibits : []);
|
||||
visitedExhibits[place] = new Set(Array.isArray(exhibits) ? exhibits : [])
|
||||
}
|
||||
return visitedExhibits;
|
||||
};
|
||||
return visitedExhibits
|
||||
}
|
||||
|
||||
const Chenyun = () => {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{
|
||||
moduleName: string;
|
||||
}>();
|
||||
moduleName: string
|
||||
}>()
|
||||
|
||||
// starName 固定为 chenyun
|
||||
const starName = "chenyun";
|
||||
const starName = 'chenyun'
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = getUserDefine(CHENYUN_LESSON_ID, CHENYUN_KEY);
|
||||
console.log(raw)
|
||||
if (!raw) return;
|
||||
;(async () => {
|
||||
console.log('1233')
|
||||
try {
|
||||
const raw = await getUserDefine(CHENYUN_LESSON_ID, CHENYUN_KEY)
|
||||
console.log(raw)
|
||||
if (!raw) return
|
||||
|
||||
const data = JSON.parse(raw) as {
|
||||
visitedExhibits?: Record<string, number[]>;
|
||||
isYinyuanSolved?: boolean;
|
||||
isMimianSolved?: boolean;
|
||||
};
|
||||
const data = JSON.parse(raw) as {
|
||||
visitedExhibits?: Record<string, number[]>
|
||||
isYinyuanSolved?: boolean
|
||||
isMimianSolved?: boolean
|
||||
}
|
||||
|
||||
useChenyunStore.setState((prev) => ({
|
||||
...prev,
|
||||
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
|
||||
isYinyuanSolved:
|
||||
data.isYinyuanSolved !== undefined
|
||||
? data.isYinyuanSolved
|
||||
: prev.isYinyuanSolved,
|
||||
isMimianSolved:
|
||||
data.isMimianSolved !== undefined
|
||||
? data.isMimianSolved
|
||||
: prev.isMimianSolved,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to load Chenyun store", error);
|
||||
}
|
||||
}, []);
|
||||
useChenyunStore.setState((prev) => ({
|
||||
...prev,
|
||||
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
|
||||
isYinyuanSolved:
|
||||
data.isYinyuanSolved !== undefined ? data.isYinyuanSolved : prev.isYinyuanSolved,
|
||||
isMimianSolved:
|
||||
data.isMimianSolved !== undefined ? data.isMimianSolved : prev.isMimianSolved,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to load Chenyun store', error)
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
if (!moduleName) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -70,7 +69,7 @@ const Chenyun = () => {
|
||||
>
|
||||
<Image
|
||||
className="absolute right-37 top-22"
|
||||
src={getStaticImage("components/革命先驱故居-返回")}
|
||||
src={getStaticImage('components/革命先驱故居-返回')}
|
||||
button
|
||||
onClick={() => navigate(`/entrance/${moduleName}`)}
|
||||
/>
|
||||
@@ -79,24 +78,24 @@ const Chenyun = () => {
|
||||
{/* 对话 */}
|
||||
<Image
|
||||
button
|
||||
src={getStaticImage("star/一-解锁")}
|
||||
src={getStaticImage('star/一-解锁')}
|
||||
onClick={() => navigate(`/${moduleName}/${starName}/place1`)}
|
||||
/>
|
||||
{/* 体悟 */}
|
||||
<Image
|
||||
button
|
||||
src={getStaticImage("star/二-解锁")}
|
||||
src={getStaticImage('star/二-解锁')}
|
||||
onClick={() => navigate(`/${moduleName}/${starName}/reflection`)}
|
||||
/>
|
||||
{/* 内化 */}
|
||||
<Image
|
||||
button
|
||||
src={getStaticImage("star/三-解锁")}
|
||||
src={getStaticImage('star/三-解锁')}
|
||||
onClick={() => navigate(`/${moduleName}/${starName}/internalization`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Chenyun;
|
||||
export default Chenyun
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Image,
|
||||
Video,
|
||||
@@ -8,231 +8,221 @@ import {
|
||||
type DragFillResult,
|
||||
LetterToChenyun,
|
||||
EndingOverlay,
|
||||
} from "@/components";
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
|
||||
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
|
||||
import { MultipleChoiceQuizPanel } from "@/components/layout/quiz";
|
||||
import { GuideSteps, GuideStepsItem } from "@/components";
|
||||
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
|
||||
} from '@/components'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { SuccessAlert, DragQuizPanel } from '@/components/layout'
|
||||
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
type QuizOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
isCorrect?: boolean;
|
||||
explanation?: string; // 错误选项的解释
|
||||
};
|
||||
value: string
|
||||
label: string
|
||||
isCorrect?: boolean
|
||||
explanation?: string // 错误选项的解释
|
||||
}
|
||||
|
||||
type QuizAnchor = {
|
||||
id: string;
|
||||
time: number;
|
||||
title: string;
|
||||
prompt: string;
|
||||
type: "single" | "multiple" | "drag";
|
||||
options: QuizOption[];
|
||||
id: string
|
||||
time: number
|
||||
title: string
|
||||
prompt: string
|
||||
type: 'single' | 'multiple' | 'drag'
|
||||
options: QuizOption[]
|
||||
// 拖拽题专用
|
||||
blanks?: { id: string; label: string }[];
|
||||
explanation?: string;
|
||||
};
|
||||
blanks?: { id: string; label: string }[]
|
||||
explanation?: string
|
||||
}
|
||||
|
||||
// 星名固定为 chenyun
|
||||
const starName = "chenyun";
|
||||
const starName = 'chenyun'
|
||||
|
||||
// 通用提示文案
|
||||
const MULTIPLE_DEFAULT_ERROR =
|
||||
"你的选择还不够全面哦~请你再想想吧!";
|
||||
const MULTIPLE_DEFAULT_ERROR = '你的选择还不够全面哦~请你再想想吧!'
|
||||
|
||||
// 锚点数据:可根据实际视频时长调整 time
|
||||
const QUIZ_ANCHORS: QuizAnchor[] = [
|
||||
{
|
||||
id: "quiz-1",
|
||||
id: 'quiz-1',
|
||||
time: 60 * 5 + 52, //5.52分钟
|
||||
title: "题目1",
|
||||
prompt: "陈云经济思想的核心内容指的是(\xa0\xa0\xa0\xa0)。",
|
||||
type: "multiple",
|
||||
title: '题目1',
|
||||
prompt: '陈云经济思想的核心内容指的是(\xa0\xa0\xa0\xa0)。',
|
||||
type: 'multiple',
|
||||
options: [
|
||||
{ value: "A", label: "综合平衡", isCorrect: true },
|
||||
{ value: 'A', label: '综合平衡', isCorrect: true },
|
||||
{
|
||||
value: "B",
|
||||
label: "鸟笼比喻",
|
||||
value: 'B',
|
||||
label: '鸟笼比喻',
|
||||
},
|
||||
{ value: "C", label: "正确的处理好计划与市场的关系", isCorrect: true },
|
||||
{ value: "D", label: "经济建设的目的论", isCorrect: true },
|
||||
{ value: 'C', label: '正确的处理好计划与市场的关系', isCorrect: true },
|
||||
{ value: 'D', label: '经济建设的目的论', isCorrect: true },
|
||||
],
|
||||
explanation: "鸟笼比喻是陈云提出的一个比喻。请你再想想吧!",
|
||||
explanation: '鸟笼比喻是陈云提出的一个比喻。请你再想想吧!',
|
||||
},
|
||||
{
|
||||
id: "quiz-2",
|
||||
id: 'quiz-2',
|
||||
time: 60 * 10 + 1, //10.01分钟
|
||||
title: "题目2",
|
||||
prompt: "陈云说过以下哪些话能着重体现出他的经济思想?",
|
||||
type: "multiple",
|
||||
title: '题目2',
|
||||
prompt: '陈云说过以下哪些话能着重体现出他的经济思想?',
|
||||
type: 'multiple',
|
||||
options: [
|
||||
{
|
||||
value: "A",
|
||||
label: "不唯上、不唯书、只唯实,交换、比较、反复",
|
||||
value: 'A',
|
||||
label: '不唯上、不唯书、只唯实,交换、比较、反复',
|
||||
isCorrect: true,
|
||||
},
|
||||
{ value: "B", label: "无农不稳,无粮则乱", isCorrect: true },
|
||||
{ value: "C", label: "个人名利淡如水,党的事业重如山", isCorrect: true },
|
||||
{ value: 'B', label: '无农不稳,无粮则乱', isCorrect: true },
|
||||
{ value: 'C', label: '个人名利淡如水,党的事业重如山', isCorrect: true },
|
||||
{
|
||||
value: "D",
|
||||
label: "领导机关制定政策,要用百分之九十以上的时间作调查研究工作",
|
||||
value: 'D',
|
||||
label: '领导机关制定政策,要用百分之九十以上的时间作调查研究工作',
|
||||
isCorrect: true,
|
||||
},
|
||||
],
|
||||
explanation: "你的选择还不够全面哦~请你再想想吧!",
|
||||
explanation: '你的选择还不够全面哦~请你再想想吧!',
|
||||
},
|
||||
{
|
||||
id: "quiz-3",
|
||||
id: 'quiz-3',
|
||||
time: 16 * 60 + 26, //16:26
|
||||
title: "题目3",
|
||||
prompt: "新时代的青年可以从陈云身上学习到",
|
||||
type: "drag",
|
||||
title: '题目3',
|
||||
prompt: '新时代的青年可以从陈云身上学习到',
|
||||
type: 'drag',
|
||||
options: [
|
||||
{ value: "积极的人生态度", label: "A.积极的人生态度", isCorrect: true },
|
||||
{ value: "刻苦的学习精神", label: "B.刻苦的学习精神", isCorrect: true },
|
||||
{ value: "朴素的节俭精神", label: "C.朴素的节俭精神", isCorrect: true },
|
||||
{ value: "只唯上的工作作风", label: "D.只唯上的工作作风" },
|
||||
{ value: '积极的人生态度', label: 'A.积极的人生态度', isCorrect: true },
|
||||
{ value: '刻苦的学习精神', label: 'B.刻苦的学习精神', isCorrect: true },
|
||||
{ value: '朴素的节俭精神', label: 'C.朴素的节俭精神', isCorrect: true },
|
||||
{ value: '只唯上的工作作风', label: 'D.只唯上的工作作风' },
|
||||
],
|
||||
blanks: [
|
||||
{ id: "blank-1", label: "____" },
|
||||
{ id: "blank-2", label: "____" },
|
||||
{ id: "blank-3", label: "____" },
|
||||
{ id: 'blank-1', label: '____' },
|
||||
{ id: 'blank-2', label: '____' },
|
||||
{ id: 'blank-3', label: '____' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "quiz-4",
|
||||
id: 'quiz-4',
|
||||
time: 60 * 5 + 21, // 5:21
|
||||
title: "题目4",
|
||||
prompt: "以下哪些陈云的生活小事值得我们学习?",
|
||||
type: "multiple",
|
||||
title: '题目4',
|
||||
prompt: '以下哪些陈云的生活小事值得我们学习?',
|
||||
type: 'multiple',
|
||||
options: [
|
||||
{ value: "A", label: "光盘行动,不浪费粮食", isCorrect: true },
|
||||
{ value: "B", label: "打了 32 个补丁的棉坎肩他穿了 38 年", isCorrect: true },
|
||||
{ value: "C", label: "2-3 厘米长的铅笔头仍然留用", isCorrect: true },
|
||||
{ value: "D", label: "搓肥皂时关闭水龙头,节约用水", isCorrect: true },
|
||||
{ value: 'A', label: '光盘行动,不浪费粮食', isCorrect: true },
|
||||
{ value: 'B', label: '打了 32 个补丁的棉坎肩他穿了 38 年', isCorrect: true },
|
||||
{ value: 'C', label: '2-3 厘米长的铅笔头仍然留用', isCorrect: true },
|
||||
{ value: 'D', label: '搓肥皂时关闭水龙头,节约用水', isCorrect: true },
|
||||
],
|
||||
explanation: MULTIPLE_DEFAULT_ERROR,
|
||||
},
|
||||
{
|
||||
id: "quiz-5",
|
||||
id: 'quiz-5',
|
||||
time: 60 * 16 + 23, // 16:23
|
||||
title: "题目5",
|
||||
prompt: "新时代的青年要从陈云身上学习哪些精神品质?",
|
||||
type: "multiple",
|
||||
title: '题目5',
|
||||
prompt: '新时代的青年要从陈云身上学习哪些精神品质?',
|
||||
type: 'multiple',
|
||||
options: [
|
||||
{ value: "A", label: "谦虚谨慎,实事求是的工作作风", isCorrect: true },
|
||||
{ value: "B", label: "勤俭朴素,艰苦奋斗的优良作风", isCorrect: true },
|
||||
{ value: "C", label: "密切联系群众,关心群众生活", isCorrect: true },
|
||||
{ value: 'A', label: '谦虚谨慎,实事求是的工作作风', isCorrect: true },
|
||||
{ value: 'B', label: '勤俭朴素,艰苦奋斗的优良作风', isCorrect: true },
|
||||
{ value: 'C', label: '密切联系群众,关心群众生活', isCorrect: true },
|
||||
{
|
||||
value: "D",
|
||||
label: "只唯上的工作作风",
|
||||
value: 'D',
|
||||
label: '只唯上的工作作风',
|
||||
isCorrect: false,
|
||||
explanation: "\"只唯上\"的工作作风与陈云同志实事求是的精神相悖。请你再想想吧!",
|
||||
explanation: '"只唯上"的工作作风与陈云同志实事求是的精神相悖。请你再想想吧!',
|
||||
},
|
||||
],
|
||||
explanation: MULTIPLE_DEFAULT_ERROR,
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
const ChenyunInternalization = () => {
|
||||
const navigate = useNavigate();
|
||||
const { moduleName } = useParams<{ moduleName: string }>();
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{ moduleName: string }>()
|
||||
// 获取碎片动画触发方法
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation();
|
||||
const [activeSuccess, setActiveSuccess] = useState<string | null>(null);
|
||||
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(
|
||||
null
|
||||
);
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
||||
const [activeSuccess, setActiveSuccess] = useState<string | null>(null)
|
||||
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null)
|
||||
// 拖拽题状态
|
||||
const [dragQuizValues, setDragQuizValues] = useState<
|
||||
Record<string, DragFillResult[]>
|
||||
>({});
|
||||
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>(
|
||||
{}
|
||||
);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [showVideo, setShowVideo] = useState(false);
|
||||
const [postGuideVisible, setPostGuideVisible] = useState(false);
|
||||
const [showLetter, setShowLetter] = useState(false);
|
||||
const [showLetterVideo, setShowLetterVideo] = useState(false);
|
||||
const [showStudyGuide, setShowStudyGuide] = useState(false);
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
const letterVideoRef = useRef<VideoRef>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [dragQuizValues, setDragQuizValues] = useState<Record<string, DragFillResult[]>>({})
|
||||
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>({})
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [showVideo, setShowVideo] = useState(false)
|
||||
const [postGuideVisible, setPostGuideVisible] = useState(false)
|
||||
const [showLetter, setShowLetter] = useState(false)
|
||||
const [showLetterVideo, setShowLetterVideo] = useState(false)
|
||||
const [showStudyGuide, setShowStudyGuide] = useState(false)
|
||||
const videoRef = useRef<VideoRef>(null)
|
||||
const letterVideoRef = useRef<VideoRef>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(true);
|
||||
}, []);
|
||||
setVisible(true)
|
||||
}, [])
|
||||
|
||||
// 组件卸载时清理背景音乐
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
const audio = audioRef.current
|
||||
return () => {
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 视频加载完成后自动播放
|
||||
const handleVideoCanPlay = () => {
|
||||
if (showVideo && videoRef.current) {
|
||||
videoRef.current.play().catch((error) => {
|
||||
console.warn("视频播放失败:", error);
|
||||
});
|
||||
console.warn('视频播放失败:', error)
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 信件视频加载完成后自动播放
|
||||
const handleLetterVideoCanPlay = () => {
|
||||
if (showLetterVideo && letterVideoRef.current) {
|
||||
letterVideoRef.current.play().catch((error) => {
|
||||
console.warn("信件视频播放失败:", error);
|
||||
});
|
||||
console.warn('信件视频播放失败:', error)
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
const anchors: VideoAnchor[] = useMemo(
|
||||
() =>
|
||||
QUIZ_ANCHORS.map((item) => {
|
||||
const correctAnswers = item.options
|
||||
.filter((opt) => opt.isCorrect)
|
||||
.map((opt) => opt.value);
|
||||
const correctAnswers = item.options.filter((opt) => opt.isCorrect).map((opt) => opt.value)
|
||||
|
||||
// 拖拽题处理
|
||||
if (item.type === "drag" && item.blanks) {
|
||||
if (item.type === 'drag' && item.blanks) {
|
||||
return {
|
||||
id: item.id,
|
||||
time: item.time,
|
||||
once: true,
|
||||
afterResume: "remove",
|
||||
afterResume: 'remove',
|
||||
render: ({ resume }) => {
|
||||
// 拖拽题:处理选项拖拽与校验
|
||||
const handleDragChange = (val: DragFillResult[]) => {
|
||||
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }));
|
||||
setDragQuizErrors((prev) => ({ ...prev, [item.id]: "" }));
|
||||
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }))
|
||||
setDragQuizErrors((prev) => ({ ...prev, [item.id]: '' }))
|
||||
|
||||
if (val.length < correctAnswers.length) return;
|
||||
if (val.length < correctAnswers.length) return
|
||||
|
||||
// 检查是否包含了所有正确答案(不要求顺序)
|
||||
const filledAnswers = val.map((v) => v.optionValue);
|
||||
const answerSet = new Set(correctAnswers);
|
||||
const filledSet = new Set(filledAnswers);
|
||||
const filledAnswers = val.map((v) => v.optionValue)
|
||||
const answerSet = new Set(correctAnswers)
|
||||
const filledSet = new Set(filledAnswers)
|
||||
const isCorrect =
|
||||
answerSet.size === filledSet.size &&
|
||||
[...answerSet].every((answer) => filledSet.has(answer));
|
||||
[...answerSet].every((answer) => filledSet.has(answer))
|
||||
|
||||
if (isCorrect) {
|
||||
setActiveSuccess(item.id);
|
||||
setResumeCallback(() => resume);
|
||||
setActiveSuccess(item.id)
|
||||
setResumeCallback(() => resume)
|
||||
} else {
|
||||
setDragQuizErrors((prev) => ({
|
||||
...prev,
|
||||
[item.id]:
|
||||
"这不是陈云的工作作风。请你再想想吧!",
|
||||
}));
|
||||
[item.id]: '这不是陈云的工作作风。请你再想想吧!',
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -255,36 +245,36 @@ const ChenyunInternalization = () => {
|
||||
title="恭喜你, 选择正确!"
|
||||
content="回答正确,请继续探索。"
|
||||
onContinue={() => {
|
||||
setActiveSuccess(null);
|
||||
triggerPuzzleAnimation(16);
|
||||
setActiveSuccess(null)
|
||||
triggerPuzzleAnimation(16)
|
||||
setDragQuizValues((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[item.id];
|
||||
return next;
|
||||
});
|
||||
const next = { ...prev }
|
||||
delete next[item.id]
|
||||
return next
|
||||
})
|
||||
setDragQuizErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[item.id];
|
||||
return next;
|
||||
});
|
||||
const cb = resumeCallback;
|
||||
setResumeCallback(null);
|
||||
cb?.();
|
||||
const next = { ...prev }
|
||||
delete next[item.id]
|
||||
return next
|
||||
})
|
||||
const cb = resumeCallback
|
||||
setResumeCallback(null)
|
||||
cb?.()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 多选题处理
|
||||
if (item.type === "multiple") {
|
||||
if (item.type === 'multiple') {
|
||||
return {
|
||||
id: item.id,
|
||||
time: item.time,
|
||||
once: true,
|
||||
afterResume: "remove",
|
||||
afterResume: 'remove',
|
||||
render: ({ resume }) => (
|
||||
<>
|
||||
<MultipleChoiceQuizPanel
|
||||
@@ -298,32 +288,27 @@ const ChenyunInternalization = () => {
|
||||
correctAnswer={correctAnswers}
|
||||
errorMessage={(selectedValues) => {
|
||||
// 题目1:选到“鸟笼比喻(B)”给专属提示;否则未选全给“还不够全面”提示
|
||||
if (item.id === "quiz-1") {
|
||||
if (selectedValues.includes("B")) {
|
||||
return (
|
||||
item.explanation ||
|
||||
"鸟笼比喻是陈云提出的一个比喻。请你再想想吧!"
|
||||
);
|
||||
if (item.id === 'quiz-1') {
|
||||
if (selectedValues.includes('B')) {
|
||||
return item.explanation || '鸟笼比喻是陈云提出的一个比喻。请你再想想吧!'
|
||||
}
|
||||
return MULTIPLE_DEFAULT_ERROR;
|
||||
return MULTIPLE_DEFAULT_ERROR
|
||||
}
|
||||
// 题目5:选到“D”给专属提示;否则未选全给“还不够全面”提示
|
||||
if (item.id === "quiz-5") {
|
||||
if (selectedValues.includes("D")) {
|
||||
const pickedD = item.options.find(
|
||||
(o) => o.value === "D"
|
||||
);
|
||||
return pickedD?.explanation || MULTIPLE_DEFAULT_ERROR;
|
||||
if (item.id === 'quiz-5') {
|
||||
if (selectedValues.includes('D')) {
|
||||
const pickedD = item.options.find((o) => o.value === 'D')
|
||||
return pickedD?.explanation || MULTIPLE_DEFAULT_ERROR
|
||||
}
|
||||
return MULTIPLE_DEFAULT_ERROR;
|
||||
return MULTIPLE_DEFAULT_ERROR
|
||||
}
|
||||
return item.explanation || MULTIPLE_DEFAULT_ERROR;
|
||||
return item.explanation || MULTIPLE_DEFAULT_ERROR
|
||||
}}
|
||||
onSubmit={(values: string[], isCorrect: boolean) => {
|
||||
if (isCorrect) {
|
||||
setActiveSuccess(item.id);
|
||||
if (item.id === "quiz-1") triggerPuzzleAnimation(15);
|
||||
setResumeCallback(() => resume);
|
||||
setActiveSuccess(item.id)
|
||||
if (item.id === 'quiz-1') triggerPuzzleAnimation(15)
|
||||
setResumeCallback(() => resume)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -332,15 +317,15 @@ const ChenyunInternalization = () => {
|
||||
// title="恭喜你, 选择正确!"
|
||||
// content="回答正确,请继续探索。"
|
||||
onContinue={() => {
|
||||
setActiveSuccess(null);
|
||||
const cb = resumeCallback;
|
||||
setResumeCallback(null);
|
||||
cb?.();
|
||||
setActiveSuccess(null)
|
||||
const cb = resumeCallback
|
||||
setResumeCallback(null)
|
||||
cb?.()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 单选题处理(保留原有逻辑,以防万一)
|
||||
@@ -348,60 +333,50 @@ const ChenyunInternalization = () => {
|
||||
id: item.id,
|
||||
time: item.time,
|
||||
once: true,
|
||||
afterResume: "remove",
|
||||
afterResume: 'remove',
|
||||
render: () => null,
|
||||
};
|
||||
}
|
||||
}),
|
||||
[
|
||||
activeSuccess,
|
||||
resumeCallback,
|
||||
dragQuizValues,
|
||||
dragQuizErrors,
|
||||
triggerPuzzleAnimation,
|
||||
]
|
||||
);
|
||||
[activeSuccess, resumeCallback, dragQuizValues, dragQuizErrors, triggerPuzzleAnimation]
|
||||
)
|
||||
|
||||
if (!moduleName) return null;
|
||||
if (!moduleName) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full bg-black"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage("components/internalization/bg"),
|
||||
backgroundImage: getBackgroundImage('components/internalization/bg'),
|
||||
}}
|
||||
>
|
||||
{/* 离开按钮 */}
|
||||
<Image
|
||||
className="absolute right-8 top-8 z-10"
|
||||
src={getStaticImage("components/离开")}
|
||||
src={getStaticImage('components/离开')}
|
||||
button
|
||||
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
|
||||
/>
|
||||
|
||||
{/* 内化结束后的背景音乐 */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={getPublicPath("audio/letter/8如愿.m4a")}
|
||||
loop
|
||||
/>
|
||||
<audio ref={audioRef} src={getPublicPath('audio/letter/8如愿.m4a')} loop />
|
||||
|
||||
{showVideo && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
src={getPublicPath("video/chenyun/chenyun_neihua.mp4")}
|
||||
src={getPublicPath('video/chenyun/chenyun_neihua.mp4')}
|
||||
className="h-full w-full"
|
||||
anchors={anchors}
|
||||
playsInline
|
||||
controls
|
||||
onCanPlay={handleVideoCanPlay}
|
||||
onEnded={() => {
|
||||
setShowVideo(false);
|
||||
setPostGuideVisible(true);
|
||||
setShowVideo(false)
|
||||
setPostGuideVisible(true)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = 0;
|
||||
audioRef.current.currentTime = 0
|
||||
audioRef.current.play().catch((error) => {
|
||||
console.warn("背景音乐播放失败:", error);
|
||||
});
|
||||
console.warn('背景音乐播放失败:', error)
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -411,17 +386,17 @@ const ChenyunInternalization = () => {
|
||||
<GuideSteps
|
||||
visible={visible}
|
||||
onVisibleChange={(visible) => {
|
||||
setVisible(visible);
|
||||
setVisible(visible)
|
||||
if (!visible) {
|
||||
setShowVideo(true);
|
||||
handleVideoCanPlay();
|
||||
setShowVideo(true)
|
||||
handleVideoCanPlay()
|
||||
}
|
||||
}}
|
||||
showMask={false}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_36.mp3")}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36.mp3')}
|
||||
>
|
||||
小沪,跟着你参观陈云纪念馆真是收获满满!看到馆里展示的“米棉之战”史料、全国财经统筹的实物展品,我真切感受到陈云爷爷在建国初期平抑物价、稳定经济的魄力。
|
||||
</GuideStepsItem>
|
||||
@@ -430,7 +405,7 @@ const ChenyunInternalization = () => {
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_37.mp3")}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_37.mp3')}
|
||||
>
|
||||
针对你的这个问题,今天我们有幸邀请到了中国中共党史学会理事,全国陈云思想生平研究会常务理事兼副秘书长,中共一大纪念馆陈列研究部主任,研究馆员房中老师。关于你的问题,我想房老师会给你指点迷津!
|
||||
</GuideStepsItem>
|
||||
@@ -440,10 +415,10 @@ const ChenyunInternalization = () => {
|
||||
<GuideSteps
|
||||
visible={postGuideVisible}
|
||||
onVisibleChange={(visible) => {
|
||||
setPostGuideVisible(visible);
|
||||
setPostGuideVisible(visible)
|
||||
// 引导结束后显示信件
|
||||
if (!visible) {
|
||||
setShowLetter(true);
|
||||
setShowLetter(true)
|
||||
}
|
||||
}}
|
||||
showMask={false}
|
||||
@@ -451,16 +426,14 @@ const ChenyunInternalization = () => {
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_38.mp3")}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_38.mp3')}
|
||||
>
|
||||
感谢房老师的讲解!从陈云童年成长的水乡故居,到他主持"银元之战""米棉之战"的史料,再到那台称量粮食的天平、握不住的铅笔头,我真切感受到他"不唯上、不唯书、只唯实"的精神,也懂了他"为老百姓做事"的初心。
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem speaker="student">
|
||||
这些都让我特别受触动!
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem speaker="student">这些都让我特别受触动!</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_39.mp3")}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_39.mp3')}
|
||||
>
|
||||
你的感悟很深刻!陈云同志用一生诠释了“务实”与“为民”——从破解边区经济困境,到擘画国家工业化蓝图,再到改革开放时力挺宝钢建设,每一步都扎根实际、心系百姓。
|
||||
</GuideStepsItem>
|
||||
@@ -474,9 +447,9 @@ const ChenyunInternalization = () => {
|
||||
visible={showLetter}
|
||||
onSubmit={(values) => {
|
||||
// 提交信件的逻辑(values 为多个填空的内容)
|
||||
console.log("提交信件", values);
|
||||
setShowLetter(false);
|
||||
setShowLetterVideo(true);
|
||||
console.log('提交信件', values)
|
||||
setShowLetter(false)
|
||||
setShowLetterVideo(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -484,13 +457,13 @@ const ChenyunInternalization = () => {
|
||||
{showLetterVideo && (
|
||||
<Video
|
||||
ref={letterVideoRef}
|
||||
src={getPublicPath("video/letter/neihua_jixin.mp4")}
|
||||
src={getPublicPath('video/letter/neihua_jixin.mp4')}
|
||||
className="h-full w-full"
|
||||
playsInline
|
||||
onCanPlay={handleLetterVideoCanPlay}
|
||||
onEnded={() => {
|
||||
setShowLetterVideo(false);
|
||||
setShowStudyGuide(true);
|
||||
setShowLetterVideo(false)
|
||||
setShowStudyGuide(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -502,16 +475,16 @@ const ChenyunInternalization = () => {
|
||||
animationSpeed={200}
|
||||
onContinue={() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
audioRef.current.pause()
|
||||
audioRef.current.currentTime = 0
|
||||
}
|
||||
setShowStudyGuide(false);
|
||||
navigate(`/${moduleName}/${starName}/home`);
|
||||
setShowStudyGuide(false)
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}}
|
||||
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_40.mp3")}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_40.mp3')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ChenyunInternalization;
|
||||
export default ChenyunInternalization
|
||||
@@ -6,53 +6,53 @@ import {
|
||||
Title,
|
||||
Overlay,
|
||||
StudyGuideModal,
|
||||
} from "@/components";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import Cursor from "@/components/cursor";
|
||||
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
|
||||
import { useChenyunStore } from "@/store/chenyunStore";
|
||||
import { useCursorController } from "@/hooks/useCursorController";
|
||||
} from '@/components'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { useState } from 'react'
|
||||
import Cursor from '@/components/cursor'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
|
||||
// starName 固定为 chenyun
|
||||
const starName = "chenyun";
|
||||
const starName = 'chenyun'
|
||||
|
||||
// 展品 ID 与资源路径映射,ID 用来做唯一标识
|
||||
const exhibitSrcMap: Record<1 | 2, string> = {
|
||||
1: "chenyun/place1/场景1展品1",
|
||||
2: "chenyun/place1/场景1展品2",
|
||||
1: 'chenyun/place1/场景1展品1',
|
||||
2: 'chenyun/place1/场景1展品2',
|
||||
// 3: "chenyun/place1/场景1展品3",
|
||||
// 4: "chenyun/place1/场景1展品4",
|
||||
};
|
||||
}
|
||||
const ChenyunPlace1 = () => {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{
|
||||
moduleName: string;
|
||||
}>();
|
||||
moduleName: string
|
||||
}>()
|
||||
|
||||
// 获取碎片动画触发方法
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation();
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
||||
|
||||
// 学习引导弹窗
|
||||
const [showGuideModal, setShowGuideModal] = useState(true);
|
||||
const [showGuideModal, setShowGuideModal] = useState(true)
|
||||
// 对话结束后的学习引导弹窗
|
||||
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false);
|
||||
const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false);
|
||||
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false)
|
||||
const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false)
|
||||
// 学习说明对话框显示状态
|
||||
const [showIntroDialog, setShowIntroDialog] = useState(false);
|
||||
const [showIntroDialog, setShowIntroDialog] = useState(false)
|
||||
// 引导对话显示状态
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [visible, setVisible] = useState(false)
|
||||
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
|
||||
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
|
||||
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
|
||||
|
||||
// 使用持久化的访问状态
|
||||
const place = "place1";
|
||||
const { markExhibitVisited, getVisitedExhibits } = useChenyunStore();
|
||||
const place = 'place1'
|
||||
const { markExhibitVisited, getVisitedExhibits } = useChenyunStore()
|
||||
|
||||
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
|
||||
const visitedExhibits = getVisitedExhibits(place);
|
||||
const visited = new Set(visitedExhibits);
|
||||
const visitedExhibits = getVisitedExhibits(place)
|
||||
const visited = new Set(visitedExhibits)
|
||||
|
||||
// Cursor 控制器:使用依赖映射实现自动解锁
|
||||
// cursor 1 初始启用,cursor 2 依赖展品1(当展品1被访问后自动解锁)
|
||||
@@ -62,27 +62,27 @@ const ChenyunPlace1 = () => {
|
||||
2: [1], // cursor 2 依赖展品 1,当展品1被访问后自动解锁
|
||||
},
|
||||
visited, // 传入数组,而不是 Set
|
||||
});
|
||||
})
|
||||
|
||||
const handleOpenExhibit = (index: 1 | 2) => {
|
||||
setActiveExhibitId(index);
|
||||
setActiveExhibitId(index)
|
||||
// ExhibitOverlay 打开时会自动通过 onOpen 通知 ResidenceLayout 执行动画
|
||||
};
|
||||
}
|
||||
|
||||
const handleStartLearning = () => {
|
||||
setShowIntroDialog(false);
|
||||
setVisible(true);
|
||||
};
|
||||
setShowIntroDialog(false)
|
||||
setVisible(true)
|
||||
}
|
||||
|
||||
const progressCount = visited.size;
|
||||
const total = 2;
|
||||
const completed = progressCount >= total;
|
||||
const progressCount = visited.size
|
||||
const total = 2
|
||||
const completed = progressCount >= total
|
||||
const subtitle = completed
|
||||
? "探索陈云纪念馆老宅堂屋(已完成)"
|
||||
: `探索陈云纪念馆老宅堂屋(${progressCount}/${total})`;
|
||||
? '探索陈云纪念馆老宅堂屋(已完成)'
|
||||
: `探索陈云纪念馆老宅堂屋(${progressCount}/${total})`
|
||||
const subtitleClassName = completed
|
||||
? "text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]"
|
||||
: undefined;
|
||||
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -101,11 +101,11 @@ const ChenyunPlace1 = () => {
|
||||
|
||||
<Image
|
||||
className="absolute right-5 top-5"
|
||||
src={getStaticImage("components/离开")}
|
||||
src={getStaticImage('components/离开')}
|
||||
button
|
||||
onClick={() => {
|
||||
if (moduleName && starName) {
|
||||
navigate(`/${moduleName}/${starName}/home`);
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -141,10 +141,11 @@ const ChenyunPlace1 = () => {
|
||||
{completed && (
|
||||
<Image
|
||||
className="absolute bottom-0 right-0"
|
||||
src={getStaticImage("chenyun/place1/继续前进")}
|
||||
src={getStaticImage('chenyun/place1/继续前进')}
|
||||
button
|
||||
onClick={() => {
|
||||
navigate(`/${moduleName}/${starName}/place2`);
|
||||
saveChenyunStore()
|
||||
navigate(`/${moduleName}/${starName}/place2`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -152,20 +153,20 @@ const ChenyunPlace1 = () => {
|
||||
{/* 遮罩层 */}
|
||||
<ExhibitOverlay
|
||||
visible={activeExhibitId !== null}
|
||||
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
|
||||
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
|
||||
onOpen={() => {
|
||||
// 示例:展品1对应碎片1,展品2对应碎片2
|
||||
// 根据实际需求调用 triggerPuzzleAnimation
|
||||
if (activeExhibitId === 1) {
|
||||
triggerPuzzleAnimation(1);
|
||||
triggerPuzzleAnimation(1)
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
if (activeExhibitId !== null) {
|
||||
markExhibitVisited(place, activeExhibitId);
|
||||
markExhibitVisited(place, activeExhibitId)
|
||||
// cursor 状态会根据 visited 自动更新,无需手动调用 enable
|
||||
}
|
||||
setActiveExhibitId(null);
|
||||
setActiveExhibitId(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -173,12 +174,12 @@ const ChenyunPlace1 = () => {
|
||||
<StudyGuideModal
|
||||
visible={showGuideModal}
|
||||
onContinue={() => {
|
||||
setShowGuideModal(false);
|
||||
setVisible(true);
|
||||
setShowGuideModal(false)
|
||||
setVisible(true)
|
||||
}}
|
||||
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_01.mp3")}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_01.mp3')}
|
||||
>
|
||||
同学你好,欢迎来到“走进故居·对话”学习模块!{"\n"}
|
||||
同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
|
||||
此刻你正站在陈云同志的故居堂屋里,接下来请跟着时光的脚步,
|
||||
一起翻开他人生的7帧珍贵篇章:水乡少年·艰辛成长、商务启航·投身革命、
|
||||
苏区淬炼·崭露头角、延安担当·经济破局、恢复时期·经济奠基、建设时期·务实兴邦、
|
||||
@@ -201,23 +202,23 @@ const ChenyunPlace1 = () => {
|
||||
<GuideSteps
|
||||
visible={visible}
|
||||
onVisibleChange={(nextVisible) => {
|
||||
setVisible(nextVisible);
|
||||
setVisible(nextVisible)
|
||||
if (!nextVisible && !afterDialogGuideShown) {
|
||||
setAfterDialogGuideShown(true);
|
||||
setShowAfterDialogGuide(true);
|
||||
setAfterDialogGuideShown(true)
|
||||
setShowAfterDialogGuide(true)
|
||||
}
|
||||
}}
|
||||
showMask={false}
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_03.mp3")}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_03.mp3')}
|
||||
>
|
||||
小沪!这就是陈云小时候生活的房子吗?看起来和普通农家差不多,他童年过得艰苦吗?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_04.mp3")}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04.mp3')}
|
||||
>
|
||||
这座房子就是陈云故居,位于上海市青浦区练塘镇下塘街95号,是一座坐南朝北、面向河畔的两层楼江南民居。
|
||||
</GuideStepsItem>
|
||||
@@ -234,24 +235,20 @@ const ChenyunPlace1 = () => {
|
||||
visible={showAfterDialogGuide}
|
||||
onContinue={() => setShowAfterDialogGuide(false)}
|
||||
ailgn="left"
|
||||
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_02.mp3")}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_02.mp3')}
|
||||
>
|
||||
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
|
||||
{"\n"}
|
||||
{'\n'}
|
||||
2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
|
||||
{"\n"}
|
||||
{'\n'}
|
||||
3.所有动态光点的展品全部学习后才能进入下一个场景学习。
|
||||
</StudyGuideModal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
// 学习说明内容
|
||||
const LearningIntroduction = ({
|
||||
handleStartLearning,
|
||||
}: {
|
||||
handleStartLearning: () => void;
|
||||
}) => {
|
||||
const LearningIntroduction = ({ handleStartLearning }: { handleStartLearning: () => void }) => {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="relative w-full h-full bg-gradient-to-b from-[#f5f1e8] via-[#ede8dc] to-[#e8e0d1] shadow-[0_20px_60px_rgba(0,0,0,0.4)] border-4 border-[#d4c5a9] overflow-hidden">
|
||||
@@ -282,22 +279,20 @@ const LearningIntroduction = ({
|
||||
<div className="mb-8 bg-[rgba(255,255,255,0.6)] rounded-2xl p-6 border-2 border-[#d4c5a9] flex-shrink-0">
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-2xl">
|
||||
{[
|
||||
"水乡少年 艰辛成长",
|
||||
"商务启航 投身革命",
|
||||
"苏区淬炼 崭露头角",
|
||||
"延安担当经济破局",
|
||||
"恢复时期经济奠基",
|
||||
"建设时期 务实兴邦",
|
||||
"改革浪潮 擘画实业",
|
||||
'水乡少年 艰辛成长',
|
||||
'商务启航 投身革命',
|
||||
'苏区淬炼 崭露头角',
|
||||
'延安担当经济破局',
|
||||
'恢复时期经济奠基',
|
||||
'建设时期 务实兴邦',
|
||||
'改革浪潮 擘画实业',
|
||||
].map((stage, index, array) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<span className="px-4 py-2 bg-[rgba(139,115,85,0.15)] rounded-lg text-[#5c4a37] font-semibold">
|
||||
{stage}
|
||||
</span>
|
||||
{index < array.length - 1 && (
|
||||
<span className="mx-2 text-[#8b7355] font-bold text-3xl">
|
||||
→
|
||||
</span>
|
||||
<span className="mx-2 text-[#8b7355] font-bold text-3xl">→</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -308,10 +303,10 @@ const LearningIntroduction = ({
|
||||
<div className="mb-8 space-y-4 flex-1 overflow-hidden">
|
||||
<div className="h-full overflow-hidden">
|
||||
{[
|
||||
"走进故居-对话学习模块会包含多个场景,场景中有蓝色动态光点的展品,表示未浏览,可通过点击光点来学习展品背后的故事,学习展品后蓝色动态光点会变为绿色静态光点,表示已浏览。",
|
||||
"场景中的展品有可能会包含一个或多个拼图,游览过程中如获得拼图,会自动掉落拼图并收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。",
|
||||
"所有有蓝色动态光点的展品全部学习后才能进入下一个场景进行学习。",
|
||||
"每个阶段学习过后,会有学习回顾环节,以导图的形式总结了阶段的学习内容。",
|
||||
'走进故居-对话学习模块会包含多个场景,场景中有蓝色动态光点的展品,表示未浏览,可通过点击光点来学习展品背后的故事,学习展品后蓝色动态光点会变为绿色静态光点,表示已浏览。',
|
||||
'场景中的展品有可能会包含一个或多个拼图,游览过程中如获得拼图,会自动掉落拼图并收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。',
|
||||
'所有有蓝色动态光点的展品全部学习后才能进入下一个场景进行学习。',
|
||||
'每个阶段学习过后,会有学习回顾环节,以导图的形式总结了阶段的学习内容。',
|
||||
].map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
@@ -320,9 +315,7 @@ const LearningIntroduction = ({
|
||||
<span className="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-[#8b7355] to-[#6b5d47] text-white text-3xl font-bold rounded-full flex items-center justify-center shadow-md">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-2xl text-[#5c4a37] leading-relaxed flex-1 pt-1">
|
||||
{feature}
|
||||
</p>
|
||||
<p className="text-2xl text-[#5c4a37] leading-relaxed flex-1 pt-1">{feature}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -341,7 +334,7 @@ const LearningIntroduction = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ChenyunPlace1;
|
||||
export default ChenyunPlace1
|
||||
@@ -1,89 +1,82 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ExhibitOverlay,
|
||||
Title,
|
||||
Image,
|
||||
Cursor,
|
||||
GuideSteps,
|
||||
GuideStepsItem,
|
||||
} from "@/components";
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useChenyunStore } from "@/store/chenyunStore";
|
||||
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
|
||||
import { useCursorController } from "@/hooks/useCursorController";
|
||||
import { useState } from 'react'
|
||||
import { ExhibitOverlay, Title, Image, Cursor, GuideSteps, GuideStepsItem } from '@/components'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
|
||||
// starName 固定为 chenyun
|
||||
const starName = "chenyun";
|
||||
const starName = 'chenyun'
|
||||
|
||||
// 展品 ID 与资源路径映射
|
||||
const exhibitSrcMap: Record<1 | 2, string> = {
|
||||
1: "chenyun/place2/场景2展品1",
|
||||
2: "chenyun/place2/场景2展品2",
|
||||
};
|
||||
1: 'chenyun/place2/场景2展品1',
|
||||
2: 'chenyun/place2/场景2展品2',
|
||||
}
|
||||
|
||||
// 展品描述映射(当前未使用,若后续需要可恢复)
|
||||
const ChenyunPlace2 = () => {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{
|
||||
moduleName: string;
|
||||
}>();
|
||||
moduleName: string
|
||||
}>()
|
||||
|
||||
// 获取碎片动画触发方法
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation();
|
||||
const { triggerPuzzleAnimation } = usePuzzleAnimation()
|
||||
|
||||
// 当前激活展品 ID,只存 ID 做唯一标识
|
||||
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
|
||||
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
|
||||
|
||||
// 使用持久化的访问状态
|
||||
const place = "place2";
|
||||
const { markExhibitVisited, getVisitedExhibits } = useChenyunStore();
|
||||
const place = 'place2'
|
||||
const { markExhibitVisited, getVisitedExhibits } = useChenyunStore()
|
||||
|
||||
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
|
||||
const visitedExhibits = getVisitedExhibits(place);
|
||||
const visited = new Set(visitedExhibits);
|
||||
const visitedExhibits = getVisitedExhibits(place)
|
||||
const visited = new Set(visitedExhibits)
|
||||
const { isEnabled } = useCursorController({
|
||||
initialEnabled: [1],
|
||||
dependencies: { 2: [1] },
|
||||
visited,
|
||||
});
|
||||
})
|
||||
// GuideSteps 显示状态
|
||||
const [guideVisible, setGuideVisible] = useState(false);
|
||||
const [guideVisible, setGuideVisible] = useState(false)
|
||||
// GuideSteps 当前步骤
|
||||
const [guideVisible2, setGuideVisible2] = useState(false);
|
||||
const [guideStep2, setGuideStep2] = useState(0);
|
||||
const [guideVisible2, setGuideVisible2] = useState(false)
|
||||
const [guideStep2, setGuideStep2] = useState(0)
|
||||
|
||||
const [guideStep, setGuideStep] = useState(0);
|
||||
const [guideStep, setGuideStep] = useState(0)
|
||||
|
||||
const handleOpenExhibit = (index: 1 | 2) => {
|
||||
if (index === 1) {
|
||||
// 展品1:点击时同时打开展品和引导对话,从第1步开始
|
||||
setActiveExhibitId(1);
|
||||
setGuideVisible(true);
|
||||
setGuideStep(0);
|
||||
setActiveExhibitId(1)
|
||||
setGuideVisible(true)
|
||||
setGuideStep(0)
|
||||
} else {
|
||||
// 展品2:点击时同时打开展品和引导对话,从第1步开始
|
||||
setActiveExhibitId(2);
|
||||
setGuideVisible2(true);
|
||||
setGuideStep2(0);
|
||||
setActiveExhibitId(index);
|
||||
setActiveExhibitId(2)
|
||||
setGuideVisible2(true)
|
||||
setGuideStep2(0)
|
||||
setActiveExhibitId(index)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 监听GuideSteps步骤变化,仅同步当前步骤
|
||||
const handleGuideStepChange = (step: number) => {
|
||||
setGuideStep(step);
|
||||
};
|
||||
setGuideStep(step)
|
||||
}
|
||||
|
||||
const progressCount = visited.size;
|
||||
const total = 2;
|
||||
const completed = progressCount >= total;
|
||||
const progressCount = visited.size
|
||||
const total = 2
|
||||
const completed = progressCount >= total
|
||||
const subtitle = completed
|
||||
? "探索陈云就读过的学校(已完成)"
|
||||
: `探索陈云就读过的学校(${progressCount}/${total})`;
|
||||
? '探索陈云就读过的学校(已完成)'
|
||||
: `探索陈云就读过的学校(${progressCount}/${total})`
|
||||
const subtitleClassName = completed
|
||||
? "text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]"
|
||||
: undefined;
|
||||
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
|
||||
: undefined
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full bg-cover bg-center bg-no-repeat"
|
||||
@@ -102,11 +95,11 @@ const ChenyunPlace2 = () => {
|
||||
{/* 返回上一章 */}
|
||||
<Image
|
||||
className="absolute right-5 top-5"
|
||||
src={getStaticImage("components/离开")}
|
||||
src={getStaticImage('components/离开')}
|
||||
button
|
||||
onClick={() => {
|
||||
if (moduleName && starName) {
|
||||
navigate(`/${moduleName}/${starName}/place1`);
|
||||
navigate(`/${moduleName}/${starName}/place1`)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -130,10 +123,11 @@ const ChenyunPlace2 = () => {
|
||||
{completed && (
|
||||
<Image
|
||||
className="absolute bottom-0 right-160"
|
||||
src={getStaticImage("chenyun/place2/继续前进2")}
|
||||
src={getStaticImage('chenyun/place2/继续前进2')}
|
||||
button
|
||||
onClick={() => {
|
||||
navigate(`/${moduleName}/${starName}/place3`);
|
||||
saveChenyunStore()
|
||||
navigate(`/${moduleName}/${starName}/place3`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -141,19 +135,19 @@ const ChenyunPlace2 = () => {
|
||||
{/* 遮罩层 */}
|
||||
<ExhibitOverlay
|
||||
visible={activeExhibitId !== null}
|
||||
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
|
||||
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
|
||||
onOpen={() => {
|
||||
if (activeExhibitId === 1) {
|
||||
triggerPuzzleAnimation(2);
|
||||
triggerPuzzleAnimation(2)
|
||||
} else if (activeExhibitId === 2) {
|
||||
triggerPuzzleAnimation(3);
|
||||
triggerPuzzleAnimation(3)
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
if (activeExhibitId !== null) {
|
||||
markExhibitVisited(place, activeExhibitId);
|
||||
markExhibitVisited(place, activeExhibitId)
|
||||
}
|
||||
setActiveExhibitId(null);
|
||||
setActiveExhibitId(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -169,13 +163,13 @@ const ChenyunPlace2 = () => {
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_05.mp3")}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_05.mp3')}
|
||||
>
|
||||
那陈云是怎么有机会到图中的青浦县立乙种商业学校读书的呀?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_06.mp3")}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_06.mp3')}
|
||||
>
|
||||
因为家贫,初小读完的陈云便辍学在家。后在舅母亲戚的资助下,陈云才到青浦县立乙种商业学校学习珠算和记账的,他在很短时间里就基本掌握了珠算,还初步学会写账,这为陈云日后从事经济工作打下了基础。
|
||||
</GuideStepsItem>
|
||||
@@ -196,13 +190,13 @@ const ChenyunPlace2 = () => {
|
||||
>
|
||||
<GuideStepsItem
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_07.mp3")}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_07.mp3')}
|
||||
>
|
||||
这里有一幅陈云题写的颜安小学,请问陈云在这所学校读过书吗?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_08.mp3")}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_08.mp3')}
|
||||
>
|
||||
是的,陈云在青浦县立乙种商业学校学习了一个多月后,又因经济困难不得不辍学回家。多亏当地知识分子、颜安小学校长杜枢,他免费保荐陈云入颜安小学高小部读书。
|
||||
</GuideStepsItem>
|
||||
@@ -211,7 +205,7 @@ const ChenyunPlace2 = () => {
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ChenyunPlace2;
|
||||
export default ChenyunPlace2
|
||||
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user