diff --git a/package-lock.json b/package-lock.json
index dcf1a2f..74d14a0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 27ea37a..008f65c 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
diff --git a/public/assets/stage/full/emb_p33_1.webp b/public/assets/stage/full/emb_p33_1.webp
new file mode 100644
index 0000000..8cc0423
Binary files /dev/null and b/public/assets/stage/full/emb_p33_1.webp differ
diff --git a/public/assets/stage/full/emb_p6_5.webp b/public/assets/stage/full/emb_p6_5.webp
new file mode 100644
index 0000000..b183e5c
Binary files /dev/null and b/public/assets/stage/full/emb_p6_5.webp differ
diff --git a/public/assets/stage/full/logo.webp b/public/assets/stage/full/logo.webp
new file mode 100644
index 0000000..7cd404d
Binary files /dev/null and b/public/assets/stage/full/logo.webp differ
diff --git a/public/assets/stage/full/qr-contact.webp b/public/assets/stage/full/qr-contact.webp
new file mode 100644
index 0000000..be305e5
Binary files /dev/null and b/public/assets/stage/full/qr-contact.webp differ
diff --git a/public/assets/stage/full/t30-bottle.webp b/public/assets/stage/full/t30-bottle.webp
new file mode 100644
index 0000000..5db50b2
Binary files /dev/null and b/public/assets/stage/full/t30-bottle.webp differ
diff --git a/public/assets/stage/thumb/emb_p33_1.webp b/public/assets/stage/thumb/emb_p33_1.webp
new file mode 100644
index 0000000..a33946a
Binary files /dev/null and b/public/assets/stage/thumb/emb_p33_1.webp differ
diff --git a/public/assets/stage/thumb/emb_p6_5.webp b/public/assets/stage/thumb/emb_p6_5.webp
new file mode 100644
index 0000000..ac6f4e5
Binary files /dev/null and b/public/assets/stage/thumb/emb_p6_5.webp differ
diff --git a/public/assets/stage/thumb/logo.webp b/public/assets/stage/thumb/logo.webp
new file mode 100644
index 0000000..8a1e772
Binary files /dev/null and b/public/assets/stage/thumb/logo.webp differ
diff --git a/public/assets/stage/thumb/qr-contact.webp b/public/assets/stage/thumb/qr-contact.webp
new file mode 100644
index 0000000..1b62f1e
Binary files /dev/null and b/public/assets/stage/thumb/qr-contact.webp differ
diff --git a/public/assets/stage/thumb/t30-bottle.webp b/public/assets/stage/thumb/t30-bottle.webp
new file mode 100644
index 0000000..83eb9fe
Binary files /dev/null and b/public/assets/stage/thumb/t30-bottle.webp differ
diff --git a/scripts/optimize-stage-images.mjs b/scripts/optimize-stage-images.mjs
new file mode 100644
index 0000000..edeeda4
--- /dev/null
+++ b/scripts/optimize-stage-images.mjs
@@ -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)
+})
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 3daf9ae..a6c87cc 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -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
+ if (isRemote) {
+ return
}
- // 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 (
- {isDeepPage ? : }
+
)
}
+ if (isStage) {
+ return
+ }
+
// 3. Desktop Normal Mode Branch
return (
diff --git a/src/hooks/useStageMode.tsx b/src/hooks/useStageMode.tsx
index 634633b..8dea59a 100644
--- a/src/hooks/useStageMode.tsx
+++ b/src/hooks/useStageMode.tsx
@@ -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])
+}
diff --git a/src/mobile/MobileDeepSection.tsx b/src/mobile/MobileDeepSection.tsx
new file mode 100644
index 0000000..e197e94
--- /dev/null
+++ b/src/mobile/MobileDeepSection.tsx
@@ -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 (
+
+
+
{page.eyebrow}
+
{page.title}
+
{page.lead}
+
+
+ {page.images[0] ? (
+

+ ) : null}
+
+
+ {page.blocks.map((block, i) => {
+ if (block.type === 'heading') {
+ return (
+
+ {block.text}
+
+ )
+ }
+ if (block.type === 'paragraph') {
+ return (
+
+ {block.text}
+
+ )
+ }
+ if (block.type === 'quote') {
+ return (
+
+ {block.text}
+ {block.source ? —— {block.source}
: null}
+
+ )
+ }
+ return (
+
+ {block.items.map((item) => (
+ - {item}
+ ))}
+
+ )
+ })}
+
+
+ {page.images.slice(1).map((src) => (
+

+ ))}
+
+ {page.closing ?
{page.closing}
: null}
+
+ )
+}
diff --git a/src/mobile/MobileShell.css b/src/mobile/MobileShell.css
index a50e527..4297e42 100644
--- a/src/mobile/MobileShell.css
+++ b/src/mobile/MobileShell.css
@@ -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;
}
diff --git a/src/mobile/MobileShell.tsx b/src/mobile/MobileShell.tsx
index b67827f..4f19999 100644
--- a/src/mobile/MobileShell.tsx
+++ b/src/mobile/MobileShell.tsx
@@ -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 (
-
- {/* Mobile Top Header */}
+
- {/* Drawer Mask & Navigation Panel */}
-
setDrawerOpen(false)}
- />
-
-
- 全站导航目录
-
-
-
- setDrawerOpen(false)}
- >
- 首页
- ›
-
-
-
- {navGroups.map((group) => (
-
-
{group.label}
-
- {group.children.map((child) => (
- `m-drawer-subitem ${isActive ? 'is-active' : ''}`}
- onClick={() => setDrawerOpen(false)}
- >
- {child.label}
-
- ))}
-
-
- ))}
-
-
- {/* Main Content Area */}
-
+
{children || }
diff --git a/src/mobile/MobileViews.css b/src/mobile/MobileViews.css
index b402d15..d9c2c03 100644
--- a/src/mobile/MobileViews.css
+++ b/src/mobile/MobileViews.css
@@ -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;
+}
diff --git a/src/mobile/MobileViews.tsx b/src/mobile/MobileViews.tsx
index 5ea4c72..f8a9541 100644
--- a/src/mobile/MobileViews.tsx
+++ b/src/mobile/MobileViews.tsx
@@ -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 = {
+ '/': '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
- case '/brand':
- return
- case '/poetry':
- return
- case '/qingxiang':
- return
- case '/haoke':
- return
- case '/system':
- return
- case '/tech':
- return
- case '/subsidy':
- return
- case '/partner':
- return
- case '/tech-detail':
- return
- case '/contact':
- return
- default:
- return
- }
+ 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 (
+
+
+
+
+ {cultureDeep.map((page) => (
+
+ ))}
+
+
+ {poetryDeep.map((page) => (
+
+ ))}
+
+
+ {qingxiangDeep.map((page) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
}
-/* 1. Mobile Home View */
function MobileHomeView() {
return (
@@ -62,19 +132,18 @@ function MobileHomeView() {
{hero.subtitle}
)
}
-/* 2. Mobile Brand View */
function MobileBrandView() {
return (
@@ -129,21 +198,10 @@ function MobileBrandView() {
{brand.closing}
-
-
-
深度阅读
- {brand.deepReads.map((dp) => (
-
-
{dp.label} ›
-
{dp.blurb}
-
- ))}
-
)
}
-/* 3. Mobile Poetry View */
function MobilePoetryView() {
return (
@@ -168,21 +226,10 @@ function MobilePoetryView() {
{poetry.closing}
-
-
-
诗酒专题
- {poetry.deepReads.map((dp) => (
-
-
{dp.label} ›
-
{dp.blurb}
-
- ))}
-
)
}
-/* 4. Mobile Qingxiang View */
function MobileQingxiangView() {
return (
@@ -225,21 +272,10 @@ function MobileQingxiangView() {
{qingxiang.mission}
-
-
-
清香宝典专题
- {qingxiang.deepReads.map((dp) => (
-
-
{dp.label} ›
-
{dp.blurb}
-
- ))}
-
)
}
-/* 5. Mobile Haoke View */
function MobileHaokeView() {
return (
@@ -270,7 +306,6 @@ function MobileHaokeView() {
)
}
-/* 6. Mobile System View */
function MobileSystemView() {
return (
@@ -309,7 +344,6 @@ function MobileSystemView() {
)
}
-/* 7. Mobile Tech View */
function MobileTechView() {
return (
@@ -329,17 +363,10 @@ function MobileTechView() {
{tech.claim}
-
-
-
- {tech.cta.label}
-
-
)
}
-/* 8. Mobile Subsidy View */
function MobileSubsidyView() {
return (
@@ -372,7 +399,6 @@ function MobileSubsidyView() {
)
}
-/* 9. Mobile Partner View */
function MobilePartnerView() {
return (
@@ -392,18 +418,17 @@ function MobilePartnerView() {
)
}
-/* 10. Mobile Tech Detail View */
function MobileTechDetailView() {
return (
@@ -427,7 +452,6 @@ function MobileTechDetailView() {
)
}
-/* 11. Mobile Contact View */
function MobileContactView() {
return (
@@ -456,7 +480,7 @@ function MobileContactView() {
textDecoration: 'none',
}}
>
- 📞 {contact.phone}
+ {contact.phone}
{contact.wechatHint}
diff --git a/src/pages/Home.css b/src/pages/Home.css
index 75cc63c..61bffcd 100644
--- a/src/pages/Home.css
+++ b/src/pages/Home.css
@@ -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;
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index cb5d86a..60a69c7 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -44,15 +44,25 @@ export function HomePage() {
从酒祖文脉到好客闭环,循定稿结构逐章走进。
- {moduleEntries.map((item, index) => (
-
-
- {String(index + 1).padStart(2, '0')}
- {item.label}
- {item.desc}
-
-
- ))}
+
+ {[0, 1].map((copy) => (
+
+ {moduleEntries.map((item, index) => (
+
+ {String(index + 1).padStart(2, '0')}
+ {item.label}
+ {item.desc}
+
+ ))}
+
+ ))}
+
diff --git a/src/stage/StageProgressiveImage.tsx b/src/stage/StageProgressiveImage.tsx
new file mode 100644
index 0000000..fa6ef08
--- /dev/null
+++ b/src/stage/StageProgressiveImage.tsx
@@ -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 (
+
+

+

setFullLoaded(true)}
+ />
+
+ )
+}
diff --git a/src/stage/StageShell.css b/src/stage/StageShell.css
index 9153150..c47a2fd 100644
--- a/src/stage/StageShell.css
+++ b/src/stage/StageShell.css
@@ -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 */
diff --git a/src/stage/StageShell.tsx b/src/stage/StageShell.tsx
index 40bf1f4..60dbc11 100644
--- a/src/stage/StageShell.tsx
+++ b/src/stage/StageShell.tsx
@@ -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
(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 */}
-
+
杜康好客
全景展控
@@ -107,6 +177,15 @@ export function StageShell() {
{/* Index, AutoPlay Toggle & Exit */}
+
+
))}
- 每 6 秒自动切换展控 · 支持键盘方向键与手机划屏
+ 自动切换 · 键盘切页 · 手机遥控可调每页时长
+
+ setRemoteOpen(false)}
+ />
)
}
diff --git a/src/stage/StageViews.css b/src/stage/StageViews.css
index afe2a85..bfcfa22 100644
--- a/src/stage/StageViews.css
+++ b/src/stage/StageViews.css
@@ -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%;
diff --git a/src/stage/StageViews.tsx b/src/stage/StageViews.tsx
index d40f682..85feb91 100644
--- a/src/stage/StageViews.tsx
+++ b/src/stage/StageViews.tsx
@@ -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() {
-

+
@@ -117,8 +118,8 @@ function StageBrandView() {
-

-

+
+
@@ -204,7 +205,7 @@ function StageQingxiangView() {
-

+
{qingxiang.styleWords.map((word) => (
@@ -496,7 +497,7 @@ function StageContactView() {
-

+
diff --git a/src/stage/remote/RemoteShell.css b/src/stage/remote/RemoteShell.css
new file mode 100644
index 0000000..d486c45
--- /dev/null
+++ b/src/stage/remote/RemoteShell.css
@@ -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);
+}
diff --git a/src/stage/remote/RemoteShell.tsx b/src/stage/remote/RemoteShell.tsx
new file mode 100644
index 0000000..0a42530
--- /dev/null
+++ b/src/stage/remote/RemoteShell.tsx
@@ -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 (
+
+
+
+
+
+
+ {!hostId ? (
+
+ ) : (
+ <>
+
+ {status === 'connected'
+ ? `已连接 ${hostId}`
+ : status === 'connecting'
+ ? `正在连接 ${hostId}…`
+ : status === 'error'
+ ? '连接失败,请确认大屏仍在展控模式'
+ : '未连接'}
+
+
+
+
+ {String(index + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}
+
+ {label}
+
+ {allSame ? `统一时长 ${globalSeconds} 秒(全部模块)` : `统一设置(当前各模块时长不同)`}
+
+
+
+ {allSame ? `${globalSeconds}s` : `${globalSeconds}s*`}
+
+
+
+ {PRESET_SECONDS.map((sec) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
下方右侧加减只改该模块;顶部时长会统一改全部 11 页
+
+ >
+ )}
+
+ )
+}
diff --git a/src/stage/remote/StageRemotePanel.css b/src/stage/remote/StageRemotePanel.css
new file mode 100644
index 0000000..190cbeb
--- /dev/null
+++ b/src/stage/remote/StageRemotePanel.css
@@ -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);
+}
diff --git a/src/stage/remote/StageRemotePanel.tsx b/src/stage/remote/StageRemotePanel.tsx
new file mode 100644
index 0000000..68d33b9
--- /dev/null
+++ b/src/stage/remote/StageRemotePanel.tsx
@@ -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 (
+
+
+
+ )
+}
diff --git a/src/stage/remote/protocol.ts b/src/stage/remote/protocol.ts
new file mode 100644
index 0000000..6ab7bab
--- /dev/null
+++ b/src/stage/remote/protocol.ts
@@ -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
+ 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
+}
diff --git a/src/stage/remote/useStageClient.ts b/src/stage/remote/useStageClient.ts
new file mode 100644
index 0000000..19b902d
--- /dev/null
+++ b/src/stage/remote/useStageClient.ts
@@ -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('idle')
+ const [state, setState] = useState(null)
+ const connRef = useRef(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 }
+}
diff --git a/src/stage/remote/useStageHost.ts b/src/stage/remote/useStageHost.ts
new file mode 100644
index 0000000..9652768
--- /dev/null
+++ b/src/stage/remote/useStageHost.ts
@@ -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('connecting')
+ const [clientCount, setClientCount] = useState(0)
+ const connectionsRef = useRef([])
+ 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 }
+}
diff --git a/src/stage/stageAssets.ts b/src/stage/stageAssets.ts
new file mode 100644
index 0000000..8f5bd2c
--- /dev/null
+++ b/src/stage/stageAssets.ts
@@ -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], // 联系我们
+]
diff --git a/src/stage/useStageImagePreload.ts b/src/stage/useStageImagePreload.ts
new file mode 100644
index 0000000..8f720d3
--- /dev/null
+++ b/src/stage/useStageImagePreload.ts
@@ -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()
+
+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])
+}