feat: 更新 ESLint 和 Prettier 配置,增强代码质量管理

- 在 .gitignore 中添加 public/audio/* 以忽略音频文件。
- 更新 bun.lock,添加 eslint-config-prettier 和 eslint-plugin-prettier 依赖,确保 ESLint 与 Prettier 的兼容性。
- 修改 eslint.config.ts,整合 Prettier 规则,优化代码风格一致性。
- 在 package.json 中添加 lint:fix 和 format 脚本,简化代码格式化和修复流程。
- 更新 tailwind.config.ts 和其他组件文件,统一代码风格,提升可读性。
This commit is contained in:
zhongyidian committed 2026-03-05 13:50:47 +08:00
1 parent 5d00adc1a5
commit 76c67b492e
216 files changed
+12029 -13551

No files matched your search

+4 -1
View File
@@ -1,7 +1,10 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"mcp__chrome-devtools__click" "mcp__chrome-devtools__click",
"Bash(for i in 1 2 3 4 5 6 7 8)",
"Bash(do echo \"=== place$i import line ===\" grep -n \"useChenyunStore\\\\|saveChenyunStore\" \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/src/pages/chenyun/place/place$i.tsx\" echo \"=== place$i 继续前进 block ===\" grep -n -A5 \"继续前进\" \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/src/pages/chenyun/place/place$i.tsx\")",
"Bash(done)"
] ]
} }
} }
+1
View File
@@ -13,6 +13,7 @@ dist-ssr
*.local *.local
public/video/* public/video/*
public/words/* public/words/*
public/audio/*
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
+8
View File
@@ -0,0 +1,8 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"endOfLine": "lf"
}
+12 -13
View File
@@ -1,28 +1,27 @@
import type { StorybookConfig } from "@storybook/react-vite"; import type { StorybookConfig } from '@storybook/react-vite'
import { mergeConfig } from "vite"; import { mergeConfig } from 'vite'
import path from "path"; import path from 'path'
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [ addons: [
"@storybook/addon-links", '@storybook/addon-links',
"@storybook/addon-essentials", '@storybook/addon-essentials',
"@storybook/addon-interactions", '@storybook/addon-interactions',
], ],
framework: { framework: {
name: "@storybook/react-vite", name: '@storybook/react-vite',
options: {}, options: {},
}, },
async viteFinal(config) { async viteFinal(config) {
return mergeConfig(config, { return mergeConfig(config, {
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "../src"), '@': path.resolve(__dirname, '../src'),
}, },
}, },
}); })
}, },
}; }
export default config;
export default config
+4 -5
View File
@@ -1,5 +1,5 @@
import type { Preview } from "@storybook/react"; import type { Preview } from '@storybook/react'
import "../src/index.css"; import '../src/index.css'
const preview: Preview = { const preview: Preview = {
parameters: { parameters: {
@@ -10,7 +10,6 @@ const preview: Preview = {
}, },
}, },
}, },
}; }
export default preview;
export default preview
+17
View File
@@ -35,10 +35,13 @@
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.6", "baseline-browser-mapping": "^2.9.6",
"eslint": "^9.36.0", "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-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22", "eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0", "globals": "^16.4.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1",
"storybook": "^8.4.7", "storybook": "^8.4.7",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"typescript": "^5.9.3", "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=="], "@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=="], "@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=="], "@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": ["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-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=="], "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-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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "tailwindcss": ["tailwindcss@4.2.1", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.1.tgz", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
+24 -19
View File
@@ -1,33 +1,38 @@
import js from "@eslint/js"; import js from '@eslint/js'
import globals from "globals"; import globals from 'globals'
import reactHooks from "eslint-plugin-react-hooks"; import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from "eslint-plugin-react-refresh"; import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from "typescript-eslint"; import tseslint from 'typescript-eslint'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
export default tseslint.config( 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], extends: [
files: ["**/*.{ts,tsx}"], js.configs.recommended,
...tseslint.configs.recommended,
eslintPluginPrettierRecommended, // Prettier + ESLint 整合
],
files: ['**/*.{ts,tsx}'],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
plugins: { plugins: {
"react-hooks": reactHooks, 'react-hooks': reactHooks,
"react-refresh": reactRefresh, 'react-refresh': reactRefresh,
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
"warn", '@typescript-eslint/no-unused-vars': [
{ allowConstantExport: true }, 'warn',
{ varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
], ],
"@typescript-eslint/no-unused-vars": [ '@typescript-eslint/no-explicit-any': 'off',
"warn", // 禁用可能与 Prettier 冲突的 ESLint 规则
{ varsIgnorePattern: "^_", argsIgnorePattern: "^_" }, quotes: 'off',
], semi: 'off',
"@typescript-eslint/no-explicit-any": "off",
}, },
} }
); )
+6
View File
@@ -11,6 +11,9 @@
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"preview": "vite preview", "preview": "vite preview",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
@@ -47,10 +50,13 @@
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.6", "baseline-browser-mapping": "^2.9.6",
"eslint": "^9.36.0", "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-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22", "eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0", "globals": "^16.4.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1",
"storybook": "^8.4.7", "storybook": "^8.4.7",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"typescript": "^5.9.3", "typescript": "^5.9.3",
+17 -17
View File
@@ -1,30 +1,30 @@
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from 'react-router-dom'
import { Suspense, useEffect } from "react"; import { Suspense, useEffect } from 'react'
import router from "./router"; import router from './router'
import { ScaleProvider, useScale } from "./contexts/ScaleContext"; import { ScaleProvider, useScale } from './contexts/ScaleContext'
import { RotateHint } from "./components/RotateHint"; import { RotateHint } from './components/RotateHint'
import { useUserStore } from "./store"; import { useUserStore } from './store'
/** /**
* 应用内容组件 * 应用内容组件
* 使用 useScale 获取缩放样式 * 使用 useScale 获取缩放样式
*/ */
function AppContent() { function AppContent() {
const { scaleStyle } = useScale(); const { scaleStyle } = useScale()
const setCourseInfo = useUserStore((state) => state.setCourseInfo); const setCourseInfo = useUserStore((state) => state.setCourseInfo)
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search)
const courseId = params.get("courseid"); const courseId = params.get('courseid')
const uid = params.get("uid"); const uid = params.get('uid')
if (courseId || uid) { if (courseId || uid) {
setCourseInfo({ setCourseInfo({
courseId: courseId ?? null, courseId: courseId ?? null,
uid: uid ?? null, uid: uid ?? null,
}); })
} }
}, [setCourseInfo]); }, [setCourseInfo])
return ( return (
<> <>
@@ -32,7 +32,7 @@ function AppContent() {
<div <div
style={{ style={{
...scaleStyle, ...scaleStyle,
overflow: "hidden", overflow: 'hidden',
}} }}
> >
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
@@ -42,7 +42,7 @@ function AppContent() {
</div> </div>
<RotateHint /> <RotateHint />
</> </>
); )
} }
/** /**
@@ -54,7 +54,7 @@ function App() {
<ScaleProvider> <ScaleProvider>
<AppContent /> <AppContent />
</ScaleProvider> </ScaleProvider>
); )
} }
export default App; export default App
+13 -14
View File
@@ -1,34 +1,33 @@
import { useScale } from "@/contexts/ScaleContext"; import { useScale } from '@/contexts/ScaleContext'
/** /**
* 横屏提示组件 * 横屏提示组件
* 在移动端竖屏时显示提示,引导用户横屏观看 * 在移动端竖屏时显示提示,引导用户横屏观看
*/ */
export function RotateHint() { export function RotateHint() {
const { isMobilePortrait } = useScale(); const { isMobilePortrait } = useScale()
if (!isMobilePortrait) return null; if (!isMobilePortrait) return null
return ( return (
<div <div
style={{ style={{
position: "fixed", position: 'fixed',
inset: 0, inset: 0,
background: background:
"rgba(0,0,0,0.55) radial-gradient(circle at center, rgba(255,255,255,0.1), rgba(0,0,0,0.6))", 'rgba(0,0,0,0.55) radial-gradient(circle at center, rgba(255,255,255,0.1), rgba(0,0,0,0.6))',
color: "#fff", color: '#fff',
zIndex: 9999, zIndex: 9999,
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
textAlign: "center", textAlign: 'center',
padding: "24px", padding: '24px',
fontSize: "20px", fontSize: '20px',
lineHeight: 1.5, lineHeight: 1.5,
}} }}
> >
请横屏观看,以获得最佳体验 请横屏观看,以获得最佳体验
</div> </div>
); )
} }
@@ -1,64 +1,59 @@
import { createRoot, Root } from "react-dom/client"; import { createRoot, Root } from 'react-dom/client'
import { motion } from "framer-motion"; import { motion } from 'framer-motion'
import React from "react"; import React from 'react'
export type FlyToPosition = export type FlyToPosition = 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
| "center"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";
export interface FlyToAnimationOptions { export interface FlyToAnimationOptions {
/** 起始元素的 ref */ /** 起始元素的 ref */
fromRef: React.RefObject<HTMLElement | null>; fromRef: React.RefObject<HTMLElement | null>
/** 目标元素的 ref */ /** 目标元素的 ref */
toRef: React.RefObject<HTMLElement | null>; toRef: React.RefObject<HTMLElement | null>
/** 目标位置(相对于目标元素) */ /** 目标位置(相对于目标元素) */
targetPosition?: FlyToPosition; targetPosition?: FlyToPosition
/** 动画持续时间(毫秒) */ /** 动画持续时间(毫秒) */
duration?: number; duration?: number
/** 动画缓动函数 */ /** 动画缓动函数 */
ease?: string | number[]; ease?: string | number[]
/** 动画完成回调 */ /** 动画完成回调 */
onComplete?: () => void; onComplete?: () => void
/** 自定义样式 */ /** 自定义样式 */
className?: string; className?: string
/** 缩放比例(从起始到目标) */ /** 缩放比例(从起始到目标) */
scale?: { from: number; to: number }; scale?: { from: number; to: number }
/** 透明度(从起始到目标) */ /** 透明度(从起始到目标) */
opacity?: number[]; opacity?: number[]
} }
interface AnimationInstance extends FlyToAnimationOptions { interface AnimationInstance extends FlyToAnimationOptions {
id: string; id: string
startPos: { x: number; y: number }; startPos: { x: number; y: number }
targetPos: { x: number; y: number }; targetPos: { x: number; y: number }
elementHtml: string; elementHtml: string
} }
class FlyToAnimationManagerClass { class FlyToAnimationManagerClass {
private animations: Map<string, AnimationInstance> = new Map(); private animations: Map<string, AnimationInstance> = new Map()
private containerRoot: Root | null = null; private containerRoot: Root | null = null
// 初始化容器 // 初始化容器
private initContainer() { private initContainer() {
if (typeof window === "undefined") return; if (typeof window === 'undefined') return
if (this.containerRoot) return; if (this.containerRoot) return
const container = document.createElement("div"); const container = document.createElement('div')
container.id = "fly-to-animation-container"; container.id = 'fly-to-animation-container'
document.body.appendChild(container); document.body.appendChild(container)
this.containerRoot = createRoot(container); this.containerRoot = createRoot(container)
this.render(); this.render()
} }
// 渲染动画容器 // 渲染动画容器
private 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( this.containerRoot.render(
<> <>
@@ -66,150 +61,140 @@ class FlyToAnimationManagerClass {
<FlyToAnimationItem key={animation.id} animation={animation} /> <FlyToAnimationItem key={animation.id} animation={animation} />
))} ))}
</> </>
); )
} }
// 通知更新 // 通知更新
private notify() { private notify() {
this.render(); this.render()
} }
// 创建动画 // 创建动画
flyTo(options: FlyToAnimationOptions) { flyTo(options: FlyToAnimationOptions) {
const { fromRef, toRef } = options; const { fromRef, toRef } = options
if (!fromRef?.current || !toRef?.current) { if (!fromRef?.current || !toRef?.current) {
console.warn("FlyToAnimation: fromRef 或 toRef 未设置"); console.warn('FlyToAnimation: fromRef 或 toRef 未设置')
return; return
} }
this.initContainer(); this.initContainer()
// 使用 requestAnimationFrame 确保在下一帧获取准确的位置 // 使用 requestAnimationFrame 确保在下一帧获取准确的位置
requestAnimationFrame(() => { requestAnimationFrame(() => {
const fromRect = fromRef.current!.getBoundingClientRect(); const fromRect = fromRef.current!.getBoundingClientRect()
const toRect = toRef.current!.getBoundingClientRect(); const toRect = toRef.current!.getBoundingClientRect()
// 计算起始位置(起始元素的中心) // 计算起始位置(起始元素的中心)
const startX = fromRect.left + fromRect.width / 2; const startX = fromRect.left + fromRect.width / 2
const startY = fromRect.top + fromRect.height / 2; const startY = fromRect.top + fromRect.height / 2
// 计算目标位置 // 计算目标位置
const targetPosition = options.targetPosition || "center"; const targetPosition = options.targetPosition || 'center'
let targetX = 0; let targetX = 0
let targetY = 0; let targetY = 0
switch (targetPosition) { switch (targetPosition) {
case "center": case 'center':
targetX = toRect.left + toRect.width / 2; targetX = toRect.left + toRect.width / 2
targetY = toRect.top + toRect.height / 2; targetY = toRect.top + toRect.height / 2
break; break
case "top-left": case 'top-left':
targetX = toRect.left; targetX = toRect.left
targetY = toRect.top; targetY = toRect.top
break; break
case "top-right": case 'top-right':
targetX = toRect.right; targetX = toRect.right
targetY = toRect.top; targetY = toRect.top
break; break
case "bottom-left": case 'bottom-left':
targetX = toRect.left; targetX = toRect.left
targetY = toRect.bottom; targetY = toRect.bottom
break; break
case "bottom-right": case 'bottom-right':
targetX = toRect.right; targetX = toRect.right
targetY = toRect.bottom; targetY = toRect.bottom
break; break
} }
// 克隆起始元素的 HTML 内容和样式 // 克隆起始元素的 HTML 内容和样式
// 使用 cloneNode 深度克隆,包括所有子元素 // 使用 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) => { allStyles.forEach((prop) => {
const value = computedStyle.getPropertyValue(prop); const value = computedStyle.getPropertyValue(prop)
if (value) { if (value) {
clonedElement.style.setProperty(prop, value); clonedElement.style.setProperty(prop, value)
} }
}); })
// 确保克隆元素的尺寸与原始元素一致 // 确保克隆元素的尺寸与原始元素一致
clonedElement.style.width = `${fromRect.width}px`; clonedElement.style.width = `${fromRect.width}px`
clonedElement.style.height = `${fromRect.height}px`; clonedElement.style.height = `${fromRect.height}px`
clonedElement.style.position = "absolute"; clonedElement.style.position = 'absolute'
clonedElement.style.left = "0"; clonedElement.style.left = '0'
clonedElement.style.top = "0"; clonedElement.style.top = '0'
clonedElement.style.margin = "0"; clonedElement.style.margin = '0'
clonedElement.style.padding = "0"; clonedElement.style.padding = '0'
// 获取克隆后的 HTML(只获取内部内容,不包含外层元素) // 获取克隆后的 HTML(只获取内部内容,不包含外层元素)
const elementHtml = clonedElement.innerHTML; const elementHtml = clonedElement.innerHTML
// 创建动画实例 // 创建动画实例
const id = `fly-to-${Date.now()}-${Math.random() const id = `fly-to-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
.toString(36)
.slice(2, 11)}`;
const instance: AnimationInstance = { const instance: AnimationInstance = {
id, id,
...options, ...options,
startPos: { x: startX, y: startY }, startPos: { x: startX, y: startY },
targetPos: { x: targetX, y: targetY }, targetPos: { x: targetX, y: targetY },
elementHtml, elementHtml,
}; }
this.animations.set(id, instance); this.animations.set(id, instance)
this.notify(); this.notify()
// 动画完成后自动清理 // 动画完成后自动清理
const duration = options.duration || 1200; const duration = options.duration || 1200
setTimeout(() => { setTimeout(() => {
this.remove(id); this.remove(id)
options.onComplete?.(); options.onComplete?.()
}, duration); }, duration)
}); })
} }
// 移除动画 // 移除动画
remove(id: string) { remove(id: string) {
this.animations.delete(id); this.animations.delete(id)
this.notify(); this.notify()
} }
} }
// 创建全局单例 // 创建全局单例
export const flyToAnimationManager = new FlyToAnimationManagerClass(); export const flyToAnimationManager = new FlyToAnimationManagerClass()
// 单个动画项组件 // 单个动画项组件
const FlyToAnimationItem = ({ const FlyToAnimationItem = ({ animation }: { animation: AnimationInstance }) => {
animation,
}: {
animation: AnimationInstance;
}) => {
const { const {
startPos, startPos,
targetPos, targetPos,
elementHtml, elementHtml,
duration = 1200, duration = 1200,
ease = "easeInOut", ease = 'easeInOut',
className = "", className = '',
scale = { from: 1, to: 0.3 }, scale = { from: 1, to: 0.3 },
opacity = [1, 0.8, 0], opacity = [1, 0.8, 0],
} = animation; } = animation
// 计算 opacity 的 times 数组 // 计算 opacity 的 times 数组
const opacityTimes = const opacityTimes = opacity.length > 1 ? opacity.map((_, i) => i / (opacity.length - 1)) : [0, 1]
opacity.length > 1
? opacity.map((_, i) => i / (opacity.length - 1))
: [0, 1];
// 如果 opacity 是数组,使用整个数组;否则使用单个值 // 如果 opacity 是数组,使用整个数组;否则使用单个值
const animateOpacity = const animateOpacity = opacity.length > 1 ? opacity : (opacity[opacity.length - 1] ?? 0)
opacity.length > 1 ? opacity : opacity[opacity.length - 1] ?? 0;
return ( return (
<motion.div <motion.div
@@ -247,10 +232,7 @@ const FlyToAnimationItem = ({
`translate(-50%, -50%) translateX(${x}) translateY(${y}) scale(${scale})` `translate(-50%, -50%) translateX(${x}) translateY(${y}) scale(${scale})`
} }
> >
<div <div dangerouslySetInnerHTML={{ __html: elementHtml }} style={{ pointerEvents: 'none' }} />
dangerouslySetInnerHTML={{ __html: elementHtml }}
style={{ pointerEvents: "none" }}
/>
</motion.div> </motion.div>
); )
}; }
+4 -4
View File
@@ -1,5 +1,5 @@
import { flyToAnimationManager } from "./FlyToAnimationManager"; import { flyToAnimationManager } from './FlyToAnimationManager'
import type { FlyToAnimationOptions } from "./FlyToAnimationManager"; import type { FlyToAnimationOptions } from './FlyToAnimationManager'
/** /**
* 飞行动画函数 * 飞行动画函数
@@ -19,5 +19,5 @@ import type { FlyToAnimationOptions } from "./FlyToAnimationManager";
* ``` * ```
*/ */
export const flyToAnimation = (options: FlyToAnimationOptions) => { export const flyToAnimation = (options: FlyToAnimationOptions) => {
flyToAnimationManager.flyTo(options); flyToAnimationManager.flyTo(options)
}; }
+3 -3
View File
@@ -1,4 +1,4 @@
// 函数式 API // 函数式 API
export { flyToAnimation } from "./flyToAnimation"; export { flyToAnimation } from './flyToAnimation'
export type { FlyToAnimationOptions } from "./FlyToAnimationManager"; export type { FlyToAnimationOptions } from './FlyToAnimationManager'
export type { FlyToPosition } from "./FlyToAnimationManager"; export type { FlyToPosition } from './FlyToAnimationManager'
+27 -27
View File
@@ -1,56 +1,56 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from '@storybook/react'
import { Button, ImageButton } from "./button"; import { Button, ImageButton } from './button'
const meta: Meta<typeof Button> = { const meta: Meta<typeof Button> = {
title: "Components/Button", title: 'Components/Button',
component: Button, component: Button,
tags: ["autodocs"], tags: ['autodocs'],
argTypes: { argTypes: {
variant: { variant: {
control: { type: "select" }, control: { type: 'select' },
options: ["primary", "secondary", "ghost"], options: ['primary', 'secondary', 'ghost'],
}, },
}, },
}; }
export default meta; export default meta
type Story = StoryObj<typeof Button>; type Story = StoryObj<typeof Button>
export const Primary: Story = { export const Primary: Story = {
args: { args: {
variant: "primary", variant: 'primary',
children: "按钮", children: '按钮',
}, },
}; }
export const Secondary: Story = { export const Secondary: Story = {
args: { args: {
variant: "secondary", variant: 'secondary',
children: "次要按钮", children: '次要按钮',
}, },
}; }
export const Ghost: Story = { export const Ghost: Story = {
args: { args: {
variant: "ghost", variant: 'ghost',
children: "幽灵按钮", children: '幽灵按钮',
}, },
}; }
export const Disabled: Story = { export const Disabled: Story = {
args: { args: {
disabled: true, disabled: true,
children: "禁用状态", children: '禁用状态',
}, },
}; }
export const Loading: Story = { export const Loading: Story = {
args: { args: {
loading: true, loading: true,
children: "加载中...", children: '加载中...',
}, },
}; }
export const WithImage: Story = { export const WithImage: Story = {
render: () => ( render: () => (
@@ -64,12 +64,12 @@ export const WithImage: Story = {
/> />
</div> </div>
), ),
}; }
export const Playground: Story = { export const Playground: Story = {
args: { args: {
variant: "primary", variant: 'primary',
children: "点击我", children: '点击我',
className: "px-8 py-3 text-lg", className: 'px-8 py-3 text-lg',
}, },
}; }
+43 -51
View File
@@ -1,6 +1,6 @@
import { forwardRef, useState, useEffect } from "react"; import { forwardRef, useState, useEffect } from 'react'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
import type { BaseButtonProps, ImageButtonProps } from "./button.types"; import type { BaseButtonProps, ImageButtonProps } from './button.types'
/** /**
* 基础按钮组件 * 基础按钮组件
@@ -9,44 +9,39 @@ const Button = forwardRef<HTMLButtonElement, BaseButtonProps>(
( (
{ {
children, children,
className = "", className = '',
variant = "primary", variant = 'primary',
disabled = false, disabled = false,
loading = false, loading = false,
type = "button", type = 'button',
...props ...props
}, },
ref ref
) => { ) => {
const baseClasses = 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 = { const variantClasses = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90", primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: 'hover:bg-accent hover:text-accent-foreground',
}; }
return ( return (
<button <button
ref={ref} ref={ref}
type={type as "button" | "submit" | "reset" | undefined} type={type as 'button' | 'submit' | 'reset' | undefined}
className={cn( className={cn(baseClasses, variantClasses[variant], 'px-4 py-2', className)}
baseClasses,
variantClasses[variant],
"px-4 py-2",
className
)}
disabled={disabled || loading} disabled={disabled || loading}
{...props} {...props}
> >
{loading ? "加载中..." : children} {loading ? '加载中...' : children}
</button> </button>
); )
} }
); )
Button.displayName = "Button"; Button.displayName = 'Button'
/** /**
* 图片按钮组件 * 图片按钮组件
@@ -57,50 +52,50 @@ const ImageButton = forwardRef<HTMLButtonElement, ImageButtonProps>(
src, src,
alt, alt,
hoverSrc, hoverSrc,
width = "auto", width = 'auto',
height = "auto", height = 'auto',
className = "", className = '',
disabled = false, disabled = false,
loading = false, loading = false,
type = "button", type = 'button',
onMouseEnter, onMouseEnter,
onMouseLeave, onMouseLeave,
...props ...props
}, },
ref ref
) => { ) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false)
const [imageSrc, setImageSrc] = useState(src); const [imageSrc, setImageSrc] = useState(src)
useEffect(() => { useEffect(() => {
if (hoverSrc) { if (hoverSrc) {
const img = new Image(); const img = new Image()
img.src = hoverSrc; img.src = hoverSrc
} }
}, [hoverSrc]); }, [hoverSrc])
const handleMouseEnter = (e: React.MouseEvent<HTMLButtonElement>) => { const handleMouseEnter = (e: React.MouseEvent<HTMLButtonElement>) => {
setIsHovered(true); setIsHovered(true)
if (hoverSrc) { if (hoverSrc) {
setImageSrc(hoverSrc); setImageSrc(hoverSrc)
}
onMouseEnter?.(e)
} }
onMouseEnter?.(e);
};
const handleMouseLeave = (e: React.MouseEvent<HTMLButtonElement>) => { const handleMouseLeave = (e: React.MouseEvent<HTMLButtonElement>) => {
setIsHovered(false); setIsHovered(false)
setImageSrc(src); setImageSrc(src)
onMouseLeave?.(e); onMouseLeave?.(e)
}; }
return ( return (
<button <button
ref={ref} ref={ref}
type={type as "button" | "submit" | "reset" | undefined} type={type as 'button' | 'submit' | 'reset' | undefined}
className={cn( className={cn(
"transition-opacity hover:opacity-90 active:opacity-80", 'transition-opacity hover:opacity-90 active:opacity-80',
"focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary", 'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary',
"disabled:opacity-50 disabled:cursor-not-allowed", 'disabled:opacity-50 disabled:cursor-not-allowed',
className className
)} )}
disabled={disabled || loading} disabled={disabled || loading}
@@ -113,17 +108,14 @@ const ImageButton = forwardRef<HTMLButtonElement, ImageButtonProps>(
alt={alt} alt={alt}
width={width} width={width}
height={height} height={height}
className={cn( className={cn('object-contain transition-transform', isHovered && 'transform scale-105')}
"object-contain transition-transform",
isHovered && "transform scale-105"
)}
draggable="false" draggable="false"
/> />
</button> </button>
); )
} }
); )
ImageButton.displayName = "ImageButton"; ImageButton.displayName = 'ImageButton'
export { Button, ImageButton }; export { Button, ImageButton }
+13 -14
View File
@@ -1,30 +1,29 @@
import type { ButtonHTMLAttributes, ReactNode } from "react"; import type { ButtonHTMLAttributes, ReactNode } from 'react'
/** 基础按钮属性 */ /** 基础按钮属性 */
export interface BaseButtonProps export interface BaseButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
extends ButtonHTMLAttributes<HTMLButtonElement> {
/** 是否显示加载状态 */ /** 是否显示加载状态 */
loading?: boolean; loading?: boolean
/** 按钮变体 */ /** 按钮变体 */
variant?: "primary" | "secondary" | "ghost"; variant?: 'primary' | 'secondary' | 'ghost'
/** 自定义类名 */ /** 自定义类名 */
className?: string; className?: string
/** 子元素 */ /** 子元素 */
children: ReactNode; children: ReactNode
} }
/** 图片按钮属性 */ /** 图片按钮属性 */
export interface ImageButtonProps extends Omit<BaseButtonProps, "children"> { export interface ImageButtonProps extends Omit<BaseButtonProps, 'children'> {
/** 图片URL */ /** 图片URL */
src: string; src: string
/** 图片替代文本 */ /** 图片替代文本 */
alt: string; alt: string
/** 图片宽度 */ /** 图片宽度 */
width?: number | string; width?: number | string
/** 图片高度 */ /** 图片高度 */
height?: number | string; height?: number | string
/** 悬停时显示的图片URL */ /** 悬停时显示的图片URL */
hoverSrc?: string; hoverSrc?: string
} }
export type ButtonProps = BaseButtonProps | ImageButtonProps; export type ButtonProps = BaseButtonProps | ImageButtonProps
+2 -2
View File
@@ -1,2 +1,2 @@
export * from "./button"; export * from './button'
export * from "./button.types"; export * from './button.types'
+40 -57
View File
@@ -1,16 +1,16 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react'
import { Image } from "@/components"; import { Image } from '@/components'
import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
import type { Clue } from "@/store/clueStore"; import type { Clue } from '@/store/clueStore'
export interface ClueBookModalProps { export interface ClueBookModalProps {
visible: boolean; visible: boolean
clues: Clue[]; clues: Clue[]
onClose: () => void; onClose: () => void
getClueImage: (clueId: string) => string; getClueImage: (clueId: string) => string
getClueDetailImage: (clueId: string) => string; getClueDetailImage: (clueId: string) => string
getClueHintImage?: (clueId: string) => string | null; getClueHintImage?: (clueId: string) => string | null
} }
/** /**
@@ -24,34 +24,34 @@ export const ClueBookModal = ({
getClueDetailImage, getClueDetailImage,
getClueHintImage, getClueHintImage,
}: ClueBookModalProps) => { }: ClueBookModalProps) => {
const [selectedClueId, setSelectedClueId] = useState<string | null>(null); const [selectedClueId, setSelectedClueId] = useState<string | null>(null)
const [showClueHint, setShowClueHint] = useState(false); const [showClueHint, setShowClueHint] = useState(false)
// 默认选中第一个已收集的线索 // 默认选中第一个已收集的线索
useEffect(() => { useEffect(() => {
if (visible && clues.length > 0 && !selectedClueId) { if (visible && clues.length > 0 && !selectedClueId) {
const firstCollected = clues.find((c) => c.collected); const firstCollected = clues.find((c) => c.collected)
if (firstCollected) { if (firstCollected) {
setSelectedClueId(firstCollected.id); setSelectedClueId(firstCollected.id)
} }
} }
}, [visible, clues, selectedClueId]); }, [visible, clues, selectedClueId])
// 当切换线索时,关闭线索提示 // 当切换线索时,关闭线索提示
useEffect(() => { useEffect(() => {
setShowClueHint(false); setShowClueHint(false)
}, [selectedClueId]); }, [selectedClueId])
// 检查当前线索是否有提示 // 检查当前线索是否有提示
const hasClueHint = (clueId: string): boolean => { const hasClueHint = (clueId: string): boolean => {
if (!getClueHintImage) return false; if (!getClueHintImage) return false
const hintImage = getClueHintImage(clueId); const hintImage = getClueHintImage(clueId)
return hintImage !== null && hintImage !== undefined; 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 ( return (
<div className="absolute inset-0 z-50 flex items-center justify-center"> <div className="absolute inset-0 z-50 flex items-center justify-center">
@@ -67,9 +67,7 @@ export const ClueBookModal = ({
<div <div
className="absolute inset-0 bg-cover bg-center bg-no-repeat" className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{ style={{
backgroundImage: getBackgroundImage( backgroundImage: getBackgroundImage('sunzhongshan/reflection/详情线索簿背景'),
"sunzhongshan/reflection/详情线索簿背景"
),
}} }}
/> />
@@ -81,8 +79,8 @@ export const ClueBookModal = ({
<div className="text-center text-gray-500 mt-8">暂无线索</div> <div className="text-center text-gray-500 mt-8">暂无线索</div>
) : ( ) : (
clues.map((clue) => { clues.map((clue) => {
const isSelected = clue.id === selectedClueId; const isSelected = clue.id === selectedClueId
const isCollected = clue.collected; const isCollected = clue.collected
return ( return (
<div <div
@@ -95,27 +93,22 @@ export const ClueBookModal = ({
// )} // )}
onClick={() => { onClick={() => {
if (isCollected) { if (isCollected) {
setSelectedClueId(clue.id); setSelectedClueId(clue.id)
} }
}} }}
> >
{/* 线索缩略图 - 带倾斜效果 */} {/* 线索缩略图 - 带倾斜效果 */}
<Image <Image
src={ src={isCollected ? getClueImage(clue.id) : getClueImage(clue.id)}
isCollected
? getClueImage(clue.id)
: getClueImage(clue.id)
}
className={cn( className={cn(
"hover:scale-110 hover:brightness-115 cursor-pointer transition-all duration-300", 'hover:scale-110 hover:brightness-115 cursor-pointer transition-all duration-300',
isSelected && isSelected && 'scale-115 brightness-115 z-10 grayscale-0',
"scale-115 brightness-115 z-10 grayscale-0",
!isCollected && !isCollected &&
"opacity-50 grayscale cursor-not-allowed hover:scale-100 hover:brightness-100" 'opacity-50 grayscale cursor-not-allowed hover:scale-100 hover:brightness-100'
)} )}
/> />
</div> </div>
); )
}) })
)} )}
</div> </div>
@@ -126,20 +119,12 @@ export const ClueBookModal = ({
<div className="w-full h-full flex flex-col items-center justify-center relative"> <div className="w-full h-full flex flex-col items-center justify-center relative">
{/* 显示线索详情图片 */} {/* 显示线索详情图片 */}
<div className="relative h-full text-center"> <div className="relative h-full text-center">
<Image <Image src={getClueDetailImage(selectedClue.id)} className="h-full" />
src={getClueDetailImage(selectedClue.id)}
className="h-full"
/>
{/* 线索提示描述(覆盖在详情图片上) */} {/* 线索提示描述(覆盖在详情图片上) */}
{showClueHint && {showClueHint && hasClueHint(selectedClue.id) && getClueHintImage && (
hasClueHint(selectedClue.id) &&
getClueHintImage && (
<div className="absolute inset-0 z-20 flex items-center justify-center"> <div className="absolute inset-0 z-20 flex items-center justify-center">
<div className="relative h-full"> <div className="relative h-full">
<Image <Image src={getClueHintImage(selectedClue.id)!} className="h-full" />
src={getClueHintImage(selectedClue.id)!}
className="h-full"
/>
{/* 关闭按钮 */} {/* 关闭按钮 */}
<Image <Image
className="absolute top-0 right-0 z-30" className="absolute top-0 right-0 z-30"
@@ -154,9 +139,7 @@ export const ClueBookModal = ({
{hasClueHint(selectedClue.id) && ( {hasClueHint(selectedClue.id) && (
<div className="absolute bottom-10 right-0 z-10"> <div className="absolute bottom-10 right-0 z-10">
<Image <Image
src={getStaticImage( src={getStaticImage(`sunzhongshan/reflection/内置线索提示按钮`)}
`sunzhongshan/reflection/内置线索提示按钮`
)}
button button
onClick={() => setShowClueHint(true)} onClick={() => setShowClueHint(true)}
/> />
@@ -166,7 +149,7 @@ export const ClueBookModal = ({
</div> </div>
) : ( ) : (
<div className="text-center text-gray-500 text-4xl"> <div className="text-center text-gray-500 text-4xl">
{selectedClue ? "该线索尚未收集" : "请选择一个线索查看详情"} {selectedClue ? '该线索尚未收集' : '请选择一个线索查看详情'}
</div> </div>
)} )}
</div> </div>
@@ -181,5 +164,5 @@ export const ClueBookModal = ({
/> />
</div> </div>
</div> </div>
); )
}; }
+48 -58
View File
@@ -1,24 +1,24 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react'
import { Image } from "@/components"; import { Image } from '@/components'
import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { getBackgroundImage, getStaticImage } from '@/lib/utils'
export interface ClueHint { export interface ClueHint {
id: string; id: string
name: string; name: string
position: { position: {
top: string; top: string
left: string; left: string
width: string; width: string
height: string; height: string
}; }
} }
export interface ClueHintModalProps { export interface ClueHintModalProps {
visible: boolean; visible: boolean
backgroundImage: string; backgroundImage: string
clues: ClueHint[]; clues: ClueHint[]
onClose: () => void; onClose: () => void
sourceContainerRef?: React.RefObject<HTMLElement | null>; sourceContainerRef?: React.RefObject<HTMLElement | null>
} }
/** /**
@@ -31,65 +31,55 @@ export const ClueHintModal = ({
onClose, onClose,
sourceContainerRef, sourceContainerRef,
}: ClueHintModalProps) => { }: ClueHintModalProps) => {
const modalContainerRef = useRef<HTMLDivElement>(null); const modalContainerRef = useRef<HTMLDivElement>(null)
const backgroundRef = useRef<HTMLDivElement>(null); const backgroundRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1)
const [size, setSize] = useState({ width: "100%", height: "100%" }); const [size, setSize] = useState({ width: '100%', height: '100%' })
// 计算缩放比例和偏移 // 计算缩放比例和偏移
useEffect(() => { useEffect(() => {
if ( if (!visible || !sourceContainerRef?.current || !modalContainerRef.current) {
!visible || return
!sourceContainerRef?.current ||
!modalContainerRef.current
) {
return;
} }
const calculateScale = () => { const calculateScale = () => {
const sourceRect = sourceContainerRef.current!.getBoundingClientRect(); const sourceRect = sourceContainerRef.current!.getBoundingClientRect()
const modalRect = modalContainerRef.current!.getBoundingClientRect(); const modalRect = modalContainerRef.current!.getBoundingClientRect()
if (sourceRect.width === 0 || sourceRect.height === 0) { if (sourceRect.width === 0 || sourceRect.height === 0) {
return; return
} }
// 计算宽高缩放比例,取较小的值以确保完全显示 // 计算宽高缩放比例,取较小的值以确保完全显示
const scaleX = modalRect.width / sourceRect.width; const scaleX = modalRect.width / sourceRect.width
const scaleY = modalRect.height / sourceRect.height; const scaleY = modalRect.height / sourceRect.height
const newScale = Math.min(scaleX, scaleY); const newScale = Math.min(scaleX, scaleY)
setScale(newScale); setScale(newScale)
setSize({ setSize({
width: `${sourceRect.width}px`, width: `${sourceRect.width}px`,
height: `${sourceRect.height}px`, height: `${sourceRect.height}px`,
}); })
}; }
// 延迟计算以确保 DOM 已渲染 // 延迟计算以确保 DOM 已渲染
const timer = setTimeout(calculateScale, 0); const timer = setTimeout(calculateScale, 0)
calculateScale(); calculateScale()
// 监听窗口大小变化 // 监听窗口大小变化
window.addEventListener("resize", calculateScale); window.addEventListener('resize', calculateScale)
return () => { return () => {
clearTimeout(timer); clearTimeout(timer)
window.removeEventListener("resize", calculateScale); window.removeEventListener('resize', calculateScale)
}; }
}, [visible, sourceContainerRef]); }, [visible, sourceContainerRef])
if (!visible) return null; if (!visible) return null
return ( return (
<div <div className="absolute inset-0 z-50 flex items-center justify-center" ref={modalContainerRef}>
className="absolute inset-0 z-50 flex items-center justify-center"
ref={modalContainerRef}
>
{/* 蒙版背景 */} {/* 蒙版背景 */}
<div <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* 背景图片容器 - 使用缩放匹配源容器尺寸并居中 */} {/* 背景图片容器 - 使用缩放匹配源容器尺寸并居中 */}
<div <div
@@ -98,19 +88,19 @@ export const ClueHintModal = ({
style={{ style={{
backgroundImage: `url(${backgroundImage})`, backgroundImage: `url(${backgroundImage})`,
transform: `scale(${scale})`, transform: `scale(${scale})`,
transformOrigin: "center center", transformOrigin: 'center center',
width: size.width, width: size.width,
height: size.height, height: size.height,
border: "40px solid transparent", border: '40px solid transparent',
borderImageSource: getBackgroundImage("sunzhongshan/reflection/框"), borderImageSource: getBackgroundImage('sunzhongshan/reflection/框'),
borderImageSlice: "40 fill", borderImageSlice: '40 fill',
borderImageRepeat: "stretch round", borderImageRepeat: 'stretch round',
}} }}
> >
{/* 线索标记 */} {/* 线索标记 */}
{clues.map( {clues.map(
(clue) => (clue) =>
clue.position.width !== "0%" && ( clue.position.width !== '0%' && (
<div <div
key={clue.id} key={clue.id}
className="absolute border-4 border-yellow-400 bg-yellow-400/20 rounded-lg flex items-center justify-center animate-pulse" className="absolute border-4 border-yellow-400 bg-yellow-400/20 rounded-lg flex items-center justify-center animate-pulse"
@@ -137,5 +127,5 @@ export const ClueHintModal = ({
onClick={onClose} onClick={onClose}
/> />
</div> </div>
); )
}; }
+4 -4
View File
@@ -1,5 +1,5 @@
export { ClueHintModal } from "./ClueHintModal"; export { ClueHintModal } from './ClueHintModal'
export type { ClueHintModalProps, ClueHint } from "./ClueHintModal"; export type { ClueHintModalProps, ClueHint } from './ClueHintModal'
export { ClueBookModal } from "./ClueBookModal"; export { ClueBookModal } from './ClueBookModal'
export type { ClueBookModalProps } from "./ClueBookModal"; export type { ClueBookModalProps } from './ClueBookModal'
+42 -50
View File
@@ -1,73 +1,65 @@
import { cn, getBackgroundImage, getStaticImage } from "@/lib/utils"; import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
// 原始尺寸(保持比例) // 原始尺寸(保持比例)
const ORIGINAL_SIZES = { const ORIGINAL_SIZES = {
read: { width: 116, height: 140 }, read: { width: 116, height: 140 },
unread: { width: 120, height: 121 }, unread: { width: 120, height: 121 },
} as const; } as const
interface CursorProps { interface CursorProps {
className?: string; className?: string
onClick: () => void; onClick: () => void
/** 已阅读 / 已完成 状态;true 时显示预留的 div,由你自行实现效果 */ /** 已阅读 / 已完成 状态;true 时显示预留的 div,由你自行实现效果 */
read?: boolean; read?: boolean
/** 尺寸缩放比例,默认为 1(原始大小) */ /** 尺寸缩放比例,默认为 1(原始大小) */
size?: number; size?: number
/** 禁用状态,禁用时无法点击但不影响 read 状态显示。当 read=true 时,disabled 不生效 */ /** 禁用状态,禁用时无法点击但不影响 read 状态显示。当 read=true 时,disabled 不生效 */
disabled?: boolean; disabled?: boolean
} }
const Cursor = ({ const Cursor = ({ className, onClick, read = false, size = 1, disabled = false }: CursorProps) => {
className, const baseSize = read ? ORIGINAL_SIZES.read : ORIGINAL_SIZES.unread
onClick,
read = false,
size = 1,
disabled = false,
}: CursorProps) => {
const baseSize = read ? ORIGINAL_SIZES.read : ORIGINAL_SIZES.unread;
// 当 read=true 时,disabled 不生效;只有 read=false 且 disabled=true 时才禁用 // 当 read=true 时,disabled 不生效;只有 read=false 且 disabled=true 时才禁用
const isDisabled = disabled && !read; const isDisabled = disabled && !read
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
if (isDisabled) { if (isDisabled) {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
return; return
}
onClick()
} }
onClick();
};
const handleTouchEnd = (e: React.TouchEvent) => { const handleTouchEnd = (e: React.TouchEvent) => {
if (isDisabled) return; if (isDisabled) return
e.preventDefault(); e.preventDefault()
onClick(); onClick()
}; }
return ( return (
<div <div
className={cn( className={cn(
"select-none flex justify-center items-center pl-0 transition-all duration-300", 'select-none flex justify-center items-center pl-0 transition-all duration-300',
isDisabled isDisabled
? "opacity-50" ? 'opacity-50'
: "hover:scale-105 hover:brightness-110 hover:contrast-110 active:scale-95", : 'hover:scale-105 hover:brightness-110 hover:contrast-110 active:scale-95',
!read && "animate-[spin_3s_linear_infinite]", !read && 'animate-[spin_3s_linear_infinite]',
className className
)} )}
style={{ style={{
width: `${baseSize.width * size}px`, width: `${baseSize.width * size}px`,
height: `${baseSize.height * size}px`, height: `${baseSize.height * size}px`,
WebkitTapHighlightColor: "transparent", WebkitTapHighlightColor: 'transparent',
WebkitTouchCallout: "none", WebkitTouchCallout: 'none',
WebkitUserSelect: "none", WebkitUserSelect: 'none',
touchAction: isDisabled ? "none" : "manipulation", touchAction: isDisabled ? 'none' : 'manipulation',
pointerEvents: "auto", pointerEvents: 'auto',
cursor: isDisabled ? "not-allowed" : "pointer", cursor: isDisabled ? 'not-allowed' : 'pointer',
backgroundImage: getBackgroundImage( backgroundImage: getBackgroundImage(read ? 'components/point_' : 'components/border'),
read ? "components/point_" : "components/border" backgroundSize: 'contain',
), backgroundPosition: 'center',
backgroundSize: "contain", backgroundRepeat: 'no-repeat',
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}} }}
onClick={handleClick} onClick={handleClick}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
@@ -75,19 +67,19 @@ const Cursor = ({
> >
{!read && ( {!read && (
<img <img
src={getStaticImage("components/point.png")} src={getStaticImage('components/point.png')}
alt="" alt=""
style={{ style={{
width: "100%", width: '100%',
height: "100%", height: '100%',
objectFit: "contain", objectFit: 'contain',
objectPosition: "center", objectPosition: 'center',
pointerEvents: "none", pointerEvents: 'none',
}} }}
/> />
)} )}
</div> </div>
); )
}; }
export default Cursor; export default Cursor
@@ -1,21 +1,21 @@
import { useEffect } from "react"; import { useEffect } from 'react'
import { cn, getBackgroundImage } from "@/lib/utils"; import { cn, getBackgroundImage } from '@/lib/utils'
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { useAudioManager } from "@/hooks/useAudioManager"; import { useAudioManager } from '@/hooks/useAudioManager'
export interface ExhibitDescriptionNarrationProps { export interface ExhibitDescriptionNarrationProps {
/** 描述文本 */ /** 描述文本 */
text?: string; text?: string
/** 是否可见 */ /** 是否可见 */
visible?: boolean; visible?: boolean
/** 是否自动播放音频(默认 true) */ /** 是否自动播放音频(默认 true) */
autoPlay?: boolean; autoPlay?: boolean
/** 音频文件地址 */ /** 音频文件地址 */
audioSrc?: string; audioSrc?: string
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */ /** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string; audioChannelId?: string
/** 额外的自定义类 */ /** 额外的自定义类 */
className?: string; className?: string
} }
/** /**
@@ -34,30 +34,27 @@ const ExhibitDescriptionNarration = ({
}: ExhibitDescriptionNarrationProps) => { }: ExhibitDescriptionNarrationProps) => {
// 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥 // 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥
const { currentSrc, isPlaying, play, stop } = useAudioManager( const { currentSrc, isPlaying, play, stop } = useAudioManager(
audioChannelId audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' }
? { mode: "channel", channelId: audioChannelId } )
: { mode: "global" }
);
// 当前组件对应的音频是否正在播放 // 当前组件对应的音频是否正在播放
const isCurrentAudioPlaying = const isCurrentAudioPlaying = audioSrc && currentSrc === audioSrc && isPlaying
audioSrc && currentSrc === audioSrc && isPlaying;
// 自动播放音频 // 自动播放音频
useEffect(() => { useEffect(() => {
if (visible && autoPlay && audioSrc) { if (visible && autoPlay && audioSrc) {
play(audioSrc); play(audioSrc)
} }
}, [visible, autoPlay, audioSrc, play]); }, [visible, autoPlay, audioSrc, play])
// 组件不可见时停止音频(仅当当前播放的是该组件的 audioSrc) // 组件不可见时停止音频(仅当当前播放的是该组件的 audioSrc)
useEffect(() => { useEffect(() => {
if (!visible && audioSrc && currentSrc === audioSrc) { if (!visible && audioSrc && currentSrc === audioSrc) {
stop(); stop()
} }
}, [visible, audioSrc, currentSrc, stop]); }, [visible, audioSrc, currentSrc, stop])
if (!text) return null; if (!text) return null
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -68,9 +65,9 @@ const ExhibitDescriptionNarration = ({
exit={{ opacity: 0, x: -20 }} exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
className={cn( className={cn(
"max-w-[580px] px-12 py-14", 'max-w-[580px] px-12 py-14',
"shadow-black/60", 'shadow-black/60',
"relative overflow-hidden", 'relative overflow-hidden',
className className
)} )}
> >
@@ -84,12 +81,10 @@ const ExhibitDescriptionNarration = ({
className="absolute inset-0 pointer-events-none box-border" className="absolute inset-0 pointer-events-none box-border"
aria-hidden="true" aria-hidden="true"
style={{ style={{
border: "40px solid transparent", border: '40px solid transparent',
borderImageSource: getBackgroundImage( borderImageSource: getBackgroundImage('components/narration/b.png'),
"components/narration/b.png" borderImageSlice: '40 fill',
), borderImageRepeat: 'stretch round',
borderImageSlice: "40 fill",
borderImageRepeat: "stretch round",
}} }}
/> />
<div className="relative z-10 flex flex-col gap-4"> <div className="relative z-10 flex flex-col gap-4">
@@ -107,11 +102,7 @@ const ExhibitDescriptionNarration = ({
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg" 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="播放" aria-label="播放"
> >
<svg <svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
className="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" /> <path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
</svg> </svg>
</button> </button>
@@ -122,11 +113,7 @@ const ExhibitDescriptionNarration = ({
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg" 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="停止" aria-label="停止"
> >
<svg <svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
className="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M5.25 3A2.25 2.25 0 003 5.25v9.5A2.25 2.25 0 005.25 17h9.5A2.25 2.25 0 0017 14.75v-9.5A2.25 2.25 0 0014.75 3h-9.5z" /> <path d="M5.25 3A2.25 2.25 0 003 5.25v9.5A2.25 2.25 0 005.25 17h9.5A2.25 2.25 0 0017 14.75v-9.5A2.25 2.25 0 0014.75 3h-9.5z" />
</svg> </svg>
</button> </button>
@@ -137,7 +124,7 @@ const ExhibitDescriptionNarration = ({
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
); )
}; }
export default ExhibitDescriptionNarration; export default ExhibitDescriptionNarration
+198 -210
View File
@@ -1,42 +1,35 @@
import { cn, getStaticImage } from "@/lib/utils"; import { cn, getStaticImage } from '@/lib/utils'
import { import { MouseEvent, useEffect, useRef, useCallback, useMemo, useState } from 'react'
MouseEvent, import { createPortal } from 'react-dom'
useEffect, import { AnimatePresence, motion } from 'framer-motion'
useRef, import { Image } from '@/components'
useCallback, import { useScale } from '@/contexts/ScaleContext'
useMemo, import ExhibitDescriptionNarration from './ExhibitDescriptionNarration'
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 { export interface ExhibitOverlayProps {
/** 展品图片地址,可以是静态资源路径或完整 URL */ /** 展品图片地址,可以是静态资源路径或完整 URL */
src: string; src: string
/** 关闭回调 */ /** 关闭回调 */
onClose?: () => void; onClose?: () => void
/** 是否可见,默认 true */ /** 是否可见,默认 true */
visible?: boolean; visible?: boolean
/** 额外的自定义类 */ /** 额外的自定义类 */
className?: string; className?: string
/** 展品描述文本 */ /** 展品描述文本 */
description?: string; description?: string
/** 是否自动播放描述语音,默认 false */ /** 是否自动播放描述语音,默认 false */
autoPlayDescription?: boolean; autoPlayDescription?: boolean
/** 展品描述对应的音频地址(可选) */ /** 展品描述对应的音频地址(可选) */
audioSrc?: string; audioSrc?: string
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */ /** 音频分组 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, onClick,
onOpen, onOpen,
}: ExhibitOverlayProps) => { }: ExhibitOverlayProps) => {
const maskSrc = getStaticImage("components/蒙版遮罩"); const maskSrc = getStaticImage('components/蒙版遮罩')
const backSrc = getStaticImage("components/close"); const backSrc = getStaticImage('components/close')
// 获取缩放参数,用于视觉对齐 // 获取缩放参数,用于视觉对齐
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale(); const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false)
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false)
// SSR 兼容:只在客户端挂载 // SSR 兼容:只在客户端挂载
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true)
}, []); }, [])
// 允许传入相对路径或绝对 URL // 允许传入相对路径或绝对 URL
const exhibitSrc = const exhibitSrc =
src.startsWith("http://") || src.startsWith('http://') || src.startsWith('https://') || src.startsWith('/')
src.startsWith("https://") ||
src.startsWith("/")
? src ? src
: getStaticImage(src); : getStaticImage(src)
const handleClose = (e: MouseEvent<HTMLImageElement>) => { const handleClose = (e: MouseEvent<HTMLImageElement>) => {
e.stopPropagation(); e.stopPropagation()
onClose?.(); onClose?.()
}; }
// ====== 缩放 / 拖拽状态 ====== // ====== 缩放 / 拖拽状态 ======
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null)
const viewElRef = useRef<HTMLDivElement | null>(null); const viewElRef = useRef<HTMLDivElement | null>(null)
const frameRef = useRef<number | null>(null); const frameRef = useRef<number | null>(null)
const pointers = useRef<Map<number, Point>>(new Map()); const pointers = useRef<Map<number, Point>>(new Map())
const lastDistance = useRef<number | null>(null); const lastDistance = useRef<number | null>(null)
// 拖拽初始状态:记录 pointerDown 时的状态 // 拖拽初始状态:记录 pointerDown 时的状态
const dragStartRef = useRef<{ const dragStartRef = useRef<{
pointerId: number; pointerId: number
startX: number; startX: number
startY: number; startY: number
startTransform: { scale: number; x: number; y: number }; startTransform: { scale: number; x: number; y: number }
} | null>(null); } | null>(null)
const transformRef = useRef({ scale: 1, x: 0, y: 0 }); const transformRef = useRef({ scale: 1, x: 0, y: 0 })
const MIN_SCALE = 0.8; const MIN_SCALE = 0.8
const MAX_SCALE = 3; const MAX_SCALE = 3
// 针对当前 scale 预计算 clamp // 针对当前 scale 预计算 clamp
const clampScale = useCallback( const clampScale = useCallback(
(next: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, next)), (next: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, next)),
[] []
); )
const applyTransformStyle = useCallback(() => { const applyTransformStyle = useCallback(() => {
const el = viewElRef.current; const el = viewElRef.current
if (!el) return; if (!el) return
const { scale, x, y } = transformRef.current; const { scale, x, y } = transformRef.current
// 使用 translate3d 触发 GPU 加速 // 使用 translate3d 触发 GPU 加速
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`
el.style.transformOrigin = "0 0"; el.style.transformOrigin = '0 0'
el.style.touchAction = "none"; el.style.touchAction = 'none'
}, []); }, [])
const scheduleUpdate = useCallback(() => { const scheduleUpdate = useCallback(() => {
if (frameRef.current) return; if (frameRef.current) return
frameRef.current = requestAnimationFrame(() => { frameRef.current = requestAnimationFrame(() => {
applyTransformStyle(); applyTransformStyle()
frameRef.current = null; frameRef.current = null
}); })
}, [applyTransformStyle]); }, [applyTransformStyle])
const resetTransform = useCallback(() => { const resetTransform = useCallback(() => {
transformRef.current = { scale: 1, x: 0, y: 0 }; transformRef.current = { scale: 1, x: 0, y: 0 }
pointers.current.clear(); pointers.current.clear()
lastDistance.current = null; lastDistance.current = null
dragStartRef.current = null; dragStartRef.current = null
setIsDragging(false); setIsDragging(false)
// 释放所有指针捕获 // 释放所有指针捕获
if (containerRef.current) { if (containerRef.current) {
pointers.current.forEach((_, pointerId) => { pointers.current.forEach((_, pointerId) => {
try { try {
containerRef.current?.releasePointerCapture(pointerId); containerRef.current?.releasePointerCapture(pointerId)
} catch { } catch {
// 忽略捕获释放错误 // 忽略捕获释放错误
} }
}); })
} }
applyTransformStyle(); applyTransformStyle()
}, [applyTransformStyle]); }, [applyTransformStyle])
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
onOpen?.(); onOpen?.()
} }
}, [visible, onOpen]); }, [visible, onOpen])
// 关闭或切换时重置 // 关闭或切换时重置
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
resetTransform(); resetTransform()
} else { } else {
// 不可见时清理所有状态和指针捕获 // 不可见时清理所有状态和指针捕获
transformRef.current = { scale: 1, x: 0, y: 0 }; transformRef.current = { scale: 1, x: 0, y: 0 }
pointers.current.clear(); pointers.current.clear()
lastDistance.current = null; lastDistance.current = null
dragStartRef.current = null; dragStartRef.current = null
if (containerRef.current) { if (containerRef.current) {
pointers.current.forEach((_, pointerId) => { pointers.current.forEach((_, pointerId) => {
try { try {
containerRef.current?.releasePointerCapture(pointerId); containerRef.current?.releasePointerCapture(pointerId)
} catch { } catch {
// 忽略捕获释放错误 // 忽略捕获释放错误
} }
}); })
} }
// 取消待处理的动画帧 // 取消待处理的动画帧
if (frameRef.current) { if (frameRef.current) {
cancelAnimationFrame(frameRef.current); cancelAnimationFrame(frameRef.current)
frameRef.current = null; frameRef.current = null
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, src]); }, [visible, src])
// 组件卸载时清理资源 // 组件卸载时清理资源
useEffect(() => { useEffect(() => {
const containerEl = containerRef.current; const containerEl = containerRef.current
const pointersMap = pointers.current; const pointersMap = pointers.current
const frameId = frameRef.current; const frameId = frameRef.current
return () => { return () => {
// 清理所有指针捕获 // 清理所有指针捕获
if (containerEl) { if (containerEl) {
pointersMap.forEach((_, pointerId) => { pointersMap.forEach((_, pointerId) => {
try { try {
containerEl.releasePointerCapture(pointerId); containerEl.releasePointerCapture(pointerId)
} catch { } catch {
// 忽略捕获释放错误 // 忽略捕获释放错误
} }
}); })
} }
// 取消待处理的动画帧 // 取消待处理的动画帧
if (frameId) { if (frameId) {
cancelAnimationFrame(frameId); cancelAnimationFrame(frameId)
} }
}; }
}, []); }, [])
// 缩放函数:origin 是屏幕坐标,需要转换为相对于 viewElRef 的坐标 // 缩放函数:origin 是屏幕坐标,需要转换为相对于 viewElRef 的坐标
const applyZoom = useCallback( const applyZoom = useCallback(
(factor: number, origin: Point) => { (factor: number, origin: Point) => {
if (!viewElRef.current || !containerRef.current) return; if (!viewElRef.current || !containerRef.current) return
const prev = transformRef.current; const prev = transformRef.current
const nextScale = clampScale(prev.scale * factor); const nextScale = clampScale(prev.scale * factor)
// 边界检查:避免除零 // 边界检查:避免除零
if (prev.scale === 0) return; if (prev.scale === 0) return
// 获取 viewElRef 相对于视口的位置 // 获取 viewElRef 相对于视口的位置
const viewRect = viewElRef.current.getBoundingClientRect(); const viewRect = viewElRef.current.getBoundingClientRect()
// 将屏幕坐标转换为相对于 viewElRef 的坐标 // 将屏幕坐标转换为相对于 viewElRef 的坐标
const relativeOrigin = { const relativeOrigin = {
x: origin.x - viewRect.left, x: origin.x - viewRect.left,
y: origin.y - viewRect.top, y: origin.y - viewRect.top,
}; }
const scaleChange = nextScale / prev.scale; const scaleChange = nextScale / prev.scale
// 以相对坐标 origin 为基准,保持该点视觉位置不动 // 以相对坐标 origin 为基准,保持该点视觉位置不动
const nextX = const nextX = (prev.x - relativeOrigin.x) * scaleChange + relativeOrigin.x
(prev.x - relativeOrigin.x) * scaleChange + relativeOrigin.x; const nextY = (prev.y - relativeOrigin.y) * scaleChange + relativeOrigin.y
const nextY = transformRef.current = { scale: nextScale, x: nextX, y: nextY }
(prev.y - relativeOrigin.y) * scaleChange + relativeOrigin.y; scheduleUpdate()
transformRef.current = { scale: nextScale, x: nextX, y: nextY };
scheduleUpdate();
}, },
[clampScale, scheduleUpdate] [clampScale, scheduleUpdate]
); )
// 滚轮缩放:直接使用屏幕坐标 // 滚轮缩放:直接使用屏幕坐标
const handleWheel = useCallback( const handleWheel = useCallback(
(e: React.WheelEvent<HTMLDivElement>) => { (e: React.WheelEvent<HTMLDivElement>) => {
const delta = e.deltaY; const delta = e.deltaY
const factor = delta < 0 ? 1.05 : 0.95; 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] [applyZoom]
); )
// 计算两点距离 // 计算两点距离
const getDistance = useCallback((p1: Point, p2: Point) => { const getDistance = useCallback((p1: Point, p2: Point) => {
const dx = p1.x - p2.x; const dx = p1.x - p2.x
const dy = p1.y - p2.y; const dy = p1.y - p2.y
return Math.hypot(dx, dy); return Math.hypot(dx, dy)
}, []); }, [])
// 指针按下:记录初始状态 // 指针按下:记录初始状态
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
if (!containerRef.current || !viewElRef.current) return; if (!containerRef.current || !viewElRef.current) return
try { try {
containerRef.current.setPointerCapture(e.pointerId); containerRef.current.setPointerCapture(e.pointerId)
} catch (err) { } catch (err) {
console.warn("Failed to capture pointer:", err); console.warn('Failed to capture pointer:', err)
} }
// 记录屏幕坐标 // 记录屏幕坐标
const point = { const point = {
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
}; }
pointers.current.set(e.pointerId, point); pointers.current.set(e.pointerId, point)
// 单指拖拽:记录初始状态 // 单指拖拽:记录初始状态
if (pointers.current.size === 1) { if (pointers.current.size === 1) {
@@ -275,22 +264,22 @@ const ExhibitOverlay = ({
startX: e.clientX, startX: e.clientX,
startY: e.clientY, startY: e.clientY,
startTransform: { ...transformRef.current }, startTransform: { ...transformRef.current },
}; }
setIsDragging(true); setIsDragging(true)
} else if (pointers.current.size === 2) { } else if (pointers.current.size === 2) {
// 双指缩放:记录初始距离 // 双指缩放:记录初始距离
const values = Array.from(pointers.current.values()); const values = Array.from(pointers.current.values())
if (values.length === 2) { if (values.length === 2) {
const [p1, p2] = values; const [p1, p2] = values
if (p1 && p2) { if (p1 && p2) {
lastDistance.current = getDistance(p1, p2); lastDistance.current = getDistance(p1, p2)
dragStartRef.current = null; dragStartRef.current = null
} }
} }
} }
}, },
[getDistance] [getDistance]
); )
/** /**
* 单指拖拽处理 * 单指拖拽处理
@@ -299,29 +288,29 @@ const ExhibitOverlay = ({
*/ */
const handleSingleDrag = useCallback( const handleSingleDrag = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
const dragStart = dragStartRef.current; const dragStart = dragStartRef.current
if (!dragStart) return; if (!dragStart) return
// 1. 计算屏幕坐标系下的位移 // 1. 计算屏幕坐标系下的位移
const dxScreen = e.clientX - dragStart.startX; const dxScreen = e.clientX - dragStart.startX
const dyScreen = e.clientY - dragStart.startY; const dyScreen = e.clientY - dragStart.startY
// 2. 映射到设计稿坐标系(补偿全局 scale) // 2. 映射到设计稿坐标系(补偿全局 scale)
const dx = dxScreen / scale; const dx = dxScreen / scale
const dy = dyScreen / scale; const dy = dyScreen / scale
// 3. 应用到 transform(设计稿坐标) // 3. 应用到 transform(设计稿坐标)
const nextX = dragStart.startTransform.x + dx; const nextX = dragStart.startTransform.x + dx
const nextY = dragStart.startTransform.y + dy; const nextY = dragStart.startTransform.y + dy
transformRef.current.x = nextX; transformRef.current.x = nextX
transformRef.current.y = nextY; transformRef.current.y = nextY
// 统一交给 rAF 的 scheduleUpdate,减少直接 DOM 操作次数 // 统一交给 rAF 的 scheduleUpdate,减少直接 DOM 操作次数
scheduleUpdate(); scheduleUpdate()
}, },
[scale, scheduleUpdate] [scale, scheduleUpdate]
); )
/** /**
* 双指缩放处理 * 双指缩放处理
@@ -329,73 +318,72 @@ const ExhibitOverlay = ({
* - 使用两指中点作为缩放中心 * - 使用两指中点作为缩放中心
*/ */
const handlePinchZoom = useCallback(() => { const handlePinchZoom = useCallback(() => {
if (pointers.current.size !== 2) return; if (pointers.current.size !== 2) return
const values = Array.from(pointers.current.values()); const values = Array.from(pointers.current.values())
const [p1, p2] = values; const [p1, p2] = values
if (!p1 || !p2) return; if (!p1 || !p2) return
const distance = getDistance(p1, p2); const distance = getDistance(p1, p2)
if (!lastDistance.current || lastDistance.current <= 0) { if (!lastDistance.current || lastDistance.current <= 0) {
lastDistance.current = distance; lastDistance.current = distance
return; return
} }
const factor = distance / lastDistance.current; const factor = distance / lastDistance.current
const center = { const center = {
x: (p1.x + p2.x) * 0.5, x: (p1.x + p2.x) * 0.5,
y: (p1.y + p2.y) * 0.5, y: (p1.y + p2.y) * 0.5,
}; }
applyZoom(factor, center); applyZoom(factor, center)
lastDistance.current = distance; lastDistance.current = distance
}, [getDistance, applyZoom]); }, [getDistance, applyZoom])
// 指针移动:更新指针位置,然后按当前手势类型分发给拖拽 / 缩放处理 // 指针移动:更新指针位置,然后按当前手势类型分发给拖拽 / 缩放处理
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
if (!pointers.current.has(e.pointerId)) return; if (!pointers.current.has(e.pointerId)) return
// 更新当前 pointer 的屏幕坐标 // 更新当前 pointer 的屏幕坐标
pointers.current.set(e.pointerId, { pointers.current.set(e.pointerId, {
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
}); })
// 优先处理双指缩放 // 优先处理双指缩放
if (pointers.current.size === 2) { if (pointers.current.size === 2) {
handlePinchZoom(); handlePinchZoom()
return; return
} }
// 单指拖拽 // 单指拖拽
if (pointers.current.size === 1) { if (pointers.current.size === 1) {
handleSingleDrag(e); handleSingleDrag(e)
} }
}, },
[handlePinchZoom, handleSingleDrag] [handlePinchZoom, handleSingleDrag]
); )
// 指针抬起 // 指针抬起
const handlePointerUp = useCallback( const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
const dragStart = dragStartRef.current; const dragStart = dragStartRef.current
// 检测是否是点击(移动距离很小) // 检测是否是点击(移动距离很小)
if (dragStart && dragStart.pointerId === e.pointerId && onClick) { if (dragStart && dragStart.pointerId === e.pointerId && onClick) {
// 计算从按下到抬起的移动距离 // 计算从按下到抬起的移动距离
const dx = e.clientX - dragStart.startX; const dx = e.clientX - dragStart.startX
const dy = e.clientY - dragStart.startY; const dy = e.clientY - dragStart.startY
const distance = Math.hypot(dx, dy); const distance = Math.hypot(dx, dy)
// 如果移动距离小于 5px,认为是点击而不是拖拽 // 如果移动距离小于 5px,认为是点击而不是拖拽
if (distance < 5) { if (distance < 5) {
// 检查 transform 是否改变,如果改变了说明是拖拽 // 检查 transform 是否改变,如果改变了说明是拖拽
const transformChanged = const transformChanged =
Math.abs(transformRef.current.x - dragStart.startTransform.x) > Math.abs(transformRef.current.x - dragStart.startTransform.x) > 0.1 ||
0.1 || Math.abs(transformRef.current.y - dragStart.startTransform.y) > 0.1
Math.abs(transformRef.current.y - dragStart.startTransform.y) > 0.1;
if (!transformChanged) { if (!transformChanged) {
onClick(); onClick()
} }
} }
} }
@@ -403,76 +391,76 @@ const ExhibitOverlay = ({
// 释放指针捕获 // 释放指针捕获
if (containerRef.current) { if (containerRef.current) {
try { try {
containerRef.current.releasePointerCapture(e.pointerId); containerRef.current.releasePointerCapture(e.pointerId)
} catch { } catch {
// 忽略捕获释放错误 // 忽略捕获释放错误
} }
} }
pointers.current.delete(e.pointerId); pointers.current.delete(e.pointerId)
if (pointers.current.size < 2) { if (pointers.current.size < 2) {
lastDistance.current = null; lastDistance.current = null
} }
// 如果剩余单指,重新记录拖拽初始状态 // 如果剩余单指,重新记录拖拽初始状态
if (pointers.current.size === 1) { if (pointers.current.size === 1) {
const remaining = Array.from(pointers.current.values())[0]; const remaining = Array.from(pointers.current.values())[0]
const remainingId = Array.from(pointers.current.keys())[0]; const remainingId = Array.from(pointers.current.keys())[0]
if (remaining && remainingId !== undefined) { if (remaining && remainingId !== undefined) {
dragStartRef.current = { dragStartRef.current = {
pointerId: remainingId, pointerId: remainingId,
startX: remaining.x, startX: remaining.x,
startY: remaining.y, startY: remaining.y,
startTransform: { ...transformRef.current }, startTransform: { ...transformRef.current },
}; }
} }
} else { } else {
dragStartRef.current = null; dragStartRef.current = null
} }
setIsDragging(pointers.current.size > 0); setIsDragging(pointers.current.size > 0)
}, },
[onClick] [onClick]
); )
// 双击重置 // 双击重置
const handleDoubleClick = useCallback(() => { const handleDoubleClick = useCallback(() => {
resetTransform(); resetTransform()
}, [resetTransform]); }, [resetTransform])
// 容器尺寸:基于设计稿尺寸的 90% // 容器尺寸:基于设计稿尺寸的 90%
const containerSize = useMemo( const containerSize = useMemo(
() => Math.min(DESIGN_WIDTH, DESIGN_HEIGHT) * 0.9, () => Math.min(DESIGN_WIDTH, DESIGN_HEIGHT) * 0.9,
[DESIGN_WIDTH, DESIGN_HEIGHT] [DESIGN_WIDTH, DESIGN_HEIGHT]
); )
// 视觉对齐样式:确保与 App 内容对齐 // 视觉对齐样式:确保与 App 内容对齐
const overlayStyle = useMemo<React.CSSProperties>( const overlayStyle = useMemo<React.CSSProperties>(
() => ({ () => ({
position: "fixed", position: 'fixed',
inset: 0, inset: 0,
zIndex: 9999, zIndex: 9999,
pointerEvents: "none", pointerEvents: 'none',
}), }),
[] []
); )
const scaledContainerStyle = useMemo<React.CSSProperties>( const scaledContainerStyle = useMemo<React.CSSProperties>(
() => ({ () => ({
position: "absolute", position: 'absolute',
width: `${DESIGN_WIDTH}px`, width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`, height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`, left: `${offsetX}px`,
top: `${offsetY}px`, top: `${offsetY}px`,
transform: `scale(${scale})`, transform: `scale(${scale})`,
transformOrigin: "top left", transformOrigin: 'top left',
overflow: "hidden", overflow: 'hidden',
}), }),
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT] [scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
); )
if (!mounted) return null; if (!mounted) return null
const content = ( const content = (
<AnimatePresence> <AnimatePresence>
@@ -483,16 +471,16 @@ const ExhibitOverlay = ({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }} transition={{ duration: 0.3, ease: 'easeInOut' }}
> >
<div style={scaledContainerStyle}> <div style={scaledContainerStyle}>
<div <div
className={cn( className={cn(
"absolute inset-0 flex items-center justify-center", 'absolute inset-0 flex items-center justify-center',
"bg-black/70", 'bg-black/70',
className className
)} )}
style={{ pointerEvents: "auto" }} style={{ pointerEvents: 'auto' }}
> >
{/* 背景蒙版 */} {/* 背景蒙版 */}
{maskSrc && ( {maskSrc && (
@@ -532,11 +520,11 @@ const ExhibitOverlay = ({
className="absolute top-1/2 -translate-y-1/2 pointer-events-none" className="absolute top-1/2 -translate-y-1/2 pointer-events-none"
style={{ style={{
// 有描述时:从描述右侧30px开始,到右边界,在这个区域内居中 // 有描述时:从描述右侧30px开始,到右边界,在这个区域内居中
left: description ? "520px" : "0", // 30 + 450 + 30 left: description ? '520px' : '0', // 30 + 450 + 30
right: "0", right: '0',
display: "flex", display: 'flex',
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
}} }}
> >
<div <div
@@ -545,9 +533,9 @@ const ExhibitOverlay = ({
style={{ style={{
maxWidth: containerSize, maxWidth: containerSize,
maxHeight: containerSize, maxHeight: containerSize,
width: "100%", width: '100%',
height: "100%", height: '100%',
cursor: isDragging ? "grabbing" : "grab", cursor: isDragging ? 'grabbing' : 'grab',
}} }}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
@@ -560,16 +548,16 @@ const ExhibitOverlay = ({
ref={viewElRef} ref={viewElRef}
className="max-w-full max-h-full select-none relative pointer-events-auto" className="max-w-full max-h-full select-none relative pointer-events-auto"
style={{ style={{
transform: "translate3d(0px, 0px, 0) scale(1)", transform: 'translate3d(0px, 0px, 0) scale(1)',
transformOrigin: "0 0", transformOrigin: '0 0',
willChange: "transform", willChange: 'transform',
// iOS 微信浏览器优化 // iOS 微信浏览器优化
WebkitTransform: "translate3d(0px, 0px, 0) scale(1)", WebkitTransform: 'translate3d(0px, 0px, 0) scale(1)',
WebkitBackfaceVisibility: "hidden", WebkitBackfaceVisibility: 'hidden',
backfaceVisibility: "hidden", backfaceVisibility: 'hidden',
// 强制硬件加速 // 强制硬件加速
WebkitPerspective: "1000", WebkitPerspective: '1000',
perspective: "1000", perspective: '1000',
}} }}
> >
<Image <Image
@@ -586,10 +574,10 @@ const ExhibitOverlay = ({
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
); )
// 直接使用 Portal 渲染到 document.body // 直接使用 Portal 渲染到 document.body
return createPortal(content, document.body); return createPortal(content, document.body)
}; }
export default ExhibitOverlay; export default ExhibitOverlay
+4 -4
View File
@@ -1,4 +1,4 @@
export { default as ExhibitOverlay } from "./ExhibitOverlay"; export { default as ExhibitOverlay } from './ExhibitOverlay'
export type { ExhibitOverlayProps } from "./ExhibitOverlay"; export type { ExhibitOverlayProps } from './ExhibitOverlay'
export { default as ExhibitDescriptionNarration } from "./ExhibitDescriptionNarration"; export { default as ExhibitDescriptionNarration } from './ExhibitDescriptionNarration'
export type { ExhibitDescriptionNarrationProps } from "./ExhibitDescriptionNarration"; export type { ExhibitDescriptionNarrationProps } from './ExhibitDescriptionNarration'
+76 -95
View File
@@ -1,15 +1,9 @@
import { import { ReactNode, useState, Children, isValidElement, useEffect } from 'react'
ReactNode, import { AnimatePresence, motion } from 'framer-motion'
useState, import { cn, getBackgroundImage } from '@/lib/utils'
Children, import { Overlay } from '@/components/overlay'
isValidElement, import { useVideoContextSafe } from '@/contexts/VideoContext'
useEffect, import { useAudioManager } from '@/hooks/useAudioManager'
} from "react";
import { AnimatePresence, motion } from "framer-motion";
import { cn, getBackgroundImage } from "@/lib/utils";
import { Overlay } from "@/components/overlay";
import { useVideoContextSafe } from "@/contexts/VideoContext";
import { useAudioManager } from "@/hooks/useAudioManager";
/** /**
* 使用示例: * 使用示例:
@@ -20,29 +14,29 @@ import { useAudioManager } from "@/hooks/useAudioManager";
* </GuideSteps> * </GuideSteps>
*/ */
interface GuideStepsProps { interface GuideStepsProps {
children: ReactNode; children: ReactNode
className?: string; className?: string
visible?: boolean; visible?: boolean
onVisibleChange?: (visible: boolean) => void; onVisibleChange?: (visible: boolean) => void
currentStep?: number; currentStep?: number
onStepChange?: (step: number) => void; onStepChange?: (step: number) => void
/** 每个步骤对应的高亮选择器数组,索引对应步骤序号 */ /** 每个步骤对应的高亮选择器数组,索引对应步骤序号 */
highlightSelectors?: (string | string[])[]; highlightSelectors?: (string | string[])[]
/** 控制是否显示遮罩层(包括背景蒙版和 Overlay 遮罩,默认:true,保持向后兼容) */ /** 控制是否显示遮罩层(包括背景蒙版和 Overlay 遮罩,默认:true,保持向后兼容) */
showMask?: boolean; showMask?: boolean
/** 自定义 z-index,用于控制层级(默认:50) */ /** 自定义 z-index,用于控制层级(默认:50) */
zIndex?: number; zIndex?: number
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */ /** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string; audioChannelId?: string
} }
interface GuideStepsItemProps { interface GuideStepsItemProps {
children: ReactNode; children: ReactNode
speaker?: "guide" | "student"; speaker?: 'guide' | 'student'
/** 当前步骤对应的音频地址 */ /** 当前步骤对应的音频地址 */
audioSrc?: string; audioSrc?: string
/** 进入该步骤时是否自动播放音频(有 audioSrc 且未显式设置时,默认自动播放) */ /** 进入该步骤时是否自动播放音频(有 audioSrc 且未显式设置时,默认自动播放) */
autoPlayOnEnter?: boolean; autoPlayOnEnter?: boolean
} }
const GuideSteps = ({ const GuideSteps = ({
@@ -57,125 +51,117 @@ const GuideSteps = ({
zIndex = 50, zIndex = 50,
audioChannelId, audioChannelId,
}: GuideStepsProps) => { }: GuideStepsProps) => {
const [internalStep, setInternalStep] = useState(0); const [internalStep, setInternalStep] = useState(0)
const [internalVisible, setInternalVisible] = useState(true); const [internalVisible, setInternalVisible] = useState(true)
// 安全地获取视频上下文(如果不在 VideoProvider 中,返回 null) // 安全地获取视频上下文(如果不在 VideoProvider 中,返回 null)
const videoContext = useVideoContextSafe(); const videoContext = useVideoContextSafe()
// 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥 // 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥
const { play, stop } = useAudioManager( const { play, stop } = useAudioManager(
audioChannelId audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' }
? { mode: "channel", channelId: audioChannelId } )
: { mode: "global" }
);
// 收集每个步骤的内容和 speaker 信息 // 收集每个步骤的内容和 speaker 信息
const stepsData = Children.toArray(children).map((child) => { const stepsData = Children.toArray(children).map((child) => {
if ( if (isValidElement<GuideStepsItemProps>(child) && child.type === GuideStepsItem) {
isValidElement<GuideStepsItemProps>(child) && const hasAudio = !!child.props.audioSrc
child.type === GuideStepsItem
) {
const hasAudio = !!child.props.audioSrc;
return { return {
content: child.props.children, content: child.props.children,
speaker: child.props.speaker || "guide", // 默认为 "guide" 保持向后兼容 speaker: child.props.speaker || 'guide', // 默认为 "guide" 保持向后兼容
audioSrc: child.props.audioSrc, audioSrc: child.props.audioSrc,
// 如果没有显式设置 autoPlayOnEnter,则在有 audioSrc 时默认自动播放 // 如果没有显式设置 autoPlayOnEnter,则在有 audioSrc 时默认自动播放
autoPlayOnEnter: autoPlayOnEnter:
child.props.autoPlayOnEnter !== undefined child.props.autoPlayOnEnter !== undefined ? child.props.autoPlayOnEnter : hasAudio,
? child.props.autoPlayOnEnter }
: hasAudio,
};
} }
// 如果不是 GuideStepsItem,保持原有行为 // 如果不是 GuideStepsItem,保持原有行为
return { return {
content: child, content: child,
speaker: "guide" as const, speaker: 'guide' as const,
audioSrc: undefined, audioSrc: undefined,
autoPlayOnEnter: false, autoPlayOnEnter: false,
}; }
}); })
const steps = stepsData.map((step) => step.content); const steps = stepsData.map((step) => step.content)
const totalSteps = steps.length; const totalSteps = steps.length
// 使用受控或非受控模式 // 使用受控或非受控模式
const isControlled = controlledStep !== undefined; const isControlled = controlledStep !== undefined
const currentStep = isControlled ? controlledStep : internalStep; const currentStep = isControlled ? controlledStep : internalStep
const baseVisible = visible !== undefined ? visible : internalVisible; const baseVisible = visible !== undefined ? visible : internalVisible
// 如果视频正在播放或内容不可见,则不显示 GuideSteps // 如果视频正在播放或内容不可见,则不显示 GuideSteps
// 如果不在 VideoProvider 中(videoContext 为 null),则正常显示 // 如果不在 VideoProvider 中(videoContext 为 null),则正常显示
const isVisible = const isVisible =
baseVisible && baseVisible &&
(!videoContext || (!videoContext || (!videoContext.isVideoPlaying && videoContext.isContentVisible))
(!videoContext.isVideoPlaying && videoContext.isContentVisible));
// 获取当前步骤的 speaker // 获取当前步骤的 speaker
const currentSpeaker = stepsData[currentStep]?.speaker || "guide"; const currentSpeaker = stepsData[currentStep]?.speaker || 'guide'
const currentStepData = stepsData[currentStep]; const currentStepData = stepsData[currentStep]
const currentAudioSrc = currentStepData?.audioSrc; const currentAudioSrc = currentStepData?.audioSrc
const currentAutoPlayOnEnter = currentStepData?.autoPlayOnEnter ?? false; const currentAutoPlayOnEnter = currentStepData?.autoPlayOnEnter ?? false
// 步骤变化时,按需自动播放音频(依赖稳定值,避免循环触发) // 步骤变化时,按需自动播放音频(依赖稳定值,避免循环触发)
useEffect(() => { useEffect(() => {
if (!isVisible) return; if (!isVisible) return
if (currentAutoPlayOnEnter && currentAudioSrc) { if (currentAutoPlayOnEnter && currentAudioSrc) {
play(currentAudioSrc); play(currentAudioSrc)
} }
}, [isVisible, currentStep, currentAutoPlayOnEnter, currentAudioSrc, play]); }, [isVisible, currentStep, currentAutoPlayOnEnter, currentAudioSrc, play])
const updateStep = (newStep: number) => { const updateStep = (newStep: number) => {
if (!isControlled) { if (!isControlled) {
setInternalStep(newStep); setInternalStep(newStep)
}
onStepChange?.(newStep)
} }
onStepChange?.(newStep);
};
const updateVisible = (newVisible: boolean) => { const updateVisible = (newVisible: boolean) => {
if (visible === undefined) { if (visible === undefined) {
setInternalVisible(newVisible); setInternalVisible(newVisible)
} }
// 隐藏引导时,停止当前 channel 的音频 // 隐藏引导时,停止当前 channel 的音频
if (!newVisible) { if (!newVisible) {
stop(); stop()
}
onVisibleChange?.(newVisible)
} }
onVisibleChange?.(newVisible);
};
// 受控 visible 场景:当外部把 visible 置为 false 时,也需要停止音频 // 受控 visible 场景:当外部把 visible 置为 false 时,也需要停止音频
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
stop(); stop()
} }
}, [isVisible, stop]); }, [isVisible, stop])
const handleNext = (e: React.MouseEvent) => { const handleNext = (e: React.MouseEvent) => {
e.stopPropagation(); // 阻止事件冒泡 e.stopPropagation() // 阻止事件冒泡
if (currentStep < totalSteps - 1) { if (currentStep < totalSteps - 1) {
updateStep(currentStep + 1); updateStep(currentStep + 1)
}
} }
};
const handleTextClick = () => { const handleTextClick = () => {
if (currentStep < totalSteps - 1) { if (currentStep < totalSteps - 1) {
updateStep(currentStep + 1); updateStep(currentStep + 1)
} else { } else {
// 最后一步,隐藏组件 // 最后一步,隐藏组件
updateVisible(false); updateVisible(false)
}
} }
};
// 获取当前步骤的高亮选择器 // 获取当前步骤的高亮选择器
const currentHighlightSelectors = const currentHighlightSelectors =
highlightSelectors && highlightSelectors[currentStep] highlightSelectors && highlightSelectors[currentStep]
? highlightSelectors[currentStep] ? highlightSelectors[currentStep]
: undefined; : undefined
// 根据 speaker 选择背景图片 // 根据 speaker 选择背景图片
const currentBackgroundPath = const currentBackgroundPath =
currentSpeaker === "student" ? "components/引导板2" : "components/引导板"; currentSpeaker === 'student' ? 'components/引导板2' : 'components/引导板'
// GuideSteps 内容 // GuideSteps 内容
const guideStepsContent = ( const guideStepsContent = (
@@ -183,15 +169,12 @@ const GuideSteps = ({
{isVisible && ( {isVisible && (
<motion.div <motion.div
key="guide-steps-container" key="guide-steps-container"
className={cn( className={cn('fixed inset-0 flex items-center justify-center', className)}
"fixed inset-0 flex items-center justify-center",
className
)}
style={{ zIndex: zIndex + 1 }} style={{ zIndex: zIndex + 1 }}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }} transition={{ duration: 0.3, ease: 'easeInOut' }}
onClick={handleTextClick} onClick={handleTextClick}
> >
{/* 引导板容器和背景 - 作为整体切换,确保 padding 和背景同步 */} {/* 引导板容器和背景 - 作为整体切换,确保 padding 和背景同步 */}
@@ -201,13 +184,13 @@ const GuideSteps = ({
<motion.div <motion.div
key={`container-${currentSpeaker}`} key={`container-${currentSpeaker}`}
className={cn( className={cn(
"absolute bottom-0 w-[1764px] h-[656px] pt-75 pb-10", 'absolute bottom-0 w-[1764px] h-[656px] pt-75 pb-10',
currentSpeaker === "student" ? "pl-50 pr-40" : "pr-80" currentSpeaker === 'student' ? 'pl-50 pr-40' : 'pr-80'
)} )}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }} transition={{ duration: 0.2, ease: 'easeInOut' }}
> >
{/* 背景层 */} {/* 背景层 */}
<div <div
@@ -226,7 +209,7 @@ const GuideSteps = ({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }} transition={{ duration: 0.3, ease: 'easeInOut' }}
> >
<div className="text-[#4e402e] text-4xl font-bold leading-15"> <div className="text-[#4e402e] text-4xl font-bold leading-15">
{steps[currentStep]} {steps[currentStep]}
@@ -241,9 +224,7 @@ const GuideSteps = ({
> >
继续 继续
</span> </span>
<span className="text-[#8B7355] text-2xl animate-pulse"> <span className="text-[#8B7355] text-2xl animate-pulse">→</span>
→
</span>
</div> </div>
)} )}
</motion.div> </motion.div>
@@ -254,7 +235,7 @@ const GuideSteps = ({
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
); )
// 始终使用 Overlay 包裹,showMask 由 Overlay 处理 // 始终使用 Overlay 包裹,showMask 由 Overlay 处理
return ( return (
@@ -266,11 +247,11 @@ const GuideSteps = ({
> >
{guideStepsContent} {guideStepsContent}
</Overlay> </Overlay>
); )
}; }
const GuideStepsItem = ({ children }: GuideStepsItemProps) => { const GuideStepsItem = ({ children }: GuideStepsItemProps) => {
return <>{children}</>; return <>{children}</>
}; }
export { GuideSteps, GuideStepsItem }; export { GuideSteps, GuideStepsItem }
+59 -60
View File
@@ -1,35 +1,35 @@
import type { ReactNode } from "react"; import type { ReactNode } from 'react'
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom'
import { Image } from "@/components"; import { Image } from '@/components'
import { cn, getStaticImage } from "@/lib/utils"; import { cn, getStaticImage } from '@/lib/utils'
import { useScale } from "@/contexts/ScaleContext"; import { useScale } from '@/contexts/ScaleContext'
import { useAudioManager } from "@/hooks/useAudioManager"; import { useAudioManager } from '@/hooks/useAudioManager'
import { useVideoContextSafe } from "@/contexts/VideoContext"; import { useVideoContextSafe } from '@/contexts/VideoContext'
export interface StudyGuideModalProps { export interface StudyGuideModalProps {
/** 是否可见 */ /** 是否可见 */
visible?: boolean; visible?: boolean
/** 标题文案,默认:温馨提示 */ /** 标题文案,默认:温馨提示 */
title?: string; title?: string
/** 主体内容(支持换行、富文本) */ /** 主体内容(支持换行、富文本) */
children?: ReactNode; children?: ReactNode
/** 点击继续按钮 */ /** 点击继续按钮 */
onContinue?: () => void; onContinue?: () => void
/** 点击遮罩是否关闭,默认 true */ /** 点击遮罩是否关闭,默认 true */
maskClosable?: boolean; maskClosable?: boolean
/** 自定义 zIndex,默认 120 */ /** 自定义 zIndex,默认 120 */
zIndex?: number; zIndex?: number
/** 对齐方式,默认 center */ /** 对齐方式,默认 center */
ailgn?: "center" | "left" | "right"; ailgn?: 'center' | 'left' | 'right'
// 垂直对齐方式,默认 center // 垂直对齐方式,默认 center
verticalAlign?: "center" | "top" | "bottom"; verticalAlign?: 'center' | 'top' | 'bottom'
/** 引导语音地址(可选) */ /** 引导语音地址(可选) */
audioSrc?: string; audioSrc?: string
/** 弹窗出现时是否自动播放引导语音(默认 true) */ /** 弹窗出现时是否自动播放引导语音(默认 true) */
autoPlayAudio?: boolean; autoPlayAudio?: boolean
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */ /** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string; audioChannelId?: string
} }
/** /**
@@ -48,77 +48,76 @@ export interface StudyGuideModalProps {
*/ */
export const StudyGuideModal = ({ export const StudyGuideModal = ({
visible = false, visible = false,
title: _title = "温馨提示", title: _title = '温馨提示',
children, children,
onContinue, onContinue,
maskClosable: _maskClosable = true, maskClosable: _maskClosable = true,
zIndex = 120, zIndex = 120,
ailgn = "center", ailgn = 'center',
verticalAlign = "top", verticalAlign = 'top',
audioSrc, audioSrc,
autoPlayAudio = true, autoPlayAudio = true,
audioChannelId, audioChannelId,
}: StudyGuideModalProps) => { }: StudyGuideModalProps) => {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false)
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale(); const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
const videoContext = useVideoContextSafe(); const videoContext = useVideoContextSafe()
const { currentSrc, play, stop } = useAudioManager( const { currentSrc, play, stop } = useAudioManager(
audioChannelId audioChannelId
? { ? {
mode: "channel", mode: 'channel',
channelId: audioChannelId, channelId: audioChannelId,
} }
: { mode: "global" } : { mode: 'global' }
); )
// SSR 兼容:只在客户端挂载 // SSR 兼容:只在客户端挂载
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true)
}, []); }, [])
// 当视频正在播放或内容未展示时,不应播放引导语音 // 当视频正在播放或内容未展示时,不应播放引导语音
const canPlayAudio = const canPlayAudio =
!videoContext || !videoContext || (!videoContext.isVideoPlaying && videoContext.isContentVisible)
(!videoContext.isVideoPlaying && videoContext.isContentVisible);
// 弹窗显隐联动音频播放/停止 // 弹窗显隐联动音频播放/停止
useEffect(() => { useEffect(() => {
if (!mounted) return; if (!mounted) return
if (visible && autoPlayAudio && audioSrc && canPlayAudio) { if (visible && autoPlayAudio && audioSrc && canPlayAudio) {
play(audioSrc); play(audioSrc)
} }
// 注意:这里不依赖 currentSrc,避免 play() 更新状态后重复触发自动播放 // 注意:这里不依赖 currentSrc,避免 play() 更新状态后重复触发自动播放
}, [visible, mounted, autoPlayAudio, audioSrc, play, canPlayAudio]); }, [visible, mounted, autoPlayAudio, audioSrc, play, canPlayAudio])
// 关闭弹窗时停止(仅当当前播放的是该弹窗的 audioSrc) // 关闭弹窗时停止(仅当当前播放的是该弹窗的 audioSrc)
useEffect(() => { useEffect(() => {
if (!mounted) return; if (!mounted) return
if (!visible && audioSrc && currentSrc === audioSrc) { if (!visible && audioSrc && currentSrc === audioSrc) {
stop(); stop()
} }
}, [visible, mounted, audioSrc, currentSrc, stop]); }, [visible, mounted, audioSrc, currentSrc, stop])
// 当视频开始播放时,如果当前正在播放该弹窗音频,则停止,避免视频音频与弹窗音频重叠 // 当视频开始播放时,如果当前正在播放该弹窗音频,则停止,避免视频音频与弹窗音频重叠
useEffect(() => { useEffect(() => {
if (!mounted) return; if (!mounted) return
if (!canPlayAudio && audioSrc && currentSrc === audioSrc) { 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 = () => { const handleMaskClick = () => {
// if (maskClosable) { // if (maskClosable) {
// onContinue?.(); // onContinue?.();
// } // }
}; }
const content = ( const content = (
<div <div
className="fixed inset-0 flex items-center justify-center" className="fixed inset-0 flex items-center justify-center"
style={{ zIndex, pointerEvents: "auto" }} style={{ zIndex, pointerEvents: 'auto' }}
> >
{/* 背景遮罩 */} {/* 背景遮罩 */}
<div className="absolute inset-0 bg-black/60" onClick={handleMaskClick} /> <div className="absolute inset-0 bg-black/60" onClick={handleMaskClick} />
@@ -127,7 +126,7 @@ export const StudyGuideModal = ({
<div className="relative w-[1700px] h-[760px] pointer-events-none pt-65"> <div className="relative w-[1700px] h-[760px] pointer-events-none pt-65">
{/* 引导框背景 */} {/* 引导框背景 */}
<img <img
src={getStaticImage("components/guide/引导框")} src={getStaticImage('components/guide/引导框')}
alt="学习引导背景" alt="学习引导背景"
className="absolute inset-0 w-full h-full object-contain" className="absolute inset-0 w-full h-full object-contain"
/> />
@@ -135,9 +134,9 @@ export const StudyGuideModal = ({
{/* 文本内容 */} {/* 文本内容 */}
<div <div
className={cn( className={cn(
"relative z-10 w-[1480px] flex items-center justify-center mx-auto text-white pointer-events-auto", 'relative z-10 w-[1480px] flex items-center justify-center mx-auto text-white pointer-events-auto',
ailgn === "left" && "justify-start", ailgn === 'left' && 'justify-start',
ailgn === "right" && "justify-end" ailgn === 'right' && 'justify-end'
)} )}
> >
{/* {title && ( {/* {title && (
@@ -148,11 +147,11 @@ export const StudyGuideModal = ({
{children && ( {children && (
<div <div
className={cn( className={cn(
"text-4xl leading-relaxed whitespace-pre-line overflow-y-auto custom-scrollbar max-h-[300px]", 'text-4xl leading-relaxed whitespace-pre-line overflow-y-auto custom-scrollbar max-h-[300px]',
"[text-shadow:_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4)]" '[text-shadow:_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4)]'
)} )}
style={{ style={{
marginTop: verticalAlign === "center" ? "85px" : "", marginTop: verticalAlign === 'center' ? '85px' : '',
}} }}
> >
{children} {children}
@@ -163,7 +162,7 @@ export const StudyGuideModal = ({
{/* 继续按钮 */} {/* 继续按钮 */}
<div className="absolute right-15 bottom-8 z-20 pointer-events-auto"> <div className="absolute right-15 bottom-8 z-20 pointer-events-auto">
<Image <Image
src={getStaticImage("components/guide/继续")} src={getStaticImage('components/guide/继续')}
alt="继续" alt="继续"
button button
onClick={onContinue} onClick={onContinue}
@@ -171,30 +170,30 @@ export const StudyGuideModal = ({
</div> </div>
</div> </div>
</div> </div>
); )
// 当 zIndex >= 10000 时,使用 Portal 渲染到 document.body,并应用缩放 // 当 zIndex >= 10000 时,使用 Portal 渲染到 document.body,并应用缩放
if (zIndex >= 10000) { if (zIndex >= 10000) {
const scaledContent = ( const scaledContent = (
<div <div
style={{ style={{
position: "fixed", position: 'fixed',
inset: 0, inset: 0,
width: `${DESIGN_WIDTH}px`, width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`, height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`, left: `${offsetX}px`,
top: `${offsetY}px`, top: `${offsetY}px`,
transform: `scale(${scale})`, transform: `scale(${scale})`,
transformOrigin: "top left", transformOrigin: 'top left',
pointerEvents: "auto", pointerEvents: 'auto',
zIndex, zIndex,
}} }}
> >
{content} {content}
</div> </div>
); )
return createPortal(scaledContent, document.body); return createPortal(scaledContent, document.body)
} }
return content; return content
}; }
+2 -2
View File
@@ -1,2 +1,2 @@
export { GuideSteps, GuideStepsItem } from "./GuideSteps"; export { GuideSteps, GuideStepsItem } from './GuideSteps'
export { StudyGuideModal } from "./StudyGuideModal"; export { StudyGuideModal } from './StudyGuideModal'
+47 -51
View File
@@ -1,16 +1,16 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from 'react'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
import type { ImageProps } from "./image.types"; import type { ImageProps } from './image.types'
export function Image({ export function Image({
src, src,
alt = "", alt = '',
className, className,
width, width,
height, height,
objectFit = "cover", objectFit = 'cover',
objectPosition = "center", objectPosition = 'center',
loading = "lazy", loading = 'lazy',
placeholder, placeholder,
fallback, fallback,
onLoad, onLoad,
@@ -21,81 +21,77 @@ export function Image({
style, style,
...restProps ...restProps
}: ImageProps) { }: ImageProps) {
const [imageSrc, setImageSrc] = useState(src); const [imageSrc, setImageSrc] = useState(src)
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false)
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false)
const imgRef = useRef<HTMLImageElement>(null); const imgRef = useRef<HTMLImageElement>(null)
// 处理图片加载 // 处理图片加载
const handleLoad = () => { const handleLoad = () => {
setIsLoading(false); setIsLoading(false)
setIsLoaded(true); setIsLoaded(true)
setHasError(false); setHasError(false)
onLoad?.(); onLoad?.()
}; }
// 处理图片错误 // 处理图片错误
const handleError = () => { const handleError = () => {
setIsLoading(false); setIsLoading(false)
setHasError(true); setHasError(true)
if (fallback && imageSrc !== fallback) { if (fallback && imageSrc !== fallback) {
setImageSrc(fallback); setImageSrc(fallback)
}
onError?.()
} }
onError?.();
};
// 重置状态当src改变时 // 重置状态当src改变时
useEffect(() => { useEffect(() => {
if (src !== imageSrc) { if (src !== imageSrc) {
setImageSrc(src); setImageSrc(src)
setIsLoading(true); setIsLoading(true)
setHasError(false); setHasError(false)
setIsLoaded(false); setIsLoaded(false)
} }
}, [src, imageSrc]); }, [src, imageSrc])
// 懒加载处理 // 懒加载处理
useEffect(() => { useEffect(() => {
if ( if (loading === 'lazy' && typeof IntersectionObserver !== 'undefined' && imgRef.current) {
loading === "lazy" &&
typeof IntersectionObserver !== "undefined" &&
imgRef.current
) {
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
setImageSrc(src); setImageSrc(src)
observer.disconnect(); observer.disconnect()
} }
}); })
}, },
{ threshold: 0.1 } { threshold: 0.1 }
); )
observer.observe(imgRef.current); observer.observe(imgRef.current)
return () => observer.disconnect(); return () => observer.disconnect()
} }
}, [loading, src]); }, [loading, src])
const imageStyle: React.CSSProperties = { const imageStyle: React.CSSProperties = {
width, width,
height, height,
objectFit, objectFit,
objectPosition, objectPosition,
filter: blur ? "blur(4px)" : "none", filter: blur ? 'blur(4px)' : 'none',
transition: "filter 0.3s ease", transition: 'filter 0.3s ease',
...style, ...style,
}; }
return ( return (
<div <div
className={cn( className={cn(
"relative", 'relative',
width === "100%" ? "block" : "inline-block", width === '100%' ? 'block' : 'inline-block',
button && button &&
"cursor-pointer transition-all duration-300 hover:scale-105 hover:brightness-110 hover:contrast-110 active:scale-95", 'cursor-pointer transition-all duration-300 hover:scale-105 hover:brightness-110 hover:contrast-110 active:scale-95',
className className
)} )}
style={{ width, height }} style={{ width, height }}
@@ -107,8 +103,8 @@ export function Image({
className="w-full h-full bg-gray-200 flex items-center justify-center" className="w-full h-full bg-gray-200 flex items-center justify-center"
style={{ style={{
backgroundImage: `url(${placeholder})`, backgroundImage: `url(${placeholder})`,
backgroundSize: "cover", backgroundSize: 'cover',
backgroundPosition: "center", backgroundPosition: 'center',
}} }}
> >
<div className="animate-pulse bg-gray-300 rounded w-full h-full" /> <div className="animate-pulse bg-gray-300 rounded w-full h-full" />
@@ -155,11 +151,11 @@ export function Image({
onLoad={handleLoad} onLoad={handleLoad}
onError={handleError} onError={handleError}
className={cn( className={cn(
"transition-opacity duration-300", 'transition-opacity duration-300',
isLoaded ? "opacity-100" : "opacity-0", isLoaded ? 'opacity-100' : 'opacity-0',
"w-full h-full" 'w-full h-full'
)} )}
/> />
</div> </div>
); )
} }
+17 -17
View File
@@ -1,36 +1,36 @@
import type { HTMLAttributes } from "react"; import type { HTMLAttributes } from 'react'
export interface ImageProps extends HTMLAttributes<HTMLDivElement> { export interface ImageProps extends HTMLAttributes<HTMLDivElement> {
/** 图片地址 */ /** 图片地址 */
src: string; src: string
/** 图片替代文本 */ /** 图片替代文本 */
alt?: string; alt?: string
/** 自定义类名 */ /** 自定义类名 */
className?: string; className?: string
/** 图片宽度 */ /** 图片宽度 */
width?: number | string; width?: number | string
/** 图片高度 */ /** 图片高度 */
height?: number | string; height?: number | string
/** 图片填充方式 */ /** 图片填充方式 */
objectFit?: "cover" | "contain" | "fill" | "none" | "scale-down"; objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
/** 图片位置 */ /** 图片位置 */
objectPosition?: string; objectPosition?: string
/** 加载方式 */ /** 加载方式 */
loading?: "lazy" | "eager"; loading?: 'lazy' | 'eager'
/** 占位图 */ /** 占位图 */
placeholder?: string; placeholder?: string
/** 加载失败时的回退图片 */ /** 加载失败时的回退图片 */
fallback?: string; fallback?: string
/** 图片加载完成回调 */ /** 图片加载完成回调 */
onLoad?: () => void; onLoad?: () => void
/** 图片加载失败回调 */ /** 图片加载失败回调 */
onError?: () => void; onError?: () => void
/** 是否可拖拽 */ /** 是否可拖拽 */
draggable?: boolean; draggable?: boolean
/** 模糊效果 */ /** 模糊效果 */
blur?: boolean; blur?: boolean
/** 图片质量(仅用于提示) */ /** 图片质量(仅用于提示) */
quality?: number; quality?: number
/** 是否启用按钮样式(hover 和 active 效果) */ /** 是否启用按钮样式(hover 和 active 效果) */
button?: boolean; button?: boolean
} }
+2 -2
View File
@@ -1,2 +1,2 @@
export * from "./image"; export * from './image'
export * from "./image.types"; export * from './image.types'
+32 -37
View File
@@ -1,34 +1,34 @@
// Video component // Video component
export { default as Video } from "./video"; export { default as Video } from './video'
export type { VideoProps, VideoRef, VideoAnchor, AnchorContext } from "./video"; export type { VideoProps, VideoRef, VideoAnchor, AnchorContext } from './video'
// Button components // Button components
export { Button, ImageButton } from "./button"; export { Button, ImageButton } from './button'
export type { BaseButtonProps, ImageButtonProps } from "./button"; export type { BaseButtonProps, ImageButtonProps } from './button'
// Image component // Image component
export { Image } from "./image"; export { Image } from './image'
export type { ImageProps } from "./image"; export type { ImageProps } from './image'
// Title // Title
export { Title } from "./title"; export { Title } from './title'
// Guide components // Guide components
export { GuideSteps, GuideStepsItem, StudyGuideModal } from "./guide"; export { GuideSteps, GuideStepsItem, StudyGuideModal } from './guide'
// Exhibit overlay // Exhibit overlay
export { ExhibitOverlay } from "./exhibit"; export { ExhibitOverlay } from './exhibit'
export type { ExhibitOverlayProps } from "./exhibit"; export type { ExhibitOverlayProps } from './exhibit'
// Overlay component // Overlay component
export { Overlay } from "./overlay"; export { Overlay } from './overlay'
export type { OverlayProps } from "./overlay"; export type { OverlayProps } from './overlay'
export { EndingOverlay } from "./overlay"; export { EndingOverlay } from './overlay'
export type { EndingOverlayProps } from "./overlay"; export type { EndingOverlayProps } from './overlay'
// Spotlight component // Spotlight component
export { Spotlight } from "./spotlight"; export { Spotlight } from './spotlight'
// Quiz panel components // Quiz panel components
export { export {
@@ -38,7 +38,7 @@ export {
DragQuizPanel, DragQuizPanel,
MatchQuizPanel, MatchQuizPanel,
SelectFillQuizPanel, SelectFillQuizPanel,
} from "./layout/quiz"; } from './layout/quiz'
export type { export type {
QuizPanelProps, QuizPanelProps,
SingleChoiceQuizPanelProps, SingleChoiceQuizPanelProps,
@@ -46,13 +46,13 @@ export type {
DragQuizPanelProps, DragQuizPanelProps,
MatchQuizPanelProps, MatchQuizPanelProps,
SelectFillQuizPanelProps, SelectFillQuizPanelProps,
} from "./layout/quiz"; } from './layout/quiz'
//Cursor //Cursor
export { default as Cursor } from "@/components/cursor"; export { default as Cursor } from '@/components/cursor'
// Quiz components // Quiz components
export { SingleChoice, MultipleChoice, DragFill } from "./quiz"; export { SingleChoice, MultipleChoice, DragFill } from './quiz'
export type { export type {
SingleChoiceProps, SingleChoiceProps,
SingleChoiceOption, SingleChoiceOption,
@@ -62,38 +62,33 @@ export type {
DragFillOption, DragFillOption,
DragFillBlank, DragFillBlank,
DragFillResult, DragFillResult,
} from "./quiz"; } from './quiz'
// Narration component // Narration component
export { Narration } from "./narration"; export { Narration } from './narration'
export type { NarrationProps } from "./narration"; export type { NarrationProps } from './narration'
// Clue components // Clue components
export { ClueHintModal, ClueBookModal } from "./clue"; export { ClueHintModal, ClueBookModal } from './clue'
export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from "./clue"; export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from './clue'
// Animation components // Animation components
export { flyToAnimation } from "./animation"; export { flyToAnimation } from './animation'
export type { FlyToAnimationOptions, FlyToPosition } from "./animation"; export type { FlyToAnimationOptions, FlyToPosition } from './animation'
// Select component // Select component
export { Select } from "./select"; export { Select } from './select'
export type { SelectProps, SelectOption } from "./select"; export type { SelectProps, SelectOption } from './select'
// Letter components // Letter components
export { export { LetterToChenyun, LetterToSunzhongshan, LetterToHuangyanpei, LetterToLuxun } from './letter'
LetterToChenyun,
LetterToSunzhongshan,
LetterToHuangyanpei,
LetterToLuxun,
} from "./letter";
export type { export type {
LetterToChenyunProps, LetterToChenyunProps,
LetterToSunzhongshanProps, LetterToSunzhongshanProps,
LetterToHuangyanpeiProps, LetterToHuangyanpeiProps,
LetterToLuxunProps, LetterToLuxunProps,
} from "./letter"; } from './letter'
// Puzzle components // Puzzle components
export { PuzzleSystem, PuzzlePieceDisplay } from "./puzzle"; export { PuzzleSystem, PuzzlePieceDisplay } from './puzzle'
export type { PuzzleSystemProps, PuzzlePieceDisplayProps } from "./puzzle"; export type { PuzzleSystemProps, PuzzlePieceDisplayProps } from './puzzle'
+131 -148
View File
@@ -1,21 +1,21 @@
import { Outlet, useMatches, useLocation } from "react-router-dom"; import { Outlet, useMatches, useLocation } from 'react-router-dom'
import { useState, useEffect, useRef, useMemo } from "react"; import { useState, useEffect, useRef, useMemo } from 'react'
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom'
import { getPublicPath, getStaticImage } from "@/lib/utils"; import { getPublicPath, getStaticImage } from '@/lib/utils'
import type { VideoRouteHandle } from "@/router/types"; import type { VideoRouteHandle } from '@/router/types'
import Video, { type VideoRef } from "@/components/video"; import Video, { type VideoRef } from '@/components/video'
import { VideoProvider } from "@/contexts/VideoContext"; import { VideoProvider } from '@/contexts/VideoContext'
import { PuzzleBoxProvider, usePuzzleBox } from "@/contexts/PuzzleBoxContext"; import { PuzzleBoxProvider, usePuzzleBox } from '@/contexts/PuzzleBoxContext'
import { PuzzleAnimationProvider } from "@/contexts/PuzzleAnimationContext"; import { PuzzleAnimationProvider } from '@/contexts/PuzzleAnimationContext'
import { Image, PuzzleSystem, flyToAnimation } from "@/components"; import { Image, PuzzleSystem, flyToAnimation } from '@/components'
import { usePuzzleStore } from "@/store/puzzleStore"; import { usePuzzleStore } from '@/store/puzzleStore'
import PuzzlePiecePortal from "@/components/puzzle/PuzzlePiecePortal"; 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 ResidenceLayoutContent = () => {
const matches = useMatches(); const matches = useMatches()
const location = useLocation(); const location = useLocation()
const { puzzleBoxRef } = usePuzzleBox(); const { puzzleBoxRef } = usePuzzleBox()
const { unlockPuzzle, isPuzzleUnlocked } = usePuzzleStore(); const { unlockPuzzle, isPuzzleUnlocked } = usePuzzleStore()
const [isVideoPlaying, setIsVideoPlaying] = useState(false); const [isVideoPlaying, setIsVideoPlaying] = useState(false)
const [isContentVisible, setIsContentVisible] = useState(false); const [isContentVisible, setIsContentVisible] = useState(false)
const [isVideoFadingOut, setIsVideoFadingOut] = useState(false); const [isVideoFadingOut, setIsVideoFadingOut] = useState(false)
const [isPuzzleSystemVisible, setIsPuzzleSystemVisible] = useState(false); const [isPuzzleSystemVisible, setIsPuzzleSystemVisible] = useState(false)
const videoRef = useRef<VideoRef>(null); const videoRef = useRef<VideoRef>(null)
const previousPathRef = useRef<string>(""); const previousPathRef = useRef<string>('')
// 碎片动画相关状态 // 碎片动画相关状态
const [showPuzzlePiece, setShowPuzzlePiece] = useState(false); const [showPuzzlePiece, setShowPuzzlePiece] = useState(false)
const [puzzlePieceId, setPuzzlePieceId] = useState<number | null>(null); const [puzzlePieceId, setPuzzlePieceId] = useState<number | null>(null)
const [isAnimationPlaying, setIsAnimationPlaying] = useState(false); const [isAnimationPlaying, setIsAnimationPlaying] = useState(false)
const [isFlying, setIsFlying] = useState(false); // 标记是否正在飞行 const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行
const puzzlePieceRef = useRef<HTMLDivElement>(null); const puzzlePieceRef = useRef<HTMLDivElement>(null)
const animationInProgressRef = useRef<boolean>(false); // 防止重复执行动画 const animationInProgressRef = useRef<boolean>(false) // 防止重复执行动画
// 从路径中提取故居名称(starName),如 'chenyun', 'sunzhongshan' 等 // 从路径中提取故居名称(starName),如 'chenyun', 'sunzhongshan' 等
// 路径格式:/:moduleName/:starName/:page // 路径格式:/:moduleName/:starName/:page
const residence = useMemo(() => { const residence = useMemo(() => {
const pathParts = location.pathname.split("/").filter(Boolean); const pathParts = location.pathname.split('/').filter(Boolean)
// 路径格式:moduleName/starName/page,所以 starName 是第二个部分 // 路径格式:moduleName/starName/page,所以 starName 是第二个部分
if (pathParts.length >= 2) { if (pathParts.length >= 2) {
return pathParts[1]; return pathParts[1]
} }
return ""; return ''
}, [location.pathname]); }, [location.pathname])
// 判断当前故居是否支持拼图盒功能 // 判断当前故居是否支持拼图盒功能
const hasPuzzleFeature = useMemo( const hasPuzzleFeature = useMemo(
() => RESIDENCES_WITH_PUZZLE.includes(residence as any), () => RESIDENCES_WITH_PUZZLE.includes(residence as any),
[residence] [residence]
); )
// 动画配置常量 // 动画配置常量
const ANIMATION_CONFIG = { const ANIMATION_CONFIG = {
@@ -69,221 +69,207 @@ const ResidenceLayoutContent = () => {
MAX_RETRIES: 3, // 最大重试次数 MAX_RETRIES: 3, // 最大重试次数
SCALE: { from: 1, to: 0.25 }, // 缩放比例(更小的目标尺寸,效果更明显) SCALE: { from: 1, to: 0.25 }, // 缩放比例(更小的目标尺寸,效果更明显)
OPACITY: [1, 1, 0.6, 0] as number[], // 透明度变化(更平滑的淡出) OPACITY: [1, 1, 0.6, 0] as number[], // 透明度变化(更平滑的淡出)
}; }
// 清理动画状态 // 清理动画状态
const cleanupAnimation = () => { const cleanupAnimation = () => {
setIsAnimationPlaying(false); setIsAnimationPlaying(false)
setShowPuzzlePiece(false); setShowPuzzlePiece(false)
setPuzzlePieceId(null); setPuzzlePieceId(null)
setIsFlying(false); setIsFlying(false)
animationInProgressRef.current = false; animationInProgressRef.current = false
}; }
// 执行飞行动画 // 执行飞行动画
const executeFlyAnimation = (pieceId: number) => { const executeFlyAnimation = (pieceId: number) => {
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) { if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
return false; return false
} }
setIsFlying(true); setIsFlying(true)
// 使用 requestAnimationFrame 确保元素已完全渲染 // 使用 requestAnimationFrame 确保元素已完全渲染
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!puzzlePieceRef.current || !residence) { if (!puzzlePieceRef.current || !residence) {
return; return
} }
flyToAnimation({ flyToAnimation({
fromRef: puzzlePieceRef, fromRef: puzzlePieceRef,
toRef: puzzleBoxRef, toRef: puzzleBoxRef,
targetPosition: "center", targetPosition: 'center',
duration: ANIMATION_CONFIG.FLY_DURATION, duration: ANIMATION_CONFIG.FLY_DURATION,
scale: ANIMATION_CONFIG.SCALE, scale: ANIMATION_CONFIG.SCALE,
opacity: ANIMATION_CONFIG.OPACITY, opacity: ANIMATION_CONFIG.OPACITY,
ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速 ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速
onComplete: () => { onComplete: () => {
if (residence) { if (residence) {
unlockPuzzle(residence, pieceId); unlockPuzzle(residence, pieceId)
} }
cleanupAnimation(); cleanupAnimation()
}, },
}); })
}); })
return true; return true
}; }
// 等待元素准备就绪并执行动画 // 等待元素准备就绪并执行动画
const waitForElementsAndAnimate = async ( const waitForElementsAndAnimate = async (pieceId: number, retryCount = 0): Promise<void> => {
pieceId: number,
retryCount = 0
): Promise<void> => {
if (!residence) { if (!residence) {
cleanupAnimation(); cleanupAnimation()
return; return
} }
// 检查元素是否已准备好 // 检查元素是否已准备好
if (puzzleBoxRef.current && puzzlePieceRef.current) { if (puzzleBoxRef.current && puzzlePieceRef.current) {
const success = executeFlyAnimation(pieceId); const success = executeFlyAnimation(pieceId)
if (success) return; if (success) return
} }
// 如果元素未准备好且未超过最大重试次数,则重试 // 如果元素未准备好且未超过最大重试次数,则重试
if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) { if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) {
await new Promise((resolve) => await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY))
setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY) return waitForElementsAndAnimate(pieceId, retryCount + 1)
);
return waitForElementsAndAnimate(pieceId, retryCount + 1);
} }
// 超过最大重试次数,直接解锁并清理 // 超过最大重试次数,直接解锁并清理
console.warn("Puzzle animation: 元素未准备好,直接解锁"); console.warn('Puzzle animation: 元素未准备好,直接解锁')
unlockPuzzle(residence, pieceId); unlockPuzzle(residence, pieceId)
cleanupAnimation(); cleanupAnimation()
}; }
// 处理碎片动画触发(仅支持拼图功能的故居) // 处理碎片动画触发(仅支持拼图功能的故居)
const handleTriggerPuzzleAnimation = (pieceId: number) => { 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); setIsAnimationPlaying(true)
setPuzzlePieceId(pieceId); setPuzzlePieceId(pieceId)
setShowPuzzlePiece(true); setShowPuzzlePiece(true)
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成 // 使用双重 requestAnimationFrame 确保 Portal 渲染完成
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(async () => { requestAnimationFrame(async () => {
// 等待初始出现动画(旋转+放大)完成 // 等待初始出现动画(旋转+放大)完成
await new Promise((resolve) => await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION))
setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION)
);
// 等待发光动画显示一段时间,让用户看到碎片 // 等待发光动画显示一段时间,让用户看到碎片
await new Promise((resolve) => await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION))
setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION)
);
// 等待元素准备就绪并执行飞行动画 // 等待元素准备就绪并执行飞行动画
await waitForElementsAndAnimate(pieceId); await waitForElementsAndAnimate(pieceId)
}); })
}); })
}; }
// 获取当前路由的视频路径 // 获取当前路由的视频路径
const videoSrc = useMemo(() => { const videoSrc = useMemo(() => {
const currentMatch = matches[matches.length - 1]; const currentMatch = matches[matches.length - 1]
return (currentMatch?.handle as VideoRouteHandle | undefined)?.videoSrc; return (currentMatch?.handle as VideoRouteHandle | undefined)?.videoSrc
}, [matches]); }, [matches])
// 判断是否是前进导航(而不是后退) // 判断是否是前进导航(而不是后退)
const isForwardNavigation = useMemo(() => { const isForwardNavigation = useMemo(() => {
const currentPath = location.pathname; const currentPath = location.pathname
const previousPath = previousPathRef.current || ""; const previousPath = previousPathRef.current || ''
// 提取场景编号(例如:/module/chenyun/place5 -> 5) // 提取场景编号(例如:/module/chenyun/place5 -> 5)
const getCurrentPlaceNumber = (path: string) => { const getCurrentPlaceNumber = (path: string) => {
const match = path.match(/place(\d+)/); const match = path.match(/place(\d+)/)
return match && match[1] ? parseInt(match[1], 10) : 0; return match && match[1] ? parseInt(match[1], 10) : 0
}; }
const currentPlace = getCurrentPlaceNumber(currentPath); const currentPlace = getCurrentPlaceNumber(currentPath)
const previousPlace = getCurrentPlaceNumber(previousPath); const previousPlace = getCurrentPlaceNumber(previousPath)
// 如果当前场景编号大于前一个场景编号,说明是前进 // 如果当前场景编号大于前一个场景编号,说明是前进
// 如果是首次加载(previousPath为空),也视为前进 // 如果是首次加载(previousPath为空),也视为前进
return !previousPath || currentPlace > previousPlace; return !previousPath || currentPlace > previousPlace
}, [location.pathname]); }, [location.pathname])
// 处理视频播放完成(自然结束) // 处理视频播放完成(自然结束)
const handleVideoEnded = () => { const handleVideoEnded = () => {
// 先让内容层开始淡入 // 先让内容层开始淡入
setIsContentVisible(true); setIsContentVisible(true)
// 开始视频层淡出动画 // 开始视频层淡出动画
setIsVideoFadingOut(true); setIsVideoFadingOut(true)
// 然后延迟移除视频层,让视频层淡出动画完成 // 然后延迟移除视频层,让视频层淡出动画完成
setTimeout(() => { setTimeout(() => {
setIsVideoPlaying(false); setIsVideoPlaying(false)
setIsVideoFadingOut(false); setIsVideoFadingOut(false)
}, 500); // 与 CSS transition 时间一致 }, 500) // 与 CSS transition 时间一致
}; }
// 处理跳过操作 // 处理跳过操作
const handleSkip = () => { const handleSkip = () => {
// 立即暂停视频 // 立即暂停视频
videoRef.current?.pause(); videoRef.current?.pause()
// 立即显示内容层 // 立即显示内容层
setIsContentVisible(true); setIsContentVisible(true)
// 立即移除视频层,不等待淡出动画 // 立即移除视频层,不等待淡出动画
setIsVideoPlaying(false); setIsVideoPlaying(false)
setIsVideoFadingOut(false); setIsVideoFadingOut(false)
}; }
// 处理视频加载错误 // 处理视频加载错误
const handleVideoError = () => { const handleVideoError = () => {
// 视频加载失败,直接显示内容 // 视频加载失败,直接显示内容
setIsVideoPlaying(false); setIsVideoPlaying(false)
setIsContentVisible(true); setIsContentVisible(true)
setIsVideoFadingOut(false); setIsVideoFadingOut(false)
}; }
// 路由变化时重置状态 // 路由变化时重置状态
useEffect(() => { useEffect(() => {
// 只有在前进导航且有视频时才播放视频 // 只有在前进导航且有视频时才播放视频
if (videoSrc && isForwardNavigation) { if (videoSrc && isForwardNavigation) {
// 有视频且是前进,重置状态准备播放 // 有视频且是前进,重置状态准备播放
setIsVideoPlaying(true); setIsVideoPlaying(true)
setIsContentVisible(false); setIsContentVisible(false)
setIsVideoFadingOut(false); setIsVideoFadingOut(false)
} else { } else {
// 没有视频或是后退,直接显示内容 // 没有视频或是后退,直接显示内容
setIsVideoPlaying(false); setIsVideoPlaying(false)
setIsContentVisible(true); setIsContentVisible(true)
setIsVideoFadingOut(false); setIsVideoFadingOut(false)
} }
// 更新前一个路径 // 更新前一个路径
previousPathRef.current = location.pathname; previousPathRef.current = location.pathname
}, [location.pathname, videoSrc, isForwardNavigation]); }, [location.pathname, videoSrc, isForwardNavigation])
// 当视频元素渲染后,重置并确保播放 // 当视频元素渲染后,重置并确保播放
useEffect(() => { useEffect(() => {
if (isVideoPlaying && videoRef.current?.video && videoSrc) { if (isVideoPlaying && videoRef.current?.video && videoSrc) {
const video = videoRef.current.video; const video = videoRef.current.video
video.currentTime = 0; video.currentTime = 0
video.play().catch(() => { video.play().catch(() => {
// 自动播放失败,直接显示内容 // 自动播放失败,直接显示内容
handleVideoError(); handleVideoError()
}); })
} }
}, [isVideoPlaying, videoSrc]); }, [isVideoPlaying, videoSrc])
return ( return (
<VideoProvider <VideoProvider isVideoPlaying={isVideoPlaying} isContentVisible={isContentVisible}>
isVideoPlaying={isVideoPlaying} <PuzzleAnimationProvider triggerPuzzleAnimation={handleTriggerPuzzleAnimation}>
isContentVisible={isContentVisible}
>
<PuzzleAnimationProvider
triggerPuzzleAnimation={handleTriggerPuzzleAnimation}
>
<div className="relative w-full h-full"> <div className="relative w-full h-full">
{/* 内容层 - 始终渲染,根据状态控制显示 */} {/* 内容层 - 始终渲染,根据状态控制显示 */}
<div <div
className={`absolute inset-0 ${ className={`absolute inset-0 ${
isContentVisible isContentVisible
? "z-50 opacity-100" // 显示时在上层 ? 'z-50 opacity-100' // 显示时在上层
: "z-10 opacity-0 pointer-events-none" // 隐藏时在下层且不可交互 : 'z-10 opacity-0 pointer-events-none' // 隐藏时在下层且不可交互
} transition-opacity duration-300 ease-in`} } transition-opacity duration-300 ease-in`}
> >
<Outlet /> <Outlet />
@@ -293,13 +279,13 @@ const ResidenceLayoutContent = () => {
ref={puzzleBoxRef} ref={puzzleBoxRef}
className="absolute left-4 bottom-4 z-[1] cursor-pointer" className="absolute left-4 bottom-4 z-[1] cursor-pointer"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
if (residence) { if (residence) {
setIsPuzzleSystemVisible(true); setIsPuzzleSystemVisible(true)
} }
}} }}
> >
<Image src={getStaticImage("components/拼图盒")} button /> <Image src={getStaticImage('components/拼图盒')} button />
</div> </div>
)} )}
</div> </div>
@@ -309,8 +295,8 @@ const ResidenceLayoutContent = () => {
<div <div
className={`absolute inset-0 ${ className={`absolute inset-0 ${
isVideoFadingOut isVideoFadingOut
? "z-40 opacity-0 pointer-events-none" // 淡出时移到下层 ? 'z-40 opacity-0 pointer-events-none' // 淡出时移到下层
: "z-50 opacity-100" // 播放时在上层 : 'z-50 opacity-100' // 播放时在上层
} bg-black transition-opacity duration-500 ease-out`} } bg-black transition-opacity duration-500 ease-out`}
> >
<Video <Video
@@ -337,10 +323,7 @@ const ResidenceLayoutContent = () => {
)} )}
{/* 碎片显示 - 用于飞行动画,显示在屏幕正中间(仅支持拼图功能的故居显示) */} {/* 碎片显示 - 用于飞行动画,显示在屏幕正中间(仅支持拼图功能的故居显示) */}
{hasPuzzleFeature && {hasPuzzleFeature && showPuzzlePiece && puzzlePieceId && residence && (
showPuzzlePiece &&
puzzlePieceId &&
residence && (
<PuzzlePiecePortal <PuzzlePiecePortal
ref={puzzlePieceRef} ref={puzzlePieceRef}
residence={residence} residence={residence}
@@ -354,7 +337,7 @@ const ResidenceLayoutContent = () => {
createPortal( createPortal(
<div <div
className="fixed inset-0 z-[100000] pointer-events-auto" className="fixed inset-0 z-[100000] pointer-events-auto"
style={{ touchAction: "none" }} style={{ touchAction: 'none' }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => e.preventDefault()} onTouchStart={(e) => e.preventDefault()}
onTouchMove={(e) => e.preventDefault()} onTouchMove={(e) => e.preventDefault()}
@@ -365,15 +348,15 @@ const ResidenceLayoutContent = () => {
</div> </div>
</PuzzleAnimationProvider> </PuzzleAnimationProvider>
</VideoProvider> </VideoProvider>
); )
}; }
const ResidenceLayout = () => { const ResidenceLayout = () => {
return ( return (
<PuzzleBoxProvider> <PuzzleBoxProvider>
<ResidenceLayoutContent /> <ResidenceLayoutContent />
</PuzzleBoxProvider> </PuzzleBoxProvider>
); )
}; }
export default ResidenceLayout; export default ResidenceLayout
+21 -27
View File
@@ -1,15 +1,15 @@
import type { ReactNode } from "react"; import type { ReactNode } from 'react'
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom'
import { useScale } from "@/contexts/ScaleContext"; import { useScale } from '@/contexts/ScaleContext'
export interface ScaledPortalProps { export interface ScaledPortalProps {
/** 是否启用(不启用则直接渲染 children) */ /** 是否启用(不启用则直接渲染 children) */
enabled?: boolean; enabled?: boolean
/** z-index 层级(建议 > ExhibitOverlay 的 9999) */ /** z-index 层级(建议 > ExhibitOverlay 的 9999) */
zIndex?: number; zIndex?: number
/** 子元素 */ /** 子元素 */
children: ReactNode; children: ReactNode
} }
/** /**
@@ -17,43 +17,37 @@ export interface ScaledPortalProps {
* - 使用 Portal 渲染到 document.body,避免被其他 Portal/遮罩层挡住 * - 使用 Portal 渲染到 document.body,避免被其他 Portal/遮罩层挡住
* - 使用 useScale 的 offset/scale 做视觉对齐(与 ExhibitOverlay 一致) * - 使用 useScale 的 offset/scale 做视觉对齐(与 ExhibitOverlay 一致)
*/ */
const ScaledPortal = ({ const ScaledPortal = ({ enabled = true, zIndex = 10050, children }: ScaledPortalProps) => {
enabled = true, const [mounted, setMounted] = useState(false)
zIndex = 10050, const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
children,
}: ScaledPortalProps) => {
const [mounted, setMounted] = useState(false);
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true)
}, []); }, [])
if (!enabled) return <>{children}</>; if (!enabled) return <>{children}</>
if (!mounted) return null; if (!mounted) return null
const scaled = ( const scaled = (
<div <div
style={{ style={{
position: "fixed", position: 'fixed',
inset: 0, inset: 0,
width: `${DESIGN_WIDTH}px`, width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`, height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`, left: `${offsetX}px`,
top: `${offsetY}px`, top: `${offsetY}px`,
transform: `scale(${scale})`, transform: `scale(${scale})`,
transformOrigin: "top left", transformOrigin: 'top left',
pointerEvents: "auto", pointerEvents: 'auto',
zIndex, zIndex,
}} }}
> >
{children} {children}
</div> </div>
); )
return createPortal(scaled, document.body);
};
export default ScaledPortal;
return createPortal(scaled, document.body)
}
export default ScaledPortal
+15 -20
View File
@@ -1,16 +1,16 @@
import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { getBackgroundImage, getStaticImage } from '@/lib/utils'
export interface ErrorAlertProps { export interface ErrorAlertProps {
/** 文本 */ /** 文本 */
text: string; text: string
/** 是否可见 */ /** 是否可见 */
visible?: boolean; visible?: boolean
/** 点击遮罩层回调 */ /** 点击遮罩层回调 */
onMaskClick?: () => void; onMaskClick?: () => void
/** 关闭回调 */ /** 关闭回调 */
onClose?: () => void; onClose?: () => void
/** 自定义 zIndex */ /** 自定义 zIndex */
zIndex?: number; zIndex?: number
} }
/** /**
@@ -24,18 +24,15 @@ const ErrorAlert = ({
onClose, onClose,
zIndex = 10000, zIndex = 10000,
}: ErrorAlertProps) => { }: ErrorAlertProps) => {
if (!visible) return null; if (!visible) return null
return ( return (
<div <div className="absolute inset-0 z-50 flex items-center justify-center" style={{ zIndex }}>
className="absolute inset-0 z-50 flex items-center justify-center"
style={{ zIndex }}
>
{/* 蒙版背景 */} {/* 蒙版背景 */}
<div <div
className="absolute inset-0 bg-cover bg-center bg-no-repeat" className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{ style={{
backgroundImage: `url(${getStaticImage("components/蒙版遮罩")})`, backgroundImage: `url(${getStaticImage('components/蒙版遮罩')})`,
}} }}
onClick={onMaskClick || onClose} onClick={onMaskClick || onClose}
/> />
@@ -45,9 +42,7 @@ const ErrorAlert = ({
{/* 标题和副标题容器 - 带深色背景条 */} {/* 标题和副标题容器 - 带深色背景条 */}
<div className="w-full bg-black/70 px-20 py-20 flex flex-col items-center gap-4"> <div className="w-full bg-black/70 px-20 py-20 flex flex-col items-center gap-4">
{/* 副标题 */} {/* 副标题 */}
<div className="text-white text-5xl font-medium tracking-widest"> <div className="text-white text-5xl font-medium tracking-widest">{text}</div>
{text}
</div>
</div> </div>
{/* 继续按钮 */} {/* 继续按钮 */}
@@ -55,8 +50,8 @@ const ErrorAlert = ({
className="w-[414px] h-[109px] bg-cover bg-center bg-no-repeat cursor-pointer hover:scale-105 hover:brightness-110 transition-all duration-300" className="w-[414px] h-[109px] bg-cover bg-center bg-no-repeat cursor-pointer hover:scale-105 hover:brightness-110 transition-all duration-300"
style={{ style={{
backgroundImage: ` backgroundImage: `
${getBackgroundImage("components/继续前进")}, ${getBackgroundImage('components/继续前进')},
${getBackgroundImage("components/按钮")} ${getBackgroundImage('components/按钮')}
`, `,
}} }}
onClick={onClose} onClick={onClose}
@@ -64,7 +59,7 @@ const ErrorAlert = ({
></div> ></div>
</div> </div>
</div> </div>
); )
}; }
export default ErrorAlert; export default ErrorAlert
@@ -1,27 +1,27 @@
import { getStaticImage, getBackgroundImage } from "@/lib/utils"; import { getStaticImage, getBackgroundImage } from '@/lib/utils'
import type { MouseEvent } from "react"; import type { MouseEvent } from 'react'
import { useEffect } from "react"; import { useEffect } from 'react'
import { useAudioManager } from "@/hooks/useAudioManager"; import { useAudioManager } from '@/hooks/useAudioManager'
export interface SuccessAlertProps { export interface SuccessAlertProps {
/** 标题 */ /** 标题 */
title?: string; title?: string
/** 内容文本 */ /** 内容文本 */
content?: string; content?: string
/** 是否可见 */ /** 是否可见 */
visible?: boolean; visible?: boolean
/** 成功提示音频地址(可选) */ /** 成功提示音频地址(可选) */
audioSrc?: string; audioSrc?: string
/** 弹窗出现时是否自动播放音频(默认 true) */ /** 弹窗出现时是否自动播放音频(默认 true) */
autoPlayAudio?: boolean; autoPlayAudio?: boolean
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */ /** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string; audioChannelId?: string
/** 点击遮罩层回调 */ /** 点击遮罩层回调 */
onMaskClick?: () => void; onMaskClick?: () => void
/** 继续按钮回调 */ /** 继续按钮回调 */
onContinue?: () => void; onContinue?: () => void
/** 关闭回调 */ /** 关闭回调 */
onClose?: () => void; onClose?: () => void
} }
/** /**
@@ -29,7 +29,7 @@ export interface SuccessAlertProps {
* 使用 components/正确版 作为背景 * 使用 components/正确版 作为背景
*/ */
const SuccessAlert = ({ const SuccessAlert = ({
title = "恭喜你, 选择正确!", title = '恭喜你, 选择正确!',
content, content,
visible = true, visible = true,
audioSrc, audioSrc,
@@ -40,43 +40,41 @@ const SuccessAlert = ({
onClose, onClose,
}: SuccessAlertProps) => { }: SuccessAlertProps) => {
const { currentSrc, play, stop } = useAudioManager( const { currentSrc, play, stop } = useAudioManager(
audioChannelId audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' }
? { mode: "channel", channelId: audioChannelId } )
: { mode: "global" }
);
// 弹窗显示时自动播放音频 // 弹窗显示时自动播放音频
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return
if (!autoPlayAudio) return; if (!autoPlayAudio) return
if (!audioSrc) return; if (!audioSrc) return
play(audioSrc); play(audioSrc)
}, [visible, autoPlayAudio, audioSrc, play]); }, [visible, autoPlayAudio, audioSrc, play])
// 弹窗关闭时停止音频(仅当当前播放的是该弹窗的 audioSrc) // 弹窗关闭时停止音频(仅当当前播放的是该弹窗的 audioSrc)
useEffect(() => { useEffect(() => {
if (visible) return; if (visible) return
if (audioSrc && currentSrc === audioSrc) { if (audioSrc && currentSrc === audioSrc) {
stop(); stop()
} }
}, [visible, audioSrc, currentSrc, stop]); }, [visible, audioSrc, currentSrc, stop])
if (!visible) return null; if (!visible) return null
const handleContinue = (e: MouseEvent) => { const handleContinue = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
// 点击继续时停止音频 // 点击继续时停止音频
if (audioSrc && currentSrc === audioSrc) { if (audioSrc && currentSrc === audioSrc) {
stop(); stop()
} }
// 只调用 onContinue,它会处理关闭逻辑 // 只调用 onContinue,它会处理关闭逻辑
if (onContinue) { if (onContinue) {
onContinue(); onContinue()
} else if (onClose) { } else if (onClose) {
onClose(); onClose()
}
} }
};
return ( return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center"> <div className="fixed inset-0 z-[10000] flex items-center justify-center">
@@ -84,7 +82,7 @@ const SuccessAlert = ({
<div <div
className="absolute inset-0 bg-cover bg-center bg-no-repeat" className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{ style={{
backgroundImage: getBackgroundImage("components/蒙版遮罩"), backgroundImage: getBackgroundImage('components/蒙版遮罩'),
}} }}
onClick={onMaskClick || onClose} onClick={onMaskClick || onClose}
/> />
@@ -99,15 +97,11 @@ const SuccessAlert = ({
{/* 背景图片 - 使用 components/正确版 */} {/* 背景图片 - 使用 components/正确版 */}
<img <img
className="absolute inset-0 top-0 -z-10" className="absolute inset-0 top-0 -z-10"
src={getStaticImage("components/正确版")} src={getStaticImage('components/正确版')}
alt="正确版" alt="正确版"
/> />
{/* 标题 */} {/* 标题 */}
{title && ( {title && <div className="text-[#ffbe57] text-6xl font-bold text-center">{title}</div>}
<div className="text-[#ffbe57] text-6xl font-bold text-center">
{title}
</div>
)}
{/* 内容文本 */} {/* 内容文本 */}
{content && ( {content && (
@@ -123,8 +117,8 @@ const SuccessAlert = ({
className="w-[414px] h-[109px] bg-cover bg-center bg-no-repeat cursor-pointer hover:scale-105 hover:brightness-110 transition-all duration-300" className="w-[414px] h-[109px] bg-cover bg-center bg-no-repeat cursor-pointer hover:scale-105 hover:brightness-110 transition-all duration-300"
style={{ style={{
backgroundImage: ` backgroundImage: `
${getBackgroundImage("components/继续前进")}, ${getBackgroundImage('components/继续前进')},
${getBackgroundImage("components/按钮")} ${getBackgroundImage('components/按钮')}
`, `,
}} }}
onClick={handleContinue} onClick={handleContinue}
@@ -133,7 +127,7 @@ const SuccessAlert = ({
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default SuccessAlert; export default SuccessAlert
+18 -30
View File
@@ -1,31 +1,25 @@
import { Image } from "@/components"; import { Image } from '@/components'
import { cn, getStaticImage } from "@/lib/utils"; import { cn, getStaticImage } from '@/lib/utils'
export interface AlertProps { export interface AlertProps {
/** 标题 */ /** 标题 */
title: string; title: string
/** 副标题 */ /** 副标题 */
subtitle: string; subtitle: string
/** 是否可见 */ /** 是否可见 */
visible?: boolean; visible?: boolean
/** 点击遮罩层回调 */ /** 点击遮罩层回调 */
onMaskClick?: () => void; onMaskClick?: () => void
/** 关闭回调 */ /** 关闭回调 */
onClose?: () => void; onClose?: () => void
} }
/** /**
* Alert 组件 - 提示弹窗 * Alert 组件 - 提示弹窗
* @returns * @returns
*/ */
const Alert = ({ const Alert = ({ title, subtitle, visible = true, onMaskClick, onClose }: AlertProps) => {
title, if (!visible) return null
subtitle,
visible = true,
onMaskClick,
onClose,
}: AlertProps) => {
if (!visible) return null;
return ( return (
<div className="absolute inset-0 z-50 flex items-center justify-center"> <div className="absolute inset-0 z-50 flex items-center justify-center">
@@ -33,7 +27,7 @@ const Alert = ({
<div <div
className="absolute inset-0 bg-cover bg-center bg-no-repeat" className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{ style={{
backgroundImage: `url(${getStaticImage("components/蒙版遮罩")})`, backgroundImage: `url(${getStaticImage('components/蒙版遮罩')})`,
}} }}
onClick={onMaskClick || onClose} onClick={onMaskClick || onClose}
/> />
@@ -45,28 +39,22 @@ const Alert = ({
{/* 标题 */} {/* 标题 */}
<div <div
className={cn([ className={cn([
"absolute -top-10", 'absolute -top-10',
"text-white text-7xl font-bold", 'text-white text-7xl font-bold',
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]", '[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]',
])} ])}
> >
{title} {title}
</div> </div>
{/* 副标题 */} {/* 副标题 */}
<div className="text-white text-5xl font-medium tracking-widest"> <div className="text-white text-5xl font-medium tracking-widest">{subtitle}</div>
{subtitle}
</div>
</div> </div>
{/* 按钮:去了解 */} {/* 按钮:去了解 */}
<Image <Image src={getStaticImage('components/去了解')} onClick={onClose} button />
src={getStaticImage("components/去了解")}
onClick={onClose}
button
/>
</div> </div>
</div> </div>
); )
}; }
export default Alert; export default Alert
+13 -13
View File
@@ -1,16 +1,16 @@
export { default as ResidenceLayout } from "./ResidenceLayout"; export { default as ResidenceLayout } from './ResidenceLayout'
export { default as ClueOverlay } from "../../pages/chenyun/reflection/clue"; export { default as ClueOverlay } from '../../pages/chenyun/reflection/clue'
export type { ClueOverlayProps } from "../../pages/chenyun/reflection/clue"; export type { ClueOverlayProps } from '../../pages/chenyun/reflection/clue'
export { default as ScaledPortal } from "./ScaledPortal"; export { default as ScaledPortal } from './ScaledPortal'
export type { ScaledPortalProps } from "./ScaledPortal"; export type { ScaledPortalProps } from './ScaledPortal'
export { default as Alert } from "./alert/title-alert"; export { default as Alert } from './alert/title-alert'
export type { AlertProps } from "./alert/title-alert"; export type { AlertProps } from './alert/title-alert'
export { default as SuccessAlert } from "./alert/success-alert"; export { default as SuccessAlert } from './alert/success-alert'
export type { SuccessAlertProps } from "./alert/success-alert"; export type { SuccessAlertProps } from './alert/success-alert'
export { default as ErrorAlert } from "./alert/error-alert"; export { default as ErrorAlert } from './alert/error-alert'
export type { ErrorAlertProps } from "./alert/error-alert"; export type { ErrorAlertProps } from './alert/error-alert'
export { DragQuizPanel } from "./quiz"; export { DragQuizPanel } from './quiz'
export type { DragQuizPanelProps } from "./quiz"; export type { DragQuizPanelProps } from './quiz'
// export type { // export type {
// ClueData, // ClueData,
// ClueType, // ClueType,
+31 -44
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from 'react'
import { DragFill } from "@/components"; import { DragFill } from '@/components'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
import type { DragQuizPanelProps } from "./types"; import type { DragQuizPanelProps } from './types'
/** 拖拽题面板:使用 DragFill 组件 */ /** 拖拽题面板:使用 DragFill 组件 */
const DragQuizPanel = ({ const DragQuizPanel = ({
@@ -18,25 +18,22 @@ const DragQuizPanel = ({
onChange, onChange,
}: DragQuizPanelProps) => { }: DragQuizPanelProps) => {
// 跟踪之前的 visible 状态 // 跟踪之前的 visible 状态
const prevVisibleRef = useRef(visible); const prevVisibleRef = useRef(visible)
// 当窗口关闭时,清除拖拽状态 // 当窗口关闭时,清除拖拽状态
useEffect(() => { useEffect(() => {
// 如果从可见变为不可见,清除拖拽状态 // 如果从可见变为不可见,清除拖拽状态
if (prevVisibleRef.current && !visible && value.length > 0) { if (prevVisibleRef.current && !visible && value.length > 0) {
onChange([]); onChange([])
} }
prevVisibleRef.current = visible; prevVisibleRef.current = visible
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]); }, [visible])
if (!visible) return null; if (!visible) return null
return ( return (
<div <div className="fixed inset-0 flex items-center justify-center bg-black/40" style={{ zIndex }}>
className="fixed inset-0 flex items-center justify-center bg-black/40"
style={{ zIndex }}
>
<div className="relative w-[1728px] h-[972px] flex flex-col items-center pt-12 pb-14"> <div className="relative w-[1728px] h-[972px] flex flex-col items-center pt-12 pb-14">
{/* 题干卡片 */} {/* 题干卡片 */}
<div className="w-[1700px] rounded-[32px] bg-black/90 px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]"> <div className="w-[1700px] rounded-[32px] bg-black/90 px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
@@ -46,18 +43,18 @@ const DragQuizPanel = ({
<div> <div>
{questionPrefix && <span>{questionPrefix}</span>} {questionPrefix && <span>{questionPrefix}</span>}
{blanks.map((blank, index) => { {blanks.map((blank, index) => {
const filledResult = value.find((r) => r.blankId === blank.id); const filledResult = value.find((r) => r.blankId === blank.id)
const filledValue = filledResult?.optionValue || null; const filledValue = filledResult?.optionValue || null
return ( return (
<span key={blank.id}> <span key={blank.id}>
<span <span
data-blank-id={blank.id} data-blank-id={blank.id}
className={cn( className={cn(
"inline-flex items-center justify-center min-w-[140px] h-[56px] px-6 mx-3", 'inline-flex items-center justify-center min-w-[140px] h-[56px] px-6 mx-3',
"rounded-xl border-2 transition-all duration-300 shadow-[0_4px_16px_rgba(0,0,0,0.2)]", 'rounded-xl border-2 transition-all duration-300 shadow-[0_4px_16px_rgba(0,0,0,0.2)]',
filledValue filledValue
? "border-[#f0be75] bg-gradient-to-br from-[#3a3020] to-[#2a2520] backdrop-blur-sm" ? 'border-[#f0be75] bg-gradient-to-br from-[#3a3020] to-[#2a2520] backdrop-blur-sm'
: "border-[#d4c5a9]/60 bg-white/10 backdrop-blur-sm hover:border-[#d4c5a9]" : 'border-[#d4c5a9]/60 bg-white/10 backdrop-blur-sm hover:border-[#d4c5a9]'
)} )}
> >
{filledValue ? ( {filledValue ? (
@@ -67,38 +64,28 @@ const DragQuizPanel = ({
type="button" 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" 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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
onChange( onChange(value.filter((r) => r.blankId !== blank.id))
value.filter((r) => r.blankId !== blank.id)
);
}} }}
> >
× ×
</button> </button>
</span> </span>
) : ( ) : (
<span className="text-white/40 text-[24px] font-light"> <span className="text-white/40 text-[24px] font-light">____</span>
____
</span>
)} )}
</span> </span>
{index < blanks.length - 1 && ( {index < blanks.length - 1 && (
<span className="text-white/80 mx-1"> <span className="text-white/80 mx-1">
{blankSeparators && blankSeparators[index] {blankSeparators && blankSeparators[index] ? blankSeparators[index] : '、'}
? blankSeparators[index]
: "、"}
</span> </span>
)} )}
</span> </span>
); )
})} })}
{questionSuffix && <span>{questionSuffix}</span>} {questionSuffix && <span>{questionSuffix}</span>}
{/* 答案反馈 */} {/* 答案反馈 */}
{error && ( {error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
<div className="mt-5 text-center text-[26px] text-[#fcbe53]">
{error}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -117,12 +104,12 @@ const DragQuizPanel = ({
renderOption={(option, isUsed) => ( renderOption={(option, isUsed) => (
<div <div
className={cn( className={cn(
"relative h-[110px] px-10 flex items-center justify-center", 'relative h-[110px] px-10 flex items-center justify-center',
"rounded-[32px] transition-all duration-300", 'rounded-[32px] transition-all duration-300',
"text-[32px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]", 'text-[32px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]',
isUsed isUsed
? "border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50" ? 'border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50'
: "border-[#d4c5a9] bg-gradient-to-br from-[#4a3f35] to-[#3a2f25] text-[#f5e6d3] cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)] hover:scale-[1.02]" : 'border-[#d4c5a9] bg-gradient-to-br from-[#4a3f35] to-[#3a2f25] text-[#f5e6d3] cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)] hover:scale-[1.02]'
)} )}
> >
{!isUsed && ( {!isUsed && (
@@ -136,13 +123,13 @@ const DragQuizPanel = ({
renderBlank={(_blank) => { renderBlank={(_blank) => {
// 返回一个隐藏元素,实际的空白区域在题干中 // 返回一个隐藏元素,实际的空白区域在题干中
// 但需要通过 data-blank-id 让 DragFill 能找到它 // 但需要通过 data-blank-id 让 DragFill 能找到它
return <div className="hidden" />; return <div className="hidden" />
}} }}
/> />
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default DragQuizPanel; export default DragQuizPanel
+111 -126
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from 'react'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
import type { MatchQuizPanelProps } from "./types"; import type { MatchQuizPanelProps } from './types'
/** /**
* 连线题面板组件 * 连线题面板组件
@@ -41,153 +41,141 @@ const MatchQuizPanel = ({
onError, onError,
disabled = false, disabled = false,
}: MatchQuizPanelProps) => { }: MatchQuizPanelProps) => {
const [matches, setMatches] = useState<Array<{ leftId: string; rightId: string }>>([]); const [matches, setMatches] = useState<Array<{ leftId: string; rightId: string }>>([])
const [selectedLeftId, setSelectedLeftId] = useState<string | null>(null); const [selectedLeftId, setSelectedLeftId] = useState<string | null>(null)
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>('')
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null)
// 当前错误的连线对,用于做红色高亮+抖动反馈 // 当前错误的连线对,用于做红色高亮+抖动反馈
const [errorPairs, setErrorPairs] = useState< const [errorPairs, setErrorPairs] = useState<Array<{ leftId: string; rightId: string }>>([])
Array<{ leftId: string; rightId: string }>
>([]);
// 当窗口关闭时,重置状态 // 当窗口关闭时,重置状态
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
setMatches([]); setMatches([])
setErrorPairs([]); setErrorPairs([])
setSelectedLeftId(null); setSelectedLeftId(null)
setError(""); setError('')
} }
}, [visible]); }, [visible])
// 处理左列项点击 // 处理左列项点击
const handleLeftItemClick = (leftId: string) => { 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) { if (existingMatch) {
setMatches(matches.filter((m) => m.leftId !== leftId)); setMatches(matches.filter((m) => m.leftId !== leftId))
setSelectedLeftId(null); setSelectedLeftId(null)
setError(""); setError('')
return; return
} }
// 如果已经选中了另一个左项,取消之前的选中 // 如果已经选中了另一个左项,取消之前的选中
if (selectedLeftId && selectedLeftId !== leftId) { if (selectedLeftId && selectedLeftId !== leftId) {
setSelectedLeftId(leftId); setSelectedLeftId(leftId)
setError(""); setError('')
return; return
} }
// 切换选中状态 // 切换选中状态
setSelectedLeftId(selectedLeftId === leftId ? null : leftId); setSelectedLeftId(selectedLeftId === leftId ? null : leftId)
setError(""); setError('')
}; }
// 处理右列项点击 // 处理右列项点击
const handleRightItemClick = (rightId: string) => { 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) { if (existingMatch) {
setMatches(matches.filter((m) => m.rightId !== rightId)); setMatches(matches.filter((m) => m.rightId !== rightId))
setSelectedLeftId(null); setSelectedLeftId(null)
setError(""); setError('')
return; return
} }
// 创建新的匹配 // 创建新的匹配
const newMatch = { leftId: selectedLeftId, rightId }; const newMatch = { leftId: selectedLeftId, rightId }
const newMatches = [...matches, newMatch]; const newMatches = [...matches, newMatch]
// 每次连线后立即校验当前所有连线是否存在错误 // 每次连线后立即校验当前所有连线是否存在错误
const wrongMatches = const wrongMatches =
correctMatches?.filter( correctMatches?.filter(() => true) && // 保证 correctMatches 存在时才校验
() => true
) && // 保证 correctMatches 存在时才校验
newMatches.filter( newMatches.filter(
(match) => (match) =>
!correctMatches?.some( !correctMatches?.some((cm) => cm.leftId === match.leftId && cm.rightId === match.rightId)
(cm) => cm.leftId === match.leftId && cm.rightId === match.rightId
) )
);
const hasError = !!wrongMatches && wrongMatches.length > 0; const hasError = !!wrongMatches && wrongMatches.length > 0
if (hasError && wrongMatches) { if (hasError && wrongMatches) {
// 记录错误连线对,用于红色高亮+抖动反馈 // 记录错误连线对,用于红色高亮+抖动反馈
setErrorPairs(wrongMatches); setErrorPairs(wrongMatches)
setMatches(newMatches); setMatches(newMatches)
setSelectedLeftId(null); setSelectedLeftId(null)
setError(""); setError('')
let errorMsg: string; let errorMsg: string
if (typeof errorMessage === "function") { if (typeof errorMessage === 'function') {
errorMsg = errorMessage(newMatches); errorMsg = errorMessage(newMatches)
} else if (errorMessage) { } else if (errorMessage) {
errorMsg = errorMessage; errorMsg = errorMessage
} else { } else {
errorMsg = "连线不正确,请重新尝试!"; errorMsg = '连线不正确,请重新尝试!'
} }
setError(errorMsg); setError(errorMsg)
onError?.(errorMsg); onError?.(errorMsg)
// 短暂展示错误状态(变红+抖动),随后只清空错误连线,保留已答对的连线 // 短暂展示错误状态(变红+抖动),随后只清空错误连线,保留已答对的连线
setTimeout(() => { setTimeout(() => {
setMatches((prev) => setMatches((prev) =>
prev.filter( prev.filter(
(m) => (m) => !wrongMatches.some((w) => w.leftId === m.leftId && w.rightId === m.rightId)
!wrongMatches.some(
(w) => w.leftId === m.leftId && w.rightId === m.rightId
) )
) )
); setErrorPairs([])
setErrorPairs([]); }, 600)
}, 600); return
return;
} }
// 没有错误时,正常记录匹配 // 没有错误时,正常记录匹配
setMatches(newMatches); setMatches(newMatches)
setSelectedLeftId(null); setSelectedLeftId(null)
setError(""); setError('')
// 如果没有错误且所有项都已连完,则认为答题成功 // 如果没有错误且所有项都已连完,则认为答题成功
if ( if (newMatches.length === leftItems.length && newMatches.length === rightItems.length) {
newMatches.length === leftItems.length && onSubmit?.(newMatches, true)
newMatches.length === rightItems.length }
) {
onSubmit?.(newMatches, true);
} }
};
// 获取左项是否已匹配 // 获取左项是否已匹配
const isLeftItemMatched = (leftId: string) => { const isLeftItemMatched = (leftId: string) => {
return matches.some((m) => m.leftId === leftId); return matches.some((m) => m.leftId === leftId)
}; }
// 获取右项是否已匹配 // 获取右项是否已匹配
const isRightItemMatched = (rightId: string) => { const isRightItemMatched = (rightId: string) => {
return matches.some((m) => m.rightId === rightId); return matches.some((m) => m.rightId === rightId)
}; }
// 获取左项对应的右项ID // 获取左项对应的右项ID
const getMatchedRightId = (leftId: string) => { const getMatchedRightId = (leftId: string) => {
const match = matches.find((m) => m.leftId === leftId); const match = matches.find((m) => m.leftId === leftId)
return match?.rightId || null; return match?.rightId || null
}; }
// 获取右项对应的左项ID // 获取右项对应的左项ID
const getMatchedLeftId = (rightId: string) => { const getMatchedLeftId = (rightId: string) => {
const match = matches.find((m) => m.rightId === rightId); const match = matches.find((m) => m.rightId === rightId)
return match?.leftId || null; return match?.leftId || null
}; }
if (!visible) return null; if (!visible) return null
return ( return (
<div <div
@@ -215,35 +203,33 @@ const MatchQuizPanel = ({
{/* 左列:日期 */} {/* 左列:日期 */}
<div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4"> <div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
{leftItems.map((item) => { {leftItems.map((item) => {
const isMatched = isLeftItemMatched(item.id); const isMatched = isLeftItemMatched(item.id)
const isSelected = selectedLeftId === item.id; const isSelected = selectedLeftId === item.id
const isError = errorPairs.some((p) => p.leftId === item.id); const isError = errorPairs.some((p) => p.leftId === item.id)
return ( return (
<div <div
key={item.id} key={item.id}
className={cn( className={cn(
"relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200", 'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
"text-[20px] md:text-[28px] leading-relaxed cursor-pointer", 'text-[20px] md:text-[28px] leading-relaxed cursor-pointer',
"touch-manipulation", // 优化移动端触摸 'touch-manipulation', // 优化移动端触摸
disabled || isMatched disabled || isMatched ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
? "opacity-60 cursor-not-allowed"
: "cursor-pointer",
isError isError
? "bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse" ? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
: isSelected : isSelected
? "bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105" ? 'bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105'
: isMatched : isMatched
? "bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]" ? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
: "bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]" : 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
)} )}
onClick={() => handleLeftItemClick(item.id)} onClick={() => handleLeftItemClick(item.id)}
onTouchStart={(e) => { onTouchStart={(e) => {
// 移动端触摸优化 // 移动端触摸优化
e.currentTarget.classList.add("scale-105"); e.currentTarget.classList.add('scale-105')
}} }}
onTouchEnd={(e) => { onTouchEnd={(e) => {
e.currentTarget.classList.remove("scale-105"); e.currentTarget.classList.remove('scale-105')
}} }}
> >
{item.label} {item.label}
@@ -258,7 +244,7 @@ const MatchQuizPanel = ({
</div> </div>
)} )}
</div> </div>
); )
})} })}
</div> </div>
@@ -267,18 +253,18 @@ const MatchQuizPanel = ({
{/* 绘制连线 */} {/* 绘制连线 */}
<svg className="absolute inset-0 w-full h-full pointer-events-none overflow-visible"> <svg className="absolute inset-0 w-full h-full pointer-events-none overflow-visible">
{matches.map((match, index) => { {matches.map((match, index) => {
const leftItem = leftItems.find((item) => item.id === match.leftId); const leftItem = leftItems.find((item) => item.id === match.leftId)
const rightItem = rightItems.find((item) => item.id === match.rightId); const rightItem = rightItems.find((item) => item.id === match.rightId)
if (!leftItem || !rightItem) return null; if (!leftItem || !rightItem) return null
const leftIndex = leftItems.findIndex((item) => item.id === match.leftId); const leftIndex = leftItems.findIndex((item) => item.id === match.leftId)
const rightIndex = rightItems.findIndex((item) => item.id === match.rightId); const rightIndex = rightItems.findIndex((item) => item.id === match.rightId)
// 计算连线起点和终点(基于项目高度和间距) // 计算连线起点和终点(基于项目高度和间距)
const itemHeight = 80; // 每个项目的大概高度 const itemHeight = 80 // 每个项目的大概高度
const gap = 16; // 项目之间的间距 const gap = 16 // 项目之间的间距
const startY = leftIndex * (itemHeight + gap) + itemHeight / 2; const startY = leftIndex * (itemHeight + gap) + itemHeight / 2
const endY = rightIndex * (itemHeight + gap) + itemHeight / 2; const endY = rightIndex * (itemHeight + gap) + itemHeight / 2
return ( return (
<line <line
@@ -292,7 +278,7 @@ const MatchQuizPanel = ({
strokeDasharray="5,5" strokeDasharray="5,5"
markerEnd="url(#arrowhead)" markerEnd="url(#arrowhead)"
/> />
); )
})} })}
{/* 箭头标记定义 */} {/* 箭头标记定义 */}
<defs> <defs>
@@ -313,37 +299,37 @@ const MatchQuizPanel = ({
{/* 右列:事件 */} {/* 右列:事件 */}
<div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4"> <div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
{rightItems.map((item) => { {rightItems.map((item) => {
const isMatched = isRightItemMatched(item.id); const isMatched = isRightItemMatched(item.id)
const isError = errorPairs.some((p) => p.rightId === item.id); const isError = errorPairs.some((p) => p.rightId === item.id)
return ( return (
<div <div
key={item.id} key={item.id}
className={cn( className={cn(
"relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200", 'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
"text-[20px] md:text-[28px] leading-relaxed", 'text-[20px] md:text-[28px] leading-relaxed',
"touch-manipulation", // 优化移动端触摸 'touch-manipulation', // 优化移动端触摸
disabled || isMatched disabled || isMatched
? "opacity-60 cursor-not-allowed" ? 'opacity-60 cursor-not-allowed'
: selectedLeftId : selectedLeftId
? "cursor-pointer" ? 'cursor-pointer'
: "cursor-default", : 'cursor-default',
isError isError
? "bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse" ? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
: isMatched : isMatched
? "bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]" ? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
: selectedLeftId : selectedLeftId
? "bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]" ? 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
: "bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60" : 'bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60'
)} )}
onClick={() => handleRightItemClick(item.id)} onClick={() => handleRightItemClick(item.id)}
onTouchStart={(e) => { onTouchStart={(e) => {
if (selectedLeftId) { if (selectedLeftId) {
e.currentTarget.classList.add("scale-105"); e.currentTarget.classList.add('scale-105')
} }
}} }}
onTouchEnd={(e) => { onTouchEnd={(e) => {
e.currentTarget.classList.remove("scale-105"); e.currentTarget.classList.remove('scale-105')
}} }}
> >
{item.label} {item.label}
@@ -358,7 +344,7 @@ const MatchQuizPanel = ({
</div> </div>
)} )}
</div> </div>
); )
})} })}
</div> </div>
</div> </div>
@@ -369,8 +355,7 @@ const MatchQuizPanel = ({
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default MatchQuizPanel;
export default MatchQuizPanel
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react'
import { MultipleChoice } from "@/components"; import { MultipleChoice } from '@/components'
import type { MultipleChoiceQuizPanelProps } from "./types"; import type { MultipleChoiceQuizPanelProps } from './types'
/** /**
* 多选题面板组件 * 多选题面板组件
@@ -44,94 +44,85 @@ const MultipleChoiceQuizPanel = ({
disabled = false, disabled = false,
minSelections, minSelections,
maxSelections, maxSelections,
submitText = "提交", submitText = '提交',
}: MultipleChoiceQuizPanelProps) => { }: MultipleChoiceQuizPanelProps) => {
const [selectedValues, setSelectedValues] = useState<string[]>([]); const [selectedValues, setSelectedValues] = useState<string[]>([])
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>('')
// 当窗口关闭时,清除选中状态和错误状态 // 当窗口关闭时,清除选中状态和错误状态
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
setSelectedValues([]); setSelectedValues([])
setError(""); setError('')
} }
}, [visible]); }, [visible])
if (!visible) return null; if (!visible) return null
// 处理选项点击 // 处理选项点击
const handleOptionChange = (values: string[]) => { const handleOptionChange = (values: string[]) => {
if (disabled) return; if (disabled) return
setSelectedValues(values); setSelectedValues(values)
setError(""); setError('')
}; }
// 处理提交 // 处理提交
const handleSubmit = () => { const handleSubmit = () => {
if (disabled) return; if (disabled) return
// 检查最小选择数量 // 检查最小选择数量
if (minSelections !== undefined && selectedValues.length < minSelections) { if (minSelections !== undefined && selectedValues.length < minSelections) {
const errorMsg = const errorMsg = minSelectionsErrorMessage || `请至少选择 ${minSelections} 项`
minSelectionsErrorMessage || `请至少选择 ${minSelections} 项`; setError(errorMsg)
setError(errorMsg); onError?.(errorMsg)
onError?.(errorMsg); return
return;
} }
// 验证答案 // 验证答案
let isCorrect: boolean | undefined = undefined; let isCorrect: boolean | undefined = undefined
if (correctAnswer !== undefined) { if (correctAnswer !== undefined) {
// 比较两个数组是否相等(不考虑顺序) // 比较两个数组是否相等(不考虑顺序)
const correctSet = new Set(correctAnswer); const correctSet = new Set(correctAnswer)
const selectedSet = new Set(selectedValues); const selectedSet = new Set(selectedValues)
isCorrect = isCorrect =
correctSet.size === selectedSet.size && correctSet.size === selectedSet.size && [...correctSet].every((val) => selectedSet.has(val))
[...correctSet].every((val) => selectedSet.has(val));
if (isCorrect) { if (isCorrect) {
// 答题成功:清空状态,执行 onSubmit(外部控制关闭窗口) // 答题成功:清空状态,执行 onSubmit(外部控制关闭窗口)
const submittedValues = [...selectedValues]; const submittedValues = [...selectedValues]
setSelectedValues([]); setSelectedValues([])
setError(""); setError('')
onSubmit?.(submittedValues, true); onSubmit?.(submittedValues, true)
} else { } else {
// 答题失败:清空状态,执行 onError,允许重新选择 // 答题失败:清空状态,执行 onError,允许重新选择
const submittedValues = [...selectedValues]; const submittedValues = [...selectedValues]
setSelectedValues([]); setSelectedValues([])
const errorMsg = const errorMsg =
typeof errorMessage === "function" typeof errorMessage === 'function'
? errorMessage(submittedValues) ? errorMessage(submittedValues)
: errorMessage || "很抱歉,你选错了!请你再思考一下吧!"; : errorMessage || '很抱歉,你选错了!请你再思考一下吧!'
setError(errorMsg); setError(errorMsg)
onError?.(errorMsg); onError?.(errorMsg)
} }
} else { } else {
// 没有提供正确答案,直接提交 // 没有提供正确答案,直接提交
const submittedValues = [...selectedValues]; const submittedValues = [...selectedValues]
setSelectedValues([]); setSelectedValues([])
setError(""); setError('')
onSubmit?.(submittedValues, true); onSubmit?.(submittedValues, true)
}
} }
};
return ( return (
<div <div className="fixed inset-0 flex items-center justify-center bg-black/40" style={{ zIndex }}>
className="fixed inset-0 flex items-center justify-center bg-black/40"
style={{ zIndex }}
>
<div className="relative w-[1728px] flex flex-col items-center py-6"> <div className="relative w-[1728px] flex flex-col items-center py-6">
{/* 题干卡片 */} {/* 题干卡片 */}
<div className="w-[1700px] rounded-[32px] bg-black px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] flex-shrink-0"> <div className="w-[1700px] rounded-[32px] bg-black px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] flex-shrink-0">
<div className="text-[34px] leading-relaxed text-white">{title}</div> <div className="text-[34px] leading-relaxed text-white">{title}</div>
{/* 答案反馈 */} {/* 答案反馈 */}
{error && ( {error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
<div className="mt-5 text-center text-[26px] text-[#fcbe53]">
{error}
</div>
)}
</div> </div>
{/* 选项区域 - 可滚动 */} {/* 选项区域 - 可滚动 */}
@@ -149,56 +140,48 @@ const MultipleChoiceQuizPanel = ({
disabled || disabled ||
(!isSelected && (!isSelected &&
maxSelections !== undefined && maxSelections !== undefined &&
selectedValues.length >= maxSelections); selectedValues.length >= maxSelections)
// 处理选项点击 // 处理选项点击
const handleClick = () => { const handleClick = () => {
if (isDisabled) return; if (isDisabled) return
let newValues: string[]; let newValues: string[]
if (isSelected) { if (isSelected) {
// 取消选择 // 取消选择
newValues = selectedValues.filter((v) => v !== option.value); newValues = selectedValues.filter((v) => v !== option.value)
// 检查最小选择数量限制 // 检查最小选择数量限制
if ( if (minSelections !== undefined && newValues.length < minSelections) {
minSelections !== undefined && return
newValues.length < minSelections
) {
return;
} }
} else { } else {
// 选择 // 选择
// 检查最大选择数量限制 // 检查最大选择数量限制
if ( if (maxSelections !== undefined && selectedValues.length >= maxSelections) {
maxSelections !== undefined && return
selectedValues.length >= maxSelections
) {
return;
} }
newValues = [...selectedValues, option.value]; newValues = [...selectedValues, option.value]
} }
handleOptionChange(newValues); handleOptionChange(newValues)
}; }
return ( return (
<div key={option.value} className="mb-4"> <div key={option.value} className="mb-4">
<div <div
className={`w-[1500px] flex items-center p-4 text-[28px] leading-relaxed rounded-tr-[50px] rounded-bl-4xl transition-all duration-150 ${ className={`w-[1500px] flex items-center p-4 text-[28px] leading-relaxed rounded-tr-[50px] rounded-bl-4xl transition-all duration-150 ${
isDisabled isDisabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
? "opacity-60 cursor-not-allowed"
: "cursor-pointer"
} ${ } ${
isSelected isSelected
? "bg-[#d4a574] text-[#5c3e2e] bg-gradient-to-r from-[#ddf3e1] to-[#fbdd97]" ? 'bg-[#d4a574] text-[#5c3e2e] bg-gradient-to-r from-[#ddf3e1] to-[#fbdd97]'
: "bg-[#d7b888] text-[#5c3e2e] hover:brightness-110" : 'bg-[#d7b888] text-[#5c3e2e] hover:brightness-110'
}`} }`}
onClick={handleClick} onClick={handleClick}
> >
{option.label} {option.label}
</div> </div>
</div> </div>
); )
}} }}
/> />
</div> </div>
@@ -212,8 +195,7 @@ const MultipleChoiceQuizPanel = ({
disabled={ disabled={
disabled || disabled ||
selectedValues.length === 0 || selectedValues.length === 0 ||
(minSelections !== undefined && (minSelections !== undefined && selectedValues.length < minSelections)
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" className="px-30 py-4 text-[28px] font-medium text-white bg-[#c2995f] rounded-[40px] transition-all duration-200 hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
> >
@@ -223,7 +205,7 @@ const MultipleChoiceQuizPanel = ({
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default MultipleChoiceQuizPanel; export default MultipleChoiceQuizPanel
+19 -29
View File
@@ -1,6 +1,6 @@
import { SingleChoice } from "@/components"; import { SingleChoice } from '@/components'
import { getBackgroundImage } from "@/lib/utils"; import { getBackgroundImage } from '@/lib/utils'
import type { QuizPanelProps } from "./types"; import type { QuizPanelProps } from './types'
/** 单选题展示面板:题干使用“底板”背景,点击即选,hover 高亮 */ /** 单选题展示面板:题干使用“底板”背景,点击即选,hover 高亮 */
const QuizPanel = ({ const QuizPanel = ({
@@ -12,13 +12,10 @@ const QuizPanel = ({
error, error,
onChange, onChange,
}: QuizPanelProps) => { }: QuizPanelProps) => {
if (!visible) return null; if (!visible) return null
return ( return (
<div <div className="fixed inset-0 flex items-center justify-center bg-black/40" style={{ zIndex }}>
className="fixed inset-0 flex items-center justify-center bg-black/40"
style={{ zIndex }}
>
<div className="relative w-[1728px] h-[972px] flex flex-col items-center justify-center"> <div className="relative w-[1728px] h-[972px] flex flex-col items-center justify-center">
{/* 题干卡片 */} {/* 题干卡片 */}
<div className="w-[1600px] min-h-[260px] rounded-[32px] bg-black px-20 py-14 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]"> <div className="w-[1600px] min-h-[260px] rounded-[32px] bg-black px-20 py-14 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
@@ -40,24 +37,21 @@ const QuizPanel = ({
className="w-[1431px] h-[156px] flex items-center px-16 text-[28px] leading-snug text-[#fdf5e6] bg-cover bg-center bg-no-repeat transition-all duration-150" 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={{ style={{
backgroundImage: getBackgroundImage( backgroundImage: getBackgroundImage(
isSelected isSelected ? 'components/quiz/选中' : 'components/quiz/未选择'
? "components/quiz/选中"
: "components/quiz/未选择"
), ),
filter: isSelected ? undefined : "brightness(0.96)", filter: isSelected ? undefined : 'brightness(0.96)',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (isSelected) return; if (isSelected) return
(e.currentTarget as HTMLDivElement).style.backgroundImage = ;(e.currentTarget as HTMLDivElement).style.backgroundImage =
getBackgroundImage("components/quiz/选中"); getBackgroundImage('components/quiz/选中')
(e.currentTarget as HTMLDivElement).style.filter = ""; ;(e.currentTarget as HTMLDivElement).style.filter = ''
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (isSelected) return; if (isSelected) return
(e.currentTarget as HTMLDivElement).style.backgroundImage = ;(e.currentTarget as HTMLDivElement).style.backgroundImage =
getBackgroundImage("components/quiz/未选择"); getBackgroundImage('components/quiz/未选择')
(e.currentTarget as HTMLDivElement).style.filter = ;(e.currentTarget as HTMLDivElement).style.filter = 'brightness(0.96)'
"brightness(0.96)";
}} }}
> >
{option.label} {option.label}
@@ -67,15 +61,11 @@ const QuizPanel = ({
/> />
{/* 答案反馈 */} {/* 答案反馈 */}
{error && ( {error && <div className="mt-6 text-center text-[26px] text-[#ff4d4f]">{error}</div>}
<div className="mt-6 text-center text-[26px] text-[#ff4d4f]">
{error}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default QuizPanel; export default QuizPanel
@@ -1,8 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import type { import type { SelectFillQuizPanelProps, SelectFillResult } from './types'
SelectFillQuizPanelProps,
SelectFillResult,
} from "./types";
/** /**
* 选择填空题面板组件 * 选择填空题面板组件
@@ -21,131 +18,124 @@ const SelectFillQuizPanel = ({
onSubmit, onSubmit,
onError, onError,
disabled = false, disabled = false,
submitText = "提交", submitText = '提交',
}: SelectFillQuizPanelProps) => { }: SelectFillQuizPanelProps) => {
const [results, setResults] = useState<SelectFillResult[]>([]); const [results, setResults] = useState<SelectFillResult[]>([])
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>('')
// 当前正在填的空的下标(从 0 开始),默认为第一个空 // 当前正在填的空的下标(从 0 开始),默认为第一个空
const [activeBlankIndex, setActiveBlankIndex] = useState(0); const [activeBlankIndex, setActiveBlankIndex] = useState(0)
// 关闭时重置内部状态 // 关闭时重置内部状态
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
setResults([]); setResults([])
setError(""); setError('')
setActiveBlankIndex(0); setActiveBlankIndex(0)
} }
}, [visible]); }, [visible])
if (!visible) return null; if (!visible) return null
const isAllFilled = blanks.every((blank) => 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) => { const handleOptionClick = (value: string) => {
if (disabled) return; if (disabled) return
const currentBlank = blanks[activeBlankIndex]; const currentBlank = blanks[activeBlankIndex]
if (!currentBlank) return; if (!currentBlank) return
const newResults: SelectFillResult[] = (() => { const newResults: SelectFillResult[] = (() => {
const others = results.filter((r) => r.blankId !== currentBlank.id); const others = results.filter((r) => r.blankId !== currentBlank.id)
return [...others, { blankId: currentBlank.id, value }]; return [...others, { blankId: currentBlank.id, value }]
})(); })()
setResults(newResults); setResults(newResults)
setError(""); setError('')
// 自动跳到下一个空 // 自动跳到下一个空
const nextIndex = (() => { const nextIndex = (() => {
// 优先跳到还未填写的下一个空 // 优先跳到还未填写的下一个空
for (let i = 0; i < blanks.length; i++) { for (let i = 0; i < blanks.length; i++) {
const blankItem = blanks[i]!; const blankItem = blanks[i]!
const hasValue = newResults.some( const hasValue = newResults.some((r) => r.blankId === blankItem.id && r.value !== '')
(r) => r.blankId === blankItem.id && r.value !== "" if (!hasValue) return i
); }
if (!hasValue) return i; return activeBlankIndex
})()
setActiveBlankIndex(nextIndex)
} }
return activeBlankIndex;
})();
setActiveBlankIndex(nextIndex);
};
// 清空某个空 // 清空某个空
const handleClearBlank = (blankId: string) => { const handleClearBlank = (blankId: string) => {
if (disabled) return; if (disabled) return
setResults(results.filter((r) => r.blankId !== blankId)); setResults(results.filter((r) => r.blankId !== blankId))
setError(""); setError('')
const index = blanks.findIndex((b) => b.id === blankId); const index = blanks.findIndex((b) => b.id === blankId)
if (index >= 0) setActiveBlankIndex(index); if (index >= 0) setActiveBlankIndex(index)
}; }
// 提交答案 // 提交答案
const handleSubmit = () => { const handleSubmit = () => {
if (disabled) return; if (disabled) return
// 有空未填 // 有空未填
if (!isAllFilled) { if (!isAllFilled) {
const msg = "请先把所有空都填完再提交哦~"; const msg = '请先把所有空都填完再提交哦~'
setError(msg); setError(msg)
onError?.(msg); onError?.(msg)
return; return
} }
const filledResults = blanks.map((blank) => { const filledResults = blanks.map((blank) => {
const r = results.find((res) => res.blankId === blank.id); const r = results.find((res) => res.blankId === blank.id)
return { blankId: blank.id, value: r?.value ?? "" }; return { blankId: blank.id, value: r?.value ?? '' }
}); })
let isCorrect = true; let isCorrect = true
if (correctAnswers) { if (correctAnswers) {
for (const r of filledResults) { for (const r of filledResults) {
const expected = correctAnswers[r.blankId]; const expected = correctAnswers[r.blankId]
if (expected !== undefined && expected !== r.value) { if (expected !== undefined && expected !== r.value) {
isCorrect = false; isCorrect = false
break; break
} }
} }
} }
if (!correctAnswers) { if (!correctAnswers) {
onSubmit?.(filledResults, true); onSubmit?.(filledResults, true)
return; return
} }
if (isCorrect) { if (isCorrect) {
setError(""); setError('')
onSubmit?.(filledResults, true); onSubmit?.(filledResults, true)
} else { } else {
const msg = const msg = errorMessage || '还不太对,再仔细对照题干和提示想一想吧~'
errorMessage || "还不太对,再仔细对照题干和提示想一想吧~"; setError(msg)
setError(msg);
// 错误后清空已填写内容,重新作答 // 错误后清空已填写内容,重新作答
setResults([]); setResults([])
setActiveBlankIndex(0); setActiveBlankIndex(0)
onError?.(msg); onError?.(msg)
}
} }
};
const getBlankValue = (blankId: string) => const getBlankValue = (blankId: string) => results.find((r) => r.blankId === blankId)?.value ?? ''
results.find((r) => r.blankId === blankId)?.value ?? "";
return ( return (
<div <div className="fixed inset-0 flex items-center justify-center bg-black/40" style={{ zIndex }}>
className="fixed inset-0 flex items-center justify-center bg-black/40"
style={{ zIndex }}
>
<div className="relative w-[1728px] h-[972px] flex flex-col items-center justify-center"> <div className="relative w-[1728px] h-[972px] flex flex-col items-center justify-center">
{/* 题干 + 空格区域 */} {/* 题干 + 空格区域 */}
<div className="w-[1700px] rounded-[32px] bg-black px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]"> <div className="w-[1700px] rounded-[32px] bg-black px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
<div className="text-[32px] leading-relaxed text-white"> <div className="text-[32px] leading-relaxed text-white">
{titlePrefix && <span>{titlePrefix}</span>} {titlePrefix && <span>{titlePrefix}</span>}
{blanks.map((blank, index) => { {blanks.map((blank, index) => {
const value = getBlankValue(blank.id); const value = getBlankValue(blank.id)
const isActive = index === activeBlankIndex; const isActive = index === activeBlankIndex
return ( return (
<span key={blank.id}> <span key={blank.id}>
{blank.prefix && <span>{blank.prefix}</span>} {blank.prefix && <span>{blank.prefix}</span>}
@@ -153,29 +143,29 @@ const SelectFillQuizPanel = ({
type="button" 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 ${ 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 disabled
? "opacity-60 cursor-not-allowed border-[#d4c5a9]/40" ? 'opacity-60 cursor-not-allowed border-[#d4c5a9]/40'
: "cursor-pointer" : 'cursor-pointer'
} ${ } ${
value value
? "border-[#f0be75] bg-gradient-to-br from-[#3a3020] to-[#2a2520] text-[#f0be75]" ? 'border-[#f0be75] bg-gradient-to-br from-[#3a3020] to-[#2a2520] text-[#f0be75]'
: "border-[#d4c5a9]/60 bg-white/10 text-white/40" : 'border-[#d4c5a9]/60 bg-white/10 text-white/40'
} ${ } ${
isActive && !disabled isActive && !disabled
? "ring-2 ring-[#f0be75] ring-offset-2 ring-offset-black" ? 'ring-2 ring-[#f0be75] ring-offset-2 ring-offset-black'
: "" : ''
}`} }`}
onClick={() => { onClick={() => {
if (disabled) return; if (disabled) return
setActiveBlankIndex(index); setActiveBlankIndex(index)
}} }}
> >
{value || "____"} {value || '____'}
{value && !disabled && ( {value && !disabled && (
<span <span
className="ml-3 text-[22px] text-[#f0be75]/80" className="ml-3 text-[22px] text-[#f0be75]/80"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
handleClearBlank(blank.id); handleClearBlank(blank.id)
}} }}
> >
× ×
@@ -184,17 +174,13 @@ const SelectFillQuizPanel = ({
</button> </button>
{blank.suffix && <span>{blank.suffix}</span>} {blank.suffix && <span>{blank.suffix}</span>}
</span> </span>
); )
})} })}
{titleSuffix && <span>{titleSuffix}</span>} {titleSuffix && <span>{titleSuffix}</span>}
</div> </div>
{/* 答案反馈 */} {/* 答案反馈 */}
{error && ( {error && <div className="mt-6 text-center text-[26px] text-[#fcbe53]">{error}</div>}
<div className="mt-6 text-center text-[26px] text-[#fcbe53]">
{error}
</div>
)}
</div> </div>
{/* 选项区域 */} {/* 选项区域 */}
@@ -207,8 +193,8 @@ const SelectFillQuizPanel = ({
disabled={disabled} disabled={disabled}
className={`min-w-[90px] h-[72px] px-6 rounded-2xl text-[28px] font-medium border-2 transition-all ${ className={`min-w-[90px] h-[72px] px-6 rounded-2xl text-[28px] font-medium border-2 transition-all ${
disabled disabled
? "opacity-60 cursor-not-allowed bg-[#d7b888]/60 border-[#d4c5a9]/60 text-[#5c3e2e]/60" ? '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]" : '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)} onClick={() => handleOptionClick(option.value)}
> >
@@ -231,9 +217,7 @@ const SelectFillQuizPanel = ({
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default SelectFillQuizPanel;
export default SelectFillQuizPanel
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { SingleChoice } from "@/components"; import { SingleChoice } from '@/components'
import type { SingleChoiceQuizPanelProps } from "./types"; import type { SingleChoiceQuizPanelProps } from './types'
/** /**
* 单选题面板组件 * 单选题面板组件
@@ -39,70 +39,61 @@ const SingleChoiceQuizPanel = ({
onError, onError,
disabled = false, disabled = false,
}: SingleChoiceQuizPanelProps) => { }: SingleChoiceQuizPanelProps) => {
const [selectedValue, setSelectedValue] = useState<string>(""); const [selectedValue, setSelectedValue] = useState<string>('')
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>('')
// 当窗口关闭时,重置内部状态 // 当窗口关闭时,重置内部状态
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
setSelectedValue(""); setSelectedValue('')
setError(""); setError('')
} }
}, [visible]); }, [visible])
if (!visible) return null; if (!visible) return null
// 处理选项点击 // 处理选项点击
const handleOptionClick = (value: string) => { const handleOptionClick = (value: string) => {
if (disabled) return; if (disabled) return
setSelectedValue(value); setSelectedValue(value)
setError(""); setError('')
// 验证答案 // 验证答案
const isCorrect = const isCorrect = correctAnswer !== undefined ? value === correctAnswer : undefined
correctAnswer !== undefined ? value === correctAnswer : undefined;
// 如果提供了正确答案,进行验证 // 如果提供了正确答案,进行验证
if (correctAnswer !== undefined) { if (correctAnswer !== undefined) {
if (isCorrect) { if (isCorrect) {
// 答题成功:清空状态,执行 onSubmit(外部控制关闭窗口) // 答题成功:清空状态,执行 onSubmit(外部控制关闭窗口)
setSelectedValue(""); setSelectedValue('')
setError(""); setError('')
onSubmit?.(value, true); onSubmit?.(value, true)
} else { } else {
// 答题失败:清空状态,执行 onError,允许重新选择 // 答题失败:清空状态,执行 onError,允许重新选择
setSelectedValue(""); setSelectedValue('')
const customError = onWrongSelect?.(value); const customError = onWrongSelect?.(value)
const errorMsg = const errorMsg = customError || errorMessage || '很抱歉,你选错了!请你再思考一下吧!'
customError || errorMessage || "很抱歉,你选错了!请你再思考一下吧!"; setError(errorMsg)
setError(errorMsg); onError?.(errorMsg)
onError?.(errorMsg);
} }
} else { } else {
// 没有提供正确答案,直接提交 // 没有提供正确答案,直接提交
setSelectedValue(""); setSelectedValue('')
setError(""); setError('')
onSubmit?.(value, true); onSubmit?.(value, true)
}
} }
};
return ( return (
<div <div className="fixed inset-0 flex items-center justify-center bg-black/40" style={{ zIndex }}>
className="fixed inset-0 flex items-center justify-center bg-black/40"
style={{ zIndex }}
>
<div className="relative w-[1728px] h-[972px] flex flex-col items-center justify-center"> <div className="relative w-[1728px] h-[972px] flex flex-col items-center justify-center">
{/* 题干卡片 */} {/* 题干卡片 */}
<div className="w-[1700px] rounded-[32px] bg-black px-20 py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]"> <div className="w-[1700px] rounded-[32px] bg-black px-20 py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
<div className="text-[34px] leading-relaxed text-white">{title}</div> <div className="text-[34px] leading-relaxed text-white">{title}</div>
{/* 答案反馈 */} {/* 答案反馈 */}
{error && ( {error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
<div className="mt-5 text-center text-[26px] text-[#fcbe53]">
{error}
</div>
)}
</div> </div>
{/* 选项区域 */} {/* 选项区域 */}
@@ -119,13 +110,11 @@ const SingleChoiceQuizPanel = ({
> >
<div <div
className={`w-[1500px] flex items-center p-4 text-[28px] leading-relaxed rounded-tr-[50px] rounded-bl-4xl transition-all duration-150 ${ className={`w-[1500px] flex items-center p-4 text-[28px] leading-relaxed rounded-tr-[50px] rounded-bl-4xl transition-all duration-150 ${
disabled disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
? "opacity-60 cursor-not-allowed"
: "cursor-pointer"
} ${ } ${
isSelected isSelected
? "bg-[#d4a574] text-[#5c3e2e]" ? 'bg-[#d4a574] text-[#5c3e2e]'
: "bg-[#d7b888] text-[#5c3e2e] hover:bg-gradient-to-r from-[#ddf3e1] to-[#fbdd97]" : 'bg-[#d7b888] text-[#5c3e2e] hover:bg-gradient-to-r from-[#ddf3e1] to-[#fbdd97]'
}`} }`}
> >
{option.label} {option.label}
@@ -136,7 +125,7 @@ const SingleChoiceQuizPanel = ({
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default SingleChoiceQuizPanel; export default SingleChoiceQuizPanel
+8 -8
View File
@@ -1,9 +1,9 @@
// 新组件 // 新组件
export { default as SingleChoiceQuizPanel } from "./SingleChoiceQuizPanel"; export { default as SingleChoiceQuizPanel } from './SingleChoiceQuizPanel'
export { default as MultipleChoiceQuizPanel } from "./MultipleChoiceQuizPanel"; export { default as MultipleChoiceQuizPanel } from './MultipleChoiceQuizPanel'
export { default as DragQuizPanel } from "./DragQuizPanel"; export { default as DragQuizPanel } from './DragQuizPanel'
export { default as MatchQuizPanel } from "./MatchQuizPanel"; export { default as MatchQuizPanel } from './MatchQuizPanel'
export { default as SelectFillQuizPanel } from "./SelectFillQuizPanel"; export { default as SelectFillQuizPanel } from './SelectFillQuizPanel'
export type { export type {
SingleChoiceQuizPanelProps, SingleChoiceQuizPanelProps,
MultipleChoiceQuizPanelProps, MultipleChoiceQuizPanelProps,
@@ -14,8 +14,8 @@ export type {
SelectFillQuizPanelProps, SelectFillQuizPanelProps,
SelectFillBlank, SelectFillBlank,
SelectFillResult, SelectFillResult,
} from "./types"; } from './types'
// 保留旧组件以保持向后兼容 // 保留旧组件以保持向后兼容
export { default as QuizPanel } from "./QuizPanal"; export { default as QuizPanel } from './QuizPanal'
export type { QuizPanelProps } from "./types"; export type { QuizPanelProps } from './types'
+73 -73
View File
@@ -3,188 +3,188 @@ import {
MultipleChoiceOption, MultipleChoiceOption,
DragFillResult, DragFillResult,
DragFillBlank, DragFillBlank,
} from "@/components"; } from '@/components'
/** 拖拽题面板配置 */ /** 拖拽题面板配置 */
export interface DragQuizPanelProps { export interface DragQuizPanelProps {
/** 是否可见 */ /** 是否可见 */
visible: boolean; visible: boolean
/** z-index 层级 */ /** 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 { export interface SingleChoiceQuizPanelProps {
/** 是否可见 */ /** 是否可见 */
visible: boolean; visible: boolean
/** z-index 层级 */ /** 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 */ /** 答错时回调,可返回自定义解析文案覆盖默认 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 { export interface MultipleChoiceQuizPanelProps {
/** 是否可见 */ /** 是否可见 */
visible: boolean; visible: boolean
/** z-index 层级 */ /** 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 { export interface MatchQuizItem {
/** 唯一标识 */ /** 唯一标识 */
id: string; id: string
/** 显示文本 */ /** 显示文本 */
label: string; label: string
} }
/** 连线题匹配 */ /** 连线题匹配 */
export interface MatchQuizMatch { export interface MatchQuizMatch {
/** 左列项ID */ /** 左列项ID */
leftId: string; leftId: string
/** 右列项ID */ /** 右列项ID */
rightId: string; rightId: string
} }
/** 连线题面板配置 */ /** 连线题面板配置 */
export interface MatchQuizPanelProps { export interface MatchQuizPanelProps {
/** 是否可见 */ /** 是否可见 */
visible: boolean; visible: boolean
/** z-index 层级 */ /** 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 { export interface SelectFillBlank {
/** 唯一标识,用于和答案、结果关联 */ /** 唯一标识,用于和答案、结果关联 */
id: string; id: string
/** 空前面的文本(可选) */ /** 空前面的文本(可选) */
prefix?: string; prefix?: string
/** 空后面的文本(可选) */ /** 空后面的文本(可选) */
suffix?: string; suffix?: string
} }
/** 选择填空题的答题结果 */ /** 选择填空题的答题结果 */
export interface SelectFillResult { export interface SelectFillResult {
blankId: string; blankId: string
value: string; value: string
} }
/** 选择填空题面板配置 */ /** 选择填空题面板配置 */
export interface SelectFillQuizPanelProps { export interface SelectFillQuizPanelProps {
/** 是否可见 */ /** 是否可见 */
visible: boolean; visible: boolean
/** z-index 层级 */ /** z-index 层级 */
zIndex?: number; zIndex?: number
/** 题目前缀整体文本(可选) */ /** 题目前缀整体文本(可选) */
titlePrefix?: string; titlePrefix?: string
/** 题目后缀整体文本(可选) */ /** 题目后缀整体文本(可选) */
titleSuffix?: string; titleSuffix?: string
/** 填空位配置(控制有几个空、空前后的说明文本) */ /** 填空位配置(控制有几个空、空前后的说明文本) */
blanks: SelectFillBlank[]; blanks: SelectFillBlank[]
/** 可选项列表(如 0-9 的数字、若干短语等) */ /** 可选项列表(如 0-9 的数字、若干短语等) */
options: { value: string; label: string }[]; options: { value: string; label: string }[]
/** /**
* 正确答案映射 * 正确答案映射
* key 为 blankId,value 为该空对应的正确值 * key 为 blankId,value 为该空对应的正确值
*/ */
correctAnswers?: Record<string, string>; correctAnswers?: Record<string, string>
/** 自定义错误提示信息(答案错误时显示) */ /** 自定义错误提示信息(答案错误时显示) */
errorMessage?: string; errorMessage?: string
/** /**
* 提交回调,返回用户选择结果与是否全部正确 * 提交回调,返回用户选择结果与是否全部正确
* 注意:如果未提供 correctAnswers,则 isCorrect 恒为 true * 注意:如果未提供 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 替代 */ /** @deprecated 使用 SingleChoiceQuizPanelProps 替代 */
export interface QuizPanelProps { export interface QuizPanelProps {
visible: boolean; visible: boolean
zIndex?: number; zIndex?: number
title: string; title: string
options: SingleChoiceOption[]; options: SingleChoiceOption[]
value: string; value: string
error?: string; error?: string
onChange?: (value: string) => void; onChange?: (value: string) => void
} }
+94 -124
View File
@@ -1,69 +1,65 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { getStaticImage } from "@/lib/utils"; import { getStaticImage } from '@/lib/utils'
import { Image } from "@/components"; import { Image } from '@/components'
export interface LetterToChenyunProps { export interface LetterToChenyunProps {
/** 是否可见 */ /** 是否可见 */
visible?: boolean; visible?: boolean
/** 关闭回调 */ /** 关闭回调 */
onClose?: () => void; onClose?: () => void
/** 提交回调 */ /** 提交回调 */
onSubmit?: (values: string[]) => void; onSubmit?: (values: string[]) => void
} }
/** /**
* LetterToChenyun 组件 - 寄给陈云爷爷的信 * LetterToChenyun 组件 - 寄给陈云爷爷的信
* 包含飞入和放大动画 * 包含飞入和放大动画
*/ */
const LetterToChenyun = ({ const LetterToChenyun = ({ visible = false, onClose, onSubmit }: LetterToChenyunProps) => {
visible = false,
onClose,
onSubmit,
}: LetterToChenyunProps) => {
// 下划线可填写内容 // 下划线可填写内容
const [blankValues, setBlankValues] = useState<string[]>([]); const [blankValues, setBlankValues] = useState<string[]>([])
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字) // 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
const [blankErrors, setBlankErrors] = useState<boolean[]>([]); const [blankErrors, setBlankErrors] = useState<boolean[]>([])
// 是否已提交:提交后隐藏编辑信件,展示只读信件 // 是否已提交:提交后隐藏编辑信件,展示只读信件
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false)
// 校验输入:不能为空、每个不超过 10 个字符 // 校验输入:不能为空、每个不超过 10 个字符
const validateBlanks = () => { const validateBlanks = () => {
// 计算占位总数,防止有些没被输入过而是 undefined // 计算占位总数,防止有些没被输入过而是 undefined
const placeholderCount = (letterContent.match(/____/g) || []).length; const placeholderCount = (letterContent.match(/____/g) || []).length
const nextErrors: boolean[] = Array(placeholderCount).fill(false); const nextErrors: boolean[] = Array(placeholderCount).fill(false)
const nextValues: string[] = [...blankValues]; const nextValues: string[] = [...blankValues]
let hasError = false; let hasError = false
for (let i = 0; i < placeholderCount; i++) { for (let i = 0; i < placeholderCount; i++) {
const raw = blankValues[i] || ""; const raw = blankValues[i] || ''
const v = raw.trim(); const v = raw.trim()
// 为空 或 超过 10 个字,都视为不符合要求:清空并标红 // 为空 或 超过 10 个字,都视为不符合要求:清空并标红
if (!v || v.length > 10) { if (!v || v.length > 10) {
nextValues[i] = ""; nextValues[i] = ''
nextErrors[i] = true; nextErrors[i] = true
hasError = true; hasError = true
} }
} }
setBlankValues(nextValues); setBlankValues(nextValues)
setBlankErrors(nextErrors); setBlankErrors(nextErrors)
return !hasError; return !hasError
}; }
// 弹窗关闭后重置内容,方便下次重新填写 // 弹窗关闭后重置内容,方便下次重新填写
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
setSubmitted(false); setSubmitted(false)
setBlankValues([]); setBlankValues([])
setBlankErrors([]); setBlankErrors([])
} }
}, [visible]); }, [visible])
// 信件内容模板(根据设计稿) // 信件内容模板(根据设计稿)
const letterContent = `敬爱的陈云爷爷: const letterContent = `敬爱的陈云爷爷:
@@ -73,7 +69,7 @@ const LetterToChenyun = ({
作为新时代学生,我会在专业学习中____,像您当年练珠算、搞调查那样精益求精;未来无论从事什么工作,都要把服务群众、____放在首位;面对困难时不退缩,像您当年稳定物价、推进实业那样,用____本领(结合所学专业)为国家发展贡献自己的力量! 作为新时代学生,我会在专业学习中____,像您当年练珠算、搞调查那样精益求精;未来无论从事什么工作,都要把服务群众、____放在首位;面对困难时不退缩,像您当年稳定物价、推进实业那样,用____本领(结合所学专业)为国家发展贡献自己的力量!
站在新时代的起点,我一定以____为志向,勤学苦练,传承精神,让您的务实担当在青春里扎根,让奋斗在建设祖国的实践中绽放光彩!`; 站在新时代的起点,我一定以____为志向,勤学苦练,传承精神,让您的务实担当在青春里扎根,让奋斗在建设祖国的实践中绽放光彩!`
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -96,7 +92,7 @@ const LetterToChenyun = ({
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.5, y: 100 }} exit={{ opacity: 0, scale: 0.5, y: 100 }}
transition={{ transition={{
type: "spring", type: 'spring',
stiffness: 300, stiffness: 300,
damping: 30, damping: 30,
duration: 0.6, duration: 0.6,
@@ -105,28 +101,28 @@ const LetterToChenyun = ({
<div <div
className="relative w-[1200px] h-full pointer-events-auto" className="relative w-[1200px] h-full pointer-events-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ perspective: "1200px" }} style={{ perspective: '1200px' }}
> >
{/* 编辑态 / 只读态:Y 轴翻转切换 */} {/* 编辑态 / 只读态:Y 轴翻转切换 */}
<motion.div <motion.div
className="relative w-full h-full" className="relative w-full h-full"
initial={false} initial={false}
animate={{ rotateY: submitted ? 180 : 0 }} animate={{ rotateY: submitted ? 180 : 0 }}
transition={{ duration: 0.7, ease: "easeInOut" }} transition={{ duration: 0.7, ease: 'easeInOut' }}
style={{ transformStyle: "preserve-3d" }} style={{ transformStyle: 'preserve-3d' }}
> >
{/* 前面:编辑态 */} {/* 前面:编辑态 */}
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
backfaceVisibility: "hidden", backfaceVisibility: 'hidden',
// 编辑态展示时才响应事件,避免与背面点击冲突 // 编辑态展示时才响应事件,避免与背面点击冲突
pointerEvents: submitted ? "none" : "auto", pointerEvents: submitted ? 'none' : 'auto',
}} }}
> >
{/* 编辑态:信纸背景 */} {/* 编辑态:信纸背景 */}
<img <img
src={getStaticImage("components/letter/信底")} src={getStaticImage('components/letter/信底')}
alt="信纸" alt="信纸"
className="absolute inset-0 w-full h-full object-contain" className="absolute inset-0 w-full h-full object-contain"
/> />
@@ -136,8 +132,8 @@ const LetterToChenyun = ({
<div <div
className="text-white text-6xl font-bold tracking-[20px]" className="text-white text-6xl font-bold tracking-[20px]"
style={{ style={{
writingMode: "vertical-rl", writingMode: 'vertical-rl',
textOrientation: "upright", textOrientation: 'upright',
}} }}
> >
寄给陈云的信 寄给陈云的信
@@ -148,75 +144,62 @@ const LetterToChenyun = ({
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99"> <div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
<div className="text-[#4e402e] text-[26px] leading-relaxed"> <div className="text-[#4e402e] text-[26px] leading-relaxed">
{(() => { {(() => {
let placeholderIndex = 0; let placeholderIndex = 0
let foundNiHao = false; let foundNiHao = false
return letterContent return letterContent.split('\n').map((line, lineIndex) => {
.split("\n")
.map((line, lineIndex) => {
// 检测是否已经找到"您好"行 // 检测是否已经找到"您好"行
if (line.includes("您好")) { if (line.includes('您好')) {
foundNiHao = true; foundNiHao = true
} }
// "您好"行之后的非空行需要首行缩进 // "您好"行之后的非空行需要首行缩进
const needsIndent = const needsIndent =
foundNiHao && foundNiHao && line.trim() !== '' && !line.includes('您好')
line.trim() !== "" &&
!line.includes("您好");
const segments = line.split("____"); const segments = line.split('____')
return ( return (
<div <div
key={lineIndex} key={lineIndex}
className="mb-6" className="mb-6"
style={needsIndent ? { textIndent: "2em" } : {}} style={needsIndent ? { textIndent: '2em' } : {}}
> >
{segments.map((part, partIndex) => { {segments.map((part, partIndex) => {
const isLast = const isLast = partIndex === segments.length - 1
partIndex === segments.length - 1;
if (isLast) { if (isLast) {
return ( return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
} }
const currentIndex = placeholderIndex++; const currentIndex = placeholderIndex++
return ( return (
<span <span key={`part-${lineIndex}-${partIndex}`}>
key={`part-${lineIndex}-${partIndex}`}
>
{part} {part}
<input <input
name={`blank-${currentIndex}`} name={`blank-${currentIndex}`}
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${ className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
blankErrors[currentIndex] blankErrors[currentIndex]
? "border-red-500" ? 'border-red-500'
: "border-[#4e402e]" : 'border-[#4e402e]'
}`} }`}
value={blankValues[currentIndex] ?? ""} value={blankValues[currentIndex] ?? ''}
onChange={(e) => { onChange={(e) => {
const next = [...blankValues]; const next = [...blankValues]
next[currentIndex] = e.target.value; next[currentIndex] = e.target.value
setBlankValues(next); setBlankValues(next)
// 用户再次输入时,清除该空的错误高亮 // 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) { if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]; const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false; nextErrs[currentIndex] = false
setBlankErrors(nextErrs); setBlankErrors(nextErrs)
} }
}} }}
/> />
</span> </span>
); )
})} })}
</div> </div>
); )
}); })
})()} })()}
</div> </div>
</div> </div>
@@ -224,7 +207,7 @@ const LetterToChenyun = ({
{/* 笔的图片 */} {/* 笔的图片 */}
<div className="absolute bottom-[150px] -right-30"> <div className="absolute bottom-[150px] -right-30">
<img <img
src={getStaticImage("components/letter/笔")} src={getStaticImage('components/letter/笔')}
alt="笔" alt="笔"
className="w-full h-full object-contain" className="w-full h-full object-contain"
/> />
@@ -233,11 +216,11 @@ const LetterToChenyun = ({
{/* 提交按钮:切换到只读信件态 */} {/* 提交按钮:切换到只读信件态 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99"> <div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image <Image
src={getStaticImage("components/letter/可提交状态")} src={getStaticImage('components/letter/可提交状态')}
alt="可提交状态" alt="可提交状态"
onClick={() => { onClick={() => {
if (!validateBlanks()) return; if (!validateBlanks()) return
setSubmitted(true); setSubmitted(true)
}} }}
button button
/> />
@@ -248,25 +231,25 @@ const LetterToChenyun = ({
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
transform: "rotateY(180deg)", transform: 'rotateY(180deg)',
backfaceVisibility: "hidden", backfaceVisibility: 'hidden',
// 只读态展示时才响应事件 // 只读态展示时才响应事件
pointerEvents: submitted ? "auto" : "none", pointerEvents: submitted ? 'auto' : 'none',
}} }}
> >
{/* 只读态:新背景 */} {/* 只读态:新背景 */}
<img <img
src={getStaticImage("components/letter/信")} src={getStaticImage('components/letter/信')}
alt="信件" alt="信件"
className="absolute inset-0 w-full h-full object-contain" className="absolute inset-0 w-full h-full object-contain"
/> />
<Image <Image
src={getStaticImage("components/close")} src={getStaticImage('components/close')}
alt="信纸" alt="信纸"
className="absolute right-38 top-0" className="absolute right-38 top-0"
button button
onClick={() => { onClick={() => {
setSubmitted(false); setSubmitted(false)
}} }}
/> />
@@ -274,55 +257,42 @@ const LetterToChenyun = ({
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99"> <div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
<div className="text-[#4e402e] text-[26px] leading-relaxed"> <div className="text-[#4e402e] text-[26px] leading-relaxed">
{(() => { {(() => {
let placeholderIndex = 0; let placeholderIndex = 0
let foundNiHao = false; let foundNiHao = false
return letterContent return letterContent.split('\n').map((line, lineIndex) => {
.split("\n") if (line.includes('您好')) {
.map((line, lineIndex) => { foundNiHao = true
if (line.includes("您好")) {
foundNiHao = true;
} }
const needsIndent = const needsIndent =
foundNiHao && foundNiHao && line.trim() !== '' && !line.includes('您好')
line.trim() !== "" &&
!line.includes("您好");
const segments = line.split("____"); const segments = line.split('____')
return ( return (
<div <div
key={lineIndex} key={lineIndex}
className="mb-6" className="mb-6"
style={needsIndent ? { textIndent: "2em" } : {}} style={needsIndent ? { textIndent: '2em' } : {}}
> >
{segments.map((part, partIndex) => { {segments.map((part, partIndex) => {
const isLast = const isLast = partIndex === segments.length - 1
partIndex === segments.length - 1;
if (isLast) { if (isLast) {
return ( return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
} }
const currentIndex = placeholderIndex++; const currentIndex = placeholderIndex++
const value = blankValues[currentIndex] ?? ""; const value = blankValues[currentIndex] ?? ''
return ( return (
<span <span key={`part-${lineIndex}-${partIndex}`}>
key={`part-${lineIndex}-${partIndex}`}
>
{part} {part}
{value || "  "} {value || '  '}
</span> </span>
); )
})} })}
</div> </div>
); )
}); })
})()} })()}
</div> </div>
</div> </div>
@@ -330,10 +300,10 @@ const LetterToChenyun = ({
{/* 寄出按钮:关闭窗口 */} {/* 寄出按钮:关闭窗口 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99"> <div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image <Image
src={getStaticImage("components/letter/寄出")} src={getStaticImage('components/letter/寄出')}
alt="寄出" alt="寄出"
onClick={() => { onClick={() => {
onSubmit?.(blankValues); onSubmit?.(blankValues)
}} }}
button button
/> />
@@ -345,7 +315,7 @@ const LetterToChenyun = ({
</> </>
)} )}
</AnimatePresence> </AnimatePresence>
); )
}; }
export default LetterToChenyun; export default LetterToChenyun
+120 -164
View File
@@ -1,108 +1,104 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { getStaticImage } from "@/lib/utils"; import { getStaticImage } from '@/lib/utils'
import { Image, Select } from "@/components"; import { Image, Select } from '@/components'
import { filterInput } from "@/lib/utils/sensitiveWords"; import { filterInput } from '@/lib/utils/sensitiveWords'
export interface LetterToHuangyanpeiProps { export interface LetterToHuangyanpeiProps {
/** 是否可见 */ /** 是否可见 */
visible?: boolean; visible?: boolean
/** 关闭回调 */ /** 关闭回调 */
onClose?: () => void; onClose?: () => void
/** 提交回调 */ /** 提交回调 */
onSubmit?: (values: string[]) => void; onSubmit?: (values: string[]) => void
} }
/** /**
* LetterToHuangyanpei 组件 - 写给黄炎培先生的信 * LetterToHuangyanpei 组件 - 写给黄炎培先生的信
* 包含飞入和放大动画 * 包含飞入和放大动画
*/ */
const LetterToHuangyanpei = ({ const LetterToHuangyanpei = ({ visible = false, onClose, onSubmit }: LetterToHuangyanpeiProps) => {
visible = false,
onClose,
onSubmit,
}: LetterToHuangyanpeiProps) => {
// 下划线可填写内容 // 下划线可填写内容
const [blankValues, setBlankValues] = useState<string[]>([]); const [blankValues, setBlankValues] = useState<string[]>([])
// 每个空的校验状态:true 表示该空当前不符合要求 // 每个空的校验状态:true 表示该空当前不符合要求
const [blankErrors, setBlankErrors] = useState<boolean[]>([]); const [blankErrors, setBlankErrors] = useState<boolean[]>([])
// 是否已提交:提交后隐藏编辑信件,展示只读信件 // 是否已提交:提交后隐藏编辑信件,展示只读信件
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false)
// 填空类型定义:0=填空1(限填),1=填空2(任填),2=填空3(任填),3=填空4(限填) // 填空类型定义:0=填空1(限填),1=填空2(任填),2=填空3(任填),3=填空4(限填)
const BLANK_1_ANSWER = "手脑并用"; const BLANK_1_ANSWER = '手脑并用'
const BLANK_4_ANSWER = "有业者乐业"; const BLANK_4_ANSWER = '有业者乐业'
// 填空1的选项 // 填空1的选项
const BLANK_1_OPTIONS = [ const BLANK_1_OPTIONS = [
"手脑并用", '手脑并用',
"有业者乐业", '有业者乐业',
// "知行合一", // "知行合一",
// "学以致用", // "学以致用",
// "理论实践" // "理论实践"
]; ]
// 填空4的选项 // 填空4的选项
const BLANK_4_OPTIONS = [ const BLANK_4_OPTIONS = [
"手脑并用", '手脑并用',
"有业者乐业", '有业者乐业',
// "有业者敬业", // "有业者敬业",
// "有业者精业", // "有业者精业",
// "有业者成业", // "有业者成业",
]; ]
// 校验输入:确保所有填空符合标准 // 校验输入:确保所有填空符合标准
const validateBlanks = () => { const validateBlanks = () => {
// 计算占位总数,防止有些没被输入过而是 undefined // 计算占位总数,防止有些没被输入过而是 undefined
const placeholderCount = (letterContent.match(/____/g) || []).length; const placeholderCount = (letterContent.match(/____/g) || []).length
const nextErrors: boolean[] = Array(placeholderCount).fill(false); const nextErrors: boolean[] = Array(placeholderCount).fill(false)
const nextValues: string[] = [...blankValues]; const nextValues: string[] = [...blankValues]
let hasError = false; let hasError = false
for (let i = 0; i < placeholderCount; i++) { for (let i = 0; i < placeholderCount; i++) {
const raw = nextValues[i] || ""; const raw = nextValues[i] || ''
const v = raw.trim(); const v = raw.trim()
if (i === 0) { if (i === 0) {
// 填空1:限填,必须选择"手脑并用" // 填空1:限填,必须选择"手脑并用"
if (!v || v !== BLANK_1_ANSWER) { if (!v || v !== BLANK_1_ANSWER) {
nextValues[i] = ""; nextValues[i] = ''
nextErrors[i] = true; nextErrors[i] = true
hasError = true; hasError = true
} }
} else if (i === 1 || i === 2) { } else if (i === 1 || i === 2) {
// 填空2、3:任填,不能为空,不超过20个字 // 填空2、3:任填,不能为空,不超过20个字
if (!v || v.length > 20) { if (!v || v.length > 20) {
nextValues[i] = ""; nextValues[i] = ''
nextErrors[i] = true; nextErrors[i] = true
hasError = true; hasError = true
} }
} else if (i === 3) { } else if (i === 3) {
// 填空4:限填,必须选择"有业者乐业" // 填空4:限填,必须选择"有业者乐业"
if (!v || v !== BLANK_4_ANSWER) { if (!v || v !== BLANK_4_ANSWER) {
nextValues[i] = ""; nextValues[i] = ''
nextErrors[i] = true; nextErrors[i] = true
hasError = true; hasError = true
} }
} }
} }
setBlankValues(nextValues); setBlankValues(nextValues)
setBlankErrors(nextErrors); setBlankErrors(nextErrors)
return !hasError; return !hasError
}; }
// 弹窗关闭后重置内容,方便下次重新填写 // 弹窗关闭后重置内容,方便下次重新填写
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
setSubmitted(false); setSubmitted(false)
setBlankValues([]); setBlankValues([])
setBlankErrors([]); setBlankErrors([])
} }
}, [visible]); }, [visible])
// 信件内容模板(根据设计稿) // 信件内容模板(根据设计稿)
const letterContent = `敬爱的黄炎培先生: const letterContent = `敬爱的黄炎培先生:
@@ -115,7 +111,7 @@ const LetterToHuangyanpei = ({
我是____专业的学生,今后,我会在实训课堂上精益求精,像当年中华职校的学长学姐那样,把技能练到极致;我会在志愿服务中践行担当,用所学知识为身边人、为社会做实事;我更会紧跟时代步伐,努力成为____领域的技能人才,为中国制造升级、为民族产业振兴添砖加瓦! 我是____专业的学生,今后,我会在实训课堂上精益求精,像当年中华职校的学长学姐那样,把技能练到极致;我会在志愿服务中践行担当,用所学知识为身边人、为社会做实事;我更会紧跟时代步伐,努力成为____领域的技能人才,为中国制造升级、为民族产业振兴添砖加瓦!
请您放心,您播下的职教火种,我们会让它在新时代燃烧得更旺;您毕生追求的"无业者有业、____"的愿景,我们会用青春和汗水去实现!`; 请您放心,您播下的职教火种,我们会让它在新时代燃烧得更旺;您毕生追求的"无业者有业、____"的愿景,我们会用青春和汗水去实现!`
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -138,7 +134,7 @@ const LetterToHuangyanpei = ({
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.5, y: 100 }} exit={{ opacity: 0, scale: 0.5, y: 100 }}
transition={{ transition={{
type: "spring", type: 'spring',
stiffness: 300, stiffness: 300,
damping: 30, damping: 30,
duration: 0.6, duration: 0.6,
@@ -147,28 +143,28 @@ const LetterToHuangyanpei = ({
<div <div
className="relative w-[1200px] h-full pointer-events-auto" className="relative w-[1200px] h-full pointer-events-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ perspective: "1200px" }} style={{ perspective: '1200px' }}
> >
{/* 编辑态 / 只读态:Y 轴翻转切换 */} {/* 编辑态 / 只读态:Y 轴翻转切换 */}
<motion.div <motion.div
className="relative w-full h-full" className="relative w-full h-full"
initial={false} initial={false}
animate={{ rotateY: submitted ? 180 : 0 }} animate={{ rotateY: submitted ? 180 : 0 }}
transition={{ duration: 0.7, ease: "easeInOut" }} transition={{ duration: 0.7, ease: 'easeInOut' }}
style={{ transformStyle: "preserve-3d" }} style={{ transformStyle: 'preserve-3d' }}
> >
{/* 前面:编辑态 */} {/* 前面:编辑态 */}
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
backfaceVisibility: "hidden", backfaceVisibility: 'hidden',
// 编辑态展示时才响应事件,避免与背面点击冲突 // 编辑态展示时才响应事件,避免与背面点击冲突
pointerEvents: submitted ? "none" : "auto", pointerEvents: submitted ? 'none' : 'auto',
}} }}
> >
{/* 编辑态:信纸背景 */} {/* 编辑态:信纸背景 */}
<img <img
src={getStaticImage("components/letter/信底")} src={getStaticImage('components/letter/信底')}
alt="信纸" alt="信纸"
className="absolute inset-0 w-full h-full object-contain" className="absolute inset-0 w-full h-full object-contain"
/> />
@@ -178,8 +174,8 @@ const LetterToHuangyanpei = ({
<div <div
className="text-white text-6xl font-bold tracking-[20px]" className="text-white text-6xl font-bold tracking-[20px]"
style={{ style={{
writingMode: "vertical-rl", writingMode: 'vertical-rl',
textOrientation: "upright", textOrientation: 'upright',
}} }}
> >
写给黄炎培先生的信 写给黄炎培先生的信
@@ -190,53 +186,34 @@ const LetterToHuangyanpei = ({
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99"> <div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
<div className="text-[#4e402e] text-[26px] leading-relaxed"> <div className="text-[#4e402e] text-[26px] leading-relaxed">
{(() => { {(() => {
let placeholderIndex = 0; let placeholderIndex = 0
let foundNiHao = false; let foundNiHao = false
return letterContent return letterContent.split('\n').map((line, lineIndex) => {
.split("\n")
.map((line, lineIndex) => {
// 检测是否已经找到"您好"行 // 检测是否已经找到"您好"行
if (line.includes("您好")) { if (line.includes('您好')) {
foundNiHao = true; foundNiHao = true
} }
// "您好"行之后的非空行需要首行缩进 // "您好"行之后的非空行需要首行缩进
const needsIndent = const needsIndent =
foundNiHao && foundNiHao && line.trim() !== '' && !line.includes('您好')
line.trim() !== "" &&
!line.includes("您好");
const segments = line.split("____"); const segments = line.split('____')
return ( return (
<div <div key={lineIndex} style={needsIndent ? { textIndent: '2em' } : {}}>
key={lineIndex}
style={needsIndent ? { textIndent: "2em" } : {}}
>
{segments.map((part, partIndex) => { {segments.map((part, partIndex) => {
const isLast = const isLast = partIndex === segments.length - 1
partIndex === segments.length - 1;
if (isLast) { if (isLast) {
return ( return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
} }
const currentIndex = placeholderIndex++; const currentIndex = placeholderIndex++
const isLimitedFill = const isLimitedFill = currentIndex === 0 || currentIndex === 3 // 填空1和填空4为限填
currentIndex === 0 || currentIndex === 3; // 填空1和填空4为限填
const options = const options =
currentIndex === 0 currentIndex === 0 ? BLANK_1_OPTIONS : BLANK_4_OPTIONS
? BLANK_1_OPTIONS
: BLANK_4_OPTIONS;
return ( return (
<span <span key={`part-${lineIndex}-${partIndex}`}>
key={`part-${lineIndex}-${partIndex}`}
>
{part} {part}
{isLimitedFill ? ( {isLimitedFill ? (
// 限填:下拉选择 // 限填:下拉选择
@@ -246,19 +223,17 @@ const LetterToHuangyanpei = ({
value: opt, value: opt,
label: opt, label: opt,
}))} }))}
value={ value={blankValues[currentIndex] ?? ''}
blankValues[currentIndex] ?? ""
}
onChange={(selectedValue) => { onChange={(selectedValue) => {
const next = [...blankValues]; const next = [...blankValues]
next[currentIndex] = selectedValue; next[currentIndex] = selectedValue
setBlankValues(next); setBlankValues(next)
// 用户再次选择时,清除该空的错误高亮 // 用户再次选择时,清除该空的错误高亮
if (blankErrors[currentIndex]) { if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]; const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false; nextErrs[currentIndex] = false
setBlankErrors(nextErrs); setBlankErrors(nextErrs)
} }
}} }}
placeholder="请选择" placeholder="请选择"
@@ -272,39 +247,36 @@ const LetterToHuangyanpei = ({
name={`blank-${currentIndex}`} name={`blank-${currentIndex}`}
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${ className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
blankErrors[currentIndex] blankErrors[currentIndex]
? "border-red-500" ? 'border-red-500'
: "border-[#4e402e]" : 'border-[#4e402e]'
}`} }`}
autoComplete="off" autoComplete="off"
value={ value={blankValues[currentIndex] ?? ''}
blankValues[currentIndex] ?? ""
}
maxLength={20} maxLength={20}
onChange={(e) => { onChange={(e) => {
const rawValue = e.target.value; const rawValue = e.target.value
// 应用敏感词过滤 // 应用敏感词过滤
const filteredValue = const filteredValue = filterInput(rawValue)
filterInput(rawValue);
const next = [...blankValues]; const next = [...blankValues]
next[currentIndex] = filteredValue; next[currentIndex] = filteredValue
setBlankValues(next); setBlankValues(next)
// 用户再次输入时,清除该空的错误高亮 // 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) { if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]; const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false; nextErrs[currentIndex] = false
setBlankErrors(nextErrs); setBlankErrors(nextErrs)
} }
}} }}
/> />
)} )}
</span> </span>
); )
})} })}
</div> </div>
); )
}); })
})()} })()}
</div> </div>
</div> </div>
@@ -312,7 +284,7 @@ const LetterToHuangyanpei = ({
{/* 笔的图片 */} {/* 笔的图片 */}
<div className="absolute bottom-[150px] -right-30"> <div className="absolute bottom-[150px] -right-30">
<img <img
src={getStaticImage("components/letter/笔")} src={getStaticImage('components/letter/笔')}
alt="笔" alt="笔"
className="w-full h-full object-contain" className="w-full h-full object-contain"
/> />
@@ -321,11 +293,11 @@ const LetterToHuangyanpei = ({
{/* 提交按钮:切换到只读信件态 */} {/* 提交按钮:切换到只读信件态 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99"> <div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image <Image
src={getStaticImage("components/letter/可提交状态")} src={getStaticImage('components/letter/可提交状态')}
alt="可提交状态" alt="可提交状态"
onClick={() => { onClick={() => {
if (!validateBlanks()) return; if (!validateBlanks()) return
setSubmitted(true); setSubmitted(true)
}} }}
button button
/> />
@@ -336,25 +308,25 @@ const LetterToHuangyanpei = ({
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
transform: "rotateY(180deg)", transform: 'rotateY(180deg)',
backfaceVisibility: "hidden", backfaceVisibility: 'hidden',
// 只读态展示时才响应事件 // 只读态展示时才响应事件
pointerEvents: submitted ? "auto" : "none", pointerEvents: submitted ? 'auto' : 'none',
}} }}
> >
{/* 只读态:新背景 */} {/* 只读态:新背景 */}
<img <img
src={getStaticImage("components/letter/信")} src={getStaticImage('components/letter/信')}
alt="信件" alt="信件"
className="absolute inset-0 w-full h-full object-contain" className="absolute inset-0 w-full h-full object-contain"
/> />
<Image <Image
src={getStaticImage("components/close")} src={getStaticImage('components/close')}
alt="信纸" alt="信纸"
className="absolute right-38 top-0" className="absolute right-38 top-0"
button button
onClick={() => { onClick={() => {
setSubmitted(false); setSubmitted(false)
}} }}
/> />
@@ -362,54 +334,38 @@ const LetterToHuangyanpei = ({
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99"> <div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
<div className="text-[#4e402e] text-[26px] leading-relaxed"> <div className="text-[#4e402e] text-[26px] leading-relaxed">
{(() => { {(() => {
let placeholderIndex = 0; let placeholderIndex = 0
let foundNiHao = false; let foundNiHao = false
return letterContent return letterContent.split('\n').map((line, lineIndex) => {
.split("\n") if (line.includes('您好')) {
.map((line, lineIndex) => { foundNiHao = true
if (line.includes("您好")) {
foundNiHao = true;
} }
const needsIndent = const needsIndent =
foundNiHao && foundNiHao && line.trim() !== '' && !line.includes('您好')
line.trim() !== "" &&
!line.includes("您好");
const segments = line.split("____"); const segments = line.split('____')
return ( return (
<div <div key={lineIndex} style={needsIndent ? { textIndent: '2em' } : {}}>
key={lineIndex}
style={needsIndent ? { textIndent: "2em" } : {}}
>
{segments.map((part, partIndex) => { {segments.map((part, partIndex) => {
const isLast = const isLast = partIndex === segments.length - 1
partIndex === segments.length - 1;
if (isLast) { if (isLast) {
return ( return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
} }
const currentIndex = placeholderIndex++; const currentIndex = placeholderIndex++
const value = blankValues[currentIndex] ?? ""; const value = blankValues[currentIndex] ?? ''
return ( return (
<span <span key={`part-${lineIndex}-${partIndex}`}>
key={`part-${lineIndex}-${partIndex}`}
>
{part} {part}
{value || "  "} {value || '  '}
</span> </span>
); )
})} })}
</div> </div>
); )
}); })
})()} })()}
</div> </div>
</div> </div>
@@ -417,12 +373,12 @@ const LetterToHuangyanpei = ({
{/* 寄出按钮:关闭窗口 */} {/* 寄出按钮:关闭窗口 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99"> <div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image <Image
src={getStaticImage("components/letter/寄出")} src={getStaticImage('components/letter/寄出')}
alt="寄出" alt="寄出"
onClick={() => { onClick={() => {
// 寄出前再次校验,确保所有填空符合标准 // 寄出前再次校验,确保所有填空符合标准
if (validateBlanks()) { if (validateBlanks()) {
onSubmit?.(blankValues); onSubmit?.(blankValues)
} }
}} }}
button button
@@ -435,7 +391,7 @@ const LetterToHuangyanpei = ({
</> </>
)} )}
</AnimatePresence> </AnimatePresence>
); )
}; }
export default LetterToHuangyanpei; export default LetterToHuangyanpei
+117 -154
View File
@@ -1,103 +1,99 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { getStaticImage } from "@/lib/utils"; import { getStaticImage } from '@/lib/utils'
import { Image, Select } from "@/components"; import { Image, Select } from '@/components'
export interface LetterToLuxunProps { export interface LetterToLuxunProps {
/** 是否可见 */ /** 是否可见 */
visible?: boolean; visible?: boolean
/** 关闭回调 */ /** 关闭回调 */
onClose?: () => void; onClose?: () => void
/** 提交回调 */ /** 提交回调 */
onSubmit?: (values: string[]) => void; onSubmit?: (values: string[]) => void
} }
/** /**
* LetterToLuxun 组件 - 写给鲁迅先生的信 * LetterToLuxun 组件 - 写给鲁迅先生的信
* 包含飞入和放大动画 * 包含飞入和放大动画
*/ */
const LetterToLuxun = ({ const LetterToLuxun = ({ visible = false, onClose, onSubmit }: LetterToLuxunProps) => {
visible = false,
onClose,
onSubmit,
}: LetterToLuxunProps) => {
// 下划线可填写内容 // 下划线可填写内容
const [blankValues, setBlankValues] = useState<string[]>([]); const [blankValues, setBlankValues] = useState<string[]>([])
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字) // 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
const [blankErrors, setBlankErrors] = useState<boolean[]>([]); const [blankErrors, setBlankErrors] = useState<boolean[]>([])
// 是否已提交:提交后隐藏编辑信件,展示只读信件 // 是否已提交:提交后隐藏编辑信件,展示只读信件
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false)
// 填空类型定义:0=填空1(限填),1=填空2(限填),其他=任填 // 填空类型定义:0=填空1(限填),1=填空2(限填),其他=任填
const BLANK_1_ANSWER = "首在立人"; const BLANK_1_ANSWER = '首在立人'
const BLANK_2_ANSWER = "批判求真"; const BLANK_2_ANSWER = '批判求真'
// 填空1的选项 // 填空1的选项
const BLANK_1_OPTIONS = [ const BLANK_1_OPTIONS = [
"首在立人", '首在立人',
"批判求真", '批判求真',
// 可以添加其他选项 // 可以添加其他选项
]; ]
// 填空2的选项 // 填空2的选项
const BLANK_2_OPTIONS = [ const BLANK_2_OPTIONS = [
"首在立人", '首在立人',
"批判求真", '批判求真',
// 可以添加其他选项 // 可以添加其他选项
]; ]
// 校验输入:确保所有填空符合标准 // 校验输入:确保所有填空符合标准
const validateBlanks = () => { const validateBlanks = () => {
// 计算占位总数,防止有些没被输入过而是 undefined // 计算占位总数,防止有些没被输入过而是 undefined
const placeholderCount = (letterContent.match(/____/g) || []).length; const placeholderCount = (letterContent.match(/____/g) || []).length
const nextErrors: boolean[] = Array(placeholderCount).fill(false); const nextErrors: boolean[] = Array(placeholderCount).fill(false)
const nextValues: string[] = [...blankValues]; const nextValues: string[] = [...blankValues]
let hasError = false; let hasError = false
for (let i = 0; i < placeholderCount; i++) { for (let i = 0; i < placeholderCount; i++) {
const raw = nextValues[i] || ""; const raw = nextValues[i] || ''
const v = raw.trim(); const v = raw.trim()
if (i === 0) { if (i === 0) {
// 填空1:限填,必须选择"首在立人" // 填空1:限填,必须选择"首在立人"
if (!v || v !== BLANK_1_ANSWER) { if (!v || v !== BLANK_1_ANSWER) {
nextValues[i] = ""; nextValues[i] = ''
nextErrors[i] = true; nextErrors[i] = true
hasError = true; hasError = true
} }
} else if (i === 1) { } else if (i === 1) {
// 填空2:限填,必须选择"批判求真" // 填空2:限填,必须选择"批判求真"
if (!v || v !== BLANK_2_ANSWER) { if (!v || v !== BLANK_2_ANSWER) {
nextValues[i] = ""; nextValues[i] = ''
nextErrors[i] = true; nextErrors[i] = true
hasError = true; hasError = true
} }
} else { } else {
// 其他填空:任填,不能为空,不超过 10 个字 // 其他填空:任填,不能为空,不超过 10 个字
if (!v || v.length > 10) { if (!v || v.length > 10) {
nextValues[i] = ""; nextValues[i] = ''
nextErrors[i] = true; nextErrors[i] = true
hasError = true; hasError = true
} }
} }
} }
setBlankValues(nextValues); setBlankValues(nextValues)
setBlankErrors(nextErrors); setBlankErrors(nextErrors)
return !hasError; return !hasError
}; }
// 弹窗关闭后重置内容,方便下次重新填写 // 弹窗关闭后重置内容,方便下次重新填写
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
setSubmitted(false); setSubmitted(false)
setBlankValues([]); setBlankValues([])
setBlankErrors([]); setBlankErrors([])
} }
}, [visible]); }, [visible])
// 信件内容模板(根据设计稿) // 信件内容模板(根据设计稿)
const letterContent = `敬爱的鲁迅先生: const letterContent = `敬爱的鲁迅先生:
@@ -108,7 +104,7 @@ const LetterToLuxun = ({
我是____专业的学生,今后,我会以您"____千夫指"的骨气,在是非面前坚守立场,不随波逐流;我会以您"俯首甘为____"的热忱,参与____活动,用微光温暖他人;我更会记住您"____,走的人多了,便成了路"的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。 我是____专业的学生,今后,我会以您"____千夫指"的骨气,在是非面前坚守立场,不随波逐流;我会以您"俯首甘为____"的热忱,参与____活动,用微光温暖他人;我更会记住您"____,走的人多了,便成了路"的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
请您放心,您播下的"立人"火种,我们会让它在新时代燎原;您毕生追求的"人国"愿景,我们会用青春和奋斗去守护!`; 请您放心,您播下的"立人"火种,我们会让它在新时代燎原;您毕生追求的"人国"愿景,我们会用青春和奋斗去守护!`
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -131,7 +127,7 @@ const LetterToLuxun = ({
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.5, y: 100 }} exit={{ opacity: 0, scale: 0.5, y: 100 }}
transition={{ transition={{
type: "spring", type: 'spring',
stiffness: 300, stiffness: 300,
damping: 30, damping: 30,
duration: 0.6, duration: 0.6,
@@ -140,28 +136,28 @@ const LetterToLuxun = ({
<div <div
className="relative w-[1200px] h-full pointer-events-auto" className="relative w-[1200px] h-full pointer-events-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ perspective: "1200px" }} style={{ perspective: '1200px' }}
> >
{/* 编辑态 / 只读态:Y 轴翻转切换 */} {/* 编辑态 / 只读态:Y 轴翻转切换 */}
<motion.div <motion.div
className="relative w-full h-full" className="relative w-full h-full"
initial={false} initial={false}
animate={{ rotateY: submitted ? 180 : 0 }} animate={{ rotateY: submitted ? 180 : 0 }}
transition={{ duration: 0.7, ease: "easeInOut" }} transition={{ duration: 0.7, ease: 'easeInOut' }}
style={{ transformStyle: "preserve-3d" }} style={{ transformStyle: 'preserve-3d' }}
> >
{/* 前面:编辑态 */} {/* 前面:编辑态 */}
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
backfaceVisibility: "hidden", backfaceVisibility: 'hidden',
// 编辑态展示时才响应事件,避免与背面点击冲突 // 编辑态展示时才响应事件,避免与背面点击冲突
pointerEvents: submitted ? "none" : "auto", pointerEvents: submitted ? 'none' : 'auto',
}} }}
> >
{/* 编辑态:信纸背景 */} {/* 编辑态:信纸背景 */}
<img <img
src={getStaticImage("components/letter/信底")} src={getStaticImage('components/letter/信底')}
alt="信纸" alt="信纸"
className="absolute inset-0 w-full h-full object-contain" className="absolute inset-0 w-full h-full object-contain"
/> />
@@ -171,8 +167,8 @@ const LetterToLuxun = ({
<div <div
className="text-white text-6xl font-bold tracking-[20px]" className="text-white text-6xl font-bold tracking-[20px]"
style={{ style={{
writingMode: "vertical-rl", writingMode: 'vertical-rl',
textOrientation: "upright", textOrientation: 'upright',
}} }}
> >
写给鲁迅先生的信 写给鲁迅先生的信
@@ -183,54 +179,38 @@ const LetterToLuxun = ({
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99"> <div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
<div className="text-[#4e402e] text-[26px] leading-relaxed"> <div className="text-[#4e402e] text-[26px] leading-relaxed">
{(() => { {(() => {
let placeholderIndex = 0; let placeholderIndex = 0
let foundNiHao = false; let foundNiHao = false
return letterContent return letterContent.split('\n').map((line, lineIndex) => {
.split("\n")
.map((line, lineIndex) => {
// 检测是否已经找到"您好"行 // 检测是否已经找到"您好"行
if (line.includes("您好")) { if (line.includes('您好')) {
foundNiHao = true; foundNiHao = true
} }
// "您好"行之后的非空行需要首行缩进 // "您好"行之后的非空行需要首行缩进
const needsIndent = const needsIndent =
foundNiHao && foundNiHao && line.trim() !== '' && !line.includes('您好')
line.trim() !== "" &&
!line.includes("您好");
const segments = line.split("____"); const segments = line.split('____')
return ( return (
<div <div
key={lineIndex} key={lineIndex}
className="mb-2" className="mb-2"
style={needsIndent ? { textIndent: "2em" } : {}} style={needsIndent ? { textIndent: '2em' } : {}}
> >
{segments.map((part, partIndex) => { {segments.map((part, partIndex) => {
const isLast = const isLast = partIndex === segments.length - 1
partIndex === segments.length - 1;
if (isLast) { if (isLast) {
return ( return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
} }
const currentIndex = placeholderIndex++; const currentIndex = placeholderIndex++
const isLimitedFill = const isLimitedFill = currentIndex === 0 || currentIndex === 1 // 填空1和填空2为限填
currentIndex === 0 || currentIndex === 1; // 填空1和填空2为限填
const options = const options =
currentIndex === 0 currentIndex === 0 ? BLANK_1_OPTIONS : BLANK_2_OPTIONS
? BLANK_1_OPTIONS
: BLANK_2_OPTIONS;
return ( return (
<span <span key={`part-${lineIndex}-${partIndex}`}>
key={`part-${lineIndex}-${partIndex}`}
>
{part} {part}
{isLimitedFill ? ( {isLimitedFill ? (
// 限填:下拉选择 // 限填:下拉选择
@@ -240,19 +220,17 @@ const LetterToLuxun = ({
value: opt, value: opt,
label: opt, label: opt,
}))} }))}
value={ value={blankValues[currentIndex] ?? ''}
blankValues[currentIndex] ?? ""
}
onChange={(selectedValue) => { onChange={(selectedValue) => {
const next = [...blankValues]; const next = [...blankValues]
next[currentIndex] = selectedValue; next[currentIndex] = selectedValue
setBlankValues(next); setBlankValues(next)
// 用户再次选择时,清除该空的错误高亮 // 用户再次选择时,清除该空的错误高亮
if (blankErrors[currentIndex]) { if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]; const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false; nextErrs[currentIndex] = false
setBlankErrors(nextErrs); setBlankErrors(nextErrs)
} }
}} }}
placeholder="请选择" placeholder="请选择"
@@ -266,32 +244,30 @@ const LetterToLuxun = ({
name={`blank-${currentIndex}`} name={`blank-${currentIndex}`}
className={`inline-block w-[100px] h-[36px] mx-2 mb-2 align-middle bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${ className={`inline-block w-[100px] h-[36px] mx-2 mb-2 align-middle bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
blankErrors[currentIndex] blankErrors[currentIndex]
? "border-red-500" ? 'border-red-500'
: "border-[#4e402e]" : 'border-[#4e402e]'
}`} }`}
value={ value={blankValues[currentIndex] ?? ''}
blankValues[currentIndex] ?? ""
}
onChange={(e) => { onChange={(e) => {
const next = [...blankValues]; const next = [...blankValues]
next[currentIndex] = e.target.value; next[currentIndex] = e.target.value
setBlankValues(next); setBlankValues(next)
// 用户再次输入时,清除该空的错误高亮 // 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) { if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]; const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false; nextErrs[currentIndex] = false
setBlankErrors(nextErrs); setBlankErrors(nextErrs)
} }
}} }}
/> />
)} )}
</span> </span>
); )
})} })}
</div> </div>
); )
}); })
})()} })()}
</div> </div>
</div> </div>
@@ -299,7 +275,7 @@ const LetterToLuxun = ({
{/* 笔的图片 */} {/* 笔的图片 */}
<div className="absolute bottom-[150px] -right-30"> <div className="absolute bottom-[150px] -right-30">
<img <img
src={getStaticImage("components/letter/笔")} src={getStaticImage('components/letter/笔')}
alt="笔" alt="笔"
className="w-full h-full object-contain" className="w-full h-full object-contain"
/> />
@@ -308,11 +284,11 @@ const LetterToLuxun = ({
{/* 提交按钮:切换到只读信件态 */} {/* 提交按钮:切换到只读信件态 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99"> <div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image <Image
src={getStaticImage("components/letter/可提交状态")} src={getStaticImage('components/letter/可提交状态')}
alt="可提交状态" alt="可提交状态"
onClick={() => { onClick={() => {
if (!validateBlanks()) return; if (!validateBlanks()) return
setSubmitted(true); setSubmitted(true)
}} }}
button button
/> />
@@ -332,25 +308,25 @@ const LetterToLuxun = ({
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
transform: "rotateY(180deg)", transform: 'rotateY(180deg)',
backfaceVisibility: "hidden", backfaceVisibility: 'hidden',
// 只读态展示时才响应事件 // 只读态展示时才响应事件
pointerEvents: submitted ? "auto" : "none", pointerEvents: submitted ? 'auto' : 'none',
}} }}
> >
{/* 只读态:新背景 */} {/* 只读态:新背景 */}
<img <img
src={getStaticImage("components/letter/信")} src={getStaticImage('components/letter/信')}
alt="信件" alt="信件"
className="absolute inset-0 w-full h-full object-contain" className="absolute inset-0 w-full h-full object-contain"
/> />
<Image <Image
src={getStaticImage("components/close")} src={getStaticImage('components/close')}
alt="信纸" alt="信纸"
className="absolute right-38 top-0" className="absolute right-38 top-0"
button button
onClick={() => { onClick={() => {
setSubmitted(false); setSubmitted(false)
}} }}
/> />
@@ -358,55 +334,42 @@ const LetterToLuxun = ({
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99"> <div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
<div className="text-[#4e402e] text-[26px] leading-relaxed"> <div className="text-[#4e402e] text-[26px] leading-relaxed">
{(() => { {(() => {
let placeholderIndex = 0; let placeholderIndex = 0
let foundNiHao = false; let foundNiHao = false
return letterContent return letterContent.split('\n').map((line, lineIndex) => {
.split("\n") if (line.includes('您好')) {
.map((line, lineIndex) => { foundNiHao = true
if (line.includes("您好")) {
foundNiHao = true;
} }
const needsIndent = const needsIndent =
foundNiHao && foundNiHao && line.trim() !== '' && !line.includes('您好')
line.trim() !== "" &&
!line.includes("您好");
const segments = line.split("____"); const segments = line.split('____')
return ( return (
<div <div
key={lineIndex} key={lineIndex}
className="mb-2" className="mb-2"
style={needsIndent ? { textIndent: "2em" } : {}} style={needsIndent ? { textIndent: '2em' } : {}}
> >
{segments.map((part, partIndex) => { {segments.map((part, partIndex) => {
const isLast = const isLast = partIndex === segments.length - 1
partIndex === segments.length - 1;
if (isLast) { if (isLast) {
return ( return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
} }
const currentIndex = placeholderIndex++; const currentIndex = placeholderIndex++
const value = blankValues[currentIndex] ?? ""; const value = blankValues[currentIndex] ?? ''
return ( return (
<span <span key={`part-${lineIndex}-${partIndex}`}>
key={`part-${lineIndex}-${partIndex}`}
>
{part} {part}
{value || "  "} {value || '  '}
</span> </span>
); )
})} })}
</div> </div>
); )
}); })
})()} })()}
</div> </div>
</div> </div>
@@ -414,10 +377,10 @@ const LetterToLuxun = ({
{/* 寄出按钮:关闭窗口 */} {/* 寄出按钮:关闭窗口 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99"> <div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image <Image
src={getStaticImage("components/letter/寄出")} src={getStaticImage('components/letter/寄出')}
alt="寄出" alt="寄出"
onClick={() => { onClick={() => {
onSubmit?.(blankValues); onSubmit?.(blankValues)
}} }}
button button
/> />
@@ -429,7 +392,7 @@ const LetterToLuxun = ({
</> </>
)} )}
</AnimatePresence> </AnimatePresence>
); )
}; }
export default LetterToLuxun; export default LetterToLuxun
+120 -150
View File
@@ -1,16 +1,16 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { getStaticImage } from "@/lib/utils"; import { getStaticImage } from '@/lib/utils'
import { Image, Select } from "@/components"; import { Image, Select } from '@/components'
import { ErrorAlert } from "@/components/layout"; import { ErrorAlert } from '@/components/layout'
export interface LetterToSunzhongshanProps { 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, onSubmit,
}: LetterToSunzhongshanProps) => { }: LetterToSunzhongshanProps) => {
// 下划线可填写内容 // 下划线可填写内容
const [blankValues, setBlankValues] = useState<string[]>([]); const [blankValues, setBlankValues] = useState<string[]>([])
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字) // 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
const [blankErrors, setBlankErrors] = useState<boolean[]>([]); const [blankErrors, setBlankErrors] = useState<boolean[]>([])
// 是否已提交:提交后隐藏编辑信件,展示只读信件 // 是否已提交:提交后隐藏编辑信件,展示只读信件
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false)
// ErrorAlert 相关状态 // ErrorAlert 相关状态
const [showErrorAlert, setShowErrorAlert] = useState(false); const [showErrorAlert, setShowErrorAlert] = useState(false)
const [errorAlertText, setErrorAlertText] = useState(""); const [errorAlertText, setErrorAlertText] = useState('')
// 前三个空的正确答案 // 前三个空的正确答案
const BLANK_0_ANSWER = "振兴中华"; const BLANK_0_ANSWER = '振兴中华'
const BLANK_1_ANSWER = "天下为公"; const BLANK_1_ANSWER = '天下为公'
const BLANK_2_ANSWER = "与时俱进"; const BLANK_2_ANSWER = '与时俱进'
// 最后一个空的正确答案 // 最后一个空的正确答案
const LAST_BLANK_ANSWER = "仍需努力"; const LAST_BLANK_ANSWER = '仍需努力'
// 前三个空的选项(所有空都用这三个选项) // 前三个空的选项(所有空都用这三个选项)
const SELECT_OPTIONS = [ const SELECT_OPTIONS = [
{ value: "振兴中华", label: "振兴中华" }, { value: '振兴中华', label: '振兴中华' },
{ value: "天下为公", label: "天下为公" }, { value: '天下为公', label: '天下为公' },
{ value: "与时俱进", label: "与时俱进" }, { value: '与时俱进', label: '与时俱进' },
]; ]
// 校验输入:前三个空必须选择正确答案,最后一个空必须是"仍需努力",其他空不能为空且不超过 10 个字符 // 校验输入:前三个空必须选择正确答案,最后一个空必须是"仍需努力",其他空不能为空且不超过 10 个字符
const validateBlanks = () => { const validateBlanks = () => {
// 计算占位总数,防止有些没被输入过而是 undefined // 计算占位总数,防止有些没被输入过而是 undefined
const placeholderCount = (letterContent.match(/____/g) || []).length; const placeholderCount = (letterContent.match(/____/g) || []).length
const nextErrors: boolean[] = Array(placeholderCount).fill(false); const nextErrors: boolean[] = Array(placeholderCount).fill(false)
const nextValues: string[] = [...blankValues]; const nextValues: string[] = [...blankValues]
let hasError = false; let hasError = false
for (let i = 0; i < placeholderCount; i++) { for (let i = 0; i < placeholderCount; i++) {
const raw = blankValues[i] || ""; const raw = blankValues[i] || ''
const v = raw.trim(); const v = raw.trim()
if (i < 3) { if (i < 3) {
// 前三个空:必须选择正确答案(已在 onChange 中验证,这里只检查是否为空) // 前三个空:必须选择正确答案(已在 onChange 中验证,这里只检查是否为空)
if (!v) { if (!v) {
nextValues[i] = ""; nextValues[i] = ''
nextErrors[i] = true; nextErrors[i] = true
hasError = true; hasError = true
} }
} else if (i === placeholderCount - 1) { } else if (i === placeholderCount - 1) {
// 最后一个空:必须是"仍需努力" // 最后一个空:必须是"仍需努力"
if (!v || v !== LAST_BLANK_ANSWER) { if (!v || v !== LAST_BLANK_ANSWER) {
nextValues[i] = ""; nextValues[i] = ''
nextErrors[i] = true; nextErrors[i] = true
hasError = true; hasError = true
} }
} else { } else {
// 其他空:不能为空,不超过 10 个字 // 其他空:不能为空,不超过 10 个字
if (!v || v.length > 10) { if (!v || v.length > 10) {
nextValues[i] = ""; nextValues[i] = ''
nextErrors[i] = true; nextErrors[i] = true
hasError = true; hasError = true
} }
} }
} }
setBlankValues(nextValues); setBlankValues(nextValues)
setBlankErrors(nextErrors); setBlankErrors(nextErrors)
return !hasError; return !hasError
}; }
// 弹窗关闭后重置内容,方便下次重新填写 // 弹窗关闭后重置内容,方便下次重新填写
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
setSubmitted(false); setSubmitted(false)
setBlankValues([]); setBlankValues([])
setBlankErrors([]); setBlankErrors([])
setShowErrorAlert(false); setShowErrorAlert(false)
setErrorAlertText(""); setErrorAlertText('')
} }
}, [visible]); }, [visible])
// 信件内容模板(根据设计稿) // 信件内容模板(根据设计稿)
const letterContent = `敬爱的孙中山先生: const letterContent = `敬爱的孙中山先生:
@@ -111,7 +111,7 @@ const LetterToSunzhongshan = ({
我是____专业的学生,今后,我会以您“知难行易”的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您“心系民众”的情怀,参与____,帮身边人解决实际困难;我更会记住您“适乎世界之潮流”的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。 我是____专业的学生,今后,我会以您“知难行易”的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您“心系民众”的情怀,参与____,帮身边人解决实际困难;我更会记住您“适乎世界之潮流”的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。
请您放心,“革命尚未成功,同志____”的嘱托,我们从未忘记;“振兴中华”的初心,我们会用一生践行!`; 请您放心,“革命尚未成功,同志____”的嘱托,我们从未忘记;“振兴中华”的初心,我们会用一生践行!`
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -134,7 +134,7 @@ const LetterToSunzhongshan = ({
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.5, y: 100 }} exit={{ opacity: 0, scale: 0.5, y: 100 }}
transition={{ transition={{
type: "spring", type: 'spring',
stiffness: 300, stiffness: 300,
damping: 30, damping: 30,
duration: 0.6, duration: 0.6,
@@ -143,28 +143,28 @@ const LetterToSunzhongshan = ({
<div <div
className="relative w-[1200px] h-full pointer-events-auto" className="relative w-[1200px] h-full pointer-events-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ perspective: "1200px" }} style={{ perspective: '1200px' }}
> >
{/* 编辑态 / 只读态:Y 轴翻转切换 */} {/* 编辑态 / 只读态:Y 轴翻转切换 */}
<motion.div <motion.div
className="relative w-full h-full" className="relative w-full h-full"
initial={false} initial={false}
animate={{ rotateY: submitted ? 180 : 0 }} animate={{ rotateY: submitted ? 180 : 0 }}
transition={{ duration: 0.7, ease: "easeInOut" }} transition={{ duration: 0.7, ease: 'easeInOut' }}
style={{ transformStyle: "preserve-3d" }} style={{ transformStyle: 'preserve-3d' }}
> >
{/* 前面:编辑态 */} {/* 前面:编辑态 */}
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
backfaceVisibility: "hidden", backfaceVisibility: 'hidden',
// 编辑态展示时才响应事件,避免与背面点击冲突 // 编辑态展示时才响应事件,避免与背面点击冲突
pointerEvents: submitted ? "none" : "auto", pointerEvents: submitted ? 'none' : 'auto',
}} }}
> >
{/* 编辑态:信纸背景 */} {/* 编辑态:信纸背景 */}
<img <img
src={getStaticImage("components/letter/信底")} src={getStaticImage('components/letter/信底')}
alt="信纸" alt="信纸"
className="absolute inset-0 w-full h-full object-contain" className="absolute inset-0 w-full h-full object-contain"
/> />
@@ -174,8 +174,8 @@ const LetterToSunzhongshan = ({
<div <div
className="text-white text-6xl font-bold tracking-[20px]" className="text-white text-6xl font-bold tracking-[20px]"
style={{ style={{
writingMode: "vertical-rl", writingMode: 'vertical-rl',
textOrientation: "upright", textOrientation: 'upright',
}} }}
> >
写给孙中山先生的信 写给孙中山先生的信
@@ -186,68 +186,53 @@ const LetterToSunzhongshan = ({
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99"> <div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
<div className="text-[#4e402e] text-[26px] leading-relaxed"> <div className="text-[#4e402e] text-[26px] leading-relaxed">
{(() => { {(() => {
let placeholderIndex = 0; let placeholderIndex = 0
let foundNiHao = false; let foundNiHao = false
return letterContent return letterContent.split('\n').map((line, lineIndex) => {
.split("\n")
.map((line, lineIndex) => {
// 检测是否已经找到"您好"行 // 检测是否已经找到"您好"行
if (line.includes("您好")) { if (line.includes('您好')) {
foundNiHao = true; foundNiHao = true
} }
// "您好"行之后的非空行需要首行缩进 // "您好"行之后的非空行需要首行缩进
const needsIndent = const needsIndent =
foundNiHao && foundNiHao && line.trim() !== '' && !line.includes('您好')
line.trim() !== "" &&
!line.includes("您好");
const segments = line.split("____"); const segments = line.split('____')
return ( return (
<div <div
key={lineIndex} key={lineIndex}
className="mb-2" className="mb-2"
style={needsIndent ? { textIndent: "2em" } : {}} style={needsIndent ? { textIndent: '2em' } : {}}
> >
{segments.map((part, partIndex) => { {segments.map((part, partIndex) => {
const isLast = const isLast = partIndex === segments.length - 1
partIndex === segments.length - 1;
if (isLast) { if (isLast) {
return ( return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
} }
const currentIndex = placeholderIndex++; const currentIndex = placeholderIndex++
const isSelectFill = currentIndex < 3; // 前三个空使用 Select const isSelectFill = currentIndex < 3 // 前三个空使用 Select
return ( return (
<span <span key={`part-${lineIndex}-${partIndex}`}>
key={`part-${lineIndex}-${partIndex}`}
>
{part} {part}
{isSelectFill ? ( {isSelectFill ? (
// 前三个空:下拉选择 // 前三个空:下拉选择
<Select <Select
className="inline" className="inline"
options={SELECT_OPTIONS} options={SELECT_OPTIONS}
value={ value={blankValues[currentIndex] ?? ''}
blankValues[currentIndex] ?? ""
}
onChange={(selectedValue) => { onChange={(selectedValue) => {
const next = [...blankValues]; const next = [...blankValues]
next[currentIndex] = selectedValue; next[currentIndex] = selectedValue
setBlankValues(next); setBlankValues(next)
// 清除错误状态 // 清除错误状态
if (blankErrors[currentIndex]) { if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]; const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false; nextErrs[currentIndex] = false
setBlankErrors(nextErrs); setBlankErrors(nextErrs)
} }
// 立即验证选择是否正确 // 立即验证选择是否正确
@@ -288,32 +273,30 @@ const LetterToSunzhongshan = ({
name={`blank-${currentIndex}`} name={`blank-${currentIndex}`}
className={`inline-block w-[110px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${ className={`inline-block w-[110px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
blankErrors[currentIndex] blankErrors[currentIndex]
? "border-red-500" ? 'border-red-500'
: "border-[#4e402e]" : 'border-[#4e402e]'
}`} }`}
value={ value={blankValues[currentIndex] ?? ''}
blankValues[currentIndex] ?? ""
}
onChange={(e) => { onChange={(e) => {
const next = [...blankValues]; const next = [...blankValues]
next[currentIndex] = e.target.value; next[currentIndex] = e.target.value
setBlankValues(next); setBlankValues(next)
// 用户再次输入时,清除该空的错误高亮 // 用户再次输入时,清除该空的错误高亮
if (blankErrors[currentIndex]) { if (blankErrors[currentIndex]) {
const nextErrs = [...blankErrors]; const nextErrs = [...blankErrors]
nextErrs[currentIndex] = false; nextErrs[currentIndex] = false
setBlankErrors(nextErrs); setBlankErrors(nextErrs)
} }
}} }}
/> />
)} )}
</span> </span>
); )
})} })}
</div> </div>
); )
}); })
})()} })()}
</div> </div>
</div> </div>
@@ -321,7 +304,7 @@ const LetterToSunzhongshan = ({
{/* 笔的图片 */} {/* 笔的图片 */}
<div className="absolute bottom-[150px] -right-30"> <div className="absolute bottom-[150px] -right-30">
<img <img
src={getStaticImage("components/letter/笔")} src={getStaticImage('components/letter/笔')}
alt="笔" alt="笔"
className="w-full h-full object-contain" className="w-full h-full object-contain"
/> />
@@ -330,11 +313,11 @@ const LetterToSunzhongshan = ({
{/* 提交按钮:切换到只读信件态 */} {/* 提交按钮:切换到只读信件态 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99"> <div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image <Image
src={getStaticImage("components/letter/可提交状态")} src={getStaticImage('components/letter/可提交状态')}
alt="可提交状态" alt="可提交状态"
onClick={() => { onClick={() => {
if (!validateBlanks()) return; if (!validateBlanks()) return
setSubmitted(true); setSubmitted(true)
}} }}
button button
/> />
@@ -354,25 +337,25 @@ const LetterToSunzhongshan = ({
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
transform: "rotateY(180deg)", transform: 'rotateY(180deg)',
backfaceVisibility: "hidden", backfaceVisibility: 'hidden',
// 只读态展示时才响应事件 // 只读态展示时才响应事件
pointerEvents: submitted ? "auto" : "none", pointerEvents: submitted ? 'auto' : 'none',
}} }}
> >
{/* 只读态:新背景 */} {/* 只读态:新背景 */}
<img <img
src={getStaticImage("components/letter/信")} src={getStaticImage('components/letter/信')}
alt="信件" alt="信件"
className="absolute inset-0 w-full h-full object-contain" className="absolute inset-0 w-full h-full object-contain"
/> />
<Image <Image
src={getStaticImage("components/close")} src={getStaticImage('components/close')}
alt="信纸" alt="信纸"
className="absolute right-38 top-0" className="absolute right-38 top-0"
button button
onClick={() => { onClick={() => {
setSubmitted(false); setSubmitted(false)
}} }}
/> />
@@ -380,55 +363,42 @@ const LetterToSunzhongshan = ({
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99"> <div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
<div className="text-[#4e402e] text-[26px] leading-relaxed"> <div className="text-[#4e402e] text-[26px] leading-relaxed">
{(() => { {(() => {
let placeholderIndex = 0; let placeholderIndex = 0
let foundNiHao = false; let foundNiHao = false
return letterContent return letterContent.split('\n').map((line, lineIndex) => {
.split("\n") if (line.includes('您好')) {
.map((line, lineIndex) => { foundNiHao = true
if (line.includes("您好")) {
foundNiHao = true;
} }
const needsIndent = const needsIndent =
foundNiHao && foundNiHao && line.trim() !== '' && !line.includes('您好')
line.trim() !== "" &&
!line.includes("您好");
const segments = line.split("____"); const segments = line.split('____')
return ( return (
<div <div
key={lineIndex} key={lineIndex}
className="mb-2" className="mb-2"
style={needsIndent ? { textIndent: "2em" } : {}} style={needsIndent ? { textIndent: '2em' } : {}}
> >
{segments.map((part, partIndex) => { {segments.map((part, partIndex) => {
const isLast = const isLast = partIndex === segments.length - 1
partIndex === segments.length - 1;
if (isLast) { if (isLast) {
return ( return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
<span
key={`part-${lineIndex}-${partIndex}`}
>
{part}
</span>
);
} }
const currentIndex = placeholderIndex++; const currentIndex = placeholderIndex++
const value = blankValues[currentIndex] ?? ""; const value = blankValues[currentIndex] ?? ''
return ( return (
<span <span key={`part-${lineIndex}-${partIndex}`}>
key={`part-${lineIndex}-${partIndex}`}
>
{part} {part}
{value || "  "} {value || '  '}
</span> </span>
); )
})} })}
</div> </div>
); )
}); })
})()} })()}
</div> </div>
</div> </div>
@@ -436,10 +406,10 @@ const LetterToSunzhongshan = ({
{/* 寄出按钮:关闭窗口 */} {/* 寄出按钮:关闭窗口 */}
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99"> <div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<Image <Image
src={getStaticImage("components/letter/寄出")} src={getStaticImage('components/letter/寄出')}
alt="寄出" alt="寄出"
onClick={() => { onClick={() => {
onSubmit?.(blankValues); onSubmit?.(blankValues)
}} }}
button button
/> />
@@ -460,7 +430,7 @@ const LetterToSunzhongshan = ({
</> </>
)} )}
</AnimatePresence> </AnimatePresence>
); )
}; }
export default LetterToSunzhongshan; export default LetterToSunzhongshan
+8 -8
View File
@@ -1,11 +1,11 @@
export { default as LetterToChenyun } from "./LetterToChenyun"; export { default as LetterToChenyun } from './LetterToChenyun'
export type { LetterToChenyunProps } from "./LetterToChenyun"; export type { LetterToChenyunProps } from './LetterToChenyun'
export { default as LetterToSunzhongshan } from "./LetterToSunzhongshan"; export { default as LetterToSunzhongshan } from './LetterToSunzhongshan'
export type { LetterToSunzhongshanProps } from "./LetterToSunzhongshan"; export type { LetterToSunzhongshanProps } from './LetterToSunzhongshan'
export { default as LetterToHuangyanpei } from "./LetterToHuangyanpei"; export { default as LetterToHuangyanpei } from './LetterToHuangyanpei'
export type { LetterToHuangyanpeiProps } from "./LetterToHuangyanpei"; export type { LetterToHuangyanpeiProps } from './LetterToHuangyanpei'
export { default as LetterToLuxun } from "./LetterToLuxun"; export { default as LetterToLuxun } from './LetterToLuxun'
export type { LetterToLuxunProps } from "./LetterToLuxun"; export type { LetterToLuxunProps } from './LetterToLuxun'
+56 -56
View File
@@ -1,26 +1,26 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
import { useAudioManager } from "@/hooks/useAudioManager"; import { useAudioManager } from '@/hooks/useAudioManager'
export interface NarrationProps { export interface NarrationProps {
/** 旁白文本 */ /** 旁白文本 */
text: string; text: string
/** 是否可见 */ /** 是否可见 */
visible: boolean; visible: boolean
/** 关闭回调 */ /** 关闭回调 */
onClose: () => void; onClose: () => void
/** 语音播报的音频文件路径(可选) */ /** 语音播报的音频文件路径(可选) */
audioSrc?: string; audioSrc?: string
/** 自定义样式类名 */ /** 自定义样式类名 */
className?: string; className?: string
/** 是否启用逐字动画,默认 true */ /** 是否启用逐字动画,默认 true */
enableAnimation?: boolean; enableAnimation?: boolean
/** 文字动画速度(毫秒/字),默认 100ms */ /** 文字动画速度(毫秒/字),默认 100ms */
animationSpeed?: number; animationSpeed?: number
/** 覆盖层出现时是否自动播放音频(默认 true) */ /** 覆盖层出现时是否自动播放音频(默认 true) */
autoPlayAudio?: boolean; autoPlayAudio?: boolean
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */ /** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string; audioChannelId?: string
} }
/** /**
@@ -42,95 +42,95 @@ const Narration = ({
autoPlayAudio = true, autoPlayAudio = true,
audioChannelId, audioChannelId,
}: NarrationProps) => { }: NarrationProps) => {
const [displayedText, setDisplayedText] = useState(""); const [displayedText, setDisplayedText] = useState('')
const [isAnimating, setIsAnimating] = useState(true); const [isAnimating, setIsAnimating] = useState(true)
const [isSkipped, setIsSkipped] = useState(false); const [isSkipped, setIsSkipped] = useState(false)
// 旁白音频:默认自动播放,支持按 channel 分组互斥 // 旁白音频:默认自动播放,支持按 channel 分组互斥
const { currentSrc, play, stop } = useAudioManager( const { currentSrc, play, stop } = useAudioManager(
audioChannelId audioChannelId
? { ? {
mode: "channel", mode: 'channel',
channelId: audioChannelId, channelId: audioChannelId,
} }
: { mode: "global" } : { mode: 'global' }
); )
// 文字动画效果 // 文字动画效果
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
setDisplayedText(""); setDisplayedText('')
setIsAnimating(true); setIsAnimating(true)
setIsSkipped(false); setIsSkipped(false)
// 旁白关闭时,如果当前正在播放该旁白音频,则停止 // 旁白关闭时,如果当前正在播放该旁白音频,则停止
if (audioSrc && currentSrc === audioSrc) { if (audioSrc && currentSrc === audioSrc) {
stop(); stop()
} }
return; return
} }
// 如果已经跳过,不再执行动画 // 如果已经跳过,不再执行动画
if (isSkipped) { if (isSkipped) {
return; return
} }
// 如果不启用动画,直接显示全部文本 // 如果不启用动画,直接显示全部文本
if (!enableAnimation) { if (!enableAnimation) {
setDisplayedText(text); setDisplayedText(text)
setIsAnimating(false); setIsAnimating(false)
return; return
} }
let currentIndex = 0; let currentIndex = 0
setDisplayedText(""); setDisplayedText('')
setIsAnimating(true); setIsAnimating(true)
const timer = setInterval(() => { const timer = setInterval(() => {
if (currentIndex < text.length) { if (currentIndex < text.length) {
setDisplayedText(text.slice(0, currentIndex + 1)); setDisplayedText(text.slice(0, currentIndex + 1))
currentIndex++; currentIndex++
} else { } else {
setIsAnimating(false); setIsAnimating(false)
clearInterval(timer); clearInterval(timer)
} }
}, animationSpeed); }, animationSpeed)
return () => clearInterval(timer); return () => clearInterval(timer)
}, [text, visible, enableAnimation, animationSpeed, isSkipped, audioSrc, currentSrc, stop]); }, [text, visible, enableAnimation, animationSpeed, isSkipped, audioSrc, currentSrc, stop])
// 跳过动画 // 跳过动画
const handleSkip = () => { const handleSkip = () => {
setDisplayedText(text); setDisplayedText(text)
setIsAnimating(false); setIsAnimating(false)
setIsSkipped(true); setIsSkipped(true)
}; }
// 旁白出现时自动播放音频 // 旁白出现时自动播放音频
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return
if (!autoPlayAudio) return; if (!autoPlayAudio) return
if (!audioSrc) return; if (!audioSrc) return
play(audioSrc); play(audioSrc)
}, [visible, autoPlayAudio, audioSrc, play]); }, [visible, autoPlayAudio, audioSrc, play])
// 停止音频 // 停止音频
const handleClose = () => { const handleClose = () => {
if (audioSrc && currentSrc === audioSrc) { if (audioSrc && currentSrc === audioSrc) {
stop(); stop()
}
onClose()
} }
onClose();
};
if (!visible) return null; if (!visible) return null
return ( return (
<div className="fixed inset-0 flex items-center justify-center bg-black/80 z-50"> <div className="fixed inset-0 flex items-center justify-center bg-black/80 z-50">
<div <div
className={cn( className={cn(
"w-[1600px] bg-black/90 rounded-[32px] px-20 py-16", 'w-[1600px] bg-black/90 rounded-[32px] px-20 py-16',
"shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]", 'shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]',
"relative", 'relative',
className className
)} )}
> >
@@ -161,7 +161,7 @@ const Narration = ({
)} )}
</div> </div>
</div> </div>
); )
}; }
export default Narration; export default Narration
+2 -2
View File
@@ -1,2 +1,2 @@
export { default as Narration } from "./Narration"; export { default as Narration } from './Narration'
export type { NarrationProps } from "./Narration"; export type { NarrationProps } from './Narration'
+49 -58
View File
@@ -1,22 +1,22 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { getStaticImage } from "@/lib/utils"; import { getStaticImage } from '@/lib/utils'
import { useAudioManager } from "@/hooks/useAudioManager"; import { useAudioManager } from '@/hooks/useAudioManager'
export interface EndingOverlayProps { export interface EndingOverlayProps {
/** 是否可见 */ /** 是否可见 */
visible: boolean; visible: boolean
/** 中间显示的文本内容 */ /** 中间显示的文本内容 */
text: string; text: string
/** 点击继续回调 */ /** 点击继续回调 */
onContinue: () => void; onContinue: () => void
/** 文字动画速度(毫秒/字),默认 60ms */ /** 文字动画速度(毫秒/字),默认 60ms */
animationSpeed?: number; animationSpeed?: number
/** 结语对应的音频地址(可选) */ /** 结语对应的音频地址(可选) */
audioSrc?: string; audioSrc?: string
/** 覆盖层出现时是否自动播放音频(默认 true) */ /** 覆盖层出现时是否自动播放音频(默认 true) */
autoPlayAudio?: boolean; autoPlayAudio?: boolean
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */ /** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string; audioChannelId?: string
} }
/** /**
@@ -32,62 +32,62 @@ export const EndingOverlay = ({
autoPlayAudio = true, autoPlayAudio = true,
audioChannelId, audioChannelId,
}: EndingOverlayProps) => { }: EndingOverlayProps) => {
const [displayedText, setDisplayedText] = useState(""); const [displayedText, setDisplayedText] = useState('')
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false)
// 结语音频:默认自动播放,支持按 channel 分组互斥 // 结语音频:默认自动播放,支持按 channel 分组互斥
const { currentSrc, isPlaying, play, stop } = useAudioManager( const { currentSrc, isPlaying, play, stop } = useAudioManager(
audioChannelId audioChannelId
? { ? {
mode: "channel", mode: 'channel',
channelId: audioChannelId, channelId: audioChannelId,
} }
: { mode: "global" } : { mode: 'global' }
); )
// 文字逐字动画 // 文字逐字动画
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
setDisplayedText(""); setDisplayedText('')
setIsAnimating(false); setIsAnimating(false)
// 结语关闭时,如果当前正在播放该结语音频,则停止 // 结语关闭时,如果当前正在播放该结语音频,则停止
if (audioSrc && currentSrc === audioSrc) { if (audioSrc && currentSrc === audioSrc) {
stop(); stop()
} }
return; return
} }
let currentIndex = 0; let currentIndex = 0
setDisplayedText(""); setDisplayedText('')
setIsAnimating(true); setIsAnimating(true)
const timer = setInterval(() => { const timer = setInterval(() => {
if (currentIndex < text.length) { if (currentIndex < text.length) {
setDisplayedText(text.slice(0, currentIndex + 1)); setDisplayedText(text.slice(0, currentIndex + 1))
currentIndex++; currentIndex++
} else { } else {
setIsAnimating(false); setIsAnimating(false)
clearInterval(timer); clearInterval(timer)
} }
}, animationSpeed); }, animationSpeed)
return () => clearInterval(timer); return () => clearInterval(timer)
}, [visible, text, animationSpeed, audioSrc, currentSrc, stop]); }, [visible, text, animationSpeed, audioSrc, currentSrc, stop])
// 覆盖层显示时自动播放音频 // 覆盖层显示时自动播放音频
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return
if (!autoPlayAudio) return; if (!autoPlayAudio) return
if (!audioSrc) return; if (!audioSrc) return
play(audioSrc); play(audioSrc)
}, [visible, autoPlayAudio, audioSrc, play]); }, [visible, autoPlayAudio, audioSrc, play])
// 全部文字出现后自动关闭(如果有音频,需等待音频播放完成) // 全部文字出现后自动关闭(如果有音频,需等待音频播放完成)
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return
if (isAnimating) return; if (isAnimating) return
if (!text || displayedText !== text) return; if (!text || displayedText !== text) return
// 如果有音频,需要等待音频播放完成 // 如果有音频,需要等待音频播放完成
if (audioSrc) { if (audioSrc) {
@@ -95,32 +95,23 @@ export const EndingOverlay = ({
if (currentSrc === audioSrc && !isPlaying) { if (currentSrc === audioSrc && !isPlaying) {
// 音频播放完成后,等待 800ms 再关闭 // 音频播放完成后,等待 800ms 再关闭
const timer = setTimeout(() => { const timer = setTimeout(() => {
onContinue(); onContinue()
}, 800); }, 800)
return () => clearTimeout(timer); return () => clearTimeout(timer)
} }
// 音频还在播放,等待播放完成 // 音频还在播放,等待播放完成
return; return
} }
// 没有音频,文字动画完成后等待 800ms 关闭 // 没有音频,文字动画完成后等待 800ms 关闭
const timer = setTimeout(() => { const timer = setTimeout(() => {
onContinue(); onContinue()
}, 800); }, 800)
return () => clearTimeout(timer); return () => clearTimeout(timer)
}, [ }, [visible, isAnimating, displayedText, text, audioSrc, currentSrc, isPlaying, onContinue])
visible,
isAnimating,
displayedText,
text,
audioSrc,
currentSrc,
isPlaying,
onContinue,
]);
if (!visible) return null; if (!visible) return null
return ( return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-transparent"> <div className="fixed inset-0 z-[10000] flex items-center justify-center bg-transparent">
@@ -128,7 +119,7 @@ export const EndingOverlay = ({
<div <div
className="relative z-10 flex items-center max-w-full transition-all py-50 px-24 h-[825px] bg-cover bg-center bg-no-repeat" className="relative z-10 flex items-center max-w-full transition-all py-50 px-24 h-[825px] bg-cover bg-center bg-no-repeat"
style={{ style={{
backgroundImage: `url(${getStaticImage("components/overlay/bg")})`, backgroundImage: `url(${getStaticImage('components/overlay/bg')})`,
}} }}
> >
<div className="text-4xl leading-relaxed text-[#7b3f00] font-semibold tracking-wide"> <div className="text-4xl leading-relaxed text-[#7b3f00] font-semibold tracking-wide">
@@ -139,5 +130,5 @@ export const EndingOverlay = ({
{/* <div className="mt-16 h-4 w-full bg-gradient-to-r from-transparent via-[rgba(0,0,0,0.35)] to-transparent rounded-full" /> */} {/* <div className="mt-16 h-4 w-full bg-gradient-to-r from-transparent via-[rgba(0,0,0,0.35)] to-transparent rounded-full" /> */}
</div> </div>
</div> </div>
); )
}; }
+174 -214
View File
@@ -1,50 +1,43 @@
import { import { useEffect, useRef, useState, useCallback, useMemo, ReactNode } from 'react'
useEffect, import { createPortal } from 'react-dom'
useRef, import { AnimatePresence, motion } from 'framer-motion'
useState, import { useScale } from '@/contexts/ScaleContext'
useCallback, import { getBlockingDivs } from '@/components/spotlight/utils'
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 { export interface OverlayProps {
/** CSS 选择器,支持多个元素(逗号分隔或数组) */ /** CSS 选择器,支持多个元素(逗号分隔或数组) */
selectors: string | string[]; selectors: string | string[]
/** 是否可见 */ /** 是否可见 */
visible?: boolean; visible?: boolean
/** 子元素内容 */ /** 子元素内容 */
children?: ReactNode; children?: ReactNode
/** 遮罩层背景色(默认:rgba(0, 0, 0, 0.5)) */ /** 遮罩层背景色(默认:rgba(0, 0, 0, 0.5)) */
maskColor?: string; maskColor?: string
/** 高亮边框颜色(默认:#3b82f6) */ /** 高亮边框颜色(默认:#3b82f6) */
highlightColor?: string; highlightColor?: string
/** 高亮边框宽度(默认:3px) */ /** 高亮边框宽度(默认:3px) */
highlightWidth?: number; highlightWidth?: number
/** 高亮圆角(默认:8px) */ /** 高亮圆角(默认: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) */ /** z-index 层级(默认:40,低于 GuideSteps 的 z-50) */
zIndex?: number; zIndex?: number
/** 点击遮罩层回调 */ /** 点击遮罩层回调 */
onMaskClick?: () => void; onMaskClick?: () => void
/** 是否显示遮罩层(默认:true) */ /** 是否显示遮罩层(默认:true) */
showMask?: boolean; showMask?: boolean
} }
interface ElementRect { interface ElementRect {
x: number; x: number
y: number; y: number
width: number; width: number
height: number; height: number
} }
/** /**
@@ -61,8 +54,8 @@ const Overlay = ({
selectors, selectors,
visible = true, visible = true,
children, children,
maskColor = "rgba(0, 0, 0, 0.5)", maskColor = 'rgba(0, 0, 0, 0.5)',
highlightColor = "#3b82f6", highlightColor = '#3b82f6',
highlightWidth = 3, highlightWidth = 3,
borderRadius = 8, borderRadius = 8,
showShadow = false, showShadow = false,
@@ -72,223 +65,213 @@ const Overlay = ({
onMaskClick, onMaskClick,
showMask = true, showMask = true,
}: OverlayProps) => { }: OverlayProps) => {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false)
const [elementRects, setElementRects] = useState<ElementRect[]>([]); const [elementRects, setElementRects] = useState<ElementRect[]>([])
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null)
const resizeObserverRef = useRef<ResizeObserver | null>(null); const resizeObserverRef = useRef<ResizeObserver | null>(null)
const mutationObserverRef = useRef<MutationObserver | null>(null); const mutationObserverRef = useRef<MutationObserver | null>(null)
const debounceTimerRef = useRef<number | null>(null); const debounceTimerRef = useRef<number | null>(null)
const elementsCacheRef = useRef<Element[]>([]); const elementsCacheRef = useRef<Element[]>([])
const prevRectsRef = useRef<ElementRect[]>([]); const prevRectsRef = useRef<ElementRect[]>([])
// 获取缩放参数,用于视觉对齐 // 获取缩放参数,用于视觉对齐
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale(); const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
// 生成唯一的 mask ID // 生成唯一的 mask ID
const maskId = useMemo( const maskId = useMemo(() => `overlay-mask-${Math.random().toString(36).substr(2, 9)}`, [])
() => `overlay-mask-${Math.random().toString(36).substr(2, 9)}`,
[]
);
// 防抖函数 // 防抖函数
const debounce = useCallback((fn: () => void, delay: number) => { const debounce = useCallback((fn: () => void, delay: number) => {
return () => { return () => {
if (debounceTimerRef.current) { if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current); clearTimeout(debounceTimerRef.current)
} }
debounceTimerRef.current = window.setTimeout(fn, delay); debounceTimerRef.current = window.setTimeout(fn, delay)
}; }
}, []); }, [])
// SSR 兼容:只在客户端挂载 // SSR 兼容:只在客户端挂载
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true)
}, []); }, [])
// 获取所有匹配的元素(带缓存) // 获取所有匹配的元素(带缓存)
const getElements = useCallback(() => { const getElements = useCallback(() => {
if (typeof document === "undefined") return []; if (typeof document === 'undefined') return []
const selectorArray = const selectorArray = typeof selectors === 'string' ? [selectors] : selectors
typeof selectors === "string" ? [selectors] : selectors; const elements: Element[] = []
const elements: Element[] = [];
selectorArray.forEach((selector) => { selectorArray.forEach((selector) => {
try { try {
const matches = document.querySelectorAll(selector); const matches = document.querySelectorAll(selector)
elements.push(...Array.from(matches)); elements.push(...Array.from(matches))
} catch (error) { } catch (error) {
console.warn(`Invalid selector: ${selector}`, error); console.warn(`Invalid selector: ${selector}`, error)
} }
}); })
// 缓存元素列表 // 缓存元素列表
elementsCacheRef.current = elements; elementsCacheRef.current = elements
return elements; return elements
}, [selectors]); }, [selectors])
// 计算元素位置(带浅比较优化) // 计算元素位置(带浅比较优化)
const calculateRects = useCallback(() => { const calculateRects = useCallback(() => {
const elements = const elements = elementsCacheRef.current.length > 0 ? elementsCacheRef.current : getElements()
elementsCacheRef.current.length > 0 const rects: ElementRect[] = []
? elementsCacheRef.current
: getElements();
const rects: ElementRect[] = [];
elements.forEach((element) => { elements.forEach((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect()
if (rect.width > 0 && rect.height > 0) { if (rect.width > 0 && rect.height > 0) {
rects.push({ rects.push({
x: rect.left, x: rect.left,
y: rect.top, y: rect.top,
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
}); })
} }
}); })
// 浅比较,避免不必要的更新 // 浅比较,避免不必要的更新
const hasChanged = const hasChanged =
rects.length !== prevRectsRef.current.length || rects.length !== prevRectsRef.current.length ||
rects.some((rect, index) => { rects.some((rect, index) => {
const prev = prevRectsRef.current[index]; const prev = prevRectsRef.current[index]
return ( return (
!prev || !prev ||
rect.x !== prev.x || rect.x !== prev.x ||
rect.y !== prev.y || rect.y !== prev.y ||
rect.width !== prev.width || rect.width !== prev.width ||
rect.height !== prev.height rect.height !== prev.height
); )
}); })
if (hasChanged) { if (hasChanged) {
prevRectsRef.current = rects; prevRectsRef.current = rects
setElementRects(rects); setElementRects(rects)
} }
}, [getElements]); }, [getElements])
// 使用 requestAnimationFrame 批量更新位置 // 使用 requestAnimationFrame 批量更新位置
const scheduleUpdate = useCallback(() => { const scheduleUpdate = useCallback(() => {
if (rafRef.current) return; if (rafRef.current) return
rafRef.current = requestAnimationFrame(() => { rafRef.current = requestAnimationFrame(() => {
calculateRects(); calculateRects()
rafRef.current = null; rafRef.current = null
}); })
}, [calculateRects]); }, [calculateRects])
// 监听窗口变化(带防抖优化) // 监听窗口变化(带防抖优化)
useEffect(() => { useEffect(() => {
if (!visible || !mounted) return; if (!visible || !mounted) return
const debouncedUpdate = debounce(scheduleUpdate, 150); const debouncedUpdate = debounce(scheduleUpdate, 150)
const handleResize = debouncedUpdate; const handleResize = debouncedUpdate
const handleScroll = debouncedUpdate; const handleScroll = debouncedUpdate
window.addEventListener("resize", handleResize, { passive: true }); window.addEventListener('resize', handleResize, { passive: true })
window.addEventListener("scroll", handleScroll, { passive: true }); window.addEventListener('scroll', handleScroll, { passive: true })
// 初始计算 // 初始计算
scheduleUpdate(); scheduleUpdate()
return () => { return () => {
window.removeEventListener("resize", handleResize); window.removeEventListener('resize', handleResize)
window.removeEventListener("scroll", handleScroll); window.removeEventListener('scroll', handleScroll)
if (debounceTimerRef.current) { if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current); clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = null; debounceTimerRef.current = null
} }
if (rafRef.current) { if (rafRef.current) {
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current)
rafRef.current = null; rafRef.current = null
} }
}; }
}, [visible, mounted, scheduleUpdate, debounce]); }, [visible, mounted, scheduleUpdate, debounce])
// 使用 ResizeObserver 监听元素尺寸变化(带防抖优化) // 使用 ResizeObserver 监听元素尺寸变化(带防抖优化)
useEffect(() => { useEffect(() => {
if (!visible || !mounted) return; if (!visible || !mounted) return
const elements = getElements(); const elements = getElements()
if (elements.length === 0) return; if (elements.length === 0) return
const debouncedUpdate = debounce(scheduleUpdate, 150); const debouncedUpdate = debounce(scheduleUpdate, 150)
const observer = new ResizeObserver(debouncedUpdate); const observer = new ResizeObserver(debouncedUpdate)
elements.forEach((element) => { elements.forEach((element) => {
observer.observe(element); observer.observe(element)
}); })
resizeObserverRef.current = observer; resizeObserverRef.current = observer
return () => { return () => {
observer.disconnect(); observer.disconnect()
resizeObserverRef.current = null; resizeObserverRef.current = null
}; }
}, [visible, mounted, getElements, scheduleUpdate, debounce]); }, [visible, mounted, getElements, scheduleUpdate, debounce])
// 使用 MutationObserver 监听 DOM 变化(优化:只监听目标元素及其父级,带防抖) // 使用 MutationObserver 监听 DOM 变化(优化:只监听目标元素及其父级,带防抖)
useEffect(() => { useEffect(() => {
if (!visible || !mounted) return; if (!visible || !mounted) return
const elements = const elements = elementsCacheRef.current.length > 0 ? elementsCacheRef.current : getElements()
elementsCacheRef.current.length > 0
? elementsCacheRef.current
: getElements();
if (elements.length === 0) return; if (elements.length === 0) return
const debouncedUpdate = debounce(scheduleUpdate, 200); const debouncedUpdate = debounce(scheduleUpdate, 200)
const observer = new MutationObserver(debouncedUpdate); const observer = new MutationObserver(debouncedUpdate)
// 只监听目标元素及其父级,而不是整个 body // 只监听目标元素及其父级,而不是整个 body
const observedNodes = new Set<Node>(); const observedNodes = new Set<Node>()
elements.forEach((element) => { elements.forEach((element) => {
let current: Node | null = element; let current: Node | null = element
while (current && current !== document.body) { while (current && current !== document.body) {
if (!observedNodes.has(current)) { if (!observedNodes.has(current)) {
observer.observe(current, { observer.observe(current, {
childList: true, childList: true,
subtree: false, subtree: false,
attributes: true, attributes: true,
attributeFilter: ["class", "style"], attributeFilter: ['class', 'style'],
}); })
observedNodes.add(current); observedNodes.add(current)
} }
current = current.parentNode; current = current.parentNode
} }
}); })
mutationObserverRef.current = observer; mutationObserverRef.current = observer
return () => { return () => {
observer.disconnect(); observer.disconnect()
mutationObserverRef.current = null; mutationObserverRef.current = null
}; }
}, [visible, mounted, getElements, scheduleUpdate, debounce]); }, [visible, mounted, getElements, scheduleUpdate, debounce])
// 生成 SVG mask 的挖洞路径(使用 useMemo 缓存,转换为设计稿坐标) // 生成 SVG mask 的挖洞路径(使用 useMemo 缓存,转换为设计稿坐标)
const maskHoles = useMemo(() => { const maskHoles = useMemo(() => {
if (elementRects.length === 0) { if (elementRects.length === 0) {
return []; return []
} }
const padding = highlightWidth + borderRadius; const padding = highlightWidth + borderRadius
const holes: string[] = []; const holes: string[] = []
elementRects.forEach((rect) => { elementRects.forEach((rect) => {
// 将屏幕坐标转换为设计稿坐标 // 将屏幕坐标转换为设计稿坐标
const designX = (rect.x - offsetX) / scale; const designX = (rect.x - offsetX) / scale
const designY = (rect.y - offsetY) / scale; const designY = (rect.y - offsetY) / scale
const designWidth = rect.width / scale; const designWidth = rect.width / scale
const designHeight = rect.height / scale; const designHeight = rect.height / scale
const designPadding = padding / scale; const designPadding = padding / scale
const designR = borderRadius / scale; const designR = borderRadius / scale
const x = designX - designPadding; const x = designX - designPadding
const y = designY - designPadding; const y = designY - designPadding
const width = designWidth + designPadding * 2; const width = designWidth + designPadding * 2
const height = designHeight + designPadding * 2; const height = designHeight + designPadding * 2
const r = designR; const r = designR
// 创建圆角矩形路径(用于挖洞) // 创建圆角矩形路径(用于挖洞)
const path = const path =
@@ -299,34 +282,33 @@ const Overlay = ({
`L ${x + width} ${y + height - r} ` + `L ${x + width} ${y + height - r} ` +
`A ${r} ${r} 0 0 1 ${x + width - r} ${y + height} ` + `A ${r} ${r} 0 0 1 ${x + width - r} ${y + height} ` +
`L ${x + 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; return holes
}, [elementRects, highlightWidth, borderRadius, scale, offsetX, offsetY]); }, [elementRects, highlightWidth, borderRadius, scale, offsetX, offsetY])
// 生成阻止点击的 div 块(使用 Spotlight 的共享工具函数) // 生成阻止点击的 div 块(使用 Spotlight 的共享工具函数)
// 因为 SVG mask 不能阻止点击,需要使用 div 块覆盖非高亮区域 // 因为 SVG mask 不能阻止点击,需要使用 div 块覆盖非高亮区域
const blockingDivs = useMemo(() => { const blockingDivs = useMemo(() => {
if (elementRects.length === 0) return []; if (elementRects.length === 0) return []
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 0; const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 0
const viewportHeight = const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0
typeof window !== "undefined" ? window.innerHeight : 0;
const padding = highlightWidth + borderRadius; const padding = highlightWidth + borderRadius
// 计算带 padding 的矩形区域(转换为 Spotlight 需要的格式) // 计算带 padding 的矩形区域(转换为 Spotlight 需要的格式)
const paddedRects = elementRects.map((rect) => ({ const paddedRects = elementRects.map((rect) => ({
top: rect.y - padding, top: rect.y - padding,
left: rect.x - padding, left: rect.x - padding,
width: rect.width + padding * 2, width: rect.width + padding * 2,
height: rect.height + padding * 2, height: rect.height + padding * 2,
})); }))
return getBlockingDivs(paddedRects, viewportWidth, viewportHeight); return getBlockingDivs(paddedRects, viewportWidth, viewportHeight)
}, [elementRects, highlightWidth, borderRadius]); }, [elementRects, highlightWidth, borderRadius])
// 缓存视口尺寸(使用设计稿尺寸,应用缩放) // 缓存视口尺寸(使用设计稿尺寸,应用缩放)
const viewportSize = useMemo( const viewportSize = useMemo(
@@ -335,48 +317,45 @@ const Overlay = ({
height: DESIGN_HEIGHT, height: DESIGN_HEIGHT,
}), }),
[DESIGN_WIDTH, DESIGN_HEIGHT] [DESIGN_WIDTH, DESIGN_HEIGHT]
); )
// 缩放容器样式(应用 ScaleContext 的缩放)- 必须在条件返回之前 // 缩放容器样式(应用 ScaleContext 的缩放)- 必须在条件返回之前
const scaledContainerStyle = useMemo<React.CSSProperties>( const scaledContainerStyle = useMemo<React.CSSProperties>(
() => ({ () => ({
position: "absolute", position: 'absolute',
width: `${DESIGN_WIDTH}px`, width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`, height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`, left: `${offsetX}px`,
top: `${offsetY}px`, top: `${offsetY}px`,
transform: `scale(${scale})`, transform: `scale(${scale})`,
transformOrigin: "top left", transformOrigin: 'top left',
overflow: "hidden", overflow: 'hidden',
pointerEvents: "auto", // 允许点击事件穿透 pointerEvents: 'auto', // 允许点击事件穿透
}), }),
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT] [scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
); )
// 渲染逻辑: // 渲染逻辑:
// 1. 如果有高亮元素,显示遮罩和高亮边框 // 1. 如果有高亮元素,显示遮罩和高亮边框
// 2. 如果有 children,显示遮罩(即使没有高亮元素,用于包裹其他组件) // 2. 如果有 children,显示遮罩(即使没有高亮元素,用于包裹其他组件)
// 3. 如果 showMask 为 false,则不显示遮罩 // 3. 如果 showMask 为 false,则不显示遮罩
const hasHighlightElements = elementRects.length > 0; const hasHighlightElements = elementRects.length > 0
const shouldRenderMask = const shouldRenderMask =
showMask && showMask && visible && mounted && (hasHighlightElements || children !== undefined)
visible && const shouldRenderChildren = visible && mounted && children !== undefined
mounted &&
(hasHighlightElements || children !== undefined);
const shouldRenderChildren = visible && mounted && children !== undefined;
if (!shouldRenderMask && !shouldRenderChildren) { if (!shouldRenderMask && !shouldRenderChildren) {
return null; return null
} }
// 外层容器样式(全屏,不缩放) // 外层容器样式(全屏,不缩放)
const overlayStyle: React.CSSProperties = { const overlayStyle: React.CSSProperties = {
position: "fixed", position: 'fixed',
inset: 0, inset: 0,
zIndex, zIndex,
pointerEvents: "none", pointerEvents: 'none',
...style, ...style,
}; }
const content = ( const content = (
<AnimatePresence> <AnimatePresence>
@@ -388,7 +367,7 @@ const Overlay = ({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }} transition={{ duration: 0.3, ease: 'easeInOut' }}
> >
{/* 阻止点击层 - 使用 fixed 定位覆盖非高亮区域(集成自 Spotlight) */} {/* 阻止点击层 - 使用 fixed 定位覆盖非高亮区域(集成自 Spotlight) */}
{hasHighlightElements && {hasHighlightElements &&
@@ -396,21 +375,12 @@ const Overlay = ({
<div <div
key={`block-${index}`} key={`block-${index}`}
style={{ style={{
position: "fixed", position: 'fixed',
top: `${block.top}px`, top: `${block.top}px`,
left: left: typeof block.left === 'number' ? `${block.left}px` : block.left,
typeof block.left === "number" width: typeof block.width === 'number' ? `${block.width}px` : block.width,
? `${block.left}px` height: typeof block.height === 'number' ? `${block.height}px` : block.height,
: block.left, pointerEvents: 'auto',
width:
typeof block.width === "number"
? `${block.width}px`
: block.width,
height:
typeof block.height === "number"
? `${block.height}px`
: block.height,
pointerEvents: "auto",
zIndex: zIndex - 1, // 在遮罩层下方,但能阻止点击 zIndex: zIndex - 1, // 在遮罩层下方,但能阻止点击
}} }}
onClick={onMaskClick} onClick={onMaskClick}
@@ -424,25 +394,19 @@ const Overlay = ({
width={viewportSize.width} width={viewportSize.width}
height={viewportSize.height} height={viewportSize.height}
style={{ style={{
position: "absolute", position: 'absolute',
inset: 0, inset: 0,
zIndex: 0, zIndex: 0,
pointerEvents: "none", // SVG 不处理点击,由 blocking divs 处理 pointerEvents: 'none', // SVG 不处理点击,由 blocking divs 处理
}} }}
> >
<defs> <defs>
<mask id={maskId}> <mask id={maskId}>
{/* 全屏白色区域(显示遮罩) */} {/* 全屏白色区域(显示遮罩) */}
<rect <rect width={viewportSize.width} height={viewportSize.height} fill="white" />
width={viewportSize.width}
height={viewportSize.height}
fill="white"
/>
{/* 黑色区域(挖洞,隐藏遮罩)- 只在有高亮元素时显示 */} {/* 黑色区域(挖洞,隐藏遮罩)- 只在有高亮元素时显示 */}
{hasHighlightElements && {hasHighlightElements &&
maskHoles.map((hole, _index) => ( maskHoles.map((hole, _index) => <path key={_index} d={hole} fill="black" />)}
<path key={_index} d={hole} fill="black" />
))}
</mask> </mask>
</defs> </defs>
<rect <rect
@@ -458,9 +422,9 @@ const Overlay = ({
{shouldRenderChildren && ( {shouldRenderChildren && (
<div <div
style={{ style={{
position: "relative", position: 'relative',
zIndex: 1, zIndex: 1,
pointerEvents: "auto", pointerEvents: 'auto',
}} }}
> >
{children} {children}
@@ -471,44 +435,40 @@ const Overlay = ({
{hasHighlightElements && {hasHighlightElements &&
elementRects.map((rect) => { elementRects.map((rect) => {
// 将屏幕坐标转换为设计稿坐标(考虑缩放和偏移) // 将屏幕坐标转换为设计稿坐标(考虑缩放和偏移)
const designX = (rect.x - offsetX) / scale; const designX = (rect.x - offsetX) / scale
const designY = (rect.y - offsetY) / scale; const designY = (rect.y - offsetY) / scale
const designWidth = rect.width / scale; const designWidth = rect.width / scale
const designHeight = rect.height / scale; const designHeight = rect.height / scale
return ( return (
<div <div
key={`${rect.x}-${rect.y}-${rect.width}-${rect.height}`} key={`${rect.x}-${rect.y}-${rect.width}-${rect.height}`}
style={{ style={{
position: "absolute", position: 'absolute',
left: `${designX}px`, left: `${designX}px`,
top: `${designY}px`, top: `${designY}px`,
width: `${designWidth}px`, width: `${designWidth}px`,
height: `${designHeight}px`, height: `${designHeight}px`,
border: `${ border: `${highlightWidth / scale}px solid ${highlightColor}`,
highlightWidth / scale
}px solid ${highlightColor}`,
borderRadius: `${borderRadius / scale}px`, borderRadius: `${borderRadius / scale}px`,
pointerEvents: "none", pointerEvents: 'none',
zIndex: 2, zIndex: 2,
boxShadow: showShadow boxShadow: showShadow
? `0 0 0 ${ ? `0 0 0 ${(highlightWidth * 2) / scale}px ${highlightColor}40, 0 0 ${
(highlightWidth * 2) / scale
}px ${highlightColor}40, 0 0 ${
(highlightWidth * 4) / scale (highlightWidth * 4) / scale
}px ${highlightColor}60` }px ${highlightColor}60`
: undefined, : undefined,
}} }}
/> />
); )
})} })}
</div> </div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
); )
return createPortal(content, document.body); return createPortal(content, document.body)
}; }
export default Overlay; export default Overlay
+4 -4
View File
@@ -1,5 +1,5 @@
export { default as Overlay } from "./Overlay"; export { default as Overlay } from './Overlay'
export type { OverlayProps } from "./Overlay"; export type { OverlayProps } from './Overlay'
export { EndingOverlay } from "./EndingOverlay"; export { EndingOverlay } from './EndingOverlay'
export type { EndingOverlayProps } from "./EndingOverlay"; export type { EndingOverlayProps } from './EndingOverlay'
+114 -120
View File
@@ -1,51 +1,50 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from 'react'
import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { usePuzzleStore } from "@/store/puzzleStore"; import { usePuzzleStore } from '@/store/puzzleStore'
import { Image } from "@/components"; import { Image } from '@/components'
/** /**
* 六边形进度条组件 * 六边形进度条组件
* 根据进度值显示不同颜色:100%绿色,50%-99%橙色,0%-49%灰色 * 根据进度值显示不同颜色:100%绿色,50%-99%橙色,0%-49%灰色
*/ */
interface HexagonProgressBarProps { interface HexagonProgressBarProps {
progress: number; // 0-100 progress: number // 0-100
} }
const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => { const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
const progressPercent = Math.min(100, Math.max(0, progress)); const progressPercent = Math.min(100, Math.max(0, progress))
const progressText = progressPercent.toFixed(2) === "100.00" const progressText =
? "进度100%" progressPercent.toFixed(2) === '100.00' ? '进度100%' : `进度${Math.round(progressPercent)}%`
: `进度${Math.round(progressPercent)}%`;
// 根据进度值确定填充颜色 // 根据进度值确定填充颜色
const getProgressColor = () => { const getProgressColor = () => {
if (progressPercent >= 100) { if (progressPercent >= 100) {
return "#90B86B"; // 浅橄榄绿色(100%完成) return '#90B86B' // 浅橄榄绿色(100%完成)
} else if (progressPercent >= 50) { } else if (progressPercent >= 50) {
return "#EEB362"; // 橙色(50%-99%) return '#EEB362' // 橙色(50%-99%)
} else { } 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%) // 左顶点(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 innerHexagonPath = 'polygon(0% 50%, 3% 0%, 97% 0%, 100% 50%, 97% 100%, 3% 100%)'
const borderWidth = 4; // 增加边框宽度从2px到3px const borderWidth = 4 // 增加边框宽度从2px到3px
return ( return (
<div className="relative" style={{ width: "260px", height: "50px" }}> <div className="relative" style={{ width: '260px', height: '50px' }}>
{/* 外层:白色边框(使用 clip-path 裁剪形状) */} {/* 外层:白色边框(使用 clip-path 裁剪形状) */}
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
clipPath: outerHexagonPath, clipPath: outerHexagonPath,
backgroundColor: "white", backgroundColor: 'white',
}} }}
/> />
{/* 内容容器(边框内部区域) */} {/* 内容容器(边框内部区域) */}
@@ -63,7 +62,7 @@ const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
backgroundColor: "#757575", backgroundColor: '#757575',
}} }}
/> />
{/* 填充层(根据进度百分比从左侧填充) */} {/* 填充层(根据进度百分比从左侧填充) */}
@@ -78,7 +77,7 @@ const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
backgroundColor: fillColor, backgroundColor: fillColor,
transition: "width 0.3s ease, background-color 0.3s ease", transition: 'width 0.3s ease, background-color 0.3s ease',
}} }}
/> />
</div> </div>
@@ -88,42 +87,42 @@ const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
<div <div
className="absolute inset-0 flex items-center justify-center text-white text-2xl font-bold pointer-events-none" className="absolute inset-0 flex items-center justify-center text-white text-2xl font-bold pointer-events-none"
style={{ style={{
textShadow: "0 2px 4px rgba(0,0,0,0.6), 0 1px 2px rgba(0,0,0,0.8)", textShadow: '0 2px 4px rgba(0,0,0,0.6), 0 1px 2px rgba(0,0,0,0.8)',
zIndex: 10, zIndex: 10,
}} }}
> >
{progressText} {progressText}
</div> </div>
</div> </div>
); )
}; }
export interface MenuPuzzleCenterProps { export interface MenuPuzzleCenterProps {
/** 是否可见 */ /** 是否可见 */
visible: boolean; visible: boolean
/** 关闭回调 */ /** 关闭回调 */
onClose: () => void; onClose: () => void
/** 选择某个故居后的回调(进入对应的碎片系统) */ /** 选择某个故居后的回调(进入对应的碎片系统) */
onSelectResidence: (residence: string) => void; onSelectResidence: (residence: string) => void
} }
interface ResidenceConfig { interface ResidenceConfig {
/** 拼图存储用的英文 key(如 chenyun) */ /** 拼图存储用的英文 key(如 chenyun) */
residence: string; residence: string
/** 显示名称 */ /** 显示名称 */
displayName: string; displayName: string
/** 在大地图上的水平位置(百分比字符串,如 "20%") */ /** 在大地图上的水平位置(百分比字符串,如 "20%") */
left: string; left: string
/** 在大地图上的垂直位置(百分比字符串,如 "40%") */ /** 在大地图上的垂直位置(百分比字符串,如 "40%") */
top: string; top: string
/** 显示用图片路径(相对于 puzzle/home/ 目录) */ /** 显示用图片路径(相对于 puzzle/home/ 目录) */
image: string; image: string
/** 是否已经开放拼图(未开放时只展示,不可点击) */ /** 是否已经开放拼图(未开放时只展示,不可点击) */
hasPuzzle: boolean; hasPuzzle: boolean
/** 是否有导图资源 */ /** 是否有导图资源 */
hasMap?: boolean; hasMap?: boolean
/** 导图资源文件名(位于 puzzle/home/map 下) */ /** 导图资源文件名(位于 puzzle/home/map 下) */
mapImage?: string; mapImage?: string
} }
/** /**
@@ -133,91 +132,87 @@ interface ResidenceConfig {
*/ */
const RESIDENCE_LIST: ResidenceConfig[] = [ const RESIDENCE_LIST: ResidenceConfig[] = [
{ {
residence: "maozedong", residence: 'maozedong',
displayName: "毛泽东旧居", displayName: '毛泽东旧居',
left: "30%", left: '30%',
top: "35%", top: '35%',
image: "毛泽东", image: '毛泽东',
hasPuzzle: true, hasPuzzle: true,
hasMap: true, hasMap: true,
mapImage: "毛泽东导图", mapImage: '毛泽东导图',
}, },
{ {
residence: "sunzhongshan", residence: 'sunzhongshan',
displayName: "孙中山故居", displayName: '孙中山故居',
left: "12%", left: '12%',
top: "51%", top: '51%',
image: "孙中山", image: '孙中山',
hasPuzzle: true, hasPuzzle: true,
hasMap: true, hasMap: true,
mapImage: "孙中山导图", mapImage: '孙中山导图',
}, },
{ {
residence: "luxun", residence: 'luxun',
displayName: "鲁迅故居", displayName: '鲁迅故居',
left: "26.3%", left: '26.3%',
top: "74%", top: '74%',
image: "鲁迅", image: '鲁迅',
hasPuzzle: true, hasPuzzle: true,
hasMap: true, hasMap: true,
mapImage: "鲁迅导图", mapImage: '鲁迅导图',
}, },
{ {
residence: "huangyanpei", residence: 'huangyanpei',
displayName: "黄炎培故居", displayName: '黄炎培故居',
left: "44%", left: '44%',
top: "67%", top: '67%',
image: "黄炎培", image: '黄炎培',
hasPuzzle: true, hasPuzzle: true,
hasMap: true, hasMap: true,
mapImage: "黄炎培导图", mapImage: '黄炎培导图',
}, },
{ {
residence: "nieer", residence: 'nieer',
displayName: "聂耳旧居", displayName: '聂耳旧居',
left: "62%", left: '62%',
top: "60%", top: '60%',
image: "聂耳", image: '聂耳',
hasPuzzle: true, hasPuzzle: true,
hasMap: false, hasMap: false,
}, },
{ {
residence: "zhangleping", residence: 'zhangleping',
displayName: "张乐平故居", displayName: '张乐平故居',
left: "73%", left: '73%',
top: "33%", top: '33%',
image: "张乐平", image: '张乐平',
hasPuzzle: true, hasPuzzle: true,
hasMap: false, hasMap: false,
}, },
{ {
residence: "rongdesheng", residence: 'rongdesheng',
displayName: "荣德生旧居", displayName: '荣德生旧居',
left: "90%", left: '90%',
top: "45%", top: '45%',
image: "荣德生", image: '荣德生',
hasPuzzle: true, hasPuzzle: true,
hasMap: false, hasMap: false,
}, },
{ {
residence: "chenyun", residence: 'chenyun',
displayName: "陈云故居", displayName: '陈云故居',
left: "80%", left: '80%',
top: "76%", top: '76%',
image: "陈云", image: '陈云',
hasPuzzle: true, hasPuzzle: true,
hasMap: true, hasMap: true,
mapImage: "陈云导图", mapImage: '陈云导图',
}, },
]; ]
const MenuPuzzleCenter = ({ const MenuPuzzleCenter = ({ visible, onClose, onSelectResidence }: MenuPuzzleCenterProps) => {
visible, const { getPuzzleCollectProgress } = usePuzzleStore()
onClose, const [activeMapImage, setActiveMapImage] = useState<string | null>(null)
onSelectResidence,
}: MenuPuzzleCenterProps) => {
const { getPuzzleCollectProgress } = usePuzzleStore();
const [activeMapImage, setActiveMapImage] = useState<string | null>(null);
const residenceProgress = useMemo( const residenceProgress = useMemo(
() => () =>
@@ -227,37 +222,40 @@ const MenuPuzzleCenter = ({
progress: item.hasPuzzle ? getPuzzleCollectProgress(item.residence) : 0, progress: item.hasPuzzle ? getPuzzleCollectProgress(item.residence) : 0,
})), })),
[getPuzzleCollectProgress] [getPuzzleCollectProgress]
); )
if (!visible) return null; if (!visible) return null
return ( return (
<div <div
className="fixed inset-0 z-90 flex items-center justify-center" className="fixed inset-0 z-90 flex items-center justify-center"
style={{ background: "rgba(0,0,0,0.8)" }} style={{ background: 'rgba(0,0,0,0.8)' }}
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
onClose(); onClose()
} }
}} }}
> >
<div <div
className="relative w-full h-full bg-cover bg-center bg-no-repeat" className="relative w-full h-full bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: getBackgroundImage("puzzle/home/底图") }} style={{ backgroundImage: getBackgroundImage('puzzle/home/底图') }}
> >
{/* 箭头指示底图 */} {/* 箭头指示底图 */}
<img className="absolute top-84 left-55" src={getStaticImage("puzzle/home/箭头指引")} alt="" /> <img
className="absolute top-84 left-55"
src={getStaticImage('puzzle/home/箭头指引')}
alt=""
/>
{/* 关闭按钮 */} {/* 关闭按钮 */}
<Image <Image
className="absolute right-8 top-8 z-10" className="absolute right-8 top-8 z-10"
src={getStaticImage("components/关闭")} src={getStaticImage('components/关闭')}
alt="关闭" alt="关闭"
button button
onClick={onClose} onClick={onClose}
/> />
{/* 故居列表:按照设计图进行绝对定位 */} {/* 故居列表:按照设计图进行绝对定位 */}
<div className="absolute inset-0"> <div className="absolute inset-0">
{residenceProgress.map( {residenceProgress.map(
@@ -278,10 +276,7 @@ const MenuPuzzleCenter = ({
style={{ left, top }} style={{ left, top }}
> >
{/* 故居图片(资源在 puzzle/home 下,可根据实际名称调整) */} {/* 故居图片(资源在 puzzle/home 下,可根据实际名称调整) */}
<Image <Image src={getStaticImage(`puzzle/home/${image}`)} alt={displayName} />
src={getStaticImage(`puzzle/home/${image}`)}
alt={displayName}
/>
{/* 进度展示,仅对已开放拼图的故居显示 */} {/* 进度展示,仅对已开放拼图的故居显示 */}
{hasPuzzle && ( {hasPuzzle && (
@@ -292,20 +287,24 @@ const MenuPuzzleCenter = ({
{/* 图标 */} {/* 图标 */}
<div className="flex justify-center gap-6"> <div className="flex justify-center gap-6">
<Image src={getStaticImage(`puzzle/home/拼图`)} alt="拼图" button <Image
src={getStaticImage(`puzzle/home/拼图`)}
alt="拼图"
button
onClick={(e) => { onClick={(e) => {
if (!hasPuzzle) return; if (!hasPuzzle) return
e.stopPropagation(); e.stopPropagation()
onSelectResidence(residence); onSelectResidence(residence)
}} /> }}
/>
<Image <Image
src={getStaticImage(`puzzle/home/导图`)} src={getStaticImage(`puzzle/home/导图`)}
alt="导图" alt="导图"
button button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
if (!hasMap || !mapImage) return; if (!hasMap || !mapImage) return
setActiveMapImage(mapImage); setActiveMapImage(mapImage)
}} }}
/> />
</div> </div>
@@ -318,10 +317,10 @@ const MenuPuzzleCenter = ({
{activeMapImage && ( {activeMapImage && (
<div <div
className="fixed inset-0 z-110 flex items-center justify-center" className="fixed inset-0 z-110 flex items-center justify-center"
style={{ background: "rgba(0,0,0,0.75)" }} style={{ background: 'rgba(0,0,0,0.75)' }}
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
setActiveMapImage(null); setActiveMapImage(null)
} }
}} }}
> >
@@ -329,25 +328,20 @@ const MenuPuzzleCenter = ({
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<Image <Image
className="absolute -right-4 -top-8 z-10" className="absolute -right-4 -top-8 z-10"
src={getStaticImage("components/close")} src={getStaticImage('components/close')}
alt="关闭" alt="关闭"
button button
onClick={() => setActiveMapImage(null)} onClick={() => setActiveMapImage(null)}
/> />
<img <img src={getStaticImage(`puzzle/home/map/${activeMapImage}`)} alt="导图" />
src={getStaticImage(`puzzle/home/map/${activeMapImage}`)}
alt="导图"
/>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
); )
}; }
export default MenuPuzzleCenter;
export default MenuPuzzleCenter
+13 -13
View File
@@ -1,15 +1,15 @@
import { forwardRef } from "react"; import { forwardRef } from 'react'
import { getStaticImage } from "@/lib/utils"; import { getStaticImage } from '@/lib/utils'
export interface PuzzlePieceDisplayProps { export interface PuzzlePieceDisplayProps {
/** 故居名称,如 'chenyun' */ /** 故居名称,如 'chenyun' */
residence: string; residence: string
/** 碎片ID */ /** 碎片ID */
pieceId: number; pieceId: number
/** 是否显示发光动画 */ /** 是否显示发光动画 */
showGlow?: boolean; showGlow?: boolean
/** 自定义类名 */ /** 自定义类名 */
className?: string; className?: string
} }
/** /**
@@ -19,16 +19,16 @@ export interface PuzzlePieceDisplayProps {
* - 可以被 ref 引用,用于飞行动画 * - 可以被 ref 引用,用于飞行动画
*/ */
const PuzzlePieceDisplay = forwardRef<HTMLDivElement, PuzzlePieceDisplayProps>( const PuzzlePieceDisplay = forwardRef<HTMLDivElement, PuzzlePieceDisplayProps>(
({ residence, pieceId, showGlow = true, className = "" }, ref) => { ({ residence, pieceId, showGlow = true, className = '' }, ref) => {
// 碎片图片路径 // 碎片图片路径
const pieceImage = getStaticImage(`puzzle/${residence}/${pieceId}`); const pieceImage = getStaticImage(`puzzle/${residence}/${pieceId}`)
return ( return (
<div <div
ref={ref} ref={ref}
className={`relative inline-block ${className}`} className={`relative inline-block ${className}`}
style={{ style={{
animation: showGlow ? "puzzleGlow 1s ease-in-out infinite" : "none", animation: showGlow ? 'puzzleGlow 1s ease-in-out infinite' : 'none',
}} }}
> >
<style>{` <style>{`
@@ -43,10 +43,10 @@ const PuzzlePieceDisplay = forwardRef<HTMLDivElement, PuzzlePieceDisplayProps>(
`}</style> `}</style>
<img src={pieceImage} alt={`碎片 ${pieceId}`} className="w-full" /> <img src={pieceImage} alt={`碎片 ${pieceId}`} className="w-full" />
</div> </div>
); )
} }
); )
PuzzlePieceDisplay.displayName = "PuzzlePieceDisplay"; PuzzlePieceDisplay.displayName = 'PuzzlePieceDisplay'
export default PuzzlePieceDisplay; export default PuzzlePieceDisplay
+32 -32
View File
@@ -1,13 +1,13 @@
import { forwardRef, useEffect, useState } from "react"; import { forwardRef, useEffect, useState } from 'react'
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom'
import { motion } from "framer-motion"; import { motion } from 'framer-motion'
import PuzzlePieceDisplay from "./PuzzlePieceDisplay"; import PuzzlePieceDisplay from './PuzzlePieceDisplay'
interface PuzzlePiecePortalProps { interface PuzzlePiecePortalProps {
residence: string; residence: string
pieceId: number; pieceId: number
/** 是否隐藏(飞行时隐藏原始碎片) */ /** 是否隐藏(飞行时隐藏原始碎片) */
hidden?: boolean; hidden?: boolean
} }
/** /**
@@ -17,38 +17,38 @@ interface PuzzlePiecePortalProps {
*/ */
const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>( const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
({ residence, pieceId, hidden = false }, ref) => { ({ residence, pieceId, hidden = false }, ref) => {
const [isAnimating, setIsAnimating] = useState(true); const [isAnimating, setIsAnimating] = useState(true)
// 当组件首次渲染时,标记动画开始 // 当组件首次渲染时,标记动画开始
useEffect(() => { useEffect(() => {
setIsAnimating(true); setIsAnimating(true)
// 动画完成后,重置状态以便下次出现时重新播放 // 动画完成后,重置状态以便下次出现时重新播放
const timer = setTimeout(() => { const timer = setTimeout(() => {
setIsAnimating(false); setIsAnimating(false)
}, 800); // 与动画时长一致 }, 800) // 与动画时长一致
return () => clearTimeout(timer); return () => clearTimeout(timer)
}, [pieceId]); // 当 pieceId 变化时重新播放动画 }, [pieceId]) // 当 pieceId 变化时重新播放动画
return createPortal( return createPortal(
<div <div
style={{ style={{
position: "fixed", position: 'fixed',
left: "50%", left: '50%',
top: "50%", top: '50%',
width: "120px", width: '120px',
height: "120px", height: '120px',
transform: "translate(-50%, -50%)", transform: 'translate(-50%, -50%)',
perspective: "1000px", // 添加透视,让3D效果更明显 perspective: '1000px', // 添加透视,让3D效果更明显
zIndex: 99999, zIndex: 99999,
pointerEvents: "none", pointerEvents: 'none',
}} }}
> >
<motion.div <motion.div
ref={ref} ref={ref}
style={{ style={{
width: "100%", width: '100%',
height: "100%", height: '100%',
transformStyle: "preserve-3d", // 保持3D变换 transformStyle: 'preserve-3d', // 保持3D变换
}} }}
initial={ initial={
isAnimating isAnimating
@@ -61,17 +61,17 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
} }
animate={ animate={
hidden hidden
? { opacity: 0, visibility: "hidden" as const } ? { opacity: 0, visibility: 'hidden' as const }
: isAnimating : isAnimating
? { ? {
scale: 1, scale: 1,
rotateY: 0, rotateY: 0,
opacity: 1, opacity: 1,
visibility: "visible" as const, visibility: 'visible' as const,
} }
: { : {
opacity: 1, opacity: 1,
visibility: "visible" as const, visibility: 'visible' as const,
} }
} }
transition={{ transition={{
@@ -87,7 +87,7 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
}, },
opacity: { opacity: {
duration: 0.6, duration: 0.6,
ease: "easeOut", ease: 'easeOut',
}, },
}} }}
> >
@@ -100,10 +100,10 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
</motion.div> </motion.div>
</div>, </div>,
document.body document.body
); )
} }
); )
PuzzlePiecePortal.displayName = "PuzzlePiecePortal"; PuzzlePiecePortal.displayName = 'PuzzlePiecePortal'
export default PuzzlePiecePortal; export default PuzzlePiecePortal
+78 -88
View File
@@ -1,32 +1,32 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react'
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from 'react-router-dom'
import { cn, getBackgroundImage, getStaticImage } from "@/lib/utils"; import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
import { usePuzzleStore } from "@/store/puzzleStore"; import { usePuzzleStore } from '@/store/puzzleStore'
import { getPuzzlePieceData } from "@/config/puzzleData"; import { getPuzzlePieceData } from '@/config/puzzleData'
import { getPuzzlePiecePath, getPuzzleViewBox } from "@/config/puzzlePaths"; import { getPuzzlePiecePath, getPuzzleViewBox } from '@/config/puzzlePaths'
import { Image } from "@/components"; import { Image } from '@/components'
/** /**
* 将英文故居名称转换为中文显示名称 * 将英文故居名称转换为中文显示名称
*/ */
const getResidenceDisplayName = (residence: string): string => { const getResidenceDisplayName = (residence: string): string => {
const nameMap: Record<string, string> = { const nameMap: Record<string, string> = {
chenyun: "陈云", chenyun: '陈云',
huangyanpei: "黄炎培", huangyanpei: '黄炎培',
luxun: "鲁迅", luxun: '鲁迅',
sunzhongshan: "孙中山", sunzhongshan: '孙中山',
}; }
return nameMap[residence] || residence; return nameMap[residence] || residence
}; }
export interface PuzzleSystemProps { export interface PuzzleSystemProps {
/** 是否可见 */ /** 是否可见 */
visible: boolean; visible: boolean
/** 关闭回调 */ /** 关闭回调 */
onClose: () => void; onClose: () => void
/** 故居名称,如 'chenyun' */ /** 故居名称,如 'chenyun' */
residence: string; residence: string
} }
/** /**
@@ -42,24 +42,24 @@ const PuzzlePieceSvg = ({
onHover, onHover,
onLeave, onLeave,
}: { }: {
residence: string; residence: string
pieceId: number; pieceId: number
isUnlocked: boolean; isUnlocked: boolean
isSelected: boolean; isSelected: boolean
isHovered: boolean; isHovered: boolean
onClick: () => void; onClick: () => void
onHover: (id: number) => void; onHover: (id: number) => void
onLeave: () => void; onLeave: () => void
}) => { }) => {
const pathData = getPuzzlePiecePath(residence, pieceId); const pathData = getPuzzlePiecePath(residence, pieceId)
if (!pathData) return null; if (!pathData) return null
// 为每个碎片生成唯一的pattern ID // 为每个碎片生成唯一的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 ( return (
<> <>
@@ -81,9 +81,9 @@ const PuzzlePieceSvg = ({
onMouseEnter={() => (isUnlocked ? onHover(pieceId) : undefined)} onMouseEnter={() => (isUnlocked ? onHover(pieceId) : undefined)}
onMouseLeave={onLeave} onMouseLeave={onLeave}
style={{ style={{
cursor: isUnlocked ? "pointer" : "not-allowed", cursor: isUnlocked ? 'pointer' : 'not-allowed',
}} }}
className={cn(isUnlocked ? "puzzle-piece" : "")} className={cn(isUnlocked ? 'puzzle-piece' : '')}
> >
<defs> <defs>
{/* Safari/iOS 对 SVG image 的 CSS filter 支持有限,使用滤镜定义保证生效 */} {/* Safari/iOS 对 SVG image 的 CSS filter 支持有限,使用滤镜定义保证生效 */}
@@ -116,7 +116,7 @@ const PuzzlePieceSvg = ({
preserveAspectRatio="none" preserveAspectRatio="none"
filter={isUnlocked ? undefined : `url(#${patternId}-dim)`} filter={isUnlocked ? undefined : `url(#${patternId}-dim)`}
style={{ style={{
transition: "filter 220ms ease", transition: 'filter 220ms ease',
}} }}
/> />
</pattern> </pattern>
@@ -126,14 +126,14 @@ const PuzzlePieceSvg = ({
<path <path
d={pathData} d={pathData}
fill={`url(#${patternId})`} fill={`url(#${patternId})`}
stroke={isUnlocked ? (isSelected ? "#75d1fb" : "#fff") : "#888"} stroke={isUnlocked ? (isSelected ? '#75d1fb' : '#fff') : '#888'}
strokeWidth={isUnlocked ? (isSelected ? 3 : isHovered ? 2.3 : 2) : 1} strokeWidth={isUnlocked ? (isSelected ? 3 : isHovered ? 2.3 : 2) : 1}
opacity={isUnlocked ? 1 : 0.8} opacity={isUnlocked ? 1 : 0.8}
/> />
</g> </g>
</> </>
); )
}; }
/** /**
* 拼图系统组件 * 拼图系统组件
@@ -143,36 +143,36 @@ const PuzzlePieceSvg = ({
* - 点击碎片可跳转到对应页面 * - 点击碎片可跳转到对应页面
*/ */
const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => { const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
const navigate = useNavigate(); const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>(); const { moduleName } = useParams<{ moduleName: string }>()
const { isPuzzleUnlocked, getPuzzleCollectProgress } = usePuzzleStore(); const { isPuzzleUnlocked, getPuzzleCollectProgress } = usePuzzleStore()
const [selectedPiece, setSelectedPiece] = useState<number | null>(null); const [selectedPiece, setSelectedPiece] = useState<number | null>(null)
const [hoveredPiece, setHoveredPiece] = useState<number | null>(null); const [hoveredPiece, setHoveredPiece] = useState<number | null>(null)
// 当弹窗关闭时重置选中状态 // 当弹窗关闭时重置选中状态
useEffect(() => { useEffect(() => {
if (!visible) { 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 handlePieceClick = (pieceId: number) => {
const isUnlocked = isPuzzleUnlocked(residence, pieceId); const isUnlocked = isPuzzleUnlocked(residence, pieceId)
if (isUnlocked) { if (isUnlocked) {
// 显示选中效果,更新详情框 // 显示选中效果,更新详情框
setSelectedPiece(pieceId); setSelectedPiece(pieceId)
} else { } else {
// 未解锁的碎片,可以显示提示或不做任何操作 // 未解锁的碎片,可以显示提示或不做任何操作
// 暂时不做处理,或者可以添加提示 // 暂时不做处理,或者可以添加提示
} }
}; }
// 处理前往按钮点击 // 处理前往按钮点击
const handleGoToPage = () => { const handleGoToPage = () => {
@@ -180,40 +180,36 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
// console.log(residence, selectedPiece, moduleName); // console.log(residence, selectedPiece, moduleName);
// const route = getPuzzleRoute(residence, selectedPiece, moduleName); // const route = getPuzzleRoute(residence, selectedPiece, moduleName);
// console.log(route); // console.log(route);
const route = selectedPieceData?.route; const route = selectedPieceData?.route
if (route) { if (route) {
navigate(`/${moduleName}/${residence}/${route}`); navigate(`/${moduleName}/${residence}/${route}`)
onClose(); onClose()
}
} }
} }
};
// 获取当前选中碎片的数据 // 获取当前选中碎片的数据
const selectedPieceData = selectedPiece const selectedPieceData = selectedPiece ? getPuzzlePieceData(residence, selectedPiece) : null
? getPuzzlePieceData(residence, selectedPiece)
: null;
// 生成16块碎片(1-16) // 生成16块碎片(1-16)
const pieces = Array.from({ length: 16 }, (_, i) => i + 1); const pieces = Array.from({ length: 16 }, (_, i) => i + 1)
// 将悬停的碎片放到最后渲染,保证 hover 层级在最上 // 将悬停的碎片放到最后渲染,保证 hover 层级在最上
const renderPieces = const renderPieces =
hoveredPiece != null hoveredPiece != null ? [...pieces.filter((p) => p !== hoveredPiece), hoveredPiece] : pieces
? [...pieces.filter((p) => p !== hoveredPiece), hoveredPiece]
: pieces;
// 获取收集进度 // 获取收集进度
const collectProgress = getPuzzleCollectProgress(residence); const collectProgress = getPuzzleCollectProgress(residence)
return ( return (
<div <div
className="fixed inset-0 z-[100] flex items-center justify-center" className="fixed inset-0 z-[100] flex items-center justify-center"
style={{ style={{
background: "rgba(0, 0, 0, 0.8)", background: 'rgba(0, 0, 0, 0.8)',
}} }}
onClick={(e) => { onClick={(e) => {
// 点击背景关闭 // 点击背景关闭
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
onClose(); onClose()
} }
}} }}
> >
@@ -227,7 +223,7 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
{/* 关闭按钮 */} {/* 关闭按钮 */}
<Image <Image
className="absolute right-8 top-8 z-10" className="absolute right-8 top-8 z-10"
src={getStaticImage("components/关闭")} src={getStaticImage('components/关闭')}
alt="关闭" alt="关闭"
button button
onClick={onClose} onClick={onClose}
@@ -236,27 +232,27 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
{/* 收集进度 */} {/* 收集进度 */}
<div <div
className="absolute left-1/2 -translate-x-1/2 top-8 z-10 w-[708px] h-[107px] px-12 flex justify-between items-center" className="absolute left-1/2 -translate-x-1/2 top-8 z-10 w-[708px] h-[107px] px-12 flex justify-between items-center"
style={{ backgroundImage: getBackgroundImage("puzzle/收集进度") }} style={{ backgroundImage: getBackgroundImage('puzzle/收集进度') }}
> >
<div className="relative"> <div className="relative">
<span <span
className={cn( className={cn(
"text-white text-2xl font-bold", 'text-white text-2xl font-bold',
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]" '[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]'
)} )}
> >
{getResidenceDisplayName(residence)}故居碎片 {getResidenceDisplayName(residence)}故居碎片
</span> </span>
<img <img
className="absolute -top-10 left-0 -z-10" className="absolute -top-10 left-0 -z-10"
src={getStaticImage("puzzle/碎片装饰")} src={getStaticImage('puzzle/碎片装饰')}
alt="碎片装饰" alt="碎片装饰"
/> />
</div> </div>
<div className="text-[#490000] text-4xl font-bold"> <div className="text-[#490000] text-4xl font-bold">
{/* 收集进度 */} {/* 收集进度 */}
{collectProgress.toFixed(2) == "1.00" {collectProgress.toFixed(2) == '1.00'
? "已集齐" ? '已集齐'
: `收集进度${collectProgress.toFixed(2)}%`} : `收集进度${collectProgress.toFixed(2)}%`}
</div> </div>
</div> </div>
@@ -271,12 +267,12 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
viewBox={getPuzzleViewBox(residence)} viewBox={getPuzzleViewBox(residence)}
className="w-full h-full" className="w-full h-full"
style={{ style={{
filter: "drop-shadow(0px 2px 4px rgba(0,0,0,0.3))", filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.3))',
overflow: "visible", overflow: 'visible',
}} }}
> >
{renderPieces.map((pieceId) => { {renderPieces.map((pieceId) => {
const isUnlocked = isPuzzleUnlocked(residence, pieceId); const isUnlocked = isPuzzleUnlocked(residence, pieceId)
return ( return (
<PuzzlePieceSvg <PuzzlePieceSvg
@@ -290,7 +286,7 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
onHover={(id) => setHoveredPiece(id)} onHover={(id) => setHoveredPiece(id)}
onLeave={() => setHoveredPiece(null)} onLeave={() => setHoveredPiece(null)}
/> />
); )
})} })}
</svg> </svg>
</div> </div>
@@ -300,26 +296,22 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
<div <div
className="w-[657px] h-[776px] bg-no-repeat bg-cover p-10 text-center" className="w-[657px] h-[776px] bg-no-repeat bg-cover p-10 text-center"
style={{ style={{
backgroundImage: getBackgroundImage("puzzle/详情框"), backgroundImage: getBackgroundImage('puzzle/详情框'),
}} }}
> >
{/* 标题 */} {/* 标题 */}
{selectedPieceData && ( {selectedPieceData && (
<div <div
className={cn( className={cn(
"text-white text-5xl font-bold whitespace-nowrap mt-8 mb-4 space-y-4", 'text-white text-5xl font-bold whitespace-nowrap mt-8 mb-4 space-y-4',
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]" '[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]'
)} )}
> >
{/* 空格换行 */} {/* 空格换行 */}
{selectedPieceData.title {selectedPieceData.title.split(' ').map((word: string, index: number) => (
.split(" ")
.map((word: string, index: number) => (
<div key={index}> <div key={index}>
{word} {word}
{index < {index < selectedPieceData.title.split(' ').length - 1 && ' '}
selectedPieceData.title.split(" ").length - 1 &&
" "}
</div> </div>
))} ))}
</div> </div>
@@ -327,9 +319,7 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
{/* 对应页面图片 */} {/* 对应页面图片 */}
<div className="max-h-[500px] min-h-[400px] flex items-center justify-center"> <div className="max-h-[500px] min-h-[400px] flex items-center justify-center">
<img <img
src={getStaticImage( src={getStaticImage(`puzzle/${selectedPieceData.detailImage}`)}
`puzzle/${selectedPieceData.detailImage}`
)}
alt={selectedPieceData.title} alt={selectedPieceData.title}
className="max-w-full max-h-full object-contain" className="max-w-full max-h-full object-contain"
/> />
@@ -338,7 +328,7 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
{selectedPieceData && ( {selectedPieceData && (
<Image <Image
className="mt-4" className="mt-4"
src={getStaticImage("puzzle/点击前往浏览")} src={getStaticImage('puzzle/点击前往浏览')}
alt="前往按钮" alt="前往按钮"
button button
onClick={handleGoToPage} onClick={handleGoToPage}
@@ -351,7 +341,7 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default PuzzleSystem; export default PuzzleSystem
+5 -6
View File
@@ -1,6 +1,5 @@
export { default as PuzzleSystem } from "./PuzzleSystem"; export { default as PuzzleSystem } from './PuzzleSystem'
export type { PuzzleSystemProps } from "./PuzzleSystem"; export type { PuzzleSystemProps } from './PuzzleSystem'
export { default as PuzzlePieceDisplay } from "./PuzzlePieceDisplay"; export { default as PuzzlePieceDisplay } from './PuzzlePieceDisplay'
export type { PuzzlePieceDisplayProps } from "./PuzzlePieceDisplay"; export type { PuzzlePieceDisplayProps } from './PuzzlePieceDisplay'
export { default as PuzzlePiecePortal } from "./PuzzlePiecePortal"; export { default as PuzzlePiecePortal } from './PuzzlePiecePortal'
@@ -1,169 +1,167 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from '@storybook/react'
import { useState } from "react"; import { useState } from 'react'
import DragFill from "./drag-fill"; import DragFill from './drag-fill'
import type { DragFillProps } from "./drag-fill.types"; import type { DragFillProps } from './drag-fill.types'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
const meta: Meta<typeof DragFill> = { const meta: Meta<typeof DragFill> = {
title: "Components/Quiz/DragFill", title: 'Components/Quiz/DragFill',
component: DragFill, component: DragFill,
tags: ["autodocs"], tags: ['autodocs'],
argTypes: { argTypes: {
options: { options: {
control: { type: "object" }, control: { type: 'object' },
description: "可拖拽的选项列表", description: '可拖拽的选项列表',
}, },
blanks: { blanks: {
control: { type: "object" }, control: { type: 'object' },
description: "空白区域列表", description: '空白区域列表',
}, },
value: { value: {
control: { type: "object" }, control: { type: 'object' },
description: "当前填充结果(受控模式)", description: '当前填充结果(受控模式)',
}, },
defaultValue: { defaultValue: {
control: { type: "object" }, control: { type: 'object' },
description: "默认填充结果", description: '默认填充结果',
}, },
disabled: { disabled: {
control: { type: "boolean" }, control: { type: 'boolean' },
description: "是否禁用整个组件", description: '是否禁用整个组件',
}, },
allowDuplicate: { allowDuplicate: {
control: { type: "boolean" }, control: { type: 'boolean' },
description: "是否允许重复选择同一选项", description: '是否允许重复选择同一选项',
}, },
showSubmitButton: { showSubmitButton: {
control: { type: "boolean" }, control: { type: 'boolean' },
description: "是否显示提交按钮", description: '是否显示提交按钮',
}, },
submitText: { submitText: {
control: { type: "text" }, control: { type: 'text' },
description: "提交按钮文本", description: '提交按钮文本',
}, },
}, },
}; }
export default meta; export default meta
type Story = StoryObj<typeof DragFill>; type Story = StoryObj<typeof DragFill>
// 基础示例 // 基础示例
export const Default: Story = { export const Default: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C", "选项D"], options: ['选项A', '选项B', '选项C', '选项D'],
blanks: ["空白1", "空白2", "空白3"], blanks: ['空白1', '空白2', '空白3'],
onSubmit: (results) => { onSubmit: (results) => {
console.log("填充结果:", results); console.log('填充结果:', results)
}, },
}, },
}; }
// 使用选项和空白对象 // 使用选项和空白对象
export const WithObjects: Story = { export const WithObjects: Story = {
args: { args: {
options: [ options: [
{ value: "opt1", label: "选项一" }, { value: 'opt1', label: '选项一' },
{ value: "opt2", label: "选项二" }, { value: 'opt2', label: '选项二' },
{ value: "opt3", label: "选项三(禁用)", disabled: true }, { value: 'opt3', label: '选项三(禁用)', disabled: true },
{ value: "opt4", label: "选项四" }, { value: 'opt4', label: '选项四' },
], ],
blanks: [ blanks: [
{ id: "blank1", label: "第一个空白", required: true }, { id: 'blank1', label: '第一个空白', required: true },
{ id: "blank2", label: "第二个空白" }, { id: 'blank2', label: '第二个空白' },
{ id: "blank3", label: "第三个空白(必填)", required: true }, { id: 'blank3', label: '第三个空白(必填)', required: true },
], ],
onSubmit: (results) => { onSubmit: (results) => {
console.log("填充结果:", results); console.log('填充结果:', results)
}, },
}, },
}; }
// 受控模式 // 受控模式
export const Controlled: Story = { export const Controlled: Story = {
render: () => { render: () => {
const [value, setValue] = useState<any[]>([]); const [value, setValue] = useState<any[]>([])
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<DragFill <DragFill
options={["选项A", "选项B", "选项C"]} options={['选项A', '选项B', '选项C']}
blanks={["空白1", "空白2"]} blanks={['空白1', '空白2']}
value={value} value={value}
onChange={setValue} onChange={setValue}
onSubmit={(results) => { onSubmit={(results) => {
alert(`提交的结果: ${JSON.stringify(results)}`); alert(`提交的结果: ${JSON.stringify(results)}`)
}} }}
/> />
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600">当前填充: {JSON.stringify(value)}</div>
当前填充: {JSON.stringify(value)}
</div> </div>
</div> )
);
}, },
}; }
// 允许重复选择 // 允许重复选择
export const AllowDuplicate: Story = { export const AllowDuplicate: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C"], options: ['选项A', '选项B', '选项C'],
blanks: ["空白1", "空白2", "空白3"], blanks: ['空白1', '空白2', '空白3'],
allowDuplicate: true, allowDuplicate: true,
onSubmit: (results) => { onSubmit: (results) => {
console.log("填充结果:", results); console.log('填充结果:', results)
}, },
}, },
}; }
// 禁用状态 // 禁用状态
export const Disabled: Story = { export const Disabled: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C"], options: ['选项A', '选项B', '选项C'],
blanks: ["空白1", "空白2"], blanks: ['空白1', '空白2'],
disabled: true, disabled: true,
defaultValue: [{ blankId: "blank1", optionValue: "选项A" }], defaultValue: [{ blankId: 'blank1', optionValue: '选项A' }],
}, },
}; }
// 不显示提交按钮 // 不显示提交按钮
export const WithoutSubmitButton: Story = { export const WithoutSubmitButton: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C"], options: ['选项A', '选项B', '选项C'],
blanks: ["空白1", "空白2"], blanks: ['空白1', '空白2'],
showSubmitButton: false, showSubmitButton: false,
onChange: (results) => { onChange: (results) => {
console.log("填充变化:", results); console.log('填充变化:', results)
}, },
}, },
}; }
// 自定义样式 // 自定义样式
export const CustomStyled: Story = { export const CustomStyled: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C", "选项D"], options: ['选项A', '选项B', '选项C', '选项D'],
blanks: ["空白1", "空白2"], blanks: ['空白1', '空白2'],
className: "max-w-2xl mx-auto p-6 bg-gray-50 rounded-xl", className: 'max-w-2xl mx-auto p-6 bg-gray-50 rounded-xl',
optionClassName: "text-lg font-semibold", optionClassName: 'text-lg font-semibold',
blankClassName: "min-h-[100px]", blankClassName: 'min-h-[100px]',
submitText: "确认答案", submitText: '确认答案',
onSubmit: (results) => { onSubmit: (results) => {
console.log("填充结果:", results); console.log('填充结果:', results)
}, },
}, },
}; }
// 自定义渲染 // 自定义渲染
export const CustomRender: Story = { export const CustomRender: Story = {
render: () => { render: () => {
return ( return (
<DragFill <DragFill
options={["选项A", "选项B", "选项C"]} options={['选项A', '选项B', '选项C']}
blanks={["空白1", "空白2"]} blanks={['空白1', '空白2']}
renderOption={(option, isUsed) => ( renderOption={(option, isUsed) => (
<div <div
className={cn( className={cn(
"p-4 rounded-lg border-2 transition-all cursor-move", 'p-4 rounded-lg border-2 transition-all cursor-move',
isUsed isUsed
? "border-gray-200 bg-gray-100 opacity-50" ? 'border-gray-200 bg-gray-100 opacity-50'
: "border-blue-300 bg-blue-50 hover:border-blue-500" : 'border-blue-300 bg-blue-50 hover:border-blue-500'
)} )}
draggable={!isUsed} draggable={!isUsed}
> >
@@ -176,29 +174,24 @@ export const CustomRender: Story = {
renderBlank={(blank, filledValue) => ( renderBlank={(blank, filledValue) => (
<div <div
className={cn( className={cn(
"min-h-[100px] p-6 rounded-lg border-2 border-dashed flex items-center justify-center", 'min-h-[100px] p-6 rounded-lg border-2 border-dashed flex items-center justify-center',
filledValue filledValue ? 'border-green-400 bg-green-50' : 'border-purple-300 bg-purple-50'
? "border-green-400 bg-green-50"
: "border-purple-300 bg-purple-50"
)} )}
> >
{filledValue ? ( {filledValue ? (
<div className="text-green-700 font-bold text-lg"> <div className="text-green-700 font-bold text-lg">✓ {filledValue}</div>
✓ {filledValue}
</div>
) : ( ) : (
<div className="text-purple-500 text-center"> <div className="text-purple-500 text-center">
<div className="text-3xl mb-2">⬇️</div> <div className="text-3xl mb-2">⬇️</div>
<div>{blank.label || "拖拽到这里"}</div> <div>{blank.label || '拖拽到这里'}</div>
</div> </div>
)} )}
</div> </div>
)} )}
onSubmit={(results) => { onSubmit={(results) => {
console.log("填充结果:", results); console.log('填充结果:', results)
}} }}
/> />
); )
}, },
}; }
+180 -237
View File
@@ -1,12 +1,12 @@
import { useState, useRef, useEffect, useCallback, forwardRef } from "react"; import { useState, useRef, useEffect, useCallback, forwardRef } from 'react'
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
import type { import type {
DragFillProps, DragFillProps,
DragFillOption, DragFillOption,
DragFillBlank, DragFillBlank,
DragFillResult, DragFillResult,
} from "./drag-fill.types"; } from './drag-fill.types'
/** /**
* DragFill - 拖拽填空组件(使用鼠标事件实现) * DragFill - 拖拽填空组件(使用鼠标事件实现)
@@ -33,12 +33,12 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
onSubmit, onSubmit,
disabled = false, disabled = false,
allowDuplicate = false, allowDuplicate = false,
layout = "vertical", layout = 'vertical',
hideLabels = false, hideLabels = false,
className, className,
optionClassName, optionClassName,
blankClassName, blankClassName,
submitText = "提交", submitText = '提交',
showSubmitButton = true, showSubmitButton = true,
renderOption, renderOption,
renderBlank, renderBlank,
@@ -48,205 +48,180 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
) => { ) => {
// 标准化选项数据 // 标准化选项数据
const normalizedOptions: DragFillOption[] = options.map((option) => { const normalizedOptions: DragFillOption[] = options.map((option) => {
if (typeof option === "string") { if (typeof option === 'string') {
return { value: option, label: option }; return { value: option, label: option }
} }
return option; return option
}); })
// 标准化空白区域数据 // 标准化空白区域数据
const normalizedBlanks: DragFillBlank[] = blanks.map((blank, index) => { const normalizedBlanks: DragFillBlank[] = blanks.map((blank, index) => {
if (typeof blank === "string") { if (typeof blank === 'string') {
return { id: `blank-${index}`, label: blank }; return { id: `blank-${index}`, label: blank }
} }
return blank; return blank
}); })
// 受控/非受控模式 // 受控/非受控模式
const isControlled = controlledValue !== undefined; const isControlled = controlledValue !== undefined
const [internalValue, setInternalValue] = useState<DragFillResult[]>( const [internalValue, setInternalValue] = useState<DragFillResult[]>(defaultValue || [])
defaultValue || [] const currentValue = isControlled ? controlledValue : internalValue
);
const currentValue = isControlled ? controlledValue : internalValue;
// 拖拽状态 // 拖拽状态
const [draggingOption, setDraggingOption] = useState<string | null>(null); const [draggingOption, setDraggingOption] = useState<string | null>(null)
const [dragPosition, setDragPosition] = useState<{ const [dragPosition, setDragPosition] = useState<{
x: number; x: number
y: number; y: number
} | null>(null); } | null>(null)
const [hoveredBlank, setHoveredBlank] = useState<string | null>(null); const [hoveredBlank, setHoveredBlank] = useState<string | null>(null)
const dragOffsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const dragOffsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
const blankRefsMap = useRef<Map<string, HTMLElement>>(new Map()); const blankRefsMap = useRef<Map<string, HTMLElement>>(new Map())
// 检查选项是否已被使用 // 检查选项是否已被使用
const isOptionUsed = useCallback( const isOptionUsed = useCallback(
(optionValue: string): boolean => { (optionValue: string): boolean => {
if (allowDuplicate) return false; if (allowDuplicate) return false
return currentValue.some( return currentValue.some((result) => result.optionValue === optionValue)
(result) => result.optionValue === optionValue
);
}, },
[allowDuplicate, currentValue] [allowDuplicate, currentValue]
); )
// 通用的坐标更新逻辑(鼠标/触摸) // 通用的坐标更新逻辑(鼠标/触摸)
const updateDragPosition = useCallback( const updateDragPosition = useCallback(
(clientX: number, clientY: number) => { (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) => { blankRefsMap.current.forEach((element, blankId) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect()
if ( if (
clientX >= rect.left && clientX >= rect.left &&
clientX <= rect.right && clientX <= rect.right &&
clientY >= rect.top && clientY >= rect.top &&
clientY <= rect.bottom clientY <= rect.bottom
) { ) {
foundBlank = blankId; foundBlank = blankId
} }
}); })
// 如果没找到,尝试查找页面中所有带 data-blank-id 的元素 // 如果没找到,尝试查找页面中所有带 data-blank-id 的元素
if (!foundBlank) { if (!foundBlank) {
normalizedBlanks.forEach((blank) => { normalizedBlanks.forEach((blank) => {
const element = document.querySelector( const element = document.querySelector(`[data-blank-id="${blank.id}"]`)
`[data-blank-id="${blank.id}"]`
);
if (element) { if (element) {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect()
if ( if (
clientX >= rect.left && clientX >= rect.left &&
clientX <= rect.right && clientX <= rect.right &&
clientY >= rect.top && clientY >= rect.top &&
clientY <= rect.bottom clientY <= rect.bottom
) { ) {
foundBlank = blank.id; foundBlank = blank.id
} }
} }
}); })
} }
setHoveredBlank(foundBlank); setHoveredBlank(foundBlank)
}, },
[draggingOption, normalizedBlanks] [draggingOption, normalizedBlanks]
); )
// 处理鼠标移动 // 处理鼠标移动
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
updateDragPosition(e.clientX, e.clientY); updateDragPosition(e.clientX, e.clientY)
}, },
[updateDragPosition] [updateDragPosition]
); )
// 处理触摸移动 // 处理触摸移动
const handleTouchMove = useCallback( const handleTouchMove = useCallback(
(e: TouchEvent) => { (e: TouchEvent) => {
const touch = e.touches[0]; const touch = e.touches[0]
if (!touch) return; if (!touch) return
// 阻止页面滚动,确保拖拽顺滑 // 阻止页面滚动,确保拖拽顺滑
e.preventDefault(); e.preventDefault()
updateDragPosition(touch.clientX, touch.clientY); updateDragPosition(touch.clientX, touch.clientY)
}, },
[updateDragPosition] [updateDragPosition]
); )
// 处理释放(鼠标/触摸) // 处理释放(鼠标/触摸)
const handleRelease = useCallback(() => { const handleRelease = useCallback(() => {
if (!draggingOption) return; if (!draggingOption) return
// 如果悬停在空白区域上,执行放置 // 如果悬停在空白区域上,执行放置
if (hoveredBlank) { if (hoveredBlank) {
const optionValue = draggingOption; const optionValue = draggingOption
// 检查是否允许重复 // 检查是否允许重复
if (!allowDuplicate) { if (!allowDuplicate) {
const isAlreadyUsed = currentValue.some( const isAlreadyUsed = currentValue.some((result) => result.optionValue === optionValue)
(result) => result.optionValue === optionValue
);
if (isAlreadyUsed) { if (isAlreadyUsed) {
// 如果已使用,先移除旧的填充 // 如果已使用,先移除旧的填充
const newValue = currentValue.filter( const newValue = currentValue.filter((result) => result.optionValue !== optionValue)
(result) => result.optionValue !== optionValue
);
// 添加新的填充 // 添加新的填充
const updatedValue = [ const updatedValue = [...newValue, { blankId: hoveredBlank, optionValue }]
...newValue,
{ blankId: hoveredBlank, optionValue },
];
if (!isControlled) { if (!isControlled) {
setInternalValue(updatedValue); setInternalValue(updatedValue)
} }
onChange?.(updatedValue); onChange?.(updatedValue)
setDraggingOption(null); setDraggingOption(null)
setDragPosition(null); setDragPosition(null)
setHoveredBlank(null); setHoveredBlank(null)
return; return
} }
} }
// 检查空白区域是否已有填充 // 检查空白区域是否已有填充
const existingIndex = currentValue.findIndex( const existingIndex = currentValue.findIndex((result) => result.blankId === hoveredBlank)
(result) => result.blankId === hoveredBlank
);
let updatedValue: DragFillResult[]; let updatedValue: DragFillResult[]
if (existingIndex >= 0) { if (existingIndex >= 0) {
// 替换现有填充 // 替换现有填充
updatedValue = [...currentValue]; updatedValue = [...currentValue]
updatedValue[existingIndex] = { blankId: hoveredBlank, optionValue }; updatedValue[existingIndex] = { blankId: hoveredBlank, optionValue }
} else { } else {
// 添加新填充 // 添加新填充
updatedValue = [ updatedValue = [...currentValue, { blankId: hoveredBlank, optionValue }]
...currentValue,
{ blankId: hoveredBlank, optionValue },
];
} }
if (!isControlled) { if (!isControlled) {
setInternalValue(updatedValue); setInternalValue(updatedValue)
} }
onChange?.(updatedValue); onChange?.(updatedValue)
} }
setDraggingOption(null); setDraggingOption(null)
setDragPosition(null); setDragPosition(null)
setHoveredBlank(null); setHoveredBlank(null)
}, [ }, [draggingOption, hoveredBlank, allowDuplicate, currentValue, isControlled, onChange])
draggingOption,
hoveredBlank,
allowDuplicate,
currentValue,
isControlled,
onChange,
]);
// 添加全局鼠标事件监听 // 添加全局鼠标事件监听
useEffect(() => { useEffect(() => {
if (draggingOption) { if (draggingOption) {
document.addEventListener("mousemove", handleMouseMove); document.addEventListener('mousemove', handleMouseMove)
document.addEventListener("mouseup", handleRelease); document.addEventListener('mouseup', handleRelease)
document.addEventListener("touchmove", handleTouchMove, { document.addEventListener('touchmove', handleTouchMove, {
passive: false, passive: false,
}); })
document.addEventListener("touchend", handleRelease); document.addEventListener('touchend', handleRelease)
document.addEventListener("touchcancel", handleRelease); document.addEventListener('touchcancel', handleRelease)
return () => { return () => {
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener("mouseup", handleRelease); document.removeEventListener('mouseup', handleRelease)
document.removeEventListener("touchmove", handleTouchMove); document.removeEventListener('touchmove', handleTouchMove)
document.removeEventListener("touchend", handleRelease); document.removeEventListener('touchend', handleRelease)
document.removeEventListener("touchcancel", handleRelease); document.removeEventListener('touchcancel', handleRelease)
};
} }
}, [draggingOption, handleMouseMove, handleTouchMove, handleRelease]); }
}, [draggingOption, handleMouseMove, handleTouchMove, handleRelease])
// 开始拖拽(通用) // 开始拖拽(通用)
const startDragging = ( const startDragging = (
@@ -255,98 +230,93 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
clientY: number, clientY: number,
currentTarget: HTMLElement currentTarget: HTMLElement
) => { ) => {
if (disabled) return; if (disabled) return
const option = normalizedOptions.find((opt) => opt.value === optionValue); const option = normalizedOptions.find((opt) => opt.value === optionValue)
if (option?.disabled) return; if (option?.disabled) return
// 检查是否已使用 // 检查是否已使用
if (!allowDuplicate && isOptionUsed(optionValue)) return; if (!allowDuplicate && isOptionUsed(optionValue)) return
// 获取实际的元素(如果有自定义渲染,使用第一个子元素) // 获取实际的元素(如果有自定义渲染,使用第一个子元素)
const wrapperElement = currentTarget; const wrapperElement = currentTarget
const actualElement = renderOption const actualElement = renderOption
? (wrapperElement.firstElementChild as HTMLElement) ? (wrapperElement.firstElementChild as HTMLElement)
: wrapperElement; : wrapperElement
if (!actualElement) return; if (!actualElement) return
const rect = actualElement.getBoundingClientRect(); const rect = actualElement.getBoundingClientRect()
// 记录偏移量和元素尺寸 // 记录偏移量和元素尺寸
dragOffsetRef.current = { dragOffsetRef.current = {
x: clientX - rect.left, x: clientX - rect.left,
y: clientY - rect.top, y: clientY - rect.top,
}; }
setDraggingOption(optionValue); setDraggingOption(optionValue)
setDragPosition({ x: clientX, y: clientY }); setDragPosition({ x: clientX, y: clientY })
}; }
// 处理鼠标按下开始拖拽 // 处理鼠标按下开始拖拽
const handleMouseDown = (e: React.MouseEvent, optionValue: string) => { 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 handleTouchStart = (e: React.TouchEvent, optionValue: string) => {
const touch = e.touches[0]; const touch = e.touches[0]
if (!touch) return; if (!touch) return
// 防止滚动干扰 // 防止滚动干扰
e.preventDefault(); e.preventDefault()
startDragging(optionValue, touch.clientX, touch.clientY, e.currentTarget as HTMLElement); startDragging(optionValue, touch.clientX, touch.clientY, e.currentTarget as HTMLElement)
}; }
// 移除填充 // 移除填充
const handleRemoveFill = (blankId: string) => { const handleRemoveFill = (blankId: string) => {
if (disabled) return; if (disabled) return
const updatedValue = currentValue.filter( const updatedValue = currentValue.filter((result) => result.blankId !== blankId)
(result) => result.blankId !== blankId
);
if (!isControlled) { if (!isControlled) {
setInternalValue(updatedValue); setInternalValue(updatedValue)
}
onChange?.(updatedValue)
} }
onChange?.(updatedValue);
};
// 处理提交 // 处理提交
const handleSubmit = () => { const handleSubmit = () => {
if (disabled) return; if (disabled) return
// 检查必填项 // 检查必填项
const requiredBlanks = normalizedBlanks.filter((blank) => blank.required); const requiredBlanks = normalizedBlanks.filter((blank) => blank.required)
const filledBlankIds = currentValue.map((result) => result.blankId); const filledBlankIds = currentValue.map((result) => result.blankId)
const allRequiredFilled = requiredBlanks.every((blank) => const allRequiredFilled = requiredBlanks.every((blank) => filledBlankIds.includes(blank.id))
filledBlankIds.includes(blank.id)
);
if (!allRequiredFilled) { if (!allRequiredFilled) {
return; return
}
onSubmit?.(currentValue)
} }
onSubmit?.(currentValue);
};
// 获取空白区域的填充值 // 获取空白区域的填充值
const getBlankFill = (blankId: string): string | null => { const getBlankFill = (blankId: string): string | null => {
const result = currentValue.find((result) => result.blankId === blankId); const result = currentValue.find((result) => result.blankId === blankId)
return result ? result.optionValue : null; return result ? result.optionValue : null
}; }
// 默认渲染选项 // 默认渲染选项
const defaultRenderOption = (option: DragFillOption, isUsed: boolean) => { const defaultRenderOption = (option: DragFillOption, isUsed: boolean) => {
const isDragging = draggingOption === option.value; const isDragging = draggingOption === option.value
return ( return (
<div <div
onMouseDown={(e) => handleMouseDown(e, option.value)} onMouseDown={(e) => handleMouseDown(e, option.value)}
onTouchStart={(e) => handleTouchStart(e, option.value)} onTouchStart={(e) => handleTouchStart(e, option.value)}
className={cn( 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 && !disabled &&
!option.disabled && !option.disabled &&
!isUsed && !isUsed &&
"cursor-move hover:border-orange-400 hover:bg-orange-50", 'cursor-move hover:border-orange-400 hover:bg-orange-50',
isDragging isDragging
? "opacity-50 border-orange-500 bg-orange-100" ? 'opacity-50 border-orange-500 bg-orange-100'
: "border-gray-300 bg-white text-gray-700", : 'border-gray-300 bg-white text-gray-700',
(disabled || option.disabled || isUsed) && (disabled || option.disabled || isUsed) && 'opacity-50 cursor-not-allowed',
"opacity-50 cursor-not-allowed",
optionClassName optionClassName
)} )}
> >
@@ -370,57 +340,45 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
)} )}
</div> </div>
</div> </div>
); )
}; }
// 默认渲染空白区域 // 默认渲染空白区域
const defaultRenderBlank = ( const defaultRenderBlank = (blank: DragFillBlank, filledValue: string | null) => {
blank: DragFillBlank, const isHovered = hoveredBlank === blank.id
filledValue: string | null const option = filledValue ? normalizedOptions.find((opt) => opt.value === filledValue) : null
) => {
const isHovered = hoveredBlank === blank.id;
const option = filledValue
? normalizedOptions.find((opt) => opt.value === filledValue)
: null;
return ( return (
<div <div
ref={(el) => { ref={(el) => {
if (el) { if (el) {
blankRefsMap.current.set(blank.id, el); blankRefsMap.current.set(blank.id, el)
} else { } else {
blankRefsMap.current.delete(blank.id); blankRefsMap.current.delete(blank.id)
} }
}} }}
className={cn( className={cn(
"min-h-[80px] px-6 py-4 border-2 border-dashed rounded-lg transition-all", 'min-h-[80px] px-6 py-4 border-2 border-dashed rounded-lg transition-all',
"flex items-center justify-between", 'flex items-center justify-between',
isHovered && draggingOption isHovered && draggingOption
? "border-orange-500 bg-orange-50" ? 'border-orange-500 bg-orange-50'
: filledValue : filledValue
? "border-green-500 bg-green-50" ? 'border-green-500 bg-green-50'
: "border-gray-300 bg-gray-50", : 'border-gray-300 bg-gray-50',
blank.required && !filledValue && "border-red-300", blank.required && !filledValue && 'border-red-300',
blankClassName blankClassName
)} )}
> >
{filledValue ? ( {filledValue ? (
<> <>
<span className="text-green-700 font-medium"> <span className="text-green-700 font-medium">{option?.label || filledValue}</span>
{option?.label || filledValue}
</span>
{!disabled && ( {!disabled && (
<button <button
onClick={() => handleRemoveFill(blank.id)} onClick={() => handleRemoveFill(blank.id)}
className="text-red-500 hover:text-red-700 transition-colors" className="text-red-500 hover:text-red-700 transition-colors"
type="button" type="button"
> >
<svg <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -432,95 +390,84 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
)} )}
</> </>
) : ( ) : (
<span className="text-gray-500"> <span className="text-gray-500">{blank.label || '拖拽选项到这里'}</span>
{blank.label || "拖拽选项到这里"}
</span>
)} )}
</div> </div>
); )
}; }
return ( return (
<> <>
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"flex gap-6 w-full h-full", 'flex gap-6 w-full h-full',
layout === "horizontal" ? "flex-row items-start" : "flex-col", layout === 'horizontal' ? 'flex-row items-start' : 'flex-col',
layout === "horizontal-options" && "flex-col", layout === 'horizontal-options' && 'flex-col',
className className
)} )}
> >
{/* 选项区域 */} {/* 选项区域 */}
<div <div
className={cn( className={cn(
"flex flex-col gap-3", 'flex flex-col gap-3',
layout === "horizontal" && "flex-1", layout === 'horizontal' && 'flex-1',
layout === "horizontal-options" && "w-full flex-1" layout === 'horizontal-options' && 'w-full flex-1'
)} )}
> >
{!hideLabels && ( {!hideLabels && <h3 className="text-lg font-semibold text-gray-700">选项</h3>}
<h3 className="text-lg font-semibold text-gray-700">选项</h3>
)}
<div <div
className={cn( className={cn(
layout === "horizontal-options" layout === 'horizontal-options'
? optionClassName || "flex flex-row gap-6 flex-wrap w-full" ? optionClassName || 'flex flex-row gap-6 flex-wrap w-full'
: cn( : cn(
optionClassName, optionClassName,
!optionClassName && "grid gap-x-12 gap-y-16", !optionClassName && 'grid gap-x-12 gap-y-16',
!optionClassName && normalizedOptions.length > 2 !optionClassName && normalizedOptions.length > 2
? "grid-cols-2" ? 'grid-cols-2'
: !optionClassName && "grid-cols-1" : !optionClassName && 'grid-cols-1'
) )
)} )}
> >
{normalizedOptions.map((option) => { {normalizedOptions.map((option) => {
const isUsed = isOptionUsed(option.value); const isUsed = isOptionUsed(option.value)
const isDragging = draggingOption === option.value; const isDragging = draggingOption === option.value
return ( return (
<div <div
key={option.value} key={option.value}
onMouseDown={(e) => { onMouseDown={(e) => {
// 只有在没有自定义渲染或者不是已使用的情况下才处理拖拽 // 只有在没有自定义渲染或者不是已使用的情况下才处理拖拽
if (!disabled && !option.disabled && !isUsed) { if (!disabled && !option.disabled && !isUsed) {
handleMouseDown(e, option.value); handleMouseDown(e, option.value)
} }
}} }}
onTouchStart={(e) => { onTouchStart={(e) => {
if (!disabled && !option.disabled && !isUsed) { if (!disabled && !option.disabled && !isUsed) {
handleTouchStart(e, option.value); handleTouchStart(e, option.value)
} }
}} }}
className={cn( className={cn(
renderOption && "select-none", renderOption && 'select-none',
!optionClassName && renderOption && "flex-1", !optionClassName && renderOption && 'flex-1',
isDragging && "opacity-50" isDragging && 'opacity-50'
)} )}
> >
{renderOption {renderOption
? renderOption(option, isUsed) ? renderOption(option, isUsed)
: defaultRenderOption(option, isUsed)} : defaultRenderOption(option, isUsed)}
</div> </div>
); )
})} })}
</div> </div>
</div> </div>
{/* 空白区域 */} {/* 空白区域 */}
<div <div className={cn('flex flex-col gap-3', layout === 'horizontal' && 'flex-1')}>
className={cn( {!hideLabels && <h3 className="text-lg font-semibold text-gray-700">填空</h3>}
"flex flex-col gap-3",
layout === "horizontal" && "flex-1"
)}
>
{!hideLabels && (
<h3 className="text-lg font-semibold text-gray-700">填空</h3>
)}
<div className="flex flex-col"> <div className="flex flex-col">
{normalizedBlanks.map((blank) => { {normalizedBlanks.map((blank) => {
const filledValue = getBlankFill(blank.id); const filledValue = getBlankFill(blank.id)
const isHovered = hoveredBlank === blank.id && !!draggingOption; const isHovered = hoveredBlank === blank.id && !!draggingOption
return ( return (
<div <div
key={blank.id} key={blank.id}
@@ -528,14 +475,12 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
if (el) { if (el) {
// 如果使用自定义渲染,需要找到实际的空白区域元素 // 如果使用自定义渲染,需要找到实际的空白区域元素
// 默认使用第一个子元素 // 默认使用第一个子元素
const targetEl = renderBlank const targetEl = renderBlank ? (el.firstElementChild as HTMLElement) : el
? (el.firstElementChild as HTMLElement)
: el;
if (targetEl) { if (targetEl) {
blankRefsMap.current.set(blank.id, targetEl); blankRefsMap.current.set(blank.id, targetEl)
} }
} else { } else {
blankRefsMap.current.delete(blank.id); blankRefsMap.current.delete(blank.id)
} }
}} }}
> >
@@ -543,7 +488,7 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
? renderBlank(blank, filledValue, isHovered) ? renderBlank(blank, filledValue, isHovered)
: defaultRenderBlank(blank, filledValue)} : defaultRenderBlank(blank, filledValue)}
</div> </div>
); )
})} })}
</div> </div>
</div> </div>
@@ -554,10 +499,10 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
onClick={handleSubmit} onClick={handleSubmit}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"px-6 py-3 rounded-lg font-medium transition-colors", 'px-6 py-3 rounded-lg font-medium transition-colors',
"bg-orange-500 text-white hover:bg-orange-600", 'bg-orange-500 text-white hover:bg-orange-600',
"disabled:opacity-50 disabled:cursor-not-allowed", 'disabled:opacity-50 disabled:cursor-not-allowed',
"focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2" 'focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2'
)} )}
> >
{submitText} {submitText}
@@ -574,31 +519,29 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
style={{ style={{
left: dragPosition.x, left: dragPosition.x,
top: dragPosition.y, top: dragPosition.y,
transform: "translate(-50%, -50%)", transform: 'translate(-50%, -50%)',
}} }}
> >
{(() => { {(() => {
const option = normalizedOptions.find( const option = normalizedOptions.find((opt) => opt.value === draggingOption)
(opt) => opt.value === draggingOption if (!option) return null
);
if (!option) return null;
if (renderDragging) { if (renderDragging) {
return renderDragging(option); return renderDragging(option)
} }
return ( return (
<span className="text-[28px] font-bold text-[#f0be75] drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"> <span className="text-[28px] font-bold text-[#f0be75] drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]">
{option.label} {option.label}
</span> </span>
); )
})()} })()}
</div>, </div>,
document.body document.body
)} )}
</> </>
); )
} }
); )
DragFill.displayName = "DragFill"; DragFill.displayName = 'DragFill'
export default DragFill; export default DragFill
@@ -1,73 +1,69 @@
import { ReactNode } from "react"; import { ReactNode } from 'react'
/** 选项数据 */ /** 选项数据 */
export interface DragFillOption { export interface DragFillOption {
/** 选项值 */ /** 选项值 */
value: string; value: string
/** 选项显示文本 */ /** 选项显示文本 */
label: string; label: string
/** 是否禁用 */ /** 是否禁用 */
disabled?: boolean; disabled?: boolean
} }
/** 空白区域数据 */ /** 空白区域数据 */
export interface DragFillBlank { export interface DragFillBlank {
/** 空白区域ID */ /** 空白区域ID */
id: string; id: string
/** 空白区域显示文本/提示 */ /** 空白区域显示文本/提示 */
label?: string; label?: string
/** 是否必填 */ /** 是否必填 */
required?: boolean; required?: boolean
} }
/** 填充结果 */ /** 填充结果 */
export interface DragFillResult { export interface DragFillResult {
/** 空白区域ID */ /** 空白区域ID */
blankId: string; blankId: string
/** 填充的选项值 */ /** 填充的选项值 */
optionValue: string; optionValue: string
} }
/** 拖拽填空组件配置项 */ /** 拖拽填空组件配置项 */
export interface DragFillProps { 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(选项水平排列) */ /** 布局方向: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?: ( renderBlank?: (blank: DragFillBlank, filledValue: string | null, isHovered: boolean) => ReactNode
blank: DragFillBlank,
filledValue: string | null,
isHovered: boolean
) => ReactNode;
/** 自定义渲染拖拽中的元素 */ /** 自定义渲染拖拽中的元素 */
renderDragging?: (option: DragFillOption) => ReactNode; renderDragging?: (option: DragFillOption) => ReactNode
} }
+3 -4
View File
@@ -1,9 +1,8 @@
export { default } from "./drag-fill"; export { default } from './drag-fill'
export { default as DragFill } from "./drag-fill"; export { default as DragFill } from './drag-fill'
export type { export type {
DragFillProps, DragFillProps,
DragFillOption, DragFillOption,
DragFillBlank, DragFillBlank,
DragFillResult, DragFillResult,
} from "./drag-fill.types"; } from './drag-fill.types'
+6 -18
View File
@@ -1,23 +1,11 @@
// Single Choice component // Single Choice component
export { SingleChoice, default as SingleChoiceDefault } from "./single-choice"; export { SingleChoice, default as SingleChoiceDefault } from './single-choice'
export type { export type { SingleChoiceProps, SingleChoiceOption } from './single-choice'
SingleChoiceProps,
SingleChoiceOption,
} from "./single-choice";
// Multiple Choice component // Multiple Choice component
export { MultipleChoice } from "./multiple-choice"; export { MultipleChoice } from './multiple-choice'
export type { export type { MultipleChoiceProps, MultipleChoiceOption } from './multiple-choice'
MultipleChoiceProps,
MultipleChoiceOption,
} from "./multiple-choice";
// Drag Fill component // Drag Fill component
export { DragFill } from "./drag-fill"; export { DragFill } from './drag-fill'
export type { export type { DragFillProps, DragFillOption, DragFillBlank, DragFillResult } from './drag-fill'
DragFillProps,
DragFillOption,
DragFillBlank,
DragFillResult,
} from "./drag-fill";
+3 -7
View File
@@ -1,7 +1,3 @@
export { default } from "./multiple-choice"; export { default } from './multiple-choice'
export { default as MultipleChoice } from "./multiple-choice"; export { default as MultipleChoice } from './multiple-choice'
export type { export type { MultipleChoiceProps, MultipleChoiceOption } from './multiple-choice.types'
MultipleChoiceProps,
MultipleChoiceOption,
} from "./multiple-choice.types";
@@ -1,193 +1,188 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from '@storybook/react'
import { useState } from "react"; import { useState } from 'react'
import MultipleChoice from "./multiple-choice"; import MultipleChoice from './multiple-choice'
import type { MultipleChoiceProps } from "./multiple-choice.types"; import type { MultipleChoiceProps } from './multiple-choice.types'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
const meta: Meta<typeof MultipleChoice> = { const meta: Meta<typeof MultipleChoice> = {
title: "Components/Quiz/MultipleChoice", title: 'Components/Quiz/MultipleChoice',
component: MultipleChoice, component: MultipleChoice,
tags: ["autodocs"], tags: ['autodocs'],
argTypes: { argTypes: {
options: { options: {
control: { type: "object" }, control: { type: 'object' },
description: "选项列表,可以是字符串数组或选项对象数组", description: '选项列表,可以是字符串数组或选项对象数组',
}, },
value: { value: {
control: { type: "object" }, control: { type: 'object' },
description: "当前选中的值数组(受控模式)", description: '当前选中的值数组(受控模式)',
}, },
defaultValue: { defaultValue: {
control: { type: "object" }, control: { type: 'object' },
description: "默认选中的值数组", description: '默认选中的值数组',
}, },
disabled: { disabled: {
control: { type: "boolean" }, control: { type: 'boolean' },
description: "是否禁用整个组件", description: '是否禁用整个组件',
}, },
minSelections: { minSelections: {
control: { type: "number" }, control: { type: 'number' },
description: "最小选择数量", description: '最小选择数量',
}, },
maxSelections: { maxSelections: {
control: { type: "number" }, control: { type: 'number' },
description: "最大选择数量", description: '最大选择数量',
}, },
showSubmitButton: { showSubmitButton: {
control: { type: "boolean" }, control: { type: 'boolean' },
description: "是否显示提交按钮", description: '是否显示提交按钮',
}, },
submitText: { submitText: {
control: { type: "text" }, control: { type: 'text' },
description: "提交按钮文本", description: '提交按钮文本',
}, },
}, },
}; }
export default meta; export default meta
type Story = StoryObj<typeof MultipleChoice>; type Story = StoryObj<typeof MultipleChoice>
// 基础示例 // 基础示例
export const Default: Story = { export const Default: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C", "选项D", "选项E"], options: ['选项A', '选项B', '选项C', '选项D', '选项E'],
onSubmit: (values) => { onSubmit: (values) => {
console.log("选中的值:", values); console.log('选中的值:', values)
}, },
}, },
}; }
// 使用选项对象 // 使用选项对象
export const WithOptionObjects: Story = { export const WithOptionObjects: Story = {
args: { args: {
options: [ options: [
{ value: "opt1", label: "选项一" }, { value: 'opt1', label: '选项一' },
{ value: "opt2", label: "选项二" }, { value: 'opt2', label: '选项二' },
{ value: "opt3", label: "选项三(禁用)", disabled: true }, { value: 'opt3', label: '选项三(禁用)', disabled: true },
{ value: "opt4", label: "选项四" }, { value: 'opt4', label: '选项四' },
{ value: "opt5", label: "选项五" }, { value: 'opt5', label: '选项五' },
], ],
onSubmit: (values) => { onSubmit: (values) => {
console.log("选中的值:", values); console.log('选中的值:', values)
}, },
}, },
}; }
// 受控模式 // 受控模式
export const Controlled: Story = { export const Controlled: Story = {
render: () => { render: () => {
const [values, setValues] = useState<string[]>([]); const [values, setValues] = useState<string[]>([])
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<MultipleChoice <MultipleChoice
options={["选项A", "选项B", "选项C", "选项D"]} options={['选项A', '选项B', '选项C', '选项D']}
value={values} value={values}
onChange={setValues} onChange={setValues}
onSubmit={(vals) => { onSubmit={(vals) => {
alert(`提交的值: ${vals.join(", ")}`); alert(`提交的值: ${vals.join(', ')}`)
}} }}
/> />
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600">
当前选中: {values.length > 0 ? values.join(", ") : "未选择"} 当前选中: {values.length > 0 ? values.join(', ') : '未选择'}
</div> </div>
</div> </div>
); )
}, },
}; }
// 限制最大选择数量 // 限制最大选择数量
export const WithMaxSelections: Story = { export const WithMaxSelections: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C", "选项D", "选项E"], options: ['选项A', '选项B', '选项C', '选项D', '选项E'],
maxSelections: 2, maxSelections: 2,
onSubmit: (values) => { onSubmit: (values) => {
console.log("选中的值:", values); console.log('选中的值:', values)
}, },
}, },
}; }
// 限制最小和最大选择数量 // 限制最小和最大选择数量
export const WithMinMaxSelections: Story = { export const WithMinMaxSelections: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C", "选项D", "选项E"], options: ['选项A', '选项B', '选项C', '选项D', '选项E'],
minSelections: 2, minSelections: 2,
maxSelections: 4, maxSelections: 4,
onSubmit: (values) => { onSubmit: (values) => {
console.log("选中的值:", values); console.log('选中的值:', values)
}, },
}, },
}; }
// 禁用状态 // 禁用状态
export const Disabled: Story = { export const Disabled: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C"], options: ['选项A', '选项B', '选项C'],
disabled: true, disabled: true,
defaultValue: ["选项A"], defaultValue: ['选项A'],
}, },
}; }
// 不显示提交按钮 // 不显示提交按钮
export const WithoutSubmitButton: Story = { export const WithoutSubmitButton: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C"], options: ['选项A', '选项B', '选项C'],
showSubmitButton: false, showSubmitButton: false,
onChange: (values) => { onChange: (values) => {
console.log("值变化:", values); console.log('值变化:', values)
}, },
}, },
}; }
// 自定义样式 // 自定义样式
export const CustomStyled: Story = { export const CustomStyled: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C", "选项D"], options: ['选项A', '选项B', '选项C', '选项D'],
className: "max-w-md mx-auto p-6 bg-gray-50 rounded-xl", className: 'max-w-md mx-auto p-6 bg-gray-50 rounded-xl',
optionClassName: "text-lg font-semibold", optionClassName: 'text-lg font-semibold',
submitText: "确认答案", submitText: '确认答案',
maxSelections: 3, maxSelections: 3,
onSubmit: (values) => { onSubmit: (values) => {
console.log("选中的值:", values); console.log('选中的值:', values)
}, },
}, },
}; }
// 自定义渲染选项 // 自定义渲染选项
export const CustomRender: Story = { export const CustomRender: Story = {
render: () => { render: () => {
return ( return (
<MultipleChoice <MultipleChoice
options={["选项A", "选项B", "选项C", "选项D"]} options={['选项A', '选项B', '选项C', '选项D']}
maxSelections={2} maxSelections={2}
renderOption={(option, isSelected) => ( renderOption={(option, isSelected) => (
<div <div
className={cn( className={cn(
"p-4 rounded-lg border-2 transition-all cursor-pointer", 'p-4 rounded-lg border-2 transition-all cursor-pointer',
isSelected isSelected ? 'border-purple-500 bg-purple-100' : 'border-gray-200 bg-white'
? "border-purple-500 bg-purple-100"
: "border-gray-200 bg-white"
)} )}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
className={cn( className={cn(
"w-6 h-6 rounded border-2 flex items-center justify-center text-lg", 'w-6 h-6 rounded border-2 flex items-center justify-center text-lg',
isSelected isSelected ? 'border-purple-500 bg-purple-500 text-white' : 'border-gray-300'
? "border-purple-500 bg-purple-500 text-white"
: "border-gray-300"
)} )}
> >
{isSelected && "✓"} {isSelected && '✓'}
</div> </div>
<span className="font-medium">{option.label}</span> <span className="font-medium">{option.label}</span>
</div> </div>
</div> </div>
)} )}
onSubmit={(values) => { onSubmit={(values) => {
console.log("选中的值:", values); console.log('选中的值:', values)
}} }}
/> />
); )
}, },
}; }
@@ -1,9 +1,6 @@
import { useState, useEffect, forwardRef } from "react"; import { useState, useEffect, forwardRef } from 'react'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
import type { import type { MultipleChoiceProps, MultipleChoiceOption } from './multiple-choice.types'
MultipleChoiceProps,
MultipleChoiceOption,
} from "./multiple-choice.types";
/** /**
* MultipleChoice - 多选组件 * MultipleChoice - 多选组件
@@ -32,7 +29,7 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
maxSelections, maxSelections,
className, className,
optionClassName, optionClassName,
submitText = "提交", submitText = '提交',
showSubmitButton = true, showSubmitButton = true,
renderOption, renderOption,
}, },
@@ -40,96 +37,79 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
) => { ) => {
// 标准化选项数据 // 标准化选项数据
const normalizedOptions: MultipleChoiceOption[] = options.map((option) => { const normalizedOptions: MultipleChoiceOption[] = options.map((option) => {
if (typeof option === "string") { if (typeof option === 'string') {
return { value: option, label: option }; return { value: option, label: option }
} }
return option; return option
}); })
// 受控/非受控模式 // 受控/非受控模式
const isControlled = controlledValue !== undefined; const isControlled = controlledValue !== undefined
const [internalValue, setInternalValue] = useState<string[]>( const [internalValue, setInternalValue] = useState<string[]>(defaultValue || [])
defaultValue || [] const currentValue = isControlled ? controlledValue : internalValue
);
const currentValue = isControlled ? controlledValue : internalValue;
// 处理选项点击 // 处理选项点击
const handleOptionClick = (optionValue: string) => { const handleOptionClick = (optionValue: string) => {
if (disabled) return; if (disabled) return
const option = normalizedOptions.find((opt) => opt.value === optionValue); const option = normalizedOptions.find((opt) => opt.value === optionValue)
if (option?.disabled) return; if (option?.disabled) return
const isSelected = currentValue.includes(optionValue); const isSelected = currentValue.includes(optionValue)
let newValue: string[]; let newValue: string[]
if (isSelected) { if (isSelected) {
// 取消选择 // 取消选择
newValue = currentValue.filter((v) => v !== optionValue); newValue = currentValue.filter((v) => v !== optionValue)
// 检查最小选择数量限制 // 检查最小选择数量限制
if ( if (minSelections !== undefined && newValue.length < minSelections) {
minSelections !== undefined && return
newValue.length < minSelections
) {
return;
} }
} else { } else {
// 选择 // 选择
// 检查最大选择数量限制 // 检查最大选择数量限制
if ( if (maxSelections !== undefined && currentValue.length >= maxSelections) {
maxSelections !== undefined && return
currentValue.length >= maxSelections
) {
return;
} }
newValue = [...currentValue, optionValue]; newValue = [...currentValue, optionValue]
} }
if (!isControlled) { if (!isControlled) {
setInternalValue(newValue); setInternalValue(newValue)
}
onChange?.(newValue)
} }
onChange?.(newValue);
};
// 处理提交 // 处理提交
const handleSubmit = () => { const handleSubmit = () => {
if (disabled) return; if (disabled) return
// 检查最小选择数量 // 检查最小选择数量
if ( if (minSelections !== undefined && currentValue.length < minSelections) {
minSelections !== undefined && return
currentValue.length < minSelections }
) { onSubmit?.(currentValue)
return;
} }
onSubmit?.(currentValue);
};
// 检查是否可以提交 // 检查是否可以提交
const canSubmit = const canSubmit =
currentValue.length > 0 && currentValue.length > 0 &&
(minSelections === undefined || (minSelections === undefined || currentValue.length >= minSelections)
currentValue.length >= minSelections);
// 默认渲染选项 // 默认渲染选项
const defaultRenderOption = ( const defaultRenderOption = (option: MultipleChoiceOption, isSelected: boolean) => {
option: MultipleChoiceOption,
isSelected: boolean
) => {
const isDisabled = const isDisabled =
disabled || disabled ||
option.disabled || option.disabled ||
(!isSelected && (!isSelected && maxSelections !== undefined && currentValue.length >= maxSelections)
maxSelections !== undefined &&
currentValue.length >= maxSelections);
return ( return (
<div <div
className={cn( className={cn(
"px-6 py-4 border-2 rounded-lg cursor-pointer transition-all", 'px-6 py-4 border-2 rounded-lg cursor-pointer transition-all',
"hover:border-green-400 hover:bg-green-50", 'hover:border-green-400 hover:bg-green-50',
isSelected isSelected
? "border-green-500 bg-green-100 text-green-700" ? 'border-green-500 bg-green-100 text-green-700'
: "border-gray-300 bg-white text-gray-700", : 'border-gray-300 bg-white text-gray-700',
isDisabled && "opacity-50 cursor-not-allowed", isDisabled && 'opacity-50 cursor-not-allowed',
optionClassName optionClassName
)} )}
onClick={() => handleOptionClick(option.value)} onClick={() => handleOptionClick(option.value)}
@@ -137,10 +117,8 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
className={cn( className={cn(
"w-5 h-5 rounded border-2 flex items-center justify-center transition-colors", 'w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
isSelected isSelected ? 'border-green-500 bg-green-500' : 'border-gray-300 bg-white'
? "border-green-500 bg-green-500"
: "border-gray-300 bg-white"
)} )}
> >
{isSelected && ( {isSelected && (
@@ -162,21 +140,21 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
<span>{option.label}</span> <span>{option.label}</span>
</div> </div>
</div> </div>
); )
}; }
return ( return (
<div ref={ref} className={cn("flex flex-col gap-4", className)}> <div ref={ref} className={cn('flex flex-col gap-4', className)}>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{normalizedOptions.map((option) => { {normalizedOptions.map((option) => {
const isSelected = currentValue.includes(option.value); const isSelected = currentValue.includes(option.value)
return ( return (
<div key={option.value}> <div key={option.value}>
{renderOption {renderOption
? renderOption(option, isSelected) ? renderOption(option, isSelected)
: defaultRenderOption(option, isSelected)} : defaultRenderOption(option, isSelected)}
</div> </div>
); )
})} })}
</div> </div>
@@ -185,27 +163,22 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
{minSelections !== undefined && ( {minSelections !== undefined && (
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
至少选择 {minSelections} 项 至少选择 {minSelections} 项
{maxSelections !== undefined && {maxSelections !== undefined && `,最多选择 ${maxSelections} 项`}
`,最多选择 ${maxSelections} 项`} {maxSelections === undefined && '(无上限)'}
{maxSelections === undefined && "(无上限)"}
</div> </div>
)} )}
{maxSelections !== undefined && minSelections === undefined && ( {maxSelections !== undefined && minSelections === undefined && (
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">最多选择 {maxSelections} 项</div>
最多选择 {maxSelections} 项
</div>
)} )}
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">已选择 {currentValue.length} 项</div>
已选择 {currentValue.length} 项
</div>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={disabled || !canSubmit} disabled={disabled || !canSubmit}
className={cn( className={cn(
"px-6 py-3 rounded-lg font-medium transition-colors", 'px-6 py-3 rounded-lg font-medium transition-colors',
"bg-green-500 text-white hover:bg-green-600", 'bg-green-500 text-white hover:bg-green-600',
"disabled:opacity-50 disabled:cursor-not-allowed", 'disabled:opacity-50 disabled:cursor-not-allowed',
"focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2" 'focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2'
)} )}
> >
{submitText} {submitText}
@@ -213,11 +186,10 @@ const MultipleChoice = forwardRef<HTMLDivElement, MultipleChoiceProps>(
</div> </div>
)} )}
</div> </div>
); )
} }
); )
MultipleChoice.displayName = "MultipleChoice"; MultipleChoice.displayName = 'MultipleChoice'
export default MultipleChoice;
export default MultipleChoice
@@ -1,45 +1,41 @@
import { ReactNode } from "react"; import { ReactNode } from 'react'
/** 选项数据 */ /** 选项数据 */
export interface MultipleChoiceOption { export interface MultipleChoiceOption {
/** 选项值 */ /** 选项值 */
value: string; value: string
/** 选项显示文本 */ /** 选项显示文本 */
label: string; label: string
/** 是否禁用 */ /** 是否禁用 */
disabled?: boolean; disabled?: boolean
} }
/** 多选组件配置项 */ /** 多选组件配置项 */
export interface MultipleChoiceProps { 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?: ( renderOption?: (option: MultipleChoiceOption, isSelected: boolean) => ReactNode
option: MultipleChoiceOption,
isSelected: boolean
) => ReactNode;
} }
+3 -7
View File
@@ -1,7 +1,3 @@
export { default } from "./single-choice"; export { default } from './single-choice'
export { default as SingleChoice } from "./single-choice"; export { default as SingleChoice } from './single-choice'
export type { export type { SingleChoiceProps, SingleChoiceOption } from './single-choice.types'
SingleChoiceProps,
SingleChoiceOption,
} from "./single-choice.types";
@@ -1,161 +1,152 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from '@storybook/react'
import { useState } from "react"; import { useState } from 'react'
import SingleChoice from "./single-choice"; import SingleChoice from './single-choice'
import type { SingleChoiceProps } from "./single-choice.types"; import type { SingleChoiceProps } from './single-choice.types'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
const meta: Meta<typeof SingleChoice> = { const meta: Meta<typeof SingleChoice> = {
title: "Components/Quiz/SingleChoice", title: 'Components/Quiz/SingleChoice',
component: SingleChoice, component: SingleChoice,
tags: ["autodocs"], tags: ['autodocs'],
argTypes: { argTypes: {
options: { options: {
control: { type: "object" }, control: { type: 'object' },
description: "选项列表,可以是字符串数组或选项对象数组", description: '选项列表,可以是字符串数组或选项对象数组',
}, },
value: { value: {
control: { type: "text" }, control: { type: 'text' },
description: "当前选中的值(受控模式)", description: '当前选中的值(受控模式)',
}, },
defaultValue: { defaultValue: {
control: { type: "text" }, control: { type: 'text' },
description: "默认选中的值", description: '默认选中的值',
}, },
disabled: { disabled: {
control: { type: "boolean" }, control: { type: 'boolean' },
description: "是否禁用整个组件", description: '是否禁用整个组件',
}, },
showSubmitButton: { showSubmitButton: {
control: { type: "boolean" }, control: { type: 'boolean' },
description: "是否显示提交按钮", description: '是否显示提交按钮',
}, },
submitText: { submitText: {
control: { type: "text" }, control: { type: 'text' },
description: "提交按钮文本", description: '提交按钮文本',
}, },
}, },
}; }
export default meta; export default meta
type Story = StoryObj<typeof SingleChoice>; type Story = StoryObj<typeof SingleChoice>
// 基础示例 // 基础示例
export const Default: Story = { export const Default: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C", "选项D"], options: ['选项A', '选项B', '选项C', '选项D'],
onSubmit: (value) => { onSubmit: (value) => {
console.log("选中的值:", value); console.log('选中的值:', value)
}, },
}, },
}; }
// 使用选项对象 // 使用选项对象
export const WithOptionObjects: Story = { export const WithOptionObjects: Story = {
args: { args: {
options: [ options: [
{ value: "opt1", label: "选项一" }, { value: 'opt1', label: '选项一' },
{ value: "opt2", label: "选项二" }, { value: 'opt2', label: '选项二' },
{ value: "opt3", label: "选项三(禁用)", disabled: true }, { value: 'opt3', label: '选项三(禁用)', disabled: true },
{ value: "opt4", label: "选项四" }, { value: 'opt4', label: '选项四' },
], ],
onSubmit: (value) => { onSubmit: (value) => {
console.log("选中的值:", value); console.log('选中的值:', value)
}, },
}, },
}; }
// 受控模式 // 受控模式
export const Controlled: Story = { export const Controlled: Story = {
render: () => { render: () => {
const [value, setValue] = useState<string>(""); const [value, setValue] = useState<string>('')
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SingleChoice <SingleChoice
options={["选项A", "选项B", "选项C"]} options={['选项A', '选项B', '选项C']}
value={value} value={value}
onChange={setValue} onChange={setValue}
onSubmit={(val) => { onSubmit={(val) => {
alert(`提交的值: ${val}`); alert(`提交的值: ${val}`)
}} }}
/> />
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600">当前选中: {value || '未选择'}</div>
当前选中: {value || "未选择"}
</div> </div>
</div> )
);
}, },
}; }
// 禁用状态 // 禁用状态
export const Disabled: Story = { export const Disabled: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C"], options: ['选项A', '选项B', '选项C'],
disabled: true, disabled: true,
defaultValue: "选项A", defaultValue: '选项A',
}, },
}; }
// 不显示提交按钮 // 不显示提交按钮
export const WithoutSubmitButton: Story = { export const WithoutSubmitButton: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C"], options: ['选项A', '选项B', '选项C'],
showSubmitButton: false, showSubmitButton: false,
onChange: (value) => { onChange: (value) => {
console.log("值变化:", value); console.log('值变化:', value)
}, },
}, },
}; }
// 自定义样式 // 自定义样式
export const CustomStyled: Story = { export const CustomStyled: Story = {
args: { args: {
options: ["选项A", "选项B", "选项C"], options: ['选项A', '选项B', '选项C'],
className: "max-w-md mx-auto p-6 bg-gray-50 rounded-xl", className: 'max-w-md mx-auto p-6 bg-gray-50 rounded-xl',
optionClassName: "text-lg font-semibold", optionClassName: 'text-lg font-semibold',
submitText: "确认答案", submitText: '确认答案',
onSubmit: (value) => { onSubmit: (value) => {
console.log("选中的值:", value); console.log('选中的值:', value)
}, },
}, },
}; }
// 自定义渲染选项 // 自定义渲染选项
export const CustomRender: Story = { export const CustomRender: Story = {
render: () => { render: () => {
return ( return (
<SingleChoice <SingleChoice
options={["选项A", "选项B", "选项C"]} options={['选项A', '选项B', '选项C']}
renderOption={(option, isSelected) => ( renderOption={(option, isSelected) => (
<div <div
className={cn( className={cn(
"p-4 rounded-lg border-2 transition-all cursor-pointer", 'p-4 rounded-lg border-2 transition-all cursor-pointer',
isSelected isSelected ? 'border-purple-500 bg-purple-100' : 'border-gray-200 bg-white'
? "border-purple-500 bg-purple-100"
: "border-gray-200 bg-white"
)} )}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
className={cn( className={cn(
"w-5 h-5 rounded-full border-2 flex items-center justify-center", 'w-5 h-5 rounded-full border-2 flex items-center justify-center',
isSelected isSelected ? 'border-purple-500 bg-purple-500' : 'border-gray-300'
? "border-purple-500 bg-purple-500"
: "border-gray-300"
)} )}
> >
{isSelected && ( {isSelected && <div className="w-2 h-2 rounded-full bg-white" />}
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div> </div>
<span className="font-medium">{option.label}</span> <span className="font-medium">{option.label}</span>
</div> </div>
</div> </div>
)} )}
onSubmit={(value) => { onSubmit={(value) => {
console.log("选中的值:", value); console.log('选中的值:', value)
}} }}
/> />
); )
}, },
}; }
@@ -1,9 +1,6 @@
import { useState, forwardRef } from "react"; import { useState, forwardRef } from 'react'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
import type { import type { SingleChoiceProps, SingleChoiceOption } from './single-choice.types'
SingleChoiceProps,
SingleChoiceOption,
} from "./single-choice.types";
/** /**
* SingleChoice - 单选组件 * SingleChoice - 单选组件
@@ -29,7 +26,7 @@ const SingleChoice = forwardRef<HTMLDivElement, SingleChoiceProps>(
disabled = false, disabled = false,
className, className,
optionClassName, optionClassName,
submitText: _submitText = "提交", // 保留入参但不使用 submitText: _submitText = '提交', // 保留入参但不使用
showSubmitButton: _showSubmitButton = false, // 保留入参但不使用 showSubmitButton: _showSubmitButton = false, // 保留入参但不使用
renderOption, renderOption,
}, },
@@ -37,80 +34,73 @@ const SingleChoice = forwardRef<HTMLDivElement, SingleChoiceProps>(
) => { ) => {
// 标准化选项数据 // 标准化选项数据
const normalizedOptions: SingleChoiceOption[] = options.map((option) => { const normalizedOptions: SingleChoiceOption[] = options.map((option) => {
if (typeof option === "string") { if (typeof option === 'string') {
return { value: option, label: option }; return { value: option, label: option }
} }
return option; return option
}); })
// 受控/非受控模式 // 受控/非受控模式
const isControlled = controlledValue !== undefined; const isControlled = controlledValue !== undefined
const [internalValue, setInternalValue] = useState<string>( const [internalValue, setInternalValue] = useState<string>(defaultValue || '')
defaultValue || "" const currentValue = isControlled ? controlledValue : internalValue
);
const currentValue = isControlled ? controlledValue : internalValue;
// 处理选项点击 // 处理选项点击
const handleOptionClick = (optionValue: string) => { const handleOptionClick = (optionValue: string) => {
if (disabled) return; if (disabled) return
if ( if (normalizedOptions.find((opt) => opt.value === optionValue)?.disabled) {
normalizedOptions.find((opt) => opt.value === optionValue)?.disabled return
) {
return;
} }
if (!isControlled) { if (!isControlled) {
setInternalValue(optionValue); setInternalValue(optionValue)
} }
onChange?.(optionValue); onChange?.(optionValue)
// 单选题:点击即触发提交回调 // 单选题:点击即触发提交回调
onSubmit?.(optionValue); onSubmit?.(optionValue)
}; }
// 默认渲染选项 // 默认渲染选项
const defaultRenderOption = ( const defaultRenderOption = (option: SingleChoiceOption, isSelected: boolean) => {
option: SingleChoiceOption,
isSelected: boolean
) => {
return ( return (
<div <div
className={cn( className={cn(
"px-6 py-4 border-2 rounded-lg cursor-pointer transition-all", 'px-6 py-4 border-2 rounded-lg cursor-pointer transition-all',
"hover:border-blue-400 hover:bg-blue-50", 'hover:border-blue-400 hover:bg-blue-50',
isSelected isSelected
? "border-blue-500 bg-blue-100 text-blue-700" ? 'border-blue-500 bg-blue-100 text-blue-700'
: "border-gray-300 bg-white text-gray-700", : 'border-gray-300 bg-white text-gray-700',
option.disabled && "opacity-50 cursor-not-allowed", option.disabled && 'opacity-50 cursor-not-allowed',
optionClassName optionClassName
)} )}
onClick={() => handleOptionClick(option.value)} onClick={() => handleOptionClick(option.value)}
> >
{option.label} {option.label}
</div> </div>
); )
}; }
return ( return (
<div ref={ref} className={cn("flex flex-col gap-4", className)}> <div ref={ref} className={cn('flex flex-col gap-4', className)}>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{normalizedOptions.map((option) => { {normalizedOptions.map((option) => {
const isSelected = currentValue === option.value; const isSelected = currentValue === option.value
return ( return (
<div key={option.value}> <div key={option.value}>
{renderOption {renderOption
? renderOption(option, isSelected) ? renderOption(option, isSelected)
: defaultRenderOption(option, isSelected)} : defaultRenderOption(option, isSelected)}
</div> </div>
); )
})} })}
</div> </div>
{/* 提交按钮移除:点击选项即提交 */} {/* 提交按钮移除:点击选项即提交 */}
</div> </div>
); )
} }
); )
SingleChoice.displayName = "SingleChoice"; SingleChoice.displayName = 'SingleChoice'
export default SingleChoice; export default SingleChoice
@@ -1,38 +1,37 @@
import { ReactNode } from "react"; import { ReactNode } from 'react'
/** 选项数据 */ /** 选项数据 */
export interface SingleChoiceOption { export interface SingleChoiceOption {
/** 选项值 */ /** 选项值 */
value: string; value: string
/** 选项显示文本 */ /** 选项显示文本 */
label: string; label: string
/** 是否禁用 */ /** 是否禁用 */
disabled?: boolean; disabled?: boolean
} }
/** 单选组件配置项 */ /** 单选组件配置项 */
export interface SingleChoiceProps { export interface SingleChoiceProps {
/** 选项列表 */ /** 选项列表 */
options: SingleChoiceOption[] | string[]; options: SingleChoiceOption[] | string[]
/** 当前选中的值 */ /** 当前选中的值 */
value?: string; value?: string
/** 默认选中的值 */ /** 默认选中的值 */
defaultValue?: string; defaultValue?: string
/** 选中值变化时的回调 */ /** 选中值变化时的回调 */
onChange?: (value: string) => void; onChange?: (value: string) => void
/** 提交时的回调 */ /** 提交时的回调 */
onSubmit?: (value: string) => void; onSubmit?: (value: string) => void
/** 是否禁用整个组件 */ /** 是否禁用整个组件 */
disabled?: boolean; disabled?: boolean
/** 自定义类名 */ /** 自定义类名 */
className?: string; className?: string
/** 选项容器的自定义类名 */ /** 选项容器的自定义类名 */
optionClassName?: string; optionClassName?: string
/** 提交按钮文本 */ /** 提交按钮文本 */
submitText?: string; submitText?: string
/** 是否显示提交按钮 */ /** 是否显示提交按钮 */
showSubmitButton?: boolean; showSubmitButton?: boolean
/** 自定义渲染选项 */ /** 自定义渲染选项 */
renderOption?: (option: SingleChoiceOption, isSelected: boolean) => ReactNode; renderOption?: (option: SingleChoiceOption, isSelected: boolean) => ReactNode
} }
+3 -4
View File
@@ -1,4 +1,3 @@
export { default } from "./select"; export { default } from './select'
export { default as Select } from "./select"; export { default as Select } from './select'
export type { SelectProps, SelectOption } from "./select.types"; export type { SelectProps, SelectOption } from './select.types'
+132 -148
View File
@@ -1,7 +1,7 @@
import { forwardRef, useState, useRef, useEffect, useCallback } from "react"; import { forwardRef, useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
import type { SelectProps } from "./select.types"; import type { SelectProps } from './select.types'
/** /**
* Select - 下拉选择组件 * Select - 下拉选择组件
@@ -26,7 +26,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
options, options,
value, value,
onChange, onChange,
placeholder = "请选择", placeholder = '请选择',
disabled = false, disabled = false,
className, className,
error = false, error = false,
@@ -37,129 +37,120 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
}, },
ref ref
) => { ) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false)
const [focusedIndex, setFocusedIndex] = useState(-1); const [focusedIndex, setFocusedIndex] = useState(-1)
const triggerRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLSpanElement>(null); const measureRef = useRef<HTMLSpanElement>(null)
const [dropdownPosition, setDropdownPosition] = useState<{ const [dropdownPosition, setDropdownPosition] = useState<{
top: number; top: number
left: number; left: number
width: number; width: number
} | null>(null); } | null>(null)
const [contentWidth, setContentWidth] = useState<number | null>(null); const [contentWidth, setContentWidth] = useState<number | null>(null)
// 获取当前选中的选项 // 获取当前选中的选项
const selectedOption = options.find((opt) => opt.value === value); const selectedOption = options.find((opt) => opt.value === value)
// 计算内容宽度(用于 fitContent) // 计算内容宽度(用于 fitContent)
const calculateContentWidth = useCallback(() => { 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"); const tempElement = document.createElement('span')
tempElement.style.visibility = "hidden"; tempElement.style.visibility = 'hidden'
tempElement.style.position = "absolute"; tempElement.style.position = 'absolute'
tempElement.style.whiteSpace = "nowrap"; tempElement.style.whiteSpace = 'nowrap'
tempElement.style.fontSize = triggerStyles.fontSize; tempElement.style.fontSize = triggerStyles.fontSize
tempElement.style.fontFamily = triggerStyles.fontFamily; tempElement.style.fontFamily = triggerStyles.fontFamily
tempElement.style.fontWeight = triggerStyles.fontWeight; tempElement.style.fontWeight = triggerStyles.fontWeight
tempElement.style.padding = "8px 12px"; // 与选项 padding 一致 (py-2 px-3) tempElement.style.padding = '8px 12px' // 与选项 padding 一致 (py-2 px-3)
document.body.appendChild(tempElement); 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) => { textsToMeasure.forEach((text) => {
tempElement.textContent = text; tempElement.textContent = text
const width = tempElement.offsetWidth; const width = tempElement.offsetWidth
if (width > calculatedWidth) { if (width > calculatedWidth) {
calculatedWidth = width; calculatedWidth = width
} }
}); })
document.body.removeChild(tempElement); document.body.removeChild(tempElement)
// 应用最小和最大宽度限制 // 应用最小和最大宽度限制
if (minWidth !== undefined && calculatedWidth < minWidth) { if (minWidth !== undefined && calculatedWidth < minWidth) {
calculatedWidth = minWidth; calculatedWidth = minWidth
} }
if (maxWidth !== undefined && calculatedWidth > maxWidth) { if (maxWidth !== undefined && calculatedWidth > maxWidth) {
calculatedWidth = maxWidth; calculatedWidth = maxWidth
} }
setContentWidth(calculatedWidth); setContentWidth(calculatedWidth)
}, [fitContent, options, placeholder, minWidth, maxWidth]); }, [fitContent, options, placeholder, minWidth, maxWidth])
// 计算下拉菜单位置 // 计算下拉菜单位置
const updateDropdownPosition = useCallback(() => { const updateDropdownPosition = useCallback(() => {
if (!triggerRef.current) return; if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect(); const rect = triggerRef.current.getBoundingClientRect()
let width = rect.width; let width = rect.width
// 如果启用 fitContent,使用计算出的内容宽度 // 如果启用 fitContent,使用计算出的内容宽度
if (fitContent && contentWidth !== null) { if (fitContent && contentWidth !== null) {
// width = contentWidth; // width = contentWidth;
width = 0; width = 0
} }
setDropdownPosition({ setDropdownPosition({
top: rect.bottom + window.scrollY, top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX, left: rect.left + window.scrollX,
width: width, width: width,
}); })
}, [fitContent, contentWidth]); }, [fitContent, contentWidth])
// 打开下拉菜单 // 打开下拉菜单
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
if (disabled) return; if (disabled) return
// 如果启用 fitContent,先计算内容宽度 // 如果启用 fitContent,先计算内容宽度
if (fitContent) { if (fitContent) {
calculateContentWidth(); calculateContentWidth()
} }
// 使用 setTimeout 确保内容宽度计算完成后再更新位置 // 使用 setTimeout 确保内容宽度计算完成后再更新位置
setTimeout(() => { setTimeout(() => {
updateDropdownPosition(); updateDropdownPosition()
setIsOpen(true); setIsOpen(true)
setFocusedIndex( setFocusedIndex(value ? options.findIndex((opt) => opt.value === value) : -1)
value ? options.findIndex((opt) => opt.value === value) : -1 }, 0)
); }, [disabled, fitContent, calculateContentWidth, updateDropdownPosition, value, options])
}, 0);
}, [
disabled,
fitContent,
calculateContentWidth,
updateDropdownPosition,
value,
options,
]);
// 关闭下拉菜单 // 关闭下拉菜单
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setIsOpen(false); setIsOpen(false)
setFocusedIndex(-1); setFocusedIndex(-1)
}, []); }, [])
// 选择选项 // 选择选项
const handleSelect = useCallback( const handleSelect = useCallback(
(optionValue: string) => { (optionValue: string) => {
if (onChange) { if (onChange) {
onChange(optionValue); onChange(optionValue)
} }
handleClose(); handleClose()
}, },
[onChange, handleClose] [onChange, handleClose]
); )
// 点击外部关闭 // 点击外部关闭
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if ( if (
@@ -168,98 +159,94 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
dropdownRef.current && dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) !dropdownRef.current.contains(event.target as Node)
) { ) {
handleClose(); handleClose()
}
} }
};
const handleScroll = () => { const handleScroll = () => {
updateDropdownPosition(); updateDropdownPosition()
}; }
const handleResize = () => { const handleResize = () => {
updateDropdownPosition(); updateDropdownPosition()
}; }
document.addEventListener("mousedown", handleClickOutside); document.addEventListener('mousedown', handleClickOutside)
window.addEventListener("scroll", handleScroll, true); window.addEventListener('scroll', handleScroll, true)
window.addEventListener("resize", handleResize); window.addEventListener('resize', handleResize)
return () => { return () => {
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener('mousedown', handleClickOutside)
window.removeEventListener("scroll", handleScroll, true); window.removeEventListener('scroll', handleScroll, true)
window.removeEventListener("resize", handleResize); window.removeEventListener('resize', handleResize)
}; }
}, [isOpen, handleClose, updateDropdownPosition]); }, [isOpen, handleClose, updateDropdownPosition])
// 键盘导航 // 键盘导航
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
const enabledIndices = options const enabledIndices = options
.map((opt, idx) => (!opt.disabled ? idx : -1)) .map((opt, idx) => (!opt.disabled ? idx : -1))
.filter((idx) => idx !== -1); .filter((idx) => idx !== -1)
switch (event.key) { switch (event.key) {
case "ArrowDown": case 'ArrowDown':
event.preventDefault(); event.preventDefault()
setFocusedIndex((prev) => { setFocusedIndex((prev) => {
const currentEnabledIdx = enabledIndices.indexOf(prev); const currentEnabledIdx = enabledIndices.indexOf(prev)
const nextEnabledIdx = const nextEnabledIdx =
currentEnabledIdx < enabledIndices.length - 1 currentEnabledIdx < enabledIndices.length - 1 ? currentEnabledIdx + 1 : 0
? currentEnabledIdx + 1 return enabledIndices[nextEnabledIdx] ?? enabledIndices[0] ?? -1
: 0; })
return enabledIndices[nextEnabledIdx] ?? enabledIndices[0] ?? -1; break
}); case 'ArrowUp':
break; event.preventDefault()
case "ArrowUp":
event.preventDefault();
setFocusedIndex((prev) => { setFocusedIndex((prev) => {
const currentEnabledIdx = enabledIndices.indexOf(prev); const currentEnabledIdx = enabledIndices.indexOf(prev)
const nextEnabledIdx = const nextEnabledIdx =
currentEnabledIdx > 0 currentEnabledIdx > 0 ? currentEnabledIdx - 1 : enabledIndices.length - 1
? currentEnabledIdx - 1 return enabledIndices[nextEnabledIdx] ?? enabledIndices[0] ?? -1
: enabledIndices.length - 1; })
return enabledIndices[nextEnabledIdx] ?? enabledIndices[0] ?? -1; break
}); case 'Enter':
break; case ' ':
case "Enter": event.preventDefault()
case " ":
event.preventDefault();
if (focusedIndex >= 0 && focusedIndex < options.length) { if (focusedIndex >= 0 && focusedIndex < options.length) {
const option = options[focusedIndex]; const option = options[focusedIndex]
if (option && !option.disabled) { if (option && !option.disabled) {
handleSelect(option.value); handleSelect(option.value)
} }
} }
break; break
case "Escape": case 'Escape':
event.preventDefault(); event.preventDefault()
handleClose(); handleClose()
break; break
}
} }
};
document.addEventListener("keydown", handleKeyDown); document.addEventListener('keydown', handleKeyDown)
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener('keydown', handleKeyDown)
}; }
}, [isOpen, focusedIndex, options, handleSelect, handleClose]); }, [isOpen, focusedIndex, options, handleSelect, handleClose])
// 滚动到选中项 // 滚动到选中项
useEffect(() => { useEffect(() => {
if (isOpen && focusedIndex >= 0 && dropdownRef.current) { if (isOpen && focusedIndex >= 0 && dropdownRef.current) {
const optionElement = dropdownRef.current.querySelector( const optionElement = dropdownRef.current.querySelector(
`[data-option-index="${focusedIndex}"]` `[data-option-index="${focusedIndex}"]`
); )
if (optionElement) { if (optionElement) {
optionElement.scrollIntoView({ optionElement.scrollIntoView({
block: "nearest", block: 'nearest',
behavior: "smooth", behavior: 'smooth',
}); })
} }
} }
}, [isOpen, focusedIndex]); }, [isOpen, focusedIndex])
return ( return (
<> <>
@@ -268,9 +255,9 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
<span <span
ref={measureRef} ref={measureRef}
style={{ style={{
position: "absolute", position: 'absolute',
visibility: "hidden", visibility: 'hidden',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
}} }}
aria-hidden="true" aria-hidden="true"
/> />
@@ -279,17 +266,17 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
{/* 触发器 */} {/* 触发器 */}
<div <div
ref={(node) => { ref={(node) => {
if (typeof ref === "function") { if (typeof ref === 'function') {
ref(node); ref(node)
} else if (ref) { } else if (ref) {
ref.current = node; ref.current = node
} }
triggerRef.current = node; triggerRef.current = node
}} }}
className={cn( className={cn(
"inline-flex justify-start w-full h-[36px] mx-2 bg-transparent border-b-2 border-dashed text-[26px] outline-none cursor-pointer", 'inline-flex justify-start w-full h-[36px] mx-2 bg-transparent border-b-2 border-dashed text-[26px] outline-none cursor-pointer',
error ? "border-red-500" : "border-[#4e402e]", error ? 'border-red-500' : 'border-[#4e402e]',
disabled && "opacity-50 cursor-not-allowed", disabled && 'opacity-50 cursor-not-allowed',
className className
)} )}
onClick={handleOpen} onClick={handleOpen}
@@ -319,29 +306,26 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
style={{ style={{
top: `${dropdownPosition.top}px`, top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`, left: `${dropdownPosition.left}px`,
width: `${ width: `${dropdownPosition.width > 0 ? dropdownPosition.width : 'auto'}px`,
dropdownPosition.width > 0 ? dropdownPosition.width : "auto" minWidth: '80px',
}px`,
minWidth: "80px",
zIndex: zIndex, zIndex: zIndex,
}} }}
role="listbox" role="listbox"
> >
{options.map((option, index) => { {options.map((option, index) => {
const isSelected = option.value === value; const isSelected = option.value === value
const isFocused = index === focusedIndex; const isFocused = index === focusedIndex
const isDisabled = option.disabled; const isDisabled = option.disabled
return ( return (
<div <div
key={option.value} key={option.value}
data-option-index={index} data-option-index={index}
className={cn( className={cn(
"px-3 py-2 cursor-pointer text-[1vw] text-[#4e402e] transition-colors text-center whitespace-nowrap", 'px-3 py-2 cursor-pointer text-[1vw] text-[#4e402e] transition-colors text-center whitespace-nowrap',
isSelected && "bg-[#4e402e]/10 font-medium", isSelected && 'bg-[#4e402e]/10 font-medium',
isFocused && !isSelected && "bg-gray-100", isFocused && !isSelected && 'bg-gray-100',
isDisabled && isDisabled && 'opacity-50 cursor-not-allowed pointer-events-none'
"opacity-50 cursor-not-allowed pointer-events-none"
)} )}
onClick={() => !isDisabled && handleSelect(option.value)} onClick={() => !isDisabled && handleSelect(option.value)}
role="option" role="option"
@@ -349,16 +333,16 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
> >
{option.label} {option.label}
</div> </div>
); )
})} })}
</div>, </div>,
document.body document.body
)} )}
</> </>
); )
} }
); )
Select.displayName = "Select"; Select.displayName = 'Select'
export default Select; export default Select
+15 -15
View File
@@ -1,37 +1,37 @@
import type { ReactNode } from "react"; import type { ReactNode } from 'react'
/** Select 选项 */ /** Select 选项 */
export interface SelectOption { export interface SelectOption {
/** 选项值 */ /** 选项值 */
value: string; value: string
/** 选项标签 */ /** 选项标签 */
label: string; label: string
/** 是否禁用 */ /** 是否禁用 */
disabled?: boolean; disabled?: boolean
} }
/** Select 组件属性 */ /** Select 组件属性 */
export interface SelectProps { export interface SelectProps {
/** 选项列表 */ /** 选项列表 */
options: SelectOption[]; options: SelectOption[]
/** 当前选中的值 */ /** 当前选中的值 */
value?: string; value?: string
/** 值变化回调 */ /** 值变化回调 */
onChange?: (value: string) => void; onChange?: (value: string) => void
/** 占位符文本 */ /** 占位符文本 */
placeholder?: string; placeholder?: string
/** 是否禁用 */ /** 是否禁用 */
disabled?: boolean; disabled?: boolean
/** 自定义类名 */ /** 自定义类名 */
className?: string; className?: string
/** 是否显示错误状态 */ /** 是否显示错误状态 */
error?: boolean; error?: boolean
/** 下拉菜单的 z-index */ /** 下拉菜单的 z-index */
zIndex?: number; zIndex?: number
/** 下拉菜单宽度是否自适应内容(根据最长的 label 文字宽度) */ /** 下拉菜单宽度是否自适应内容(根据最长的 label 文字宽度) */
fitContent?: boolean; fitContent?: boolean
/** 下拉菜单最小宽度(当 fitContent 为 true 时生效) */ /** 下拉菜单最小宽度(当 fitContent 为 true 时生效) */
minWidth?: number; minWidth?: number
/** 下拉菜单最大宽度(当 fitContent 为 true 时生效) */ /** 下拉菜单最大宽度(当 fitContent 为 true 时生效) */
maxWidth?: number; maxWidth?: number
} }
+63 -74
View File
@@ -1,16 +1,16 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from 'react'
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom'
import { getBlockingDivs, type Rect } from "./utils"; import { getBlockingDivs, type Rect } from './utils'
interface SpotlightProps { interface SpotlightProps {
/** 是否可见 */ /** 是否可见 */
visible?: boolean; visible?: boolean
/** 目标元素选择器(支持单个或多个) */ /** 目标元素选择器(支持单个或多个) */
targetSelector?: string | string[] | null; targetSelector?: string | string[] | null
/** 额外内边距 */ /** 额外内边距 */
padding?: number; padding?: number
/** 圆角 */ /** 圆角 */
borderRadius?: number; borderRadius?: number
} }
const Spotlight: React.FC<SpotlightProps> = ({ const Spotlight: React.FC<SpotlightProps> = ({
@@ -19,108 +19,104 @@ const Spotlight: React.FC<SpotlightProps> = ({
padding = 8, padding = 8,
borderRadius = 16, borderRadius = 16,
}) => { }) => {
const [rects, setRects] = useState<Rect[]>([]); const [rects, setRects] = useState<Rect[]>([])
const measure = useCallback(() => { const measure = useCallback(() => {
if (!visible || !targetSelector) { if (!visible || !targetSelector) {
setRects([]); setRects([])
return; return
} }
const selectors = Array.isArray(targetSelector) const selectors = Array.isArray(targetSelector) ? targetSelector : [targetSelector]
? targetSelector const pad = Math.max(0, padding)
: [targetSelector]; const newRects: Rect[] = []
const pad = Math.max(0, padding);
const newRects: Rect[] = [];
selectors.forEach((selector) => { selectors.forEach((selector) => {
const elements = document.querySelectorAll(selector); const elements = document.querySelectorAll(selector)
elements.forEach((el) => { elements.forEach((el) => {
const htmlEl = el as HTMLElement; const htmlEl = el as HTMLElement
const r = htmlEl.getBoundingClientRect(); const r = htmlEl.getBoundingClientRect()
// 使用视口坐标(fixed层),不叠加滚动偏移 // 使用视口坐标(fixed层),不叠加滚动偏移
newRects.push({ newRects.push({
top: r.top - pad, top: r.top - pad,
left: r.left - pad, left: r.left - pad,
width: r.width + pad * 2, width: r.width + pad * 2,
height: r.height + pad * 2, height: r.height + pad * 2,
}); })
}); })
}); })
setRects(newRects); setRects(newRects)
}, [visible, targetSelector, padding]); }, [visible, targetSelector, padding])
useEffect(() => { useEffect(() => {
// 初始测量 // 初始测量
requestAnimationFrame(measure); requestAnimationFrame(measure)
}, [visible, targetSelector, padding, measure]); }, [visible, targetSelector, padding, measure])
useEffect(() => { useEffect(() => {
const onResize = () => measure(); const onResize = () => measure()
const onScroll = () => measure(); const onScroll = () => measure()
window.addEventListener("resize", onResize); window.addEventListener('resize', onResize)
window.addEventListener("scroll", onScroll, { window.addEventListener('scroll', onScroll, {
passive: true, passive: true,
} as AddEventListenerOptions); } as AddEventListenerOptions)
// 使用 ResizeObserver 监听目标元素尺寸变化 // 使用 ResizeObserver 监听目标元素尺寸变化
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
measure(); measure()
}); })
// 观察所有目标元素 // 观察所有目标元素
if (visible && targetSelector) { if (visible && targetSelector) {
const selectors = Array.isArray(targetSelector) const selectors = Array.isArray(targetSelector) ? targetSelector : [targetSelector]
? targetSelector
: [targetSelector];
selectors.forEach((selector) => { selectors.forEach((selector) => {
const elements = document.querySelectorAll(selector); const elements = document.querySelectorAll(selector)
elements.forEach((el) => { elements.forEach((el) => {
resizeObserver.observe(el); resizeObserver.observe(el)
}); })
}); })
} }
// 初始测量(延迟一帧确保 DOM 已渲染) // 初始测量(延迟一帧确保 DOM 已渲染)
requestAnimationFrame(measure); requestAnimationFrame(measure)
return () => { return () => {
window.removeEventListener("resize", onResize); window.removeEventListener('resize', onResize)
window.removeEventListener("scroll", onScroll); window.removeEventListener('scroll', onScroll)
resizeObserver.disconnect(); resizeObserver.disconnect()
}; }
}, [measure, visible, targetSelector]); }, [measure, visible, targetSelector])
if (!visible || rects.length === 0) return null; if (!visible || rects.length === 0) return null
// 生成唯一ID用于SVG mask // 生成唯一ID用于SVG mask
const maskId = `spotlight-mask-${Math.random().toString(36).substr(2, 9)}`; const maskId = `spotlight-mask-${Math.random().toString(36).substr(2, 9)}`
// 计算阻止点击的区域(使用共享工具函数) // 计算阻止点击的区域(使用共享工具函数)
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 0; const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 0
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 0; const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0
const blockingDivs = getBlockingDivs(rects, viewportWidth, viewportHeight); const blockingDivs = getBlockingDivs(rects, viewportWidth, viewportHeight)
return createPortal( return createPortal(
<div <div
style={{ style={{
position: "fixed", position: 'fixed',
inset: 0, inset: 0,
pointerEvents: "none", pointerEvents: 'none',
zIndex: 100000, zIndex: 100000,
}} }}
> >
{/* SVG 视觉遮罩 */} {/* SVG 视觉遮罩 */}
<svg <svg
style={{ style={{
position: "absolute", position: 'absolute',
inset: 0, inset: 0,
width: "100%", width: '100%',
height: "100%", height: '100%',
pointerEvents: "none", pointerEvents: 'none',
}} }}
> >
<defs> <defs>
@@ -155,19 +151,12 @@ const Spotlight: React.FC<SpotlightProps> = ({
<div <div
key={`block-${index}`} key={`block-${index}`}
style={{ style={{
position: "absolute", position: 'absolute',
top: `${block.top}px`, top: `${block.top}px`,
left: left: typeof block.left === 'number' ? `${block.left}px` : block.left,
typeof block.left === "number" ? `${block.left}px` : block.left, width: typeof block.width === 'number' ? `${block.width}px` : block.width,
width: height: typeof block.height === 'number' ? `${block.height}px` : block.height,
typeof block.width === "number" pointerEvents: 'auto',
? `${block.width}px`
: block.width,
height:
typeof block.height === "number"
? `${block.height}px`
: block.height,
pointerEvents: "auto",
// backgroundColor: "rgba(255, 0, 0, 0.1)", // 调试用 // backgroundColor: "rgba(255, 0, 0, 0.1)", // 调试用
}} }}
/> />
@@ -178,20 +167,20 @@ const Spotlight: React.FC<SpotlightProps> = ({
<div <div
key={`border-${index}`} key={`border-${index}`}
style={{ style={{
position: "absolute", position: 'absolute',
top: `${rect.top}px`, top: `${rect.top}px`,
left: `${rect.left}px`, left: `${rect.left}px`,
width: `${rect.width}px`, width: `${rect.width}px`,
height: `${rect.height}px`, height: `${rect.height}px`,
borderRadius: `${borderRadius}px`, borderRadius: `${borderRadius}px`,
border: "2px solid rgba(255, 255, 255, 0.3)", border: '2px solid rgba(255, 255, 255, 0.3)',
pointerEvents: "none", pointerEvents: 'none',
}} }}
/> />
))} ))}
</div>, </div>,
document.body document.body
); )
}; }
export default Spotlight; export default Spotlight
+1 -1
View File
@@ -1 +1 @@
export { default as Spotlight } from "./Spotlight"; export { default as Spotlight } from './Spotlight'
+31 -31
View File
@@ -3,17 +3,17 @@
* 用于 Spotlight 和 Overlay 组件 * 用于 Spotlight 和 Overlay 组件
*/ */
export interface BlockingDiv { export interface BlockingDiv {
top: number; top: number
left: number; left: number
width: string | number; width: string | number
height: string | number; height: string | number
} }
export interface Rect { export interface Rect {
top: number; top: number
left: number; left: number
width: number; width: number
height: number; height: number
} }
/** /**
@@ -28,24 +28,24 @@ export function getBlockingDivs(
viewportWidth: number, viewportWidth: number,
viewportHeight: number viewportHeight: number
): BlockingDiv[] { ): BlockingDiv[] {
if (rects.length === 0) return []; if (rects.length === 0) return []
const blocks: BlockingDiv[] = []; const blocks: BlockingDiv[] = []
// 找出所有孔的边界 // 找出所有孔的边界
const minTop = Math.min(...rects.map((r) => r.top)); const minTop = Math.min(...rects.map((r) => r.top))
const maxBottom = Math.max(...rects.map((r) => r.top + r.height)); const maxBottom = Math.max(...rects.map((r) => r.top + r.height))
const minLeft = Math.min(...rects.map((r) => r.left)); const minLeft = Math.min(...rects.map((r) => r.left))
const maxRight = Math.max(...rects.map((r) => r.left + r.width)); const maxRight = Math.max(...rects.map((r) => r.left + r.width))
// 顶部大块 // 顶部大块
if (minTop > 0) { if (minTop > 0) {
blocks.push({ blocks.push({
top: 0, top: 0,
left: 0, left: 0,
width: "100%", width: '100%',
height: minTop, height: minTop,
}); })
} }
// 底部大块 // 底部大块
@@ -53,9 +53,9 @@ export function getBlockingDivs(
blocks.push({ blocks.push({
top: maxBottom, top: maxBottom,
left: 0, left: 0,
width: "100%", width: '100%',
height: viewportHeight - maxBottom, height: viewportHeight - maxBottom,
}); })
} }
// 左侧大块(中间部分) // 左侧大块(中间部分)
@@ -65,7 +65,7 @@ export function getBlockingDivs(
left: 0, left: 0,
width: minLeft, width: minLeft,
height: maxBottom - minTop, height: maxBottom - minTop,
}); })
} }
// 右侧大块(中间部分) // 右侧大块(中间部分)
@@ -75,20 +75,20 @@ export function getBlockingDivs(
left: maxRight, left: maxRight,
width: viewportWidth - maxRight, width: viewportWidth - maxRight,
height: maxBottom - minTop, height: maxBottom - minTop,
}); })
} }
// 对于多个孔的情况,填充孔之间的间隙 // 对于多个孔的情况,填充孔之间的间隙
if (rects.length > 1) { if (rects.length > 1) {
// 按位置排序 // 按位置排序
const sortedByTop = [...rects].sort((a, b) => a.top - b.top); const sortedByTop = [...rects].sort((a, b) => a.top - b.top)
const sortedByLeft = [...rects].sort((a, b) => a.left - b.left); const sortedByLeft = [...rects].sort((a, b) => a.left - b.left)
// 填充垂直间隙 // 填充垂直间隙
for (let i = 0; i < sortedByTop.length - 1; i++) { for (let i = 0; i < sortedByTop.length - 1; i++) {
const current = sortedByTop[i]!; const current = sortedByTop[i]!
const next = sortedByTop[i + 1]!; const next = sortedByTop[i + 1]!
const gap = next.top - (current.top + current.height); const gap = next.top - (current.top + current.height)
if (gap > 0) { if (gap > 0) {
blocks.push({ blocks.push({
@@ -96,15 +96,15 @@ export function getBlockingDivs(
left: Math.max(current.left, next.left), left: Math.max(current.left, next.left),
width: Math.min(current.width, next.width), width: Math.min(current.width, next.width),
height: gap, height: gap,
}); })
} }
} }
// 填充水平间隙 // 填充水平间隙
for (let i = 0; i < sortedByLeft.length - 1; i++) { for (let i = 0; i < sortedByLeft.length - 1; i++) {
const current = sortedByLeft[i]!; const current = sortedByLeft[i]!
const next = sortedByLeft[i + 1]!; const next = sortedByLeft[i + 1]!
const gap = next.left - (current.left + current.width); const gap = next.left - (current.left + current.width)
if (gap > 0) { if (gap > 0) {
blocks.push({ blocks.push({
@@ -112,10 +112,10 @@ export function getBlockingDivs(
left: current.left + current.width, left: current.left + current.width,
width: gap, width: gap,
height: Math.min(current.height, next.height), height: Math.min(current.height, next.height),
}); })
} }
} }
} }
return blocks; return blocks
} }
+14 -23
View File
@@ -1,4 +1,4 @@
import { cn, getBackgroundImage } from "@/lib/utils"; import { cn, getBackgroundImage } from '@/lib/utils'
/** /**
* 两个标题(主标题、副标题) * 两个标题(主标题、副标题)
@@ -6,23 +6,18 @@ import { cn, getBackgroundImage } from "@/lib/utils";
* subtitle: 副标题 * subtitle: 副标题
*/ */
interface TitleProps { interface TitleProps {
title: string; title: string
subtitle: string; subtitle: string
className?: string; className?: string
subtitleClassName?: string; subtitleClassName?: string
} }
const Title = ({ const Title = ({ title, subtitle, className, subtitleClassName }: TitleProps) => {
title,
subtitle,
className,
subtitleClassName,
}: TitleProps) => {
return ( return (
<div <div
className={cn( className={cn(
"w-[1168px] h-[149px] bg-cover bg-center bg-no-repeat relative", 'w-[1168px] h-[149px] bg-cover bg-center bg-no-repeat relative',
"flex justify-center items-center", 'flex justify-center items-center',
className className
)} )}
style={{ style={{
@@ -31,20 +26,16 @@ const Title = ({
> >
<div <div
className={cn( className={cn(
"absolute -top-5 left-1/2 -translate-x-1/2 text-white text-5xl font-bold whitespace-nowrap", 'absolute -top-5 left-1/2 -translate-x-1/2 text-white text-5xl font-bold whitespace-nowrap',
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]" '[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]'
)} )}
> >
{title} {title}
</div> </div>
{/* 副标题 */} {/* 副标题 */}
<div <div className={cn('text-[#5C3A1E] text-5xl font-bold', subtitleClassName)}>{subtitle}</div>
className={cn("text-[#5C3A1E] text-5xl font-bold", subtitleClassName)}
>
{subtitle}
</div> </div>
</div> )
); }
};
export { Title }; export { Title }
+3 -8
View File
@@ -1,8 +1,3 @@
export { default } from "./video"; export { default } from './video'
export { default as Video } from "./video"; export { default as Video } from './video'
export type { export type { VideoProps, VideoRef, VideoAnchor, AnchorContext } from './video.types'
VideoProps,
VideoRef,
VideoAnchor,
AnchorContext,
} from "./video.types";
File diff suppressed because it is too large. Load diff
+20 -21
View File
@@ -1,54 +1,53 @@
import type React from "react"; import type React from 'react'
/** 锚点上下文,提供给锚点渲染函数 */ /** 锚点上下文,提供给锚点渲染函数 */
export interface AnchorContext { export interface AnchorContext {
/** 恢复播放 */ /** 恢复播放 */
resume: () => void; resume: () => void
/** 取消静音 */ /** 取消静音 */
unmute: () => void; unmute: () => void
} }
/** 视频锚点配置 */ /** 视频锚点配置 */
export interface VideoAnchor { export interface VideoAnchor {
/** 唯一标识 */ /** 唯一标识 */
id: string; id: string
/** 触发时间(秒) */ /** 触发时间(秒) */
time: number; time: number
/** 返回要渲染的 TSX */ /** 返回要渲染的 TSX */
render: (ctx: AnchorContext) => React.ReactNode; render: (ctx: AnchorContext) => React.ReactNode
/** 是否启用,默认 true */ /** 是否启用,默认 true */
enabled?: boolean; enabled?: boolean
/** 是否只触发一次(seek 回来不再触发),默认 false */ /** 是否只触发一次(seek 回来不再触发),默认 false */
once?: boolean; once?: boolean
/** 恢复后的行为,默认 'hide' */ /** 恢复后的行为,默认 'hide' */
afterResume?: "hide" | "remove" | "keep"; afterResume?: 'hide' | 'remove' | 'keep'
/** 锚点触发时的回调(如埋点) */ /** 锚点触发时的回调(如埋点) */
onTrigger?: () => void; onTrigger?: () => void
} }
/** Video 组件 Props */ /** Video 组件 Props */
export interface VideoProps export interface VideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
extends React.VideoHTMLAttributes<HTMLVideoElement> {
/** 锚点配置列表 */ /** 锚点配置列表 */
anchors?: VideoAnchor[]; anchors?: VideoAnchor[]
/** 时间更新回调 */ /** 时间更新回调 */
onTimeUpdate?: (e: React.SyntheticEvent<HTMLVideoElement, Event>) => void; onTimeUpdate?: (e: React.SyntheticEvent<HTMLVideoElement, Event>) => void
/** 是否显示跳过按钮(默认 false) */ /** 是否显示跳过按钮(默认 false) */
showSkipButton?: boolean; showSkipButton?: boolean
/** 跳过按钮文案(默认 "跳过") */ /** 跳过按钮文案(默认 "跳过") */
skipText?: string; skipText?: string
/** 点击跳过时的回调 */ /** 点击跳过时的回调 */
onSkip?: () => void; onSkip?: () => void
/** 自定义跳过按钮样式 */ /** 自定义跳过按钮样式 */
skipButtonClassName?: string; skipButtonClassName?: string
} }
/** Video 组件 Ref 暴露的方法 */ /** Video 组件 Ref 暴露的方法 */
export interface VideoRef { export interface VideoRef {
/** 原生 video 元素 */ /** 原生 video 元素 */
video: HTMLVideoElement | null; video: HTMLVideoElement | null
/** 播放视频 */ /** 播放视频 */
play: () => Promise<void>; play: () => Promise<void>
/** 暂停视频 */ /** 暂停视频 */
pause: () => void; pause: () => void
} }
+203 -209
View File
@@ -5,384 +5,381 @@
export interface PuzzlePieceData { export interface PuzzlePieceData {
/** 碎片标题 */ /** 碎片标题 */
title: string; title: string
/** 对应的场景图片路径(相对于 puzzle/ 目录) */ /** 对应的场景图片路径(相对于 puzzle/ 目录) */
detailImage: string; detailImage: string
// 对应的场景路由 // 对应的场景路由
route: string; route: string
} }
/** /**
* 每个故居的拼图碎片数据配置(每个故居碎片都是16块) * 每个故居的拼图碎片数据配置(每个故居碎片都是16块)
* 格式:{ residence: { pieceId: PuzzlePieceData } } * 格式:{ residence: { pieceId: PuzzlePieceData } }
*/ */
export const puzzleDataConfig: Record< export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> = {
string,
Record<number, PuzzlePieceData>
> = {
chenyun: { chenyun: {
// 碎片1 - 示例数据,请根据实际情况修改 // 碎片1 - 示例数据,请根据实际情况修改
1: { 1: {
title: "水乡少年·艰率成长", title: '水乡少年·艰率成长',
detailImage: "chenyun/details/1", detailImage: 'chenyun/details/1',
route: "place1", route: 'place1',
}, },
// 碎片2 // 碎片2
2: { 2: {
title: "陈云就读 青浦县立乙种商业学校", title: '陈云就读 青浦县立乙种商业学校',
detailImage: "chenyun/details/2", detailImage: 'chenyun/details/2',
route: "place2", route: 'place2',
}, },
// 碎片3 // 碎片3
3: { 3: {
title: "陈云就读颜安小学", title: '陈云就读颜安小学',
detailImage: "chenyun/details/3", detailImage: 'chenyun/details/3',
route: "place2", route: 'place2',
}, },
// 碎片4 // 碎片4
4: { 4: {
title: "陈云在 上海商务印书馆当学徒", title: '陈云在 上海商务印书馆当学徒',
detailImage: "chenyun/details/4", detailImage: 'chenyun/details/4',
route: "place3", route: 'place3',
}, },
// 碎片5 // 碎片5
5: { 5: {
title: "陈云加入中国共产党", title: '陈云加入中国共产党',
detailImage: "chenyun/details/5", detailImage: 'chenyun/details/5',
route: "place3", route: 'place3',
}, },
// 碎片6 // 碎片6
6: { 6: {
title: "陈云被选为 中央政治局委员、常委", title: '陈云被选为 中央政治局委员、常委',
detailImage: "chenyun/details/6", detailImage: 'chenyun/details/6',
route: "place4", route: 'place4',
}, },
// 碎片7 // 碎片7
7: { 7: {
title: "陈云任 西北财经办事处副主任", title: '陈云任 西北财经办事处副主任',
detailImage: "chenyun/details/7", detailImage: 'chenyun/details/7',
route: "place5", route: 'place5',
}, },
// 碎片8 // 碎片8
8: { 8: {
title: "陈云主持 陕甘宁边区财政工作", title: '陈云主持 陕甘宁边区财政工作',
detailImage: "chenyun/details/8", detailImage: 'chenyun/details/8',
route: "place5", route: 'place5',
}, },
// 碎片9 // 碎片9
9: { 9: {
title: "恢复时期·经济奠基", title: '恢复时期·经济奠基',
detailImage: "chenyun/details/9", detailImage: 'chenyun/details/9',
route: "place6", route: 'place6',
}, },
// 碎片10 // 碎片10
10: { 10: {
title: "银元之战", title: '银元之战',
detailImage: "chenyun/details/10", detailImage: 'chenyun/details/10',
route: "place7", route: 'place7',
}, },
// 碎片11 // 碎片11
11: { 11: {
title: '陈云起草的关于发展国民 经济"一五"计划报告的手稿', title: '陈云起草的关于发展国民 经济"一五"计划报告的手稿',
detailImage: "chenyun/details/11", detailImage: 'chenyun/details/11',
route: "place8", route: 'place8',
}, },
// 碎片12 // 碎片12
12: { 12: {
title: "陈云赴宝钢考察", title: '陈云赴宝钢考察',
detailImage: "chenyun/details/12", detailImage: 'chenyun/details/12',
route: "place9", route: 'place9',
}, },
// 碎片13 // 碎片13
13: { 13: {
title: "体悟·银元之战", title: '体悟·银元之战',
detailImage: "chenyun/details/13", detailImage: 'chenyun/details/13',
route: "reflection", route: 'reflection',
}, },
// 碎片14 // 碎片14
14: { 14: {
title: "体悟·米棉之战", title: '体悟·米棉之战',
detailImage: "chenyun/details/14", detailImage: 'chenyun/details/14',
route: "reflection", route: 'reflection',
}, },
// 碎片15 // 碎片15
15: { 15: {
title: "陈云经济思想的核心内容", title: '陈云经济思想的核心内容',
detailImage: "chenyun/details/15", detailImage: 'chenyun/details/15',
route: "internalization", route: 'internalization',
}, },
// 碎片16 // 碎片16
16: { 16: {
title: "陈云精神", title: '陈云精神',
detailImage: "chenyun/details/16", detailImage: 'chenyun/details/16',
route: "internalization", route: 'internalization',
}, },
}, },
huangyanpei: { huangyanpei: {
// 碎片1 // 碎片1
1: { 1: {
title: "杰出的爱国民主人士黄炎培", title: '杰出的爱国民主人士黄炎培',
detailImage: "huangyanpei/details/1", detailImage: 'huangyanpei/details/1',
route: "place1", route: 'place1',
}, },
// 碎片2 // 碎片2
2: { 2: {
title: "南洋公学", title: '南洋公学',
detailImage: "huangyanpei/details/2", detailImage: 'huangyanpei/details/2',
route: "place2", route: 'place2',
}, },
// 碎片3 // 碎片3
3: { 3: {
title: "川沙小学堂", title: '川沙小学堂',
detailImage: "huangyanpei/details/3", detailImage: 'huangyanpei/details/3',
route: "place3", route: 'place3',
}, },
// 碎片4 // 碎片4
4: { 4: {
title: "开群女学课堂", title: '开群女学课堂',
detailImage: "huangyanpei/details/4", detailImage: 'huangyanpei/details/4',
route: "place3", route: 'place3',
}, },
// 碎片5 // 碎片5
5: { 5: {
title: "杨斯盛创建浦东中学", title: '杨斯盛创建浦东中学',
detailImage: "huangyanpei/details/5", detailImage: 'huangyanpei/details/5',
route: "place3", route: 'place3',
}, },
// 碎片6 // 碎片6
6: { 6: {
title: "中华职业教育 和中华职业学校", title: '中华职业教育 和中华职业学校',
detailImage: "huangyanpei/details/6", detailImage: 'huangyanpei/details/6',
route: "place4", route: 'place4',
}, },
// 碎片7 // 碎片7
7: { 7: {
title: "中华职业教育社会址", title: '中华职业教育社会址',
detailImage: "huangyanpei/details/7", detailImage: 'huangyanpei/details/7',
route: "place4", route: 'place4',
}, },
// 碎片8 // 碎片8
8: { 8: {
title: "抗敌后援会成立大会", title: '抗敌后援会成立大会',
detailImage: "huangyanpei/details/8", detailImage: 'huangyanpei/details/8',
route: "place4", route: 'place4',
}, },
// 碎片9 // 碎片9
9: { 9: {
title: "中华职校组织义勇军", title: '中华职校组织义勇军',
detailImage: "huangyanpei/details/9", detailImage: 'huangyanpei/details/9',
route: "place4", route: 'place4',
}, },
// 碎片10 // 碎片10
10: { 10: {
title: "黄炎培与荣氏兄弟 合作工人教育", title: '黄炎培与荣氏兄弟 合作工人教育',
detailImage: "huangyanpei/details/10", detailImage: 'huangyanpei/details/10',
route: "place4", route: 'place4',
}, },
// 碎片11 // 碎片11
11: { 11: {
title: "黄炎培创建民盟", title: '黄炎培创建民盟',
detailImage: "huangyanpei/details/11", detailImage: 'huangyanpei/details/11',
route: "place5", route: 'place5',
}, },
// 碎片12 // 碎片12
12: { 12: {
title: "窑洞对话", title: '窑洞对话',
detailImage: "huangyanpei/details/12", detailImage: 'huangyanpei/details/12',
route: "place5", route: 'place5',
}, },
// 碎片13 // 碎片13
13: { 13: {
title: "黄炎培参加第一届全国人大", title: '黄炎培参加第一届全国人大',
detailImage: "huangyanpei/details/13", detailImage: 'huangyanpei/details/13',
route: "place6", route: 'place6',
}, },
// 碎片14 // 碎片14
14: { 14: {
title: "黄炎培对浦东中学新生的教导", title: '黄炎培对浦东中学新生的教导',
detailImage: "huangyanpei/details/14", detailImage: 'huangyanpei/details/14',
route: "reflection2", route: 'reflection2',
}, },
// 碎片15 // 碎片15
15: { 15: {
title: "中华职业学校的立业之本", title: '中华职业学校的立业之本',
detailImage: "huangyanpei/details/15", detailImage: 'huangyanpei/details/15',
route: "reflection4", route: 'reflection4',
}, },
// 碎片16 // 碎片16
16: { 16: {
title: "黄炎培的”实业救国“情怀”", title: '黄炎培的”实业救国“情怀”',
detailImage: "huangyanpei/details/16", detailImage: 'huangyanpei/details/16',
route: "internalization", route: 'internalization',
}, },
}, },
sunzhongshan: { sunzhongshan: {
1: { 1: {
title: "革命四杰", title: '革命四杰',
detailImage: "sunzhongshan/details/1", detailImage: 'sunzhongshan/details/1',
route: "place1", route: 'place1',
}, },
2: { 2: {
title: "郑观应的推荐信郑观应的推荐信", title: '郑观应的推荐信郑观应的推荐信',
detailImage: "sunzhongshan/details/2", detailImage: 'sunzhongshan/details/2',
route: "place1", route: 'place1',
}, },
3: { 3: {
title: "《申报》刊登孙中山就职 临时大总统消息", title: '《申报》刊登孙中山就职 临时大总统消息',
detailImage: "sunzhongshan/details/3", detailImage: 'sunzhongshan/details/3',
route: "place1", route: 'place1',
}, },
4: { 4: {
title: "《民立报》报道孙中山解职", title: '《民立报》报道孙中山解职',
detailImage: "sunzhongshan/details/4", detailImage: 'sunzhongshan/details/4',
route: "place1", route: 'place1',
}, },
5: { 5: {
title: "中国铁路计划图", title: '中国铁路计划图',
detailImage: "sunzhongshan/details/5", detailImage: 'sunzhongshan/details/5',
route: "place2", route: 'place2',
}, },
6: { 6: {
title: "实业计划", title: '实业计划',
detailImage: "sunzhongshan/details/6", detailImage: 'sunzhongshan/details/6',
route: "place2", route: 'place2',
}, },
7: { 7: {
title: "护法运动", title: '护法运动',
detailImage: "sunzhongshan/details/7", detailImage: 'sunzhongshan/details/7',
route: "place2", route: 'place2',
}, },
8: { 8: {
title: "社会主义的精义", title: '社会主义的精义',
detailImage: "sunzhongshan/details/8", detailImage: 'sunzhongshan/details/8',
route: "place3", route: 'place3',
}, },
9: { 9: {
title: "李大钊给胡适的信", title: '李大钊给胡适的信',
detailImage: "sunzhongshan/details/9", detailImage: 'sunzhongshan/details/9',
route: "place3", route: 'place3',
}, },
10: { 10: {
title: "《中国国民党第一次全国代表大会宣言》", title: '《中国国民党第一次全国代表大会宣言》',
detailImage: "sunzhongshan/details/10", detailImage: 'sunzhongshan/details/10',
route: "place3", route: 'place3',
}, },
11: { 11: {
title: "国事遗嘱", title: '国事遗嘱',
detailImage: "sunzhongshan/details/11", detailImage: 'sunzhongshan/details/11',
route: "place4", route: 'place4',
}, },
12: { 12: {
title: "第二次护法运动推理小游戏", title: '第二次护法运动推理小游戏',
detailImage: "sunzhongshan/details/12", detailImage: 'sunzhongshan/details/12',
route: "reflection1", route: 'reflection1',
}, },
13: { 13: {
title: "国共合作推理小游戏", title: '国共合作推理小游戏',
detailImage: "sunzhongshan/details/13", detailImage: 'sunzhongshan/details/13',
route: "reflection2", route: 'reflection2',
}, },
14: { 14: {
title: "抱病北上推理小游戏", title: '抱病北上推理小游戏',
detailImage: "sunzhongshan/details/14", detailImage: 'sunzhongshan/details/14',
route: "reflection3", route: 'reflection3',
}, },
15: { 15: {
title: "振兴中华对新时代青年的意义", title: '振兴中华对新时代青年的意义',
detailImage: "sunzhongshan/details/15", detailImage: 'sunzhongshan/details/15',
route: "internalization", route: 'internalization',
}, },
16: { 16: {
title: "学习孙中山的精神", title: '学习孙中山的精神',
detailImage: "sunzhongshan/details/16", detailImage: 'sunzhongshan/details/16',
route: "internalization", route: 'internalization',
} },
}, },
luxun: { luxun: {
1: { 1: {
title: "鲁迅1927年南下广州时的照片", title: '鲁迅1927年南下广州时的照片',
detailImage: "luxun/details/1", detailImage: 'luxun/details/1',
route: "place1", route: 'place1',
}, },
2: { 2: {
title: "景云里旧居场景图", title: '景云里旧居场景图',
detailImage: "luxun/details/2", detailImage: 'luxun/details/2',
route: "place1", route: 'place1',
}, },
3: { 3: {
title: "《且介亭杂文》", title: '《且介亭杂文》',
detailImage: "luxun/details/3", detailImage: 'luxun/details/3',
route: "place2", route: 'place2',
}, },
4: { 4: {
title: "《中国人失掉自信力了吗》", title: '《中国人失掉自信力了吗》',
detailImage: "luxun/details/4", detailImage: 'luxun/details/4',
route: "place2", route: 'place2',
}, },
5: { 5: {
title: "《南腔北调集》", title: '《南腔北调集》',
detailImage: "luxun/details/5", detailImage: 'luxun/details/5',
route: "place2", route: 'place2',
}, },
6: { 6: {
title: "《故事新编》", title: '《故事新编》',
detailImage: "luxun/details/6", detailImage: 'luxun/details/6',
route: "place3", route: 'place3',
}, },
7: { 7: {
title: "《理水》手迹", title: '《理水》手迹',
detailImage: "luxun/details/7", detailImage: 'luxun/details/7',
route: "place3", route: 'place3',
}, },
8: { 8: {
title: "书架上的翻译著作", title: '书架上的翻译著作',
detailImage: "luxun/details/8", detailImage: 'luxun/details/8',
route: "place3", route: 'place3',
}, },
9: { 9: {
title: "《海上述林》书册", title: '《海上述林》书册',
detailImage: "luxun/details/9", detailImage: 'luxun/details/9',
route: "place3", route: 'place3',
}, },
10: { 10: {
title: "书册上的校订批注", title: '书册上的校订批注',
detailImage: "luxun/details/10", detailImage: 'luxun/details/10',
route: "place3", route: 'place3',
}, },
11: { 11: {
title: "瞿秋白对鲁迅的评价", title: '瞿秋白对鲁迅的评价',
detailImage: "luxun/details/11", detailImage: 'luxun/details/11',
route: "place4", route: 'place4',
}, },
12: { 12: {
title: "鲁迅赠瞿秋白的对联", title: '鲁迅赠瞿秋白的对联',
detailImage: "luxun/details/12", detailImage: 'luxun/details/12',
route: "place4", route: 'place4',
}, },
13: { 13: {
title: "冯雪峰与鲁迅谈话的资料图", title: '冯雪峰与鲁迅谈话的资料图',
detailImage: "luxun/details/13", detailImage: 'luxun/details/13',
route: "place4", route: 'place4',
}, },
14: { 14: {
title: "鲁迅创作历程", title: '鲁迅创作历程',
detailImage: "luxun/details/14", detailImage: 'luxun/details/14',
route: "reflection1", route: 'reflection1',
}, },
15: { 15: {
title: "鲁迅精神的总结", title: '鲁迅精神的总结',
detailImage: "luxun/details/15", detailImage: 'luxun/details/15',
route: "reflection3", route: 'reflection3',
}, },
16: { 16: {
title: "鲁迅在木刻上对青年的培养", title: '鲁迅在木刻上对青年的培养',
detailImage: "luxun/details/16", detailImage: 'luxun/details/16',
route: "internalization", route: 'internalization',
} },
}, },
// 后续可以添加其他故居的数据 // 后续可以添加其他故居的数据
// sunzhongshan: { ... }, // sunzhongshan: { ... },
}; }
/** /**
* 获取碎片的数据配置 * 获取碎片的数据配置
@@ -390,11 +387,8 @@ export const puzzleDataConfig: Record<
* @param pieceId 碎片ID * @param pieceId 碎片ID
* @returns 碎片数据,如果不存在则返回默认值 * @returns 碎片数据,如果不存在则返回默认值
*/ */
export const getPuzzlePieceData = ( export const getPuzzlePieceData = (residence: string, pieceId: number): PuzzlePieceData | null => {
residence: string, const residenceConfig = puzzleDataConfig[residence]
pieceId: number const pieceData = residenceConfig?.[pieceId]
): PuzzlePieceData | null => { return pieceData || null
const residenceConfig = puzzleDataConfig[residence]; }
const pieceData = residenceConfig?.[pieceId];
return pieceData || null;
};
+9 -12
View File
@@ -5,9 +5,9 @@
export interface PuzzlePathConfig { export interface PuzzlePathConfig {
/** viewBox */ /** viewBox */
viewBox: string; viewBox: string
/** 每个碎片的路径数据 */ /** 每个碎片的路径数据 */
pieces: Record<number, string>; pieces: Record<number, string>
} }
/** /**
@@ -17,7 +17,7 @@ export interface PuzzlePathConfig {
* 因此这里使用单例配置,而不是按 residence 区分。 * 因此这里使用单例配置,而不是按 residence 区分。
*/ */
export const puzzlePathConfig: PuzzlePathConfig = { export const puzzlePathConfig: PuzzlePathConfig = {
viewBox: "0 0 748.92 747.84", viewBox: '0 0 748.92 747.84',
pieces: { pieces: {
1: `M183.45,10.9c1.43,18.24,0.5,50.8,0.77,51.72c-0.08,0.84,0.36,4.83,1.93,7.2c1.57,2.37,4.26,3.11,6.05,3.09 1: `M183.45,10.9c1.43,18.24,0.5,50.8,0.77,51.72c-0.08,0.84,0.36,4.83,1.93,7.2c1.57,2.37,4.26,3.11,6.05,3.09
c4.55,0.77,11.08-0.12,15.31-2.06c4.24-1.94,8.62-4.28,13.33-4.36c9.76-0.15,16.29,3.39,19.44,9.75 c4.55,0.77,11.08-0.12,15.31-2.06c4.24-1.94,8.62-4.28,13.33-4.36c9.76-0.15,16.29,3.39,19.44,9.75
@@ -209,7 +209,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
c-0.34-4.32-1.87-9.39-3.65-13.36c-1.09-2.43-2.12-3.98-2.87-6.53c-2.54-8.62,0.07-11.89,8.9-13.78 c-0.34-4.32-1.87-9.39-3.65-13.36c-1.09-2.43-2.12-3.98-2.87-6.53c-2.54-8.62,0.07-11.89,8.9-13.78
c18.77-0.05,35.67-0.2,54.43-0.17C742.23,561.91,745.52,560.97,747.08,562.77z`, c18.77-0.05,35.67-0.2,54.43-0.17C742.23,561.91,745.52,560.97,747.08,562.77z`,
}, },
}; }
/** /**
* 获取碎片的路径数据 * 获取碎片的路径数据
@@ -217,13 +217,10 @@ export const puzzlePathConfig: PuzzlePathConfig = {
* @param pieceId 碎片ID * @param pieceId 碎片ID
* @returns 路径数据字符串 * @returns 路径数据字符串
*/ */
export const getPuzzlePiecePath = ( export const getPuzzlePiecePath = (_residence: string, pieceId: number): string | null => {
_residence: string,
pieceId: number
): string | null => {
// 所有故居共用同一套路径数据;residence 参数保留用于兼容调用方 // 所有故居共用同一套路径数据;residence 参数保留用于兼容调用方
return puzzlePathConfig.pieces[pieceId] ?? null; return puzzlePathConfig.pieces[pieceId] ?? null
}; }
/** /**
* 获取拼图的viewBox * 获取拼图的viewBox
@@ -232,5 +229,5 @@ export const getPuzzlePiecePath = (
*/ */
export const getPuzzleViewBox = (_residence: string): string => { export const getPuzzleViewBox = (_residence: string): string => {
// 所有故居共用同一套 viewBox;residence 参数保留用于兼容调用方 // 所有故居共用同一套 viewBox;residence 参数保留用于兼容调用方
return puzzlePathConfig.viewBox ?? "0 0 748.92 747.84"; return puzzlePathConfig.viewBox ?? '0 0 748.92 747.84'
}; }
+11 -15
View File
@@ -1,34 +1,30 @@
import { createContext, useContext, ReactNode } from "react"; import { createContext, useContext, ReactNode } from 'react'
interface PuzzleAnimationContextType { interface PuzzleAnimationContextType {
/** 触发碎片动画,传递碎片ID */ /** 触发碎片动画,传递碎片ID */
triggerPuzzleAnimation: (pieceId: number) => void; triggerPuzzleAnimation: (pieceId: number) => void
} }
const PuzzleAnimationContext = createContext<PuzzleAnimationContextType | null>( const PuzzleAnimationContext = createContext<PuzzleAnimationContextType | null>(null)
null
);
export const PuzzleAnimationProvider = ({ export const PuzzleAnimationProvider = ({
children, children,
triggerPuzzleAnimation, triggerPuzzleAnimation,
}: { }: {
children: ReactNode; children: ReactNode
triggerPuzzleAnimation: (pieceId: number) => void; triggerPuzzleAnimation: (pieceId: number) => void
}) => { }) => {
return ( return (
<PuzzleAnimationContext.Provider value={{ triggerPuzzleAnimation }}> <PuzzleAnimationContext.Provider value={{ triggerPuzzleAnimation }}>
{children} {children}
</PuzzleAnimationContext.Provider> </PuzzleAnimationContext.Provider>
); )
}; }
export const usePuzzleAnimation = () => { export const usePuzzleAnimation = () => {
const context = useContext(PuzzleAnimationContext); const context = useContext(PuzzleAnimationContext)
if (!context) { if (!context) {
throw new Error( throw new Error('usePuzzleAnimation must be used within PuzzleAnimationProvider')
"usePuzzleAnimation must be used within PuzzleAnimationProvider"
);
} }
return context; return context
}; }
+10 -15
View File
@@ -1,26 +1,21 @@
import { createContext, useContext, useRef, ReactNode } from "react"; import { createContext, useContext, useRef, ReactNode } from 'react'
interface PuzzleBoxContextType { interface PuzzleBoxContextType {
puzzleBoxRef: React.RefObject<HTMLDivElement | null>; puzzleBoxRef: React.RefObject<HTMLDivElement | null>
} }
const PuzzleBoxContext = createContext<PuzzleBoxContextType | null>(null); const PuzzleBoxContext = createContext<PuzzleBoxContextType | null>(null)
export const PuzzleBoxProvider = ({ children }: { children: ReactNode }) => { export const PuzzleBoxProvider = ({ children }: { children: ReactNode }) => {
const puzzleBoxRef = useRef<HTMLDivElement>(null); const puzzleBoxRef = useRef<HTMLDivElement>(null)
return ( return <PuzzleBoxContext.Provider value={{ puzzleBoxRef }}>{children}</PuzzleBoxContext.Provider>
<PuzzleBoxContext.Provider value={{ puzzleBoxRef }}> }
{children}
</PuzzleBoxContext.Provider>
);
};
export const usePuzzleBox = () => { export const usePuzzleBox = () => {
const context = useContext(PuzzleBoxContext); const context = useContext(PuzzleBoxContext)
if (!context) { if (!context) {
throw new Error("usePuzzleBox must be used within PuzzleBoxProvider"); throw new Error('usePuzzleBox must be used within PuzzleBoxProvider')
} }
return context; return context
}; }
+46 -56
View File
@@ -6,23 +6,23 @@ import {
useState, useState,
useMemo, useMemo,
useCallback, useCallback,
} from "react"; } from 'react'
export interface ScaleContextValue { export interface ScaleContextValue {
scale: number; scale: number
offsetX: number; offsetX: number
offsetY: number; offsetY: number
DESIGN_WIDTH: number; DESIGN_WIDTH: number
DESIGN_HEIGHT: number; DESIGN_HEIGHT: number
scaleStyle: React.CSSProperties; scaleStyle: React.CSSProperties
isMobilePortrait: boolean; isMobilePortrait: boolean
} }
const ScaleContext = createContext<ScaleContextValue | null>(null); const ScaleContext = createContext<ScaleContextValue | null>(null)
// 设计稿尺寸常量 // 设计稿尺寸常量
const DESIGN_WIDTH = 1920; const DESIGN_WIDTH = 1920
const DESIGN_HEIGHT = 1080; const DESIGN_HEIGHT = 1080
/** /**
* 缩放 Provider * 缩放 Provider
@@ -31,71 +31,65 @@ const DESIGN_HEIGHT = 1080;
export function ScaleProvider({ children }: { children: ReactNode }) { export function ScaleProvider({ children }: { children: ReactNode }) {
// 当前窗口尺寸 // 当前窗口尺寸
const [windowWidth, setWindowWidth] = useState( const [windowWidth, setWindowWidth] = useState(
typeof window !== "undefined" ? window.innerWidth : DESIGN_WIDTH typeof window !== 'undefined' ? window.innerWidth : DESIGN_WIDTH
); )
const [windowHeight, setWindowHeight] = useState( const [windowHeight, setWindowHeight] = useState(
typeof window !== "undefined" ? window.innerHeight : DESIGN_HEIGHT typeof window !== 'undefined' ? window.innerHeight : DESIGN_HEIGHT
); )
// 是否移动端竖屏 // 是否移动端竖屏
const isMobilePortrait = useMemo(() => { const isMobilePortrait = useMemo(() => {
if (typeof window === "undefined") return false; if (typeof window === 'undefined') return false
const ua = window.navigator.userAgent.toLowerCase(); const ua = window.navigator.userAgent.toLowerCase()
const isMobile = /iphone|ipad|ipod|android|mobile/.test(ua); const isMobile = /iphone|ipad|ipod|android|mobile/.test(ua)
return isMobile && windowHeight > windowWidth; return isMobile && windowHeight > windowWidth
}, [windowHeight, windowWidth]); }, [windowHeight, windowWidth])
// 缩放比例 // 缩放比例
const scale = useMemo(() => { const scale = useMemo(() => {
const scaleX = windowWidth / DESIGN_WIDTH; const scaleX = windowWidth / DESIGN_WIDTH
const scaleY = windowHeight / DESIGN_HEIGHT; const scaleY = windowHeight / DESIGN_HEIGHT
return Math.min(scaleX, scaleY); return Math.min(scaleX, scaleY)
}, [windowWidth, windowHeight]); }, [windowWidth, windowHeight])
// 缩放后的实际尺寸 // 缩放后的实际尺寸
const scaledWidth = useMemo(() => DESIGN_WIDTH * scale, [scale]); const scaledWidth = useMemo(() => DESIGN_WIDTH * scale, [scale])
const scaledHeight = useMemo(() => DESIGN_HEIGHT * scale, [scale]); const scaledHeight = useMemo(() => DESIGN_HEIGHT * scale, [scale])
// 居中偏移量 // 居中偏移量
const offsetX = useMemo( const offsetX = useMemo(() => (windowWidth - scaledWidth) / 2, [windowWidth, scaledWidth])
() => (windowWidth - scaledWidth) / 2, const offsetY = useMemo(() => (windowHeight - scaledHeight) / 2, [windowHeight, scaledHeight])
[windowWidth, scaledWidth]
);
const offsetY = useMemo(
() => (windowHeight - scaledHeight) / 2,
[windowHeight, scaledHeight]
);
// 缩放样式对象 // 缩放样式对象
const scaleStyle = useMemo<React.CSSProperties>( const scaleStyle = useMemo<React.CSSProperties>(
() => ({ () => ({
position: "absolute", position: 'absolute',
width: `${DESIGN_WIDTH}px`, width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`, height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`, left: `${offsetX}px`,
top: `${offsetY}px`, top: `${offsetY}px`,
transform: `scale(${scale})`, transform: `scale(${scale})`,
transformOrigin: "top left", transformOrigin: 'top left',
}), }),
[scale, offsetX, offsetY] [scale, offsetX, offsetY]
); )
// 更新窗口尺寸(使用 useCallback 优化) // 更新窗口尺寸(使用 useCallback 优化)
const updateWindowSize = useCallback(() => { const updateWindowSize = useCallback(() => {
if (typeof window === "undefined") return; if (typeof window === 'undefined') return
setWindowWidth(window.innerWidth); setWindowWidth(window.innerWidth)
setWindowHeight(window.innerHeight); setWindowHeight(window.innerHeight)
}, []); }, [])
// 监听窗口大小变化 // 监听窗口大小变化
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === 'undefined') return
updateWindowSize(); updateWindowSize()
window.addEventListener("resize", updateWindowSize); window.addEventListener('resize', updateWindowSize)
return () => { return () => {
window.removeEventListener("resize", updateWindowSize); window.removeEventListener('resize', updateWindowSize)
}; }
}, [updateWindowSize]); }, [updateWindowSize])
// Context 值(使用 useMemo 缓存) // Context 值(使用 useMemo 缓存)
const contextValue = useMemo<ScaleContextValue>( const contextValue = useMemo<ScaleContextValue>(
@@ -109,22 +103,18 @@ export function ScaleProvider({ children }: { children: ReactNode }) {
isMobilePortrait, isMobilePortrait,
}), }),
[scale, offsetX, offsetY, scaleStyle, isMobilePortrait] [scale, offsetX, offsetY, scaleStyle, isMobilePortrait]
); )
return ( return <ScaleContext.Provider value={contextValue}>{children}</ScaleContext.Provider>
<ScaleContext.Provider value={contextValue}>
{children}
</ScaleContext.Provider>
);
} }
/** /**
* 使用缩放 Context 的 Hook * 使用缩放 Context 的 Hook
*/ */
export function useScale() { export function useScale() {
const context = useContext(ScaleContext); const context = useContext(ScaleContext)
if (!context) { if (!context) {
throw new Error("useScale must be used within ScaleProvider"); throw new Error('useScale must be used within ScaleProvider')
} }
return context; return context
} }
+16 -16
View File
@@ -1,42 +1,42 @@
import { createContext, useContext, ReactNode } from "react"; import { createContext, useContext, ReactNode } from 'react'
interface VideoContextValue { interface VideoContextValue {
/** 视频是否正在播放 */ /** 视频是否正在播放 */
isVideoPlaying: boolean; isVideoPlaying: boolean
/** 内容是否可见 */ /** 内容是否可见 */
isContentVisible: boolean; isContentVisible: boolean
} }
const VideoContext = createContext<VideoContextValue | undefined>(undefined); const VideoContext = createContext<VideoContextValue | undefined>(undefined)
export const VideoProvider = ({ export const VideoProvider = ({
children, children,
isVideoPlaying, isVideoPlaying,
isContentVisible, isContentVisible,
}: { }: {
children: ReactNode; children: ReactNode
isVideoPlaying: boolean; isVideoPlaying: boolean
isContentVisible: boolean; isContentVisible: boolean
}) => { }) => {
return ( return (
<VideoContext.Provider value={{ isVideoPlaying, isContentVisible }}> <VideoContext.Provider value={{ isVideoPlaying, isContentVisible }}>
{children} {children}
</VideoContext.Provider> </VideoContext.Provider>
); )
}; }
export const useVideoContext = () => { export const useVideoContext = () => {
const context = useContext(VideoContext); const context = useContext(VideoContext)
if (context === undefined) { if (context === undefined) {
throw new Error("useVideoContext must be used within a VideoProvider"); throw new Error('useVideoContext must be used within a VideoProvider')
} }
return context; return context
}; }
/** /**
* 安全地获取视频上下文,如果不在 VideoProvider 中则返回 null * 安全地获取视频上下文,如果不在 VideoProvider 中则返回 null
*/ */
export const useVideoContextSafe = () => { export const useVideoContextSafe = () => {
const context = useContext(VideoContext); const context = useContext(VideoContext)
return context ?? null; return context ?? null
}; }
+8 -16
View File
@@ -1,16 +1,8 @@
export { useDragAndDrop } from "./useDragAndDrop"; export { useDragAndDrop } from './useDragAndDrop'
export { usePageScale } from "./usePageScale"; export { usePageScale } from './usePageScale'
export { useTouch } from "./useTouch"; export { useTouch } from './useTouch'
export { useZoomAndDrag } from "./useZoomAndDrag"; export { useZoomAndDrag } from './useZoomAndDrag'
export { useCursorController } from "./useCursorController"; export { useCursorController } from './useCursorController'
export type { export type { UseTouchOptions, UseTouchReturn, TouchEventData, SwipeDirection } from './useTouch'
UseTouchOptions, export type { UseZoomAndDragOptions, UseZoomAndDragReturn } from './useZoomAndDrag'
UseTouchReturn, export type { CursorControllerConfig } from './useCursorController'
TouchEventData,
SwipeDirection,
} from "./useTouch";
export type {
UseZoomAndDragOptions,
UseZoomAndDragReturn,
} from "./useZoomAndDrag";
export type { CursorControllerConfig } from "./useCursorController";
+45 -45
View File
@@ -1,64 +1,64 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from 'react'
type AudioMode = "global" | "channel"; type AudioMode = 'global' | 'channel'
interface UseAudioManagerOptions { interface UseAudioManagerOptions {
mode?: AudioMode; mode?: AudioMode
channelId?: string; channelId?: string
} }
interface AudioState { interface AudioState {
key: string | null; key: string | null
currentSrc: string | null; currentSrc: string | null
isPlaying: boolean; isPlaying: boolean
} }
// 全局音频池:每个 key(全局或某个 channel)对应一个 HTMLAudioElement // 全局音频池:每个 key(全局或某个 channel)对应一个 HTMLAudioElement
const audioPool = new Map<string, HTMLAudioElement>(); const audioPool = new Map<string, HTMLAudioElement>()
// 订阅者列表,用于在任意地方播放/暂停时同步状态 // 订阅者列表,用于在任意地方播放/暂停时同步状态
const listeners = new Set<(state: AudioState) => void>(); const listeners = new Set<(state: AudioState) => void>()
let globalState: AudioState = { let globalState: AudioState = {
key: null, key: null,
currentSrc: null, currentSrc: null,
isPlaying: false, isPlaying: false,
}; }
function emit(state: AudioState) { function emit(state: AudioState) {
globalState = state; globalState = state
listeners.forEach((listener) => listener(state)); listeners.forEach((listener) => listener(state))
} }
function resolveKey(options?: UseAudioManagerOptions): string { function resolveKey(options?: UseAudioManagerOptions): string {
const mode = options?.mode ?? "global"; const mode = options?.mode ?? 'global'
if (mode === "global") { if (mode === 'global') {
return "__GLOBAL__"; return '__GLOBAL__'
} }
// mode === "channel" // mode === "channel"
return `channel:${options?.channelId ?? "__DEFAULT__"}`; return `channel:${options?.channelId ?? '__DEFAULT__'}`
} }
function getOrCreateAudio(key: string): HTMLAudioElement { function getOrCreateAudio(key: string): HTMLAudioElement {
let audio = audioPool.get(key); let audio = audioPool.get(key)
if (!audio) { if (!audio) {
audio = new Audio(); audio = new Audio()
audioPool.set(key, audio); audioPool.set(key, audio)
audio.addEventListener("ended", () => { audio.addEventListener('ended', () => {
// 某个 key 下的音频自然播放结束时,仅更新该 key 对应的播放状态 // 某个 key 下的音频自然播放结束时,仅更新该 key 对应的播放状态
if (globalState.key === key) { if (globalState.key === key) {
emit({ emit({
key, key,
currentSrc: globalState.currentSrc, currentSrc: globalState.currentSrc,
isPlaying: false, isPlaying: false,
}); })
} }
}); })
} }
return audio; return audio
} }
/** /**
@@ -67,70 +67,70 @@ function getOrCreateAudio(key: string): HTMLAudioElement {
* - mode = "channel" + channelId:同一 channel 内互斥,channel 之间互不影响 * - mode = "channel" + channelId:同一 channel 内互斥,channel 之间互不影响
*/ */
export function useAudioManager(options?: UseAudioManagerOptions) { export function useAudioManager(options?: UseAudioManagerOptions) {
const [state, setState] = useState<AudioState>(globalState); const [state, setState] = useState<AudioState>(globalState)
// 将 options 展开为基础类型,避免在 useCallback 中依赖对象引用导致每次 render 都重建函数 // 将 options 展开为基础类型,避免在 useCallback 中依赖对象引用导致每次 render 都重建函数
const resolvedMode: AudioMode = options?.mode ?? "global"; const resolvedMode: AudioMode = options?.mode ?? 'global'
const resolvedChannelId: string | undefined = options?.channelId; const resolvedChannelId: string | undefined = options?.channelId
useEffect(() => { useEffect(() => {
listeners.add(setState); listeners.add(setState)
return () => { return () => {
listeners.delete(setState); listeners.delete(setState)
}; }
}, []); }, [])
const play = useCallback( const play = useCallback(
(src: string) => { (src: string) => {
const key = resolveKey({ const key = resolveKey({
mode: resolvedMode, mode: resolvedMode,
channelId: resolvedChannelId, channelId: resolvedChannelId,
}); })
const audio = getOrCreateAudio(key); const audio = getOrCreateAudio(key)
if (audio.src !== src) { if (audio.src !== src) {
audio.src = src; audio.src = src
} }
audio.currentTime = 0; audio.currentTime = 0
// 捕获浏览器因策略或中断导致的 play() 拒绝,避免控制台 Unhandled promise // 捕获浏览器因策略或中断导致的 play() 拒绝,避免控制台 Unhandled promise
audio.play().catch(() => { audio.play().catch(() => {
// 可以在这里按需上报或忽略 // 可以在这里按需上报或忽略
}); })
emit({ emit({
key, key,
currentSrc: src, currentSrc: src,
isPlaying: true, isPlaying: true,
}); })
}, },
[resolvedMode, resolvedChannelId] [resolvedMode, resolvedChannelId]
); )
const stop = useCallback(() => { const stop = useCallback(() => {
const key = resolveKey({ const key = resolveKey({
mode: resolvedMode, mode: resolvedMode,
channelId: resolvedChannelId, channelId: resolvedChannelId,
}); })
const audio = audioPool.get(key); const audio = audioPool.get(key)
if (!audio) return; if (!audio) return
audio.pause(); audio.pause()
audio.currentTime = 0; audio.currentTime = 0
if (globalState.key === key) { if (globalState.key === key) {
emit({ emit({
key, key,
currentSrc: globalState.currentSrc, currentSrc: globalState.currentSrc,
isPlaying: false, isPlaying: false,
}); })
} }
}, [resolvedMode, resolvedChannelId]); }, [resolvedMode, resolvedChannelId])
return { return {
...state, ...state,
play, play,
stop, stop,
}; }
} }
+113 -136
View File
@@ -1,27 +1,27 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
/** Cursor 解锁规则 */ /** Cursor 解锁规则 */
export interface CursorUnlockRule { export interface CursorUnlockRule {
/** cursor ID */ /** cursor ID */
id: string | number; id: string | number
/** 依赖的展品 ID 列表,当这些展品都被访问后,自动解锁此 cursor */ /** 依赖的展品 ID 列表,当这些展品都被访问后,自动解锁此 cursor */
dependsOn?: (string | number)[]; dependsOn?: (string | number)[]
/** 初始是否启用(如果未配置 dependsOn,则使用此值) */ /** 初始是否启用(如果未配置 dependsOn,则使用此值) */
initialEnabled?: boolean; initialEnabled?: boolean
} }
/** Cursor 控制器配置 */ /** Cursor 控制器配置 */
export interface CursorControllerConfig { export interface CursorControllerConfig {
/** 方式1:使用解锁规则(推荐,更灵活) */ /** 方式1:使用解锁规则(推荐,更灵活) */
unlockRules?: CursorUnlockRule[]; unlockRules?: CursorUnlockRule[]
/** 方式2:使用依赖映射(简化配置,格式:{ cursorId: [依赖的展品Id列表] }) */ /** 方式2:使用依赖映射(简化配置,格式:{ cursorId: [依赖的展品Id列表] }) */
dependencies?: Record<string | number, (string | number)[]>; dependencies?: Record<string | number, (string | number)[]>
/** 已访问的展品 ID(支持 Set 或数组,内部会自动处理引用问题) */ /** 已访问的展品 ID(支持 Set 或数组,内部会自动处理引用问题) */
visited?: Set<string | number> | (string | number)[]; visited?: Set<string | number> | (string | number)[]
/** 初始启用的 cursor ID 列表(向后兼容,如果没有配置 unlockRules 或 dependencies 则使用此值) */ /** 初始启用的 cursor ID 列表(向后兼容,如果没有配置 unlockRules 或 dependencies 则使用此值) */
initialEnabled?: (string | number)[]; initialEnabled?: (string | number)[]
/** cursor 完成时的回调函数 */ /** cursor 完成时的回调函数 */
onCursorComplete?: (id: string | number) => void; onCursorComplete?: (id: string | number) => void
} }
/** /**
@@ -61,105 +61,93 @@ export interface CursorControllerConfig {
* ``` * ```
*/ */
export const useCursorController = (config: CursorControllerConfig = {}) => { export const useCursorController = (config: CursorControllerConfig = {}) => {
const { const { unlockRules = [], dependencies = {}, visited, initialEnabled = [] } = config
unlockRules = [],
dependencies = {},
visited,
initialEnabled = [],
} = config;
// 手动覆盖的状态(优先级高于自动解锁) // 手动覆盖的状态(优先级高于自动解锁)
const [manualOverrides, setManualOverrides] = useState< const [manualOverrides, setManualOverrides] = useState<Map<string | number, boolean>>(new Map())
Map<string | number, boolean>
>(new Map());
// 将 visited(Set 或数组)序列化为稳定的 key,用于比较内容变化 // 将 visited(Set 或数组)序列化为稳定的 key,用于比较内容变化
// 无论传入 Set 还是数组,都转换为数组再序列化,避免引用问题 // 无论传入 Set 还是数组,都转换为数组再序列化,避免引用问题
const visitedKey = useMemo(() => { const visitedKey = useMemo(() => {
if (!visited) return ""; if (!visited) return ''
const visitedArray = Array.isArray(visited) ? visited : Array.from(visited); const visitedArray = Array.isArray(visited) ? visited : Array.from(visited)
if (visitedArray.length === 0) return ""; if (visitedArray.length === 0) return ''
const sorted = [...visitedArray].sort((a, b) => Number(a) - Number(b)); const sorted = [...visitedArray].sort((a, b) => Number(a) - Number(b))
return sorted.join(","); return sorted.join(',')
}, [visited]); }, [visited])
// 使用 ref 存储上一次的 key,避免不必要的重新计算 // 使用 ref 存储上一次的 key,避免不必要的重新计算
const prevKeyRef = useRef<string>(""); const prevKeyRef = useRef<string>('')
// 使用 ref 存储最新的 visited,避免在依赖中使用对象引用 // 使用 ref 存储最新的 visited,避免在依赖中使用对象引用
const visitedRef = useRef<Set<string | number>>(new Set()); const visitedRef = useRef<Set<string | number>>(new Set())
// 将 visited(Set 或数组)转换为 Set,并更新 ref // 将 visited(Set 或数组)转换为 Set,并更新 ref
// 只在内容变化时更新,避免引用问题 // 只在内容变化时更新,避免引用问题
useMemo(() => { useMemo(() => {
if (!visited) { if (!visited) {
visitedRef.current = new Set<string | number>(); visitedRef.current = new Set<string | number>()
return; return
} }
const newSet = Array.isArray(visited) ? new Set(visited) : visited; const newSet = Array.isArray(visited) ? new Set(visited) : visited
visitedRef.current = newSet; visitedRef.current = newSet
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [visitedKey]); // 使用 visitedKey 而不是 visited 对象,避免引用问题 }, [visitedKey]) // 使用 visitedKey 而不是 visited 对象,避免引用问题
// 计算初始启用的 cursor // 计算初始启用的 cursor
const getInitialEnabled = useCallback(() => { const getInitialEnabled = useCallback(() => {
const enabled = new Set<string | number>(); const enabled = new Set<string | number>()
const currentVisitedSet = visitedRef.current; // 使用 ref 中的最新值 const currentVisitedSet = visitedRef.current // 使用 ref 中的最新值
// 优先使用 unlockRules // 优先使用 unlockRules
if (unlockRules.length > 0) { if (unlockRules.length > 0) {
unlockRules.forEach((rule) => { unlockRules.forEach((rule) => {
if (rule.dependsOn && rule.dependsOn.length > 0) { if (rule.dependsOn && rule.dependsOn.length > 0) {
// 检查依赖是否都满足 // 检查依赖是否都满足
const allDepsMet = rule.dependsOn.every((dep) => const allDepsMet = rule.dependsOn.every((dep) => currentVisitedSet.has(dep))
currentVisitedSet.has(dep)
);
if (allDepsMet) { if (allDepsMet) {
enabled.add(rule.id); enabled.add(rule.id)
} }
} else if (rule.initialEnabled) { } else if (rule.initialEnabled) {
enabled.add(rule.id); enabled.add(rule.id)
} }
}); })
return enabled; return enabled
} }
// 其次使用 dependencies // 其次使用 dependencies
if (Object.keys(dependencies).length > 0) { if (Object.keys(dependencies).length > 0) {
Object.entries(dependencies).forEach(([cursorId, deps]) => { Object.entries(dependencies).forEach(([cursorId, deps]) => {
const id = isNaN(Number(cursorId)) ? cursorId : Number(cursorId); const id = isNaN(Number(cursorId)) ? cursorId : Number(cursorId)
if ( if (deps.length === 0 || deps.every((dep) => currentVisitedSet.has(dep))) {
deps.length === 0 || enabled.add(id)
deps.every((dep) => currentVisitedSet.has(dep))
) {
enabled.add(id);
} }
}); })
initialEnabled.forEach((id) => enabled.add(id)); initialEnabled.forEach((id) => enabled.add(id))
return enabled; return enabled
} }
// 最后使用 initialEnabled(向后兼容) // 最后使用 initialEnabled(向后兼容)
initialEnabled.forEach((id) => enabled.add(id)); initialEnabled.forEach((id) => enabled.add(id))
return enabled; return enabled
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [unlockRules, dependencies, visitedKey, initialEnabled]); }, [unlockRules, dependencies, visitedKey, initialEnabled])
// 将 manualOverrides 序列化为 key // 将 manualOverrides 序列化为 key
const manualOverridesKey = useMemo(() => { const manualOverridesKey = useMemo(() => {
if (manualOverrides.size === 0) return ""; if (manualOverrides.size === 0) return ''
const sorted = Array.from(manualOverrides.entries()) const sorted = Array.from(manualOverrides.entries())
.sort(([a], [b]) => Number(a) - Number(b)) .sort(([a], [b]) => Number(a) - Number(b))
.map(([id, enabled]) => `${id}:${enabled}`) .map(([id, enabled]) => `${id}:${enabled}`)
.join(","); .join(',')
return sorted; return sorted
}, [manualOverrides]); }, [manualOverrides])
// 根据解锁规则和 visited 状态计算应该启用的 cursor // 根据解锁规则和 visited 状态计算应该启用的 cursor
const calculateEnabledCursors = useCallback(() => { const calculateEnabledCursors = useCallback(() => {
const enabled = new Set<string | number>(); const enabled = new Set<string | number>()
// 使用 ref 中的最新值,避免依赖问题 // 使用 ref 中的最新值,避免依赖问题
const currentVisitedSet = visitedRef.current; const currentVisitedSet = visitedRef.current
// 优先使用 unlockRules // 优先使用 unlockRules
if (unlockRules.length > 0) { if (unlockRules.length > 0) {
@@ -167,159 +155,148 @@ export const useCursorController = (config: CursorControllerConfig = {}) => {
// 手动覆盖优先 // 手动覆盖优先
if (manualOverrides.has(rule.id)) { if (manualOverrides.has(rule.id)) {
if (manualOverrides.get(rule.id)) { if (manualOverrides.get(rule.id)) {
enabled.add(rule.id); enabled.add(rule.id)
} }
return; return
} }
// 自动解锁逻辑 // 自动解锁逻辑
if (rule.dependsOn && rule.dependsOn.length > 0) { if (rule.dependsOn && rule.dependsOn.length > 0) {
const allDepsMet = rule.dependsOn.every((dep) => const allDepsMet = rule.dependsOn.every((dep) => currentVisitedSet.has(dep))
currentVisitedSet.has(dep)
);
if (allDepsMet) { if (allDepsMet) {
enabled.add(rule.id); enabled.add(rule.id)
} }
} else if (rule.initialEnabled) { } else if (rule.initialEnabled) {
enabled.add(rule.id); enabled.add(rule.id)
} }
}); })
return enabled; return enabled
} }
// 其次使用 dependencies // 其次使用 dependencies
if (Object.keys(dependencies).length > 0) { if (Object.keys(dependencies).length > 0) {
const allCursorIds = new Set([ const allCursorIds = new Set([
...initialEnabled, ...initialEnabled,
...Object.keys(dependencies).map((key) => ...Object.keys(dependencies).map((key) => (isNaN(Number(key)) ? key : Number(key))),
isNaN(Number(key)) ? key : Number(key) ])
),
]);
allCursorIds.forEach((id) => { allCursorIds.forEach((id) => {
// 手动覆盖优先 // 手动覆盖优先
if (manualOverrides.has(id)) { if (manualOverrides.has(id)) {
if (manualOverrides.get(id)) { if (manualOverrides.get(id)) {
enabled.add(id); enabled.add(id)
} }
return; return
} }
// 自动解锁逻辑 // 自动解锁逻辑
const deps = dependencies[id]; const deps = dependencies[id]
if (deps && deps.length > 0) { if (deps && deps.length > 0) {
const allDepsMet = deps.every((dep) => currentVisitedSet.has(dep)); const allDepsMet = deps.every((dep) => currentVisitedSet.has(dep))
if (allDepsMet) { if (allDepsMet) {
enabled.add(id); enabled.add(id)
} }
} else if (initialEnabled.includes(id)) { } else if (initialEnabled.includes(id)) {
enabled.add(id); enabled.add(id)
} }
}); })
return enabled; return enabled
} }
// 最后使用 initialEnabled(向后兼容) // 最后使用 initialEnabled(向后兼容)
initialEnabled.forEach((id) => { initialEnabled.forEach((id) => {
if (manualOverrides.has(id)) { if (manualOverrides.has(id)) {
if (manualOverrides.get(id)) { if (manualOverrides.get(id)) {
enabled.add(id); enabled.add(id)
} }
} else { } else {
enabled.add(id); enabled.add(id)
} }
}); })
return enabled; return enabled
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [unlockRules, dependencies, visitedKey, initialEnabled, manualOverridesKey])
unlockRules,
dependencies,
visitedKey,
initialEnabled,
manualOverridesKey,
]);
const [enabledCursors, setEnabledCursors] = const [enabledCursors, setEnabledCursors] = useState<Set<string | number>>(getInitialEnabled)
useState<Set<string | number>>(getInitialEnabled);
// 当 visited 或 manualOverrides 内容变化时,自动重新计算 // 当 visited 或 manualOverrides 内容变化时,自动重新计算
useEffect(() => { useEffect(() => {
const currentKey = `${visitedKey}|${manualOverridesKey}`; const currentKey = `${visitedKey}|${manualOverridesKey}`
// 只在 key 真正变化时才重新计算 // 只在 key 真正变化时才重新计算
if (prevKeyRef.current !== currentKey) { if (prevKeyRef.current !== currentKey) {
prevKeyRef.current = currentKey; prevKeyRef.current = currentKey
setEnabledCursors(calculateEnabledCursors()); setEnabledCursors(calculateEnabledCursors())
} }
}, [visitedKey, manualOverridesKey, calculateEnabledCursors]); }, [visitedKey, manualOverridesKey, calculateEnabledCursors])
// 激活 cursor(手动控制,会覆盖自动解锁) // 激活 cursor(手动控制,会覆盖自动解锁)
const enable = useCallback((ids: string | number | (string | number)[]) => { const enable = useCallback((ids: string | number | (string | number)[]) => {
setManualOverrides((prev) => { setManualOverrides((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev)
const idsArray = Array.isArray(ids) ? ids : [ids]; const idsArray = Array.isArray(ids) ? ids : [ids]
idsArray.forEach((id) => { idsArray.forEach((id) => {
newMap.set(id, true); newMap.set(id, true)
}); })
return newMap; return newMap
}); })
setEnabledCursors((prev) => { setEnabledCursors((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev)
const idsArray = Array.isArray(ids) ? ids : [ids]; const idsArray = Array.isArray(ids) ? ids : [ids]
idsArray.forEach((id) => { idsArray.forEach((id) => {
newSet.add(id); newSet.add(id)
}); })
return newSet; return newSet
}); })
}, []); }, [])
// 禁用 cursor // 禁用 cursor
const disable = useCallback((ids: string | number | (string | number)[]) => { const disable = useCallback((ids: string | number | (string | number)[]) => {
setManualOverrides((prev) => { setManualOverrides((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev)
const idsArray = Array.isArray(ids) ? ids : [ids]; const idsArray = Array.isArray(ids) ? ids : [ids]
idsArray.forEach((id) => { idsArray.forEach((id) => {
newMap.set(id, false); newMap.set(id, false)
}); })
return newMap; return newMap
}); })
setEnabledCursors((prev) => { setEnabledCursors((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev)
const idsArray = Array.isArray(ids) ? ids : [ids]; const idsArray = Array.isArray(ids) ? ids : [ids]
idsArray.forEach((id) => { idsArray.forEach((id) => {
newSet.delete(id); newSet.delete(id)
}); })
return newSet; return newSet
}); })
}, []); }, [])
// 检查是否启用 // 检查是否启用
const isEnabled = useCallback( const isEnabled = useCallback(
(id: string | number) => { (id: string | number) => {
return enabledCursors.has(id); return enabledCursors.has(id)
}, },
[enabledCursors] [enabledCursors]
); )
// 获取所有启用的 cursor // 获取所有启用的 cursor
const getEnabledCursors = useCallback(() => { const getEnabledCursors = useCallback(() => {
return new Set(enabledCursors); return new Set(enabledCursors)
}, [enabledCursors]); }, [enabledCursors])
// 清除手动覆盖,恢复自动解锁 // 清除手动覆盖,恢复自动解锁
const clearManualOverride = useCallback((id: string | number) => { const clearManualOverride = useCallback((id: string | number) => {
setManualOverrides((prev) => { setManualOverrides((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev)
newMap.delete(id); newMap.delete(id)
return newMap; return newMap
}); })
}, []); }, [])
// 重置状态 // 重置状态
const reset = useCallback(() => { const reset = useCallback(() => {
setManualOverrides(new Map()); setManualOverrides(new Map())
setEnabledCursors(getInitialEnabled()); setEnabledCursors(getInitialEnabled())
}, [getInitialEnabled]); }, [getInitialEnabled])
return { return {
enable, enable,
@@ -328,5 +305,5 @@ export const useCursorController = (config: CursorControllerConfig = {}) => {
getEnabledCursors, getEnabledCursors,
clearManualOverride, clearManualOverride,
reset, reset,
}; }
}; }
+45 -51
View File
@@ -1,10 +1,10 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from 'react'
import { useScale } from "@/contexts/ScaleContext"; import { useScale } from '@/contexts/ScaleContext'
interface DragState<T = any> { interface DragState<T = any> {
isDragging: boolean; isDragging: boolean
dragData: T | null; dragData: T | null
mousePosition: { x: number; y: number }; mousePosition: { x: number; y: number }
} }
/** /**
@@ -12,71 +12,71 @@ interface DragState<T = any> {
* 支持页面 scale 缩放的拖拽功能 * 支持页面 scale 缩放的拖拽功能
*/ */
export function useDragAndDrop<T = any>() { export function useDragAndDrop<T = any>() {
const { scale, offsetX, offsetY } = useScale(); const { scale, offsetX, offsetY } = useScale()
const [state, setState] = useState<DragState<T>>({ const [state, setState] = useState<DragState<T>>({
isDragging: false, isDragging: false,
dragData: null, dragData: null,
mousePosition: { x: 0, y: 0 }, mousePosition: { x: 0, y: 0 },
}); })
const isDraggingRef = useRef(false); const isDraggingRef = useRef(false)
// 开始拖拽 // 开始拖拽
const startDrag = useCallback((data: T, event?: React.MouseEvent) => { const startDrag = useCallback((data: T, event?: React.MouseEvent) => {
if (event) { if (event) {
event.preventDefault(); event.preventDefault()
event.stopPropagation(); event.stopPropagation()
// 立即获取鼠标初始位置 // 立即获取鼠标初始位置
isDraggingRef.current = true; isDraggingRef.current = true
setState({ setState({
isDragging: true, isDragging: true,
dragData: data, dragData: data,
mousePosition: { x: event.clientX, y: event.clientY }, mousePosition: { x: event.clientX, y: event.clientY },
}); })
} else { } else {
isDraggingRef.current = true; isDraggingRef.current = true
setState({ setState({
isDragging: true, isDragging: true,
dragData: data, dragData: data,
mousePosition: { x: 0, y: 0 }, mousePosition: { x: 0, y: 0 },
}); })
} }
}, []); }, [])
// 结束拖拽 // 结束拖拽
const endDrag = useCallback(() => { const endDrag = useCallback(() => {
isDraggingRef.current = false; isDraggingRef.current = false
setState({ setState({
isDragging: false, isDragging: false,
dragData: null, dragData: null,
mousePosition: { x: 0, y: 0 }, mousePosition: { x: 0, y: 0 },
}); })
}, []); }, [])
// 监听鼠标移动 - 使用独立的事件监听器 // 监听鼠标移动 - 使用独立的事件监听器
useEffect(() => { useEffect(() => {
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return; if (!isDraggingRef.current) return
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
mousePosition: { x: e.clientX, y: e.clientY }, mousePosition: { x: e.clientX, y: e.clientY },
})); }))
}; }
const handleMouseUp = () => { const handleMouseUp = () => {
// 拖拽结束的逻辑由外部处理 // 拖拽结束的逻辑由外部处理
// 这里只是提供一个安全机制 // 这里只是提供一个安全机制
}; }
// 始终添加事件监听器,在handleMouseMove内部检查拖拽状态 // 始终添加事件监听器,在handleMouseMove内部检查拖拽状态
window.addEventListener("mousemove", handleMouseMove); window.addEventListener('mousemove', handleMouseMove)
window.addEventListener("mouseup", handleMouseUp); window.addEventListener('mouseup', handleMouseUp)
return () => { return () => {
window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp); window.removeEventListener('mouseup', handleMouseUp)
}; }
}, []); // 空依赖数组,只在组件挂载/卸载时执行 }, []) // 空依赖数组,只在组件挂载/卸载时执行
// 将屏幕坐标转换为缩放后的页面坐标 // 将屏幕坐标转换为缩放后的页面坐标
const screenToScaledCoords = useCallback( const screenToScaledCoords = useCallback(
@@ -84,26 +84,23 @@ export function useDragAndDrop<T = any>() {
return { return {
x: (screenX - offsetX) / scale, x: (screenX - offsetX) / scale,
y: (screenY - offsetY) / scale, y: (screenY - offsetY) / scale,
}; }
}, },
[scale, offsetX, offsetY] [scale, offsetX, offsetY]
); )
// 检查点是否在元素内 // 检查点是否在元素内
const isPointInElement = useCallback( const isPointInElement = useCallback(
(pointX: number, pointY: number, element: HTMLElement | null): boolean => { (pointX: number, pointY: number, element: HTMLElement | null): boolean => {
if (!element) return false; if (!element) return false
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect()
return ( return (
pointX >= rect.left && pointX >= rect.left && pointX <= rect.right && pointY >= rect.top && pointY <= rect.bottom
pointX <= rect.right && )
pointY >= rect.top &&
pointY <= rect.bottom
);
}, },
[] []
); )
return { return {
isDragging: state.isDragging, isDragging: state.isDragging,
@@ -113,7 +110,7 @@ export function useDragAndDrop<T = any>() {
endDrag, endDrag,
screenToScaledCoords, screenToScaledCoords,
isPointInElement, isPointInElement,
}; }
} }
/** /**
@@ -121,28 +118,25 @@ export function useDragAndDrop<T = any>() {
* 用于检测拖拽元素是否悬停在放置区域上 * 用于检测拖拽元素是否悬停在放置区域上
*/ */
export function useDropZone() { export function useDropZone() {
const dropZoneRef = useRef<HTMLDivElement>(null); const dropZoneRef = useRef<HTMLDivElement>(null)
const [isOver, setIsOver] = useState(false); const [isOver, setIsOver] = useState(false)
// 检查鼠标是否在放置区域内 // 检查鼠标是否在放置区域内
const checkIsOver = useCallback((mouseX: number, mouseY: number) => { const checkIsOver = useCallback((mouseX: number, mouseY: number) => {
if (!dropZoneRef.current) return false; if (!dropZoneRef.current) return false
const rect = dropZoneRef.current.getBoundingClientRect(); const rect = dropZoneRef.current.getBoundingClientRect()
const over = const over =
mouseX >= rect.left && mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom
mouseX <= rect.right &&
mouseY >= rect.top &&
mouseY <= rect.bottom;
// 只有当状态真正改变时才更新 // 只有当状态真正改变时才更新
setIsOver((prev) => (prev !== over ? over : prev)); setIsOver((prev) => (prev !== over ? over : prev))
return over; return over
}, []); }, [])
return { return {
dropZoneRef, dropZoneRef,
isOver, isOver,
checkIsOver, checkIsOver,
}; }
} }
+31 -37
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from 'react'
/** /**
* 等比例缩放Hook * 等比例缩放Hook
@@ -6,72 +6,66 @@ import { useEffect, useState, useMemo } from "react";
*/ */
export function usePageScale() { export function usePageScale() {
// 设计稿尺寸 // 设计稿尺寸
const DESIGN_WIDTH = 1920; const DESIGN_WIDTH = 1920
const DESIGN_HEIGHT = 1080; const DESIGN_HEIGHT = 1080
// 当前窗口尺寸 // 当前窗口尺寸
const [windowWidth, setWindowWidth] = useState(window.innerWidth); const [windowWidth, setWindowWidth] = useState(window.innerWidth)
const [windowHeight, setWindowHeight] = useState(window.innerHeight); const [windowHeight, setWindowHeight] = useState(window.innerHeight)
// 是否移动端竖屏(需要旋转横屏展示) // 是否移动端竖屏(需要旋转横屏展示)
const isMobilePortrait = useMemo(() => { const isMobilePortrait = useMemo(() => {
const ua = window.navigator.userAgent.toLowerCase(); const ua = window.navigator.userAgent.toLowerCase()
const isMobile = /iphone|ipad|ipod|android|mobile/.test(ua); const isMobile = /iphone|ipad|ipod|android|mobile/.test(ua)
return isMobile && windowHeight > windowWidth; return isMobile && windowHeight > windowWidth
}, [windowHeight, windowWidth]); }, [windowHeight, windowWidth])
// 缩放比例 // 缩放比例
const scale = useMemo(() => { const scale = useMemo(() => {
const baseWidth = DESIGN_WIDTH; const baseWidth = DESIGN_WIDTH
const baseHeight = DESIGN_HEIGHT; const baseHeight = DESIGN_HEIGHT
const scaleX = windowWidth / baseWidth; const scaleX = windowWidth / baseWidth
const scaleY = windowHeight / baseHeight; const scaleY = windowHeight / baseHeight
return Math.min(scaleX, scaleY); return Math.min(scaleX, scaleY)
}, [windowWidth, windowHeight]); }, [windowWidth, windowHeight])
// 缩放后的实际尺寸 // 缩放后的实际尺寸
const scaledWidth = useMemo(() => DESIGN_WIDTH * scale, [scale]); const scaledWidth = useMemo(() => DESIGN_WIDTH * scale, [scale])
const scaledHeight = useMemo(() => DESIGN_HEIGHT * scale, [scale]); const scaledHeight = useMemo(() => DESIGN_HEIGHT * scale, [scale])
// 居中偏移量 // 居中偏移量
const offsetX = useMemo( const offsetX = useMemo(() => (windowWidth - scaledWidth) / 2, [windowWidth, scaledWidth])
() => (windowWidth - scaledWidth) / 2, const offsetY = useMemo(() => (windowHeight - scaledHeight) / 2, [windowHeight, scaledHeight])
[windowWidth, scaledWidth]
);
const offsetY = useMemo(
() => (windowHeight - scaledHeight) / 2,
[windowHeight, scaledHeight]
);
// 缩放样式对象 // 缩放样式对象
const scaleStyle = useMemo( const scaleStyle = useMemo(
() => ({ () => ({
position: "absolute" as const, position: 'absolute' as const,
width: `${DESIGN_WIDTH}px`, width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`, height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`, left: `${offsetX}px`,
top: `${offsetY}px`, top: `${offsetY}px`,
transform: `scale(${scale})`, transform: `scale(${scale})`,
transformOrigin: "top left", transformOrigin: 'top left',
}), }),
[scale, offsetX, offsetY] [scale, offsetX, offsetY]
); )
// 更新窗口尺寸 // 更新窗口尺寸
const updateWindowSize = () => { const updateWindowSize = () => {
setWindowWidth(window.innerWidth); setWindowWidth(window.innerWidth)
setWindowHeight(window.innerHeight); setWindowHeight(window.innerHeight)
}; }
// 监听窗口大小变化 // 监听窗口大小变化
useEffect(() => { useEffect(() => {
window.addEventListener("resize", updateWindowSize); window.addEventListener('resize', updateWindowSize)
updateWindowSize(); updateWindowSize()
return () => { return () => {
window.removeEventListener("resize", updateWindowSize); window.removeEventListener('resize', updateWindowSize)
}; }
}, []); }, [])
return { return {
// 响应式数据 // 响应式数据
@@ -90,5 +84,5 @@ export function usePageScale() {
// 常量 // 常量
DESIGN_WIDTH, DESIGN_WIDTH,
DESIGN_HEIGHT, DESIGN_HEIGHT,
}; }
} }
+146 -158
View File
@@ -1,39 +1,39 @@
import { useRef, useCallback, useState } from "react"; import { useRef, useCallback, useState } from 'react'
/** /**
* 触摸点信息 * 触摸点信息
*/ */
interface TouchPoint { interface TouchPoint {
x: number; x: number
y: number; y: number
timestamp: number; timestamp: number
} }
/** /**
* 滑动方向 * 滑动方向
*/ */
export type SwipeDirection = "left" | "right" | "up" | "down"; export type SwipeDirection = 'left' | 'right' | 'up' | 'down'
/** /**
* 手势事件回调参数 * 手势事件回调参数
*/ */
export interface TouchEventData { export interface TouchEventData {
/** 起始位置 */ /** 起始位置 */
startPoint: TouchPoint; startPoint: TouchPoint
/** 当前位置 */ /** 当前位置 */
currentPoint: TouchPoint; currentPoint: TouchPoint
/** 移动距离 X */ /** 移动距离 X */
deltaX: number; deltaX: number
/** 移动距离 Y */ /** 移动距离 Y */
deltaY: number; deltaY: number
/** 移动的总距离 */ /** 移动的总距离 */
distance: number; distance: number
/** 移动速度(像素/毫秒) */ /** 移动速度(像素/毫秒) */
velocity: number; velocity: number
/** 移动方向 */ /** 移动方向 */
direction: SwipeDirection | null; direction: SwipeDirection | null
/** 原始事件 */ /** 原始事件 */
event: React.TouchEvent | React.MouseEvent; event: React.TouchEvent | React.MouseEvent
} }
/** /**
@@ -41,55 +41,55 @@ export interface TouchEventData {
*/ */
export interface UseTouchOptions { export interface UseTouchOptions {
/** 是否启用,默认 true */ /** 是否启用,默认 true */
enabled?: boolean; enabled?: boolean
// ========== 滑动配置 ========== // ========== 滑动配置 ==========
/** 触发滑动的最小距离(像素),默认 10 */ /** 触发滑动的最小距离(像素),默认 10 */
swipeThreshold?: number; swipeThreshold?: number
/** 触发快速滑动的最小速度(像素/毫秒),默认 0.5 */ /** 触发快速滑动的最小速度(像素/毫秒),默认 0.5 */
swipeVelocityThreshold?: number; swipeVelocityThreshold?: number
// ========== 长按配置 ========== // ========== 长按配置 ==========
/** 触发长按的时间(毫秒),默认 500 */ /** 触发长按的时间(毫秒),默认 500 */
longPressDelay?: number; longPressDelay?: number
/** 长按时允许的最大移动距离(像素),默认 10 */ /** 长按时允许的最大移动距离(像素),默认 10 */
longPressMoveThreshold?: number; longPressMoveThreshold?: number
// ========== 双击配置 ========== // ========== 双击配置 ==========
/** 双击的最大时间间隔(毫秒),默认 300 */ /** 双击的最大时间间隔(毫秒),默认 300 */
doubleTapDelay?: number; doubleTapDelay?: number
/** 双击的最大移动距离(像素),默认 10 */ /** 双击的最大移动距离(像素),默认 10 */
doubleTapMoveThreshold?: number; doubleTapMoveThreshold?: number
// ========== 事件回调 ========== // ========== 事件回调 ==========
/** 触摸开始 */ /** 触摸开始 */
onTouchStart?: (data: TouchEventData) => void; onTouchStart?: (data: TouchEventData) => void
/** 触摸移动 */ /** 触摸移动 */
onTouchMove?: (data: TouchEventData) => void; onTouchMove?: (data: TouchEventData) => void
/** 触摸结束 */ /** 触摸结束 */
onTouchEnd?: (data: TouchEventData) => void; onTouchEnd?: (data: TouchEventData) => void
/** 点击 */ /** 点击 */
onTap?: (data: TouchEventData) => void; onTap?: (data: TouchEventData) => void
/** 双击 */ /** 双击 */
onDoubleTap?: (data: TouchEventData) => void; onDoubleTap?: (data: TouchEventData) => void
/** 长按 */ /** 长按 */
onLongPress?: (data: TouchEventData) => void; onLongPress?: (data: TouchEventData) => void
/** 滑动 */ /** 滑动 */
onSwipe?: (data: TouchEventData) => void; onSwipe?: (data: TouchEventData) => void
/** 向左滑动 */ /** 向左滑动 */
onSwipeLeft?: (data: TouchEventData) => void; onSwipeLeft?: (data: TouchEventData) => void
/** 向右滑动 */ /** 向右滑动 */
onSwipeRight?: (data: TouchEventData) => void; onSwipeRight?: (data: TouchEventData) => void
/** 向上滑动 */ /** 向上滑动 */
onSwipeUp?: (data: TouchEventData) => void; onSwipeUp?: (data: TouchEventData) => void
/** 向下滑动 */ /** 向下滑动 */
onSwipeDown?: (data: TouchEventData) => void; onSwipeDown?: (data: TouchEventData) => void
// ========== 其他配置 ========== // ========== 其他配置 ==========
/** 是否阻止默认行为,默认 false */ /** 是否阻止默认行为,默认 false */
preventDefault?: boolean; preventDefault?: boolean
/** 是否阻止事件冒泡,默认 false */ /** 是否阻止事件冒泡,默认 false */
stopPropagation?: boolean; stopPropagation?: boolean
} }
/** /**
@@ -97,42 +97,42 @@ export interface UseTouchOptions {
*/ */
export interface UseTouchReturn { export interface UseTouchReturn {
/** 是否正在触摸 */ /** 是否正在触摸 */
isTouching: boolean; isTouching: boolean
/** 触摸事件处理器 */ /** 触摸事件处理器 */
handlers: { handlers: {
onTouchStart: (e: React.TouchEvent) => void; onTouchStart: (e: React.TouchEvent) => void
onTouchMove: (e: React.TouchEvent) => void; onTouchMove: (e: React.TouchEvent) => void
onTouchEnd: (e: React.TouchEvent) => void; onTouchEnd: (e: React.TouchEvent) => void
onMouseDown: (e: React.MouseEvent) => void; onMouseDown: (e: React.MouseEvent) => void
}; }
/** 当前触摸数据 */ /** 当前触摸数据 */
touchData: TouchEventData | null; touchData: TouchEventData | null
} }
/** /**
* 计算两点之间的距离 * 计算两点之间的距离
*/ */
function getDistance(p1: TouchPoint, p2: TouchPoint): number { function getDistance(p1: TouchPoint, p2: TouchPoint): number {
const dx = p2.x - p1.x; const dx = p2.x - p1.x
const dy = p2.y - p1.y; const dy = p2.y - p1.y
return Math.sqrt(dx * dx + dy * dy); return Math.sqrt(dx * dx + dy * dy)
} }
/** /**
* 计算滑动方向 * 计算滑动方向
*/ */
function getDirection(deltaX: number, deltaY: number): SwipeDirection | null { function getDirection(deltaX: number, deltaY: number): SwipeDirection | null {
const absDeltaX = Math.abs(deltaX); const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY); const absDeltaY = Math.abs(deltaY)
if (absDeltaX < 10 && absDeltaY < 10) { if (absDeltaX < 10 && absDeltaY < 10) {
return null; return null
} }
if (absDeltaX > absDeltaY) { if (absDeltaX > absDeltaY) {
return deltaX > 0 ? "right" : "left"; return deltaX > 0 ? 'right' : 'left'
} else { } else {
return deltaY > 0 ? "down" : "up"; return deltaY > 0 ? 'down' : 'up'
} }
} }
@@ -174,19 +174,19 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
onSwipeDown, onSwipeDown,
preventDefault = false, preventDefault = false,
stopPropagation = false, stopPropagation = false,
} = options; } = options
// 状态 // 状态
const [isTouching, setIsTouching] = useState(false); const [isTouching, setIsTouching] = useState(false)
const [touchData, setTouchData] = useState<TouchEventData | null>(null); const [touchData, setTouchData] = useState<TouchEventData | null>(null)
// 引用 // 引用
const startPointRef = useRef<TouchPoint | null>(null); const startPointRef = useRef<TouchPoint | null>(null)
const currentPointRef = useRef<TouchPoint | null>(null); const currentPointRef = useRef<TouchPoint | null>(null)
const lastTapTimeRef = useRef<number>(0); const lastTapTimeRef = useRef<number>(0)
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null); const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
const isLongPressTriggeredRef = useRef(false); const isLongPressTriggeredRef = useRef(false)
const isMouseDownRef = useRef(false); const isMouseDownRef = useRef(false)
/** /**
* 创建触摸点 * 创建触摸点
@@ -198,7 +198,7 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
timestamp: Date.now(), timestamp: Date.now(),
}), }),
[] []
); )
/** /**
* 创建触摸事件数据 * 创建触摸事件数据
@@ -209,12 +209,12 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
currentPoint: TouchPoint, currentPoint: TouchPoint,
event: React.TouchEvent | React.MouseEvent event: React.TouchEvent | React.MouseEvent
): TouchEventData => { ): TouchEventData => {
const deltaX = currentPoint.x - startPoint.x; const deltaX = currentPoint.x - startPoint.x
const deltaY = currentPoint.y - startPoint.y; const deltaY = currentPoint.y - startPoint.y
const distance = getDistance(startPoint, currentPoint); const distance = getDistance(startPoint, currentPoint)
const deltaTime = currentPoint.timestamp - startPoint.timestamp; const deltaTime = currentPoint.timestamp - startPoint.timestamp
const velocity = deltaTime > 0 ? distance / deltaTime : 0; const velocity = deltaTime > 0 ? distance / deltaTime : 0
const direction = getDirection(deltaX, deltaY); const direction = getDirection(deltaX, deltaY)
return { return {
startPoint, startPoint,
@@ -225,62 +225,59 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
velocity, velocity,
direction, direction,
event, event,
}; }
}, },
[] []
); )
/** /**
* 清除长按定时器 * 清除长按定时器
*/ */
const clearLongPressTimer = useCallback(() => { const clearLongPressTimer = useCallback(() => {
if (longPressTimerRef.current) { if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current); clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null; longPressTimerRef.current = null
} }
}, []); }, [])
/** /**
* 处理触摸开始 * 处理触摸开始
*/ */
const handleStart = useCallback( const handleStart = useCallback(
(x: number, y: number, event: React.TouchEvent | React.MouseEvent) => { (x: number, y: number, event: React.TouchEvent | React.MouseEvent) => {
if (!enabled) return; if (!enabled) return
if (preventDefault) event.preventDefault(); if (preventDefault) event.preventDefault()
if (stopPropagation) event.stopPropagation(); if (stopPropagation) event.stopPropagation()
const point = createTouchPoint(x, y); const point = createTouchPoint(x, y)
startPointRef.current = point; startPointRef.current = point
currentPointRef.current = point; currentPointRef.current = point
isLongPressTriggeredRef.current = false; isLongPressTriggeredRef.current = false
setIsTouching(true); setIsTouching(true)
const data = createTouchEventData(point, point, event); const data = createTouchEventData(point, point, event)
setTouchData(data); setTouchData(data)
onTouchStart?.(data); onTouchStart?.(data)
// 设置长按定时器 // 设置长按定时器
if (onLongPress) { if (onLongPress) {
clearLongPressTimer(); clearLongPressTimer()
longPressTimerRef.current = setTimeout(() => { longPressTimerRef.current = setTimeout(() => {
if (startPointRef.current && currentPointRef.current) { if (startPointRef.current && currentPointRef.current) {
const distance = getDistance( const distance = getDistance(startPointRef.current, currentPointRef.current)
startPointRef.current,
currentPointRef.current
);
if (distance < longPressMoveThreshold) { if (distance < longPressMoveThreshold) {
isLongPressTriggeredRef.current = true; isLongPressTriggeredRef.current = true
const longPressData = createTouchEventData( const longPressData = createTouchEventData(
startPointRef.current, startPointRef.current,
currentPointRef.current, currentPointRef.current,
event event
); )
onLongPress(longPressData); onLongPress(longPressData)
} }
} }
}, longPressDelay); }, longPressDelay)
} }
}, },
[ [
@@ -295,28 +292,28 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
longPressMoveThreshold, longPressMoveThreshold,
clearLongPressTimer, clearLongPressTimer,
] ]
); )
/** /**
* 处理触摸移动 * 处理触摸移动
*/ */
const handleMove = useCallback( const handleMove = useCallback(
(x: number, y: number, event: React.TouchEvent | React.MouseEvent) => { (x: number, y: number, event: React.TouchEvent | React.MouseEvent) => {
if (!enabled || !startPointRef.current) return; if (!enabled || !startPointRef.current) return
if (preventDefault) event.preventDefault(); if (preventDefault) event.preventDefault()
if (stopPropagation) event.stopPropagation(); if (stopPropagation) event.stopPropagation()
const point = createTouchPoint(x, y); const point = createTouchPoint(x, y)
currentPointRef.current = point; currentPointRef.current = point
const data = createTouchEventData(startPointRef.current, point, event); const data = createTouchEventData(startPointRef.current, point, event)
setTouchData(data); setTouchData(data)
onTouchMove?.(data); onTouchMove?.(data)
// 如果移动距离超过阈值,取消长按 // 如果移动距离超过阈值,取消长按
if (data.distance > longPressMoveThreshold) { if (data.distance > longPressMoveThreshold) {
clearLongPressTimer(); clearLongPressTimer()
} }
}, },
[ [
@@ -329,68 +326,63 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
longPressMoveThreshold, longPressMoveThreshold,
clearLongPressTimer, clearLongPressTimer,
] ]
); )
/** /**
* 处理触摸结束 * 处理触摸结束
*/ */
const handleEnd = useCallback( const handleEnd = useCallback(
(event: React.TouchEvent | React.MouseEvent) => { (event: React.TouchEvent | React.MouseEvent) => {
if (!enabled || !startPointRef.current || !currentPointRef.current) if (!enabled || !startPointRef.current || !currentPointRef.current) return
return;
if (preventDefault) event.preventDefault(); if (preventDefault) event.preventDefault()
if (stopPropagation) event.stopPropagation(); if (stopPropagation) event.stopPropagation()
clearLongPressTimer(); clearLongPressTimer()
const data = createTouchEventData( const data = createTouchEventData(startPointRef.current, currentPointRef.current, event)
startPointRef.current, onTouchEnd?.(data)
currentPointRef.current,
event
);
onTouchEnd?.(data);
// 判断手势类型 // 判断手势类型
const { distance, velocity, direction } = data; const { distance, velocity, direction } = data
// 长按已触发,不处理其他手势 // 长按已触发,不处理其他手势
if (isLongPressTriggeredRef.current) { if (isLongPressTriggeredRef.current) {
setIsTouching(false); setIsTouching(false)
setTouchData(null); setTouchData(null)
startPointRef.current = null; startPointRef.current = null
currentPointRef.current = null; currentPointRef.current = null
return; return
} }
// 滑动手势 // 滑动手势
if (distance > swipeThreshold || velocity > swipeVelocityThreshold) { if (distance > swipeThreshold || velocity > swipeVelocityThreshold) {
onSwipe?.(data); onSwipe?.(data)
if (direction === "left") onSwipeLeft?.(data); if (direction === 'left') onSwipeLeft?.(data)
if (direction === "right") onSwipeRight?.(data); if (direction === 'right') onSwipeRight?.(data)
if (direction === "up") onSwipeUp?.(data); if (direction === 'up') onSwipeUp?.(data)
if (direction === "down") onSwipeDown?.(data); if (direction === 'down') onSwipeDown?.(data)
} }
// 点击或双击 // 点击或双击
else if (distance < doubleTapMoveThreshold) { else if (distance < doubleTapMoveThreshold) {
const now = Date.now(); const now = Date.now()
const timeSinceLastTap = now - lastTapTimeRef.current; const timeSinceLastTap = now - lastTapTimeRef.current
if (timeSinceLastTap < doubleTapDelay && onDoubleTap) { if (timeSinceLastTap < doubleTapDelay && onDoubleTap) {
// 双击 // 双击
onDoubleTap(data); onDoubleTap(data)
lastTapTimeRef.current = 0; // 重置,避免三击触发两次双击 lastTapTimeRef.current = 0 // 重置,避免三击触发两次双击
} else { } else {
// 单击 // 单击
onTap?.(data); onTap?.(data)
lastTapTimeRef.current = now; lastTapTimeRef.current = now
} }
} }
setIsTouching(false); setIsTouching(false)
setTouchData(null); setTouchData(null)
startPointRef.current = null; startPointRef.current = null
currentPointRef.current = null; currentPointRef.current = null
}, },
[ [
enabled, enabled,
@@ -411,66 +403,62 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
doubleTapDelay, doubleTapDelay,
clearLongPressTimer, clearLongPressTimer,
] ]
); )
// 触摸事件处理器 // 触摸事件处理器
const handleTouchStart = useCallback( const handleTouchStart = useCallback(
(e: React.TouchEvent) => { (e: React.TouchEvent) => {
if (e.touches.length === 1) { if (e.touches.length === 1) {
const touch = e.touches[0]; const touch = e.touches[0]
handleStart(touch?.clientX || 0, touch?.clientY || 0, e); handleStart(touch?.clientX || 0, touch?.clientY || 0, e)
} }
}, },
[handleStart] [handleStart]
); )
const handleTouchMove = useCallback( const handleTouchMove = useCallback(
(e: React.TouchEvent) => { (e: React.TouchEvent) => {
if (e.touches.length === 1) { if (e.touches.length === 1) {
const touch = e.touches[0]; const touch = e.touches[0]
handleMove(touch?.clientX || 0, touch?.clientY || 0, e); handleMove(touch?.clientX || 0, touch?.clientY || 0, e)
} }
}, },
[handleMove] [handleMove]
); )
const handleTouchEnd = useCallback( const handleTouchEnd = useCallback(
(e: React.TouchEvent) => { (e: React.TouchEvent) => {
handleEnd(e); handleEnd(e)
}, },
[handleEnd] [handleEnd]
); )
// 鼠标事件处理器(桌面端支持) // 鼠标事件处理器(桌面端支持)
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
isMouseDownRef.current = true; isMouseDownRef.current = true
handleStart(e.clientX, e.clientY, e); handleStart(e.clientX, e.clientY, e)
const handleMouseMove = (event: MouseEvent) => { const handleMouseMove = (event: MouseEvent) => {
if (isMouseDownRef.current) { if (isMouseDownRef.current) {
handleMove( handleMove(event.clientX, event.clientY, e as unknown as React.MouseEvent)
event.clientX, }
event.clientY,
e as unknown as React.MouseEvent
);
} }
};
const handleMouseUp = (event: MouseEvent) => { const handleMouseUp = (event: MouseEvent) => {
if (isMouseDownRef.current) { if (isMouseDownRef.current) {
isMouseDownRef.current = false; isMouseDownRef.current = false
handleEnd(e as unknown as React.MouseEvent); handleEnd(e as unknown as React.MouseEvent)
window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp); window.removeEventListener('mouseup', handleMouseUp)
}
} }
};
window.addEventListener("mousemove", handleMouseMove); window.addEventListener('mousemove', handleMouseMove)
window.addEventListener("mouseup", handleMouseUp); window.addEventListener('mouseup', handleMouseUp)
}, },
[handleStart, handleMove, handleEnd] [handleStart, handleMove, handleEnd]
); )
return { return {
isTouching, isTouching,
@@ -481,5 +469,5 @@ export function useTouch(options: UseTouchOptions = {}): UseTouchReturn {
onMouseDown: handleMouseDown, onMouseDown: handleMouseDown,
}, },
touchData, touchData,
}; }
} }
+141 -148
View File
@@ -1,217 +1,213 @@
import { useRef, useCallback, useEffect } from "react"; import { useRef, useCallback, useEffect } from 'react'
import { useScale } from "@/contexts/ScaleContext"; import { useScale } from '@/contexts/ScaleContext'
type Point = { x: number; y: number }; type Point = { x: number; y: number }
export interface UseZoomAndDragOptions { export interface UseZoomAndDragOptions {
/** 最小缩放比例,默认 0.8 */ /** 最小缩放比例,默认 0.8 */
minScale?: number; minScale?: number
/** 最大缩放比例,默认 3 */ /** 最大缩放比例,默认 3 */
maxScale?: number; maxScale?: number
/** 是否可见,用于重置状态 */ /** 是否可见,用于重置状态 */
visible?: boolean; visible?: boolean
} }
export interface UseZoomAndDragReturn { export interface UseZoomAndDragReturn {
/** 容器 ref */ /** 容器 ref */
containerRef: React.RefObject<HTMLDivElement>; containerRef: React.RefObject<HTMLDivElement>
/** 视图元素 ref */ /** 视图元素 ref */
viewElRef: React.RefObject<HTMLDivElement>; viewElRef: React.RefObject<HTMLDivElement>
/** 重置变换 */ /** 重置变换 */
resetTransform: () => void; resetTransform: () => void
/** 滚轮事件处理 */ /** 滚轮事件处理 */
handleWheel: (e: React.WheelEvent<HTMLDivElement>) => void; handleWheel: (e: React.WheelEvent<HTMLDivElement>) => void
/** 指针按下事件处理 */ /** 指针按下事件处理 */
handlePointerDown: (e: React.PointerEvent<HTMLDivElement>) => void; handlePointerDown: (e: React.PointerEvent<HTMLDivElement>) => void
/** 指针移动事件处理 */ /** 指针移动事件处理 */
handlePointerMove: (e: React.PointerEvent<HTMLDivElement>) => void; handlePointerMove: (e: React.PointerEvent<HTMLDivElement>) => void
/** 指针抬起事件处理 */ /** 指针抬起事件处理 */
handlePointerUp: (e: React.PointerEvent<HTMLDivElement>) => void; handlePointerUp: (e: React.PointerEvent<HTMLDivElement>) => void
/** 双击事件处理 */ /** 双击事件处理 */
handleDoubleClick: () => void; handleDoubleClick: () => void
} }
/** /**
* 缩放和拖拽 Hook * 缩放和拖拽 Hook
* 提供图片/内容的缩放、拖拽、双击重置功能 * 提供图片/内容的缩放、拖拽、双击重置功能
*/ */
export const useZoomAndDrag = ( export const useZoomAndDrag = (options: UseZoomAndDragOptions = {}): UseZoomAndDragReturn => {
options: UseZoomAndDragOptions = {} const { minScale = 0.8, maxScale = 3, visible = true } = options
): UseZoomAndDragReturn => { const { scale } = useScale()
const { minScale = 0.8, maxScale = 3, visible = true } = options;
const { scale } = useScale();
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null)
const viewElRef = useRef<HTMLDivElement | null>(null); const viewElRef = useRef<HTMLDivElement | null>(null)
const frameRef = useRef<number | null>(null); const frameRef = useRef<number | null>(null)
const pointers = useRef<Map<number, Point>>(new Map()); const pointers = useRef<Map<number, Point>>(new Map())
const lastDistance = useRef<number | null>(null); const lastDistance = useRef<number | null>(null)
const dragStartRef = useRef<{ const dragStartRef = useRef<{
pointerId: number; pointerId: number
startX: number; startX: number
startY: number; startY: number
startTransform: { scale: number; x: number; y: number }; startTransform: { scale: number; x: number; y: number }
} | null>(null); } | null>(null)
const transformRef = useRef({ scale: 1, x: 0, y: 0 }); const transformRef = useRef({ scale: 1, x: 0, y: 0 })
// 针对当前 scale 预计算 clamp // 针对当前 scale 预计算 clamp
const clampScale = useCallback( const clampScale = useCallback(
(next: number) => Math.min(maxScale, Math.max(minScale, next)), (next: number) => Math.min(maxScale, Math.max(minScale, next)),
[minScale, maxScale] [minScale, maxScale]
); )
const applyTransformStyle = useCallback(() => { const applyTransformStyle = useCallback(() => {
const el = viewElRef.current; const el = viewElRef.current
if (!el) return; if (!el) return
const { scale, x, y } = transformRef.current; const { scale, x, y } = transformRef.current
// 使用 translate3d 触发 GPU 加速 // 使用 translate3d 触发 GPU 加速
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`
el.style.transformOrigin = "0 0"; el.style.transformOrigin = '0 0'
el.style.touchAction = "none"; el.style.touchAction = 'none'
}, []); }, [])
const scheduleUpdate = useCallback(() => { const scheduleUpdate = useCallback(() => {
if (frameRef.current) return; if (frameRef.current) return
frameRef.current = requestAnimationFrame(() => { frameRef.current = requestAnimationFrame(() => {
applyTransformStyle(); applyTransformStyle()
frameRef.current = null; frameRef.current = null
}); })
}, [applyTransformStyle]); }, [applyTransformStyle])
const resetTransform = useCallback(() => { const resetTransform = useCallback(() => {
transformRef.current = { scale: 1, x: 0, y: 0 }; transformRef.current = { scale: 1, x: 0, y: 0 }
pointers.current.clear(); pointers.current.clear()
lastDistance.current = null; lastDistance.current = null
dragStartRef.current = null; dragStartRef.current = null
// 释放所有指针捕获 // 释放所有指针捕获
if (containerRef.current) { if (containerRef.current) {
pointers.current.forEach((_, pointerId) => { pointers.current.forEach((_, pointerId) => {
try { try {
containerRef.current?.releasePointerCapture(pointerId); containerRef.current?.releasePointerCapture(pointerId)
} catch { } catch {
// 忽略捕获释放错误 // 忽略捕获释放错误
} }
}); })
} }
applyTransformStyle(); applyTransformStyle()
}, [applyTransformStyle]); }, [applyTransformStyle])
// 关闭或切换时重置 // 关闭或切换时重置
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
resetTransform(); resetTransform()
} else { } else {
// 不可见时清理所有状态和指针捕获 // 不可见时清理所有状态和指针捕获
transformRef.current = { scale: 1, x: 0, y: 0 }; transformRef.current = { scale: 1, x: 0, y: 0 }
pointers.current.clear(); pointers.current.clear()
lastDistance.current = null; lastDistance.current = null
dragStartRef.current = null; dragStartRef.current = null
if (containerRef.current) { if (containerRef.current) {
pointers.current.forEach((_, pointerId) => { pointers.current.forEach((_, pointerId) => {
try { try {
containerRef.current?.releasePointerCapture(pointerId); containerRef.current?.releasePointerCapture(pointerId)
} catch { } catch {
// 忽略捕获释放错误 // 忽略捕获释放错误
} }
}); })
} }
// 取消待处理的动画帧 // 取消待处理的动画帧
if (frameRef.current) { if (frameRef.current) {
cancelAnimationFrame(frameRef.current); cancelAnimationFrame(frameRef.current)
frameRef.current = null; frameRef.current = null
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]); }, [visible])
// 组件卸载时清理资源 // 组件卸载时清理资源
useEffect(() => { useEffect(() => {
const containerEl = containerRef.current; const containerEl = containerRef.current
const pointersMap = pointers.current; const pointersMap = pointers.current
const frameId = frameRef.current; const frameId = frameRef.current
return () => { return () => {
// 清理所有指针捕获 // 清理所有指针捕获
if (containerEl) { if (containerEl) {
pointersMap.forEach((_, pointerId) => { pointersMap.forEach((_, pointerId) => {
try { try {
containerEl.releasePointerCapture(pointerId); containerEl.releasePointerCapture(pointerId)
} catch { } catch {
// 忽略捕获释放错误 // 忽略捕获释放错误
} }
}); })
} }
// 取消待处理的动画帧 // 取消待处理的动画帧
if (frameId) { if (frameId) {
cancelAnimationFrame(frameId); cancelAnimationFrame(frameId)
} }
}; }
}, []); }, [])
// 缩放函数:origin 是屏幕坐标,需要转换为相对于 viewElRef 的坐标 // 缩放函数:origin 是屏幕坐标,需要转换为相对于 viewElRef 的坐标
const applyZoom = useCallback( const applyZoom = useCallback(
(factor: number, origin: Point) => { (factor: number, origin: Point) => {
if (!viewElRef.current || !containerRef.current) return; if (!viewElRef.current || !containerRef.current) return
const prev = transformRef.current; const prev = transformRef.current
const nextScale = clampScale(prev.scale * factor); const nextScale = clampScale(prev.scale * factor)
// 边界检查:避免除零 // 边界检查:避免除零
if (prev.scale === 0) return; if (prev.scale === 0) return
// 获取 viewElRef 相对于视口的位置 // 获取 viewElRef 相对于视口的位置
const viewRect = viewElRef.current.getBoundingClientRect(); const viewRect = viewElRef.current.getBoundingClientRect()
// 将屏幕坐标转换为相对于 viewElRef 的坐标 // 将屏幕坐标转换为相对于 viewElRef 的坐标
const relativeOrigin = { const relativeOrigin = {
x: origin.x - viewRect.left, x: origin.x - viewRect.left,
y: origin.y - viewRect.top, y: origin.y - viewRect.top,
}; }
const scaleChange = nextScale / prev.scale; const scaleChange = nextScale / prev.scale
// 以相对坐标 origin 为基准,保持该点视觉位置不动 // 以相对坐标 origin 为基准,保持该点视觉位置不动
const nextX = const nextX = (prev.x - relativeOrigin.x) * scaleChange + relativeOrigin.x
(prev.x - relativeOrigin.x) * scaleChange + relativeOrigin.x; const nextY = (prev.y - relativeOrigin.y) * scaleChange + relativeOrigin.y
const nextY = transformRef.current = { scale: nextScale, x: nextX, y: nextY }
(prev.y - relativeOrigin.y) * scaleChange + relativeOrigin.y; scheduleUpdate()
transformRef.current = { scale: nextScale, x: nextX, y: nextY };
scheduleUpdate();
}, },
[clampScale, scheduleUpdate] [clampScale, scheduleUpdate]
); )
// 滚轮缩放:直接使用屏幕坐标 // 滚轮缩放:直接使用屏幕坐标
const handleWheel = useCallback( const handleWheel = useCallback(
(e: React.WheelEvent<HTMLDivElement>) => { (e: React.WheelEvent<HTMLDivElement>) => {
const delta = e.deltaY; const delta = e.deltaY
const factor = delta < 0 ? 1.05 : 0.95; 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] [applyZoom]
); )
// 计算两点距离 // 计算两点距离
const getDistance = useCallback((p1: Point, p2: Point) => { const getDistance = useCallback((p1: Point, p2: Point) => {
const dx = p1.x - p2.x; const dx = p1.x - p2.x
const dy = p1.y - p2.y; const dy = p1.y - p2.y
return Math.hypot(dx, dy); return Math.hypot(dx, dy)
}, []); }, [])
// 指针按下:记录初始状态 // 指针按下:记录初始状态
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
if (!containerRef.current || !viewElRef.current) return; if (!containerRef.current || !viewElRef.current) return
try { try {
containerRef.current.setPointerCapture(e.pointerId); containerRef.current.setPointerCapture(e.pointerId)
} catch (err) { } catch (err) {
console.warn("Failed to capture pointer:", err); console.warn('Failed to capture pointer:', err)
} }
// 记录屏幕坐标 // 记录屏幕坐标
const point = { const point = {
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
}; }
pointers.current.set(e.pointerId, point); pointers.current.set(e.pointerId, point)
// 单指拖拽:记录初始状态 // 单指拖拽:记录初始状态
if (pointers.current.size === 1) { if (pointers.current.size === 1) {
@@ -220,21 +216,21 @@ export const useZoomAndDrag = (
startX: e.clientX, startX: e.clientX,
startY: e.clientY, startY: e.clientY,
startTransform: { ...transformRef.current }, startTransform: { ...transformRef.current },
}; }
} else if (pointers.current.size === 2) { } else if (pointers.current.size === 2) {
// 双指缩放:记录初始距离 // 双指缩放:记录初始距离
const values = Array.from(pointers.current.values()); const values = Array.from(pointers.current.values())
if (values.length === 2) { if (values.length === 2) {
const [p1, p2] = values; const [p1, p2] = values
if (p1 && p2) { if (p1 && p2) {
lastDistance.current = getDistance(p1, p2); lastDistance.current = getDistance(p1, p2)
dragStartRef.current = null; dragStartRef.current = null
} }
} }
} }
}, },
[getDistance] [getDistance]
); )
/** /**
* 单指拖拽处理 * 单指拖拽处理
@@ -243,29 +239,29 @@ export const useZoomAndDrag = (
*/ */
const handleSingleDrag = useCallback( const handleSingleDrag = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
const dragStart = dragStartRef.current; const dragStart = dragStartRef.current
if (!dragStart) return; if (!dragStart) return
// 1. 计算屏幕坐标系下的位移 // 1. 计算屏幕坐标系下的位移
const dxScreen = e.clientX - dragStart.startX; const dxScreen = e.clientX - dragStart.startX
const dyScreen = e.clientY - dragStart.startY; const dyScreen = e.clientY - dragStart.startY
// 2. 映射到设计稿坐标系(补偿全局 scale) // 2. 映射到设计稿坐标系(补偿全局 scale)
const dx = dxScreen / scale; const dx = dxScreen / scale
const dy = dyScreen / scale; const dy = dyScreen / scale
// 3. 应用到 transform(设计稿坐标) // 3. 应用到 transform(设计稿坐标)
const nextX = dragStart.startTransform.x + dx; const nextX = dragStart.startTransform.x + dx
const nextY = dragStart.startTransform.y + dy; const nextY = dragStart.startTransform.y + dy
transformRef.current.x = nextX; transformRef.current.x = nextX
transformRef.current.y = nextY; transformRef.current.y = nextY
// 统一交给 rAF 的 scheduleUpdate,减少直接 DOM 操作次数 // 统一交给 rAF 的 scheduleUpdate,减少直接 DOM 操作次数
scheduleUpdate(); scheduleUpdate()
}, },
[scale, scheduleUpdate] [scale, scheduleUpdate]
); )
/** /**
* 双指缩放处理 * 双指缩放处理
@@ -273,93 +269,90 @@ export const useZoomAndDrag = (
* - 使用两指中点作为缩放中心 * - 使用两指中点作为缩放中心
*/ */
const handlePinchZoom = useCallback(() => { const handlePinchZoom = useCallback(() => {
if (pointers.current.size !== 2) return; if (pointers.current.size !== 2) return
const values = Array.from(pointers.current.values()); const values = Array.from(pointers.current.values())
const [p1, p2] = values; const [p1, p2] = values
if (!p1 || !p2) return; if (!p1 || !p2) return
const distance = getDistance(p1, p2); const distance = getDistance(p1, p2)
if (!lastDistance.current || lastDistance.current <= 0) { if (!lastDistance.current || lastDistance.current <= 0) {
lastDistance.current = distance; lastDistance.current = distance
return; return
} }
const factor = distance / lastDistance.current; const factor = distance / lastDistance.current
const center = { const center = {
x: (p1.x + p2.x) * 0.5, x: (p1.x + p2.x) * 0.5,
y: (p1.y + p2.y) * 0.5, y: (p1.y + p2.y) * 0.5,
}; }
applyZoom(factor, center); applyZoom(factor, center)
lastDistance.current = distance; lastDistance.current = distance
}, [getDistance, applyZoom]); }, [getDistance, applyZoom])
// 指针移动:更新指针位置,然后按当前手势类型分发给拖拽 / 缩放处理 // 指针移动:更新指针位置,然后按当前手势类型分发给拖拽 / 缩放处理
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
if (!pointers.current.has(e.pointerId)) return; if (!pointers.current.has(e.pointerId)) return
// 更新当前 pointer 的屏幕坐标 // 更新当前 pointer 的屏幕坐标
pointers.current.set(e.pointerId, { pointers.current.set(e.pointerId, {
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
}); })
// 优先处理双指缩放 // 优先处理双指缩放
if (pointers.current.size === 2) { if (pointers.current.size === 2) {
handlePinchZoom(); handlePinchZoom()
return; return
} }
// 单指拖拽 // 单指拖拽
if (pointers.current.size === 1) { if (pointers.current.size === 1) {
handleSingleDrag(e); handleSingleDrag(e)
} }
}, },
[handlePinchZoom, handleSingleDrag] [handlePinchZoom, handleSingleDrag]
); )
// 指针抬起 // 指针抬起
const handlePointerUp = useCallback( const handlePointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
(e: React.PointerEvent<HTMLDivElement>) => {
// 释放指针捕获 // 释放指针捕获
if (containerRef.current) { if (containerRef.current) {
try { try {
containerRef.current.releasePointerCapture(e.pointerId); containerRef.current.releasePointerCapture(e.pointerId)
} catch { } catch {
// 忽略捕获释放错误 // 忽略捕获释放错误
} }
} }
pointers.current.delete(e.pointerId); pointers.current.delete(e.pointerId)
if (pointers.current.size < 2) { if (pointers.current.size < 2) {
lastDistance.current = null; lastDistance.current = null
} }
// 如果剩余单指,重新记录拖拽初始状态 // 如果剩余单指,重新记录拖拽初始状态
if (pointers.current.size === 1) { if (pointers.current.size === 1) {
const remaining = Array.from(pointers.current.values())[0]; const remaining = Array.from(pointers.current.values())[0]
const remainingId = Array.from(pointers.current.keys())[0]; const remainingId = Array.from(pointers.current.keys())[0]
if (remaining && remainingId !== undefined) { if (remaining && remainingId !== undefined) {
dragStartRef.current = { dragStartRef.current = {
pointerId: remainingId, pointerId: remainingId,
startX: remaining.x, startX: remaining.x,
startY: remaining.y, startY: remaining.y,
startTransform: { ...transformRef.current }, startTransform: { ...transformRef.current },
}; }
} }
} else { } else {
dragStartRef.current = null; dragStartRef.current = null
} }
}, }, [])
[]
);
// 双击重置 // 双击重置
const handleDoubleClick = useCallback(() => { const handleDoubleClick = useCallback(() => {
resetTransform(); resetTransform()
}, [resetTransform]); }, [resetTransform])
return { return {
containerRef: containerRef as React.RefObject<HTMLDivElement>, containerRef: containerRef as React.RefObject<HTMLDivElement>,
@@ -370,5 +363,5 @@ export const useZoomAndDrag = (
handlePointerMove, handlePointerMove,
handlePointerUp, handlePointerUp,
handleDoubleClick, handleDoubleClick,
}; }
}; }
-127
View File
@@ -1,127 +0,0 @@
$.extend({
getUrlVars: function(){
var vars = [], hash;
var hashes = window.location.href.slice(window.location.href.indexOf('?') + 1).split('&');
for(var i = 0; i < hashes.length; i++)
{
hash = hashes[i].split('=');
vars.push(hash[0]);
vars[hash[0]] = hash[1];
}
return vars;
},
getUrlVar: function(name){
return $.getUrlVars()[name];
}
});
var lesson="yuxiang"
var videoajax = videoAjax();
videoajax.setHost("/");
//设置我的自定义数据(所有的自定义数据都设置在第一个课时ID上,以key来区分不同的类型)
function setLtDefine(key,value){
if(iscallyb){
setUserDefine(1,key,value);
}else{
sessionStorage.setItem(lesson+"_"+key,value);
}
}
var mycstr9 = "";
//获取定义数据
function getLtDefine(key){
if(iscallyb){
let cstr = "";
if(mycstr9 == ""){
let cstr = getUsersDefine(1, emykey);
if(cstr == undefined || cstr == null || cstr == "null" || cstr == ""){
mycstr9 = getFutureDa(10);
setLtDefine(emykey, mycstr9);
}else{
mycstr9 = cstr;
}
}
cstr = mycstr9;
let str = (cstr == undefined || cstr==null || cstr=="null")?"":cstr;
getCurrentDa(str);
var objitem = getUserDefine(1,key);
return (objitem == undefined || objitem==null || objitem=="null")?"":objitem;
}else{
// let cstr = sessionStorage.getItem(lesson+"_"+emykey);
// let str = (cstr == undefined || cstr==null || cstr=="null")?"":cstr;
// getCurrentDa(str);
var itemvalue = sessionStorage.getItem(lesson+"_"+key);
return (itemvalue == undefined || itemvalue==null || itemvalue=="null")?"":itemvalue;
}
}
//设置课程笔记10002
function setCourseNotepad(collect){
setLtDefine(10002,$.toJSON(collect));
}
//获取课程笔记10002
function getCourseNotepad(){
return (getLtDefine(10002) == undefined || getLtDefine(10002) == null || getLtDefine(10002) == "") ? ["","","","","","","","",""] : $.parseJSON(getLtDefine(10002));
}
//lessonId(课时唯一ID)
export function setUserDefine(lessonId,key,value) {
var param = { "lessonId": lesson+"_"+lessonId, "key": key, "content": value };
videoajax.addUserDefine(param);
}
//获取用户自定义数据
export function getUserDefine(lessonId,key) {
var param = { "lessonId": lesson+"_"+lessonId, "order": key };
var content = "";
videoajax.getUserDefineInfoByUser(param, function (data) {
console.log(data)
if (data && data.code == 200) {
var json = data.data.value || {};
var contents = json[0].content || "";
content = contents;
}else{
content = "";
}
});
return content;
}
//获取【所有】用户自定义数据
export function getUsersDefine(lessonId,key) {
var param = { "lessonId": lesson+"_"+lessonId, "order": key };
var content = "";
videoajax.getUserDefineInfo(param, function (data) {
if (data && data.code == 200) {
//console.info(data);
var json = data.data.value || {};
if(json.length > 1){
if(key == emykey){
let mdate = new Date(Math.max.apply(null, json.map(item => new Date(item.content))));
content = formatDateFromMilliseconds(mdate);
}else{
content = json;
}
}else{
content = json[0].content || "";
}
}else{
content = "";
}
});
return content;
}
//根据课时顺序号(所处课程的第几课时)更新对应易班平台上的每个环节进度
export function setCourseTimeProgress(order){
//var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID
var courseid = getLtDefine("10000");
console.info("courseid:" + courseid + " order:" + order);
if (iscallyb && courseid != undefined && courseid != null && courseid != "") {
if (order > 0) {
var param2 = { "order": order, "co": courseid, "process": 100 };
videoajax.finishCourse(param2);
}
}
}
+233
View File
@@ -0,0 +1,233 @@
// 声明外部依赖(由运行环境提供)
declare const $: any
declare const iscallyb: boolean
declare const emykey: string
declare function videoAjax(): VideoAjax
declare function getFutureDa(days: number): string
declare function getCurrentDa(str: string): void
declare function formatDateFromMilliseconds(date: Date): string
interface UserDefineParam {
lessonId: string
key?: string | number
order?: string | number
content?: string
}
interface VideoAjaxResponseItem {
content?: string
}
interface VideoAjaxResponse {
code: number
data: {
value?: VideoAjaxResponseItem[] | string
}
}
interface VideoAjax {
setHost(host: string): void
addUserDefine(param: UserDefineParam): void
getUserDefineInfoByUser(
param: UserDefineParam,
callback: (data: VideoAjaxResponse | undefined) => void
): void
getUserDefineInfo(
param: UserDefineParam,
callback: (data: VideoAjaxResponse | undefined) => void
): void
finishCourse(param: { order: number; co: string; process: number }): void
}
// 扩展 jQuery,获取 URL 参数
$.extend({
getUrlVars(): string[] & Record<string, string | undefined> {
const vars: any[] & Record<string, string | undefined> = [] as any
let hash: string[]
const queryIndex = window.location.href.indexOf('?')
if (queryIndex === -1) return vars
const hashes = window.location.href
.slice(queryIndex + 1)
.split('&')
.filter(Boolean)
for (let i = 0; i < hashes.length; i++) {
const item = hashes[i]
if (!item) continue
hash = item.split('=')
const keyRaw = hash[0] ?? ''
const valueRaw = hash[1] ?? ''
const key = decodeURIComponent(keyRaw)
const value = decodeURIComponent(valueRaw)
vars.push(key)
vars[key] = value
}
return vars
},
getUrlVar(name: string): string | undefined {
return ($ as any).getUrlVars()[name]
},
})
const LESSON_PREFIX = 'hsgrl'
const videoajax: VideoAjax = videoAjax()
videoajax.setHost('/')
// 设置我的自定义数据(所有的自定义数据都设置在第一个课时ID上,以key来区分不同的类型)
function setLtDefine(key: string | number, value: string): void {
if (iscallyb) {
setUserDefine(1, key, value)
} else {
sessionStorage.setItem(`${LESSON_PREFIX}_${key}`, value)
}
}
let mycstr9 = ''
// 获取定义数据(由于底层是异步接口,这里返回 Promise)
async function getLtDefine(key: string | number): Promise<string> {
if (iscallyb) {
if (mycstr9 === '') {
const all = await getUsersDefine(1, emykey)
if (all === undefined || all === null || all === 'null' || all === '') {
mycstr9 = getFutureDa(10)
setLtDefine(emykey, mycstr9)
} else {
mycstr9 = all as string // 这里是 emykey,对应上面 getUsersDefine 的 string 分支
}
}
const str = mycstr9 === undefined || mycstr9 === null || mycstr9 === 'null' ? '' : mycstr9
getCurrentDa(str)
const objitem = await getUserDefine(1, key)
return objitem === undefined || objitem === null || objitem === 'null' ? '' : objitem
} else {
const itemvalue = sessionStorage.getItem(`${LESSON_PREFIX}_${key}`)
return itemvalue === undefined || itemvalue === null || itemvalue === 'null' ? '' : itemvalue
}
}
// 设置课程笔记10002
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function setCourseNotepad(collect: string[]): void {
// 保持和原逻辑一致:序列化为 JSON 存储
setLtDefine(10002, $.toJSON(collect))
}
// 获取课程笔记10002(异步)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function getCourseNotepad(): Promise<string[]> {
const raw = await getLtDefine(10002)
if (raw === undefined || raw === null || raw === '') {
// 原逻辑:返回长度为 9 的空字符串数组
return ['', '', '', '', '', '', '', '', '']
}
return $.parseJSON(raw) as string[]
}
// lessonId(课时唯一ID)
export function setUserDefine(
lessonId: string | number,
key: string | number,
value: string
): void {
const param: UserDefineParam = {
lessonId: `${LESSON_PREFIX}_${lessonId}`,
key,
content: value,
}
videoajax.addUserDefine(param)
}
// 内部:将回调形式封装为 Promise
function getUserDefineAsyncInternal(
lessonId: string | number,
key: string | number
): Promise<string> {
const param: UserDefineParam = {
lessonId: `${LESSON_PREFIX}_${lessonId}`,
order: key,
}
return new Promise((resolve) => {
let content = ''
videoajax.getUserDefineInfoByUser(param, (res) => {
// 保留原有 console 行为
console.log(res)
if (res && res.code === 200) {
const json = (res.data.value || []) as VideoAjaxResponseItem[]
const contents = json[0]?.content || ''
content = contents
} else {
content = ''
}
resolve(content)
})
})
}
// Promise 版本:获取用户自定义数据
export async function getUserDefine(
lessonId: string | number,
key: string | number
): Promise<string> {
return getUserDefineAsyncInternal(lessonId, key)
}
// 内部:获取【所有】用户自定义数据(Promise 封装)
function getUsersDefineAsyncInternal(
lessonId: string | number,
key: string | number
): Promise<string | VideoAjaxResponseItem[]> {
const param: UserDefineParam = {
lessonId: `${LESSON_PREFIX}_${lessonId}`,
order: key,
}
return new Promise((resolve) => {
let content: string | VideoAjaxResponseItem[] = ''
videoajax.getUserDefineInfo(param, (data) => {
if (data && data.code === 200) {
const json = (data.data.value || []) as VideoAjaxResponseItem[]
if (json.length > 1) {
if (key === emykey) {
const mdate = new Date(
Math.max.apply(
null,
json.map((item) => new Date(item.content || '').getTime())
)
)
content = formatDateFromMilliseconds(mdate)
} else {
content = json
}
} else {
content = json[0]?.content || ''
}
} else {
content = ''
}
resolve(content)
})
})
}
// Promise 版本:获取【所有】用户自定义数据
export async function getUsersDefine(
lessonId: string | number,
key: string | number
): Promise<string | VideoAjaxResponseItem[]> {
return getUsersDefineAsyncInternal(lessonId, key)
}
// 根据课时顺序号(所处课程的第几课时)更新对应易班平台上的每个环节进度(异步)
export async function setCourseTimeProgress(order: number): Promise<void> {
// var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID
const courseid = await getLtDefine('10000')
console.info(`courseid:${courseid} order:${order}`)
if (iscallyb && courseid !== undefined && courseid !== null && courseid !== '') {
if (order > 0) {
const param2 = { order, co: courseid, process: 100 }
videoajax.finishCourse(param2)
}
}
}
+25 -28
View File
@@ -1,56 +1,53 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from 'clsx'
import { twMerge } from "tailwind-merge"; import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs))
} }
// 图片资源映射 - 使用静态导入确保构建时包含所有图片 // 图片资源映射 - 使用静态导入确保构建时包含所有图片
const imageModules = import.meta.glob( const imageModules = import.meta.glob('../../assets/images/**/*.{png,jpg,jpeg,gif,svg,webp}', {
"../../assets/images/**/*.{png,jpg,jpeg,gif,svg,webp}",
{
eager: true, eager: true,
} })
);
//访问静态图片 - 使用静态导入确保构建后路径正确 //访问静态图片 - 使用静态导入确保构建后路径正确
export const getStaticImage = (path: string) => { export const getStaticImage = (path: string) => {
try { try {
// 确保路径以正确的格式结尾 // 确保路径以正确的格式结尾
const imagePath = const imagePath =
path.endsWith(".png") || path.endsWith('.png') ||
path.endsWith(".jpg") || path.endsWith('.jpg') ||
path.endsWith(".jpeg") || path.endsWith('.jpeg') ||
path.endsWith(".gif") || path.endsWith('.gif') ||
path.endsWith(".svg") || path.endsWith('.svg') ||
path.endsWith(".webp") path.endsWith('.webp')
? `../../assets/images/${path}` ? `../../assets/images/${path}`
: `../../assets/images/${path}.png`; : `../../assets/images/${path}.png`
// 使用静态导入的模块 // 使用静态导入的模块
const module = imageModules[imagePath]; const module = imageModules[imagePath]
if (module && typeof module === "object" && "default" in module) { if (module && typeof module === 'object' && 'default' in module) {
return module.default as string; return module.default as string
} }
// 回退到URL方式 // 回退到URL方式
return new URL(imagePath, import.meta.url).href; return new URL(imagePath, import.meta.url).href
} catch (error) { } catch (error) {
console.error("Failed to load image:", path, error); console.error('Failed to load image:', path, error)
return ""; return ''
} }
}; }
// 获取背景图片的专用函数 // 获取背景图片的专用函数
export const getBackgroundImage = (path: string) => { export const getBackgroundImage = (path: string) => {
const imageUrl = getStaticImage(path); const imageUrl = getStaticImage(path)
return imageUrl ? `url(${imageUrl})` : "none"; return imageUrl ? `url(${imageUrl})` : 'none'
}; }
// 获取 public 文件夹中的资源路径(用于 Word 文档等) // 获取 public 文件夹中的资源路径(用于 Word 文档等)
export const getPublicPath = (path: string) => { export const getPublicPath = (path: string) => {
// 移除开头的斜杠(如果有) // 移除开头的斜杠(如果有)
const cleanPath = path.startsWith("/") ? path.slice(1) : path; const cleanPath = path.startsWith('/') ? path.slice(1) : path
// 使用 Vite 的 BASE_URL 环境变量来处理基础路径 // 使用 Vite 的 BASE_URL 环境变量来处理基础路径
return `${import.meta.env.BASE_URL}${cleanPath}`; return `${import.meta.env.BASE_URL}${cleanPath}`
}; }
+17 -19
View File
@@ -5,12 +5,12 @@
// 敏感词库(可根据实际需求扩展) // 敏感词库(可根据实际需求扩展)
const SENSITIVE_WORDS = [ const SENSITIVE_WORDS = [
"敏感词1", '敏感词1',
"敏感词2", '敏感词2',
"测试", '测试',
"垃圾", '垃圾',
// 可以根据实际需求添加更多敏感词 // 可以根据实际需求添加更多敏感词
]; ]
/** /**
* 检查文本中是否包含敏感词 * 检查文本中是否包含敏感词
@@ -18,13 +18,11 @@ const SENSITIVE_WORDS = [
* @returns 是否包含敏感词 * @returns 是否包含敏感词
*/ */
export function containsSensitiveWord(text: string): boolean { export function containsSensitiveWord(text: string): boolean {
if (!text) return false; if (!text) return false
const normalizedText = text.toLowerCase().trim(); const normalizedText = text.toLowerCase().trim()
return SENSITIVE_WORDS.some((word) => return SENSITIVE_WORDS.some((word) => normalizedText.includes(word.toLowerCase()))
normalizedText.includes(word.toLowerCase())
);
} }
/** /**
@@ -33,16 +31,16 @@ export function containsSensitiveWord(text: string): boolean {
* @returns 处理后的文本 * @returns 处理后的文本
*/ */
export function filterSensitiveWords(text: string): string { export function filterSensitiveWords(text: string): string {
if (!text) return text; if (!text) return text
let result = text; let result = text
SENSITIVE_WORDS.forEach((word) => { SENSITIVE_WORDS.forEach((word) => {
const regex = new RegExp(word, "gi"); const regex = new RegExp(word, 'gi')
result = result.replace(regex, "*".repeat(word.length)); result = result.replace(regex, '*'.repeat(word.length))
}); })
return result; return result
} }
/** /**
@@ -51,10 +49,10 @@ export function filterSensitiveWords(text: string): string {
* @returns 过滤后的内容 * @returns 过滤后的内容
*/ */
export function filterInput(input: string): string { export function filterInput(input: string): string {
if (!input) return input; if (!input) return input
// 先进行敏感词过滤 // 先进行敏感词过滤
const filtered = filterSensitiveWords(input); const filtered = filterSensitiveWords(input)
return filtered; return filtered
} }
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff. Show more