feat: 更新包管理器为 bun,优化项目配置和资源管理
- 将包管理器从 pnpm 更改为 bun,提升依赖管理效率。 - 在 index.html 中引入 jQuery 和 videoajax.js,增强页面功能。 - 删除 pnpm-lock.yaml 文件,清理不必要的锁定文件。 - 在多个组件中优化状态管理和访问逻辑,提升用户体验。 - 更新多个页面和组件以支持新的状态管理方式,确保数据一致性。
This commit is contained in:
1 parent
ac5a2159a1
commit
7bbeb10e57
33 files changed
+2291
-4531
No files matched your search
@@ -0,0 +1,353 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 getProgressColor = () => {
|
||||
if (progressPercent >= 100) {
|
||||
return "#90B86B"; // 浅橄榄绿色(100%完成)
|
||||
} else if (progressPercent >= 50) {
|
||||
return "#EEB362"; // 橙色(50%-99%)
|
||||
} else {
|
||||
return "#E0E0E0"; // 浅灰色(0%-49%)
|
||||
}
|
||||
};
|
||||
|
||||
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 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" }}>
|
||||
{/* 外层:白色边框(使用 clip-path 裁剪形状) */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
clipPath: outerHexagonPath,
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
/>
|
||||
{/* 内容容器(边框内部区域) */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${borderWidth}px`,
|
||||
top: `${borderWidth}px`,
|
||||
right: `${borderWidth}px`,
|
||||
bottom: `${borderWidth}px`,
|
||||
clipPath: innerHexagonPath,
|
||||
}}
|
||||
>
|
||||
{/* 背景层:深灰色背景(未填充部分) */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundColor: "#757575",
|
||||
}}
|
||||
/>
|
||||
{/* 填充层(根据进度百分比从左侧填充) */}
|
||||
{progressPercent > 0 && (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 overflow-hidden"
|
||||
style={{
|
||||
width: `${progressPercent}%`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundColor: fillColor,
|
||||
transition: "width 0.3s ease, background-color 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 文字(白色,居中,始终显示在最上层,带阴影效果) */}
|
||||
<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)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{progressText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface MenuPuzzleCenterProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 选择某个故居后的回调(进入对应的碎片系统) */
|
||||
onSelectResidence: (residence: string) => void;
|
||||
}
|
||||
|
||||
interface ResidenceConfig {
|
||||
/** 拼图存储用的英文 key(如 chenyun) */
|
||||
residence: string;
|
||||
/** 显示名称 */
|
||||
displayName: string;
|
||||
/** 在大地图上的水平位置(百分比字符串,如 "20%") */
|
||||
left: string;
|
||||
/** 在大地图上的垂直位置(百分比字符串,如 "40%") */
|
||||
top: string;
|
||||
/** 显示用图片路径(相对于 puzzle/home/ 目录) */
|
||||
image: string;
|
||||
/** 是否已经开放拼图(未开放时只展示,不可点击) */
|
||||
hasPuzzle: boolean;
|
||||
/** 是否有导图资源 */
|
||||
hasMap?: boolean;
|
||||
/** 导图资源文件名(位于 puzzle/home/map 下) */
|
||||
mapImage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层碎片管理中心中展示的故居列表(8 个故居)
|
||||
* left / top 为大致位置,后续可以根据视觉效果微调
|
||||
* image 资源都位于 puzzle/home 目录下,命名可按实际资源调整
|
||||
*/
|
||||
const RESIDENCE_LIST: ResidenceConfig[] = [
|
||||
{
|
||||
residence: "maozedong",
|
||||
displayName: "毛泽东旧居",
|
||||
left: "30%",
|
||||
top: "35%",
|
||||
image: "毛泽东",
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: "毛泽东导图",
|
||||
},
|
||||
{
|
||||
residence: "sunzhongshan",
|
||||
displayName: "孙中山故居",
|
||||
left: "12%",
|
||||
top: "51%",
|
||||
image: "孙中山",
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: "孙中山导图",
|
||||
},
|
||||
{
|
||||
residence: "luxun",
|
||||
displayName: "鲁迅故居",
|
||||
left: "26.3%",
|
||||
top: "74%",
|
||||
image: "鲁迅",
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: "鲁迅导图",
|
||||
},
|
||||
{
|
||||
residence: "huangyanpei",
|
||||
displayName: "黄炎培故居",
|
||||
left: "44%",
|
||||
top: "67%",
|
||||
image: "黄炎培",
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: "黄炎培导图",
|
||||
},
|
||||
{
|
||||
residence: "nieer",
|
||||
displayName: "聂耳旧居",
|
||||
left: "62%",
|
||||
top: "60%",
|
||||
image: "聂耳",
|
||||
hasPuzzle: true,
|
||||
hasMap: false,
|
||||
},
|
||||
{
|
||||
residence: "zhangleping",
|
||||
displayName: "张乐平故居",
|
||||
left: "73%",
|
||||
top: "33%",
|
||||
image: "张乐平",
|
||||
hasPuzzle: true,
|
||||
hasMap: false,
|
||||
},
|
||||
{
|
||||
residence: "rongdesheng",
|
||||
displayName: "荣德生旧居",
|
||||
left: "90%",
|
||||
top: "45%",
|
||||
image: "荣德生",
|
||||
hasPuzzle: true,
|
||||
hasMap: false,
|
||||
},
|
||||
{
|
||||
residence: "chenyun",
|
||||
displayName: "陈云故居",
|
||||
left: "80%",
|
||||
top: "76%",
|
||||
image: "陈云",
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: "陈云导图",
|
||||
},
|
||||
];
|
||||
|
||||
const MenuPuzzleCenter = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSelectResidence,
|
||||
}: MenuPuzzleCenterProps) => {
|
||||
const { getPuzzleCollectProgress } = usePuzzleStore();
|
||||
const [activeMapImage, setActiveMapImage] = useState<string | null>(null);
|
||||
|
||||
const residenceProgress = useMemo(
|
||||
() =>
|
||||
RESIDENCE_LIST.map((item) => ({
|
||||
...item,
|
||||
// 只有已开放拼图的故居才计算收集进度
|
||||
progress: item.hasPuzzle ? getPuzzleCollectProgress(item.residence) : 0,
|
||||
})),
|
||||
[getPuzzleCollectProgress]
|
||||
);
|
||||
|
||||
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)" }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-full h-full bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: getBackgroundImage("puzzle/home/底图") }}
|
||||
>
|
||||
{/* 箭头指示底图 */}
|
||||
<img className="absolute top-84 left-55" src={getStaticImage("puzzle/home/箭头指引")} alt="" />
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<Image
|
||||
className="absolute right-8 top-8 z-10"
|
||||
src={getStaticImage("components/关闭")}
|
||||
alt="关闭"
|
||||
button
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
|
||||
{/* 故居列表:按照设计图进行绝对定位 */}
|
||||
<div className="absolute inset-0">
|
||||
{residenceProgress.map(
|
||||
({
|
||||
residence,
|
||||
displayName,
|
||||
left,
|
||||
top,
|
||||
image,
|
||||
hasPuzzle,
|
||||
progress,
|
||||
hasMap,
|
||||
mapImage,
|
||||
}) => (
|
||||
<div
|
||||
key={residence}
|
||||
className={`absolute -translate-x-1/2 -translate-y-1/2`}
|
||||
style={{ left, top }}
|
||||
>
|
||||
{/* 故居图片(资源在 puzzle/home 下,可根据实际名称调整) */}
|
||||
<Image
|
||||
src={getStaticImage(`puzzle/home/${image}`)}
|
||||
alt={displayName}
|
||||
/>
|
||||
|
||||
{/* 进度展示,仅对已开放拼图的故居显示 */}
|
||||
{hasPuzzle && (
|
||||
<div className="mb-4 flex justify-center">
|
||||
<HexagonProgressBar progress={progress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<div className="flex justify-center gap-6">
|
||||
<Image src={getStaticImage(`puzzle/home/拼图`)} alt="拼图" button
|
||||
onClick={(e) => {
|
||||
if (!hasPuzzle) return;
|
||||
e.stopPropagation();
|
||||
onSelectResidence(residence);
|
||||
}} />
|
||||
<Image
|
||||
src={getStaticImage(`puzzle/home/导图`)}
|
||||
alt="导图"
|
||||
button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasMap || !mapImage) return;
|
||||
setActiveMapImage(mapImage);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 导图弹窗 */}
|
||||
{activeMapImage && (
|
||||
<div
|
||||
className="fixed inset-0 z-110 flex items-center justify-center"
|
||||
style={{ background: "rgba(0,0,0,0.75)" }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setActiveMapImage(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative w-full h-full">
|
||||
<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")}
|
||||
alt="关闭"
|
||||
button
|
||||
onClick={() => setActiveMapImage(null)}
|
||||
/>
|
||||
|
||||
<img
|
||||
src={getStaticImage(`puzzle/home/map/${activeMapImage}`)}
|
||||
alt="导图"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuPuzzleCenter;
|
||||
|
||||
|
||||
@@ -86,6 +86,22 @@ const PuzzlePieceSvg = ({
|
||||
className={cn(isUnlocked ? "puzzle-piece" : "")}
|
||||
>
|
||||
<defs>
|
||||
{/* Safari/iOS 对 SVG image 的 CSS filter 支持有限,使用滤镜定义保证生效 */}
|
||||
<filter
|
||||
id={`${patternId}-dim`}
|
||||
colorInterpolationFilters="sRGB"
|
||||
x="0%"
|
||||
y="0%"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
{/* 将亮度压低到约 20% */}
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0.2 0 0 0 0 0 0.2 0 0 0 0 0 0.2 0 0 0 0 0 1 0"
|
||||
/>
|
||||
</filter>
|
||||
|
||||
<pattern
|
||||
id={patternId}
|
||||
patternUnits="objectBoundingBox"
|
||||
@@ -98,8 +114,8 @@ const PuzzlePieceSvg = ({
|
||||
width={1}
|
||||
height={1}
|
||||
preserveAspectRatio="none"
|
||||
filter={isUnlocked ? undefined : `url(#${patternId}-dim)`}
|
||||
style={{
|
||||
filter: isUnlocked ? "none" : "brightness(0.2)",
|
||||
transition: "filter 220ms ease",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -90,12 +90,12 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
[allowDuplicate, currentValue]
|
||||
);
|
||||
|
||||
// 处理鼠标移动
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
// 通用的坐标更新逻辑(鼠标/触摸)
|
||||
const updateDragPosition = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
if (!draggingOption) return;
|
||||
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
setDragPosition({ x: clientX, y: clientY });
|
||||
|
||||
// 检测是否悬停在空白区域上
|
||||
let foundBlank: string | null = null;
|
||||
@@ -104,10 +104,10 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
blankRefsMap.current.forEach((element, blankId) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
) {
|
||||
foundBlank = blankId;
|
||||
}
|
||||
@@ -122,10 +122,10 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
) {
|
||||
foundBlank = blank.id;
|
||||
}
|
||||
@@ -138,8 +138,28 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
[draggingOption, normalizedBlanks]
|
||||
);
|
||||
|
||||
// 处理鼠标释放
|
||||
const handleMouseUp = useCallback(() => {
|
||||
// 处理鼠标移动
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
updateDragPosition(e.clientX, e.clientY);
|
||||
},
|
||||
[updateDragPosition]
|
||||
);
|
||||
|
||||
// 处理触摸移动
|
||||
const handleTouchMove = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
// 阻止页面滚动,确保拖拽顺滑
|
||||
e.preventDefault();
|
||||
updateDragPosition(touch.clientX, touch.clientY);
|
||||
},
|
||||
[updateDragPosition]
|
||||
);
|
||||
|
||||
// 处理释放(鼠标/触摸)
|
||||
const handleRelease = useCallback(() => {
|
||||
if (!draggingOption) return;
|
||||
|
||||
// 如果悬停在空白区域上,执行放置
|
||||
@@ -212,16 +232,29 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
useEffect(() => {
|
||||
if (draggingOption) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.addEventListener("mouseup", handleRelease);
|
||||
document.addEventListener("touchmove", handleTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener("touchend", handleRelease);
|
||||
document.addEventListener("touchcancel", handleRelease);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.removeEventListener("mouseup", handleRelease);
|
||||
document.removeEventListener("touchmove", handleTouchMove);
|
||||
document.removeEventListener("touchend", handleRelease);
|
||||
document.removeEventListener("touchcancel", handleRelease);
|
||||
};
|
||||
}
|
||||
}, [draggingOption, handleMouseMove, handleMouseUp]);
|
||||
}, [draggingOption, handleMouseMove, handleTouchMove, handleRelease]);
|
||||
|
||||
// 处理鼠标按下开始拖拽
|
||||
const handleMouseDown = (e: React.MouseEvent, optionValue: string) => {
|
||||
// 开始拖拽(通用)
|
||||
const startDragging = (
|
||||
optionValue: string,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
currentTarget: HTMLElement
|
||||
) => {
|
||||
if (disabled) return;
|
||||
const option = normalizedOptions.find((opt) => opt.value === optionValue);
|
||||
if (option?.disabled) return;
|
||||
@@ -230,10 +263,10 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
if (!allowDuplicate && isOptionUsed(optionValue)) return;
|
||||
|
||||
// 获取实际的元素(如果有自定义渲染,使用第一个子元素)
|
||||
const wrapperElement = e.currentTarget as HTMLElement;
|
||||
const actualElement = (
|
||||
renderOption ? wrapperElement.firstElementChild : wrapperElement
|
||||
) as HTMLElement;
|
||||
const wrapperElement = currentTarget;
|
||||
const actualElement = renderOption
|
||||
? (wrapperElement.firstElementChild as HTMLElement)
|
||||
: wrapperElement;
|
||||
|
||||
if (!actualElement) return;
|
||||
|
||||
@@ -241,12 +274,26 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
|
||||
// 记录偏移量和元素尺寸
|
||||
dragOffsetRef.current = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
x: clientX - rect.left,
|
||||
y: clientY - rect.top,
|
||||
};
|
||||
|
||||
setDraggingOption(optionValue);
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
setDragPosition({ x: clientX, y: clientY });
|
||||
};
|
||||
|
||||
// 处理鼠标按下开始拖拽
|
||||
const handleMouseDown = (e: React.MouseEvent, optionValue: string) => {
|
||||
startDragging(optionValue, e.clientX, e.clientY, e.currentTarget);
|
||||
};
|
||||
|
||||
// 处理触摸开始拖拽
|
||||
const handleTouchStart = (e: React.TouchEvent, optionValue: string) => {
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
// 防止滚动干扰
|
||||
e.preventDefault();
|
||||
startDragging(optionValue, touch.clientX, touch.clientY, e.currentTarget);
|
||||
};
|
||||
|
||||
// 移除填充
|
||||
@@ -288,6 +335,7 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
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",
|
||||
!disabled &&
|
||||
@@ -439,6 +487,11 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
handleMouseDown(e, option.value);
|
||||
}
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (!disabled && !option.disabled && !isUsed) {
|
||||
handleTouchStart(e, option.value);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
renderOption && "select-none",
|
||||
!optionClassName && renderOption && "flex-1",
|
||||
|
||||
Reference in new issue
Block a user