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:
1 parent
5d00adc1a5
commit
76c67b492e
216 files changed
+12424
-13946
No files matched your search
@@ -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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/*
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
+12
-13
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
@@ -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)
|
||||||
};
|
}
|
||||||
@@ -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'
|
||||||
@@ -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',
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from "./button";
|
export * from './button'
|
||||||
export * from "./button.types";
|
export * from './button.types'
|
||||||
@@ -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,37 +119,27 @@ 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) &&
|
<div className="absolute inset-0 z-20 flex items-center justify-center">
|
||||||
getClueHintImage && (
|
<div className="relative h-full">
|
||||||
<div className="absolute inset-0 z-20 flex items-center justify-center">
|
<Image src={getClueHintImage(selectedClue.id)!} className="h-full" />
|
||||||
<div className="relative h-full">
|
{/* 关闭按钮 */}
|
||||||
<Image
|
<Image
|
||||||
src={getClueHintImage(selectedClue.id)!}
|
className="absolute top-0 right-0 z-30"
|
||||||
className="h-full"
|
src={getStaticImage(`sunzhongshan/reflection/关闭`)}
|
||||||
/>
|
button
|
||||||
{/* 关闭按钮 */}
|
onClick={() => setShowClueHint(false)}
|
||||||
<Image
|
/>
|
||||||
className="absolute top-0 right-0 z-30"
|
|
||||||
src={getStaticImage(`sunzhongshan/reflection/关闭`)}
|
|
||||||
button
|
|
||||||
onClick={() => setShowClueHint(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
{/* 线索提示按钮(右下角) */}
|
{/* 线索提示按钮(右下角) */}
|
||||||
{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>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
@@ -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>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
};
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export { GuideSteps, GuideStepsItem } from "./GuideSteps";
|
export { GuideSteps, GuideStepsItem } from './GuideSteps'
|
||||||
export { StudyGuideModal } from "./StudyGuideModal";
|
export { StudyGuideModal } from './StudyGuideModal'
|
||||||
@@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from "./image";
|
export * from './image'
|
||||||
export * from "./image.types";
|
export * from './image.types'
|
||||||
+32
-37
@@ -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'
|
||||||
@@ -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,24 +323,21 @@ const ResidenceLayoutContent = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 碎片显示 - 用于飞行动画,显示在屏幕正中间(仅支持拼图功能的故居显示) */}
|
{/* 碎片显示 - 用于飞行动画,显示在屏幕正中间(仅支持拼图功能的故居显示) */}
|
||||||
{hasPuzzleFeature &&
|
{hasPuzzleFeature && showPuzzlePiece && puzzlePieceId && residence && (
|
||||||
showPuzzlePiece &&
|
<PuzzlePiecePortal
|
||||||
puzzlePieceId &&
|
ref={puzzlePieceRef}
|
||||||
residence && (
|
residence={residence}
|
||||||
<PuzzlePiecePortal
|
pieceId={puzzlePieceId}
|
||||||
ref={puzzlePieceRef}
|
hidden={isFlying}
|
||||||
residence={residence}
|
/>
|
||||||
pieceId={puzzlePieceId}
|
)}
|
||||||
hidden={isFlying}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 动画遮罩层 - 动画播放时盖住所有元素,防止操作 */}
|
{/* 动画遮罩层 - 动画播放时盖住所有元素,防止操作 */}
|
||||||
{isAnimationPlaying &&
|
{isAnimationPlaying &&
|
||||||
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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
return activeBlankIndex
|
||||||
})();
|
})()
|
||||||
setActiveBlankIndex(nextIndex);
|
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
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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('您好')) {
|
||||||
// 检测是否已经找到"您好"行
|
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 <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||||
return (
|
}
|
||||||
<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}
|
||||||
>
|
<input
|
||||||
{part}
|
name={`blank-${currentIndex}`}
|
||||||
<input
|
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||||
name={`blank-${currentIndex}`}
|
blankErrors[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 ${
|
? 'border-red-500'
|
||||||
blankErrors[currentIndex]
|
: 'border-[#4e402e]'
|
||||||
? "border-red-500"
|
}`}
|
||||||
: "border-[#4e402e]"
|
value={blankValues[currentIndex] ?? ''}
|
||||||
}`}
|
onChange={(e) => {
|
||||||
value={blankValues[currentIndex] ?? ""}
|
const next = [...blankValues]
|
||||||
onChange={(e) => {
|
next[currentIndex] = e.target.value
|
||||||
const next = [...blankValues];
|
setBlankValues(next)
|
||||||
next[currentIndex] = e.target.value;
|
|
||||||
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 <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||||
return (
|
}
|
||||||
<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}
|
||||||
>
|
{value || ' '}
|
||||||
{part}
|
</span>
|
||||||
{value || " "}
|
)
|
||||||
</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
|
||||||
@@ -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,121 +186,97 @@ 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('您好')) {
|
||||||
// 检测是否已经找到"您好"行
|
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}
|
{segments.map((part, partIndex) => {
|
||||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
const isLast = partIndex === segments.length - 1
|
||||||
>
|
if (isLast) {
|
||||||
{segments.map((part, partIndex) => {
|
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||||
const isLast =
|
}
|
||||||
partIndex === segments.length - 1;
|
|
||||||
if (isLast) {
|
|
||||||
return (
|
|
||||||
<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 ? BLANK_1_OPTIONS : BLANK_4_OPTIONS
|
||||||
currentIndex === 0
|
|
||||||
? BLANK_1_OPTIONS
|
|
||||||
: BLANK_4_OPTIONS;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||||
key={`part-${lineIndex}-${partIndex}`}
|
{part}
|
||||||
>
|
{isLimitedFill ? (
|
||||||
{part}
|
// 限填:下拉选择
|
||||||
{isLimitedFill ? (
|
<Select
|
||||||
// 限填:下拉选择
|
className="inline"
|
||||||
<Select
|
options={options.map((opt) => ({
|
||||||
className="inline"
|
value: opt,
|
||||||
options={options.map((opt) => ({
|
label: opt,
|
||||||
value: opt,
|
}))}
|
||||||
label: opt,
|
value={blankValues[currentIndex] ?? ''}
|
||||||
}))}
|
onChange={(selectedValue) => {
|
||||||
value={
|
const next = [...blankValues]
|
||||||
blankValues[currentIndex] ?? ""
|
next[currentIndex] = selectedValue
|
||||||
|
setBlankValues(next)
|
||||||
|
|
||||||
|
// 用户再次选择时,清除该空的错误高亮
|
||||||
|
if (blankErrors[currentIndex]) {
|
||||||
|
const nextErrs = [...blankErrors]
|
||||||
|
nextErrs[currentIndex] = false
|
||||||
|
setBlankErrors(nextErrs)
|
||||||
}
|
}
|
||||||
onChange={(selectedValue) => {
|
}}
|
||||||
const next = [...blankValues];
|
placeholder="请选择"
|
||||||
next[currentIndex] = selectedValue;
|
error={blankErrors[currentIndex]}
|
||||||
setBlankValues(next);
|
zIndex={10000}
|
||||||
|
fitContent={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// 任填:文本输入,限制20个字,敏感词过滤
|
||||||
|
<input
|
||||||
|
name={`blank-${currentIndex}`}
|
||||||
|
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||||
|
blankErrors[currentIndex]
|
||||||
|
? 'border-red-500'
|
||||||
|
: 'border-[#4e402e]'
|
||||||
|
}`}
|
||||||
|
autoComplete="off"
|
||||||
|
value={blankValues[currentIndex] ?? ''}
|
||||||
|
maxLength={20}
|
||||||
|
onChange={(e) => {
|
||||||
|
const rawValue = e.target.value
|
||||||
|
// 应用敏感词过滤
|
||||||
|
const filteredValue = filterInput(rawValue)
|
||||||
|
|
||||||
// 用户再次选择时,清除该空的错误高亮
|
const next = [...blankValues]
|
||||||
if (blankErrors[currentIndex]) {
|
next[currentIndex] = filteredValue
|
||||||
const nextErrs = [...blankErrors];
|
setBlankValues(next)
|
||||||
nextErrs[currentIndex] = false;
|
|
||||||
setBlankErrors(nextErrs);
|
// 用户再次输入时,清除该空的错误高亮
|
||||||
}
|
if (blankErrors[currentIndex]) {
|
||||||
}}
|
const nextErrs = [...blankErrors]
|
||||||
placeholder="请选择"
|
nextErrs[currentIndex] = false
|
||||||
error={blankErrors[currentIndex]}
|
setBlankErrors(nextErrs)
|
||||||
zIndex={10000}
|
|
||||||
fitContent={true}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
// 任填:文本输入,限制20个字,敏感词过滤
|
|
||||||
<input
|
|
||||||
name={`blank-${currentIndex}`}
|
|
||||||
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
|
||||||
blankErrors[currentIndex]
|
|
||||||
? "border-red-500"
|
|
||||||
: "border-[#4e402e]"
|
|
||||||
}`}
|
|
||||||
autoComplete="off"
|
|
||||||
value={
|
|
||||||
blankValues[currentIndex] ?? ""
|
|
||||||
}
|
}
|
||||||
maxLength={20}
|
}}
|
||||||
onChange={(e) => {
|
/>
|
||||||
const rawValue = e.target.value;
|
)}
|
||||||
// 应用敏感词过滤
|
</span>
|
||||||
const filteredValue =
|
)
|
||||||
filterInput(rawValue);
|
})}
|
||||||
|
</div>
|
||||||
const next = [...blankValues];
|
)
|
||||||
next[currentIndex] = filteredValue;
|
})
|
||||||
setBlankValues(next);
|
|
||||||
|
|
||||||
// 用户再次输入时,清除该空的错误高亮
|
|
||||||
if (blankErrors[currentIndex]) {
|
|
||||||
const nextErrs = [...blankErrors];
|
|
||||||
nextErrs[currentIndex] = false;
|
|
||||||
setBlankErrors(nextErrs);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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}
|
{segments.map((part, partIndex) => {
|
||||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
const isLast = partIndex === segments.length - 1
|
||||||
>
|
if (isLast) {
|
||||||
{segments.map((part, partIndex) => {
|
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||||
const isLast =
|
}
|
||||||
partIndex === segments.length - 1;
|
|
||||||
if (isLast) {
|
|
||||||
return (
|
|
||||||
<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}
|
||||||
>
|
{value || ' '}
|
||||||
{part}
|
</span>
|
||||||
{value || " "}
|
)
|
||||||
</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
|
||||||
@@ -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,115 +179,95 @@ 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('您好')) {
|
||||||
// 检测是否已经找到"您好"行
|
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 <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||||
return (
|
}
|
||||||
<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 ? BLANK_1_OPTIONS : BLANK_2_OPTIONS
|
||||||
currentIndex === 0
|
|
||||||
? BLANK_1_OPTIONS
|
|
||||||
: BLANK_2_OPTIONS;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||||
key={`part-${lineIndex}-${partIndex}`}
|
{part}
|
||||||
>
|
{isLimitedFill ? (
|
||||||
{part}
|
// 限填:下拉选择
|
||||||
{isLimitedFill ? (
|
<Select
|
||||||
// 限填:下拉选择
|
className="inline"
|
||||||
<Select
|
options={options.map((opt) => ({
|
||||||
className="inline"
|
value: opt,
|
||||||
options={options.map((opt) => ({
|
label: opt,
|
||||||
value: opt,
|
}))}
|
||||||
label: opt,
|
value={blankValues[currentIndex] ?? ''}
|
||||||
}))}
|
onChange={(selectedValue) => {
|
||||||
value={
|
const next = [...blankValues]
|
||||||
blankValues[currentIndex] ?? ""
|
next[currentIndex] = selectedValue
|
||||||
|
setBlankValues(next)
|
||||||
|
|
||||||
|
// 用户再次选择时,清除该空的错误高亮
|
||||||
|
if (blankErrors[currentIndex]) {
|
||||||
|
const nextErrs = [...blankErrors]
|
||||||
|
nextErrs[currentIndex] = false
|
||||||
|
setBlankErrors(nextErrs)
|
||||||
}
|
}
|
||||||
onChange={(selectedValue) => {
|
}}
|
||||||
const next = [...blankValues];
|
placeholder="请选择"
|
||||||
next[currentIndex] = selectedValue;
|
error={blankErrors[currentIndex]}
|
||||||
setBlankValues(next);
|
zIndex={10000}
|
||||||
|
fitContent={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// 任填:文本输入
|
||||||
|
<input
|
||||||
|
name={`blank-${currentIndex}`}
|
||||||
|
className={`inline-block w-[100px] h-[36px] mx-2 mb-2 align-middle bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||||
|
blankErrors[currentIndex]
|
||||||
|
? 'border-red-500'
|
||||||
|
: 'border-[#4e402e]'
|
||||||
|
}`}
|
||||||
|
value={blankValues[currentIndex] ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...blankValues]
|
||||||
|
next[currentIndex] = e.target.value
|
||||||
|
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="请选择"
|
|
||||||
error={blankErrors[currentIndex]}
|
|
||||||
zIndex={10000}
|
|
||||||
fitContent={true}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
// 任填:文本输入
|
|
||||||
<input
|
|
||||||
name={`blank-${currentIndex}`}
|
|
||||||
className={`inline-block w-[100px] h-[36px] mx-2 mb-2 align-middle bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
|
||||||
blankErrors[currentIndex]
|
|
||||||
? "border-red-500"
|
|
||||||
: "border-[#4e402e]"
|
|
||||||
}`}
|
|
||||||
value={
|
|
||||||
blankValues[currentIndex] ?? ""
|
|
||||||
}
|
}
|
||||||
onChange={(e) => {
|
}}
|
||||||
const next = [...blankValues];
|
/>
|
||||||
next[currentIndex] = e.target.value;
|
)}
|
||||||
setBlankValues(next);
|
</span>
|
||||||
|
)
|
||||||
// 用户再次输入时,清除该空的错误高亮
|
})}
|
||||||
if (blankErrors[currentIndex]) {
|
</div>
|
||||||
const nextErrs = [...blankErrors];
|
)
|
||||||
nextErrs[currentIndex] = false;
|
})
|
||||||
setBlankErrors(nextErrs);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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 <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||||
return (
|
}
|
||||||
<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}
|
||||||
>
|
{value || ' '}
|
||||||
{part}
|
</span>
|
||||||
{value || " "}
|
)
|
||||||
</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
|
||||||
@@ -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,134 +186,117 @@ 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('您好')) {
|
||||||
// 检测是否已经找到"您好"行
|
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 <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||||
return (
|
}
|
||||||
<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}
|
||||||
>
|
{isSelectFill ? (
|
||||||
{part}
|
// 前三个空:下拉选择
|
||||||
{isSelectFill ? (
|
<Select
|
||||||
// 前三个空:下拉选择
|
className="inline"
|
||||||
<Select
|
options={SELECT_OPTIONS}
|
||||||
className="inline"
|
value={blankValues[currentIndex] ?? ''}
|
||||||
options={SELECT_OPTIONS}
|
onChange={(selectedValue) => {
|
||||||
value={
|
const next = [...blankValues]
|
||||||
blankValues[currentIndex] ?? ""
|
next[currentIndex] = selectedValue
|
||||||
|
setBlankValues(next)
|
||||||
|
|
||||||
|
// 清除错误状态
|
||||||
|
if (blankErrors[currentIndex]) {
|
||||||
|
const nextErrs = [...blankErrors]
|
||||||
|
nextErrs[currentIndex] = false
|
||||||
|
setBlankErrors(nextErrs)
|
||||||
}
|
}
|
||||||
onChange={(selectedValue) => {
|
|
||||||
const next = [...blankValues];
|
|
||||||
next[currentIndex] = selectedValue;
|
|
||||||
setBlankValues(next);
|
|
||||||
|
|
||||||
// 清除错误状态
|
// 立即验证选择是否正确
|
||||||
if (blankErrors[currentIndex]) {
|
// let correctAnswer = "";
|
||||||
const nextErrs = [...blankErrors];
|
// if (currentIndex === 0) {
|
||||||
nextErrs[currentIndex] = false;
|
// correctAnswer = BLANK_0_ANSWER;
|
||||||
setBlankErrors(nextErrs);
|
// } else if (currentIndex === 1) {
|
||||||
}
|
// correctAnswer = BLANK_1_ANSWER;
|
||||||
|
// } else if (currentIndex === 2) {
|
||||||
|
// correctAnswer = BLANK_2_ANSWER;
|
||||||
|
// }
|
||||||
|
|
||||||
// 立即验证选择是否正确
|
// if (
|
||||||
// let correctAnswer = "";
|
// correctAnswer &&
|
||||||
// if (currentIndex === 0) {
|
// selectedValue !== correctAnswer
|
||||||
// correctAnswer = BLANK_0_ANSWER;
|
// ) {
|
||||||
// } else if (currentIndex === 1) {
|
// // 选择错误,显示错误提示
|
||||||
// correctAnswer = BLANK_1_ANSWER;
|
// setErrorAlertText(
|
||||||
// } else if (currentIndex === 2) {
|
// "请选择正确答案"
|
||||||
// correctAnswer = BLANK_2_ANSWER;
|
// );
|
||||||
// }
|
// setShowErrorAlert(true);
|
||||||
|
// // 清空选择
|
||||||
|
// next[currentIndex] = "";
|
||||||
|
// setBlankValues(next);
|
||||||
|
// const nextErrs = [...blankErrors];
|
||||||
|
// nextErrs[currentIndex] = true;
|
||||||
|
// setBlankErrors(nextErrs);
|
||||||
|
// }
|
||||||
|
}}
|
||||||
|
placeholder="请选择"
|
||||||
|
error={blankErrors[currentIndex]}
|
||||||
|
zIndex={10000}
|
||||||
|
fitContent={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// 其他空:文本输入
|
||||||
|
<input
|
||||||
|
name={`blank-${currentIndex}`}
|
||||||
|
className={`inline-block w-[110px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||||
|
blankErrors[currentIndex]
|
||||||
|
? 'border-red-500'
|
||||||
|
: 'border-[#4e402e]'
|
||||||
|
}`}
|
||||||
|
value={blankValues[currentIndex] ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...blankValues]
|
||||||
|
next[currentIndex] = e.target.value
|
||||||
|
setBlankValues(next)
|
||||||
|
|
||||||
// if (
|
// 用户再次输入时,清除该空的错误高亮
|
||||||
// correctAnswer &&
|
if (blankErrors[currentIndex]) {
|
||||||
// selectedValue !== correctAnswer
|
const nextErrs = [...blankErrors]
|
||||||
// ) {
|
nextErrs[currentIndex] = false
|
||||||
// // 选择错误,显示错误提示
|
setBlankErrors(nextErrs)
|
||||||
// setErrorAlertText(
|
|
||||||
// "请选择正确答案"
|
|
||||||
// );
|
|
||||||
// setShowErrorAlert(true);
|
|
||||||
// // 清空选择
|
|
||||||
// next[currentIndex] = "";
|
|
||||||
// setBlankValues(next);
|
|
||||||
// const nextErrs = [...blankErrors];
|
|
||||||
// nextErrs[currentIndex] = true;
|
|
||||||
// setBlankErrors(nextErrs);
|
|
||||||
// }
|
|
||||||
}}
|
|
||||||
placeholder="请选择"
|
|
||||||
error={blankErrors[currentIndex]}
|
|
||||||
zIndex={10000}
|
|
||||||
fitContent={true}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
// 其他空:文本输入
|
|
||||||
<input
|
|
||||||
name={`blank-${currentIndex}`}
|
|
||||||
className={`inline-block w-[110px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
|
||||||
blankErrors[currentIndex]
|
|
||||||
? "border-red-500"
|
|
||||||
: "border-[#4e402e]"
|
|
||||||
}`}
|
|
||||||
value={
|
|
||||||
blankValues[currentIndex] ?? ""
|
|
||||||
}
|
}
|
||||||
onChange={(e) => {
|
}}
|
||||||
const next = [...blankValues];
|
/>
|
||||||
next[currentIndex] = e.target.value;
|
)}
|
||||||
setBlankValues(next);
|
</span>
|
||||||
|
)
|
||||||
// 用户再次输入时,清除该空的错误高亮
|
})}
|
||||||
if (blankErrors[currentIndex]) {
|
</div>
|
||||||
const nextErrs = [...blankErrors];
|
)
|
||||||
nextErrs[currentIndex] = false;
|
})
|
||||||
setBlankErrors(nextErrs);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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 <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||||
return (
|
}
|
||||||
<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}
|
||||||
>
|
{value || ' '}
|
||||||
{part}
|
</span>
|
||||||
{value || " "}
|
)
|
||||||
</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
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,18 +61,18 @@ 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={{
|
||||||
duration: 0.8, // 稍微延长动画时间,让效果更舒缓
|
duration: 0.8, // 稍微延长动画时间,让效果更舒缓
|
||||||
@@ -87,23 +87,23 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
|||||||
},
|
},
|
||||||
opacity: {
|
opacity: {
|
||||||
duration: 0.6,
|
duration: 0.6,
|
||||||
ease: "easeOut",
|
ease: 'easeOut',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PuzzlePieceDisplay
|
<PuzzlePieceDisplay
|
||||||
residence={residence}
|
residence={residence}
|
||||||
pieceId={pieceId}
|
pieceId={pieceId}
|
||||||
showGlow={true}
|
showGlow={true}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
PuzzlePiecePortal.displayName = "PuzzlePiecePortal";
|
PuzzlePiecePortal.displayName = 'PuzzlePiecePortal'
|
||||||
|
|
||||||
export default PuzzlePiecePortal;
|
export default PuzzlePiecePortal
|
||||||
@@ -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,36 +296,30 @@ 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(" ")
|
<div key={index}>
|
||||||
.map((word: string, index: number) => (
|
{word}
|
||||||
<div key={index}>
|
{index < selectedPieceData.title.split(' ').length - 1 && ' '}
|
||||||
{word}
|
</div>
|
||||||
{index <
|
))}
|
||||||
selectedPieceData.title.split(" ").length - 1 &&
|
|
||||||
" "}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 对应页面图片 */}
|
{/* 对应页面图片 */}
|
||||||
<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
|
||||||
@@ -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)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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'
|
||||||
|
|
||||||
@@ -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";
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
onSubmit?.(currentValue);
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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 @@
|
|||||||
export { default as Spotlight } from "./Spotlight";
|
export { default as Spotlight } from './Spotlight'
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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";
|
|
||||||
+348
-399
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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;
|
|
||||||
};
|
|
||||||
+25
-28
@@ -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,9 +17,9 @@ 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
|
||||||
c2.45,4.95,3.29,11.07,3.43,15.04c0.14,3.97-1.91,10.2-5.1,15.06c-7.34,8.59-13.38,10.01-25.33,7.75
|
c2.45,4.95,3.29,11.07,3.43,15.04c0.14,3.97-1.91,10.2-5.1,15.06c-7.34,8.59-13.38,10.01-25.33,7.75
|
||||||
c-3.65-1.19-6.82-3.14-10.27-4.77c-4.72-2.23-10.96-1.46-14.54,0.51s-4.59,7.35-4.63,12.61c-0.13,16.61,0.51,52.67,0.46,54.33
|
c-3.65-1.19-6.82-3.14-10.27-4.77c-4.72-2.23-10.96-1.46-14.54,0.51s-4.59,7.35-4.63,12.61c-0.13,16.61,0.51,52.67,0.46,54.33
|
||||||
@@ -29,7 +29,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c-15.78,0.05-31.55,0.02-47.33,0c-1.97,0-4,0.24-5.86-0.78c-0.8-0.62-1.06-1.45-1-2.43c0-56.64,0-113.27,0-169.9
|
c-15.78,0.05-31.55,0.02-47.33,0c-1.97,0-4,0.24-5.86-0.78c-0.8-0.62-1.06-1.45-1-2.43c0-56.64,0-113.27,0-169.9
|
||||||
c0-2.6-0.65-5.47,2.57-6.88L5.42,3.69c1.5-1.09,3.23-0.57,4.84-0.57c56.4-0.03,112.8-0.03,169.2,0.02c0.76,0,3.2-0.03,3.99,1.71
|
c0-2.6-0.65-5.47,2.57-6.88L5.42,3.69c1.5-1.09,3.23-0.57,4.84-0.57c56.4-0.03,112.8-0.03,169.2,0.02c0.76,0,3.2-0.03,3.99,1.71
|
||||||
C184.24,6.59,183.39,10.12,183.45,10.9z`,
|
C184.24,6.59,183.39,10.12,183.45,10.9z`,
|
||||||
2: `M188.09,4.59c0.34-0.32,0.67-0.64,1.01-0.97c1.71-1,3.58-0.5,5.36-0.5c57.1-0.03,114.19-0.03,171.29-0.02
|
2: `M188.09,4.59c0.34-0.32,0.67-0.64,1.01-0.97c1.71-1,3.58-0.5,5.36-0.5c57.1-0.03,114.19-0.03,171.29-0.02
|
||||||
c2.46,0,5.04-0.47,7.01,1.64l-0.1-0.13c0.86,0.78,0.64,1.83,0.64,2.8c0,8.4-0.1,16.81-0.16,25.22c-0.06,8.83,0.27,17.45,0.16,26.47
|
c2.46,0,5.04-0.47,7.01,1.64l-0.1-0.13c0.86,0.78,0.64,1.83,0.64,2.8c0,8.4-0.1,16.81-0.16,25.22c-0.06,8.83,0.27,17.45,0.16,26.47
|
||||||
c-0.01,0.81,0.27,4.82-1.54,7.37c-0.37,0.52-2.22,2.48-4.72,2.88c-3.92,0.62-7.81-1.87-10.2-3.3c-9.85-5.19-22.79-7.85-31.85,0.15
|
c-0.01,0.81,0.27,4.82-1.54,7.37c-0.37,0.52-2.22,2.48-4.72,2.88c-3.92,0.62-7.81-1.87-10.2-3.3c-9.85-5.19-22.79-7.85-31.85,0.15
|
||||||
c-7.66,6.78-12.92,19.18-10.98,29.22c2.05,10.56,10.29,20.17,21.18,22.88c5.74,1.43,13.37,0.25,18.86-2.32
|
c-7.66,6.78-12.92,19.18-10.98,29.22c2.05,10.56,10.29,20.17,21.18,22.88c5.74,1.43,13.37,0.25,18.86-2.32
|
||||||
@@ -41,7 +41,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c1.53,0.52,7.65,4.16,12.64,5.39c6.31,1.55,11.68,0.63,13.36,0.19c22.13-5.1,23.79-31.22,15.62-45.19
|
c1.53,0.52,7.65,4.16,12.64,5.39c6.31,1.55,11.68,0.63,13.36,0.19c22.13-5.1,23.79-31.22,15.62-45.19
|
||||||
c-2.42-3.51-5.79-6.97-9.12-8.76c-4.83-2.59-9.77-2.78-15.6-1.85c-4.13,0.66-8.56,3.2-12.31,5.02c-3.17,1.54-8.76,3.65-12.39,2.43
|
c-2.42-3.51-5.79-6.97-9.12-8.76c-4.83-2.59-9.77-2.78-15.6-1.85c-4.13,0.66-8.56,3.2-12.31,5.02c-3.17,1.54-8.76,3.65-12.39,2.43
|
||||||
c-3.94-1.51-5.05-4.02-5.04-8.48c0.03-18.3,0.01-36.6,0.01-54.9C187.63,5.81,187.72,5.17,188.09,4.59z`,
|
c-3.94-1.51-5.05-4.02-5.04-8.48c0.03-18.3,0.01-36.6,0.01-54.9C187.63,5.81,187.72,5.17,188.09,4.59z`,
|
||||||
3: `M562.57,67.53c2.08,2.53,5.24,4.32,7.69,4.7c2.86,0.45,7.3-0.98,10.44-2.18c4.49-1.68,8.08-4.5,13.08-4.93
|
3: `M562.57,67.53c2.08,2.53,5.24,4.32,7.69,4.7c2.86,0.45,7.3-0.98,10.44-2.18c4.49-1.68,8.08-4.5,13.08-4.93
|
||||||
c15.4-1.33,25.8,9.73,25.36,25.36c-0.45,15.95-9.07,27.01-24.1,26.05c-3.22-0.21-6.88-2.19-9.75-3.56
|
c15.4-1.33,25.8,9.73,25.36,25.36c-0.45,15.95-9.07,27.01-24.1,26.05c-3.22-0.21-6.88-2.19-9.75-3.56
|
||||||
c-2.55-1.22-5.45-2.78-8.15-3.67c-8.04-2.65-16.65,1.83-16.75,10.33c-0.21,16.95,0.72,33.3,0.69,50.26c0,1.83-0.01,5.52-0.11,7.34
|
c-2.55-1.22-5.45-2.78-8.15-3.67c-8.04-2.65-16.65,1.83-16.75,10.33c-0.21,16.95,0.72,33.3,0.69,50.26c0,1.83-0.01,5.52-0.11,7.34
|
||||||
c-0.2,3.59-0.22,5.17-3.79,5.38c-1,0.06-1.99,0.1-2.99,0.1c-17.29,0.01-34.58,0.02-51.87,0c-1.66,0-3.32-0.06-4.96-0.39
|
c-0.2,3.59-0.22,5.17-3.79,5.38c-1,0.06-1.99,0.1-2.99,0.1c-17.29,0.01-34.58,0.02-51.87,0c-1.66,0-3.32-0.06-4.96-0.39
|
||||||
@@ -53,7 +53,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c3.96,1.03,9.79-0.67,10.89-2.06c2.7-3.42,2.73-8.79,2.72-12.29c-0.07-15.58-0.04-31.15-0.01-46.73c0-1.63-0.41-3.32,0.45-4.88
|
c3.96,1.03,9.79-0.67,10.89-2.06c2.7-3.42,2.73-8.79,2.72-12.29c-0.07-15.58-0.04-31.15-0.01-46.73c0-1.63-0.41-3.32,0.45-4.88
|
||||||
c0.33-0.31,0.66-0.63,0.99-0.94c1.53-1.01,3.24-0.52,4.86-0.53C439.39,3.09,497.12,3.1,554.86,3.1c6.39,0,7.14,0.76,7.14,7.16
|
c0.33-0.31,0.66-0.63,0.99-0.94c1.53-1.01,3.24-0.52,4.86-0.53C439.39,3.09,497.12,3.1,554.86,3.1c6.39,0,7.14,0.76,7.14,7.16
|
||||||
c0.01,12.15,0.02,24.29-0.02,36.44C561.97,48.31,560.76,65.58,562.57,67.53z`,
|
c0.01,12.15,0.02,24.29-0.02,36.44C561.97,48.31,560.76,65.58,562.57,67.53z`,
|
||||||
4: `M684.07,182.22c-8.23,1.12-11.9,6.11-10.48,14.23c1.05,5.97,4.32,11.13,6.04,16.87
|
4: `M684.07,182.22c-8.23,1.12-11.9,6.11-10.48,14.23c1.05,5.97,4.32,11.13,6.04,16.87
|
||||||
c4.95,16.46-8.81,33.55-25.83,31.99c-9.08-0.84-18.04-6.51-20.55-16.1c-0.21-0.79-0.93-6.95,0.09-13.43
|
c4.95,16.46-8.81,33.55-25.83,31.99c-9.08-0.84-18.04-6.51-20.55-16.1c-0.21-0.79-0.93-6.95,0.09-13.43
|
||||||
c1.04-6.64,6.43-13.59,6.87-15.13c1.17-4.09,0.81-10.62-0.93-13.19c-2.09-3.09-7.34-4.96-12.48-5c-16.4-0.75-54.38,0.2-56.05,0.14
|
c1.04-6.64,6.43-13.59,6.87-15.13c1.17-4.09,0.81-10.62-0.93-13.19c-2.09-3.09-7.34-4.96-12.48-5c-16.4-0.75-54.38,0.2-56.05,0.14
|
||||||
c-6.08-0.25-6.48-0.21-6.57-6.84c-0.08-5.46-0.1-10.92-0.08-16.38c0.06-13.57-0.11-21.8-0.34-34.47c0.01-12.18,6.53-15.92,15.2-11
|
c-6.08-0.25-6.48-0.21-6.57-6.84c-0.08-5.46-0.1-10.92-0.08-16.38c0.06-13.57-0.11-21.8-0.34-34.47c0.01-12.18,6.53-15.92,15.2-11
|
||||||
@@ -62,7 +62,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c-0.06-15.07,0.04-29.1,0.07-44.16c0-1.52-0.39-3.11,0.33-4.58c0.1-0.15,0.27-0.4,0.37-0.55c1.68-1.86,3.7-1.18,5.58-1.19
|
c-0.06-15.07,0.04-29.1,0.07-44.16c0-1.52-0.39-3.11,0.33-4.58c0.1-0.15,0.27-0.4,0.37-0.55c1.68-1.86,3.7-1.18,5.58-1.19
|
||||||
C627.4,3.09,684.46,3.1,741.52,3.1c6.07,0,6.19,0.11,6.19,6.07c0.01,55.9-0.04,111.79,0.09,167.69c0.01,3.85-0.89,5.08-4.91,5.03
|
C627.4,3.09,684.46,3.1,741.52,3.1c6.07,0,6.19,0.11,6.19,6.07c0.01,55.9-0.04,111.79,0.09,167.69c0.01,3.85-0.89,5.08-4.91,5.03
|
||||||
c-17.8-0.22-35.6-0.11-53.4-0.08C687.68,181.81,685.82,181.4,684.07,182.22z`,
|
c-17.8-0.22-35.6-0.11-53.4-0.08C687.68,181.81,685.82,181.4,684.07,182.22z`,
|
||||||
5: `M69.29,372.08c-1.77-2.35-4.33-2.08-6.82-2.08c-15.96,0-31.92,0-47.88,0c-7.25,0-11.6-2.75-11.6-9.82
|
5: `M69.29,372.08c-1.77-2.35-4.33-2.08-6.82-2.08c-15.96,0-31.92,0-47.88,0c-7.25,0-11.6-2.75-11.6-9.82
|
||||||
c0-55.03,0.34-110.13,0.34-165.16c0-6.78,2.49-9.65,9.43-9.65c14.63-0.01,29.47,0,44.1-0.06c0.95,0,10.35-0.97,15-5.9
|
c0-55.03,0.34-110.13,0.34-165.16c0-6.78,2.49-9.65,9.43-9.65c14.63-0.01,29.47,0,44.1-0.06c0.95,0,10.35-0.97,15-5.9
|
||||||
c3.23-3.42,2.88-9.68,2.44-11.28s-3.2-9.48-4.62-13.01c-4.54-11.34-2.28-21.08,6.87-28.52c8.12-6.61,19.64-7.38,27.85-1.86
|
c3.23-3.42,2.88-9.68,2.44-11.28s-3.2-9.48-4.62-13.01c-4.54-11.34-2.28-21.08,6.87-28.52c8.12-6.61,19.64-7.38,27.85-1.86
|
||||||
c9.37,6.31,15.08,18.4,10.12,28.93c-1.69,3.58-7.53,18.17-3.55,24.79c2.12,4.55,8.04,6.74,12.41,6.8
|
c9.37,6.31,15.08,18.4,10.12,28.93c-1.69,3.58-7.53,18.17-3.55,24.79c2.12,4.55,8.04,6.74,12.41,6.8
|
||||||
@@ -73,7 +73,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c-16.01,0.01-20.6,6.43-15.65,21.81c1.83,5.68,3.94,11.28,4.31,17.35c0.65,10.57-5,17.19-13.63,21.85
|
c-16.01,0.01-20.6,6.43-15.65,21.81c1.83,5.68,3.94,11.28,4.31,17.35c0.65,10.57-5,17.19-13.63,21.85
|
||||||
c-8.29,4.48-16.22,3.79-23.73-2.08c-10.33-8.07-13.04-17.15-8.65-29.45c1.84-5.15,4.31-10.11,4.98-15.63
|
c-8.29,4.48-16.22,3.79-23.73-2.08c-10.33-8.07-13.04-17.15-8.65-29.45c1.84-5.15,4.31-10.11,4.98-15.63
|
||||||
C75.09,379.41,74.19,374.92,69.29,372.08z`,
|
C75.09,379.41,74.19,374.92,69.29,372.08z`,
|
||||||
6: `M305.86,215.26c0.07-2.85-1.9-8.06-1.9-8.06s-2.16-6.41-3.35-9.49c-3.07-7.94,0.43-12.87,9.72-12.18
|
6: `M305.86,215.26c0.07-2.85-1.9-8.06-1.9-8.06s-2.16-6.41-3.35-9.49c-3.07-7.94,0.43-12.87,9.72-12.18
|
||||||
c19.94-0.3,39.89-0.13,59.83-0.22c3.74-0.02,3.73,2.34,3.73,5.01c0,18.12,0.05,36.23,0.08,54.35c0,0.83,0,1.66,0.02,2.49
|
c19.94-0.3,39.89-0.13,59.83-0.22c3.74-0.02,3.73,2.34,3.73,5.01c0,18.12,0.05,36.23,0.08,54.35c0,0.83,0,1.66,0.02,2.49
|
||||||
c0.12,5.82,0.35,10.62,3.04,13.08c1.52,1.4,3.75,2.07,6.35,2.06c2.6-0.01,5.56-0.71,8.55-2.06c1.21-0.55,4.88-3.01,6.08-3.58
|
c0.12,5.82,0.35,10.62,3.04,13.08c1.52,1.4,3.75,2.07,6.35,2.06c2.6-0.01,5.56-0.71,8.55-2.06c1.21-0.55,4.88-3.01,6.08-3.58
|
||||||
c14.65-6.96,24.68,0.83,31.23,15.5c3.07,13.14-0.85,24.6-14.13,30.22c-0.76,0.32-1.57,0.54-2.36,0.81
|
c14.65-6.96,24.68,0.83,31.23,15.5c3.07,13.14-0.85,24.6-14.13,30.22c-0.76,0.32-1.57,0.54-2.36,0.81
|
||||||
@@ -89,7 +89,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c10.06,3.98,16.45-2.03,16.66-12.83c0.22-11.11-0.56-19.98-0.55-31.1c0-8.3-0.09-17.51-0.23-25.8c-0.05-3.26,1.61-6.18,5.27-6.15
|
c10.06,3.98,16.45-2.03,16.66-12.83c0.22-11.11-0.56-19.98-0.55-31.1c0-8.3-0.09-17.51-0.23-25.8c-0.05-3.26,1.61-6.18,5.27-6.15
|
||||||
c19.58,0.13,39.17,0.1,58.75,0.05c4.18-0.01,8.08,1,8.99,5.94s-3.48,12.44-5.93,18.89c-3.29,8.68-2.57,19.34,2.35,27.5
|
c19.58,0.13,39.17,0.1,58.75,0.05c4.18-0.01,8.08,1,8.99,5.94s-3.48,12.44-5.93,18.89c-3.29,8.68-2.57,19.34,2.35,27.5
|
||||||
c5.32,8.83,17.88,14.21,27.17,12.41c10.32-2,19.22-6.53,22.25-17.55C307.5,227.29,307.28,220.48,305.86,215.26z`,
|
c5.32,8.83,17.88,14.21,27.17,12.41c10.32-2,19.22-6.53,22.25-17.55C307.5,227.29,307.28,220.48,305.86,215.26z`,
|
||||||
7: `M405.35,306.99c13.2,0.78,24.03-6.49,28.08-19.2c4.18-13.14-4.54-33.18-20.07-36.18
|
7: `M405.35,306.99c13.2,0.78,24.03-6.49,28.08-19.2c4.18-13.14-4.54-33.18-20.07-36.18
|
||||||
c-5.66-1.1-11.76-0.44-16.9,1.52c-2.48,0.95-6.15,3.96-8.63,4.91c-7.6,2.93-10.83-1.78-10.95-9.88
|
c-5.66-1.1-11.76-0.44-16.9,1.52c-2.48,0.95-6.15,3.96-8.63,4.91c-7.6,2.93-10.83-1.78-10.95-9.88
|
||||||
c-0.13-9.14-0.04-18.29-0.05-27.44c0-9.81,0.25-19.63-0.11-29.43c-0.18-4.98,1.63-6.16,6.31-6.08c15.96,0.25,31.93,0.13,47.89,0.07
|
c-0.13-9.14-0.04-18.29-0.05-27.44c0-9.81,0.25-19.63-0.11-29.43c-0.18-4.98,1.63-6.16,6.31-6.08c15.96,0.25,31.93,0.13,47.89,0.07
|
||||||
c2.81-0.01,8,0.37,10.75-0.14c10.43-1.93,11.55-8.68,8.13-19.07c-1.61-4.52-3.45-8.98-4.78-13.58
|
c2.81-0.01,8,0.37,10.75-0.14c10.43-1.93,11.55-8.68,8.13-19.07c-1.61-4.52-3.45-8.98-4.78-13.58
|
||||||
@@ -103,7 +103,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c-0.95-7.61,1.9-14.36,4.74-21.12c1.09-2.6,2.15-5.2,2.42-8.07c0.88-9.5-3.4-14.61-13-14.7c-18.11-0.17-36.23-0.07-54.35-0.08
|
c-0.95-7.61,1.9-14.36,4.74-21.12c1.09-2.6,2.15-5.2,2.42-8.07c0.88-9.5-3.4-14.61-13-14.7c-18.11-0.17-36.23-0.07-54.35-0.08
|
||||||
c-6.64,0-6.65-0.01-6.65-6.57c0.01-17.45-0.01-34.9,0.05-52.35c-0.47-10.08,2.9-13.59,14.67-8.96
|
c-6.64,0-6.65-0.01-6.65-6.57c0.01-17.45-0.01-34.9,0.05-52.35c-0.47-10.08,2.9-13.59,14.67-8.96
|
||||||
C393.57,302.85,401.04,306.93,405.35,306.99z`,
|
C393.57,302.85,401.04,306.93,405.35,306.99z`,
|
||||||
8: `M629.94,230.44c2.79,11.07,11.69,17,21.52,18.56c11.58,1.84,20.63-4.03,27.25-12.14
|
8: `M629.94,230.44c2.79,11.07,11.69,17,21.52,18.56c11.58,1.84,20.63-4.03,27.25-12.14
|
||||||
c6.03-7.39,6.34-14.84,3.67-23.85c-1.55-5.25-3.72-10.32-5.24-15.58c-2.16-7.49,1.02-12.56,8.65-12.7
|
c6.03-7.39,6.34-14.84,3.67-23.85c-1.55-5.25-3.72-10.32-5.24-15.58c-2.16-7.49,1.02-12.56,8.65-12.7
|
||||||
c19.45-0.35,38.92-0.16,58.38-0.21c2.35-0.01,3.34,1.06,3.2,3.35c-0.05,0.83,0,1.66,0,2.49c0,58.05-0.05,116.1,0.1,174.15
|
c19.45-0.35,38.92-0.16,58.38-0.21c2.35-0.01,3.34,1.06,3.2,3.35c-0.05,0.83,0,1.66,0,2.49c0,58.05-0.05,116.1,0.1,174.15
|
||||||
c0.01,4.34-1,5.6-5.46,5.52c-17.13-0.28-34.26-0.09-51.39-0.14c-2.14-0.01-4.33-0.17-6.41-0.65c-6.06-1.39-8.94-5.92-7.36-11.92
|
c0.01,4.34-1,5.6-5.46,5.52c-17.13-0.28-34.26-0.09-51.39-0.14c-2.14-0.01-4.33-0.17-6.41-0.65c-6.06-1.39-8.94-5.92-7.36-11.92
|
||||||
@@ -115,7 +115,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c5.28-1.58,19.8,4.62,22.43,5.63c6.06,2.34,13.76-0.58,16.42-5.16c1.6-2.75,1.67-8.7,1.71-12.62c0.18-16.62-0.42-27.93-0.4-44.55
|
c5.28-1.58,19.8,4.62,22.43,5.63c6.06,2.34,13.76-0.58,16.42-5.16c1.6-2.75,1.67-8.7,1.71-12.62c0.18-16.62-0.42-27.93-0.4-44.55
|
||||||
c0.01-12.12-0.51-12.38,11.44-12.38c16.29,0,31.89-0.3,48.18-0.27c12.63,0.02,15.62,5.72,11.53,17.83
|
c0.01-12.12-0.51-12.38,11.44-12.38c16.29,0,31.89-0.3,48.18-0.27c12.63,0.02,15.62,5.72,11.53,17.83
|
||||||
c-0.43,1.26-4.5,7.61-5.07,9.76C628.81,218.73,628.59,224.07,629.94,230.44z`,
|
c-0.43,1.26-4.5,7.61-5.07,9.76C628.81,218.73,628.59,224.07,629.94,230.44z`,
|
||||||
9: `M84.53,496.23c-15.47,3.69-25.25,18.2-19.18,34.57c1.32,3.57,3.21,8.61,4.34,12.23
|
9: `M84.53,496.23c-15.47,3.69-25.25,18.2-19.18,34.57c1.32,3.57,3.21,8.61,4.34,12.23
|
||||||
c2.6,8.32,0.13,14.79-8.37,16.34c-0.51,0.09-2.99,0.59-6.16,0.79c-7.03,0.45-17.55-0.33-20.05-0.32
|
c2.6,8.32,0.13,14.79-8.37,16.34c-0.51,0.09-2.99,0.59-6.16,0.79c-7.03,0.45-17.55-0.33-20.05-0.32
|
||||||
c-12.31,0.09-11.59-0.64-23.9-0.66c-5.69-0.01-6.59-0.92-6.59-6.71c-0.01-57.89-0.01-115.78,0-173.67c0-5.73,1.55-4.91,7.33-4.96
|
c-12.31,0.09-11.59-0.64-23.9-0.66c-5.69-0.01-6.59-0.92-6.59-6.71c-0.01-57.89-0.01-115.78,0-173.67c0-5.73,1.55-4.91,7.33-4.96
|
||||||
c15.8-0.14,48.31-0.41,48.81-0.39c12.8,0.41,10.63,12.15,7.98,18.03c-2.65,5.88-3.64,13.67-4.71,20
|
c15.8-0.14,48.31-0.41,48.81-0.39c12.8,0.41,10.63,12.15,7.98,18.03c-2.65,5.88-3.64,13.67-4.71,20
|
||||||
@@ -127,7 +127,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c-0.14,2.99-0.39,8.57-0.39,11.56c0,14.97-0.21,29.06-0.27,44.03c-0.03,7.55-2.64,8.64-9.99,8.66c-17.63,0.05-31.79,0.11-49.43,0
|
c-0.14,2.99-0.39,8.57-0.39,11.56c0,14.97-0.21,29.06-0.27,44.03c-0.03,7.55-2.64,8.64-9.99,8.66c-17.63,0.05-31.79,0.11-49.43,0
|
||||||
c-5.91-0.04-11.34-1.3-13.63-7.83c-0.7-5.73,0.3-11.21,2.35-16.57c1.9-4.98,3.19-10.04,3.15-15.49
|
c-5.91-0.04-11.34-1.3-13.63-7.83c-0.7-5.73,0.3-11.21,2.35-16.57c1.9-4.98,3.19-10.04,3.15-15.49
|
||||||
c-0.1-13.19-7.41-22.07-20.48-23.96C94.1,495.68,88.65,495.09,84.53,496.23z`,
|
c-0.1-13.19-7.41-22.07-20.48-23.96C94.1,495.68,88.65,495.09,84.53,496.23z`,
|
||||||
10: `M279.15,622.5c-10.41,0.05-25.44-10.63-23.06-25.85c0.58-4.52,4.45-12.5,6.79-18.7
|
10: `M279.15,622.5c-10.41,0.05-25.44-10.63-23.06-25.85c0.58-4.52,4.45-12.5,6.79-18.7
|
||||||
c4.57-12.09-0.28-19.29-13.16-19.36c-18.79-0.1-37.57-0.13-56.36,0.06c-4.18,0.04-5.46-1.31-5.41-5.44
|
c4.57-12.09-0.28-19.29-13.16-19.36c-18.79-0.1-37.57-0.13-56.36,0.06c-4.18,0.04-5.46-1.31-5.41-5.44
|
||||||
c0.19-17.62,0.09-35.24,0.1-52.87c0-1.33-0.03-2.67,0.15-3.98c1.12-8.23,5.66-10.84,13.25-7.51c3.49,1.53,6.79,3.47,10.49,4.56
|
c0.19-17.62,0.09-35.24,0.1-52.87c0-1.33-0.03-2.67,0.15-3.98c1.12-8.23,5.66-10.84,13.25-7.51c3.49,1.53,6.79,3.47,10.49,4.56
|
||||||
c11.1,3.29,23.31-0.74,29.65-9.87c7.39-10.63,6.91-25.35-1.16-35.38c-6.76-8.41-15.31-11.09-26.41-6.87
|
c11.1,3.29,23.31-0.74,29.65-9.87c7.39-10.63,6.91-25.35-1.16-35.38c-6.76-8.41-15.31-11.09-26.41-6.87
|
||||||
@@ -141,7 +141,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c0.05,17.62-0.05,35.25,0.1,52.87c0.03,3.86-1.12,5.28-5.11,5.23c-18.45-0.2-36.91-0.06-55.36-0.22
|
c0.05,17.62-0.05,35.25,0.1,52.87c0.03,3.86-1.12,5.28-5.11,5.23c-18.45-0.2-36.91-0.06-55.36-0.22
|
||||||
c-13.01-0.11-18.55,7.33-14.2,19.69c1.93,5.49,4.04,10.9,4.59,16.76c1.14,12.15-6.95,21.77-13.23,25.4
|
c-13.01-0.11-18.55,7.33-14.2,19.69c1.93,5.49,4.04,10.9,4.59,16.76c1.14,12.15-6.95,21.77-13.23,25.4
|
||||||
C288.04,621.84,285.07,622.48,279.15,622.5z`,
|
C288.04,621.84,285.07,622.48,279.15,622.5z`,
|
||||||
11: `M500.83,558.56c-0.93-0.24-2.9-0.39-3.85-0.52c-8.34-0.97-10.63-5.7-9.18-12.35c0.67-5.07,3.86-7.4,5.45-11.59
|
11: `M500.83,558.56c-0.93-0.24-2.9-0.39-3.85-0.52c-8.34-0.97-10.63-5.7-9.18-12.35c0.67-5.07,3.86-7.4,5.45-11.59
|
||||||
c6.43-16.99-2.21-35.06-18.65-38.69c-5.09-1.12-10.21-1.04-15.06,1.33c-14.07,3.64-21.77,18.71-17.25,34.16
|
c6.43-16.99-2.21-35.06-18.65-38.69c-5.09-1.12-10.21-1.04-15.06,1.33c-14.07,3.64-21.77,18.71-17.25,34.16
|
||||||
c1.21,4.13,3.09,8.06,4.58,12.12c3.42,9.28-0.88,15.47-10.82,15.51c-17.97,0.06-35.93-0.07-53.9,0.14
|
c1.21,4.13,3.09,8.06,4.58,12.12c3.42,9.28-0.88,15.47-10.82,15.51c-17.97,0.06-35.93-0.07-53.9,0.14
|
||||||
c-4.22,0.05-5.44-1.39-5.38-5.48c0.23-16.8,0.18-33.6,0.23-50.4c0-1.83,0.03-3.67-0.12-5.48c-0.9-10.26-8.89-15.19-18.45-11.38
|
c-4.22,0.05-5.44-1.39-5.38-5.48c0.23-16.8,0.18-33.6,0.23-50.4c0-1.83,0.03-3.67-0.12-5.48c-0.9-10.26-8.89-15.19-18.45-11.38
|
||||||
@@ -155,7 +155,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c14.8-5.11,26.57,1.47,30.1,16.78c3.02,13.1-2.26,23.32-15.96,30.86c-7.16,1.34-13.89,0.02-20.22-3.43
|
c14.8-5.11,26.57,1.47,30.1,16.78c3.02,13.1-2.26,23.32-15.96,30.86c-7.16,1.34-13.89,0.02-20.22-3.43
|
||||||
c-13.4-7.3-21.26-1.61-20.68,13.08c0.67,17.09,0.05,34.24,0.19,51.36c0.04,4.41-1.27,6.14-6,6.08
|
c-13.4-7.3-21.26-1.61-20.68,13.08c0.67,17.09,0.05,34.24,0.19,51.36c0.04,4.41-1.27,6.14-6,6.08
|
||||||
C537.4,558.41,519.11,558.56,500.83,558.56z`,
|
C537.4,558.41,519.11,558.56,500.83,558.56z`,
|
||||||
12: `M672.86,356.02c-2.51,6.41,0.79,18.02,13.81,17.95c20.1,0.13,35.75-0.18,55.04-0.12
|
12: `M672.86,356.02c-2.51,6.41,0.79,18.02,13.81,17.95c20.1,0.13,35.75-0.18,55.04-0.12
|
||||||
c2.71,0.01,5.6,0.85,5.59,3.82c-0.1,60.36,0.04,119.05,0.02,179.41c0,0.5-0.1,0.99-0.15,1.49c-2.03,2.03-6.17,0.87-8.61,0.87
|
c2.71,0.01,5.6,0.85,5.59,3.82c-0.1,60.36,0.04,119.05,0.02,179.41c0,0.5-0.1,0.99-0.15,1.49c-2.03,2.03-6.17,0.87-8.61,0.87
|
||||||
c-17.12,0.05-26.27-0.3-43.38-0.28c-2.15,0-11.08-0.45-13.17,0.28c-8.84-0.48-10.79,8.3-8.45,16.77c1.81,6.57,5.42,11.45,6.51,18.3
|
c-17.12,0.05-26.27-0.3-43.38-0.28c-2.15,0-11.08-0.45-13.17,0.28c-8.84-0.48-10.79,8.3-8.45,16.77c1.81,6.57,5.42,11.45,6.51,18.3
|
||||||
c2.11,13.13-5.76,25.93-18.87,28.22c-12.96,1.83-24.63-5.18-27.84-18.37c-1.17-5.93-0.47-11.13,1.66-16.63
|
c2.11,13.13-5.76,25.93-18.87,28.22c-12.96,1.83-24.63-5.18-27.84-18.37c-1.17-5.93-0.47-11.13,1.66-16.63
|
||||||
@@ -166,7 +166,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c-0.48-5.35,0.15-50.06,0.08-57.77c0.12-6.36,1.52-7.71,7.82-7.74c15.45-0.08,55.13,1.59,59.91,0.1c8.64-1.2,8.83-10.58,6.17-18.91
|
c-0.48-5.35,0.15-50.06,0.08-57.77c0.12-6.36,1.52-7.71,7.82-7.74c15.45-0.08,55.13,1.59,59.91,0.1c8.64-1.2,8.83-10.58,6.17-18.91
|
||||||
c-2.18-6.81-4.82-11.61-5.42-18.83c-1.06-12.89,6.43-21.57,18.36-24.77c12.02-3.23,23.2,1.76,28.07,13.54
|
c-2.18-6.81-4.82-11.61-5.42-18.83c-1.06-12.89,6.43-21.57,18.36-24.77c12.02-3.23,23.2,1.76,28.07,13.54
|
||||||
c2.84,3.48,2.84,14.33-0.1,18.72C676.25,349.18,675.38,349.61,672.86,356.02z`,
|
c2.84,3.48,2.84,14.33-0.1,18.72C676.25,349.18,675.38,349.61,672.86,356.02z`,
|
||||||
13: `M158.93,683.51c7.6-1.54,8.41-3.24,12.33-4.54c8.68-2.89,12.81,0.42,12.66,9.57
|
13: `M158.93,683.51c7.6-1.54,8.41-3.24,12.33-4.54c8.68-2.89,12.81,0.42,12.66,9.57
|
||||||
c-0.33,19.46-0.3,34.43,0.16,52.58c0.03,4.07-1,5.48-3.9,5.47c-19.8-0.07-39.6-0.1-59.4-0.09c-36.94,0.01-73.88-0.05-110.81,0.19
|
c-0.33,19.46-0.3,34.43,0.16,52.58c0.03,4.07-1,5.48-3.9,5.47c-19.8-0.07-39.6-0.1-59.4-0.09c-36.94,0.01-73.88-0.05-110.81,0.19
|
||||||
c-5.19,0.03-6.37-1.64-6.36-6.56c0.16-57.4,0.22-112.35,0.06-169.76c-0.01-4.91,0.91-7.27,6.13-7.15
|
c-5.19,0.03-6.37-1.64-6.36-6.56c0.16-57.4,0.22-112.35,0.06-169.76c-0.01-4.91,0.91-7.27,6.13-7.15
|
||||||
c14.89,0.4,21.99,0.35,37.35,0.26c7.15-0.04,14.42,0.15,21.69-3.87c2.87-1.59,5.56-5.31,4.74-13.79
|
c14.89,0.4,21.99,0.35,37.35,0.26c7.15-0.04,14.42,0.15,21.69-3.87c2.87-1.59,5.56-5.31,4.74-13.79
|
||||||
@@ -175,7 +175,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c0.76,2.79,2.86,4.51,3.28,5.26c3.46,4.47,10.74,4.2,15.7,4.24c17.26,0.14,30.99,0.14,48.25,0.17c7.71,0.01,8.18,2.39,8.19,10.06
|
c0.76,2.79,2.86,4.51,3.28,5.26c3.46,4.47,10.74,4.2,15.7,4.24c17.26,0.14,30.99,0.14,48.25,0.17c7.71,0.01,8.18,2.39,8.19,10.06
|
||||||
c0.02,17.26-0.44,31.02-0.06,48.28c0.19,8.42-2.29,17.32-15.1,11.43c-2.43-1.79-8.68-4.5-15.04-6
|
c0.02,17.26-0.44,31.02-0.06,48.28c0.19,8.42-2.29,17.32-15.1,11.43c-2.43-1.79-8.68-4.5-15.04-6
|
||||||
c-18.33-1.94-30.25,13.79-28.97,26.61c0.98,9.84,2.51,19.41,11.03,25.64C142.42,683.83,151.33,685.05,158.93,683.51z`,
|
c-18.33-1.94-30.25,13.79-28.97,26.61c0.98,9.84,2.51,19.41,11.03,25.64C142.42,683.83,151.33,685.05,158.93,683.51z`,
|
||||||
14: `M385.72,672.62c-5.94-0.44-10.23,1.91-11.61,7.64c-0.65,2.72-0.08,42.36-0.13,59.34
|
14: `M385.72,672.62c-5.94-0.44-10.23,1.91-11.61,7.64c-0.65,2.72-0.08,42.36-0.13,59.34
|
||||||
c-0.02,6.16-0.66,6.76-6.97,6.76c-57.57,0.08-115.14,0.08-172.71,0.33c-5.2,0.02-6.43-1.62-6.34-6.55
|
c-0.02,6.16-0.66,6.76-6.97,6.76c-57.57,0.08-115.14,0.08-172.71,0.33c-5.2,0.02-6.43-1.62-6.34-6.55
|
||||||
c0.32-17.63,0-33.67,0.07-51.31c0.05-11.57-6.48-17.41-17.48-13.67c-0.98,0.33-4.28,2.83-7.6,3.9c-4.42,1.42-9.07,1.44-10.17,1.44
|
c0.32-17.63,0-33.67,0.07-51.31c0.05-11.57-6.48-17.41-17.48-13.67c-0.98,0.33-4.28,2.83-7.6,3.9c-4.42,1.42-9.07,1.44-10.17,1.44
|
||||||
c-3.21-0.01-4.19-0.08-7.28-0.98c-1.74-0.51-4.01-1.36-5.1-2.05c-12.5-7.87-16.11-26.46-6.48-38.65c3.59-4.55,6.97-6.42,13.06-7.76
|
c-3.21-0.01-4.19-0.08-7.28-0.98c-1.74-0.51-4.01-1.36-5.1-2.05c-12.5-7.87-16.11-26.46-6.48-38.65c3.59-4.55,6.97-6.42,13.06-7.76
|
||||||
@@ -187,7 +187,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c0,1.33-0.05,9.66,0,10.99c0.41,10.02,6.99,12.08,16.18,8.29c3.53-1.46,7.61-4.38,11.29-5.49c13.88-4.19,27.74,6.89,29.47,21.38
|
c0,1.33-0.05,9.66,0,10.99c0.41,10.02,6.99,12.08,16.18,8.29c3.53-1.46,7.61-4.38,11.29-5.49c13.88-4.19,27.74,6.89,29.47,21.38
|
||||||
c1.71,14.38-7.59,22.68-13.59,26.47c-1.87,0.29-5.38,2.24-10.2,1.61c-4.14-0.54-10.64-5.06-11.97-5.66
|
c1.71,14.38-7.59,22.68-13.59,26.47c-1.87,0.29-5.38,2.24-10.2,1.61c-4.14-0.54-10.64-5.06-11.97-5.66
|
||||||
C390.15,673.37,387.41,672.75,385.72,672.62z`,
|
C390.15,673.37,387.41,672.75,385.72,672.62z`,
|
||||||
15: `M532.16,626.84c-13.24-1.71-28.15,9.06-29.54,23.72c-1.05,11.14,2.42,20.46,11.85,26.84
|
15: `M532.16,626.84c-13.24-1.71-28.15,9.06-29.54,23.72c-1.05,11.14,2.42,20.46,11.85,26.84
|
||||||
c9.32,6.31,19.4,6.22,28.74,0.56c12.05-7.3,18.75-1.95,18.33,11.04c-0.55,17.28-0.17,34.59-0.04,51.89
|
c9.32,6.31,19.4,6.22,28.74,0.56c12.05-7.3,18.75-1.95,18.33,11.04c-0.55,17.28-0.17,34.59-0.04,51.89
|
||||||
c0.02,3.53-0.9,4.95-4.81,4.94c-32.42-0.01-64.84,0.31-97.26,0.31c-25.77,0-51.54-0.37-77.31-0.37c-4.22,0-5.41-1.44-5.37-5.51
|
c0.02,3.53-0.9,4.95-4.81,4.94c-32.42-0.01-64.84,0.31-97.26,0.31c-25.77,0-51.54-0.37-77.31-0.37c-4.22,0-5.41-1.44-5.37-5.51
|
||||||
c0.19-17.3,0.08-34.59,0.11-51.89c-0.68-5.66,1.37-12.28,7.2-12.22c4.4,0.05,11.68,4.63,18.71,7.14
|
c0.19-17.3,0.08-34.59,0.11-51.89c-0.68-5.66,1.37-12.28,7.2-12.22c4.4,0.05,11.68,4.63,18.71,7.14
|
||||||
@@ -199,7 +199,7 @@ export const puzzlePathConfig: PuzzlePathConfig = {
|
|||||||
c-1.06,2.33-4.08,7.38-4.84,11.2c-0.39,1.99-0.93,8.02,0.93,11.13c2.7,4.53,8.52,6.04,11.09,6.11c0.83,0.04,23.75,0.56,41.65,0.54
|
c-1.06,2.33-4.08,7.38-4.84,11.2c-0.39,1.99-0.93,8.02,0.93,11.13c2.7,4.53,8.52,6.04,11.09,6.11c0.83,0.04,23.75,0.56,41.65,0.54
|
||||||
c7.02-0.01,15.74,0,17.73,0.09c1.99,0.09,5.41-1.42,5.12,1.33c-0.28,2.75,0.12,17.35,0,30.44c-0.13,13.77,1.53,28.83-0.11,36.06
|
c7.02-0.01,15.74,0,17.73,0.09c1.99,0.09,5.41-1.42,5.12,1.33c-0.28,2.75,0.12,17.35,0,30.44c-0.13,13.77,1.53,28.83-0.11,36.06
|
||||||
c-1.51,6.64-11.54,4.01-13.52,3.5C542.63,632.62,538.99,627.12,532.16,626.84z`,
|
c-1.51,6.64-11.54,4.01-13.52,3.5C542.63,632.62,538.99,627.12,532.16,626.84z`,
|
||||||
16: `M747.08,562.77c0.08,1.99,0.35,7.73,0.35,9.72c0.01,57.38-0.05,110.21-0.06,167.59c0,6.6,0,6.61-6.35,6.61
|
16: `M747.08,562.77c0.08,1.99,0.35,7.73,0.35,9.72c0.01,57.38-0.05,110.21-0.06,167.59c0,6.6,0,6.61-6.35,6.61
|
||||||
c-56.72,0.01-113.43-0.03-170.15,0.14c-5.02,0.02-6.59-1.36-6.5-6.45c0.3-16.13,0.08-32.27,0.13-48.4
|
c-56.72,0.01-113.43-0.03-170.15,0.14c-5.02,0.02-6.59-1.36-6.5-6.45c0.3-16.13,0.08-32.27,0.13-48.4
|
||||||
c0.01-3.85-0.24-7.67-1.2-11.37c-1.71-6.57-6.14-9.24-12.77-7.8c-3.1,0.67-5.93,2.1-8.76,3.52c-13.94,7.02-27.51,2.59-33.94-11.02
|
c0.01-3.85-0.24-7.67-1.2-11.37c-1.71-6.57-6.14-9.24-12.77-7.8c-3.1,0.67-5.93,2.1-8.76,3.52c-13.94,7.02-27.51,2.59-33.94-11.02
|
||||||
c-5.96-12.61-0.55-25.37,10.34-31.73c4.64-2.88,13.55-4.56,19.08-0.59c1.9,1.36,7.36,3.92,9.54,4.72
|
c-5.96-12.61-0.55-25.37,10.34-31.73c4.64-2.88,13.55-4.56,19.08-0.59c1.9,1.36,7.36,3.92,9.54,4.72
|
||||||
@@ -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'
|
||||||
};
|
}
|
||||||
@@ -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
|
||||||
};
|
}
|
||||||
@@ -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
|
||||||
};
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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";
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
+162
-169
@@ -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)
|
||||||
|
|
||||||
|
if (pointers.current.size < 2) {
|
||||||
|
lastDistance.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果剩余单指,重新记录拖拽初始状态
|
||||||
|
if (pointers.current.size === 1) {
|
||||||
|
const remaining = Array.from(pointers.current.values())[0]
|
||||||
|
const remainingId = Array.from(pointers.current.keys())[0]
|
||||||
|
if (remaining && remainingId !== undefined) {
|
||||||
|
dragStartRef.current = {
|
||||||
|
pointerId: remainingId,
|
||||||
|
startX: remaining.x,
|
||||||
|
startY: remaining.y,
|
||||||
|
startTransform: { ...transformRef.current },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
pointers.current.delete(e.pointerId);
|
dragStartRef.current = null
|
||||||
|
}
|
||||||
if (pointers.current.size < 2) {
|
}, [])
|
||||||
lastDistance.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果剩余单指,重新记录拖拽初始状态
|
|
||||||
if (pointers.current.size === 1) {
|
|
||||||
const remaining = Array.from(pointers.current.values())[0];
|
|
||||||
const remainingId = Array.from(pointers.current.keys())[0];
|
|
||||||
if (remaining && remainingId !== undefined) {
|
|
||||||
dragStartRef.current = {
|
|
||||||
pointerId: remainingId,
|
|
||||||
startX: remaining.x,
|
|
||||||
startY: remaining.y,
|
|
||||||
startTransform: { ...transformRef.current },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dragStartRef.current = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 双击重置
|
// 双击重置
|
||||||
const handleDoubleClick = useCallback(() => {
|
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,
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-29
@@ -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}`
|
||||||
};
|
}
|
||||||
@@ -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
Reference in new issue
Block a user