feat: 添加引导页、视频播放和触摸交互功能

- 新增引导页组件(Guide.tsx)及相关资源
- 添加视频播放功能和相关资源文件
- 实现useTouch自定义hook支持触摸交互
- 添加store状态管理
- 更新多个页面组件(Content, Menu, Publicity)
- 优化图片组件和背景图片组件
- 更新项目依赖
This commit is contained in:
zhongyidian committed 2025-10-31 17:49:13 +08:00
1 parent 4ce7520066
commit 53d0eeac6b
24 files changed
+1390 -84

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

+6 -4
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from "react";
import { clsx } from "clsx";
interface BackgroundImageProps {
interface BackgroundImageProps extends React.HTMLAttributes<HTMLDivElement> {
src: string;
alt?: string;
className?: string;
@@ -14,12 +14,12 @@ interface BackgroundImageProps {
fallback?: string;
onLoad?: () => void;
onError?: () => void;
onClick?: () => void;
children?: React.ReactNode;
overlay?: boolean;
overlayColor?: string;
overlayOpacity?: number;
overflowHidden?: boolean;
style?: React.CSSProperties;
}
export default function BackgroundImage({
@@ -35,12 +35,13 @@ export default function BackgroundImage({
fallback,
onLoad,
onError,
onClick,
children,
overlay = false,
overlayColor = "rgba(0, 0, 0, 0.5)",
overlayOpacity = 0.5,
overflowHidden = true,
style,
...restProps
}: BackgroundImageProps) {
const [imageSrc, setImageSrc] = useState(src);
const [isLoading, setIsLoading] = useState(true);
@@ -125,6 +126,7 @@ export default function BackgroundImage({
backgroundPosition,
backgroundRepeat,
position: "relative" as const,
...style,
};
const overlayStyle = {
@@ -141,7 +143,7 @@ export default function BackgroundImage({
className
)}
style={backgroundStyle}
onClick={onClick}
{...restProps}
>
{/* 加载占位符 */}
{isLoading && !placeholder && (
+3 -4
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from "react";
import { clsx } from "clsx";
interface ImageProps {
interface ImageProps extends React.HTMLAttributes<HTMLDivElement> {
style?: React.CSSProperties;
src: string;
children?: React.ReactNode;
@@ -16,7 +16,6 @@ interface ImageProps {
fallback?: string;
onLoad?: () => void;
onError?: () => void;
onClick?: () => void;
draggable?: boolean;
quality?: number;
blur?: boolean;
@@ -38,11 +37,11 @@ export default function Image({
fallback,
onLoad,
onError,
onClick,
draggable = false,
quality: _quality = 80,
blur = false,
button = false,
...restProps
}: ImageProps) {
const [imageSrc, setImageSrc] = useState(src);
const [isLoading, setIsLoading] = useState(true);
@@ -116,7 +115,7 @@ export default function Image({
className
)}
style={{ width, height, ...style }}
onClick={onClick}
{...restProps}
>
{/* 加载占位符 */}
{isLoading && placeholder && (
+3 -3
View File
@@ -49,7 +49,7 @@ export const skillTrainingActivities: Activity[] = [
date: "2024年11月14日",
docPath: "/word/【11月15日】工作室活动情况记录.docx",
},
];
].reverse();
//交流与展示活动
export const exchangeAndShowActivities: Activity[] = [
@@ -69,7 +69,7 @@ export const exchangeAndShowActivities: Activity[] = [
{
id: 3,
name: "与桂同心育新农,山海携手促共富",
date: "2025年",
date: "2025年08月23日",
docPath: "/word/与桂同心育新农,山海携手促共富.docx",
},
{
@@ -86,4 +86,4 @@ export const exchangeAndShowActivities: Activity[] = [
docPath:
"/word/政校携手,口岸直播助侗乡——南湖学子浦江口岸助农实践出新彩.docx",
},
];
].reverse();
+428
View File
@@ -0,0 +1,428 @@
# useTouch Hook 使用文档
## 概述
`useTouch` 是一个灵活强大的触摸手势处理 Hook,支持多种手势识别,包括:
- 点击 (Tap)
- 双击 (Double Tap)
- 长按 (Long Press)
- 滑动 (Swipe) - 支持四个方向
- 触摸移动追踪
同时支持触摸屏和鼠标操作,适用于移动端和桌面端。
## 基础用法
### 1. 简单点击
```tsx
import { useTouch } from "@/hooks";
function MyComponent() {
const { handlers } = useTouch({
onTap: () => {
console.log("点击了");
},
});
return <div {...handlers}>点击我</div>;
}
```
### 2. 滑动手势
```tsx
const { handlers } = useTouch({
onSwipeLeft: () => console.log("向左滑动"),
onSwipeRight: () => console.log("向右滑动"),
onSwipeUp: () => console.log("向上滑动"),
onSwipeDown: () => console.log("向下滑动"),
});
return <div {...handlers}>滑动区域</div>;
```
### 3. 双击
```tsx
const { handlers } = useTouch({
onDoubleTap: () => {
console.log("双击触发");
},
});
return <div {...handlers}>双击我</div>;
```
### 4. 长按
```tsx
const { handlers } = useTouch({
onLongPress: () => {
console.log("长按触发");
},
longPressDelay: 500, // 500ms 触发长按
});
return <div {...handlers}>长按我</div>;
```
## 高级用法
### 获取触摸数据
```tsx
const { handlers, touchData, isTouching } = useTouch({
onTouchMove: (data) => {
console.log("移动距离:", data.deltaX, data.deltaY);
console.log("移动速度:", data.velocity);
console.log("移动方向:", data.direction);
},
});
return (
<div {...handlers}>
{isTouching && touchData && (
<div>
移动: {touchData.deltaX.toFixed(0)}px, {touchData.deltaY.toFixed(0)}px
</div>
)}
</div>
);
```
### 图片查看器示例
```tsx
function ImageViewer() {
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const { handlers } = useTouch({
onDoubleTap: () => {
// 双击放大/还原
setScale(scale === 1 ? 2 : 1);
},
onSwipe: (data) => {
// 滑动移动图片
if (scale > 1) {
setPosition((prev) => ({
x: prev.x + data.deltaX,
y: prev.y + data.deltaY,
}));
}
},
swipeThreshold: 5,
});
return (
<div {...handlers} style={{ overflow: "hidden" }}>
<img
src="image.jpg"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transition: "transform 0.3s",
}}
/>
</div>
);
}
```
### 卡片滑动删除
```tsx
function SwipeableCard() {
const [offset, setOffset] = useState(0);
const { handlers } = useTouch({
onTouchMove: (data) => {
if (data.direction === "left" || data.direction === "right") {
setOffset(data.deltaX);
}
},
onTouchEnd: (data) => {
if (Math.abs(data.deltaX) > 100) {
// 滑动超过 100px,执行删除
onDelete();
} else {
// 弹回原位
setOffset(0);
}
},
});
return (
<div
{...handlers}
style={{
transform: `translateX(${offset}px)`,
transition: offset === 0 ? "transform 0.3s" : "none",
}}
>
<div>卡片内容</div>
</div>
);
}
```
### 下拉刷新
```tsx
function PullToRefresh() {
const [pullDistance, setPullDistance] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const { handlers } = useTouch({
onTouchMove: (data) => {
// 只在向下滑动且在顶部时触发
if (data.direction === "down" && window.scrollY === 0) {
setPullDistance(Math.min(data.deltaY, 80));
}
},
onTouchEnd: (data) => {
if (pullDistance > 50) {
setIsRefreshing(true);
// 执行刷新逻辑
refresh().then(() => {
setIsRefreshing(false);
setPullDistance(0);
});
} else {
setPullDistance(0);
}
},
});
return (
<div {...handlers}>
<div style={{ height: pullDistance, transition: "height 0.3s" }}>
{isRefreshing
? "刷新中..."
: pullDistance > 50
? "松开刷新"
: "下拉刷新"}
</div>
<div>内容区域</div>
</div>
);
}
```
### 轮播图
```tsx
function Carousel({ images }: { images: string[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
const { handlers } = useTouch({
onSwipeLeft: () => {
setCurrentIndex((prev) => Math.min(prev + 1, images.length - 1));
},
onSwipeRight: () => {
setCurrentIndex((prev) => Math.max(prev - 1, 0));
},
swipeThreshold: 50,
swipeVelocityThreshold: 0.3,
});
return (
<div {...handlers} style={{ overflow: "hidden" }}>
<div
style={{
display: "flex",
transform: `translateX(-${currentIndex * 100}%)`,
transition: "transform 0.3s",
}}
>
{images.map((img, i) => (
<img key={i} src={img} style={{ width: "100%", flexShrink: 0 }} />
))}
</div>
</div>
);
}
```
## 配置选项
```typescript
interface UseTouchOptions {
// 基础配置
enabled?: boolean; // 是否启用,默认 true
preventDefault?: boolean; // 是否阻止默认行为,默认 false
stopPropagation?: boolean; // 是否阻止事件冒泡,默认 false
// 滑动配置
swipeThreshold?: number; // 滑动阈值(px),默认 10
swipeVelocityThreshold?: number; // 滑动速度阈值(px/ms),默认 0.5
// 长按配置
longPressDelay?: number; // 长按延迟(ms),默认 500
longPressMoveThreshold?: number; // 长按允许移动距离(px),默认 10
// 双击配置
doubleTapDelay?: number; // 双击间隔(ms),默认 300
doubleTapMoveThreshold?: number; // 双击允许移动距离(px),默认 10
// 事件回调
onTouchStart?: (data: TouchEventData) => void;
onTouchMove?: (data: TouchEventData) => void;
onTouchEnd?: (data: TouchEventData) => void;
onTap?: (data: TouchEventData) => void;
onDoubleTap?: (data: TouchEventData) => void;
onLongPress?: (data: TouchEventData) => void;
onSwipe?: (data: TouchEventData) => void;
onSwipeLeft?: (data: TouchEventData) => void;
onSwipeRight?: (data: TouchEventData) => void;
onSwipeUp?: (data: TouchEventData) => void;
onSwipeDown?: (data: TouchEventData) => void;
}
```
## 触摸数据
所有回调函数都会接收到 `TouchEventData` 对象:
```typescript
interface TouchEventData {
startPoint: TouchPoint; // 起始位置 { x, y, timestamp }
currentPoint: TouchPoint; // 当前位置 { x, y, timestamp }
deltaX: number; // X 轴移动距离
deltaY: number; // Y 轴移动距离
distance: number; // 移动的总距离
velocity: number; // 移动速度(像素/毫秒)
direction: SwipeDirection; // 移动方向: 'left' | 'right' | 'up' | 'down' | null
event: React.TouchEvent | React.MouseEvent; // 原始事件
}
```
## 返回值
```typescript
interface UseTouchReturn {
isTouching: boolean; // 是否正在触摸
touchData: TouchEventData | null; // 当前触摸数据
handlers: {
onTouchStart: (e: React.TouchEvent) => void;
onTouchMove: (e: React.TouchEvent) => void;
onTouchEnd: (e: React.TouchEvent) => void;
onMouseDown: (e: React.MouseEvent) => void;
};
}
```
## 注意事项
1. **事件处理器自动包含鼠标和触摸事件**,无需分别绑定
2. **长按和滑动互斥**:如果移动距离超过阈值,会取消长按
3. **双击优先级高于单击**:在双击延迟内的第二次点击会触发双击而非单击
4. **性能优化**:使用 `useCallback` 避免不必要的重渲染
5. **跨平台支持**:同时支持移动端触摸和桌面端鼠标操作
## 最佳实践
### 1. 避免与滚动冲突
```tsx
const { handlers } = useTouch({
onSwipeLeft: handleSwipe,
preventDefault: false, // 不阻止默认行为,允许页面滚动
});
```
### 2. 性能优化
```tsx
// 使用 useMemo 缓存配置
const touchOptions = useMemo(
() => ({
onSwipe: handleSwipe,
swipeThreshold: 50,
}),
[handleSwipe]
);
const { handlers } = useTouch(touchOptions);
```
### 3. 条件禁用
```tsx
const { handlers } = useTouch({
enabled: !isLoading, // 加载时禁用触摸
onTap: handleTap,
});
```
## 完整示例
```tsx
import { useTouch } from "@/hooks";
import { useState } from "react";
function TouchDemo() {
const [log, setLog] = useState<string[]>([]);
const addLog = (message: string) => {
setLog((prev) => [...prev, message].slice(-5));
};
const { handlers, isTouching, touchData } = useTouch({
onTap: () => addLog("点击"),
onDoubleTap: () => addLog("双击"),
onLongPress: () => addLog("长按"),
onSwipeLeft: () => addLog("向左滑动"),
onSwipeRight: () => addLog("向右滑动"),
onSwipeUp: () => addLog("向上滑动"),
onSwipeDown: () => addLog("向下滑动"),
});
return (
<div>
<div
{...handlers}
style={{
width: 300,
height: 300,
border: "2px solid #ccc",
display: "flex",
alignItems: "center",
justifyContent: "center",
userSelect: "none",
background: isTouching ? "#f0f0f0" : "white",
}}
>
触摸测试区域
{touchData && (
<div style={{ fontSize: 12 }}>
Δx: {touchData.deltaX.toFixed(0)}
Δy: {touchData.deltaY.toFixed(0)}
</div>
)}
</div>
<div style={{ marginTop: 20 }}>
<h3>操作日志:</h3>
{log.map((msg, i) => (
<div key={i}>{msg}</div>
))}
</div>
</div>
);
}
```
## 总结
`useTouch` Hook 提供了:
- ✅ 完整的手势识别(点击、双击、长按、滑动)
- ✅ 灵活的配置选项
- ✅ 详细的触摸数据
- ✅ 跨平台支持(触摸 + 鼠标)
- ✅ TypeScript 类型安全
- ✅ 性能优化
适用于各种触摸交互场景!
+9
View File
@@ -0,0 +1,9 @@
export { useDragAndDrop } from "./useDragAndDrop";
export { usePageScale } from "./usePageScale";
export { useTouch } from "./useTouch";
export type {
UseTouchOptions,
UseTouchReturn,
TouchEventData,
SwipeDirection,
} from "./useTouch";
+485
View File
@@ -0,0 +1,485 @@
import { useRef, useCallback, useState } from "react";
/**
* 触摸点信息
*/
interface TouchPoint {
x: number;
y: number;
timestamp: number;
}
/**
* 滑动方向
*/
export type SwipeDirection = "left" | "right" | "up" | "down";
/**
* 手势事件回调参数
*/
export interface TouchEventData {
/** 起始位置 */
startPoint: TouchPoint;
/** 当前位置 */
currentPoint: TouchPoint;
/** 移动距离 X */
deltaX: number;
/** 移动距离 Y */
deltaY: number;
/** 移动的总距离 */
distance: number;
/** 移动速度(像素/毫秒) */
velocity: number;
/** 移动方向 */
direction: SwipeDirection | null;
/** 原始事件 */
event: React.TouchEvent | React.MouseEvent;
}
/**
* useTouch 配置选项
*/
export interface UseTouchOptions {
/** 是否启用,默认 true */
enabled?: boolean;
// ========== 滑动配置 ==========
/** 触发滑动的最小距离(像素),默认 10 */
swipeThreshold?: number;
/** 触发快速滑动的最小速度(像素/毫秒),默认 0.5 */
swipeVelocityThreshold?: number;
// ========== 长按配置 ==========
/** 触发长按的时间(毫秒),默认 500 */
longPressDelay?: number;
/** 长按时允许的最大移动距离(像素),默认 10 */
longPressMoveThreshold?: number;
// ========== 双击配置 ==========
/** 双击的最大时间间隔(毫秒),默认 300 */
doubleTapDelay?: number;
/** 双击的最大移动距离(像素),默认 10 */
doubleTapMoveThreshold?: number;
// ========== 事件回调 ==========
/** 触摸开始 */
onTouchStart?: (data: TouchEventData) => void;
/** 触摸移动 */
onTouchMove?: (data: TouchEventData) => void;
/** 触摸结束 */
onTouchEnd?: (data: TouchEventData) => void;
/** 点击 */
onTap?: (data: TouchEventData) => void;
/** 双击 */
onDoubleTap?: (data: TouchEventData) => void;
/** 长按 */
onLongPress?: (data: TouchEventData) => void;
/** 滑动 */
onSwipe?: (data: TouchEventData) => void;
/** 向左滑动 */
onSwipeLeft?: (data: TouchEventData) => void;
/** 向右滑动 */
onSwipeRight?: (data: TouchEventData) => void;
/** 向上滑动 */
onSwipeUp?: (data: TouchEventData) => void;
/** 向下滑动 */
onSwipeDown?: (data: TouchEventData) => void;
// ========== 其他配置 ==========
/** 是否阻止默认行为,默认 false */
preventDefault?: boolean;
/** 是否阻止事件冒泡,默认 false */
stopPropagation?: boolean;
}
/**
* useTouch 返回值
*/
export interface UseTouchReturn {
/** 是否正在触摸 */
isTouching: boolean;
/** 触摸事件处理器 */
handlers: {
onTouchStart: (e: React.TouchEvent) => void;
onTouchMove: (e: React.TouchEvent) => void;
onTouchEnd: (e: React.TouchEvent) => void;
onMouseDown: (e: React.MouseEvent) => void;
};
/** 当前触摸数据 */
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);
}
/**
* 计算滑动方向
*/
function getDirection(deltaX: number, deltaY: number): SwipeDirection | null {
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (absDeltaX < 10 && absDeltaY < 10) {
return null;
}
if (absDeltaX > absDeltaY) {
return deltaX > 0 ? "right" : "left";
} else {
return deltaY > 0 ? "down" : "up";
}
}
/**
* 灵活的触摸手势处理 Hook
*
* @example
* ```tsx
* const { handlers, isTouching } = useTouch({
* onSwipeLeft: () => console.log('向左滑动'),
* onSwipeRight: () => console.log('向右滑动'),
* onTap: () => console.log('点击'),
* onDoubleTap: () => console.log('双击'),
* onLongPress: () => console.log('长按'),
* });
*
* return <div {...handlers}>可触摸区域</div>;
* ```
*/
export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
const {
enabled = true,
swipeThreshold = 10,
swipeVelocityThreshold = 0.5,
longPressDelay = 500,
longPressMoveThreshold = 10,
doubleTapDelay = 300,
doubleTapMoveThreshold = 10,
onTouchStart,
onTouchMove,
onTouchEnd,
onTap,
onDoubleTap,
onLongPress,
onSwipe,
onSwipeLeft,
onSwipeRight,
onSwipeUp,
onSwipeDown,
preventDefault = false,
stopPropagation = false,
} = options;
// 状态
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 createTouchPoint = useCallback(
(x: number, y: number): TouchPoint => ({
x,
y,
timestamp: Date.now(),
}),
[]
);
/**
* 创建触摸事件数据
*/
const createTouchEventData = useCallback(
(
startPoint: TouchPoint,
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);
return {
startPoint,
currentPoint,
deltaX,
deltaY,
distance,
velocity,
direction,
event,
};
},
[]
);
/**
* 清除长按定时器
*/
const clearLongPressTimer = useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
}, []);
/**
* 处理触摸开始
*/
const handleStart = useCallback(
(x: number, y: number, event: React.TouchEvent | React.MouseEvent) => {
if (!enabled) return;
if (preventDefault) event.preventDefault();
if (stopPropagation) event.stopPropagation();
const point = createTouchPoint(x, y);
startPointRef.current = point;
currentPointRef.current = point;
isLongPressTriggeredRef.current = false;
setIsTouching(true);
const data = createTouchEventData(point, point, event);
setTouchData(data);
onTouchStart?.(data);
// 设置长按定时器
if (onLongPress) {
clearLongPressTimer();
longPressTimerRef.current = setTimeout(() => {
if (startPointRef.current && currentPointRef.current) {
const distance = getDistance(
startPointRef.current,
currentPointRef.current
);
if (distance < longPressMoveThreshold) {
isLongPressTriggeredRef.current = true;
const longPressData = createTouchEventData(
startPointRef.current,
currentPointRef.current,
event
);
onLongPress(longPressData);
}
}
}, longPressDelay);
}
},
[
enabled,
preventDefault,
stopPropagation,
createTouchPoint,
createTouchEventData,
onTouchStart,
onLongPress,
longPressDelay,
longPressMoveThreshold,
clearLongPressTimer,
]
);
/**
* 处理触摸移动
*/
const handleMove = useCallback(
(x: number, y: number, event: React.TouchEvent | React.MouseEvent) => {
if (!enabled || !startPointRef.current) return;
if (preventDefault) event.preventDefault();
if (stopPropagation) event.stopPropagation();
const point = createTouchPoint(x, y);
currentPointRef.current = point;
const data = createTouchEventData(startPointRef.current, point, event);
setTouchData(data);
onTouchMove?.(data);
// 如果移动距离超过阈值,取消长按
if (data.distance > longPressMoveThreshold) {
clearLongPressTimer();
}
},
[
enabled,
preventDefault,
stopPropagation,
createTouchPoint,
createTouchEventData,
onTouchMove,
longPressMoveThreshold,
clearLongPressTimer,
]
);
/**
* 处理触摸结束
*/
const handleEnd = useCallback(
(event: React.TouchEvent | React.MouseEvent) => {
if (!enabled || !startPointRef.current || !currentPointRef.current)
return;
if (preventDefault) event.preventDefault();
if (stopPropagation) event.stopPropagation();
clearLongPressTimer();
const data = createTouchEventData(
startPointRef.current,
currentPointRef.current,
event
);
onTouchEnd?.(data);
// 判断手势类型
const { distance, velocity, direction } = data;
// 长按已触发,不处理其他手势
if (isLongPressTriggeredRef.current) {
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);
}
// 点击或双击
else if (distance < doubleTapMoveThreshold) {
const now = Date.now();
const timeSinceLastTap = now - lastTapTimeRef.current;
if (timeSinceLastTap < doubleTapDelay && onDoubleTap) {
// 双击
onDoubleTap(data);
lastTapTimeRef.current = 0; // 重置,避免三击触发两次双击
} else {
// 单击
onTap?.(data);
lastTapTimeRef.current = now;
}
}
setIsTouching(false);
setTouchData(null);
startPointRef.current = null;
currentPointRef.current = null;
},
[
enabled,
preventDefault,
stopPropagation,
createTouchEventData,
onTouchEnd,
onSwipe,
onSwipeLeft,
onSwipeRight,
onSwipeUp,
onSwipeDown,
onTap,
onDoubleTap,
swipeThreshold,
swipeVelocityThreshold,
doubleTapMoveThreshold,
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);
}
},
[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);
}
},
[handleMove]
);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
handleEnd(e);
},
[handleEnd]
);
// 鼠标事件处理器(桌面端支持)
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
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
);
}
};
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);
}
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
},
[handleStart, handleMove, handleEnd]
);
return {
isTouching,
handlers: {
onTouchStart: handleTouchStart,
onTouchMove: handleTouchMove,
onTouchEnd: handleTouchEnd,
onMouseDown: handleMouseDown,
},
touchData,
};
}
+6 -6
View File
@@ -206,10 +206,10 @@ const TabContent = ({
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-[#82B2D6] to-[#6a9bc4] text-white">
<th className="px-6 py-4 text-left text-lg font-semibold w-[70%]">
<th className="px-6 py-4 text-left text-3xl font-semibold w-[70%]">
活动名称
</th>
<th className="px-6 py-4 text-left text-lg font-semibold w-[30%]">
<th className="px-6 py-4 text-left text-3xl font-semibold w-[30%]">
时间
</th>
</tr>
@@ -220,7 +220,7 @@ const TabContent = ({
key={activity.id}
onClick={() => handleActivityClick(activity)}
className={clsx(
"border-b border-gray-200 cursor-pointer transition-all duration-200",
"border-b border-gray-200 cursor-pointer transition-all duration-200 text-3xl",
"hover:bg-[#82B2D6]/10 hover:shadow-md",
index % 2 === 0 ? "bg-white" : "bg-gray-50"
)}
@@ -259,10 +259,10 @@ const TabContent = ({
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-[#82B2D6] to-[#6a9bc4] text-white">
<th className="px-6 py-4 text-left text-lg font-semibold w-[70%]">
<th className="px-6 py-4 text-left text-3xl font-semibold w-[70%]">
活动名称
</th>
<th className="px-6 py-4 text-left text-lg font-semibold w-[30%]">
<th className="px-6 py-4 text-left text-3xl font-semibold w-[30%]">
时间
</th>
</tr>
@@ -276,7 +276,7 @@ const TabContent = ({
handleActivitiesClick(activity)
}
className={clsx(
"border-b border-gray-200 cursor-pointer transition-all duration-200",
"border-b border-gray-200 cursor-pointer transition-all duration-200 text-3xl",
"hover:bg-[#82B2D6]/10 hover:shadow-md",
index % 2 === 0 ? "bg-white" : "bg-gray-50"
)}
+77
View File
@@ -0,0 +1,77 @@
import { useNavigate } from "react-router-dom";
import { BackgroundImage, Image } from "@/components";
import { getStaticImage, getPublicPath } from "@/utils";
import { useState, useRef, useEffect } from "react";
export default function Guide() {
const navigate = useNavigate();
const [showVideo, setShowVideo] = useState(true);
const videoRef = useRef<HTMLVideoElement>(null);
// 监听视频播放完成
const handleVideoEnd = () => {
setShowVideo(false);
};
// 跳过引导,直接进入宣传页
const handleSkip = () => {
navigate("/publicity");
};
return (
<BackgroundImage
src={getStaticImage("guide/引导页背景.png")}
className="h-full w-full relative"
backgroundSize="cover"
backgroundPosition="center"
>
{/* 跳过按钮 */}
<Image
src={getStaticImage("components/按钮-跳过.png")}
alt="跳过"
button
className="absolute top-0 right-0"
onClick={handleSkip}
/>
{/* 标题 */}
<Image
src={getStaticImage("publicity/名师工作室-标题.png")}
alt="标题"
className="absolute left-0 top-30"
/>
{/* 引导视频 - 仅在首次显示 */}
{
<div className="absolute w-[500px] left-20 -bottom-20 z-50">
<video
ref={videoRef}
className="w-full h-full object-contain"
src={getPublicPath("video/5_alpha channel.webm")}
autoPlay
playsInline
onEnded={handleVideoEnd}
/>
</div>
}
{/* 引导内容区域 */}
{
<div className="absolute right-10 bottom-0 z-10">
{/* 发言底板 */}
<BackgroundImage
className="p-20"
width={1220}
height={763}
src={getStaticImage("guide/框.png")}
>
<p className="text-white text-3xl tracking-wide leading-15 text-left">
各位朋友好!我是曹君,智慧商旅系主任、网络营销与直播电商专业带头人。我的名师工作室聚焦产教融合,紧扣新兴行业需求,对接虹口北外滩发展战略。
带教以实战为核心,依托全国行业资源,让学员深入企业、乡村参与直播全流程;同时兼顾实践与教学能力双提升,致力培养复合型数字技能人才,期待与大家共成长!
</p>
</BackgroundImage>
</div>
}
</BackgroundImage>
);
}
+89 -58
View File
@@ -1,8 +1,8 @@
import { useNavigate } from "react-router-dom";
import { BackgroundImage, Image, Alert } from "@/components";
import { getStaticImage } from "@/utils";
import { useState } from "react";
import { IframeModal } from "@/components/Modal";
import { useState, Fragment, useRef, useEffect } from "react";
import { useTouch } from "@/hooks";
/**
* 卢航-未选中.png
@@ -16,8 +16,8 @@ const teachers = [
avatar: getStaticImage("menu/卢航-未选中.png"),
selectedAvatar: getStaticImage("menu/卢航-选中.png"),
position: {
top: "20%",
left: "12%",
top: "20.5%",
left: "15%",
},
},
{
@@ -36,8 +36,8 @@ const teachers = [
avatar: getStaticImage("menu/龚丹-未选中.png"),
selectedAvatar: getStaticImage("menu/龚丹-选中.png"),
position: {
top: "20%",
left: "65%",
top: "20.5%",
left: "63%",
},
},
];
@@ -45,9 +45,73 @@ const teachers = [
export default function Home() {
const navigate = useNavigate();
const [selectedTeacherId, setSelectedTeacherId] = useState(0);
const [isModalOpen, setIsModalOpen] = useState(false);
const [showTipModal, setShowTipModal] = useState(false);
// 旋转相关状态
const [rotation, setRotation] = useState(0);
const [isAutoRotating, setIsAutoRotating] = useState(true);
const autoRotateTimerRef = useRef<number | null>(null);
const resumeTimerRef = useRef<NodeJS.Timeout | null>(null);
const startRotationRef = useRef(0);
// 自动旋转效果
useEffect(() => {
if (isAutoRotating) {
const animate = () => {
setRotation((prev) => prev + 0.2);
autoRotateTimerRef.current = requestAnimationFrame(animate);
};
autoRotateTimerRef.current = requestAnimationFrame(animate);
}
return () => {
if (autoRotateTimerRef.current !== null) {
cancelAnimationFrame(autoRotateTimerRef.current);
autoRotateTimerRef.current = null;
}
};
}, [isAutoRotating]);
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (autoRotateTimerRef.current !== null) {
cancelAnimationFrame(autoRotateTimerRef.current);
}
if (resumeTimerRef.current !== null) {
clearTimeout(resumeTimerRef.current);
}
};
}, []);
// 使用 useTouch 处理触摸手势
const { handlers } = useTouch({
onTouchStart: () => {
// 停止自动旋转
setIsAutoRotating(false);
// 清除恢复定时器
if (resumeTimerRef.current) {
clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = null;
}
// 记录开始时的旋转角度
startRotationRef.current = rotation;
},
onTouchMove: (data) => {
// 根据水平移动距离计算旋转角度(灵敏度 0.5)
const deltaRotation = data.deltaX * 0.5;
setRotation(startRotationRef.current + deltaRotation);
},
onTouchEnd: () => {
// 1秒后恢复自动旋转
resumeTimerRef.current = setTimeout(() => {
setIsAutoRotating(true);
}, 1000);
},
});
const handleSelectTeacher = (teacherId: number) => {
if (teacherId !== 2) {
// 点击卢航或龚丹时显示提示
@@ -55,20 +119,7 @@ export default function Home() {
return;
}
setSelectedTeacherId(teacherId);
navigate("/Publicity");
};
const handleStartExperiment = () => {
console.log("点击开启实验");
// navigate("/experiment");
};
const handleAvatarClick = () => {
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
navigate("/guide");
};
return (
@@ -92,36 +143,6 @@ export default function Home() {
className="absolute bottom-0 left-1/2 transform -translate-x-1/2"
/>
{/* 数字人头像 */}
{!isModalOpen && (
<>
<div
className="flex flex-col items-center absolute right-4 bottom-2 cursor-pointer hover:scale-105 transition-all duration-300 z-20"
onClick={handleAvatarClick}
>
<Image
src={getStaticImage("publicity/数字人-头像框.png")}
alt="数字人头像"
/>
<span className="text-white text-2xl font-bold tracking-wide">
点击我随时为你解答
</span>
</div>
</>
)}
{/* 嵌入式客服对话框 */}
<IframeModal
isOpen={isModalOpen}
onClose={handleCloseModal}
url="https://www.edutech.host/digitalHuman/"
title="数字人助手"
mode="embedded"
position="bottom-right"
width="420px"
height="650px"
/>
{/* 提示弹窗 */}
<Alert
isOpen={showTipModal}
@@ -132,21 +153,30 @@ export default function Home() {
<BackgroundImage
src={getStaticImage("menu/转盘合.png")}
className="absolute w-full h-500 -bottom-1/5 left-0"
width={2058}
height={2046}
className="absolute -bottom-1/5 -left-14.5 cursor-grab active:cursor-grabbing select-none"
backgroundSize="cover"
backgroundPosition="center"
{...handlers}
style={{
transform: `rotate(${rotation}deg)`,
transition: "none",
}}
>
{teachers.map((teacher) => (
<>
{teachers.map((teacher, _index) => (
<Fragment key={teacher.id}>
<Image
key={teacher.id}
src={
selectedTeacherId === teacher.id
? teacher.selectedAvatar
: teacher.avatar
}
alt={teacher.name}
onClick={() => handleSelectTeacher(teacher.id)}
onClick={(e) => {
e.stopPropagation();
handleSelectTeacher(teacher.id);
}}
className="absolute"
style={{
top:
@@ -157,7 +187,8 @@ export default function Home() {
selectedTeacherId === teacher.id
? "36%"
: teacher.position.left,
transition: "transform 0.2s ease, z-index 0.2s ease",
transform: `rotate(${-rotation}deg)`,
transition: "none",
}}
button={selectedTeacherId !== teacher.id}
/>
@@ -168,7 +199,7 @@ export default function Home() {
<div className="absolute top-[25%] left-[65%] text-gray-500 text-3xl font-bold cursor-not-allowed">
该名师工作室数字睿教正在筹划中
</div> */}
</>
</Fragment>
))}
</BackgroundImage>
</BackgroundImage>
+36 -8
View File
@@ -1,8 +1,9 @@
import { BackgroundImage, Image } from "@/components";
import { IframeModal } from "@/components/Modal";
import { getStaticImage } from "@/utils";
import { useState } from "react";
import { getPublicPath, getStaticImage } from "@/utils";
import { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useFirstVisitStore } from "@/store";
// 解答弹窗组件
interface DialogBoxProps {
@@ -116,6 +117,33 @@ export default function PublicityPage() {
const [selectedFunctionId, setSelectedFunctionId] = useState(0);
const [isModalOpen, setIsModalOpen] = useState(false);
// 首次访问状态管理
const { hasSeenIntro, setHasSeenIntro, isExpired, reset } =
useFirstVisitStore();
const [showIntroVideo, setShowIntroVideo] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
// 检查是否显示引导视频
useEffect(() => {
// 如果存储已过期,重置状态
if (isExpired()) {
reset();
setShowIntroVideo(true);
return;
}
if (!hasSeenIntro) {
setShowIntroVideo(true);
}
}, [hasSeenIntro, isExpired, reset]);
// 监听视频播放完成
const handleVideoEnd = () => {
setShowIntroVideo(false);
setHasSeenIntro();
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleOpenDialog = () => {
setIsDialogOpen(true);
};
@@ -170,7 +198,7 @@ export default function PublicityPage() {
className="absolute left-0 top-30"
/>
{/* 数字人 */}
{/* 数字人 - 未播放引导视频时显示 */}
<Image
src={getStaticImage("publicity/曹君名师数字人分身.png")}
alt="数字人"
@@ -178,7 +206,7 @@ export default function PublicityPage() {
/>
{/* 数字人头像 */}
{!isModalOpen && (
{/* {!isModalOpen && (
<>
<div
className="flex flex-col items-center absolute right-4 bottom-2 cursor-pointer hover:scale-105 transition-all duration-300 z-10"
@@ -193,7 +221,7 @@ export default function PublicityPage() {
</span>
</div>
</>
)}
)} */}
{/* 提示 */}
{/* <Image
src={getStaticImage("publicity/点击提示.png")}
@@ -204,10 +232,10 @@ export default function PublicityPage() {
/> */}
{/* 解答弹窗 */}
<DialogBox isOpen={isDialogOpen} onClose={handleCloseDialog} />
{/* <DialogBox isOpen={isDialogOpen} onClose={handleCloseDialog} /> */}
{/* 嵌入式客服对话框 */}
<IframeModal
{/* <IframeModal
isOpen={isModalOpen}
onClose={handleCloseModal}
url="https://www.edutech.host/digitalHuman/"
@@ -216,7 +244,7 @@ export default function PublicityPage() {
position="bottom-right"
width="420px"
height="650px"
/>
/> */}
</BackgroundImage>
);
}
+5
View File
@@ -4,6 +4,7 @@ import Menu from "@/pages/Menu";
import NotFound from "@/pages/NotFound";
import Publicity from "@/pages/Publicity";
import Content from "@/pages/Content";
import Guide from "@/pages/Guide";
/**
* 应用路由配置
* 使用 React Router v7 的 createHashRouter
@@ -22,6 +23,10 @@ const router = createHashRouter([
path: "/menu",
element: <Menu />,
},
{
path: "/guide",
element: <Guide />,
},
{
path: "/publicity",
element: <Publicity />,
+39
View File
@@ -0,0 +1,39 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface FirstVisitState {
hasSeenIntro: boolean;
timestamp: number | null; // 存储时间戳
setHasSeenIntro: () => void;
isExpired: () => boolean; // 检查是否过期
reset: () => void; // 重置状态
}
// 存储有效期(毫秒),默认5分钟
const STORAGE_EXPIRY_TIME = 5 * 60 * 1000;
export const useFirstVisitStore = create<FirstVisitState>()(
persist(
(set, get) => ({
hasSeenIntro: false,
timestamp: null,
setHasSeenIntro: () => {
set({ hasSeenIntro: true, timestamp: Date.now() });
},
isExpired: () => {
const state = get();
if (!state.timestamp) {
return false;
}
const now = Date.now();
return now - state.timestamp > STORAGE_EXPIRY_TIME;
},
reset: () => {
set({ hasSeenIntro: false, timestamp: null });
},
}),
{
name: "first-visit-storage", // 存储到localStorage的key
}
)
);
+1
View File
@@ -0,0 +1 @@
export * from "./firstVisitStore";