feat:init

This commit is contained in:
zhongyidian committed 2025-12-29 19:19:10 +08:00
1 parent 53d0eeac6b
commit ae0c050010
494 files changed
+17701 -2318

No files matched your search

@@ -0,0 +1,241 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { cn, getBackgroundImage } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
export interface ExhibitDescriptionNarrationProps {
/** 描述文本 */
text?: string;
/** 是否可见 */
visible?: boolean;
/** 是否自动播放语音 */
autoPlay?: boolean;
/** 额外的自定义类 */
className?: string;
}
/**
* 展品描述播报组件
* - 显示展品描述文本
* - 支持文本转语音(TTS)播报
* - 提供播放/暂停控制
*/
const ExhibitDescriptionNarration = ({
text,
visible = true,
autoPlay = false,
className,
}: ExhibitDescriptionNarrationProps) => {
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
const synthRef = useRef<typeof window.speechSynthesis | null>(null);
// 初始化语音合成
useEffect(() => {
if (typeof window !== "undefined" && "speechSynthesis" in window) {
synthRef.current = window.speechSynthesis;
}
}, []);
// 播放语音
const playSpeech = useCallback(() => {
if (!text || !synthRef.current) return;
// 如果已有正在播放的语音,先停止
if (utteranceRef.current) {
synthRef.current.cancel();
}
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "zh-CN";
utterance.rate = 0.9; // 语速稍慢
utterance.pitch = 1; // 音调
utterance.volume = 1; // 音量
utterance.onstart = () => {
setIsPlaying(true);
setIsPaused(false);
};
utterance.onend = () => {
setIsPlaying(false);
setIsPaused(false);
utteranceRef.current = null;
};
utterance.onerror = () => {
setIsPlaying(false);
setIsPaused(false);
utteranceRef.current = null;
};
utteranceRef.current = utterance;
synthRef.current.speak(utterance);
}, [text]);
// 暂停语音
const pauseSpeech = useCallback(() => {
if (synthRef.current && isPlaying) {
synthRef.current.pause();
setIsPaused(true);
}
}, [isPlaying]);
// 继续播放语音
const resumeSpeech = useCallback(() => {
if (synthRef.current && isPaused) {
synthRef.current.resume();
setIsPaused(false);
}
}, [isPaused]);
// 停止语音
const stopSpeech = useCallback(() => {
if (synthRef.current) {
synthRef.current.cancel();
setIsPlaying(false);
setIsPaused(false);
utteranceRef.current = null;
}
}, []);
// 自动播放
useEffect(() => {
if (visible && autoPlay && text && synthRef.current) {
// 延迟一下确保组件已渲染
const timer = setTimeout(() => {
playSpeech();
}, 300);
return () => {
clearTimeout(timer);
stopSpeech();
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, autoPlay, text]);
// 组件卸载或不可见时停止语音
useEffect(() => {
if (!visible) {
stopSpeech();
}
return () => {
stopSpeech();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
if (!text) return null;
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className={cn(
"max-w-[500px] px-12 py-14",
"shadow-black/60",
"relative overflow-hidden",
className
)}
>
{/* <img
src={borderImg}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
/> */}
<div
className="absolute inset-0 pointer-events-none box-border"
aria-hidden="true"
style={{
border: "40px solid transparent",
borderImageSource: getBackgroundImage(
"components/narration/b.png"
),
borderImageSlice: "40 fill",
borderImageRepeat: "stretch round",
}}
/>
<div className="relative z-10 flex flex-col gap-4">
{/* 文本内容 */}
<div className="flex-1 text-white text-2xl leading-12 tracking-wide font-medium">
{text}
</div>
{/* 控制按钮 */}
{synthRef.current && (
<div className="flex items-center justify-end gap-2 flex-shrink-0">
{!isPlaying && !isPaused && (
<button
onClick={playSpeech}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
aria-label="播放"
>
<svg
className="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
</svg>
</button>
)}
{isPlaying && !isPaused && (
<button
onClick={pauseSpeech}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
aria-label="暂停"
>
<svg
className="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M5.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75A.75.75 0 007.25 3h-1.5zM12.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75a.75.75 0 00-.75-.75h-1.5z" />
</svg>
</button>
)}
{isPaused && (
<button
onClick={resumeSpeech}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
aria-label="继续播放"
>
<svg
className="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
</svg>
</button>
)}
{(isPlaying || isPaused) && (
<button
onClick={stopSpeech}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
aria-label="停止"
>
<svg
className="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M5.25 3A2.25 2.25 0 003 5.25v9.5A2.25 2.25 0 005.25 17h9.5A2.25 2.25 0 0017 14.75v-9.5A2.25 2.25 0 0014.75 3h-9.5z" />
</svg>
</button>
)}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
);
};
export default ExhibitDescriptionNarration;
+579
View File
@@ -0,0 +1,579 @@
import { cn, getStaticImage } from "@/lib/utils";
import {
MouseEvent,
useEffect,
useRef,
useCallback,
useMemo,
useState,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { Image } from "@/components";
import { useScale } from "@/contexts/ScaleContext";
import ExhibitDescriptionNarration from "./ExhibitDescriptionNarration";
export interface ExhibitOverlayProps {
/** 展品图片地址,可以是静态资源路径或完整 URL */
src: string;
/** 关闭回调 */
onClose?: () => void;
/** 是否可见,默认 true */
visible?: boolean;
/** 额外的自定义类 */
className?: string;
/** 展品描述文本 */
description?: string;
/** 是否自动播放描述语音,默认 false */
autoPlayDescription?: boolean;
/** 自定义点击区域配置 */
clickableAreas?: Array<{
/** 区域名称 */
name: string;
/** 相对于图片的位置和大小(百分比) */
position: { left: number; top: number; width: number; height: number };
/** 点击回调 */
onClick: () => void;
}>;
}
type Point = { x: number; y: number };
/**
* 展品通用遮罩层
* - 背景蒙版:components/蒙版遮罩.png
* - 返回按钮:components/返回.png
* - 传入展品图片地址进行展示
* - 支持缩放、拖拽、双击重置
* - 使用屏幕坐标系统,简单可靠
*/
const ExhibitOverlay = ({
src,
onClose,
visible = true,
className,
description,
autoPlayDescription = false,
clickableAreas,
}: ExhibitOverlayProps) => {
const maskSrc = getStaticImage("components/蒙版遮罩");
const backSrc = getStaticImage("components/关闭");
// 获取缩放参数,用于视觉对齐
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
const [mounted, setMounted] = useState(false);
// SSR 兼容:只在客户端挂载
useEffect(() => {
setMounted(true);
}, []);
// 允许传入相对路径或绝对 URL
const exhibitSrc =
src.startsWith("http://") ||
src.startsWith("https://") ||
src.startsWith("/")
? src
: getStaticImage(src);
const handleClose = (e: MouseEvent<HTMLImageElement>) => {
e.stopPropagation();
onClose?.();
};
// ====== 缩放 / 拖拽状态 ======
const containerRef = useRef<HTMLDivElement | null>(null);
const viewElRef = useRef<HTMLDivElement | null>(null);
const frameRef = useRef<number | null>(null);
const pointers = useRef<Map<number, Point>>(new Map());
const lastDistance = useRef<number | null>(null);
// 拖拽初始状态:记录 pointerDown 时的状态
const dragStartRef = useRef<{
pointerId: number;
startX: number;
startY: number;
startTransform: { scale: number; x: number; y: number };
} | null>(null);
const transformRef = useRef({ scale: 1, x: 0, y: 0 });
const MIN_SCALE = 0.8;
const MAX_SCALE = 3;
// 针对当前 scale 预计算 clamp
const clampScale = useCallback(
(next: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, next)),
[]
);
const applyTransformStyle = useCallback(() => {
const el = viewElRef.current;
if (!el) return;
const { scale, x, y } = transformRef.current;
// 使用 translate3d 触发 GPU 加速
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
el.style.transformOrigin = "0 0";
el.style.touchAction = "none";
}, []);
const scheduleUpdate = useCallback(() => {
if (frameRef.current) return;
frameRef.current = requestAnimationFrame(() => {
applyTransformStyle();
frameRef.current = null;
});
}, [applyTransformStyle]);
const resetTransform = useCallback(() => {
transformRef.current = { scale: 1, x: 0, y: 0 };
pointers.current.clear();
lastDistance.current = null;
dragStartRef.current = null;
// 释放所有指针捕获
if (containerRef.current) {
pointers.current.forEach((_, pointerId) => {
try {
containerRef.current?.releasePointerCapture(pointerId);
} catch {
// 忽略捕获释放错误
}
});
}
applyTransformStyle();
}, [applyTransformStyle]);
// 关闭或切换时重置
useEffect(() => {
if (visible) {
resetTransform();
} else {
// 不可见时清理所有状态和指针捕获
transformRef.current = { scale: 1, x: 0, y: 0 };
pointers.current.clear();
lastDistance.current = null;
dragStartRef.current = null;
if (containerRef.current) {
pointers.current.forEach((_, pointerId) => {
try {
containerRef.current?.releasePointerCapture(pointerId);
} catch {
// 忽略捕获释放错误
}
});
}
// 取消待处理的动画帧
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, src]);
// 组件卸载时清理资源
useEffect(() => {
const containerEl = containerRef.current;
const pointersMap = pointers.current;
const frameId = frameRef.current;
return () => {
// 清理所有指针捕获
if (containerEl) {
pointersMap.forEach((_, pointerId) => {
try {
containerEl.releasePointerCapture(pointerId);
} catch {
// 忽略捕获释放错误
}
});
}
// 取消待处理的动画帧
if (frameId) {
cancelAnimationFrame(frameId);
}
};
}, []);
// 缩放函数:origin 是屏幕坐标,需要转换为相对于 viewElRef 的坐标
const applyZoom = useCallback(
(factor: number, origin: Point) => {
if (!viewElRef.current || !containerRef.current) return;
const prev = transformRef.current;
const nextScale = clampScale(prev.scale * factor);
// 边界检查:避免除零
if (prev.scale === 0) return;
// 获取 viewElRef 相对于视口的位置
const viewRect = viewElRef.current.getBoundingClientRect();
// 将屏幕坐标转换为相对于 viewElRef 的坐标
const relativeOrigin = {
x: origin.x - viewRect.left,
y: origin.y - viewRect.top,
};
const scaleChange = nextScale / prev.scale;
// 以相对坐标 origin 为基准,保持该点视觉位置不动
const nextX =
(prev.x - relativeOrigin.x) * scaleChange + relativeOrigin.x;
const nextY =
(prev.y - relativeOrigin.y) * scaleChange + relativeOrigin.y;
transformRef.current = { scale: nextScale, x: nextX, y: nextY };
scheduleUpdate();
},
[clampScale, scheduleUpdate]
);
// 滚轮缩放:直接使用屏幕坐标
const handleWheel = useCallback(
(e: React.WheelEvent<HTMLDivElement>) => {
const delta = e.deltaY;
const factor = delta < 0 ? 1.05 : 0.95;
// 直接使用屏幕坐标作为缩放中心
applyZoom(factor, { x: e.clientX, y: e.clientY });
},
[applyZoom]
);
// 计算两点距离
const getDistance = useCallback((p1: Point, p2: Point) => {
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
return Math.hypot(dx, dy);
}, []);
// 指针按下:记录初始状态
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!containerRef.current || !viewElRef.current) return;
try {
containerRef.current.setPointerCapture(e.pointerId);
} catch (err) {
console.warn("Failed to capture pointer:", err);
}
// 记录屏幕坐标
const point = {
x: e.clientX,
y: e.clientY,
};
pointers.current.set(e.pointerId, point);
// 单指拖拽:记录初始状态
if (pointers.current.size === 1) {
dragStartRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
startTransform: { ...transformRef.current },
};
} else if (pointers.current.size === 2) {
// 双指缩放:记录初始距离
const values = Array.from(pointers.current.values());
if (values.length === 2) {
const [p1, p2] = values;
if (p1 && p2) {
lastDistance.current = getDistance(p1, p2);
dragStartRef.current = null;
}
}
}
},
[getDistance]
);
/**
* 单指拖拽处理
* - 只关心当前 pointer 相对于按下时的位移
* - 在这里统一做全局 scale 补偿,保持「手指移动 = 屏幕上移动」
*/
const handleSingleDrag = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const dragStart = dragStartRef.current;
if (!dragStart) return;
// 1. 计算屏幕坐标系下的位移
const dxScreen = e.clientX - dragStart.startX;
const dyScreen = e.clientY - dragStart.startY;
// 2. 映射到设计稿坐标系(补偿全局 scale)
const dx = dxScreen / scale;
const dy = dyScreen / scale;
// 3. 应用到 transform(设计稿坐标)
const nextX = dragStart.startTransform.x + dx;
const nextY = dragStart.startTransform.y + dy;
transformRef.current.x = nextX;
transformRef.current.y = nextY;
// 统一交给 rAF 的 scheduleUpdate,减少直接 DOM 操作次数
scheduleUpdate();
},
[scale, scheduleUpdate]
);
/**
* 双指缩放处理
* - 使用两指间距离变化计算缩放因子
* - 使用两指中点作为缩放中心
*/
const handlePinchZoom = useCallback(() => {
if (pointers.current.size !== 2) return;
const values = Array.from(pointers.current.values());
const [p1, p2] = values;
if (!p1 || !p2) return;
const distance = getDistance(p1, p2);
if (!lastDistance.current || lastDistance.current <= 0) {
lastDistance.current = distance;
return;
}
const factor = distance / lastDistance.current;
const center = {
x: (p1.x + p2.x) * 0.5,
y: (p1.y + p2.y) * 0.5,
};
applyZoom(factor, center);
lastDistance.current = distance;
}, [getDistance, applyZoom]);
// 指针移动:更新指针位置,然后按当前手势类型分发给拖拽 / 缩放处理
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!pointers.current.has(e.pointerId)) return;
// 更新当前 pointer 的屏幕坐标
pointers.current.set(e.pointerId, {
x: e.clientX,
y: e.clientY,
});
// 优先处理双指缩放
if (pointers.current.size === 2) {
handlePinchZoom();
return;
}
// 单指拖拽
if (pointers.current.size === 1) {
handleSingleDrag(e);
}
},
[handlePinchZoom, handleSingleDrag]
);
// 指针抬起
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
// 释放指针捕获
if (containerRef.current) {
try {
containerRef.current.releasePointerCapture(e.pointerId);
} catch {
// 忽略捕获释放错误
}
}
pointers.current.delete(e.pointerId);
if (pointers.current.size < 2) {
lastDistance.current = null;
}
// 如果剩余单指,重新记录拖拽初始状态
if (pointers.current.size === 1) {
const remaining = Array.from(pointers.current.values())[0];
const remainingId = Array.from(pointers.current.keys())[0];
if (remaining && remainingId !== undefined) {
dragStartRef.current = {
pointerId: remainingId,
startX: remaining.x,
startY: remaining.y,
startTransform: { ...transformRef.current },
};
}
} else {
dragStartRef.current = null;
}
},
[]
);
// 双击重置
const handleDoubleClick = useCallback(() => {
resetTransform();
}, [resetTransform]);
// 容器尺寸:基于设计稿尺寸的 90%
const containerSize = useMemo(
() => Math.min(DESIGN_WIDTH, DESIGN_HEIGHT) * 0.9,
[DESIGN_WIDTH, DESIGN_HEIGHT]
);
// 视觉对齐样式:确保与 App 内容对齐
const overlayStyle = useMemo<React.CSSProperties>(
() => ({
position: "fixed",
inset: 0,
zIndex: 9999,
pointerEvents: "none",
}),
[]
);
const scaledContainerStyle = useMemo<React.CSSProperties>(
() => ({
position: "absolute",
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`,
top: `${offsetY}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
overflow: "hidden",
}),
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
);
if (!mounted) return null;
const content = (
<AnimatePresence>
{visible && (
<motion.div
key="exhibit-overlay"
style={overlayStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<div style={scaledContainerStyle}>
<div
className={cn(
"absolute inset-0 flex items-center justify-center",
"bg-black/70",
className
)}
style={{ pointerEvents: "auto" }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
{/* 返回按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="返回"
className="absolute top-5 right-5 z-20"
button
/>
)}
<div className="relative z-10 w-full h-full">
{/* 展品描述播报 - 固定在左侧 */}
{description && (
<ExhibitDescriptionNarration
text={description}
visible={visible}
autoPlay={autoPlayDescription}
className="absolute left-30 top-1/2 -translate-y-1/2"
/>
)}
{/* 展品内容 - 在剩余空间中居中 */}
<div
className="absolute top-1/2 -translate-y-1/2"
style={{
// 有描述时:从描述右侧30px开始,到右边界,在这个区域内居中
left: description ? "520px" : "0", // 30 + 450 + 30
right: "0",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
ref={containerRef}
className="relative flex items-center justify-center"
style={{
maxWidth: containerSize,
maxHeight: containerSize,
width: "100%",
height: "100%",
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onWheel={handleWheel}
onDoubleClick={handleDoubleClick}
>
<div
ref={viewElRef}
className="max-w-full max-h-full select-none relative"
style={{
transform: "translate3d(0px, 0px, 0) scale(1)",
transformOrigin: "0 0",
willChange: "transform",
// iOS 微信浏览器优化
WebkitTransform: "translate3d(0px, 0px, 0) scale(1)",
WebkitBackfaceVisibility: "hidden",
backfaceVisibility: "hidden",
// 强制硬件加速
WebkitPerspective: "1000",
perspective: "1000",
}}
>
<Image
src={exhibitSrc}
alt="展品"
className="max-w-full max-h-full object-contain drop-shadow-2xl"
/>
{/* 可点击区域 */}
{clickableAreas &&
clickableAreas.map((area, index) => (
<div
key={index}
className="absolute cursor-pointer transition-all duration-200 hover:bg-white/10"
style={{
left: `${area.position.left}%`,
top: `${area.position.top}%`,
width: `${area.position.width}%`,
height: `${area.position.height}%`,
}}
onClick={(e) => {
e.stopPropagation();
area.onClick();
}}
onPointerDown={(e) => {
// 阻止拖拽事件
e.stopPropagation();
}}
/>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
// 直接使用 Portal 渲染到 document.body
return createPortal(content, document.body);
};
export default ExhibitOverlay;
+4
View File
@@ -0,0 +1,4 @@
export { default as ExhibitOverlay } from "./ExhibitOverlay";
export type { ExhibitOverlayProps } from "./ExhibitOverlay";
export { default as ExhibitDescriptionNarration } from "./ExhibitDescriptionNarration";
export type { ExhibitDescriptionNarrationProps } from "./ExhibitDescriptionNarration";