diff --git a/.claude/settings.local.json b/.claude/settings.local.json index edeafa9..0ae58e1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/.gitignore b/.gitignore index 2012493..dc49877 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist-ssr *.local public/video/* public/words/* +public/audio/* # Editor directories and files .vscode/* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..2991919 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "endOfLine": "lf" +} diff --git a/.storybook/main.ts b/.storybook/main.ts index b4803a4..922a4b8 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -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 diff --git a/.storybook/preview.ts b/.storybook/preview.ts index b6b9677..e1bb3a9 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -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 diff --git a/bun.lock b/bun.lock index 46d9477..5640b0c 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/eslint.config.ts b/eslint.config.ts index 70a39d5..f3e8d65 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -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', }, } -); +) diff --git a/package.json b/package.json index 0b7a360..edfafe0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index d27bfd5..0a4cb7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() {
Loading...
}> @@ -42,7 +42,7 @@ function AppContent() { - ); + ) } /** @@ -54,7 +54,7 @@ function App() { - ); + ) } -export default App; +export default App diff --git a/src/components/RotateHint.tsx b/src/components/RotateHint.tsx index f83f93f..2174091 100644 --- a/src/components/RotateHint.tsx +++ b/src/components/RotateHint.tsx @@ -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 (
请横屏观看,以获得最佳体验
- ); + ) } - diff --git a/src/components/animation/FlyToAnimationManager.tsx b/src/components/animation/FlyToAnimationManager.tsx index 584067a..c04d1dc 100644 --- a/src/components/animation/FlyToAnimationManager.tsx +++ b/src/components/animation/FlyToAnimationManager.tsx @@ -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; + fromRef: React.RefObject /** 目标元素的 ref */ - toRef: React.RefObject; + toRef: React.RefObject /** 目标位置(相对于目标元素) */ - 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 = new Map(); - private containerRoot: Root | null = null; + private animations: Map = 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 { ))} - ); + ) } // 通知更新 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 ( -
+
- ); -}; + ) +} diff --git a/src/components/animation/flyToAnimation.ts b/src/components/animation/flyToAnimation.ts index b49c99b..a6f9e65 100644 --- a/src/components/animation/flyToAnimation.ts +++ b/src/components/animation/flyToAnimation.ts @@ -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) +} diff --git a/src/components/animation/index.ts b/src/components/animation/index.ts index d063fbc..69d47c8 100644 --- a/src/components/animation/index.ts +++ b/src/components/animation/index.ts @@ -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' diff --git a/src/components/button/button.stories.tsx b/src/components/button/button.stories.tsx index 21e7b59..7cdf53e 100644 --- a/src/components/button/button.stories.tsx +++ b/src/components/button/button.stories.tsx @@ -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 = { - 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; +type Story = StoryObj 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 = { />
), -}; +} 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', }, -}; +} diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx index ca26c94..a145995 100644 --- a/src/components/button/button.tsx +++ b/src/components/button/button.tsx @@ -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( ( { 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.displayName = "Button"; +Button.displayName = 'Button' /** * 图片按钮组件 @@ -57,50 +52,50 @@ const ImageButton = forwardRef( 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) => { - setIsHovered(true); + setIsHovered(true) if (hoverSrc) { - setImageSrc(hoverSrc); + setImageSrc(hoverSrc) } - onMouseEnter?.(e); - }; + onMouseEnter?.(e) + } const handleMouseLeave = (e: React.MouseEvent) => { - setIsHovered(false); - setImageSrc(src); - onMouseLeave?.(e); - }; + setIsHovered(false) + setImageSrc(src) + onMouseLeave?.(e) + } return ( - ); + ) } -); +) -ImageButton.displayName = "ImageButton"; +ImageButton.displayName = 'ImageButton' -export { Button, ImageButton }; +export { Button, ImageButton } diff --git a/src/components/button/button.types.ts b/src/components/button/button.types.ts index a486586..c1ad14a 100644 --- a/src/components/button/button.types.ts +++ b/src/components/button/button.types.ts @@ -1,30 +1,29 @@ -import type { ButtonHTMLAttributes, ReactNode } from "react"; +import type { ButtonHTMLAttributes, ReactNode } from 'react' /** 基础按钮属性 */ -export interface BaseButtonProps - extends ButtonHTMLAttributes { +export interface BaseButtonProps extends ButtonHTMLAttributes { /** 是否显示加载状态 */ - 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 { +export interface ImageButtonProps extends Omit { /** 图片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 diff --git a/src/components/button/index.ts b/src/components/button/index.ts index 42049c6..ff844e3 100644 --- a/src/components/button/index.ts +++ b/src/components/button/index.ts @@ -1,2 +1,2 @@ -export * from "./button"; -export * from "./button.types"; +export * from './button' +export * from './button.types' diff --git a/src/components/clue/ClueBookModal.tsx b/src/components/clue/ClueBookModal.tsx index 0e62372..c23cf96 100644 --- a/src/components/clue/ClueBookModal.tsx +++ b/src/components/clue/ClueBookModal.tsx @@ -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(null); - const [showClueHint, setShowClueHint] = useState(false); + const [selectedClueId, setSelectedClueId] = useState(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 (
@@ -67,9 +67,7 @@ export const ClueBookModal = ({
@@ -81,8 +79,8 @@ export const ClueBookModal = ({
暂无线索
) : ( clues.map((clue) => { - const isSelected = clue.id === selectedClueId; - const isCollected = clue.collected; + const isSelected = clue.id === selectedClueId + const isCollected = clue.collected return (
{ if (isCollected) { - setSelectedClueId(clue.id); + setSelectedClueId(clue.id) } }} > {/* 线索缩略图 - 带倾斜效果 */}
- ); + ) }) )}
@@ -126,37 +119,27 @@ export const ClueBookModal = ({
{/* 显示线索详情图片 */}
- + {/* 线索提示描述(覆盖在详情图片上) */} - {showClueHint && - hasClueHint(selectedClue.id) && - getClueHintImage && ( -
-
- - {/* 关闭按钮 */} - setShowClueHint(false)} - /> -
+ {showClueHint && hasClueHint(selectedClue.id) && getClueHintImage && ( +
+
+ + {/* 关闭按钮 */} + setShowClueHint(false)} + />
- )} +
+ )} {/* 线索提示按钮(右下角) */} {hasClueHint(selectedClue.id) && (
setShowClueHint(true)} /> @@ -166,7 +149,7 @@ export const ClueBookModal = ({
) : (
- {selectedClue ? "该线索尚未收集" : "请选择一个线索查看详情"} + {selectedClue ? '该线索尚未收集' : '请选择一个线索查看详情'}
)}
@@ -181,5 +164,5 @@ export const ClueBookModal = ({ />
- ); -}; + ) +} diff --git a/src/components/clue/ClueHintModal.tsx b/src/components/clue/ClueHintModal.tsx index 45820da..1e65af5 100644 --- a/src/components/clue/ClueHintModal.tsx +++ b/src/components/clue/ClueHintModal.tsx @@ -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; + visible: boolean + backgroundImage: string + clues: ClueHint[] + onClose: () => void + sourceContainerRef?: React.RefObject } /** @@ -31,65 +31,55 @@ export const ClueHintModal = ({ onClose, sourceContainerRef, }: ClueHintModalProps) => { - const modalContainerRef = useRef(null); - const backgroundRef = useRef(null); - const [scale, setScale] = useState(1); - const [size, setSize] = useState({ width: "100%", height: "100%" }); + const modalContainerRef = useRef(null) + const backgroundRef = useRef(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 ( -
+
{/* 蒙版背景 */} -
+
{/* 背景图片容器 - 使用缩放匹配源容器尺寸并居中 */}
{/* 线索标记 */} {clues.map( (clue) => - clue.position.width !== "0%" && ( + clue.position.width !== '0%' && (
- ); -}; + ) +} diff --git a/src/components/clue/index.ts b/src/components/clue/index.ts index 0188cb9..3a760b7 100644 --- a/src/components/clue/index.ts +++ b/src/components/clue/index.ts @@ -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' diff --git a/src/components/cursor/index.tsx b/src/components/cursor/index.tsx index b80331e..4d1032f 100644 --- a/src/components/cursor/index.tsx +++ b/src/components/cursor/index.tsx @@ -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 (
{!read && ( )}
- ); -}; + ) +} -export default Cursor; +export default Cursor diff --git a/src/components/exhibit/ExhibitDescriptionNarration.tsx b/src/components/exhibit/ExhibitDescriptionNarration.tsx index de8a2fb..a013057 100644 --- a/src/components/exhibit/ExhibitDescriptionNarration.tsx +++ b/src/components/exhibit/ExhibitDescriptionNarration.tsx @@ -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 ( @@ -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', }} />
@@ -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="播放" > - + @@ -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="停止" > - + @@ -137,7 +124,7 @@ const ExhibitDescriptionNarration = ({ )} - ); -}; + ) +} -export default ExhibitDescriptionNarration; +export default ExhibitDescriptionNarration diff --git a/src/components/exhibit/ExhibitOverlay.tsx b/src/components/exhibit/ExhibitOverlay.tsx index 556a24b..dda18aa 100644 --- a/src/components/exhibit/ExhibitOverlay.tsx +++ b/src/components/exhibit/ExhibitOverlay.tsx @@ -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) => { - e.stopPropagation(); - onClose?.(); - }; + e.stopPropagation() + onClose?.() + } // ====== 缩放 / 拖拽状态 ====== - const containerRef = useRef(null); - const viewElRef = useRef(null); - const frameRef = useRef(null); - const pointers = useRef>(new Map()); - const lastDistance = useRef(null); + const containerRef = useRef(null) + const viewElRef = useRef(null) + const frameRef = useRef(null) + const pointers = useRef>(new Map()) + const lastDistance = useRef(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) => { - 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) => { - 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) => { - 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) => { - 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) => { - 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( () => ({ - position: "fixed", + position: 'fixed', inset: 0, zIndex: 9999, - pointerEvents: "none", + pointerEvents: 'none', }), [] - ); + ) const scaledContainerStyle = useMemo( () => ({ - 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 = ( @@ -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' }} >
{/* 背景蒙版 */} {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', }} >
)} - ); + ) // 直接使用 Portal 渲染到 document.body - return createPortal(content, document.body); -}; + return createPortal(content, document.body) +} -export default ExhibitOverlay; +export default ExhibitOverlay diff --git a/src/components/exhibit/index.ts b/src/components/exhibit/index.ts index 565678b..85e9ecd 100644 --- a/src/components/exhibit/index.ts +++ b/src/components/exhibit/index.ts @@ -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' diff --git a/src/components/guide/GuideSteps.tsx b/src/components/guide/GuideSteps.tsx index d89a2c9..85c7170 100644 --- a/src/components/guide/GuideSteps.tsx +++ b/src/components/guide/GuideSteps.tsx @@ -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"; * */ 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(child) && - child.type === GuideStepsItem - ) { - const hasAudio = !!child.props.audioSrc; + if (isValidElement(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 && ( {/* 引导板容器和背景 - 作为整体切换,确保 padding 和背景同步 */} @@ -201,13 +184,13 @@ const GuideSteps = ({ {/* 背景层 */}
{steps[currentStep]} @@ -241,9 +224,7 @@ const GuideSteps = ({ > 继续 - - → - + →
)} @@ -254,7 +235,7 @@ const GuideSteps = ({ )} - ); + ) // 始终使用 Overlay 包裹,showMask 由 Overlay 处理 return ( @@ -266,11 +247,11 @@ const GuideSteps = ({ > {guideStepsContent} - ); -}; + ) +} const GuideStepsItem = ({ children }: GuideStepsItemProps) => { - return <>{children}; -}; + return <>{children} +} -export { GuideSteps, GuideStepsItem }; +export { GuideSteps, GuideStepsItem } diff --git a/src/components/guide/StudyGuideModal.tsx b/src/components/guide/StudyGuideModal.tsx index d31aa68..448b6af 100644 --- a/src/components/guide/StudyGuideModal.tsx +++ b/src/components/guide/StudyGuideModal.tsx @@ -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 = (
{/* 背景遮罩 */}
@@ -127,7 +126,7 @@ export const StudyGuideModal = ({
{/* 引导框背景 */} 学习引导背景 @@ -135,9 +134,9 @@ export const StudyGuideModal = ({ {/* 文本内容 */}
{/* {title && ( @@ -148,11 +147,11 @@ export const StudyGuideModal = ({ {children && (
{children} @@ -163,7 +162,7 @@ export const StudyGuideModal = ({ {/* 继续按钮 */}
继续
- ); + ) // 当 zIndex >= 10000 时,使用 Portal 渲染到 document.body,并应用缩放 if (zIndex >= 10000) { const scaledContent = (
{content}
- ); - return createPortal(scaledContent, document.body); + ) + return createPortal(scaledContent, document.body) } - return content; -}; + return content +} diff --git a/src/components/guide/index.ts b/src/components/guide/index.ts index 3e528ce..6cf425c 100644 --- a/src/components/guide/index.ts +++ b/src/components/guide/index.ts @@ -1,2 +1,2 @@ -export { GuideSteps, GuideStepsItem } from "./GuideSteps"; -export { StudyGuideModal } from "./StudyGuideModal"; +export { GuideSteps, GuideStepsItem } from './GuideSteps' +export { StudyGuideModal } from './StudyGuideModal' diff --git a/src/components/image/image.tsx b/src/components/image/image.tsx index 194ee31..6d8cd12 100644 --- a/src/components/image/image.tsx +++ b/src/components/image/image.tsx @@ -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(null); + const [imageSrc, setImageSrc] = useState(src) + const [isLoading, setIsLoading] = useState(true) + const [hasError, setHasError] = useState(false) + const [isLoaded, setIsLoaded] = useState(false) + const imgRef = useRef(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 (
@@ -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' )} />
- ); + ) } diff --git a/src/components/image/image.types.ts b/src/components/image/image.types.ts index 48c5e11..c203c1d 100644 --- a/src/components/image/image.types.ts +++ b/src/components/image/image.types.ts @@ -1,36 +1,36 @@ -import type { HTMLAttributes } from "react"; +import type { HTMLAttributes } from 'react' export interface ImageProps extends HTMLAttributes { /** 图片地址 */ - 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 } diff --git a/src/components/image/index.ts b/src/components/image/index.ts index 47a2108..f49155a 100644 --- a/src/components/image/index.ts +++ b/src/components/image/index.ts @@ -1,2 +1,2 @@ -export * from "./image"; -export * from "./image.types"; +export * from './image' +export * from './image.types' diff --git a/src/components/index.ts b/src/components/index.ts index 35e519e..8e648bd 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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' diff --git a/src/components/layout/ResidenceLayout.tsx b/src/components/layout/ResidenceLayout.tsx index b3f9137..0da722e 100644 --- a/src/components/layout/ResidenceLayout.tsx +++ b/src/components/layout/ResidenceLayout.tsx @@ -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(null); - const previousPathRef = useRef(""); + 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(null) + const previousPathRef = useRef('') // 碎片动画相关状态 - const [showPuzzlePiece, setShowPuzzlePiece] = useState(false); - const [puzzlePieceId, setPuzzlePieceId] = useState(null); - const [isAnimationPlaying, setIsAnimationPlaying] = useState(false); - const [isFlying, setIsFlying] = useState(false); // 标记是否正在飞行 - const puzzlePieceRef = useRef(null); - const animationInProgressRef = useRef(false); // 防止重复执行动画 + const [showPuzzlePiece, setShowPuzzlePiece] = useState(false) + const [puzzlePieceId, setPuzzlePieceId] = useState(null) + const [isAnimationPlaying, setIsAnimationPlaying] = useState(false) + const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行 + const puzzlePieceRef = useRef(null) + const animationInProgressRef = useRef(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 => { + const waitForElementsAndAnimate = async (pieceId: number, retryCount = 0): Promise => { 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 ( - - + +
{/* 内容层 - 始终渲染,根据状态控制显示 */}
@@ -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) } }} > - +
)}
@@ -309,8 +295,8 @@ const ResidenceLayoutContent = () => {
- ); -}; + ) +} -export default SuccessAlert; +export default SuccessAlert diff --git a/src/components/layout/alert/title-alert.tsx b/src/components/layout/alert/title-alert.tsx index 1a2b0d3..684f414 100644 --- a/src/components/layout/alert/title-alert.tsx +++ b/src/components/layout/alert/title-alert.tsx @@ -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 (
@@ -33,7 +27,7 @@ const Alert = ({
@@ -45,28 +39,22 @@ const Alert = ({ {/* 标题 */}
{title}
{/* 副标题 */} -
- {subtitle} -
+
{subtitle}
{/* 按钮:去了解 */} - +
- ); -}; + ) +} -export default Alert; +export default Alert diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts index 193005b..48148c7 100644 --- a/src/components/layout/index.ts +++ b/src/components/layout/index.ts @@ -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, diff --git a/src/components/layout/quiz/DragQuizPanel.tsx b/src/components/layout/quiz/DragQuizPanel.tsx index b5f740a..b5c4bc1 100644 --- a/src/components/layout/quiz/DragQuizPanel.tsx +++ b/src/components/layout/quiz/DragQuizPanel.tsx @@ -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 ( -
+
{/* 题干卡片 */}
@@ -46,18 +43,18 @@ const DragQuizPanel = ({
{questionPrefix && {questionPrefix}} {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 ( {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)) }} > × ) : ( - - ____ - + ____ )} {index < blanks.length - 1 && ( - {blankSeparators && blankSeparators[index] - ? blankSeparators[index] - : "、"} + {blankSeparators && blankSeparators[index] ? blankSeparators[index] : '、'} )} - ); + ) })} {questionSuffix && {questionSuffix}} {/* 答案反馈 */} - {error && ( -
- {error} -
- )} + {error &&
{error}
}
@@ -117,12 +104,12 @@ const DragQuizPanel = ({ renderOption={(option, isUsed) => (
{!isUsed && ( @@ -136,13 +123,13 @@ const DragQuizPanel = ({ renderBlank={(_blank) => { // 返回一个隐藏元素,实际的空白区域在题干中 // 但需要通过 data-blank-id 让 DragFill 能找到它 - return
; + return
}} />
- ); -}; + ) +} -export default DragQuizPanel; +export default DragQuizPanel diff --git a/src/components/layout/quiz/MatchQuizPanel.tsx b/src/components/layout/quiz/MatchQuizPanel.tsx index 5df6f96..e4fa822 100644 --- a/src/components/layout/quiz/MatchQuizPanel.tsx +++ b/src/components/layout/quiz/MatchQuizPanel.tsx @@ -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>([]); - const [selectedLeftId, setSelectedLeftId] = useState(null); - const [error, setError] = useState(""); - const containerRef = useRef(null); + const [matches, setMatches] = useState>([]) + const [selectedLeftId, setSelectedLeftId] = useState(null) + const [error, setError] = useState('') + const containerRef = useRef(null) // 当前错误的连线对,用于做红色高亮+抖动反馈 - const [errorPairs, setErrorPairs] = useState< - Array<{ leftId: string; rightId: string }> - >([]); + const [errorPairs, setErrorPairs] = useState>([]) // 当窗口关闭时,重置状态 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 (
{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 (
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 = ({
)}
- ); + ) })}
@@ -267,18 +253,18 @@ const MatchQuizPanel = ({ {/* 绘制连线 */} {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 ( - ); + ) })} {/* 箭头标记定义 */} @@ -313,37 +299,37 @@ const MatchQuizPanel = ({ {/* 右列:事件 */}
{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 (
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 = ({
)}
- ); + ) })}
@@ -369,8 +355,7 @@ const MatchQuizPanel = ({
- ); -}; - -export default MatchQuizPanel; + ) +} +export default MatchQuizPanel diff --git a/src/components/layout/quiz/MultipleChoiceQuizPanel.tsx b/src/components/layout/quiz/MultipleChoiceQuizPanel.tsx index 4c95f7c..5c706a1 100644 --- a/src/components/layout/quiz/MultipleChoiceQuizPanel.tsx +++ b/src/components/layout/quiz/MultipleChoiceQuizPanel.tsx @@ -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([]); - const [error, setError] = useState(""); + const [selectedValues, setSelectedValues] = useState([]) + const [error, setError] = useState('') // 当窗口关闭时,清除选中状态和错误状态 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 ( -
+
{/* 题干卡片 */}
{title}
{/* 答案反馈 */} - {error && ( -
- {error} -
- )} + {error &&
{error}
}
{/* 选项区域 - 可滚动 */} @@ -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 (
{option.label}
- ); + ) }} />
@@ -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 = ({
- ); -}; + ) +} -export default MultipleChoiceQuizPanel; +export default MultipleChoiceQuizPanel diff --git a/src/components/layout/quiz/QuizPanal.tsx b/src/components/layout/quiz/QuizPanal.tsx index da1f918..c5479c9 100644 --- a/src/components/layout/quiz/QuizPanal.tsx +++ b/src/components/layout/quiz/QuizPanal.tsx @@ -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 ( -
+
{/* 题干卡片 */}
@@ -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 && ( -
- {error} -
- )} + {error &&
{error}
}
- ); -}; + ) +} -export default QuizPanel; +export default QuizPanel diff --git a/src/components/layout/quiz/SelectFillQuizPanel.tsx b/src/components/layout/quiz/SelectFillQuizPanel.tsx index a812f25..a59f604 100644 --- a/src/components/layout/quiz/SelectFillQuizPanel.tsx +++ b/src/components/layout/quiz/SelectFillQuizPanel.tsx @@ -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([]); - const [error, setError] = useState(""); + const [results, setResults] = useState([]) + const [error, setError] = useState('') // 当前正在填的空的下标(从 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 ( -
+
{/* 题干 + 空格区域 */}
{titlePrefix && {titlePrefix}} {blanks.map((blank, index) => { - const value = getBlankValue(blank.id); - const isActive = index === activeBlankIndex; + const value = getBlankValue(blank.id) + const isActive = index === activeBlankIndex return ( {blank.prefix && {blank.prefix}} @@ -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 && ( { - e.stopPropagation(); - handleClearBlank(blank.id); + e.stopPropagation() + handleClearBlank(blank.id) }} > × @@ -184,17 +174,13 @@ const SelectFillQuizPanel = ({ {blank.suffix && {blank.suffix}} - ); + ) })} {titleSuffix && {titleSuffix}}
{/* 答案反馈 */} - {error && ( -
- {error} -
- )} + {error &&
{error}
}
{/* 选项区域 */} @@ -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 = ({
- ); -}; - -export default SelectFillQuizPanel; - + ) +} +export default SelectFillQuizPanel diff --git a/src/components/layout/quiz/SingleChoiceQuizPanel.tsx b/src/components/layout/quiz/SingleChoiceQuizPanel.tsx index 6f701ea..c81264b 100644 --- a/src/components/layout/quiz/SingleChoiceQuizPanel.tsx +++ b/src/components/layout/quiz/SingleChoiceQuizPanel.tsx @@ -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(""); - const [error, setError] = useState(""); + const [selectedValue, setSelectedValue] = useState('') + const [error, setError] = useState('') // 当窗口关闭时,重置内部状态 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 ( -
+
{/* 题干卡片 */}
{title}
{/* 答案反馈 */} - {error && ( -
- {error} -
- )} + {error &&
{error}
}
{/* 选项区域 */} @@ -119,13 +110,11 @@ const SingleChoiceQuizPanel = ({ >
{option.label} @@ -136,7 +125,7 @@ const SingleChoiceQuizPanel = ({
- ); -}; + ) +} -export default SingleChoiceQuizPanel; +export default SingleChoiceQuizPanel diff --git a/src/components/layout/quiz/index.ts b/src/components/layout/quiz/index.ts index 80f7680..776ed1f 100644 --- a/src/components/layout/quiz/index.ts +++ b/src/components/layout/quiz/index.ts @@ -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"; \ No newline at end of file +export { default as QuizPanel } from './QuizPanal' +export type { QuizPanelProps } from './types' diff --git a/src/components/layout/quiz/types.ts b/src/components/layout/quiz/types.ts index 1199140..52d8355 100644 --- a/src/components/layout/quiz/types.ts +++ b/src/components/layout/quiz/types.ts @@ -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; + correctAnswers?: Record /** 自定义错误提示信息(答案错误时显示) */ - 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 } diff --git a/src/components/letter/LetterToChenyun.tsx b/src/components/letter/LetterToChenyun.tsx index 5caa88a..a91c02a 100644 --- a/src/components/letter/LetterToChenyun.tsx +++ b/src/components/letter/LetterToChenyun.tsx @@ -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([]); + const [blankValues, setBlankValues] = useState([]) // 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字) - const [blankErrors, setBlankErrors] = useState([]); + const [blankErrors, setBlankErrors] = useState([]) // 是否已提交:提交后隐藏编辑信件,展示只读信件 - 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 ( @@ -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 = ({
e.stopPropagation()} - style={{ perspective: "1200px" }} + style={{ perspective: '1200px' }} > {/* 编辑态 / 只读态:Y 轴翻转切换 */} {/* 前面:编辑态 */}
{/* 编辑态:信纸背景 */} 信纸 @@ -136,8 +132,8 @@ const LetterToChenyun = ({
寄给陈云的信 @@ -148,75 +144,62 @@ const LetterToChenyun = ({
{(() => { - let placeholderIndex = 0; - let foundNiHao = false; - return letterContent - .split("\n") - .map((line, lineIndex) => { - // 检测是否已经找到"您好"行 - if (line.includes("您好")) { - foundNiHao = true; - } + let placeholderIndex = 0 + let foundNiHao = false + return letterContent.split('\n').map((line, lineIndex) => { + // 检测是否已经找到"您好"行 + if (line.includes('您好')) { + foundNiHao = true + } - // "您好"行之后的非空行需要首行缩进 - const needsIndent = - foundNiHao && - line.trim() !== "" && - !line.includes("您好"); + // "您好"行之后的非空行需要首行缩进 + const needsIndent = + foundNiHao && line.trim() !== '' && !line.includes('您好') - const segments = line.split("____"); - return ( -
- {segments.map((part, partIndex) => { - const isLast = - partIndex === segments.length - 1; - if (isLast) { - return ( - - {part} - - ); - } + const segments = line.split('____') + return ( +
+ {segments.map((part, partIndex) => { + const isLast = partIndex === segments.length - 1 + if (isLast) { + return {part} + } - const currentIndex = placeholderIndex++; - return ( - - {part} - { - const next = [...blankValues]; - next[currentIndex] = e.target.value; - setBlankValues(next); + const currentIndex = placeholderIndex++ + return ( + + {part} + { + const next = [...blankValues] + next[currentIndex] = e.target.value + setBlankValues(next) - // 用户再次输入时,清除该空的错误高亮 - if (blankErrors[currentIndex]) { - const nextErrs = [...blankErrors]; - nextErrs[currentIndex] = false; - setBlankErrors(nextErrs); - } - }} - /> - - ); - })} -
- ); - }); + // 用户再次输入时,清除该空的错误高亮 + if (blankErrors[currentIndex]) { + const nextErrs = [...blankErrors] + nextErrs[currentIndex] = false + setBlankErrors(nextErrs) + } + }} + /> + + ) + })} +
+ ) + }) })()}
@@ -224,7 +207,7 @@ const LetterToChenyun = ({ {/* 笔的图片 */}
笔 @@ -233,11 +216,11 @@ const LetterToChenyun = ({ {/* 提交按钮:切换到只读信件态 */}
可提交状态 { - if (!validateBlanks()) return; - setSubmitted(true); + if (!validateBlanks()) return + setSubmitted(true) }} button /> @@ -248,25 +231,25 @@ const LetterToChenyun = ({
{/* 只读态:新背景 */} 信件 信纸 { - setSubmitted(false); + setSubmitted(false) }} /> @@ -274,55 +257,42 @@ const LetterToChenyun = ({
{(() => { - let placeholderIndex = 0; - let foundNiHao = false; - return letterContent - .split("\n") - .map((line, lineIndex) => { - if (line.includes("您好")) { - foundNiHao = true; - } + let placeholderIndex = 0 + let foundNiHao = false + return letterContent.split('\n').map((line, lineIndex) => { + if (line.includes('您好')) { + foundNiHao = true + } - const needsIndent = - foundNiHao && - line.trim() !== "" && - !line.includes("您好"); + const needsIndent = + foundNiHao && line.trim() !== '' && !line.includes('您好') - const segments = line.split("____"); - return ( -
- {segments.map((part, partIndex) => { - const isLast = - partIndex === segments.length - 1; - if (isLast) { - return ( - - {part} - - ); - } + const segments = line.split('____') + return ( +
+ {segments.map((part, partIndex) => { + const isLast = partIndex === segments.length - 1 + if (isLast) { + return {part} + } - const currentIndex = placeholderIndex++; - const value = blankValues[currentIndex] ?? ""; + const currentIndex = placeholderIndex++ + const value = blankValues[currentIndex] ?? '' - return ( - - {part} - {value || "  "} - - ); - })} -
- ); - }); + return ( + + {part} + {value || '  '} + + ) + })} +
+ ) + }) })()}
@@ -330,10 +300,10 @@ const LetterToChenyun = ({ {/* 寄出按钮:关闭窗口 */}
寄出 { - onSubmit?.(blankValues); + onSubmit?.(blankValues) }} button /> @@ -345,7 +315,7 @@ const LetterToChenyun = ({ )} - ); -}; + ) +} -export default LetterToChenyun; +export default LetterToChenyun diff --git a/src/components/letter/LetterToHuangyanpei.tsx b/src/components/letter/LetterToHuangyanpei.tsx index 4b6b119..0f4a47b 100644 --- a/src/components/letter/LetterToHuangyanpei.tsx +++ b/src/components/letter/LetterToHuangyanpei.tsx @@ -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([]); + const [blankValues, setBlankValues] = useState([]) // 每个空的校验状态:true 表示该空当前不符合要求 - const [blankErrors, setBlankErrors] = useState([]); + const [blankErrors, setBlankErrors] = useState([]) // 是否已提交:提交后隐藏编辑信件,展示只读信件 - 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 ( @@ -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 = ({
e.stopPropagation()} - style={{ perspective: "1200px" }} + style={{ perspective: '1200px' }} > {/* 编辑态 / 只读态:Y 轴翻转切换 */} {/* 前面:编辑态 */}
{/* 编辑态:信纸背景 */} 信纸 @@ -178,8 +174,8 @@ const LetterToHuangyanpei = ({
写给黄炎培先生的信 @@ -190,121 +186,97 @@ const LetterToHuangyanpei = ({
{(() => { - let placeholderIndex = 0; - let foundNiHao = false; - return letterContent - .split("\n") - .map((line, lineIndex) => { - // 检测是否已经找到"您好"行 - if (line.includes("您好")) { - foundNiHao = true; - } + let placeholderIndex = 0 + let foundNiHao = false + return letterContent.split('\n').map((line, lineIndex) => { + // 检测是否已经找到"您好"行 + if (line.includes('您好')) { + foundNiHao = true + } - // "您好"行之后的非空行需要首行缩进 - const needsIndent = - foundNiHao && - line.trim() !== "" && - !line.includes("您好"); + // "您好"行之后的非空行需要首行缩进 + const needsIndent = + foundNiHao && line.trim() !== '' && !line.includes('您好') - const segments = line.split("____"); - return ( -
- {segments.map((part, partIndex) => { - const isLast = - partIndex === segments.length - 1; - if (isLast) { - return ( - - {part} - - ); - } + const segments = line.split('____') + return ( +
+ {segments.map((part, partIndex) => { + const isLast = partIndex === segments.length - 1 + if (isLast) { + return {part} + } - const currentIndex = placeholderIndex++; - const isLimitedFill = - currentIndex === 0 || currentIndex === 3; // 填空1和填空4为限填 - const options = - currentIndex === 0 - ? BLANK_1_OPTIONS - : BLANK_4_OPTIONS; + const currentIndex = placeholderIndex++ + const isLimitedFill = currentIndex === 0 || currentIndex === 3 // 填空1和填空4为限填 + const options = + currentIndex === 0 ? BLANK_1_OPTIONS : BLANK_4_OPTIONS - return ( - - {part} - {isLimitedFill ? ( - // 限填:下拉选择 - ({ + value: opt, + label: opt, + }))} + value={blankValues[currentIndex] ?? ''} + onChange={(selectedValue) => { + const next = [...blankValues] + next[currentIndex] = selectedValue + setBlankValues(next) + + // 用户再次选择时,清除该空的错误高亮 + if (blankErrors[currentIndex]) { + const nextErrs = [...blankErrors] + nextErrs[currentIndex] = false + setBlankErrors(nextErrs) } - onChange={(selectedValue) => { - const next = [...blankValues]; - next[currentIndex] = selectedValue; - setBlankValues(next); + }} + placeholder="请选择" + error={blankErrors[currentIndex]} + zIndex={10000} + fitContent={true} + /> + ) : ( + // 任填:文本输入,限制20个字,敏感词过滤 + { + const rawValue = e.target.value + // 应用敏感词过滤 + const filteredValue = filterInput(rawValue) - // 用户再次选择时,清除该空的错误高亮 - if (blankErrors[currentIndex]) { - const nextErrs = [...blankErrors]; - nextErrs[currentIndex] = false; - setBlankErrors(nextErrs); - } - }} - placeholder="请选择" - error={blankErrors[currentIndex]} - zIndex={10000} - fitContent={true} - /> - ) : ( - // 任填:文本输入,限制20个字,敏感词过滤 - { - const rawValue = e.target.value; - // 应用敏感词过滤 - const filteredValue = - filterInput(rawValue); - - const next = [...blankValues]; - next[currentIndex] = filteredValue; - setBlankValues(next); - - // 用户再次输入时,清除该空的错误高亮 - if (blankErrors[currentIndex]) { - const nextErrs = [...blankErrors]; - nextErrs[currentIndex] = false; - setBlankErrors(nextErrs); - } - }} - /> - )} - - ); - })} -
- ); - }); + }} + /> + )} + + ) + })} +
+ ) + }) })()}
@@ -312,7 +284,7 @@ const LetterToHuangyanpei = ({ {/* 笔的图片 */}
笔 @@ -321,11 +293,11 @@ const LetterToHuangyanpei = ({ {/* 提交按钮:切换到只读信件态 */}
可提交状态 { - if (!validateBlanks()) return; - setSubmitted(true); + if (!validateBlanks()) return + setSubmitted(true) }} button /> @@ -336,25 +308,25 @@ const LetterToHuangyanpei = ({
{/* 只读态:新背景 */} 信件 信纸 { - setSubmitted(false); + setSubmitted(false) }} /> @@ -362,54 +334,38 @@ const LetterToHuangyanpei = ({
{(() => { - let placeholderIndex = 0; - let foundNiHao = false; - return letterContent - .split("\n") - .map((line, lineIndex) => { - if (line.includes("您好")) { - foundNiHao = true; - } + let placeholderIndex = 0 + let foundNiHao = false + return letterContent.split('\n').map((line, lineIndex) => { + if (line.includes('您好')) { + foundNiHao = true + } - const needsIndent = - foundNiHao && - line.trim() !== "" && - !line.includes("您好"); + const needsIndent = + foundNiHao && line.trim() !== '' && !line.includes('您好') - const segments = line.split("____"); - return ( -
- {segments.map((part, partIndex) => { - const isLast = - partIndex === segments.length - 1; - if (isLast) { - return ( - - {part} - - ); - } + const segments = line.split('____') + return ( +
+ {segments.map((part, partIndex) => { + const isLast = partIndex === segments.length - 1 + if (isLast) { + return {part} + } - const currentIndex = placeholderIndex++; - const value = blankValues[currentIndex] ?? ""; + const currentIndex = placeholderIndex++ + const value = blankValues[currentIndex] ?? '' - return ( - - {part} - {value || "  "} - - ); - })} -
- ); - }); + return ( + + {part} + {value || '  '} + + ) + })} +
+ ) + }) })()}
@@ -417,12 +373,12 @@ const LetterToHuangyanpei = ({ {/* 寄出按钮:关闭窗口 */}
寄出 { // 寄出前再次校验,确保所有填空符合标准 if (validateBlanks()) { - onSubmit?.(blankValues); + onSubmit?.(blankValues) } }} button @@ -435,7 +391,7 @@ const LetterToHuangyanpei = ({ )} - ); -}; + ) +} -export default LetterToHuangyanpei; +export default LetterToHuangyanpei diff --git a/src/components/letter/LetterToLuxun.tsx b/src/components/letter/LetterToLuxun.tsx index 5a13fa7..41f8521 100644 --- a/src/components/letter/LetterToLuxun.tsx +++ b/src/components/letter/LetterToLuxun.tsx @@ -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([]); + const [blankValues, setBlankValues] = useState([]) // 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字) - const [blankErrors, setBlankErrors] = useState([]); + const [blankErrors, setBlankErrors] = useState([]) // 是否已提交:提交后隐藏编辑信件,展示只读信件 - 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 ( @@ -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 = ({
e.stopPropagation()} - style={{ perspective: "1200px" }} + style={{ perspective: '1200px' }} > {/* 编辑态 / 只读态:Y 轴翻转切换 */} {/* 前面:编辑态 */}
{/* 编辑态:信纸背景 */} 信纸 @@ -171,8 +167,8 @@ const LetterToLuxun = ({
写给鲁迅先生的信 @@ -183,115 +179,95 @@ const LetterToLuxun = ({
{(() => { - let placeholderIndex = 0; - let foundNiHao = false; - return letterContent - .split("\n") - .map((line, lineIndex) => { - // 检测是否已经找到"您好"行 - if (line.includes("您好")) { - foundNiHao = true; - } + let placeholderIndex = 0 + let foundNiHao = false + return letterContent.split('\n').map((line, lineIndex) => { + // 检测是否已经找到"您好"行 + if (line.includes('您好')) { + foundNiHao = true + } - // "您好"行之后的非空行需要首行缩进 - const needsIndent = - foundNiHao && - line.trim() !== "" && - !line.includes("您好"); + // "您好"行之后的非空行需要首行缩进 + const needsIndent = + foundNiHao && line.trim() !== '' && !line.includes('您好') - const segments = line.split("____"); - return ( -
- {segments.map((part, partIndex) => { - const isLast = - partIndex === segments.length - 1; - if (isLast) { - return ( - - {part} - - ); - } + const segments = line.split('____') + return ( +
+ {segments.map((part, partIndex) => { + const isLast = partIndex === segments.length - 1 + if (isLast) { + return {part} + } - const currentIndex = placeholderIndex++; - const isLimitedFill = - currentIndex === 0 || currentIndex === 1; // 填空1和填空2为限填 - const options = - currentIndex === 0 - ? BLANK_1_OPTIONS - : BLANK_2_OPTIONS; + const currentIndex = placeholderIndex++ + const isLimitedFill = currentIndex === 0 || currentIndex === 1 // 填空1和填空2为限填 + const options = + currentIndex === 0 ? BLANK_1_OPTIONS : BLANK_2_OPTIONS - return ( - - {part} - {isLimitedFill ? ( - // 限填:下拉选择 - ({ + value: opt, + label: opt, + }))} + value={blankValues[currentIndex] ?? ''} + onChange={(selectedValue) => { + const next = [...blankValues] + next[currentIndex] = selectedValue + setBlankValues(next) + + // 用户再次选择时,清除该空的错误高亮 + if (blankErrors[currentIndex]) { + const nextErrs = [...blankErrors] + nextErrs[currentIndex] = false + setBlankErrors(nextErrs) } - onChange={(selectedValue) => { - const next = [...blankValues]; - next[currentIndex] = selectedValue; - setBlankValues(next); + }} + placeholder="请选择" + error={blankErrors[currentIndex]} + zIndex={10000} + fitContent={true} + /> + ) : ( + // 任填:文本输入 + { + const next = [...blankValues] + next[currentIndex] = e.target.value + setBlankValues(next) - // 用户再次选择时,清除该空的错误高亮 - if (blankErrors[currentIndex]) { - const nextErrs = [...blankErrors]; - nextErrs[currentIndex] = false; - setBlankErrors(nextErrs); - } - }} - placeholder="请选择" - error={blankErrors[currentIndex]} - zIndex={10000} - fitContent={true} - /> - ) : ( - // 任填:文本输入 - { - const next = [...blankValues]; - next[currentIndex] = e.target.value; - setBlankValues(next); - - // 用户再次输入时,清除该空的错误高亮 - if (blankErrors[currentIndex]) { - const nextErrs = [...blankErrors]; - nextErrs[currentIndex] = false; - setBlankErrors(nextErrs); - } - }} - /> - )} - - ); - })} -
- ); - }); + }} + /> + )} + + ) + })} +
+ ) + }) })()}
@@ -299,7 +275,7 @@ const LetterToLuxun = ({ {/* 笔的图片 */}
笔 @@ -308,11 +284,11 @@ const LetterToLuxun = ({ {/* 提交按钮:切换到只读信件态 */}
可提交状态 { - if (!validateBlanks()) return; - setSubmitted(true); + if (!validateBlanks()) return + setSubmitted(true) }} button /> @@ -332,25 +308,25 @@ const LetterToLuxun = ({
{/* 只读态:新背景 */} 信件 信纸 { - setSubmitted(false); + setSubmitted(false) }} /> @@ -358,55 +334,42 @@ const LetterToLuxun = ({
{(() => { - let placeholderIndex = 0; - let foundNiHao = false; - return letterContent - .split("\n") - .map((line, lineIndex) => { - if (line.includes("您好")) { - foundNiHao = true; - } + let placeholderIndex = 0 + let foundNiHao = false + return letterContent.split('\n').map((line, lineIndex) => { + if (line.includes('您好')) { + foundNiHao = true + } - const needsIndent = - foundNiHao && - line.trim() !== "" && - !line.includes("您好"); + const needsIndent = + foundNiHao && line.trim() !== '' && !line.includes('您好') - const segments = line.split("____"); - return ( -
- {segments.map((part, partIndex) => { - const isLast = - partIndex === segments.length - 1; - if (isLast) { - return ( - - {part} - - ); - } + const segments = line.split('____') + return ( +
+ {segments.map((part, partIndex) => { + const isLast = partIndex === segments.length - 1 + if (isLast) { + return {part} + } - const currentIndex = placeholderIndex++; - const value = blankValues[currentIndex] ?? ""; + const currentIndex = placeholderIndex++ + const value = blankValues[currentIndex] ?? '' - return ( - - {part} - {value || "  "} - - ); - })} -
- ); - }); + return ( + + {part} + {value || '  '} + + ) + })} +
+ ) + }) })()}
@@ -414,10 +377,10 @@ const LetterToLuxun = ({ {/* 寄出按钮:关闭窗口 */}
寄出 { - onSubmit?.(blankValues); + onSubmit?.(blankValues) }} button /> @@ -429,7 +392,7 @@ const LetterToLuxun = ({ )} - ); -}; + ) +} -export default LetterToLuxun; +export default LetterToLuxun diff --git a/src/components/letter/LetterToSunzhongshan.tsx b/src/components/letter/LetterToSunzhongshan.tsx index 9e43531..0d0233d 100644 --- a/src/components/letter/LetterToSunzhongshan.tsx +++ b/src/components/letter/LetterToSunzhongshan.tsx @@ -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([]); + const [blankValues, setBlankValues] = useState([]) // 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字) - const [blankErrors, setBlankErrors] = useState([]); + const [blankErrors, setBlankErrors] = useState([]) // 是否已提交:提交后隐藏编辑信件,展示只读信件 - 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 ( @@ -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 = ({
e.stopPropagation()} - style={{ perspective: "1200px" }} + style={{ perspective: '1200px' }} > {/* 编辑态 / 只读态:Y 轴翻转切换 */} {/* 前面:编辑态 */}
{/* 编辑态:信纸背景 */} 信纸 @@ -174,8 +174,8 @@ const LetterToSunzhongshan = ({
写给孙中山先生的信 @@ -186,134 +186,117 @@ const LetterToSunzhongshan = ({
{(() => { - let placeholderIndex = 0; - let foundNiHao = false; - return letterContent - .split("\n") - .map((line, lineIndex) => { - // 检测是否已经找到"您好"行 - if (line.includes("您好")) { - foundNiHao = true; - } + let placeholderIndex = 0 + let foundNiHao = false + return letterContent.split('\n').map((line, lineIndex) => { + // 检测是否已经找到"您好"行 + if (line.includes('您好')) { + foundNiHao = true + } - // "您好"行之后的非空行需要首行缩进 - const needsIndent = - foundNiHao && - line.trim() !== "" && - !line.includes("您好"); + // "您好"行之后的非空行需要首行缩进 + const needsIndent = + foundNiHao && line.trim() !== '' && !line.includes('您好') - const segments = line.split("____"); - return ( -
- {segments.map((part, partIndex) => { - const isLast = - partIndex === segments.length - 1; - if (isLast) { - return ( - - {part} - - ); - } + const segments = line.split('____') + return ( +
+ {segments.map((part, partIndex) => { + const isLast = partIndex === segments.length - 1 + if (isLast) { + return {part} + } - const currentIndex = placeholderIndex++; - const isSelectFill = currentIndex < 3; // 前三个空使用 Select + const currentIndex = placeholderIndex++ + const isSelectFill = currentIndex < 3 // 前三个空使用 Select - return ( - - {part} - {isSelectFill ? ( - // 前三个空:下拉选择 - { + const next = [...blankValues] + next[currentIndex] = selectedValue + setBlankValues(next) + + // 清除错误状态 + if (blankErrors[currentIndex]) { + const nextErrs = [...blankErrors] + nextErrs[currentIndex] = false + setBlankErrors(nextErrs) } - onChange={(selectedValue) => { - const next = [...blankValues]; - next[currentIndex] = selectedValue; - setBlankValues(next); - // 清除错误状态 - if (blankErrors[currentIndex]) { - const nextErrs = [...blankErrors]; - nextErrs[currentIndex] = false; - setBlankErrors(nextErrs); - } + // 立即验证选择是否正确 + // let correctAnswer = ""; + // if (currentIndex === 0) { + // correctAnswer = BLANK_0_ANSWER; + // } else if (currentIndex === 1) { + // correctAnswer = BLANK_1_ANSWER; + // } else if (currentIndex === 2) { + // correctAnswer = BLANK_2_ANSWER; + // } - // 立即验证选择是否正确 - // let correctAnswer = ""; - // if (currentIndex === 0) { - // correctAnswer = BLANK_0_ANSWER; - // } else if (currentIndex === 1) { - // correctAnswer = BLANK_1_ANSWER; - // } else if (currentIndex === 2) { - // correctAnswer = BLANK_2_ANSWER; - // } + // if ( + // correctAnswer && + // selectedValue !== correctAnswer + // ) { + // // 选择错误,显示错误提示 + // setErrorAlertText( + // "请选择正确答案" + // ); + // setShowErrorAlert(true); + // // 清空选择 + // next[currentIndex] = ""; + // setBlankValues(next); + // const nextErrs = [...blankErrors]; + // nextErrs[currentIndex] = true; + // setBlankErrors(nextErrs); + // } + }} + placeholder="请选择" + error={blankErrors[currentIndex]} + zIndex={10000} + fitContent={true} + /> + ) : ( + // 其他空:文本输入 + { + const next = [...blankValues] + next[currentIndex] = e.target.value + setBlankValues(next) - // if ( - // correctAnswer && - // selectedValue !== correctAnswer - // ) { - // // 选择错误,显示错误提示 - // setErrorAlertText( - // "请选择正确答案" - // ); - // setShowErrorAlert(true); - // // 清空选择 - // next[currentIndex] = ""; - // setBlankValues(next); - // const nextErrs = [...blankErrors]; - // nextErrs[currentIndex] = true; - // setBlankErrors(nextErrs); - // } - }} - placeholder="请选择" - error={blankErrors[currentIndex]} - zIndex={10000} - fitContent={true} - /> - ) : ( - // 其他空:文本输入 - { - const next = [...blankValues]; - next[currentIndex] = e.target.value; - setBlankValues(next); - - // 用户再次输入时,清除该空的错误高亮 - if (blankErrors[currentIndex]) { - const nextErrs = [...blankErrors]; - nextErrs[currentIndex] = false; - setBlankErrors(nextErrs); - } - }} - /> - )} - - ); - })} -
- ); - }); + }} + /> + )} + + ) + })} +
+ ) + }) })()}
@@ -321,7 +304,7 @@ const LetterToSunzhongshan = ({ {/* 笔的图片 */}
笔 @@ -330,11 +313,11 @@ const LetterToSunzhongshan = ({ {/* 提交按钮:切换到只读信件态 */}
可提交状态 { - if (!validateBlanks()) return; - setSubmitted(true); + if (!validateBlanks()) return + setSubmitted(true) }} button /> @@ -354,25 +337,25 @@ const LetterToSunzhongshan = ({
{/* 只读态:新背景 */} 信件 信纸 { - setSubmitted(false); + setSubmitted(false) }} /> @@ -380,55 +363,42 @@ const LetterToSunzhongshan = ({
{(() => { - let placeholderIndex = 0; - let foundNiHao = false; - return letterContent - .split("\n") - .map((line, lineIndex) => { - if (line.includes("您好")) { - foundNiHao = true; - } + let placeholderIndex = 0 + let foundNiHao = false + return letterContent.split('\n').map((line, lineIndex) => { + if (line.includes('您好')) { + foundNiHao = true + } - const needsIndent = - foundNiHao && - line.trim() !== "" && - !line.includes("您好"); + const needsIndent = + foundNiHao && line.trim() !== '' && !line.includes('您好') - const segments = line.split("____"); - return ( -
- {segments.map((part, partIndex) => { - const isLast = - partIndex === segments.length - 1; - if (isLast) { - return ( - - {part} - - ); - } + const segments = line.split('____') + return ( +
+ {segments.map((part, partIndex) => { + const isLast = partIndex === segments.length - 1 + if (isLast) { + return {part} + } - const currentIndex = placeholderIndex++; - const value = blankValues[currentIndex] ?? ""; + const currentIndex = placeholderIndex++ + const value = blankValues[currentIndex] ?? '' - return ( - - {part} - {value || "  "} - - ); - })} -
- ); - }); + return ( + + {part} + {value || '  '} + + ) + })} +
+ ) + }) })()}
@@ -436,10 +406,10 @@ const LetterToSunzhongshan = ({ {/* 寄出按钮:关闭窗口 */}
寄出 { - onSubmit?.(blankValues); + onSubmit?.(blankValues) }} button /> @@ -460,7 +430,7 @@ const LetterToSunzhongshan = ({ )} - ); -}; + ) +} -export default LetterToSunzhongshan; +export default LetterToSunzhongshan diff --git a/src/components/letter/index.ts b/src/components/letter/index.ts index 578e435..ea64631 100644 --- a/src/components/letter/index.ts +++ b/src/components/letter/index.ts @@ -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' diff --git a/src/components/narration/Narration.tsx b/src/components/narration/Narration.tsx index 38ea500..eb7cb7f 100644 --- a/src/components/narration/Narration.tsx +++ b/src/components/narration/Narration.tsx @@ -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 (
@@ -161,7 +161,7 @@ const Narration = ({ )}
- ); -}; + ) +} -export default Narration; +export default Narration diff --git a/src/components/narration/index.ts b/src/components/narration/index.ts index 3bce0da..46a2213 100644 --- a/src/components/narration/index.ts +++ b/src/components/narration/index.ts @@ -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' diff --git a/src/components/overlay/EndingOverlay.tsx b/src/components/overlay/EndingOverlay.tsx index 381f0ed..75bc18f 100644 --- a/src/components/overlay/EndingOverlay.tsx +++ b/src/components/overlay/EndingOverlay.tsx @@ -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 (
@@ -128,7 +119,7 @@ export const EndingOverlay = ({
@@ -139,5 +130,5 @@ export const EndingOverlay = ({ {/*
*/}
- ); -}; + ) +} diff --git a/src/components/overlay/Overlay.tsx b/src/components/overlay/Overlay.tsx index f3773bb..b062d64 100644 --- a/src/components/overlay/Overlay.tsx +++ b/src/components/overlay/Overlay.tsx @@ -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([]); - const rafRef = useRef(null); - const resizeObserverRef = useRef(null); - const mutationObserverRef = useRef(null); - const debounceTimerRef = useRef(null); - const elementsCacheRef = useRef([]); - const prevRectsRef = useRef([]); + const [mounted, setMounted] = useState(false) + const [elementRects, setElementRects] = useState([]) + const rafRef = useRef(null) + const resizeObserverRef = useRef(null) + const mutationObserverRef = useRef(null) + const debounceTimerRef = useRef(null) + const elementsCacheRef = useRef([]) + const prevRectsRef = useRef([]) // 获取缩放参数,用于视觉对齐 - 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(); + const observedNodes = new Set() 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( () => ({ - 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 = ( @@ -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 = ({
{/* 全屏白色区域(显示遮罩) */} - + {/* 黑色区域(挖洞,隐藏遮罩)- 只在有高亮元素时显示 */} {hasHighlightElements && - maskHoles.map((hole, _index) => ( - - ))} + maskHoles.map((hole, _index) => )} {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 (
- ); + ) })}
)} - ); + ) - return createPortal(content, document.body); -}; + return createPortal(content, document.body) +} -export default Overlay; +export default Overlay diff --git a/src/components/overlay/index.ts b/src/components/overlay/index.ts index 4b6f90b..8d8df15 100644 --- a/src/components/overlay/index.ts +++ b/src/components/overlay/index.ts @@ -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' diff --git a/src/components/puzzle/MenuPuzzleCenter.tsx b/src/components/puzzle/MenuPuzzleCenter.tsx index 431748f..f367b2e 100644 --- a/src/components/puzzle/MenuPuzzleCenter.tsx +++ b/src/components/puzzle/MenuPuzzleCenter.tsx @@ -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 ( -
+
{/* 外层:白色边框(使用 clip-path 裁剪形状) */}
{/* 内容容器(边框内部区域) */} @@ -63,7 +62,7 @@ const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
{/* 填充层(根据进度百分比从左侧填充) */} @@ -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', }} />
@@ -88,42 +87,42 @@ const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
{progressText}
- ); -}; + ) +} 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(null); +const MenuPuzzleCenter = ({ visible, onClose, onSelectResidence }: MenuPuzzleCenterProps) => { + const { getPuzzleCollectProgress } = usePuzzleStore() + const [activeMapImage, setActiveMapImage] = useState(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 (
{ if (e.target === e.currentTarget) { - onClose(); + onClose() } }} >
{/* 箭头指示底图 */} - + {/* 关闭按钮 */} 关闭 - {/* 故居列表:按照设计图进行绝对定位 */}
{residenceProgress.map( @@ -278,10 +276,7 @@ const MenuPuzzleCenter = ({ style={{ left, top }} > {/* 故居图片(资源在 puzzle/home 下,可根据实际名称调整) */} - {displayName} + {displayName} {/* 进度展示,仅对已开放拼图的故居显示 */} {hasPuzzle && ( @@ -292,20 +287,24 @@ const MenuPuzzleCenter = ({ {/* 图标 */}
- 拼图 { - if (!hasPuzzle) return; - e.stopPropagation(); - onSelectResidence(residence); - }} /> + if (!hasPuzzle) return + e.stopPropagation() + onSelectResidence(residence) + }} + /> 导图 { - e.stopPropagation(); - if (!hasMap || !mapImage) return; - setActiveMapImage(mapImage); + e.stopPropagation() + if (!hasMap || !mapImage) return + setActiveMapImage(mapImage) }} />
@@ -318,10 +317,10 @@ const MenuPuzzleCenter = ({ {activeMapImage && (
{ if (e.target === e.currentTarget) { - setActiveMapImage(null); + setActiveMapImage(null) } }} > @@ -329,25 +328,20 @@ const MenuPuzzleCenter = ({
关闭 setActiveMapImage(null)} /> - 导图 + 导图
)}
- ); -}; - -export default MenuPuzzleCenter; - + ) +} +export default MenuPuzzleCenter diff --git a/src/components/puzzle/PuzzlePieceDisplay.tsx b/src/components/puzzle/PuzzlePieceDisplay.tsx index 1fa0988..1921c47 100644 --- a/src/components/puzzle/PuzzlePieceDisplay.tsx +++ b/src/components/puzzle/PuzzlePieceDisplay.tsx @@ -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( - ({ 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 (
{`碎片
- ); + ) } -); +) -PuzzlePieceDisplay.displayName = "PuzzlePieceDisplay"; +PuzzlePieceDisplay.displayName = 'PuzzlePieceDisplay' -export default PuzzlePieceDisplay; +export default PuzzlePieceDisplay diff --git a/src/components/puzzle/PuzzlePiecePortal.tsx b/src/components/puzzle/PuzzlePiecePortal.tsx index 00fc62f..75f8bc4 100644 --- a/src/components/puzzle/PuzzlePiecePortal.tsx +++ b/src/components/puzzle/PuzzlePiecePortal.tsx @@ -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( ({ 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(
( } animate={ hidden - ? { opacity: 0, visibility: "hidden" as const } + ? { opacity: 0, visibility: 'hidden' as const } : isAnimating - ? { - scale: 1, - rotateY: 0, - opacity: 1, - visibility: "visible" as const, - } - : { - opacity: 1, - visibility: "visible" as const, - } + ? { + scale: 1, + rotateY: 0, + opacity: 1, + visibility: 'visible' as const, + } + : { + opacity: 1, + visibility: 'visible' as const, + } } transition={{ duration: 0.8, // 稍微延长动画时间,让效果更舒缓 @@ -87,23 +87,23 @@ const PuzzlePiecePortal = forwardRef( }, opacity: { duration: 0.6, - ease: "easeOut", + ease: 'easeOut', }, - }} - > - + }} + > +
, document.body - ); + ) } -); +) -PuzzlePiecePortal.displayName = "PuzzlePiecePortal"; +PuzzlePiecePortal.displayName = 'PuzzlePiecePortal' -export default PuzzlePiecePortal; +export default PuzzlePiecePortal diff --git a/src/components/puzzle/PuzzleSystem.tsx b/src/components/puzzle/PuzzleSystem.tsx index d8e4d15..0882f12 100644 --- a/src/components/puzzle/PuzzleSystem.tsx +++ b/src/components/puzzle/PuzzleSystem.tsx @@ -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 = { - 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' : '')} > {/* 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', }} /> @@ -126,14 +126,14 @@ const PuzzlePieceSvg = ({ - ); -}; + ) +} /** * 拼图系统组件 @@ -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(null); - const [hoveredPiece, setHoveredPiece] = useState(null); + const navigate = useNavigate() + const { moduleName } = useParams<{ moduleName: string }>() + const { isPuzzleUnlocked, getPuzzleCollectProgress } = usePuzzleStore() + const [selectedPiece, setSelectedPiece] = useState(null) + const [hoveredPiece, setHoveredPiece] = useState(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 (
{ // 点击背景关闭 if (e.target === e.currentTarget) { - onClose(); + onClose() } }} > @@ -227,7 +223,7 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => { {/* 关闭按钮 */} 关闭 { {/* 收集进度 */}
{getResidenceDisplayName(residence)}故居碎片 碎片装饰
{/* 收集进度 */} - {collectProgress.toFixed(2) == "1.00" - ? "已集齐" + {collectProgress.toFixed(2) == '1.00' + ? '已集齐' : `收集进度${collectProgress.toFixed(2)}%`}
@@ -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 ( { onHover={(id) => setHoveredPiece(id)} onLeave={() => setHoveredPiece(null)} /> - ); + ) })}
@@ -300,36 +296,30 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
{/* 标题 */} {selectedPieceData && (
{/* 空格换行 */} - {selectedPieceData.title - .split(" ") - .map((word: string, index: number) => ( -
- {word} - {index < - selectedPieceData.title.split(" ").length - 1 && - " "} -
- ))} + {selectedPieceData.title.split(' ').map((word: string, index: number) => ( +
+ {word} + {index < selectedPieceData.title.split(' ').length - 1 && ' '} +
+ ))}
)} {/* 对应页面图片 */}
{selectedPieceData.title} @@ -338,7 +328,7 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => { {selectedPieceData && ( 前往按钮 {
- ); -}; + ) +} -export default PuzzleSystem; +export default PuzzleSystem diff --git a/src/components/puzzle/index.ts b/src/components/puzzle/index.ts index 609ff1c..3eda983 100644 --- a/src/components/puzzle/index.ts +++ b/src/components/puzzle/index.ts @@ -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' diff --git a/src/components/quiz/drag-fill/drag-fill.stories.tsx b/src/components/quiz/drag-fill/drag-fill.stories.tsx index 35175eb..e1abd32 100644 --- a/src/components/quiz/drag-fill/drag-fill.stories.tsx +++ b/src/components/quiz/drag-fill/drag-fill.stories.tsx @@ -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 = { - 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; +export default meta +type Story = StoryObj // 基础示例 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([]); + const [value, setValue] = useState([]) return (
{ - alert(`提交的结果: ${JSON.stringify(results)}`); + alert(`提交的结果: ${JSON.stringify(results)}`) }} /> -
- 当前填充: {JSON.stringify(value)} -
+
当前填充: {JSON.stringify(value)}
- ); + ) }, -}; +} // 允许重复选择 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 ( (
@@ -176,29 +174,24 @@ export const CustomRender: Story = { renderBlank={(blank, filledValue) => (
{filledValue ? ( -
- ✓ {filledValue} -
+
✓ {filledValue}
) : (
⬇️
-
{blank.label || "拖拽到这里"}
+
{blank.label || '拖拽到这里'}
)}
)} onSubmit={(results) => { - console.log("填充结果:", results); + console.log('填充结果:', results) }} /> - ); + ) }, -}; - +} diff --git a/src/components/quiz/drag-fill/drag-fill.tsx b/src/components/quiz/drag-fill/drag-fill.tsx index dc7ad03..a8f2143 100644 --- a/src/components/quiz/drag-fill/drag-fill.tsx +++ b/src/components/quiz/drag-fill/drag-fill.tsx @@ -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( 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( ) => { // 标准化选项数据 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( - defaultValue || [] - ); - const currentValue = isControlled ? controlledValue : internalValue; + const isControlled = controlledValue !== undefined + const [internalValue, setInternalValue] = useState(defaultValue || []) + const currentValue = isControlled ? controlledValue : internalValue // 拖拽状态 - const [draggingOption, setDraggingOption] = useState(null); + const [draggingOption, setDraggingOption] = useState(null) const [dragPosition, setDragPosition] = useState<{ - x: number; - y: number; - } | null>(null); - const [hoveredBlank, setHoveredBlank] = useState(null); - const dragOffsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); - const blankRefsMap = useRef>(new Map()); + x: number + y: number + } | null>(null) + const [hoveredBlank, setHoveredBlank] = useState(null) + const dragOffsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) + const blankRefsMap = useRef>(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( 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 (
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( )}
- ); - }; + ) + } // 默认渲染空白区域 - 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 (
{ 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 ? ( <> - - {option?.label || filledValue} - + {option?.label || filledValue} {!disabled && (
- ); - }; + ) + } return ( <>
{/* 选项区域 */}
- {!hideLabels && ( -

选项

- )} + {!hideLabels &&

选项

}
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 (
{ // 只有在没有自定义渲染或者不是已使用的情况下才处理拖拽 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)}
- ); + ) })}
{/* 空白区域 */} -
- {!hideLabels && ( -

填空

- )} +
+ {!hideLabels &&

填空

}
{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 (
( 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( ? renderBlank(blank, filledValue, isHovered) : defaultRenderBlank(blank, filledValue)}
- ); + ) })}
@@ -554,10 +499,10 @@ const DragFill = forwardRef( 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( 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 ( {option.label} - ); + ) })()}
, document.body )} - ); + ) } -); +) -DragFill.displayName = "DragFill"; +DragFill.displayName = 'DragFill' -export default DragFill; +export default DragFill diff --git a/src/components/quiz/drag-fill/drag-fill.types.ts b/src/components/quiz/drag-fill/drag-fill.types.ts index 5506dcc..971a100 100644 --- a/src/components/quiz/drag-fill/drag-fill.types.ts +++ b/src/components/quiz/drag-fill/drag-fill.types.ts @@ -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 } diff --git a/src/components/quiz/drag-fill/index.ts b/src/components/quiz/drag-fill/index.ts index a0c5052..20bcef9 100644 --- a/src/components/quiz/drag-fill/index.ts +++ b/src/components/quiz/drag-fill/index.ts @@ -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' diff --git a/src/components/quiz/index.ts b/src/components/quiz/index.ts index 2cbf525..a062d7b 100644 --- a/src/components/quiz/index.ts +++ b/src/components/quiz/index.ts @@ -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' diff --git a/src/components/quiz/multiple-choice/index.ts b/src/components/quiz/multiple-choice/index.ts index 02831e7..329d827 100644 --- a/src/components/quiz/multiple-choice/index.ts +++ b/src/components/quiz/multiple-choice/index.ts @@ -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' diff --git a/src/components/quiz/multiple-choice/multiple-choice.stories.tsx b/src/components/quiz/multiple-choice/multiple-choice.stories.tsx index 9a7eac6..9b8569f 100644 --- a/src/components/quiz/multiple-choice/multiple-choice.stories.tsx +++ b/src/components/quiz/multiple-choice/multiple-choice.stories.tsx @@ -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 = { - 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; +export default meta +type Story = StoryObj // 基础示例 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([]); + const [values, setValues] = useState([]) return (
{ - alert(`提交的值: ${vals.join(", ")}`); + alert(`提交的值: ${vals.join(', ')}`) }} />
- 当前选中: {values.length > 0 ? values.join(", ") : "未选择"} + 当前选中: {values.length > 0 ? values.join(', ') : '未选择'}
- ); + ) }, -}; +} // 限制最大选择数量 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 ( (
- {isSelected && "✓"} + {isSelected && '✓'}
{option.label}
)} onSubmit={(values) => { - console.log("选中的值:", values); + console.log('选中的值:', values) }} /> - ); + ) }, -}; - +} diff --git a/src/components/quiz/multiple-choice/multiple-choice.tsx b/src/components/quiz/multiple-choice/multiple-choice.tsx index 8c70b94..34ff973 100644 --- a/src/components/quiz/multiple-choice/multiple-choice.tsx +++ b/src/components/quiz/multiple-choice/multiple-choice.tsx @@ -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( maxSelections, className, optionClassName, - submitText = "提交", + submitText = '提交', showSubmitButton = true, renderOption, }, @@ -40,96 +37,79 @@ const MultipleChoice = forwardRef( ) => { // 标准化选项数据 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( - defaultValue || [] - ); - const currentValue = isControlled ? controlledValue : internalValue; + const isControlled = controlledValue !== undefined + const [internalValue, setInternalValue] = useState(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 (
handleOptionClick(option.value)} @@ -137,10 +117,8 @@ const MultipleChoice = forwardRef(
{isSelected && ( @@ -162,21 +140,21 @@ const MultipleChoice = forwardRef( {option.label}
- ); - }; + ) + } return ( -
+
{normalizedOptions.map((option) => { - const isSelected = currentValue.includes(option.value); + const isSelected = currentValue.includes(option.value) return (
{renderOption ? renderOption(option, isSelected) : defaultRenderOption(option, isSelected)}
- ); + ) })}
@@ -185,27 +163,22 @@ const MultipleChoice = forwardRef( {minSelections !== undefined && (
至少选择 {minSelections} 项 - {maxSelections !== undefined && - `,最多选择 ${maxSelections} 项`} - {maxSelections === undefined && "(无上限)"} + {maxSelections !== undefined && `,最多选择 ${maxSelections} 项`} + {maxSelections === undefined && '(无上限)'}
)} {maxSelections !== undefined && minSelections === undefined && ( -
- 最多选择 {maxSelections} 项 -
+
最多选择 {maxSelections} 项
)} -
- 已选择 {currentValue.length} 项 -
+
已选择 {currentValue.length} 项
)}
- ); + ) } -); +) -MultipleChoice.displayName = "MultipleChoice"; - -export default MultipleChoice; +MultipleChoice.displayName = 'MultipleChoice' +export default MultipleChoice diff --git a/src/components/quiz/multiple-choice/multiple-choice.types.ts b/src/components/quiz/multiple-choice/multiple-choice.types.ts index 570d351..a4e5ad6 100644 --- a/src/components/quiz/multiple-choice/multiple-choice.types.ts +++ b/src/components/quiz/multiple-choice/multiple-choice.types.ts @@ -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 } - diff --git a/src/components/quiz/single-choice/index.ts b/src/components/quiz/single-choice/index.ts index a0e4ab7..047bd9e 100644 --- a/src/components/quiz/single-choice/index.ts +++ b/src/components/quiz/single-choice/index.ts @@ -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' diff --git a/src/components/quiz/single-choice/single-choice.stories.tsx b/src/components/quiz/single-choice/single-choice.stories.tsx index 7846c4d..c892671 100644 --- a/src/components/quiz/single-choice/single-choice.stories.tsx +++ b/src/components/quiz/single-choice/single-choice.stories.tsx @@ -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 = { - 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; +export default meta +type Story = StoryObj // 基础示例 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(""); + const [value, setValue] = useState('') return (
{ - alert(`提交的值: ${val}`); + alert(`提交的值: ${val}`) }} /> -
- 当前选中: {value || "未选择"} -
+
当前选中: {value || '未选择'}
- ); + ) }, -}; +} // 禁用状态 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 ( (
- {isSelected && ( -
- )} + {isSelected &&
}
{option.label}
)} onSubmit={(value) => { - console.log("选中的值:", value); + console.log('选中的值:', value) }} /> - ); + ) }, -}; - +} diff --git a/src/components/quiz/single-choice/single-choice.tsx b/src/components/quiz/single-choice/single-choice.tsx index 0be4ef4..c951ee4 100644 --- a/src/components/quiz/single-choice/single-choice.tsx +++ b/src/components/quiz/single-choice/single-choice.tsx @@ -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( disabled = false, className, optionClassName, - submitText: _submitText = "提交", // 保留入参但不使用 + submitText: _submitText = '提交', // 保留入参但不使用 showSubmitButton: _showSubmitButton = false, // 保留入参但不使用 renderOption, }, @@ -37,80 +34,73 @@ const SingleChoice = forwardRef( ) => { // 标准化选项数据 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( - defaultValue || "" - ); - const currentValue = isControlled ? controlledValue : internalValue; + const isControlled = controlledValue !== undefined + const [internalValue, setInternalValue] = useState(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 (
handleOptionClick(option.value)} > {option.label}
- ); - }; + ) + } return ( -
+
{normalizedOptions.map((option) => { - const isSelected = currentValue === option.value; + const isSelected = currentValue === option.value return (
{renderOption ? renderOption(option, isSelected) : defaultRenderOption(option, isSelected)}
- ); + ) })}
{/* 提交按钮移除:点击选项即提交 */}
- ); + ) } -); +) -SingleChoice.displayName = "SingleChoice"; +SingleChoice.displayName = 'SingleChoice' -export default SingleChoice; +export default SingleChoice diff --git a/src/components/quiz/single-choice/single-choice.types.ts b/src/components/quiz/single-choice/single-choice.types.ts index cd1c752..021ccd0 100644 --- a/src/components/quiz/single-choice/single-choice.types.ts +++ b/src/components/quiz/single-choice/single-choice.types.ts @@ -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 } - diff --git a/src/components/select/index.ts b/src/components/select/index.ts index 3cbf1fa..e0b97a7 100644 --- a/src/components/select/index.ts +++ b/src/components/select/index.ts @@ -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' diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index 243acfc..9a18aa0 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -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( options, value, onChange, - placeholder = "请选择", + placeholder = '请选择', disabled = false, className, error = false, @@ -37,129 +37,120 @@ const Select = forwardRef( }, ref ) => { - const [isOpen, setIsOpen] = useState(false); - const [focusedIndex, setFocusedIndex] = useState(-1); - const triggerRef = useRef(null); - const dropdownRef = useRef(null); - const measureRef = useRef(null); + const [isOpen, setIsOpen] = useState(false) + const [focusedIndex, setFocusedIndex] = useState(-1) + const triggerRef = useRef(null) + const dropdownRef = useRef(null) + const measureRef = useRef(null) const [dropdownPosition, setDropdownPosition] = useState<{ - top: number; - left: number; - width: number; - } | null>(null); - const [contentWidth, setContentWidth] = useState(null); + top: number + left: number + width: number + } | null>(null) + const [contentWidth, setContentWidth] = useState(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( 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(