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:
zhongyidian committed 2026-03-05 13:50:47 +08:00
1 parent 5d00adc1a5
commit 76c67b492e
216 files changed
+11994 -13516

No files matched your search

+4 -1
View File
@@ -1,7 +1,10 @@
{
"permissions": {
"allow": [
"mcp__chrome-devtools__click"
"mcp__chrome-devtools__click",
"Bash(for i in 1 2 3 4 5 6 7 8)",
"Bash(do echo \"=== place$i import line ===\" grep -n \"useChenyunStore\\\\|saveChenyunStore\" \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/src/pages/chenyun/place/place$i.tsx\" echo \"=== place$i 继续前进 block ===\" grep -n -A5 \"继续前进\" \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/src/pages/chenyun/place/place$i.tsx\")",
"Bash(done)"
]
}
}
+1
View File
@@ -13,6 +13,7 @@ dist-ssr
*.local
public/video/*
public/words/*
public/audio/*
# Editor directories and files
.vscode/*
+8
View File
@@ -0,0 +1,8 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"endOfLine": "lf"
}
+12 -13
View File
@@ -1,28 +1,27 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { mergeConfig } from "vite";
import path from "path";
import type { StorybookConfig } from '@storybook/react-vite'
import { mergeConfig } from 'vite'
import path from 'path'
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
framework: {
name: "@storybook/react-vite",
name: '@storybook/react-vite',
options: {},
},
async viteFinal(config) {
return mergeConfig(config, {
resolve: {
alias: {
"@": path.resolve(__dirname, "../src"),
'@': path.resolve(__dirname, '../src'),
},
},
});
})
},
};
export default config;
}
export default config
+4 -5
View File
@@ -1,5 +1,5 @@
import type { Preview } from "@storybook/react";
import "../src/index.css";
import type { Preview } from '@storybook/react'
import '../src/index.css'
const preview: Preview = {
parameters: {
@@ -10,7 +10,6 @@ const preview: Preview = {
},
},
},
};
export default preview;
}
export default preview
+17
View File
@@ -35,10 +35,13 @@
"autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.6",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"storybook": "^8.4.7",
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3",
@@ -188,6 +191,8 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@pkgr/core": ["@pkgr/core@0.2.9", "https://registry.npmmirror.com/@pkgr/core/-/core-0.2.9.tgz", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
@@ -528,6 +533,10 @@
"eslint": ["eslint@9.39.3", "https://registry.npmmirror.com/eslint/-/eslint-9.39.3.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="],
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
"eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.5", "https://registry.npmmirror.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.12" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "https://registry.npmmirror.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="],
@@ -552,6 +561,8 @@
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-diff": ["fast-diff@1.3.0", "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
@@ -794,6 +805,10 @@
"prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.8.1", "https://registry.npmmirror.com/prettier/-/prettier-3.8.1.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "https://registry.npmmirror.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="],
"pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"process": ["process@0.11.10", "https://registry.npmmirror.com/process/-/process-0.11.10.tgz", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
@@ -880,6 +895,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"synckit": ["synckit@0.11.12", "https://registry.npmmirror.com/synckit/-/synckit-0.11.12.tgz", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwindcss": ["tailwindcss@4.2.1", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.1.tgz", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
+24 -19
View File
@@ -1,33 +1,38 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
export default tseslint.config(
{ ignores: ["dist", "node_modules", "vite.config.ts"] },
{ ignores: ['dist', 'node_modules', 'vite.config.ts'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
...tseslint.configs.recommended,
eslintPluginPrettierRecommended, // Prettier + ESLint 整合
],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-unused-vars': [
'warn',
{ varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
],
"@typescript-eslint/no-unused-vars": [
"warn",
{ varsIgnorePattern: "^_", argsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "off",
'@typescript-eslint/no-explicit-any': 'off',
// 禁用可能与 Prettier 冲突的 ESLint 规则
quotes: 'off',
semi: 'off',
},
}
);
)
+6
View File
@@ -11,6 +11,9 @@
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
@@ -47,10 +50,13 @@
"autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.6",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"storybook": "^8.4.7",
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3",
+17 -17
View File
@@ -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
+13 -14
View File
@@ -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>
);
};
)
}
+4 -4
View File
@@ -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)
}
+3 -3
View File
@@ -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'
+27 -27
View File
@@ -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',
},
};
}
+43 -51
View File
@@ -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 }
+13 -14
View File
@@ -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
+2 -2
View File
@@ -1,2 +1,2 @@
export * from "./button";
export * from "./button.types";
export * from './button'
export * from './button.types'
+40 -57
View File
@@ -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,20 +119,12 @@ 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 && (
{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 src={getClueHintImage(selectedClue.id)!} className="h-full" />
{/* 关闭按钮 */}
<Image
className="absolute top-0 right-0 z-30"
@@ -154,9 +139,7 @@ export const ClueBookModal = ({
{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>
);
};
)
}
+48 -58
View File
@@ -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>
);
};
)
}
+4 -4
View File
@@ -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'
+42 -50
View File
@@ -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
+198 -210
View File
@@ -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
+4 -4
View File
@@ -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'
+76 -95
View File
@@ -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 }
+59 -60
View File
@@ -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
}
+2 -2
View File
@@ -1,2 +1,2 @@
export { GuideSteps, GuideStepsItem } from "./GuideSteps";
export { StudyGuideModal } from "./StudyGuideModal";
export { GuideSteps, GuideStepsItem } from './GuideSteps'
export { StudyGuideModal } from './StudyGuideModal'
+47 -51
View File
@@ -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>
);
)
}
+17 -17
View File
@@ -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
}
+2 -2
View File
@@ -1,2 +1,2 @@
export * from "./image";
export * from "./image.types";
export * from './image'
export * from './image.types'
+32 -37
View File
@@ -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'
+131 -148
View File
@@ -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,10 +323,7 @@ const ResidenceLayoutContent = () => {
)}
{/* 碎片显示 - 用于飞行动画,显示在屏幕正中间(仅支持拼图功能的故居显示) */}
{hasPuzzleFeature &&
showPuzzlePiece &&
puzzlePieceId &&
residence && (
{hasPuzzleFeature && showPuzzlePiece && puzzlePieceId && residence && (
<PuzzlePiecePortal
ref={puzzlePieceRef}
residence={residence}
@@ -354,7 +337,7 @@ const ResidenceLayoutContent = () => {
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
+21 -27
View File
@@ -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
+15 -20
View File
@@ -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
+18 -30
View File
@@ -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
+13 -13
View File
@@ -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,
+31 -44
View File
@@ -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
+111 -126
View File
@@ -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"
? '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-[#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]"
? '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-[#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
+19 -29
View File
@@ -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
+8 -8
View File
@@ -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'
+73 -73
View File
@@ -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
}
+94 -124
View File
@@ -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) => {
let placeholderIndex = 0
let foundNiHao = false
return letterContent.split('\n').map((line, lineIndex) => {
// 检测是否已经找到"您好"行
if (line.includes("您好")) {
foundNiHao = true;
if (line.includes('您好')) {
foundNiHao = true
}
// "您好"行之后的非空行需要首行缩进
const needsIndent =
foundNiHao &&
line.trim() !== "" &&
!line.includes("您好");
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split("____");
const segments = line.split('____')
return (
<div
key={lineIndex}
className="mb-6"
style={needsIndent ? { textIndent: "2em" } : {}}
style={needsIndent ? { textIndent: '2em' } : {}}
>
{segments.map((part, partIndex) => {
const isLast =
partIndex === segments.length - 1;
const isLast = partIndex === segments.length - 1
if (isLast) {
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
}
const currentIndex = placeholderIndex++;
const currentIndex = placeholderIndex++
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
<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]"
? 'border-red-500'
: 'border-[#4e402e]'
}`}
value={blankValues[currentIndex] ?? ""}
value={blankValues[currentIndex] ?? ''}
onChange={(e) => {
const next = [...blankValues];
next[currentIndex] = e.target.value;
setBlankValues(next);
const next = [...blankValues]
next[currentIndex] = e.target.value
setBlankValues(next)
// 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors];
nextErrs[currentIndex] = false;
setBlankErrors(nextErrs);
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("您好");
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split("____");
const segments = line.split('____')
return (
<div
key={lineIndex}
className="mb-6"
style={needsIndent ? { textIndent: "2em" } : {}}
style={needsIndent ? { textIndent: '2em' } : {}}
>
{segments.map((part, partIndex) => {
const isLast =
partIndex === segments.length - 1;
const isLast = partIndex === segments.length - 1
if (isLast) {
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
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}`}
>
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{value || "  "}
{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
+120 -164
View File
@@ -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,53 +186,34 @@ 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) => {
let placeholderIndex = 0
let foundNiHao = false
return letterContent.split('\n').map((line, lineIndex) => {
// 检测是否已经找到"您好"行
if (line.includes("您好")) {
foundNiHao = true;
if (line.includes('您好')) {
foundNiHao = true
}
// "您好"行之后的非空行需要首行缩进
const needsIndent =
foundNiHao &&
line.trim() !== "" &&
!line.includes("您好");
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split("____");
const segments = line.split('____')
return (
<div
key={lineIndex}
style={needsIndent ? { textIndent: "2em" } : {}}
>
<div key={lineIndex} style={needsIndent ? { textIndent: '2em' } : {}}>
{segments.map((part, partIndex) => {
const isLast =
partIndex === segments.length - 1;
const isLast = partIndex === segments.length - 1
if (isLast) {
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
}
const currentIndex = placeholderIndex++;
const isLimitedFill =
currentIndex === 0 || currentIndex === 3; // 填空1和填空4为限填
const currentIndex = placeholderIndex++
const isLimitedFill = currentIndex === 0 || currentIndex === 3 // 填空1和填空4为限填
const options =
currentIndex === 0
? BLANK_1_OPTIONS
: BLANK_4_OPTIONS;
currentIndex === 0 ? BLANK_1_OPTIONS : BLANK_4_OPTIONS
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{isLimitedFill ? (
// 限填:下拉选择
@@ -246,19 +223,17 @@ const LetterToHuangyanpei = ({
value: opt,
label: opt,
}))}
value={
blankValues[currentIndex] ?? ""
}
value={blankValues[currentIndex] ?? ''}
onChange={(selectedValue) => {
const next = [...blankValues];
next[currentIndex] = selectedValue;
setBlankValues(next);
const next = [...blankValues]
next[currentIndex] = selectedValue
setBlankValues(next)
// 用户再次选择时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors];
nextErrs[currentIndex] = false;
setBlankErrors(nextErrs);
const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false
setBlankErrors(nextErrs)
}
}}
placeholder="请选择"
@@ -272,39 +247,36 @@ const LetterToHuangyanpei = ({
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]"
? 'border-red-500'
: 'border-[#4e402e]'
}`}
autoComplete="off"
value={
blankValues[currentIndex] ?? ""
}
value={blankValues[currentIndex] ?? ''}
maxLength={20}
onChange={(e) => {
const rawValue = e.target.value;
const rawValue = e.target.value
// 应用敏感词过滤
const filteredValue =
filterInput(rawValue);
const filteredValue = filterInput(rawValue)
const next = [...blankValues];
next[currentIndex] = filteredValue;
setBlankValues(next);
const next = [...blankValues]
next[currentIndex] = filteredValue
setBlankValues(next)
// 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors];
nextErrs[currentIndex] = false;
setBlankErrors(nextErrs);
const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false
setBlankErrors(nextErrs)
}
}}
/>
)}
</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("您好");
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split("____");
const segments = line.split('____')
return (
<div
key={lineIndex}
style={needsIndent ? { textIndent: "2em" } : {}}
>
<div key={lineIndex} style={needsIndent ? { textIndent: '2em' } : {}}>
{segments.map((part, partIndex) => {
const isLast =
partIndex === segments.length - 1;
const isLast = partIndex === segments.length - 1
if (isLast) {
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
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}`}
>
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{value || "  "}
{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
+117 -154
View File
@@ -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,54 +179,38 @@ 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) => {
let placeholderIndex = 0
let foundNiHao = false
return letterContent.split('\n').map((line, lineIndex) => {
// 检测是否已经找到"您好"行
if (line.includes("您好")) {
foundNiHao = true;
if (line.includes('您好')) {
foundNiHao = true
}
// "您好"行之后的非空行需要首行缩进
const needsIndent =
foundNiHao &&
line.trim() !== "" &&
!line.includes("您好");
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split("____");
const segments = line.split('____')
return (
<div
key={lineIndex}
className="mb-2"
style={needsIndent ? { textIndent: "2em" } : {}}
style={needsIndent ? { textIndent: '2em' } : {}}
>
{segments.map((part, partIndex) => {
const isLast =
partIndex === segments.length - 1;
const isLast = partIndex === segments.length - 1
if (isLast) {
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
}
const currentIndex = placeholderIndex++;
const isLimitedFill =
currentIndex === 0 || currentIndex === 1; // 填空1和填空2为限填
const currentIndex = placeholderIndex++
const isLimitedFill = currentIndex === 0 || currentIndex === 1 // 填空1和填空2为限填
const options =
currentIndex === 0
? BLANK_1_OPTIONS
: BLANK_2_OPTIONS;
currentIndex === 0 ? BLANK_1_OPTIONS : BLANK_2_OPTIONS
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{isLimitedFill ? (
// 限填:下拉选择
@@ -240,19 +220,17 @@ const LetterToLuxun = ({
value: opt,
label: opt,
}))}
value={
blankValues[currentIndex] ?? ""
}
value={blankValues[currentIndex] ?? ''}
onChange={(selectedValue) => {
const next = [...blankValues];
next[currentIndex] = selectedValue;
setBlankValues(next);
const next = [...blankValues]
next[currentIndex] = selectedValue
setBlankValues(next)
// 用户再次选择时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors];
nextErrs[currentIndex] = false;
setBlankErrors(nextErrs);
const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false
setBlankErrors(nextErrs)
}
}}
placeholder="请选择"
@@ -266,32 +244,30 @@ const LetterToLuxun = ({
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]"
? 'border-red-500'
: 'border-[#4e402e]'
}`}
value={
blankValues[currentIndex] ?? ""
}
value={blankValues[currentIndex] ?? ''}
onChange={(e) => {
const next = [...blankValues];
next[currentIndex] = e.target.value;
setBlankValues(next);
const next = [...blankValues]
next[currentIndex] = e.target.value
setBlankValues(next)
// 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors];
nextErrs[currentIndex] = false;
setBlankErrors(nextErrs);
const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false
setBlankErrors(nextErrs)
}
}}
/>
)}
</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("您好");
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split("____");
const segments = line.split('____')
return (
<div
key={lineIndex}
className="mb-2"
style={needsIndent ? { textIndent: "2em" } : {}}
style={needsIndent ? { textIndent: '2em' } : {}}
>
{segments.map((part, partIndex) => {
const isLast =
partIndex === segments.length - 1;
const isLast = partIndex === segments.length - 1
if (isLast) {
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
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}`}
>
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{value || "  "}
{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
+120 -150
View File
@@ -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,68 +186,53 @@ 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) => {
let placeholderIndex = 0
let foundNiHao = false
return letterContent.split('\n').map((line, lineIndex) => {
// 检测是否已经找到"您好"行
if (line.includes("您好")) {
foundNiHao = true;
if (line.includes('您好')) {
foundNiHao = true
}
// "您好"行之后的非空行需要首行缩进
const needsIndent =
foundNiHao &&
line.trim() !== "" &&
!line.includes("您好");
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split("____");
const segments = line.split('____')
return (
<div
key={lineIndex}
className="mb-2"
style={needsIndent ? { textIndent: "2em" } : {}}
style={needsIndent ? { textIndent: '2em' } : {}}
>
{segments.map((part, partIndex) => {
const isLast =
partIndex === segments.length - 1;
const isLast = partIndex === segments.length - 1
if (isLast) {
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
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}`}
>
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{isSelectFill ? (
// 前三个空:下拉选择
<Select
className="inline"
options={SELECT_OPTIONS}
value={
blankValues[currentIndex] ?? ""
}
value={blankValues[currentIndex] ?? ''}
onChange={(selectedValue) => {
const next = [...blankValues];
next[currentIndex] = selectedValue;
setBlankValues(next);
const next = [...blankValues]
next[currentIndex] = selectedValue
setBlankValues(next)
// 清除错误状态
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors];
nextErrs[currentIndex] = false;
setBlankErrors(nextErrs);
const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false
setBlankErrors(nextErrs)
}
// 立即验证选择是否正确
@@ -288,32 +273,30 @@ const LetterToSunzhongshan = ({
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]"
? 'border-red-500'
: 'border-[#4e402e]'
}`}
value={
blankValues[currentIndex] ?? ""
}
value={blankValues[currentIndex] ?? ''}
onChange={(e) => {
const next = [...blankValues];
next[currentIndex] = e.target.value;
setBlankValues(next);
const next = [...blankValues]
next[currentIndex] = e.target.value
setBlankValues(next)
// 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors];
nextErrs[currentIndex] = false;
setBlankErrors(nextErrs);
const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false
setBlankErrors(nextErrs)
}
}}
/>
)}
</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("您好");
foundNiHao && line.trim() !== '' && !line.includes('您好')
const segments = line.split("____");
const segments = line.split('____')
return (
<div
key={lineIndex}
className="mb-2"
style={needsIndent ? { textIndent: "2em" } : {}}
style={needsIndent ? { textIndent: '2em' } : {}}
>
{segments.map((part, partIndex) => {
const isLast =
partIndex === segments.length - 1;
const isLast = partIndex === segments.length - 1
if (isLast) {
return (
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
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}`}
>
<span key={`part-${lineIndex}-${partIndex}`}>
{part}
{value || "  "}
{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
+8 -8
View File
@@ -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'
+56 -56
View File
@@ -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
+2 -2
View File
@@ -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'
+49 -58
View File
@@ -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
View File
@@ -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
+4 -4
View File
@@ -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'
+114 -120
View File
@@ -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
+13 -13
View File
@@ -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
+32 -32
View File
@@ -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,17 +61,17 @@ 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,
visibility: 'visible' as const,
}
: {
opacity: 1,
visibility: "visible" as const,
visibility: 'visible' as const,
}
}
transition={{
@@ -87,7 +87,7 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
},
opacity: {
duration: 0.6,
ease: "easeOut",
ease: 'easeOut',
},
}}
>
@@ -100,10 +100,10 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
</motion.div>
</div>,
document.body
);
)
}
);
)
PuzzlePiecePortal.displayName = "PuzzlePiecePortal";
PuzzlePiecePortal.displayName = 'PuzzlePiecePortal'
export default PuzzlePiecePortal;
export default PuzzlePiecePortal
+78 -88
View File
@@ -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,26 +296,22 @@ 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) => (
{selectedPieceData.title.split(' ').map((word: string, index: number) => (
<div key={index}>
{word}
{index <
selectedPieceData.title.split(" ").length - 1 &&
" "}
{index < selectedPieceData.title.split(' ').length - 1 && ' '}
</div>
))}
</div>
@@ -327,9 +319,7 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
{/* 对应页面图片 */}
<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
+5 -6
View File
@@ -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 className="text-sm text-gray-600">当前填充: {JSON.stringify(value)}</div>
</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)
}}
/>
);
)
},
};
}
+180 -237
View File
@@ -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
}
+3 -4
View File
@@ -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'
+6 -18
View File
@@ -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'
+3 -7
View File
@@ -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
}
+3 -7
View File
@@ -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 className="text-sm text-gray-600">当前选中: {value || '未选择'}</div>
</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
}
+3 -4
View File
@@ -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
View File
@@ -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
+15 -15
View File
@@ -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
}
+63 -74
View File
@@ -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
View File
@@ -1 +1 @@
export { default as Spotlight } from "./Spotlight";
export { default as Spotlight } from './Spotlight'
+31 -31
View File
@@ -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
}
+14 -23
View File
@@ -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 className={cn('text-[#5C3A1E] text-5xl font-bold', subtitleClassName)}>{subtitle}</div>
</div>
</div>
);
};
)
}
export { Title };
export { Title }
+3 -8
View File
@@ -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'
File diff suppressed because it is too large. Load diff
+20 -21
View File
@@ -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
View File
@@ -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
}
+9 -12
View File
@@ -5,9 +5,9 @@
export interface PuzzlePathConfig {
/** viewBox */
viewBox: string;
viewBox: string
/** 每个碎片的路径数据 */
pieces: Record<number, string>;
pieces: Record<number, string>
}
/**
@@ -17,7 +17,7 @@ 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
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
@@ -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'
}
+11 -15
View File
@@ -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;
};
+10 -15
View File
@@ -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;
};
+46 -56
View File
@@ -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
}
+16 -16
View File
@@ -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
View File
@@ -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'
+45 -45
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
}
}
+141 -148
View File
@@ -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>) => {
const handlePointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
// 释放指针捕获
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
}
},
[]
);
}, [])
// 双击重置
const handleDoubleClick = useCallback(() => {
resetTransform();
}, [resetTransform]);
resetTransform()
}, [resetTransform])
return {
containerRef: containerRef as React.RefObject<HTMLDivElement>,
@@ -370,5 +363,5 @@ export const useZoomAndDrag = (
handlePointerMove,
handlePointerUp,
handleDoubleClick,
};
};
}
}
-127
View File
@@ -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);
}
}
}
+233
View File
@@ -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)
}
}
}
+25 -28
View File
@@ -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}",
{
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}`
}
+17 -19
View File
@@ -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
}
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff. Show more