fix:提交修改
@@ -8,6 +8,8 @@
|
||||
"name": "dukang-haoke-official",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"peerjs": "^1.5.5",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.0"
|
||||
@@ -16,6 +18,7 @@
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"sharp": "^0.34.2",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
@@ -302,6 +305,17 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -744,6 +758,496 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -794,6 +1298,15 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpack/msgpack": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpack/msgpack/-/msgpack-2.8.0.tgz",
|
||||
"integrity": "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -1374,6 +1887,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.403",
|
||||
"resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.403.tgz",
|
||||
@@ -1433,6 +1956,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1555,6 +2084,38 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/peerjs": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmmirror.com/peerjs/-/peerjs-1.5.5.tgz",
|
||||
"integrity": "sha512-viMUCPDL6CSfOu0ZqVcFqbWRXNHIbv2lPqNbrBIjbFYrflebOjItJ4hPfhjnuUCstqciHVu9vVJ7jFqqKi/EuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^2.8.0",
|
||||
"eventemitter3": "^4.0.7",
|
||||
"peerjs-js-binarypack": "^2.1.0",
|
||||
"webrtc-adapter": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/peer"
|
||||
}
|
||||
},
|
||||
"node_modules/peerjs-js-binarypack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/peerjs-js-binarypack/-/peerjs-js-binarypack-2.1.0.tgz",
|
||||
"integrity": "sha512-YIwCC+pTzp3Bi8jPI9UFKO0t0SLo6xALnHkiNt/iUFmUUZG0fEEmEyFKvjsDKweiFitzHRyhuh6NvyJZ4nNxMg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/peer"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1604,6 +2165,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.8.tgz",
|
||||
@@ -1725,6 +2295,12 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/sdp/-/sdp-3.2.2.tgz",
|
||||
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
|
||||
@@ -1741,6 +2317,64 @@
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1768,6 +2402,14 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz",
|
||||
@@ -1888,6 +2530,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
||||
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"sdp": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0",
|
||||
"npm": ">=3.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"optimize:stage": "node scripts/optimize-stage-images.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"peerjs": "^1.5.5",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.0"
|
||||
@@ -17,6 +20,7 @@
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"sharp": "^0.34.2",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 296 B |
|
After Width: | Height: | Size: 360 B |
|
After Width: | Height: | Size: 228 B |
|
After Width: | Height: | Size: 234 B |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,73 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const outThumb = path.join(root, 'public/assets/stage/thumb')
|
||||
const outFull = path.join(root, 'public/assets/stage/full')
|
||||
|
||||
/** @type {{ src: string; name: string; thumbW: number; fullW: number }[]} */
|
||||
const STAGE_SOURCES = [
|
||||
{ src: 'public/assets/logo.png', name: 'logo', thumbW: 48, fullW: 512 },
|
||||
{ src: 'public/assets/qr-contact.png', name: 'qr-contact', thumbW: 48, fullW: 512 },
|
||||
{ src: 'public/assets/t30-bottle.png', name: 't30-bottle', thumbW: 64, fullW: 800 },
|
||||
{ src: 'public/assets/brand/emb_p6_5.png', name: 'emb_p6_5', thumbW: 64, fullW: 960 },
|
||||
{ src: 'public/assets/brand/emb_p33_1.png', name: 'emb_p33_1', thumbW: 64, fullW: 960 },
|
||||
]
|
||||
|
||||
async function ensureDirs() {
|
||||
await fs.mkdir(outThumb, { recursive: true })
|
||||
await fs.mkdir(outFull, { recursive: true })
|
||||
}
|
||||
|
||||
async function writeThumb(srcPath, name, width) {
|
||||
const dest = path.join(outThumb, `${name}.webp`)
|
||||
await sharp(srcPath)
|
||||
.resize(width, null, { withoutEnlargement: true })
|
||||
.blur(2)
|
||||
.webp({ quality: 50 })
|
||||
.toFile(dest)
|
||||
const stat = await fs.stat(dest)
|
||||
return { dest, kb: Math.round(stat.size / 1024 * 10) / 10 }
|
||||
}
|
||||
|
||||
async function writeFull(srcPath, name, width) {
|
||||
const dest = path.join(outFull, `${name}.webp`)
|
||||
await sharp(srcPath)
|
||||
.resize(width, null, { withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toFile(dest)
|
||||
const stat = await fs.stat(dest)
|
||||
return { dest, kb: Math.round(stat.size / 1024 * 10) / 10 }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureDirs()
|
||||
let thumbTotal = 0
|
||||
let fullTotal = 0
|
||||
|
||||
for (const item of STAGE_SOURCES) {
|
||||
const srcPath = path.join(root, item.src)
|
||||
try {
|
||||
await fs.access(srcPath)
|
||||
} catch {
|
||||
console.warn(`skip (missing): ${item.src}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const thumb = await writeThumb(srcPath, item.name, item.thumbW)
|
||||
const full = await writeFull(srcPath, item.name, item.fullW)
|
||||
thumbTotal += thumb.kb
|
||||
fullTotal += full.kb
|
||||
console.log(`${item.name}: thumb ${thumb.kb} KB, full ${full.kb} KB`)
|
||||
}
|
||||
|
||||
console.log(`\nDone. thumb total ~${Math.round(thumbTotal)} KB, full total ~${Math.round(fullTotal)} KB`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import { useIsMobile } from '../hooks/useMobile'
|
||||
import { StageProvider, useStageMode } from '../hooks/useStageMode'
|
||||
import { StageProvider, useRemoteMode, useStageMode } from '../hooks/useStageMode'
|
||||
import { MobileShell } from '../mobile/MobileShell'
|
||||
import { MobileViews } from '../mobile/MobileViews'
|
||||
import { RemoteShell } from '../stage/remote/RemoteShell'
|
||||
import { StageShell } from '../stage/StageShell'
|
||||
import { Footer } from './Footer'
|
||||
import { Nav } from './Nav'
|
||||
@@ -14,41 +15,45 @@ const techRoutes = new Set(['/tech', '/subsidy', '/partner', '/tech-detail', '/c
|
||||
function LayoutInner() {
|
||||
const location = useLocation()
|
||||
const isStage = useStageMode()
|
||||
const isRemote = useRemoteMode()
|
||||
const isMobile = useIsMobile(768)
|
||||
const techLike = techRoutes.has(location.pathname)
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) return
|
||||
window.scrollTo({ top: 0, behavior: 'auto' })
|
||||
}, [location.pathname])
|
||||
}, [location.pathname, isMobile])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('stage-mode', isStage)
|
||||
document.body.classList.toggle('stage-mode', isStage)
|
||||
const stageOn = isStage && !isMobile
|
||||
document.documentElement.classList.toggle('stage-mode', stageOn)
|
||||
document.body.classList.toggle('stage-mode', stageOn)
|
||||
document.documentElement.classList.toggle('remote-mode', isRemote)
|
||||
document.body.classList.toggle('remote-mode', isRemote)
|
||||
return () => {
|
||||
document.documentElement.classList.remove('stage-mode')
|
||||
document.body.classList.remove('stage-mode')
|
||||
document.documentElement.classList.remove('remote-mode')
|
||||
document.body.classList.remove('remote-mode')
|
||||
}
|
||||
}, [isStage])
|
||||
}, [isStage, isRemote, isMobile])
|
||||
|
||||
// 1. Stage Mode Branch
|
||||
if (isStage) {
|
||||
return <StageShell />
|
||||
if (isRemote) {
|
||||
return <RemoteShell />
|
||||
}
|
||||
|
||||
// 2. Mobile Dedicated Layout Branch (< 768px)
|
||||
if (isMobile) {
|
||||
// If accessing deep pages (e.g. /culture/status), render through Outlet; main modules render through MobileViews
|
||||
const isDeepPage =
|
||||
location.pathname.startsWith('/culture/') ||
|
||||
location.pathname.startsWith('/poetry/') ||
|
||||
location.pathname.startsWith('/qingxiang/')
|
||||
return (
|
||||
<MobileShell>
|
||||
{isDeepPage ? <Outlet /> : <MobileViews />}
|
||||
<MobileViews />
|
||||
</MobileShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (isStage) {
|
||||
return <StageShell />
|
||||
}
|
||||
|
||||
// 3. Desktop Normal Mode Branch
|
||||
return (
|
||||
<div className={`site-shell ${techLike ? 'theme-tech' : ''}`}>
|
||||
|
||||
@@ -12,3 +12,8 @@ export function StageProvider({ children }: { children: ReactNode }) {
|
||||
export function useStageMode() {
|
||||
return useContext(StageContext)
|
||||
}
|
||||
|
||||
export function useRemoteMode() {
|
||||
const location = useLocation()
|
||||
return useMemo(() => new URLSearchParams(location.search).get('mode') === 'remote', [location.search])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { DeepPageContent } from '../content/deep/types'
|
||||
|
||||
export function pathToSectionId(path: string) {
|
||||
return `m${path.replace(/\//g, '-')}`
|
||||
}
|
||||
|
||||
export function MobileDeepSection({ page }: { page: DeepPageContent }) {
|
||||
return (
|
||||
<div className="m-view-container">
|
||||
<div>
|
||||
<div className="m-eyebrow">{page.eyebrow}</div>
|
||||
<h2 className="m-page-title">{page.title}</h2>
|
||||
<p className="m-lead">{page.lead}</p>
|
||||
</div>
|
||||
|
||||
{page.images[0] ? (
|
||||
<img src={page.images[0]} alt="" className="m-deep-img" />
|
||||
) : null}
|
||||
|
||||
<div className="m-deep-blocks">
|
||||
{page.blocks.map((block, i) => {
|
||||
if (block.type === 'heading') {
|
||||
return (
|
||||
<h3 key={`${page.path}-h-${i}`} className="m-deep-h">
|
||||
{block.text}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
if (block.type === 'paragraph') {
|
||||
return (
|
||||
<p key={`${page.path}-p-${i}`} className="m-deep-p">
|
||||
{block.text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (block.type === 'quote') {
|
||||
return (
|
||||
<blockquote key={`${page.path}-q-${i}`} className="m-poem-box">
|
||||
<p className="m-poem-quote">{block.text}</p>
|
||||
{block.source ? <div className="m-poem-source">—— {block.source}</div> : null}
|
||||
</blockquote>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ul key={`${page.path}-l-${i}`} className="m-deep-list">
|
||||
{block.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{page.images.slice(1).map((src) => (
|
||||
<img key={src} src={src} alt="" className="m-deep-img" />
|
||||
))}
|
||||
|
||||
{page.closing ? <div className="m-closing">{page.closing}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,227 +11,82 @@
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Fixed Header with Status Bar Safe Area Top */
|
||||
.m-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(3.6rem + env(safe-area-inset-top, 0px));
|
||||
min-height: calc(5.6rem + env(safe-area-inset-top, 0px));
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-left: 0.7rem;
|
||||
padding-right: 0.7rem;
|
||||
padding-bottom: 0.35rem;
|
||||
box-sizing: border-box;
|
||||
z-index: 1000;
|
||||
background: rgba(247, 242, 234, 0.95);
|
||||
background: rgba(247, 242, 234, 0.96);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(166, 28, 32, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-tech .m-header {
|
||||
background: rgba(18, 16, 20, 0.95);
|
||||
border-bottom-color: rgba(196, 163, 90, 0.2);
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.m-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.45rem;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.m-logo {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.m-title {
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand, #a61c20);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.theme-tech .m-title {
|
||||
color: var(--gold, #c4a35a);
|
||||
}
|
||||
|
||||
.m-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.m-stage-badge {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.72rem;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #a61c20 0%, #7d1216 100%);
|
||||
border: 1px solid rgba(196, 163, 90, 0.3);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 8px rgba(166, 28, 32, 0.25);
|
||||
}
|
||||
|
||||
.m-menu-btn {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.32rem;
|
||||
cursor: pointer;
|
||||
z-index: 1002;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.m-menu-btn span {
|
||||
width: 1.3rem;
|
||||
height: 2px;
|
||||
background: var(--brand, #a61c20);
|
||||
border-radius: 1px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-tech .m-menu-btn span {
|
||||
background: var(--gold, #c4a35a);
|
||||
}
|
||||
|
||||
.m-menu-btn.is-open span:nth-child(1) {
|
||||
transform: translateY(6px) rotate(45deg);
|
||||
}
|
||||
|
||||
.m-menu-btn.is-open span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.m-menu-btn.is-open span:nth-child(3) {
|
||||
transform: translateY(-6px) rotate(-45deg);
|
||||
}
|
||||
|
||||
/* Drawer Overlay & Panel */
|
||||
.m-drawer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.m-drawer-overlay.is-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.m-drawer {
|
||||
position: fixed;
|
||||
top: calc(3.6rem + env(safe-area-inset-top, 0px));
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(20rem, 85vw);
|
||||
background: #f7f2ea;
|
||||
z-index: 1001;
|
||||
padding: 1.25rem 1.25rem calc(2rem + env(safe-area-inset-bottom, 0px));
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.theme-tech .m-drawer {
|
||||
background: #141117;
|
||||
color: #f3ece4;
|
||||
}
|
||||
|
||||
.m-drawer.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.m-drawer-section {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.m-drawer-sec-title {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--gold, #c4a35a);
|
||||
margin: 0 0 0.5rem;
|
||||
padding-bottom: 0.3rem;
|
||||
border-bottom: 1px solid rgba(196, 163, 90, 0.2);
|
||||
}
|
||||
|
||||
.m-drawer-item {
|
||||
.m-header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.7rem 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink, #1a1512);
|
||||
gap: 0.15rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m-nav-link {
|
||||
flex: 1;
|
||||
min-height: 2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.2rem 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
color: var(--ink-soft, #5c534c);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(166, 28, 32, 0.06);
|
||||
white-space: nowrap;
|
||||
border-radius: 0.2rem;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-tech .m-drawer-item {
|
||||
color: #f3ece4;
|
||||
border-bottom-color: rgba(196, 163, 90, 0.08);
|
||||
}
|
||||
|
||||
.m-drawer-item.is-active {
|
||||
.m-nav-link.is-active {
|
||||
color: var(--brand, #a61c20);
|
||||
font-weight: 600;
|
||||
background: rgba(166, 28, 32, 0.08);
|
||||
}
|
||||
|
||||
.theme-tech .m-drawer-item.is-active {
|
||||
color: var(--gold, #c4a35a);
|
||||
background: rgba(196, 163, 90, 0.12);
|
||||
}
|
||||
|
||||
.m-drawer-sublist {
|
||||
padding-left: 0.85rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.m-drawer-subitem {
|
||||
display: block;
|
||||
padding: 0.5rem 0.5rem;
|
||||
font-size: 0.86rem;
|
||||
color: var(--ink-soft, #5c534c);
|
||||
text-decoration: none;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
.theme-tech .m-drawer-subitem {
|
||||
color: rgba(243, 236, 228, 0.7);
|
||||
}
|
||||
|
||||
.m-drawer-subitem.is-active {
|
||||
color: var(--brand, #a61c20);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Main Content Wrapper - Push down content to avoid Fixed Header overlap */
|
||||
.m-main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding-top: calc(3.6rem + env(safe-area-inset-top, 0px));
|
||||
padding-top: calc(5.6rem + env(safe-area-inset-top, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -1,99 +1,55 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { navGroups } from '../content/nav'
|
||||
import { type ReactNode } from 'react'
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
import { images } from '../content/site'
|
||||
import { Footer } from '../components/Footer'
|
||||
import './MobileShell.css'
|
||||
|
||||
const techRoutes = new Set(['/tech', '/subsidy', '/partner', '/tech-detail', '/contact'])
|
||||
const mobileNav = [
|
||||
{ id: 'm-home', label: '首页' },
|
||||
{ id: 'm-culture', label: '酒祖文化' },
|
||||
{ id: 'm-qingxiang', label: '清香臻品' },
|
||||
{ id: 'm-haoke', label: '好客科技' },
|
||||
{ id: 'm-contact', label: '合作联系' },
|
||||
] as const
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
|
||||
export function MobileShell({ children }: { children?: ReactNode }) {
|
||||
const location = useLocation()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const isTechPage = techRoutes.has(location.pathname)
|
||||
const activeId = location.hash.replace('#', '') || 'm-home'
|
||||
|
||||
// Close drawer on navigation
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false)
|
||||
window.scrollTo({ top: 0, behavior: 'auto' })
|
||||
}, [location.pathname, location.search])
|
||||
|
||||
// Prevent background scroll when drawer is open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = drawerOpen ? 'hidden' : ''
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [drawerOpen])
|
||||
const goSection = (id: string) => {
|
||||
requestAnimationFrame(() => scrollToSection(id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mobile-shell ${isTechPage ? 'theme-tech' : ''}`}>
|
||||
{/* Mobile Top Header */}
|
||||
<div className="mobile-shell">
|
||||
<header className="m-header">
|
||||
<Link to="/" className="m-brand">
|
||||
<Link to="/#m-home" className="m-brand" onClick={() => goSection('m-home')}>
|
||||
<img src={images.logo} alt="杜康好客" className="m-logo" />
|
||||
<span className="m-title">杜康好客</span>
|
||||
</Link>
|
||||
|
||||
<div className="m-header-actions">
|
||||
<Link to="/?mode=stage" className="m-stage-badge">
|
||||
大屏展控
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className={`m-menu-btn ${drawerOpen ? 'is-open' : ''}`}
|
||||
onClick={() => setDrawerOpen((v) => !v)}
|
||||
aria-label={drawerOpen ? '关闭菜单' : '打开菜单'}
|
||||
>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="m-header-nav" aria-label="手机导航">
|
||||
{mobileNav.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={`/#${item.id}`}
|
||||
className={`m-nav-link ${activeId === item.id ? 'is-active' : ''}`}
|
||||
onClick={() => goSection(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Drawer Mask & Navigation Panel */}
|
||||
<div
|
||||
className={`m-drawer-overlay ${drawerOpen ? 'is-open' : ''}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
/>
|
||||
<div className={`m-drawer ${drawerOpen ? 'is-open' : ''}`}>
|
||||
<div style={{ marginBottom: '1rem', fontWeight: 600, fontSize: '1.05rem', color: 'var(--brand)' }}>
|
||||
全站导航目录
|
||||
</div>
|
||||
|
||||
<div className="m-drawer-section">
|
||||
<Link
|
||||
to="/"
|
||||
className={`m-drawer-item ${location.pathname === '/' ? 'is-active' : ''}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
<span>首页</span>
|
||||
<span>›</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.id} className="m-drawer-section">
|
||||
<div className="m-drawer-sec-title">{group.label}</div>
|
||||
<div className="m-drawer-sublist">
|
||||
{group.children.map((child) => (
|
||||
<NavLink
|
||||
key={child.path}
|
||||
to={child.path}
|
||||
className={({ isActive }) => `m-drawer-subitem ${isActive ? 'is-active' : ''}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
{child.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="m-main" key={location.pathname}>
|
||||
<main className="m-main">
|
||||
{children || <Outlet />}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
/* ===== Mobile Views Dedicated CSS ===== */
|
||||
|
||||
.m-longpage {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m-section {
|
||||
scroll-margin-top: calc(5.6rem + env(safe-area-inset-top, 0px) + 0.4rem);
|
||||
}
|
||||
|
||||
.m-section + .m-section {
|
||||
border-top: 1px solid rgba(166, 28, 32, 0.1);
|
||||
}
|
||||
|
||||
.m-join-label {
|
||||
margin: 0;
|
||||
padding: 1.1rem 1rem 0;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--gold, #c4a35a);
|
||||
}
|
||||
|
||||
.m-section-tech {
|
||||
background: var(--tech-bg, #121014);
|
||||
color: #f3ece4;
|
||||
}
|
||||
|
||||
.m-section-tech + .m-section-tech {
|
||||
border-top-color: rgba(196, 163, 90, 0.18);
|
||||
}
|
||||
|
||||
.m-section-tech .m-join-label,
|
||||
.m-section-tech .m-eyebrow,
|
||||
.m-section-tech .m-page-title {
|
||||
color: var(--gold, #c4a35a);
|
||||
}
|
||||
|
||||
.m-section-tech .m-lead,
|
||||
.m-section-tech .m-card p,
|
||||
.m-section-tech .m-poem-source {
|
||||
color: rgba(243, 236, 228, 0.75);
|
||||
}
|
||||
|
||||
.m-section-tech .m-card {
|
||||
background: rgba(28, 25, 31, 0.85);
|
||||
border-color: rgba(196, 163, 90, 0.22);
|
||||
color: #f3ece4;
|
||||
}
|
||||
|
||||
.m-section-tech .m-card h3,
|
||||
.m-section-tech .m-big-num,
|
||||
.m-section-tech .m-closing {
|
||||
color: var(--gold, #c4a35a);
|
||||
}
|
||||
|
||||
.m-view-container {
|
||||
width: 100%;
|
||||
padding: 1.25rem 1rem 2.5rem;
|
||||
@@ -262,31 +315,38 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.m-deep-links {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(166, 28, 32, 0.12);
|
||||
.m-deep-blocks {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.m-deep-card {
|
||||
padding: 0.85rem 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-left: 3px solid var(--gold, #c4a35a);
|
||||
border-radius: 0.2rem;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.m-deep-card strong {
|
||||
font-size: 0.95rem;
|
||||
.m-deep-h {
|
||||
margin: 0.4rem 0 0;
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
font-size: 1.1rem;
|
||||
color: var(--brand, #a61c20);
|
||||
}
|
||||
|
||||
.m-deep-card span {
|
||||
font-size: 0.82rem;
|
||||
.m-deep-p {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.7;
|
||||
color: var(--ink-soft, #5c534c);
|
||||
}
|
||||
|
||||
.m-deep-list {
|
||||
margin: 0;
|
||||
padding-left: 1.15rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--ink-soft, #5c534c);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.m-deep-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.35rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { allDeepPages } from '../content/nav'
|
||||
import {
|
||||
brand,
|
||||
contact,
|
||||
@@ -13,41 +15,109 @@ import {
|
||||
tech,
|
||||
techDetail,
|
||||
} from '../content/site'
|
||||
import { MobileDeepSection, pathToSectionId } from './MobileDeepSection'
|
||||
import './MobileViews.css'
|
||||
|
||||
const cultureDeep = allDeepPages.filter((p) => p.group === 'culture')
|
||||
const poetryDeep = allDeepPages.filter((p) => p.group === 'poetry')
|
||||
const qingxiangDeep = allDeepPages.filter((p) => p.group === 'qingxiang')
|
||||
|
||||
const PATH_TO_SECTION: Record<string, string> = {
|
||||
'/': 'm-home',
|
||||
'/brand': 'm-culture',
|
||||
'/poetry': 'm-poetry',
|
||||
'/qingxiang': 'm-qingxiang',
|
||||
'/haoke': 'm-haoke',
|
||||
'/system': 'm-system',
|
||||
'/tech': 'm-tech',
|
||||
'/subsidy': 'm-subsidy',
|
||||
'/partner': 'm-partner',
|
||||
'/tech-detail': 'm-tech-detail',
|
||||
'/contact': 'm-contact',
|
||||
...Object.fromEntries(allDeepPages.map((p) => [p.path, pathToSectionId(p.path)])),
|
||||
}
|
||||
|
||||
export function MobileViews() {
|
||||
const location = useLocation()
|
||||
const path = location.pathname
|
||||
|
||||
switch (path) {
|
||||
case '/':
|
||||
return <MobileHomeView />
|
||||
case '/brand':
|
||||
return <MobileBrandView />
|
||||
case '/poetry':
|
||||
return <MobilePoetryView />
|
||||
case '/qingxiang':
|
||||
return <MobileQingxiangView />
|
||||
case '/haoke':
|
||||
return <MobileHaokeView />
|
||||
case '/system':
|
||||
return <MobileSystemView />
|
||||
case '/tech':
|
||||
return <MobileTechView />
|
||||
case '/subsidy':
|
||||
return <MobileSubsidyView />
|
||||
case '/partner':
|
||||
return <MobilePartnerView />
|
||||
case '/tech-detail':
|
||||
return <MobileTechDetailView />
|
||||
case '/contact':
|
||||
return <MobileContactView />
|
||||
default:
|
||||
return <Outlet />
|
||||
}
|
||||
useEffect(() => {
|
||||
const fromHash = location.hash.replace('#', '')
|
||||
const id = fromHash || PATH_TO_SECTION[location.pathname] || 'm-home'
|
||||
const timer = window.setTimeout(() => {
|
||||
const el = document.getElementById(id)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 40)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [location.pathname, location.hash])
|
||||
|
||||
return (
|
||||
<div className="m-longpage">
|
||||
<section id="m-home" className="m-section">
|
||||
<MobileHomeView />
|
||||
</section>
|
||||
|
||||
<section id="m-culture" className="m-section">
|
||||
<p className="m-join-label">酒祖文化</p>
|
||||
<MobileBrandView />
|
||||
</section>
|
||||
{cultureDeep.map((page) => (
|
||||
<section key={page.path} id={pathToSectionId(page.path)} className="m-section">
|
||||
<MobileDeepSection page={page} />
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section id="m-poetry" className="m-section">
|
||||
<MobilePoetryView />
|
||||
</section>
|
||||
{poetryDeep.map((page) => (
|
||||
<section key={page.path} id={pathToSectionId(page.path)} className="m-section">
|
||||
<MobileDeepSection page={page} />
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section id="m-qingxiang" className="m-section">
|
||||
<p className="m-join-label">清香臻品</p>
|
||||
<MobileQingxiangView />
|
||||
</section>
|
||||
{qingxiangDeep.map((page) => (
|
||||
<section key={page.path} id={pathToSectionId(page.path)} className="m-section">
|
||||
<MobileDeepSection page={page} />
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section id="m-haoke" className="m-section">
|
||||
<p className="m-join-label">好客科技</p>
|
||||
<MobileHaokeView />
|
||||
</section>
|
||||
|
||||
<section id="m-system" className="m-section">
|
||||
<MobileSystemView />
|
||||
</section>
|
||||
|
||||
<section id="m-tech" className="m-section m-section-tech">
|
||||
<MobileTechView />
|
||||
</section>
|
||||
|
||||
<section id="m-subsidy" className="m-section m-section-tech">
|
||||
<MobileSubsidyView />
|
||||
</section>
|
||||
|
||||
<section id="m-tech-detail" className="m-section m-section-tech">
|
||||
<MobileTechDetailView />
|
||||
</section>
|
||||
|
||||
<section id="m-partner" className="m-section">
|
||||
<p className="m-join-label">合作联系</p>
|
||||
<MobilePartnerView />
|
||||
</section>
|
||||
|
||||
<section id="m-contact" className="m-section">
|
||||
<MobileContactView />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* 1. Mobile Home View */
|
||||
function MobileHomeView() {
|
||||
return (
|
||||
<div className="m-view-container">
|
||||
@@ -62,19 +132,18 @@ function MobileHomeView() {
|
||||
{hero.subtitle}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
||||
<Link to="/brand" className="m-btn m-btn-primary">
|
||||
<a href="#m-culture" className="m-btn m-btn-primary">
|
||||
{hero.primaryCta.label}
|
||||
</Link>
|
||||
<Link to="/partner" className="m-btn m-btn-outline">
|
||||
</a>
|
||||
<a href="#m-contact" className="m-btn m-btn-outline">
|
||||
{hero.secondaryCta.label}
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* 2. Mobile Brand View */
|
||||
function MobileBrandView() {
|
||||
return (
|
||||
<div className="m-view-container">
|
||||
@@ -129,21 +198,10 @@ function MobileBrandView() {
|
||||
</div>
|
||||
|
||||
<div className="m-closing">{brand.closing}</div>
|
||||
|
||||
<div className="m-deep-links">
|
||||
<div style={{ fontSize: '0.9rem', color: 'var(--brand)', fontWeight: 600 }}>深度阅读</div>
|
||||
{brand.deepReads.map((dp) => (
|
||||
<Link key={dp.path} to={dp.path} className="m-deep-card">
|
||||
<strong>{dp.label} ›</strong>
|
||||
<span>{dp.blurb}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* 3. Mobile Poetry View */
|
||||
function MobilePoetryView() {
|
||||
return (
|
||||
<div className="m-view-container">
|
||||
@@ -168,21 +226,10 @@ function MobilePoetryView() {
|
||||
</div>
|
||||
|
||||
<div className="m-closing">{poetry.closing}</div>
|
||||
|
||||
<div className="m-deep-links">
|
||||
<div style={{ fontSize: '0.9rem', color: 'var(--brand)', fontWeight: 600 }}>诗酒专题</div>
|
||||
{poetry.deepReads.map((dp) => (
|
||||
<Link key={dp.path} to={dp.path} className="m-deep-card">
|
||||
<strong>{dp.label} ›</strong>
|
||||
<span>{dp.blurb}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* 4. Mobile Qingxiang View */
|
||||
function MobileQingxiangView() {
|
||||
return (
|
||||
<div className="m-view-container">
|
||||
@@ -225,21 +272,10 @@ function MobileQingxiangView() {
|
||||
</div>
|
||||
|
||||
<div className="m-closing">{qingxiang.mission}</div>
|
||||
|
||||
<div className="m-deep-links">
|
||||
<div style={{ fontSize: '0.9rem', color: 'var(--brand)', fontWeight: 600 }}>清香宝典专题</div>
|
||||
{qingxiang.deepReads.map((dp) => (
|
||||
<Link key={dp.path} to={dp.path} className="m-deep-card">
|
||||
<strong>{dp.label} ›</strong>
|
||||
<span>{dp.blurb}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* 5. Mobile Haoke View */
|
||||
function MobileHaokeView() {
|
||||
return (
|
||||
<div className="m-view-container">
|
||||
@@ -270,7 +306,6 @@ function MobileHaokeView() {
|
||||
)
|
||||
}
|
||||
|
||||
/* 6. Mobile System View */
|
||||
function MobileSystemView() {
|
||||
return (
|
||||
<div className="m-view-container">
|
||||
@@ -309,7 +344,6 @@ function MobileSystemView() {
|
||||
)
|
||||
}
|
||||
|
||||
/* 7. Mobile Tech View */
|
||||
function MobileTechView() {
|
||||
return (
|
||||
<div className="m-view-container">
|
||||
@@ -329,17 +363,10 @@ function MobileTechView() {
|
||||
</div>
|
||||
|
||||
<div className="m-closing">{tech.claim}</div>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: '0.5rem' }}>
|
||||
<Link to={tech.cta.to} className="m-btn m-btn-primary">
|
||||
{tech.cta.label}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* 8. Mobile Subsidy View */
|
||||
function MobileSubsidyView() {
|
||||
return (
|
||||
<div className="m-view-container">
|
||||
@@ -372,7 +399,6 @@ function MobileSubsidyView() {
|
||||
)
|
||||
}
|
||||
|
||||
/* 9. Mobile Partner View */
|
||||
function MobilePartnerView() {
|
||||
return (
|
||||
<div className="m-view-container">
|
||||
@@ -392,18 +418,17 @@ function MobilePartnerView() {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', marginTop: '1rem' }}>
|
||||
<Link to={partner.primaryCta.to} className="m-btn m-btn-primary">
|
||||
<a href="#m-contact" className="m-btn m-btn-primary">
|
||||
{partner.primaryCta.label}
|
||||
</Link>
|
||||
<Link to={partner.secondaryCta.to} className="m-btn m-btn-outline">
|
||||
</a>
|
||||
<a href="#m-tech-detail" className="m-btn m-btn-outline">
|
||||
{partner.secondaryCta.label}
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* 10. Mobile Tech Detail View */
|
||||
function MobileTechDetailView() {
|
||||
return (
|
||||
<div className="m-view-container">
|
||||
@@ -427,7 +452,6 @@ function MobileTechDetailView() {
|
||||
)
|
||||
}
|
||||
|
||||
/* 11. Mobile Contact View */
|
||||
function MobileContactView() {
|
||||
return (
|
||||
<div className="m-view-container">
|
||||
@@ -456,7 +480,7 @@ function MobileContactView() {
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
📞 {contact.phone}
|
||||
{contact.phone}
|
||||
</a>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--ink-soft)', marginTop: '0.4rem' }}>
|
||||
{contact.wechatHint}
|
||||
|
||||
@@ -80,11 +80,24 @@
|
||||
}
|
||||
|
||||
.module-rail {
|
||||
overflow: hidden;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.module-rail-track {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: moduleMarquee 40s linear infinite;
|
||||
}
|
||||
|
||||
.module-rail:hover .module-rail-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.module-rail-copy {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.75rem;
|
||||
scroll-snap-type: x mandatory;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.module-rail-item {
|
||||
@@ -94,13 +107,27 @@
|
||||
padding: 1.2rem 1.1rem;
|
||||
border-left: 2px solid var(--brand);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
scroll-snap-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
transition: transform 0.35s var(--ease), background 0.35s var(--ease);
|
||||
}
|
||||
|
||||
@keyframes moduleMarquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.module-rail-track {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.module-rail-item:hover {
|
||||
transform: translateY(-0.25rem);
|
||||
background: #fff;
|
||||
|
||||
@@ -44,15 +44,25 @@ export function HomePage() {
|
||||
<p className="lead">从酒祖文脉到好客闭环,循定稿结构逐章走进。</p>
|
||||
</Reveal>
|
||||
<div className="module-rail">
|
||||
{moduleEntries.map((item, index) => (
|
||||
<Reveal key={item.to} variant="anim-slide-right" delayMs={index * 70}>
|
||||
<Link to={item.to} className="module-rail-item" style={{ fontSize: `${1.05 + (index % 3) * 0.12}rem` }}>
|
||||
<span className="module-index">{String(index + 1).padStart(2, '0')}</span>
|
||||
<strong className="serif">{item.label}</strong>
|
||||
<span className="module-desc">{item.desc}</span>
|
||||
</Link>
|
||||
</Reveal>
|
||||
))}
|
||||
<div className="module-rail-track">
|
||||
{[0, 1].map((copy) => (
|
||||
<div key={copy} className="module-rail-copy" aria-hidden={copy === 1}>
|
||||
{moduleEntries.map((item, index) => (
|
||||
<Link
|
||||
key={`${copy}-${item.to}`}
|
||||
to={item.to}
|
||||
className="module-rail-item"
|
||||
tabIndex={copy === 1 ? -1 : undefined}
|
||||
style={{ fontSize: `${1.05 + (index % 3) * 0.12}rem` }}
|
||||
>
|
||||
<span className="module-index">{String(index + 1).padStart(2, '0')}</span>
|
||||
<strong className="serif">{item.label}</strong>
|
||||
<span className="module-desc">{item.desc}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { StageImagePair } from './stageAssets'
|
||||
|
||||
interface StageProgressiveImageProps {
|
||||
pair: StageImagePair
|
||||
alt: string
|
||||
className?: string
|
||||
priority?: boolean
|
||||
}
|
||||
|
||||
export function StageProgressiveImage({ pair, alt, className, priority }: StageProgressiveImageProps) {
|
||||
const [fullLoaded, setFullLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setFullLoaded(false)
|
||||
const img = new Image()
|
||||
img.src = pair.full
|
||||
if (img.complete) {
|
||||
setFullLoaded(true)
|
||||
return
|
||||
}
|
||||
const onLoad = () => setFullLoaded(true)
|
||||
img.addEventListener('load', onLoad)
|
||||
return () => img.removeEventListener('load', onLoad)
|
||||
}, [pair.full])
|
||||
|
||||
const shellClass = ['stage-prog-img', fullLoaded ? 'is-full-loaded' : '', className ?? '']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<div className={shellClass}>
|
||||
<img src={pair.thumb} alt="" aria-hidden className="stage-prog-img__thumb" decoding="async" />
|
||||
<img
|
||||
src={pair.full}
|
||||
alt={alt}
|
||||
className="stage-prog-img__full"
|
||||
decoding="async"
|
||||
fetchPriority={priority ? 'high' : 'auto'}
|
||||
onLoad={() => setFullLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -42,6 +42,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.75rem;
|
||||
overflow: hidden;
|
||||
z-index: 50;
|
||||
background: linear-gradient(180deg, rgba(14, 12, 16, 0.95) 0%, rgba(14, 12, 16, 0) 100%);
|
||||
border-bottom: 1px solid rgba(196, 163, 90, 0.15);
|
||||
@@ -53,11 +56,31 @@
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
text-decoration: none;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stage-logo,
|
||||
.stage-logo-prog {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stage-logo-prog .stage-prog-img__thumb,
|
||||
.stage-logo-prog .stage-prog-img__full {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 8px rgba(196, 163, 90, 0.4));
|
||||
}
|
||||
|
||||
.stage-logo-prog .stage-prog-img__thumb {
|
||||
filter: blur(4px) drop-shadow(0 0 8px rgba(196, 163, 90, 0.4));
|
||||
}
|
||||
|
||||
.stage-logo {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 8px rgba(196, 163, 90, 0.4));
|
||||
}
|
||||
@@ -92,6 +115,9 @@
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 2rem;
|
||||
border: 1px solid rgba(196, 163, 90, 0.2);
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stage-nav-pill {
|
||||
@@ -120,9 +146,18 @@
|
||||
.stage-hud-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
flex-shrink: 0;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.stage-autoplay-btn,
|
||||
.stage-remote-toggle,
|
||||
.stage-exit-btn {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stage-autoplay-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -235,7 +270,7 @@
|
||||
background: linear-gradient(90deg, var(--brand, #a61c20), var(--gold, #c4a35a));
|
||||
border-radius: 0.25rem;
|
||||
transform-origin: left;
|
||||
animation: stageProgressBar 6s linear forwards;
|
||||
animation: stageProgressBar var(--stage-progress-dur, 6s) linear forwards;
|
||||
}
|
||||
|
||||
@keyframes stageProgressBar {
|
||||
@@ -254,17 +289,29 @@
|
||||
}
|
||||
|
||||
/* Mobile Responsive Adjustments for Stage Shell */
|
||||
@media (max-width: 72rem) {
|
||||
.stage-nav-pills {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.stage-hud-top {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
height: calc(3.6rem + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
.stage-nav-pills {
|
||||
.stage-title,
|
||||
.stage-badge,
|
||||
.stage-index-num {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stage-hud-actions {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.stage-viewport {
|
||||
padding-top: calc(3.6rem + env(safe-area-inset-top, 0px));
|
||||
padding-left: 1rem;
|
||||
@@ -275,6 +322,10 @@
|
||||
.stage-exit-btn span:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stage-autoplay-btn span:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { stageNavItems } from '../content/nav'
|
||||
import { images } from '../content/site'
|
||||
import { StageViews } from './StageViews'
|
||||
import { StageProgressiveImage } from './StageProgressiveImage'
|
||||
import { stageImages } from './stageAssets'
|
||||
import { useStageImagePreload } from './useStageImagePreload'
|
||||
import { StageRemotePanel } from './remote/StageRemotePanel'
|
||||
import { useStageHost } from './remote/useStageHost'
|
||||
import {
|
||||
clampIntervalSeconds,
|
||||
DEFAULT_INTERVAL_SECONDS,
|
||||
defaultIntervals,
|
||||
type RemoteToHost,
|
||||
} from './remote/protocol'
|
||||
import './StageShell.css'
|
||||
|
||||
const AUTOPLAY_INTERVAL_MS = 6000 // 6s per slide
|
||||
|
||||
export function StageShell() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const touchStartX = useRef<number | null>(null)
|
||||
const [isAutoPlay, setIsAutoPlay] = useState(true)
|
||||
const [remoteOpen, setRemoteOpen] = useState(false)
|
||||
const [intervals, setIntervals] = useState(() => defaultIntervals(stageNavItems.length))
|
||||
|
||||
const currentIndex = stageNavItems.findIndex((item) => item.path === location.pathname)
|
||||
const activeIdx = currentIndex >= 0 ? currentIndex : 0
|
||||
|
||||
useStageImagePreload(activeIdx)
|
||||
|
||||
const goToSlide = useCallback(
|
||||
(idx: number) => {
|
||||
const target = stageNavItems[idx]
|
||||
@@ -36,14 +48,68 @@ export function StageShell() {
|
||||
goToSlide(nextIdx)
|
||||
}, [activeIdx, goToSlide])
|
||||
|
||||
// Auto-play interval timer
|
||||
const handleRemoteCommand = useCallback(
|
||||
(msg: RemoteToHost) => {
|
||||
if (msg.type === 'hello') return
|
||||
if (msg.type === 'autoplay') {
|
||||
setIsAutoPlay(msg.on)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'setInterval') {
|
||||
const seconds = clampIntervalSeconds(msg.seconds)
|
||||
setIntervals((prev) => {
|
||||
if (msg.index < 0 || msg.index >= prev.length) return prev
|
||||
if (prev[msg.index] === seconds) return prev
|
||||
const next = [...prev]
|
||||
next[msg.index] = seconds
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
if (msg.type === 'setAllIntervals') {
|
||||
const seconds = clampIntervalSeconds(msg.seconds)
|
||||
setIntervals((prev) => {
|
||||
if (prev.every((item) => item === seconds)) return prev
|
||||
return prev.map(() => seconds)
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsAutoPlay(false)
|
||||
if (msg.type === 'next') handleNext()
|
||||
if (msg.type === 'prev') handlePrev()
|
||||
if (msg.type === 'goto') goToSlide(msg.index)
|
||||
},
|
||||
[goToSlide, handleNext, handlePrev],
|
||||
)
|
||||
|
||||
const { roomId, status, clientCount, broadcastState } = useStageHost(handleRemoteCommand)
|
||||
|
||||
useEffect(() => {
|
||||
broadcastState({
|
||||
type: 'state',
|
||||
index: activeIdx,
|
||||
total: stageNavItems.length,
|
||||
autoplay: isAutoPlay,
|
||||
label: stageNavItems[activeIdx].label,
|
||||
intervals,
|
||||
})
|
||||
}, [activeIdx, isAutoPlay, intervals, clientCount, broadcastState])
|
||||
|
||||
const currentIntervalSec = intervals[activeIdx] ?? DEFAULT_INTERVAL_SECONDS
|
||||
|
||||
const qrUrl = useMemo(() => {
|
||||
if (!roomId || typeof window === 'undefined') return ''
|
||||
return `${window.location.origin}/?mode=remote&peer=${encodeURIComponent(roomId)}`
|
||||
}, [roomId])
|
||||
|
||||
// Auto-play interval timer (per-slide seconds, default 6)
|
||||
useEffect(() => {
|
||||
if (!isAutoPlay) return
|
||||
const timer = setInterval(() => {
|
||||
handleNext()
|
||||
}, AUTOPLAY_INTERVAL_MS)
|
||||
}, currentIntervalSec * 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [isAutoPlay, handleNext])
|
||||
}, [isAutoPlay, handleNext, currentIntervalSec])
|
||||
|
||||
// Touch Swipe navigation for mobile
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
@@ -64,6 +130,10 @@ export function StageShell() {
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'r' || e.key === 'R') {
|
||||
setRemoteOpen((v) => !v)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleNext()
|
||||
@@ -87,7 +157,7 @@ export function StageShell() {
|
||||
{/* Top HUD */}
|
||||
<header className="stage-hud-top">
|
||||
<Link to="/?mode=stage" className="stage-brand">
|
||||
<img src={images.logo} alt="杜康好客" className="stage-logo" />
|
||||
<StageProgressiveImage pair={stageImages.logo} alt="杜康好客" className="stage-logo-prog" priority />
|
||||
<span className="stage-title">杜康好客</span>
|
||||
<span className="stage-badge">全景展控</span>
|
||||
</Link>
|
||||
@@ -107,6 +177,15 @@ export function StageShell() {
|
||||
|
||||
{/* Index, AutoPlay Toggle & Exit */}
|
||||
<div className="stage-hud-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`stage-remote-toggle ${clientCount > 0 ? 'is-live' : ''}`}
|
||||
onClick={() => setRemoteOpen(true)}
|
||||
title="手机遥控切屏"
|
||||
>
|
||||
遥控{clientCount > 0 ? ` · ${clientCount}` : ''}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`stage-autoplay-btn ${isAutoPlay ? 'is-playing' : ''}`}
|
||||
@@ -147,13 +226,26 @@ export function StageShell() {
|
||||
aria-label={`跳转到 ${item.label}`}
|
||||
>
|
||||
{idx === activeIdx && isAutoPlay ? (
|
||||
<span className="stage-dot-progress" key={location.pathname} />
|
||||
<span
|
||||
className="stage-dot-progress"
|
||||
key={`${location.pathname}-${currentIntervalSec}`}
|
||||
style={{ '--stage-progress-dur': `${currentIntervalSec}s` } as CSSProperties}
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="stage-keyboard-hint">每 6 秒自动切换展控 · 支持键盘方向键与手机划屏</div>
|
||||
<div className="stage-keyboard-hint">自动切换 · 键盘切页 · 手机遥控可调每页时长</div>
|
||||
</footer>
|
||||
|
||||
<StageRemotePanel
|
||||
open={remoteOpen}
|
||||
roomId={roomId}
|
||||
qrUrl={qrUrl}
|
||||
status={status}
|
||||
clientCount={clientCount}
|
||||
onClose={() => setRemoteOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,95 @@
|
||||
/* ===== Progressive Image (thumb → full) ===== */
|
||||
.stage-prog-img {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.stage-prog-img__thumb,
|
||||
.stage-prog-img__full {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.stage-prog-img__thumb {
|
||||
filter: blur(8px);
|
||||
transform: scale(1.05);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stage-prog-img__full {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.stage-prog-img.is-full-loaded .stage-prog-img__thumb {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.stage-prog-img.is-full-loaded .stage-prog-img__full {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stage-home-bottle {
|
||||
width: min(20rem, 36vh);
|
||||
animation: stageFloat 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.stage-home-bottle .stage-prog-img__full,
|
||||
.stage-home-bottle .stage-prog-img__thumb {
|
||||
filter: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.stage-home-bottle:not(.is-full-loaded) .stage-prog-img__thumb {
|
||||
filter: blur(8px);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.stage-home-bottle.is-full-loaded .stage-prog-img__full {
|
||||
filter: drop-shadow(0 15px 30px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.stage-brand-photo {
|
||||
width: 100%;
|
||||
max-height: 14rem;
|
||||
}
|
||||
|
||||
.stage-brand-photo .stage-prog-img__thumb,
|
||||
.stage-brand-photo .stage-prog-img__full {
|
||||
object-fit: cover;
|
||||
border-radius: 0.35rem;
|
||||
border: 1px solid var(--stage-glass-border);
|
||||
max-height: 14rem;
|
||||
}
|
||||
|
||||
.stage-qx-bottle {
|
||||
width: min(16rem, 30vh);
|
||||
animation: stageFloat 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.stage-qx-bottle.is-full-loaded .stage-prog-img__full {
|
||||
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.stage-qr-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stage-qr-img .stage-prog-img__thumb,
|
||||
.stage-qr-img .stage-prog-img__full {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.stage-qr-img .stage-prog-img__thumb {
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
/* ===== Stage Views Presentation CSS ===== */
|
||||
|
||||
:root {
|
||||
@@ -176,6 +268,11 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stage-home-visual .stage-home-bottle {
|
||||
width: min(20rem, 36vh);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.stage-home-visual img {
|
||||
width: min(20rem, 36vh);
|
||||
height: auto;
|
||||
@@ -245,6 +342,10 @@
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.stage-brand-media .stage-brand-photo {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stage-brand-media img {
|
||||
width: 100%;
|
||||
max-height: 14rem;
|
||||
@@ -346,6 +447,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stage-qx-center .stage-qx-bottle {
|
||||
width: min(16rem, 30vh);
|
||||
}
|
||||
|
||||
.stage-qx-center img {
|
||||
width: min(16rem, 30vh);
|
||||
height: auto;
|
||||
@@ -625,6 +730,11 @@
|
||||
box-shadow: 0 0 25px rgba(196, 163, 90, 0.3);
|
||||
}
|
||||
|
||||
.stage-qr-frame .stage-qr-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stage-qr-frame img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
contact,
|
||||
haoke,
|
||||
hero,
|
||||
images,
|
||||
partner,
|
||||
poetry,
|
||||
qingxiang,
|
||||
@@ -13,6 +12,8 @@ import {
|
||||
tech,
|
||||
techDetail,
|
||||
} from '../content/site'
|
||||
import { StageProgressiveImage } from './StageProgressiveImage'
|
||||
import { stageImages } from './stageAssets'
|
||||
import './StageViews.css'
|
||||
|
||||
interface StageViewsProps {
|
||||
@@ -77,7 +78,7 @@ function StageHomeView() {
|
||||
</div>
|
||||
|
||||
<div className="stage-home-visual stage-stagger" style={{ '--i': 4 } as React.CSSProperties}>
|
||||
<img src={images.t30} alt="酒祖杜康 T30" />
|
||||
<StageProgressiveImage pair={stageImages.t30} alt="酒祖杜康 T30" className="stage-home-bottle" priority />
|
||||
<div className="stage-pedestal" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,8 +118,8 @@ function StageBrandView() {
|
||||
</div>
|
||||
|
||||
<div className="stage-brand-media stage-stagger" style={{ '--i': 6 } as React.CSSProperties}>
|
||||
<img src={images.brand.manor} alt="杜康庄园" />
|
||||
<img src={images.brand.relic} alt="造酒遗址" />
|
||||
<StageProgressiveImage pair={stageImages.brand.manor} alt="杜康庄园" className="stage-brand-photo" priority />
|
||||
<StageProgressiveImage pair={stageImages.brand.relic} alt="造酒遗址" className="stage-brand-photo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,7 +205,7 @@ function StageQingxiangView() {
|
||||
</div>
|
||||
|
||||
<div className="stage-qx-center stage-stagger" style={{ '--i': 5 } as React.CSSProperties}>
|
||||
<img src={images.t30} alt="酒祖杜康清香 T30" />
|
||||
<StageProgressiveImage pair={stageImages.t30} alt="酒祖杜康清香 T30" className="stage-qx-bottle" priority />
|
||||
<div className="stage-qx-rings">
|
||||
{qingxiang.styleWords.map((word) => (
|
||||
<div key={word} className="stage-qx-ring">
|
||||
@@ -496,7 +497,7 @@ function StageContactView() {
|
||||
|
||||
<div className="stage-contact-box stage-stagger" style={{ '--i': 4 } as React.CSSProperties}>
|
||||
<div className="stage-qr-frame">
|
||||
<img src={images.qr} alt="微信二维码" />
|
||||
<StageProgressiveImage pair={stageImages.qr} alt="微信二维码" className="stage-qr-img" priority />
|
||||
</div>
|
||||
|
||||
<div className="stage-contact-info">
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
.remote-shell {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: calc(1.25rem + env(safe-area-inset-top, 0px)) 1.25rem calc(1.5rem + env(safe-area-inset-bottom, 0px));
|
||||
background:
|
||||
radial-gradient(circle at 12% 8%, rgba(166, 28, 32, 0.22) 0%, transparent 42%),
|
||||
linear-gradient(180deg, #141117 0%, #0c0a0e 100%);
|
||||
color: #f3ece4;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html.remote-mode,
|
||||
body.remote-mode {
|
||||
background: #0c0a0e;
|
||||
color: #f3ece4;
|
||||
}
|
||||
|
||||
.remote-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.remote-logo {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.remote-kicker {
|
||||
margin: 0 0 0.15rem;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--gold, #c4a35a);
|
||||
}
|
||||
|
||||
.remote-head h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.remote-status {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.88rem;
|
||||
color: rgba(243, 236, 228, 0.62);
|
||||
}
|
||||
|
||||
.remote-status.is-connected {
|
||||
color: #e8d08d;
|
||||
}
|
||||
|
||||
.remote-status.is-error {
|
||||
color: #e8a0a2;
|
||||
}
|
||||
|
||||
.remote-now {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid rgba(196, 163, 90, 0.28);
|
||||
background: rgba(28, 25, 31, 0.7);
|
||||
}
|
||||
|
||||
.remote-now span {
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--gold, #c4a35a);
|
||||
}
|
||||
|
||||
.remote-now strong {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.remote-now-interval {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.88rem;
|
||||
color: rgba(243, 236, 228, 0.72);
|
||||
}
|
||||
|
||||
.remote-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: 2.8rem 1fr 2.8rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.remote-stepper span {
|
||||
text-align: center;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--gold, #c4a35a);
|
||||
}
|
||||
|
||||
.remote-stepper button,
|
||||
.remote-presets button,
|
||||
.remote-module-time button {
|
||||
min-height: 2.6rem;
|
||||
border: 1px solid rgba(196, 163, 90, 0.35);
|
||||
background: rgba(18, 16, 20, 0.85);
|
||||
color: #f3ece4;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.remote-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.remote-presets button {
|
||||
min-height: 2.3rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.remote-presets button.is-active {
|
||||
background: linear-gradient(135deg, #e8d08d 0%, #c4a35a 100%);
|
||||
color: #121014;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.remote-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.remote-nav button,
|
||||
.remote-autoplay,
|
||||
.remote-join button,
|
||||
.remote-module-goto {
|
||||
min-height: 3.1rem;
|
||||
border: 1px solid rgba(196, 163, 90, 0.35);
|
||||
background: rgba(28, 25, 31, 0.85);
|
||||
color: #f3ece4;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.remote-nav button:disabled,
|
||||
.remote-autoplay:disabled,
|
||||
.remote-module-goto:disabled,
|
||||
.remote-stepper button:disabled,
|
||||
.remote-presets button:disabled,
|
||||
.remote-module-time button:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.remote-autoplay {
|
||||
width: 100%;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.remote-autoplay.is-on {
|
||||
color: var(--gold, #c4a35a);
|
||||
border-color: rgba(196, 163, 90, 0.7);
|
||||
}
|
||||
|
||||
.remote-modules-hint {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: rgba(243, 236, 228, 0.5);
|
||||
}
|
||||
|
||||
.remote-modules {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.remote-module-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid rgba(196, 163, 90, 0.28);
|
||||
background: rgba(28, 25, 31, 0.7);
|
||||
}
|
||||
|
||||
.remote-module-goto {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 2.7rem;
|
||||
padding: 0 0.9rem;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.remote-module-goto span {
|
||||
color: var(--gold, #c4a35a);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.remote-module-row.is-active {
|
||||
background: linear-gradient(135deg, #e8d08d 0%, #c4a35a 100%);
|
||||
border-color: transparent;
|
||||
color: #121014;
|
||||
}
|
||||
|
||||
.remote-module-row.is-active .remote-module-goto,
|
||||
.remote-module-row.is-active .remote-module-goto span {
|
||||
color: #121014;
|
||||
}
|
||||
|
||||
.remote-module-row.is-active .remote-module-goto span {
|
||||
color: #7d1216;
|
||||
}
|
||||
|
||||
.remote-module-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding-right: 0.35rem;
|
||||
}
|
||||
|
||||
.remote-module-time em {
|
||||
min-width: 2.2rem;
|
||||
font-style: normal;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.remote-module-time button {
|
||||
width: 2.1rem;
|
||||
min-height: 2.1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.remote-module-row.is-active .remote-module-time button {
|
||||
border-color: rgba(125, 18, 22, 0.28);
|
||||
background: rgba(18, 16, 20, 0.12);
|
||||
color: #121014;
|
||||
}
|
||||
|
||||
.remote-module-row.is-active .remote-module-time em {
|
||||
color: #7d1216;
|
||||
}
|
||||
|
||||
.remote-join {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.remote-join label {
|
||||
font-size: 0.88rem;
|
||||
color: rgba(243, 236, 228, 0.72);
|
||||
}
|
||||
|
||||
.remote-join input {
|
||||
min-height: 3rem;
|
||||
padding: 0 0.9rem;
|
||||
border: 1px solid rgba(196, 163, 90, 0.4);
|
||||
background: rgba(28, 25, 31, 0.8);
|
||||
color: #f3ece4;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.remote-join button {
|
||||
background: var(--brand, #a61c20);
|
||||
border-color: var(--brand, #a61c20);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useMemo, useState, type FormEvent } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { stageNavItems } from '../../content/nav'
|
||||
import { images } from '../../content/site'
|
||||
import {
|
||||
DEFAULT_INTERVAL_SECONDS,
|
||||
MAX_INTERVAL_SECONDS,
|
||||
MIN_INTERVAL_SECONDS,
|
||||
clampIntervalSeconds,
|
||||
defaultIntervals,
|
||||
normalizeRoomId,
|
||||
} from './protocol'
|
||||
import { useStageClient } from './useStageClient'
|
||||
import './RemoteShell.css'
|
||||
|
||||
const PRESET_SECONDS = [3, 6, 10, 15, 30] as const
|
||||
|
||||
export function RemoteShell() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const peerFromUrl = useMemo(
|
||||
() => new URLSearchParams(location.search).get('peer'),
|
||||
[location.search],
|
||||
)
|
||||
const hostId = peerFromUrl ? normalizeRoomId(peerFromUrl) : null
|
||||
const [draft, setDraft] = useState('')
|
||||
const { status, state, send } = useStageClient(hostId)
|
||||
|
||||
const index = state?.index ?? 0
|
||||
const total = state?.total ?? stageNavItems.length
|
||||
const label = state?.label ?? stageNavItems[index]?.label ?? '等待大屏'
|
||||
const autoplay = state?.autoplay ?? false
|
||||
const intervals = state?.intervals ?? defaultIntervals(stageNavItems.length)
|
||||
const allSame = intervals.length > 0 && intervals.every((item) => item === intervals[0])
|
||||
const globalSeconds = allSame
|
||||
? (intervals[0] ?? DEFAULT_INTERVAL_SECONDS)
|
||||
: (intervals[index] ?? DEFAULT_INTERVAL_SECONDS)
|
||||
const connected = status === 'connected'
|
||||
|
||||
const setOneSeconds = (idx: number, seconds: number) => {
|
||||
send({ type: 'setInterval', index: idx, seconds: clampIntervalSeconds(seconds) })
|
||||
}
|
||||
|
||||
const setAllSeconds = (seconds: number) => {
|
||||
send({ type: 'setAllIntervals', seconds: clampIntervalSeconds(seconds) })
|
||||
}
|
||||
|
||||
const join = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const id = normalizeRoomId(draft)
|
||||
if (!id) return
|
||||
navigate(`/?mode=remote&peer=${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="remote-shell">
|
||||
<header className="remote-head">
|
||||
<img src={images.logo} alt="" className="remote-logo" />
|
||||
<div>
|
||||
<p className="remote-kicker">杜康好客 · 展控遥控</p>
|
||||
<h1>手机切屏</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!hostId ? (
|
||||
<form className="remote-join" onSubmit={join}>
|
||||
<label htmlFor="remote-room">输入大屏房间码</label>
|
||||
<input
|
||||
id="remote-room"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="dkhk-xxxxxx"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button type="submit">连接大屏</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p className={`remote-status is-${status}`}>
|
||||
{status === 'connected'
|
||||
? `已连接 ${hostId}`
|
||||
: status === 'connecting'
|
||||
? `正在连接 ${hostId}…`
|
||||
: status === 'error'
|
||||
? '连接失败,请确认大屏仍在展控模式'
|
||||
: '未连接'}
|
||||
</p>
|
||||
|
||||
<section className="remote-now">
|
||||
<span>
|
||||
{String(index + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}
|
||||
</span>
|
||||
<strong>{label}</strong>
|
||||
<p className="remote-now-interval">
|
||||
{allSame ? `统一时长 ${globalSeconds} 秒(全部模块)` : `统一设置(当前各模块时长不同)`}
|
||||
</p>
|
||||
<div className="remote-stepper">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!connected || globalSeconds <= MIN_INTERVAL_SECONDS}
|
||||
onClick={() => setAllSeconds(globalSeconds - 1)}
|
||||
aria-label="全部模块减少一秒"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span>{allSame ? `${globalSeconds}s` : `${globalSeconds}s*`}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!connected || globalSeconds >= MAX_INTERVAL_SECONDS}
|
||||
onClick={() => setAllSeconds(globalSeconds + 1)}
|
||||
aria-label="全部模块增加一秒"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="remote-presets">
|
||||
{PRESET_SECONDS.map((sec) => (
|
||||
<button
|
||||
key={sec}
|
||||
type="button"
|
||||
className={allSame && sec === globalSeconds ? 'is-active' : ''}
|
||||
disabled={!connected}
|
||||
onClick={() => setAllSeconds(sec)}
|
||||
>
|
||||
{sec}s
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="remote-nav">
|
||||
<button type="button" disabled={!connected} onClick={() => send({ type: 'prev' })}>
|
||||
上一页
|
||||
</button>
|
||||
<button type="button" disabled={!connected} onClick={() => send({ type: 'next' })}>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`remote-autoplay ${autoplay ? 'is-on' : ''}`}
|
||||
disabled={!connected}
|
||||
onClick={() => send({ type: 'autoplay', on: !autoplay })}
|
||||
>
|
||||
{autoplay ? '暂停自动演示' : '开启自动演示'}
|
||||
</button>
|
||||
|
||||
<p className="remote-modules-hint">下方右侧加减只改该模块;顶部时长会统一改全部 11 页</p>
|
||||
<nav className="remote-modules" aria-label="展控模块">
|
||||
{stageNavItems.map((item, idx) => {
|
||||
const seconds = intervals[idx] ?? DEFAULT_INTERVAL_SECONDS
|
||||
return (
|
||||
<div key={item.path} className={`remote-module-row ${idx === index ? 'is-active' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="remote-module-goto"
|
||||
disabled={!connected}
|
||||
onClick={() => send({ type: 'goto', index: idx })}
|
||||
>
|
||||
<span>{String(idx + 1).padStart(2, '0')}</span>
|
||||
{item.label}
|
||||
</button>
|
||||
<div className="remote-module-time">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!connected || seconds <= MIN_INTERVAL_SECONDS}
|
||||
onClick={() => setOneSeconds(idx, seconds - 1)}
|
||||
aria-label={`${item.label}减少一秒`}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<em>{seconds}s</em>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!connected || seconds >= MAX_INTERVAL_SECONDS}
|
||||
onClick={() => setOneSeconds(idx, seconds + 1)}
|
||||
aria-label={`${item.label}增加一秒`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
.stage-remote-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: rgba(8, 6, 10, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: calc(5.2rem + env(safe-area-inset-top, 0px)) 2.5rem calc(3rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.stage-remote-panel {
|
||||
width: min(22rem, 92vw);
|
||||
padding: 1.35rem 1.4rem 1.5rem;
|
||||
background: linear-gradient(180deg, #1c191f 0%, #121014 100%);
|
||||
border: 1px solid rgba(196, 163, 90, 0.35);
|
||||
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.45);
|
||||
color: #f3ece4;
|
||||
}
|
||||
|
||||
.stage-remote-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.stage-remote-kicker {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--gold, #c4a35a);
|
||||
}
|
||||
|
||||
.stage-remote-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stage-remote-close {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: rgba(243, 236, 228, 0.8);
|
||||
border: 1px solid rgba(196, 163, 90, 0.3);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stage-remote-qr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 12.5rem;
|
||||
padding: 0.75rem;
|
||||
background: #f7f2ea;
|
||||
}
|
||||
|
||||
.stage-remote-qr.is-pending {
|
||||
background: rgba(247, 242, 234, 0.08);
|
||||
color: rgba(243, 236, 228, 0.55);
|
||||
}
|
||||
|
||||
.stage-remote-code {
|
||||
margin: 1rem 0 0.35rem;
|
||||
text-align: center;
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--gold, #c4a35a);
|
||||
}
|
||||
|
||||
.stage-remote-status {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(243, 236, 228, 0.65);
|
||||
}
|
||||
|
||||
.stage-remote-status.is-live {
|
||||
color: #e8d08d;
|
||||
}
|
||||
|
||||
.stage-remote-status.error {
|
||||
color: #e8a0a2;
|
||||
}
|
||||
|
||||
.stage-remote-hint {
|
||||
margin: 0.85rem 0 0;
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
color: rgba(243, 236, 228, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.stage-remote-overlay {
|
||||
justify-content: center;
|
||||
padding: calc(4.2rem + env(safe-area-inset-top, 0px)) 1rem calc(1.5rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
.stage-remote-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 1.5rem;
|
||||
font-size: 0.78rem;
|
||||
color: rgba(243, 236, 228, 0.85);
|
||||
background: rgba(28, 25, 31, 0.8);
|
||||
border: 1px solid rgba(196, 163, 90, 0.3);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stage-remote-toggle.is-live {
|
||||
color: var(--gold, #c4a35a);
|
||||
border-color: rgba(196, 163, 90, 0.6);
|
||||
background: rgba(196, 163, 90, 0.15);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import type { HostStatus } from './useStageHost'
|
||||
import './StageRemotePanel.css'
|
||||
|
||||
type StageRemotePanelProps = {
|
||||
open: boolean
|
||||
roomId: string
|
||||
qrUrl: string
|
||||
status: HostStatus
|
||||
clientCount: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function StageRemotePanel({
|
||||
open,
|
||||
roomId,
|
||||
qrUrl,
|
||||
status,
|
||||
clientCount,
|
||||
onClose,
|
||||
}: StageRemotePanelProps) {
|
||||
if (!open) return null
|
||||
|
||||
const statusLabel =
|
||||
status === 'ready'
|
||||
? clientCount > 0
|
||||
? `已连接 ${clientCount} 台手机`
|
||||
: '等待手机扫码'
|
||||
: status === 'error'
|
||||
? '信令连接失败'
|
||||
: '正在创建房间…'
|
||||
|
||||
return (
|
||||
<div className="stage-remote-overlay" onClick={onClose}>
|
||||
<aside className="stage-remote-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="stage-remote-head">
|
||||
<div>
|
||||
<p className="stage-remote-kicker">手机遥控</p>
|
||||
<h2>扫码切屏</h2>
|
||||
</div>
|
||||
<button type="button" className="stage-remote-close" onClick={onClose} aria-label="关闭">
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className={`stage-remote-qr ${status !== 'ready' ? 'is-pending' : ''}`}>
|
||||
{status === 'ready' && qrUrl ? (
|
||||
<QRCodeSVG
|
||||
value={qrUrl}
|
||||
size={196}
|
||||
bgColor="#f7f2ea"
|
||||
fgColor="#1a1512"
|
||||
level="M"
|
||||
marginSize={1}
|
||||
/>
|
||||
) : (
|
||||
<span>{status === 'error' ? '无法生成二维码' : '准备中'}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="stage-remote-code">{roomId || '——'}</p>
|
||||
<p className={`stage-remote-status ${status} ${clientCount > 0 ? 'is-live' : ''}`}>
|
||||
{statusLabel}
|
||||
</p>
|
||||
<p className="stage-remote-hint">手机扫码进入遥控页,即可上一页 / 下一页 / 点选模块。</p>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const ROOM_ID_PREFIX = 'dkhk-'
|
||||
export const ROOM_ID_STORAGE_KEY = 'dkhk-stage-peer-id'
|
||||
|
||||
export const DEFAULT_INTERVAL_SECONDS = 6
|
||||
export const MIN_INTERVAL_SECONDS = 3
|
||||
export const MAX_INTERVAL_SECONDS = 60
|
||||
|
||||
export type RemoteToHost =
|
||||
| { type: 'hello' }
|
||||
| { type: 'next' }
|
||||
| { type: 'prev' }
|
||||
| { type: 'goto'; index: number }
|
||||
| { type: 'autoplay'; on: boolean }
|
||||
| { type: 'setInterval'; index: number; seconds: number }
|
||||
| { type: 'setAllIntervals'; seconds: number }
|
||||
|
||||
export type HostToRemote = {
|
||||
type: 'state'
|
||||
index: number
|
||||
total: number
|
||||
autoplay: boolean
|
||||
label: string
|
||||
intervals: number[]
|
||||
}
|
||||
|
||||
const ROOM_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789'
|
||||
|
||||
export function createRoomId() {
|
||||
let id = ''
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
id += ROOM_CHARS[Math.floor(Math.random() * ROOM_CHARS.length)]
|
||||
}
|
||||
return `${ROOM_ID_PREFIX}${id}`
|
||||
}
|
||||
|
||||
export function normalizeRoomId(raw: string) {
|
||||
const trimmed = raw.trim().toLowerCase()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.startsWith(ROOM_ID_PREFIX)) return trimmed
|
||||
return `${ROOM_ID_PREFIX}${trimmed}`
|
||||
}
|
||||
|
||||
export function clampIntervalSeconds(seconds: number) {
|
||||
if (!Number.isFinite(seconds)) return DEFAULT_INTERVAL_SECONDS
|
||||
return Math.min(MAX_INTERVAL_SECONDS, Math.max(MIN_INTERVAL_SECONDS, Math.round(seconds)))
|
||||
}
|
||||
|
||||
export function defaultIntervals(count: number) {
|
||||
return Array.from({ length: count }, () => DEFAULT_INTERVAL_SECONDS)
|
||||
}
|
||||
|
||||
export function isRemoteToHost(value: unknown): value is RemoteToHost {
|
||||
if (!value || typeof value !== 'object' || !('type' in value)) return false
|
||||
const type = (value as { type: unknown }).type
|
||||
if (type === 'hello' || type === 'next' || type === 'prev') return true
|
||||
if (type === 'goto') {
|
||||
return typeof (value as { index?: unknown }).index === 'number'
|
||||
}
|
||||
if (type === 'autoplay') {
|
||||
return typeof (value as { on?: unknown }).on === 'boolean'
|
||||
}
|
||||
if (type === 'setInterval') {
|
||||
const msg = value as { index?: unknown; seconds?: unknown }
|
||||
return typeof msg.index === 'number' && typeof msg.seconds === 'number'
|
||||
}
|
||||
if (type === 'setAllIntervals') {
|
||||
return typeof (value as { seconds?: unknown }).seconds === 'number'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isHostToRemote(value: unknown): value is HostToRemote {
|
||||
if (!value || typeof value !== 'object' || !('type' in value)) return false
|
||||
const msg = value as Record<string, unknown>
|
||||
return (
|
||||
msg.type === 'state' &&
|
||||
typeof msg.index === 'number' &&
|
||||
typeof msg.total === 'number' &&
|
||||
typeof msg.autoplay === 'boolean' &&
|
||||
typeof msg.label === 'string' &&
|
||||
Array.isArray(msg.intervals) &&
|
||||
msg.intervals.every((item) => typeof item === 'number')
|
||||
)
|
||||
}
|
||||
|
||||
export function decodePayload(data: unknown): unknown {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
return JSON.parse(data) as unknown
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export function parseRemoteToHost(data: unknown): RemoteToHost | null {
|
||||
const payload = decodePayload(data)
|
||||
return isRemoteToHost(payload) ? payload : null
|
||||
}
|
||||
|
||||
export function parseHostToRemote(data: unknown): HostToRemote | null {
|
||||
const payload = decodePayload(data)
|
||||
return isHostToRemote(payload) ? payload : null
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Peer, type DataConnection } from 'peerjs'
|
||||
import { parseHostToRemote, type HostToRemote, type RemoteToHost } from './protocol'
|
||||
|
||||
export type ClientStatus = 'idle' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
export function useStageClient(hostId: string | null) {
|
||||
const [status, setStatus] = useState<ClientStatus>('idle')
|
||||
const [state, setState] = useState<HostToRemote | null>(null)
|
||||
const connRef = useRef<DataConnection | null>(null)
|
||||
|
||||
const send = useCallback((msg: RemoteToHost) => {
|
||||
if (connRef.current?.open) {
|
||||
connRef.current.send(JSON.stringify(msg))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
setStatus('idle')
|
||||
setState(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const peer = new Peer({ debug: 0 })
|
||||
setStatus('connecting')
|
||||
setState(null)
|
||||
|
||||
peer.on('open', () => {
|
||||
if (cancelled) return
|
||||
const conn = peer.connect(hostId, { reliable: true })
|
||||
connRef.current = conn
|
||||
|
||||
conn.on('open', () => {
|
||||
if (cancelled) return
|
||||
setStatus('connected')
|
||||
conn.send(JSON.stringify({ type: 'hello' }))
|
||||
})
|
||||
|
||||
conn.on('data', (data) => {
|
||||
const msg = parseHostToRemote(data)
|
||||
if (msg) setState(msg)
|
||||
})
|
||||
|
||||
conn.on('close', () => {
|
||||
if (!cancelled) setStatus('error')
|
||||
})
|
||||
|
||||
conn.on('error', () => {
|
||||
if (!cancelled) setStatus('error')
|
||||
})
|
||||
})
|
||||
|
||||
peer.on('error', () => {
|
||||
if (!cancelled) setStatus('error')
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
connRef.current?.close()
|
||||
connRef.current = null
|
||||
peer.destroy()
|
||||
}
|
||||
}, [hostId])
|
||||
|
||||
return { status, state, send }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Peer, type DataConnection } from 'peerjs'
|
||||
import {
|
||||
ROOM_ID_STORAGE_KEY,
|
||||
createRoomId,
|
||||
parseRemoteToHost,
|
||||
type HostToRemote,
|
||||
type RemoteToHost,
|
||||
} from './protocol'
|
||||
|
||||
export type HostStatus = 'connecting' | 'ready' | 'error'
|
||||
|
||||
function readStoredRoomId() {
|
||||
try {
|
||||
return sessionStorage.getItem(ROOM_ID_STORAGE_KEY) ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredRoomId(id: string) {
|
||||
try {
|
||||
sessionStorage.setItem(ROOM_ID_STORAGE_KEY, id)
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function useStageHost(onCommand: (msg: RemoteToHost) => void) {
|
||||
const [roomId, setRoomId] = useState('')
|
||||
const [status, setStatus] = useState<HostStatus>('connecting')
|
||||
const [clientCount, setClientCount] = useState(0)
|
||||
const connectionsRef = useRef<DataConnection[]>([])
|
||||
const onCommandRef = useRef(onCommand)
|
||||
onCommandRef.current = onCommand
|
||||
|
||||
const refreshClientCount = useCallback(() => {
|
||||
setClientCount(connectionsRef.current.filter((conn) => conn.open).length)
|
||||
}, [])
|
||||
|
||||
const broadcastState = useCallback((state: HostToRemote) => {
|
||||
const payload = JSON.stringify(state)
|
||||
connectionsRef.current.forEach((conn) => {
|
||||
if (conn.open) conn.send(payload)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let peer: Peer | null = null
|
||||
let retrying = false
|
||||
|
||||
const attachConnection = (conn: DataConnection) => {
|
||||
connectionsRef.current.push(conn)
|
||||
|
||||
conn.on('open', () => {
|
||||
if (cancelled) return
|
||||
refreshClientCount()
|
||||
onCommandRef.current({ type: 'hello' })
|
||||
})
|
||||
|
||||
conn.on('data', (data) => {
|
||||
const msg = parseRemoteToHost(data)
|
||||
if (msg) onCommandRef.current(msg)
|
||||
})
|
||||
|
||||
const drop = () => {
|
||||
connectionsRef.current = connectionsRef.current.filter((item) => item !== conn)
|
||||
refreshClientCount()
|
||||
}
|
||||
|
||||
conn.on('close', drop)
|
||||
conn.on('error', drop)
|
||||
}
|
||||
|
||||
const start = (id: string) => {
|
||||
retrying = false
|
||||
peer = new Peer(id, { debug: 0 })
|
||||
|
||||
peer.on('open', (openId) => {
|
||||
if (cancelled) return
|
||||
writeStoredRoomId(openId)
|
||||
setRoomId(openId)
|
||||
setStatus('ready')
|
||||
})
|
||||
|
||||
peer.on('connection', (conn) => {
|
||||
if (cancelled) return
|
||||
attachConnection(conn)
|
||||
})
|
||||
|
||||
peer.on('error', (err) => {
|
||||
if (cancelled) return
|
||||
if (err.type === 'unavailable-id' && !retrying) {
|
||||
retrying = true
|
||||
peer?.destroy()
|
||||
const nextId = createRoomId()
|
||||
writeStoredRoomId(nextId)
|
||||
start(nextId)
|
||||
return
|
||||
}
|
||||
setStatus('error')
|
||||
})
|
||||
}
|
||||
|
||||
const initialId = readStoredRoomId() || createRoomId()
|
||||
writeStoredRoomId(initialId)
|
||||
start(initialId)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
connectionsRef.current.forEach((conn) => conn.close())
|
||||
connectionsRef.current = []
|
||||
peer?.destroy()
|
||||
}
|
||||
}, [refreshClientCount])
|
||||
|
||||
return { roomId, status, clientCount, broadcastState }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export type StageImagePair = { thumb: string; full: string }
|
||||
|
||||
export const stageImages = {
|
||||
logo: {
|
||||
thumb: '/assets/stage/thumb/logo.webp',
|
||||
full: '/assets/stage/full/logo.webp',
|
||||
},
|
||||
t30: {
|
||||
thumb: '/assets/stage/thumb/t30-bottle.webp',
|
||||
full: '/assets/stage/full/t30-bottle.webp',
|
||||
},
|
||||
brand: {
|
||||
manor: {
|
||||
thumb: '/assets/stage/thumb/emb_p6_5.webp',
|
||||
full: '/assets/stage/full/emb_p6_5.webp',
|
||||
},
|
||||
relic: {
|
||||
thumb: '/assets/stage/thumb/emb_p33_1.webp',
|
||||
full: '/assets/stage/full/emb_p33_1.webp',
|
||||
},
|
||||
},
|
||||
qr: {
|
||||
thumb: '/assets/stage/thumb/qr-contact.webp',
|
||||
full: '/assets/stage/full/qr-contact.webp',
|
||||
},
|
||||
} as const
|
||||
|
||||
/** Flat list of all stage image pairs for preload helpers */
|
||||
export const allStageImagePairs: StageImagePair[] = [
|
||||
stageImages.logo,
|
||||
stageImages.t30,
|
||||
stageImages.brand.manor,
|
||||
stageImages.brand.relic,
|
||||
stageImages.qr,
|
||||
]
|
||||
|
||||
/** Images used per slide index (aligned with stageNavItems) */
|
||||
export const stageSlideImages: StageImagePair[][] = [
|
||||
[stageImages.t30], // 首页
|
||||
[stageImages.brand.manor, stageImages.brand.relic], // 品牌传承
|
||||
[], // 诗酒文化
|
||||
[stageImages.t30], // 清香
|
||||
[], // 好客模式
|
||||
[], // 三端协同
|
||||
[], // 科技驱动
|
||||
[], // 酒厂实力
|
||||
[], // 城市合伙人
|
||||
[], // 科技板块
|
||||
[stageImages.qr], // 联系我们
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from 'react'
|
||||
import { stageNavItems } from '../content/nav'
|
||||
import {
|
||||
allStageImagePairs,
|
||||
stageImages,
|
||||
stageSlideImages,
|
||||
type StageImagePair,
|
||||
} from './stageAssets'
|
||||
|
||||
const loaded = new Set<string>()
|
||||
|
||||
function preloadUrl(url: string) {
|
||||
if (loaded.has(url)) return
|
||||
loaded.add(url)
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
}
|
||||
|
||||
function preloadPair(pair: StageImagePair, mode: 'thumb' | 'full' | 'both') {
|
||||
if (mode === 'thumb' || mode === 'both') preloadUrl(pair.thumb)
|
||||
if (mode === 'full' || mode === 'both') preloadUrl(pair.full)
|
||||
}
|
||||
|
||||
function preloadSlideIndex(index: number, mode: 'thumb' | 'full' | 'both') {
|
||||
const pairs = stageSlideImages[index] ?? []
|
||||
for (const pair of pairs) preloadPair(pair, mode)
|
||||
}
|
||||
|
||||
/** Preload all thumbs on mount; preload current + adjacent slide full images on index change. */
|
||||
export function useStageImagePreload(activeIdx: number) {
|
||||
useEffect(() => {
|
||||
for (const pair of allStageImagePairs) preloadPair(pair, 'thumb')
|
||||
preloadPair(stageImages.logo, 'full')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
preloadSlideIndex(activeIdx, 'full')
|
||||
|
||||
const prev = (activeIdx - 1 + stageNavItems.length) % stageNavItems.length
|
||||
const next = (activeIdx + 1) % stageNavItems.length
|
||||
preloadSlideIndex(prev, 'full')
|
||||
preloadSlideIndex(next, 'full')
|
||||
}, [activeIdx])
|
||||
}
|
||||