fix:提交修改

This commit is contained in:
ljy
2026-08-21 18:53:12 +08:00
parent fa35f17bfb
commit 265c6d700f
36 changed files with 2483 additions and 411 deletions
+655
View File
@@ -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",
+5 -1
View File
@@ -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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+73
View File
@@ -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)
})
+20 -15
View File
@@ -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' : ''}`}>
+5
View File
@@ -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])
}
+61
View File
@@ -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>
)
}
+32 -177
View File
@@ -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;
}
+33 -77
View File
@@ -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 = ''
const goSection = (id: string) => {
requestAnimationFrame(() => scrollToSection(id))
}
}, [drawerOpen])
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 ? '关闭菜单' : '打开菜单'}
<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)}
>
<span />
<span />
<span />
</button>
</div>
{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>
+80 -20
View File
@@ -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;
}
+108 -84
View File
@@ -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}
+31 -4
View File
@@ -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;
+13 -3
View File
@@ -44,16 +44,26 @@ export function HomePage() {
<p className="lead">稿</p>
</Reveal>
<div className="module-rail">
<div className="module-rail-track">
{[0, 1].map((copy) => (
<div key={copy} className="module-rail-copy" aria-hidden={copy === 1}>
{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` }}>
<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>
</Reveal>
))}
</div>
))}
</div>
</div>
</div>
</section>
</>
+44
View File
@@ -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>
)
}
+57 -6
View File
@@ -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 */
+102 -10
View File
@@ -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>
)
}
+110
View File
@@ -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%;
+7 -6
View File
@@ -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">
+269
View File
@@ -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);
}
+193
View File
@@ -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>
)
}
+122
View File
@@ -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);
}
+69
View File
@@ -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>
)
}
+105
View File
@@ -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
}
+68
View File
@@ -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 }
}
+119
View File
@@ -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 }
}
+50
View File
@@ -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], // 联系我们
]
+44
View File
@@ -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])
}