fix: UI部分完成

This commit is contained in:
2025-05-28 12:56:36 +08:00
parent 7efc3dfd67
commit 72180e9e71
30 changed files with 6940 additions and 111 deletions

View File

@@ -9,17 +9,19 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"tailwindcss": "^4.1.4",
"vue": "^3.5.13",
"@ant-design-vue/pro-layout": "^3.2.5", "@ant-design-vue/pro-layout": "^3.2.5",
"@ant-design/icons-vue": "^7.0.1", "@ant-design/icons-vue": "^7.0.1",
"@element-plus/icons-vue": "^2.3.1",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@vueuse/head": "^2.0.0", "@vueuse/head": "^2.0.0",
"ant-design-vue": "^4.1.1", "ant-design-vue": "^4.1.1",
"axios": "^1.6.2", "axios": "^1.6.2",
"element-plus": "^2.9.11",
"lucide-vue-next": "^0.507.0", "lucide-vue-next": "^0.507.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"swiper": "^11.0.5", "swiper": "^11.0.5",
"tailwindcss": "^4.1.4",
"vue": "^3.5.13",
"vue-router": "^4.2.5" "vue-router": "^4.2.5"
}, },
"devDependencies": { "devDependencies": {

187
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@ant-design/icons-vue': '@ant-design/icons-vue':
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.0.1(vue@3.5.13) version: 7.0.1(vue@3.5.13)
'@element-plus/icons-vue':
specifier: ^2.3.1
version: 2.3.1(vue@3.5.13)
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.4 specifier: ^4.1.4
version: 4.1.4(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0)) version: 4.1.4(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0))
@@ -23,12 +26,12 @@ importers:
ant-design-vue: ant-design-vue:
specifier: ^4.1.1 specifier: ^4.1.1
version: 4.2.6(vue@3.5.13) version: 4.2.6(vue@3.5.13)
aos:
specifier: ^2.3.4
version: 2.3.4
axios: axios:
specifier: ^1.6.2 specifier: ^1.6.2
version: 1.9.0 version: 1.9.0
element-plus:
specifier: ^2.9.11
version: 2.9.11(vue@3.5.13)
lucide-vue-next: lucide-vue-next:
specifier: ^0.507.0 specifier: ^0.507.0
version: 0.507.0(vue@3.5.13) version: 0.507.0(vue@3.5.13)
@@ -107,6 +110,11 @@ packages:
resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@element-plus/icons-vue@2.3.1':
resolution: {integrity: sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==}
peerDependencies:
vue: ^3.2.0
'@emotion/hash@0.9.2': '@emotion/hash@0.9.2':
resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
@@ -263,6 +271,15 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@floating-ui/core@1.7.0':
resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==}
'@floating-ui/dom@1.7.0':
resolution: {integrity: sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==}
'@floating-ui/utils@0.2.9':
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
'@jridgewell/sourcemap-codec@1.5.0': '@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
@@ -295,42 +312,36 @@ packages:
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1': '@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1': '@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1': '@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1': '@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1': '@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1': '@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -388,67 +399,56 @@ packages:
resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==} resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.40.0': '@rollup/rollup-linux-arm-musleabihf@4.40.0':
resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==} resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.40.0': '@rollup/rollup-linux-arm64-gnu@4.40.0':
resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==} resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.40.0': '@rollup/rollup-linux-arm64-musl@4.40.0':
resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==} resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.40.0': '@rollup/rollup-linux-loongarch64-gnu@4.40.0':
resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==} resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0': '@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==} resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.40.0': '@rollup/rollup-linux-riscv64-gnu@4.40.0':
resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==} resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.40.0': '@rollup/rollup-linux-riscv64-musl@4.40.0':
resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==} resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.40.0': '@rollup/rollup-linux-s390x-gnu@4.40.0':
resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==} resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.40.0': '@rollup/rollup-linux-x64-gnu@4.40.0':
resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==} resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.40.0': '@rollup/rollup-linux-x64-musl@4.40.0':
resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==} resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.40.0': '@rollup/rollup-win32-arm64-msvc@4.40.0':
resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==} resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==}
@@ -468,6 +468,9 @@ packages:
'@simonwep/pickr@1.8.2': '@simonwep/pickr@1.8.2':
resolution: {integrity: sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==} resolution: {integrity: sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==}
'@sxzz/popperjs-es@2.11.7':
resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==}
'@tailwindcss/node@4.1.4': '@tailwindcss/node@4.1.4':
resolution: {integrity: sha512-MT5118zaiO6x6hNA04OWInuAiP1YISXql8Z+/Y8iisV5nuhM8VXlyhRuqc2PEviPszcXI66W44bCIk500Oolhw==} resolution: {integrity: sha512-MT5118zaiO6x6hNA04OWInuAiP1YISXql8Z+/Y8iisV5nuhM8VXlyhRuqc2PEviPszcXI66W44bCIk500Oolhw==}
@@ -506,28 +509,24 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.4': '@tailwindcss/oxide-linux-arm64-musl@4.1.4':
resolution: {integrity: sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==} resolution: {integrity: sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.4': '@tailwindcss/oxide-linux-x64-gnu@4.1.4':
resolution: {integrity: sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==} resolution: {integrity: sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.4': '@tailwindcss/oxide-linux-x64-musl@4.1.4':
resolution: {integrity: sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==} resolution: {integrity: sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.4': '@tailwindcss/oxide-wasm32-wasi@4.1.4':
resolution: {integrity: sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==} resolution: {integrity: sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==}
@@ -565,6 +564,15 @@ packages:
'@types/estree@1.0.7': '@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
'@types/lodash-es@4.17.12':
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
'@types/lodash@4.17.17':
resolution: {integrity: sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==}
'@types/web-bluetooth@0.0.16':
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
'@unhead/dom@1.11.20': '@unhead/dom@1.11.20':
resolution: {integrity: sha512-jgfGYdOH+xHJF/j8gudjsYu3oIjFyXhCWcgKaw3vQnT616gSqyqnGQGOItL+BQtQZACKNISwIfx5PuOtztMKLA==} resolution: {integrity: sha512-jgfGYdOH+xHJF/j8gudjsYu3oIjFyXhCWcgKaw3vQnT616gSqyqnGQGOItL+BQtQZACKNISwIfx5PuOtztMKLA==}
@@ -621,20 +629,26 @@ packages:
'@vue/shared@3.5.13': '@vue/shared@3.5.13':
resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==} resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
'@vueuse/core@9.13.0':
resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
'@vueuse/head@2.0.0': '@vueuse/head@2.0.0':
resolution: {integrity: sha512-ykdOxTGs95xjD4WXE4na/umxZea2Itl0GWBILas+O4oqS7eXIods38INvk3XkJKjqMdWPcpCyLX/DioLQxU1KA==} resolution: {integrity: sha512-ykdOxTGs95xjD4WXE4na/umxZea2Itl0GWBILas+O4oqS7eXIods38INvk3XkJKjqMdWPcpCyLX/DioLQxU1KA==}
peerDependencies: peerDependencies:
vue: '>=2.7 || >=3' vue: '>=2.7 || >=3'
'@vueuse/metadata@9.13.0':
resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
'@vueuse/shared@9.13.0':
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
ant-design-vue@4.2.6: ant-design-vue@4.2.6:
resolution: {integrity: sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==} resolution: {integrity: sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==}
engines: {node: '>=12.22.0'} engines: {node: '>=12.22.0'}
peerDependencies: peerDependencies:
vue: '>=3.2.0' vue: '>=3.2.0'
aos@2.3.4:
resolution: {integrity: sha512-zh/ahtR2yME4I51z8IttIt4lC1Nw0ktsFtmeDzID1m9naJnWXhCoARaCgNOGXb5CLy3zm+wqmRAEgMYB5E2HUw==}
array-tree-filter@2.1.0: array-tree-filter@2.1.0:
resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==} resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==}
@@ -659,9 +673,6 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'} engines: {node: '>= 14.16.0'}
classlist-polyfill@1.2.0:
resolution: {integrity: sha512-GzIjNdcEtH4ieA2S8NmrSxv7DfEV5fmixQeyTmqmRmRJPGpRBaSnA2a0VrCjyT8iW8JjEdMbKzDotAJf+ajgaQ==}
combined-stream@1.0.8: combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -701,6 +712,11 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
element-plus@2.9.11:
resolution: {integrity: sha512-x4L/6YC8de4JtuE3vpaEugJdQIeHQaHtIYKyk67IeF6dTIiVax45aX4nWOygnh+xX+0gTvL6xO+9BZhPA3G82w==}
peerDependencies:
vue: ^3.2.0
enhanced-resolve@5.18.1: enhanced-resolve@5.18.1:
resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@@ -730,6 +746,9 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@@ -851,28 +870,24 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.29.2: lightningcss-linux-arm64-musl@1.29.2:
resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==} resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.29.2: lightningcss-linux-x64-gnu@1.29.2:
resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==} resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.29.2: lightningcss-linux-x64-musl@1.29.2:
resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==} resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.29.2: lightningcss-win32-arm64-msvc@1.29.2:
resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==} resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==}
@@ -893,11 +908,12 @@ packages:
lodash-es@4.17.21: lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash.debounce@4.0.8: lodash-unified@1.0.3:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
peerDependencies:
lodash.throttle@4.1.1: '@types/lodash-es': '*'
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} lodash: '*'
lodash-es: '*'
lodash@4.17.21: lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -918,6 +934,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
micromatch@4.0.8: micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
@@ -941,6 +960,9 @@ packages:
node-addon-api@7.1.1: node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
normalize-wheel-es@1.2.0:
resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
omit.js@2.0.2: omit.js@2.0.2:
resolution: {integrity: sha512-hJmu9D+bNB40YpL9jYebQl4lsTW6yEHRTroJzNLqQJYHm7c+NQnJGfZmIWh8S3q3KoaxV1aLhV6B3+0N0/kyJg==} resolution: {integrity: sha512-hJmu9D+bNB40YpL9jYebQl4lsTW6yEHRTroJzNLqQJYHm7c+NQnJGfZmIWh8S3q3KoaxV1aLhV6B3+0N0/kyJg==}
@@ -1150,6 +1172,10 @@ snapshots:
'@ctrl/tinycolor@3.6.1': {} '@ctrl/tinycolor@3.6.1': {}
'@element-plus/icons-vue@2.3.1(vue@3.5.13)':
dependencies:
vue: 3.5.13
'@emotion/hash@0.9.2': {} '@emotion/hash@0.9.2': {}
'@emotion/unitless@0.8.1': {} '@emotion/unitless@0.8.1': {}
@@ -1229,6 +1255,17 @@ snapshots:
'@esbuild/win32-x64@0.25.3': '@esbuild/win32-x64@0.25.3':
optional: true optional: true
'@floating-ui/core@1.7.0':
dependencies:
'@floating-ui/utils': 0.2.9
'@floating-ui/dom@1.7.0':
dependencies:
'@floating-ui/core': 1.7.0
'@floating-ui/utils': 0.2.9
'@floating-ui/utils@0.2.9': {}
'@jridgewell/sourcemap-codec@1.5.0': {} '@jridgewell/sourcemap-codec@1.5.0': {}
'@parcel/watcher-android-arm64@2.5.1': '@parcel/watcher-android-arm64@2.5.1':
@@ -1357,6 +1394,8 @@ snapshots:
core-js: 3.42.0 core-js: 3.42.0
nanopop: 2.4.2 nanopop: 2.4.2
'@sxzz/popperjs-es@2.11.7': {}
'@tailwindcss/node@4.1.4': '@tailwindcss/node@4.1.4':
dependencies: dependencies:
enhanced-resolve: 5.18.1 enhanced-resolve: 5.18.1
@@ -1424,6 +1463,14 @@ snapshots:
'@types/estree@1.0.7': {} '@types/estree@1.0.7': {}
'@types/lodash-es@4.17.12':
dependencies:
'@types/lodash': 4.17.17
'@types/lodash@4.17.17': {}
'@types/web-bluetooth@0.0.16': {}
'@unhead/dom@1.11.20': '@unhead/dom@1.11.20':
dependencies: dependencies:
'@unhead/schema': 1.11.20 '@unhead/schema': 1.11.20
@@ -1513,6 +1560,16 @@ snapshots:
'@vue/shared@3.5.13': {} '@vue/shared@3.5.13': {}
'@vueuse/core@9.13.0(vue@3.5.13)':
dependencies:
'@types/web-bluetooth': 0.0.16
'@vueuse/metadata': 9.13.0
'@vueuse/shared': 9.13.0(vue@3.5.13)
vue-demi: 0.14.10(vue@3.5.13)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/head@2.0.0(vue@3.5.13)': '@vueuse/head@2.0.0(vue@3.5.13)':
dependencies: dependencies:
'@unhead/dom': 1.11.20 '@unhead/dom': 1.11.20
@@ -1521,6 +1578,15 @@ snapshots:
'@unhead/vue': 1.11.20(vue@3.5.13) '@unhead/vue': 1.11.20(vue@3.5.13)
vue: 3.5.13 vue: 3.5.13
'@vueuse/metadata@9.13.0': {}
'@vueuse/shared@9.13.0(vue@3.5.13)':
dependencies:
vue-demi: 0.14.10(vue@3.5.13)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
ant-design-vue@4.2.6(vue@3.5.13): ant-design-vue@4.2.6(vue@3.5.13):
dependencies: dependencies:
'@ant-design/colors': 6.0.0 '@ant-design/colors': 6.0.0
@@ -1547,12 +1613,6 @@ snapshots:
vue-types: 3.0.2(vue@3.5.13) vue-types: 3.0.2(vue@3.5.13)
warning: 4.0.3 warning: 4.0.3
aos@2.3.4:
dependencies:
classlist-polyfill: 1.2.0
lodash.debounce: 4.0.8
lodash.throttle: 4.1.1
array-tree-filter@2.1.0: {} array-tree-filter@2.1.0: {}
async-validator@4.2.5: {} async-validator@4.2.5: {}
@@ -1581,8 +1641,6 @@ snapshots:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
classlist-polyfill@1.2.0: {}
combined-stream@1.0.8: combined-stream@1.0.8:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
@@ -1612,6 +1670,27 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
element-plus@2.9.11(vue@3.5.13):
dependencies:
'@ctrl/tinycolor': 3.6.1
'@element-plus/icons-vue': 2.3.1(vue@3.5.13)
'@floating-ui/dom': 1.7.0
'@popperjs/core': '@sxzz/popperjs-es@2.11.7'
'@types/lodash': 4.17.17
'@types/lodash-es': 4.17.12
'@vueuse/core': 9.13.0(vue@3.5.13)
async-validator: 4.2.5
dayjs: 1.11.13
escape-html: 1.0.3
lodash: 4.17.21
lodash-es: 4.17.21
lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21)
memoize-one: 6.0.0
normalize-wheel-es: 1.2.0
vue: 3.5.13
transitivePeerDependencies:
- '@vue/composition-api'
enhanced-resolve@5.18.1: enhanced-resolve@5.18.1:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@@ -1662,6 +1741,8 @@ snapshots:
'@esbuild/win32-ia32': 0.25.3 '@esbuild/win32-ia32': 0.25.3
'@esbuild/win32-x64': 0.25.3 '@esbuild/win32-x64': 0.25.3
escape-html@1.0.3: {}
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
fdir@6.4.4(picomatch@4.0.2): fdir@6.4.4(picomatch@4.0.2):
@@ -1787,9 +1868,11 @@ snapshots:
lodash-es@4.17.21: {} lodash-es@4.17.21: {}
lodash.debounce@4.0.8: {} lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21):
dependencies:
lodash.throttle@4.1.1: {} '@types/lodash-es': 4.17.12
lodash: 4.17.21
lodash-es: 4.17.21
lodash@4.17.21: {} lodash@4.17.21: {}
@@ -1807,6 +1890,8 @@ snapshots:
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
memoize-one@6.0.0: {}
micromatch@4.0.8: micromatch@4.0.8:
dependencies: dependencies:
braces: 3.0.3 braces: 3.0.3
@@ -1826,6 +1911,8 @@ snapshots:
node-addon-api@7.1.1: node-addon-api@7.1.1:
optional: true optional: true
normalize-wheel-es@1.2.0: {}
omit.js@2.0.2: {} omit.js@2.0.2: {}
packrup@0.1.2: {} packrup@0.1.2: {}

BIN
public/demo-img/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
public/demo-img/2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -1,10 +1,274 @@
<script setup>
</script>
<template> <template>
<router-view /> <div id="app">
<AppHeader />
<router-view />
<AppFooter />
<!-- 全局弹窗组件 -->
<PaymentModal
v-model:visible="globalState.paymentModalVisible"
:amount="globalState.paymentAmount"
@confirm="handlePaymentConfirm"
/>
<ProductDetailModal
v-model:visible="globalState.productDetailVisible"
:product="globalState.selectedProduct"
@add-to-cart="handleAddToCart"
@buy-now="handleBuyNow"
/>
<ShoppingCartDrawer
v-model:visible="globalState.cartVisible"
:items="globalState.cartItems"
@checkout="handleCheckout"
@update-quantity="handleUpdateQuantity"
@remove-item="handleRemoveItem"
/>
<SuccessModal
v-model:visible="globalState.successModalVisible"
:order-number="globalState.orderNumber"
:amount="globalState.paymentAmount"
@view-order="handleViewOrder"
/>
<RechargeModal
v-model:visible="globalState.rechargeModalVisible"
@confirm="handleRechargeConfirm"
/>
<RegisterModal
v-model:visible="globalState.registerModalVisible"
@confirm="handleRegisterConfirm"
/>
</div>
</template> </template>
<style lang='scss' scoped> <script setup>
import { reactive, provide } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import PaymentModal from '@/components/PaymentModal.vue'
import ProductDetailModal from '@/components/ProductDetailModal.vue'
import ShoppingCartDrawer from '@/components/ShoppingCartDrawer.vue'
import SuccessModal from '@/components/SuccessModal.vue'
import RechargeModal from '@/components/RechargeModal.vue'
import RegisterModal from '@/components/RegisterModal.vue'
import AppHeader from "@/layouts/Header.vue";
import AppFooter from "@/layouts/Footer.vue";
</style> const router = useRouter();
// 全局状态
const globalState = reactive({
currentUser: JSON.parse(localStorage.getItem('currentUser') || 'null'),
cartItems: JSON.parse(localStorage.getItem('cart') || '[]'),
favorites: JSON.parse(localStorage.getItem('favorites') || '[]'),
// 弹窗状态
paymentModalVisible: false,
productDetailVisible: false,
cartVisible: false,
successModalVisible: false,
rechargeModalVisible: false,
registerModalVisible: false,
// 弹窗数据
selectedProduct: null,
paymentAmount: 0,
orderNumber: '',
// 全局方法
showPaymentModal: (amount) => {
globalState.paymentAmount = amount
globalState.paymentModalVisible = true
},
showProductDetail: (product) => {
globalState.selectedProduct = product
globalState.productDetailVisible = true
},
showCart: () => {
globalState.cartVisible = true
},
showRechargeModal: () => {
globalState.rechargeModalVisible = true
},
showRegisterModal: () => {
globalState.registerModalVisible = true
},
updateUser: (user) => {
globalState.currentUser = user
localStorage.setItem('currentUser', JSON.stringify(user))
},
logout: () => {
globalState.currentUser = null
localStorage.removeItem('currentUser')
localStorage.removeItem('userToken')
ElMessage.success('已退出登录')
router.push('/')
}
})
// 购物车相关方法
const handleAddToCart = (item) => {
const existingItem = globalState.cartItems.find(cartItem =>
cartItem.productId === item.productId &&
cartItem.color === item.color &&
cartItem.size === item.size
)
if (existingItem) {
existingItem.quantity += item.quantity
} else {
globalState.cartItems.push(item)
}
localStorage.setItem('cart', JSON.stringify(globalState.cartItems))
ElMessage.success('已加入购物车')
globalState.cartVisible = true
}
const handleBuyNow = (amount) => {
if (!globalState.currentUser) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
globalState.showPaymentModal(amount)
}
const handleCheckout = () => {
if (!globalState.currentUser) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
const total = globalState.cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
globalState.showPaymentModal(total)
}
const handlePaymentConfirm = (paymentData) => {
setTimeout(() => {
globalState.orderNumber = 'TB' + Date.now()
// 创建订单
const order = {
id: globalState.orderNumber,
orderNumber: globalState.orderNumber,
items: [...globalState.cartItems],
totalAmount: globalState.paymentAmount,
paymentMethod: paymentData.method,
status: '已支付',
createTime: new Date().toISOString(),
userId: globalState.currentUser?.id
}
let orders = JSON.parse(localStorage.getItem('orders') || '[]')
orders.unshift(order)
localStorage.setItem('orders', JSON.stringify(orders))
// 清空购物车
globalState.cartItems = []
localStorage.setItem('cart', JSON.stringify(globalState.cartItems))
globalState.paymentModalVisible = false
globalState.successModalVisible = true
ElMessage.success('支付成功!')
// 新标签页前往 /result/success
window.open('/result/success', '_blank')
}, 1000)
}
const handleUpdateQuantity = (index, quantity) => {
if (quantity <= 0) {
globalState.cartItems.splice(index, 1)
} else {
globalState.cartItems[index].quantity = quantity
}
localStorage.setItem('cart', JSON.stringify(globalState.cartItems))
}
const handleRemoveItem = (index) => {
globalState.cartItems.splice(index, 1)
localStorage.setItem('cart', JSON.stringify(globalState.cartItems))
}
const handleViewOrder = () => {
globalState.successModalVisible = false
router.push('/orders')
}
const handleRechargeConfirm = (amount) => {
if (globalState.currentUser) {
globalState.currentUser.balance = (parseFloat(globalState.currentUser.balance) + amount).toFixed(2)
globalState.updateUser(globalState.currentUser)
ElMessage.success('充值成功!')
}
globalState.rechargeModalVisible = false
}
const handleRegisterConfirm = (userData) => {
const newUser = {
id: Date.now(),
username: userData.username,
email: userData.email,
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100',
balance: 100,
joinDate: new Date().toISOString(),
level: '新用户'
}
globalState.updateUser(newUser)
globalState.registerModalVisible = false
ElMessage.success('注册成功!')
}
// 提供全局状态
provide('globalState', globalState)
</script>
<style>
/* Element Plus 主题定制 */
:root {
--el-color-primary: #ff2222;
--el-color-primary-light-3: #ff5555;
--el-color-primary-light-5: #ff7777;
--el-color-primary-light-7: #ff9999;
--el-color-primary-light-8: #ffaaaa;
--el-color-primary-light-9: #ffcccc;
--el-color-primary-dark-2: #cc1111;
}
/* 自定义样式 */
.taobao-gradient {
background: linear-gradient(90deg, #ff4444, #ff2222);
}
.card-hover:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,34 @@
<template>
<div class="banner-carousel">
<el-carousel
:interval="3000"
height="400px"
indicator-position="outside"
arrow="hover"
>
<el-carousel-item v-for="(banner, index) in banners" :key="index">
<img
:src="banner"
:alt="`轮播图${index + 1}`"
class="w-full h-full object-cover rounded-lg"
>
</el-carousel-item>
</el-carousel>
</div>
</template>
<script setup>
defineProps({
banners: {
type: Array,
default: () => []
}
})
</script>
<style scoped>
.banner-carousel :deep(.el-carousel__container) {
border-radius: 8px;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<ul class="space-y-2 category-box">
<li
v-for="category in categories"
:key="category.id"
class="category-item relative"
>
<router-link
:to="`/products?category=${category.id}`"
class="block py-2 px-3 hover:text-red-600 hover:bg-red-50 rounded transition"
>
{{ category.name }}
</router-link>
<!-- 子菜单 -->
<div class="submenu absolute left-full top-0 ml-2 min-w-[300px] bg-white border border-gray-200 rounded-lg shadow-lg p-4 z-10 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all">
<div
v-for="(sub, subIndex) in category.subcategories"
:key="subIndex"
class="sub-category mb-4 last:mb-0"
>
<div class="sub-category-title font-semibold text-gray-800 mb-2 pb-1 border-b border-gray-100">
{{ sub.name }}
</div>
<div class="sub-category-items flex flex-wrap gap-2">
<router-link
v-for="(subItem, subItemIndex) in sub.subItems"
:key="subItemIndex"
:to="`/products?category=${subItem.id}`"
class="sub-category-item text-xs text-gray-600 px-2 py-1 rounded hover:bg-red-50 hover:text-red-600 transition"
>
{{ subItem.name }}
</router-link>
</div>
</div>
</div>
</li>
</ul>
</template>
<script setup>
defineProps({
categories: {
type: Array,
default: () => []
}
})
</script>
<style scoped>
.category-item:hover .submenu {
opacity: 1;
visibility: visible;
}
.submenu {
transition: opacity 0.2s, visibility 0.2s;
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<section class="bg-white rounded-lg p-6 shadow-sm">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-red-600 flex items-center">
<el-icon class="mr-2"><Timer /></el-icon>
限时抢购
</h3>
<div class="flex items-center text-sm">
<span class="mr-2">剩余时间</span>
<div class="flex space-x-1">
<span class="countdown-item">{{ countdown.hours }}</span>
<span>:</span>
<span class="countdown-item">{{ countdown.minutes }}</span>
<span>:</span>
<span class="countdown-item">{{ countdown.seconds }}</span>
</div>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
@click="$emit('product-click', product)"
class="flash-sale-card"
/>
</div>
</section>
</template>
<script setup>
import { Timer } from '@element-plus/icons-vue'
import ProductCard from './ProductCard.vue'
defineProps({
products: {
type: Array,
default: () => []
},
countdown: {
type: Object,
default: () => ({ hours: '00', minutes: '00', seconds: '00' })
}
})
defineEmits(['product-click'])
</script>
<style lang="scss" scoped>
.countdown-item {
@apply px-2 py-1 bg-gray-100 rounded text-sm font-mono;
}
.flash-sale-card {
@apply transform transition-transform hover:scale-105;
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<el-dialog
v-model="dialogVisible"
title="确认支付"
width="500px"
:before-close="handleClose"
>
<div class="payment-content">
<!-- 订单信息 -->
<div class="order-info mb-6 p-4 bg-gray-50 rounded-lg">
<h4 class="font-semibold mb-2">订单信息</h4>
<div class="flex justify-between">
<span>订单金额</span>
<span class="text-red-600 font-bold text-lg">¥{{ amount }}</span>
</div>
</div>
<!-- 支付方式 -->
<div class="payment-methods mb-6">
<h4 class="font-semibold mb-3">选择支付方式</h4>
<el-radio-group v-model="selectedMethod" class="w-full">
<div class="space-y-3">
<el-radio
v-for="method in paymentMethods"
:key="method.id"
:label="method.id"
class="payment-method-option"
>
<div class="flex items-center justify-between w-full">
<div class="flex items-center">
<span class="text-2xl mr-3">{{ method.icon }}</span>
<div>
<div class="font-medium">{{ method.name }}</div>
<div class="text-sm text-gray-500">{{ method.desc }}</div>
</div>
</div>
<div v-if="method.discount" class="text-red-600 text-sm">
{{ method.discount }}
</div>
</div>
</el-radio>
</div>
</el-radio-group>
</div>
<!-- 优惠券 -->
<div class="coupon-section mb-6">
<div class="flex items-center justify-between">
<span>优惠券</span>
<el-button text type="primary" @click="showCoupons = !showCoupons">
{{ showCoupons ? '收起' : '选择优惠券' }}
</el-button>
</div>
<div v-if="showCoupons" class="mt-3 space-y-2">
<div
v-for="coupon in availableCoupons"
:key="coupon.id"
class="coupon-item p-3 border rounded cursor-pointer"
:class="{ 'border-red-500 bg-red-50': selectedCoupon?.id === coupon.id }"
@click="selectedCoupon = coupon"
>
<div class="flex justify-between items-center">
<div>
<div class="font-medium">{{ coupon.name }}</div>
<div class="text-sm text-gray-500">{{ coupon.desc }}</div>
</div>
<div class="text-red-600 font-bold">-¥{{ coupon.amount }}</div>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-between">
<div class="text-lg">
实付金额<span class="text-red-600 font-bold">¥{{ finalAmount }}</span>
</div>
<div class="space-x-3">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
:loading="loading"
:disabled="!selectedMethod"
@click="handleConfirm"
>
{{ loading ? '支付中...' : `确认支付 ¥${finalAmount}` }}
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
amount: {
type: [Number, String],
default: 0
}
})
const emit = defineEmits(['update:visible', 'confirm'])
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
const loading = ref(false)
const selectedMethod = ref('')
const selectedCoupon = ref(null)
const showCoupons = ref(false)
const paymentMethods = [
{
id: 'alipay',
name: '支付宝',
desc: '推荐使用支付宝安全快捷支付',
icon: '💰',
discount: '立减2元'
},
{
id: 'wechat',
name: '微信支付',
desc: '使用微信余额或银行卡支付',
icon: '💚'
},
{
id: 'unionpay',
name: '银联支付',
desc: '支持各大银行卡支付',
icon: '💳'
},
{
id: 'balance',
name: '余额支付',
desc: '使用账户余额支付',
icon: '💎'
}
]
const availableCoupons = [
{
id: 1,
name: '新用户专享',
desc: '满100减10',
amount: 10,
minAmount: 100
},
{
id: 2,
name: '限时优惠',
desc: '满200减30',
amount: 30,
minAmount: 200
}
]
const finalAmount = computed(() => {
let amount = parseFloat(props.amount) || 0
// 应用优惠券
if (selectedCoupon.value && amount >= selectedCoupon.value.minAmount) {
amount -= selectedCoupon.value.amount
}
// 应用支付方式优惠
if (selectedMethod.value === 'alipay') {
amount = Math.max(0, amount - 2)
}
return amount.toFixed(2)
})
const handleConfirm = async () => {
if (!selectedMethod.value) {
ElMessage.warning('请选择支付方式')
return
}
loading.value = true
// 模拟支付处理
await new Promise(resolve => setTimeout(resolve, 2000))
emit('confirm', {
method: selectedMethod.value,
amount: finalAmount.value,
coupon: selectedCoupon.value
})
loading.value = false
handleClose()
}
const handleClose = () => {
dialogVisible.value = false
selectedMethod.value = ''
selectedCoupon.value = null
showCoupons.value = false
}
</script>
<style lang="scss" scoped>
.payment-method-option {
@apply w-full p-4 border border-gray-200 rounded-lg;
}
.payment-method-option:hover {
@apply border-red-300 bg-red-50;
}
.coupon-item:hover {
@apply border-red-300;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<div class="product-card bg-white rounded-lg overflow-hidden shadow-sm hover:shadow-lg transition-all cursor-pointer card-hover relative group">
<!-- 收藏按钮 -->
<button
@click.stop="toggleFavorite"
class="favorite-btn absolute top-2 right-2 w-8 h-8 bg-white/90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
:class="{ 'text-red-500': isFavorited }"
>
<el-icon><Star /></el-icon>
</button>
<!-- 商品图片 -->
<div class="h-48 overflow-hidden">
<img
:src="product.cover"
:alt="product.title"
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
>
</div>
<!-- 商品信息 -->
<div class="p-4">
<h3 class="font-medium mb-2 line-clamp-2 text-sm">{{ product.title }}</h3>
<div class="flex justify-between items-center mb-2">
<span class="text-red-600 font-bold text-lg">¥{{ product.price }}</span>
<span class="text-xs text-gray-500">已售{{ product.sold }}</span>
</div>
<div class="flex items-center justify-between text-xs text-gray-500">
<span>{{ product.brand || '其他品牌' }}</span>
<div class="flex items-center">
<el-rate
v-model="rating"
disabled
size="small"
:max="5"
class="rating-small"
/>
<span class="ml-1">{{ rating.toFixed(1) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, inject } from 'vue'
import { Star } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
product: {
type: Object,
required: true
}
})
const globalState = inject('globalState')
const rating = computed(() => Math.random() * 1 + 4)
const isFavorited = computed(() => {
return globalState.favorites.some(fav => fav.id === props.product.id)
})
const toggleFavorite = () => {
if (!globalState?.currentUser) {
ElMessage.warning('请先登录')
return
}
const index = globalState.favorites.findIndex(fav => fav.id === props.product.id)
if (index > -1) {
globalState.favorites.splice(index, 1)
ElMessage.success('已取消收藏')
} else {
globalState.favorites.push({
id: props.product.id,
title: props.product.title,
price: props.product.price,
cover: props.product.cover,
addTime: new Date().toISOString()
})
ElMessage.success('已加入收藏')
}
localStorage.setItem('favorites', JSON.stringify(globalState.favorites))
}
</script>
<style scoped>
.rating-small :deep(.el-rate__item) {
font-size: 12px;
}
.favorite-btn:hover {
background: white;
transform: scale(1.1);
}
</style>

View File

@@ -0,0 +1,721 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="product?.title || '商品详情'"
width="80%"
:max-width="1200"
top="5vh"
:before-close="handleClose"
destroy-on-close
class="product-detail-dialog"
>
<div v-if="product" class="product-detail-content">
<div class="flex flex-col lg:flex-row gap-6">
<!-- 图片轮播 - 完全复刻放大镜功能 -->
<div class="lg:w-1/2">
<div class="image-container relative">
<img
:src="product?.images[currentImageIndex]"
alt="商品详情"
class="w-full h-auto main-image"
ref="mainImageRef"
@mousemove="handleMouseMove"
@mouseenter="showMagnifierFunc"
@mouseleave="hideMagnifierFunc"
>
<!-- 优化后的放大镜 -->
<div
class="zoom-magnifier"
:class="{ show: showMagnifier }"
:style="{
left: magnifierLeft + 'px',
top: magnifierTop + 'px'
}"
></div>
<!-- 缩略图导航 -->
<div class="flex mt-4 space-x-2 overflow-x-auto">
<img
v-for="(image, index) in product?.images"
:key="index"
:src="image"
alt="缩略图"
class="w-16 h-16 object-cover cursor-pointer border-2 rounded thumbnail-image"
:class="[
{ 'border-red-500': currentImageIndex === index },
{ 'border-gray-200': currentImageIndex !== index }
]"
@click="currentImageIndex = index"
>
</div>
<!-- 放大区域 - 独立显示 -->
<div
class="zoom-overlay"
:class="{ show: showMagnifier }"
:style="{
backgroundImage: showMagnifier ? 'url(' + product?.images[currentImageIndex] + ')' : 'none',
backgroundSize: zoomLevel + '%',
backgroundPosition: backgroundPosition
}"
></div>
</div>
</div>
<!-- 商品信息 -->
<div class="lg:w-1/2">
<div class="flex justify-between items-start mb-4">
<div class="flex-1">
<h2 class="text-xl font-bold mb-2 line-height-relaxed">{{ product?.title }}</h2>
<div class="text-red-600 text-2xl font-bold mb-4">¥{{ currentPrice }}</div>
</div>
<el-button
@click="toggleFavorite"
:type="isFavorited ? 'danger' : 'default'"
:icon="isFavorited ? 'Star' : 'StarFilled'"
circle
class="favorite-btn-el"
/>
</div>
<!-- 优惠信息 -->
<el-card class="mb-4" shadow="never" body-style="padding: 16px;">
<template #header>
<span class="font-semibold">优惠活动</span>
</template>
<div class="space-y-2">
<el-tag type="danger" size="small" class="mr-2">满减</el-tag>
<span class="text-sm">¥99¥10</span>
<br>
<el-tag type="warning" size="small" class="mr-2">赠品</el-tag>
<span class="text-sm">购买即送精美礼品</span>
</div>
</el-card>
<!-- 发货地 -->
<div class="mb-4 flex items-center text-gray-600">
<el-icon class="mr-2"><LocationFilled /></el-icon>
<span>浙江杭州发货</span>
</div>
<!-- 规格选择 -->
<div class="product-specs mb-8">
<!-- 颜色选择 -->
<div class="spec-group mb-6">
<div class="spec-label mb-3">
<span class="text-sm font-medium text-gray-700">颜色分类</span>
<span v-if="selectedSpecs.color" class="ml-2 text-sm text-gray-500">
已选{{ selectedSpecs.color }}
</span>
</div>
<div class="spec-options flex flex-wrap gap-3">
<div
v-for="color in colors"
:key="color.name"
class="spec-option color-option relative cursor-pointer"
:class="{ 'selected': selectedSpecs.color === color.name }"
@click="selectedSpecs.color = color.name"
>
<img
:src="color.image"
:alt="color.name"
class="w-16 h-16 object-cover rounded border-2"
:class="{
'border-red-500': selectedSpecs.color === color.name,
'border-gray-200': selectedSpecs.color !== color.name
}"
>
<span class="block text-xs text-center mt-1">{{ color.name }}</span>
<div
v-if="selectedSpecs.color === color.name"
class="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center"
>
<el-icon class="text-white text-xs">
<Check />
</el-icon>
</div>
</div>
</div>
</div>
<!-- 尺寸选择 -->
<div class="spec-group mb-6">
<div class="spec-label mb-3">
<span class="text-sm font-medium text-gray-700">尺码</span>
<span v-if="selectedSpecs.size" class="ml-2 text-sm text-gray-500">
已选{{ selectedSpecs.size }}
</span>
<el-button text type="primary" size="small" class="ml-2">尺码表</el-button>
</div>
<div class="spec-options flex flex-wrap gap-3">
<button
v-for="size in sizes"
:key="size.name"
class="size-option px-4 py-2 border rounded text-sm font-medium transition-all cursor-pointer"
:class="{
'border-red-500 bg-red-50 text-red-600': selectedSpecs.size === size.name,
'border-gray-300 hover:border-red-300': selectedSpecs.size !== size.name,
'opacity-50 cursor-not-allowed': !size.available
}"
:disabled="!size.available"
@click="selectedSpecs.size = size.name"
>
{{ size.name }}
<span v-if="!size.available" class="block text-xs text-gray-400">缺货</span>
</button>
</div>
</div>
<!-- 数量选择 -->
<div class="spec-group mb-6">
<div class="spec-label mb-3">
<span class="text-sm font-medium text-gray-700">数量</span>
<span class="ml-2 text-sm text-gray-500">库存{{ stock }}</span>
</div>
<div class="quantity-selector flex items-center space-x-4">
<el-input-number
v-model="quantity"
:min="1"
:max="Math.min(stock, 99)"
size="large"
class="quantity-input"
/>
<span class="text-sm text-gray-500"></span>
</div>
</div>
</div>
<!-- <div class="mb-6">-->
<!-- <div class="mb-4">-->
<!-- <div class="mb-2">-->
<!-- <span class="text-sm font-medium text-gray-700">颜色分类</span>-->
<!-- <span v-if="selectedColor !== -1" class="ml-2 text-sm text-gray-500">-->
<!-- 已选{{ colors[selectedColor] }}-->
<!-- </span>-->
<!-- </div>-->
<!-- <el-radio-group v-model="selectedColor" class="color-group">-->
<!-- <el-radio-button-->
<!-- v-for="(color, index) in colors"-->
<!-- :key="index"-->
<!-- :label="index"-->
<!-- class="color-radio"-->
<!-- >-->
<!-- {{ color }}-->
<!-- </el-radio-button>-->
<!-- </el-radio-group>-->
<!-- </div>-->
<!-- <div class="mb-4">-->
<!-- <div class="mb-2">-->
<!-- <span class="text-sm font-medium text-gray-700">尺码</span>-->
<!-- <span v-if="selectedSize !== -1" class="ml-2 text-sm text-gray-500">-->
<!-- 已选{{ sizes[selectedSize] }}-->
<!-- </span>-->
<!-- <el-button text type="primary" size="small" class="ml-2">尺码表</el-button>-->
<!-- </div>-->
<!-- <el-radio-group v-model="selectedSize" class="size-group">-->
<!-- <el-radio-button-->
<!-- v-for="(size, index) in sizes"-->
<!-- :key="index"-->
<!-- :label="index"-->
<!-- class="size-radio"-->
<!-- >-->
<!-- {{ size }}-->
<!-- </el-radio-button>-->
<!-- </el-radio-group>-->
<!-- </div>-->
<!-- <div class="mb-4">-->
<!-- <div class="mb-2">-->
<!-- <span class="text-sm font-medium text-gray-700">数量</span>-->
<!-- <span class="ml-2 text-sm text-gray-500">库存99件</span>-->
<!-- </div>-->
<!-- <el-input-number-->
<!-- v-model="quantity"-->
<!-- :min="1"-->
<!-- :max="99"-->
<!-- size="default"-->
<!-- class="quantity-input"-->
<!-- />-->
<!-- </div>-->
<!-- </div>-->
<div class="space-y-3">
<div class="flex space-x-3">
<el-button
type="danger"
size="large"
class="flex-1"
:disabled="!isSpecsSelected"
@click="buyNow"
>
立即购买
</el-button>
<el-button
size="large"
class="flex-1 cart-btn"
:disabled="!isSpecsSelected"
@click="addToCart"
>
加入购物车
</el-button>
</div>
<!-- 前往详情页面按钮 -->
<el-button
type="warning"
size="large"
class="w-full"
@click="goToDetailPage"
>
<el-icon class="mr-2"><View /></el-icon>
查看完整详情页面
</el-button>
</div>
</div>
</div>
<!-- 商品详情标签页 -->
<!-- <div class="mt-6">-->
<!-- <el-tabs v-model="activeTab" class="product-detail-tabs">-->
<!-- <el-tab-pane-->
<!-- v-for="(tab, index) in detailTabs"-->
<!-- :key="index"-->
<!-- :label="tab"-->
<!-- :name="index.toString()"-->
<!-- >-->
<!-- <div v-if="index === 0" class="detail-content">-->
<!-- <h3 class="text-lg font-bold mb-4">商品描述</h3>-->
<!-- <p class="mb-4 text-gray-700">{{ product?.desc }}</p>-->
<!-- <h3 class="text-lg font-bold mb-4">产品特点</h3>-->
<!-- <ul class="list-disc pl-5 space-y-2 text-gray-700">-->
<!-- <li>高品质面料舒适透气</li>-->
<!-- <li>经典设计百搭款式</li>-->
<!-- <li>适合各种场合穿着</li>-->
<!-- <li>专业工艺精致细节</li>-->
<!-- </ul>-->
<!-- </div>-->
<!-- <div v-if="index === 1">-->
<!-- <el-table :data="productSpecs" style="width: 100%">-->
<!-- <el-table-column prop="name" label="规格" width="150" />-->
<!-- <el-table-column prop="value" label="参数" />-->
<!-- </el-table>-->
<!-- </div>-->
<!-- <div v-if="index === 2" class="reviews-content">-->
<!-- <div class="mb-6">-->
<!-- <el-rate-->
<!-- v-model="averageRating"-->
<!-- disabled-->
<!-- show-score-->
<!-- text-color="#ff9900"-->
<!-- score-template="{value} 分"-->
<!-- class="mb-2"-->
<!-- />-->
<!-- <div class="text-sm text-gray-500">基于 {{ reviews.length }} 条评价</div>-->
<!-- </div>-->
<!-- <div class="space-y-4">-->
<!-- <el-card-->
<!-- v-for="review in reviews"-->
<!-- :key="review.id"-->
<!-- shadow="never"-->
<!-- class="review-card"-->
<!-- >-->
<!-- <div class="flex items-start space-x-3">-->
<!-- <el-avatar :size="40">{{ review.user.charAt(0) }}</el-avatar>-->
<!-- <div class="flex-1">-->
<!-- <div class="flex items-center space-x-4 mb-2">-->
<!-- <span class="font-medium">{{ review.user }}</span>-->
<!-- <el-rate v-model="review.rating" disabled size="small" />-->
<!-- </div>-->
<!-- <p class="text-gray-700 mb-2">{{ review.comment }}</p>-->
<!-- <div class="text-xs text-gray-500">2023-08-15</div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </el-card>-->
<!-- </div>-->
<!-- </div>-->
<!-- </el-tab-pane>-->
<!-- </el-tabs>-->
<!-- </div>-->
</div>
</el-dialog>
</template>
<script setup>
import {ref, computed, inject, onMounted, watch, reactive} from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { LocationFilled, View, Check } from '@element-plus/icons-vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
product: {
type: Object,
default: null
}
})
const emit = defineEmits(['update:visible', 'add-to-cart', 'buy-now'])
const router = useRouter()
const globalState = inject('globalState')
// 响应式数据
const currentImageIndex = ref(0)
const selectedColor = ref(-1)
const selectedSize = ref(-1)
const quantity = ref(1)
const activeTab = ref('0')
const showMagnifier = ref(false)
const magnifierLeft = ref(0)
const magnifierTop = ref(0)
const backgroundPosition = ref('0% 0%')
const zoomLevel = ref(250)
const mainImageRef = ref(null)
const averageRating = ref(4.5)
// 静态数据
const detailTabs = ['商品详情', '规格参数', '用户评价1234']
const reviews = [
{ id: 1, user: '用户123', rating: 5, comment: '这款衣服质量很好,穿着很舒服,推荐购买!' },
{ id: 2, user: '用户456', rating: 4, comment: '包装完好,发货很快,值得信赖的店铺!' }
]
// 计算属性
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
const currentPrice = computed(() => {
if (!props.product) return '0.00'
const basePrice = parseFloat(props.product.price)
const colorPrice = selectedColor.value >= 0 ? 5 : 0
const sizePrice = selectedSize.value >= 0 ? 10 : 0
return (basePrice + colorPrice + sizePrice).toFixed(2)
})
const isFavorited = computed(() => {
if (!globalState.currentUser || !props.product) return false
return globalState.favorites.some(fav => fav.id === props.product.id)
})
const isSpecsSelected = computed(() => {
return selectedSpecs.color && selectedSpecs.size
})
// 更新productSpecs为表格数据格式
const productSpecs = [
{ name: '品牌', value: '时尚品牌' },
{ name: '材质', value: '棉质' },
{ name: '适用季节', value: '夏季' },
{ name: '尺码', value: 'S-XXL' }
]
// 方法
const handleClose = () => {
emit('update:visible', false)
resetState()
}
const resetState = () => {
currentImageIndex.value = 0
selectedColor.value = -1
selectedSize.value = -1
quantity.value = 1
activeTab.value = '0'
showMagnifier.value = false
}
const selectColor = (index) => {
selectedColor.value = index
}
const selectSize = (index) => {
selectedSize.value = index
}
const toggleFavorite = () => {
if (!globalState.currentUser) {
ElMessage.warning('请先登录')
return
}
const index = globalState.favorites.findIndex(fav => fav.id === props.product.id)
if (index > -1) {
globalState.favorites.splice(index, 1)
ElMessage.success('已取消收藏')
} else {
globalState.favorites.push({
id: props.product.id,
title: props.product.title,
price: props.product.price,
cover: props.product.cover,
addTime: new Date().toISOString()
})
ElMessage.success('已加入收藏')
}
localStorage.setItem('favorites', JSON.stringify(globalState.favorites))
}
const addToCart = () => {
if (!props.product) return
// const cartItem = {
// productId: product.value.id,
// title: product.value.title,
// price: parseFloat(currentPrice.value),
// cover: product.value.cover,
// color: selectedSpecs.color,
// size: selectedSpecs.size,
// quantity: quantity.value
// }
const cartItem = {
productId: props.product.id,
title: props.product.title,
price: parseFloat(currentPrice.value),
cover: props.product.cover,
color: selectedSpecs.color || '默认颜色',
size: selectedSpecs.size || '默认尺码',
quantity: quantity.value
}
emit('add-to-cart', cartItem)
ElMessage.success('已加入购物车')
}
const buyNow = () => {
if (!globalState.currentUser) {
ElMessage.warning('请先登录')
return
}
const totalAmount = (parseFloat(currentPrice.value) * quantity.value).toFixed(2)
emit('buy-now', totalAmount)
handleClose()
}
const goToDetailPage = () => {
if (props.product) {
// 新标签页
window.open(`/product/${props.product.id}`, '_blank')
// router.push(`/product/${props.product.id}`)
// handleClose()
}
}
// 放大镜功能
const showMagnifierFunc = () => {
showMagnifier.value = true
}
const hideMagnifierFunc = () => {
showMagnifier.value = false
}
const handleMouseMove = (e) => {
if (!showMagnifier.value || !mainImageRef.value) return
const img = mainImageRef.value
const rect = img.getBoundingClientRect()
// 获取鼠标在图片内的相对位置
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// 放大镜尺寸
const magnifierSize = 120
// 计算放大镜位置,确保不超出图片边界
magnifierLeft.value = Math.max(0, Math.min(x - magnifierSize/2, rect.width - magnifierSize))
magnifierTop.value = Math.max(0, Math.min(y - magnifierSize/2, rect.height - magnifierSize))
// 计算背景图片的位置(放大效果)
const xPercent = (x / rect.width) * 100
const yPercent = (y / rect.height) * 100
backgroundPosition.value = `${xPercent}% ${yPercent}%`
}
const selectedSpecs = reactive({
color: '',
size: ''
})
const colorPrice = selectedSpecs.color ? 5 : 0
const sizePrice = selectedSpecs.size ? 10 : 0
// 商品规格数据
const colors = [
{ name: '经典黑', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=black' },
{ name: '优雅白', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=white' },
{ name: '时尚红', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=red' },
{ name: '深海蓝', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=blue' }
]
const sizes = [
{ name: 'S', available: true },
{ name: 'M', available: true },
{ name: 'L', available: true },
{ name: 'XL', available: true },
{ name: 'XXL', available: false }
]
// 监听产品变化,重置状态
watch(() => props.product, () => {
if (props.product) {
resetState()
}
})
</script>
<style scoped>
.product-detail-dialog {
--el-dialog-border-radius: 12px;
}
.product-detail-dialog .el-dialog__header {
padding: 20px 24px 0;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 0;
}
.product-detail-dialog .el-dialog__body {
padding: 24px;
max-height: 70vh;
overflow-y: auto;
}
.color-group .el-radio-button__inner,
.size-group .el-radio-button__inner {
border-color: #dcdfe6;
color: #606266;
}
.color-group .el-radio-button__inner:hover,
.size-group .el-radio-button__inner:hover {
border-color: #ff2222;
color: #ff2222;
}
.color-group .el-radio-button.is-active .el-radio-button__inner,
.size-group .el-radio-button.is-active .el-radio-button__inner {
background-color: #ff2222;
border-color: #ff2222;
color: white;
}
.cart-btn {
border-color: #ff2222;
color: #ff2222;
}
.cart-btn:hover {
background-color: #ff2222;
color: white;
}
.favorite-btn-el.is-circle {
width: 40px;
height: 40px;
}
.quantity-input {
width: 120px;
}
.review-card {
border: 1px solid #f0f0f0;
}
.product-detail-tabs .el-tabs__nav-wrap {
padding: 0 0 16px 0;
}
/* 保持原有的放大镜样式 */
.zoom-magnifier {
position: absolute;
width: 120px;
height: 120px;
background: rgba(255, 255, 255, 0.2);
border: 3px solid #fff;
box-shadow: 0 0 15px rgba(0,0,0,0.3), inset 0 0 15px rgba(0,0,0,0.1);
pointer-events: none;
cursor: crosshair;
z-index: 5;
border-radius: 50%;
opacity: 0;
transform: scale(0.8);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.zoom-magnifier.show {
opacity: 1;
transform: scale(1);
}
.zoom-overlay {
position: fixed;
width: 400px;
height: 400px;
background: white;
border: 3px solid #ddd;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
pointer-events: none;
background-repeat: no-repeat;
z-index: 3000;
border-radius: 8px;
opacity: 0;
transform: scale(0.9) translateY(10px);
transition: opacity 0.3s ease, transform 0.3s ease;
right: 50px;
top: 50px;
}
.zoom-overlay.show {
opacity: 1;
transform: scale(1) translateY(0);
}
.image-container {
position: relative;
display: inline-block;
overflow: hidden;
border-radius: 8px;
}
.main-image {
cursor: crosshair;
transition: transform 0.3s ease;
}
.main-image:hover {
transform: scale(1.02);
}
.thumbnail-image {
transition: all 0.2s ease;
}
.thumbnail-image:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
@media (max-width: 1024px) {
.zoom-overlay {
display: none;
}
.product-detail-dialog {
width: 95% !important;
margin: 5vh auto !important;
}
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<section class="bg-white rounded-lg p-6 shadow-sm">
<div class="section-header flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-gray-800">{{ title }}</h2>
<router-link
to="/products"
class="text-gray-600 text-sm px-4 py-2 border border-gray-300 rounded hover:border-red-500 hover:text-red-500 transition"
>
查看更多
</router-link>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
@click="$emit('product-click', product)"
/>
</div>
</section>
</template>
<script setup>
import ProductCard from './ProductCard.vue'
defineProps({
title: {
type: String,
required: true
},
products: {
type: Array,
default: () => []
}
})
defineEmits(['product-click'])
</script>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
</script>
<template>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,135 @@
<template>
<el-dialog
v-model="dialogVisible"
title="用户注册"
width="400px"
:before-close="handleClose"
>
<el-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
label-width="80px"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="registerForm.username"
placeholder="请输入用户名"
/>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="registerForm.email"
type="email"
placeholder="请输入邮箱"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
placeholder="请确认密码"
show-password
/>
</el-form-item>
</el-form>
<template #footer>
<div class="flex space-x-3">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleRegister">
注册
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:visible', 'confirm'])
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
const registerFormRef = ref()
const registerForm = reactive({
username: '',
email: '',
password: '',
confirmPassword: ''
})
const validateConfirmPassword = (rule, value, callback) => {
if (value !== registerForm.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
}
const registerRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 20, message: '用户名长度在2-20个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
]
}
const handleRegister = async () => {
if (!registerFormRef.value) return
try {
await registerFormRef.value.validate()
emit('confirm', {
username: registerForm.username,
email: registerForm.email,
password: registerForm.password
})
handleClose()
} catch (error) {
console.error('表单验证失败:', error)
}
}
const handleClose = () => {
dialogVisible.value = false
registerFormRef.value?.resetFields()
}
</script>

View File

@@ -0,0 +1,202 @@
<template>
<el-drawer
v-model="drawerVisible"
title="购物车"
direction="rtl"
size="400px"
>
<div class="cart-content h-full flex flex-col">
<!-- 购物车列表 -->
<div class="cart-items flex-1 overflow-y-auto">
<div v-if="items.length === 0" class="empty-cart text-center py-8">
<div class="text-6xl mb-4">🛒</div>
<p class="text-gray-500">购物车是空的</p>
<el-button type="primary" @click="drawerVisible = false" class="mt-4">
去购物
</el-button>
</div>
<div v-else class="space-y-4">
<div
v-for="(item, index) in items"
:key="`${item.productId}-${item.color}-${item.size}`"
class="cart-item p-4 border border-gray-200 rounded-lg"
>
<div class="flex space-x-3">
<!-- 商品图片 -->
<img
:src="item.cover"
:alt="item.title"
class="w-16 h-16 object-cover rounded"
>
<!-- 商品信息 -->
<div class="flex-1 min-w-0">
<h4 class="font-medium text-sm line-clamp-2 mb-1">{{ item.title }}</h4>
<div class="text-xs text-gray-500 mb-2">
{{ item.color }} / {{ item.size }}
</div>
<div class="flex items-center justify-between">
<span class="text-red-600 font-bold">¥{{ item.price }}</span>
<!-- 数量控制 -->
<div class="flex items-center space-x-2">
<el-button
size="small"
@click="updateQuantity(index, item.quantity - 1)"
:disabled="item.quantity <= 1"
>
-
</el-button>
<span class="w-8 text-center">{{ item.quantity }}</span>
<el-button
size="small"
@click="updateQuantity(index, item.quantity + 1)"
>
+
</el-button>
</div>
</div>
</div>
<!-- 删除按钮 -->
<el-button
text
type="danger"
@click="removeItem(index)"
class="self-start"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
<!-- 底部结算 -->
<div v-if="items.length > 0" class="cart-footer border-t pt-4 mt-4">
<!-- 优惠信息 -->
<div class="discount-info mb-4 p-3 bg-yellow-50 rounded-lg">
<div class="flex items-center justify-between text-sm">
<span>满减优惠</span>
<span class="text-orange-600">-¥{{ discountAmount }}</span>
</div>
<div class="text-xs text-gray-500 mt-1">
{{ discountThreshold }}元减{{ discountAmount }}
</div>
</div>
<!-- 总计 -->
<div class="total-section mb-4">
<div class="flex justify-between items-center mb-2">
<span>商品总计</span>
<span>¥{{ subtotal }}</span>
</div>
<div class="flex justify-between items-center mb-2">
<span>运费</span>
<span class="text-green-600">{{ shippingFee === 0 ? '免运费' : `¥${shippingFee}` }}</span>
</div>
<div class="flex justify-between items-center mb-2">
<span>优惠</span>
<span class="text-orange-600">-¥{{ discountAmount }}</span>
</div>
<div class="flex justify-between items-center text-lg font-bold border-t pt-2">
<span>总计</span>
<span class="text-red-600">¥{{ finalTotal }}</span>
</div>
</div>
<!-- 结算按钮 -->
<el-button
type="primary"
size="large"
class="w-full"
@click="handleCheckout"
>
去结算 ({{ totalQuantity }})
</el-button>
<!-- 继续购物 -->
<el-button
size="large"
class="w-full mt-2"
@click="drawerVisible = false"
>
继续购物
</el-button>
</div>
</div>
</el-drawer>
</template>
<script setup>
import { computed } from 'vue'
import { Delete } from '@element-plus/icons-vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
items: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:visible', 'checkout', 'update-quantity', 'remove-item'])
const drawerVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
// 计算总数量
const totalQuantity = computed(() => {
return props.items.reduce((total, item) => total + item.quantity, 0)
})
// 计算小计
const subtotal = computed(() => {
return props.items.reduce((total, item) => total + item.price * item.quantity, 0).toFixed(2)
})
// 运费计算
const shippingFee = computed(() => {
return parseFloat(subtotal.value) >= 99 ? 0 : 10
})
// 优惠计算
const discountThreshold = 200
const discountAmount = computed(() => {
return parseFloat(subtotal.value) >= discountThreshold ? 20 : 0
})
// 最终总计
const finalTotal = computed(() => {
const total = parseFloat(subtotal.value) + shippingFee.value - discountAmount.value
return Math.max(0, total).toFixed(2)
})
const updateQuantity = (index, quantity) => {
emit('update-quantity', index, quantity)
}
const removeItem = (index) => {
emit('remove-item', index)
}
const handleCheckout = () => {
emit('checkout')
}
</script>
<style lang="scss" scoped>
.cart-item:hover {
@apply border-red-300;
}
.empty-cart {
@apply text-gray-400;
}
</style>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
</script>
<template>
</template>
<style scoped>
</style>

56
src/layouts/Footer.vue Normal file
View File

@@ -0,0 +1,56 @@
<template>
<footer class="bg-gray-800 text-white py-8 mt-8">
<div class="container mx-auto px-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<h3 class="font-bold mb-3">购物指南</h3>
<ul class="space-y-2 text-sm text-gray-400">
<li><a href="#" class="hover:text-white transition">注册登录</a></li>
<li><a href="#" class="hover:text-white transition">购物流程</a></li>
<li><a href="#" class="hover:text-white transition">支付方式</a></li>
<li><a href="#" class="hover:text-white transition">发票说明</a></li>
</ul>
</div>
<div>
<h3 class="font-bold mb-3">配送服务</h3>
<ul class="space-y-2 text-sm text-gray-400">
<li><a href="#" class="hover:text-white transition">配送政策</a></li>
<li><a href="#" class="hover:text-white transition">配送范围</a></li>
<li><a href="#" class="hover:text-white transition">物流查询</a></li>
<li><a href="#" class="hover:text-white transition">签收须知</a></li>
</ul>
</div>
<div>
<h3 class="font-bold mb-3">售后服务</h3>
<ul class="space-y-2 text-sm text-gray-400">
<li><a href="#" class="hover:text-white transition">退换政策</a></li>
<li><a href="#" class="hover:text-white transition">售后流程</a></li>
<li><a href="#" class="hover:text-white transition">服务保障</a></li>
<li><a href="#" class="hover:text-white transition">价格保护</a></li>
</ul>
</div>
<div>
<h3 class="font-bold mb-3">关于我们</h3>
<ul class="space-y-2 text-sm text-gray-400">
<li><a href="#" class="hover:text-white transition">公司简介</a></li>
<li><a href="#" class="hover:text-white transition">联系我们</a></li>
<li><a href="#" class="hover:text-white transition">加入我们</a></li>
<li><a href="#" class="hover:text-white transition">媒体报道</a></li>
</ul>
</div>
</div>
<div class="mt-8 pt-6 border-t border-gray-700 text-center text-sm text-gray-400">
<p>© 2023 淘宝商城 版权所有</p>
<p class="mt-2">京ICP备12345678号-1 | 京公网安备12345678901234号</p>
</div>
</div>
</footer>
</template>
<script setup>
// 页脚组件不需要额外的逻辑
</script>

View File

@@ -1,32 +1,260 @@
<script setup lang="ts">
</script>
<template> <template>
<header class="bg-gradient-to-r from-blue-500 to-indigo-600 text-white py-4 px-6 flex items-center justify-between"> <header class="bg-white shadow-sm sticky top-0 z-50">
<div class="flex items-center space-x-4"> <div class="container mx-auto px-4">
<!-- Logo --> <div class="flex items-center justify-between h-16">
<img src="https://via.placeholder.com/40" alt="Logo" class="h-8 w-8 rounded-full"> <!-- Logo -->
<span class="font-bold">My App</span> <div class="flex items-center">
<router-link to="/" class="flex items-center">
<svg class="w-8 h-8 text-red-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"></path>
</svg>
<span class="ml-2 text-xl font-bold">奶酪商城</span>
</router-link>
</div>
<!-- 搜索框 -->
<div class="hidden md:flex flex-1 max-w-2xl mx-4">
<el-input
v-model="searchKeyword"
placeholder="搜索商品"
class="search-input"
@keyup.enter="performSearch"
>
</el-input>
<button class="bg-red-600 text-white px-4 rounded-r-full hover:bg-red-700 transition" @click="performSearch">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</button>
</div>
<!-- 右侧菜单 -->
<div class="flex items-center space-x-4">
<!-- 购物车 -->
<el-badge :value="cartCount" :hidden="cartCount === 0">
<el-button
text
@click="globalState.showCart()"
class="cart-btn"
>
<el-icon size="20"><ShoppingCart /></el-icon>
</el-button>
</el-badge>
<!-- 用户信息 -->
<div v-if="globalState?.currentUser" class="hidden md:flex items-center">
<el-dropdown @command="handleUserCommand">
<div class="flex items-center cursor-pointer hover:text-red-600">
<el-avatar
:src="globalState?.currentUser.avatar"
:size="32"
class="mr-2"
/>
<span>{{ globalState?.currentUser.username }}</span>
<el-icon class="ml-1"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item command="orders">我的订单</el-dropdown-item>
<el-dropdown-item command="favorites">我的收藏</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- 未登录状态 -->
<div v-else class="hidden md:block">
<router-link
to="/login"
class="text-red-600 font-medium hover:text-red-700"
>
你好请登录
</router-link>
</div>
<!-- 移动端菜单 -->
<el-button
text
class="md:hidden"
@click="showMobileMenu = true"
>
<el-icon size="20"><Menu /></el-icon>
</el-button>
</div>
</div>
</div> </div>
<!-- Menu --> <!-- 导航菜单 -->
<nav class="hidden md:flex space-x-4"> <nav class="bg-gray-800 text-white">
<a href="#" class="hover:text-gray-300">首页</a> <div class="container mx-auto px-4">
<a href="#" class="hover:text-gray-300">About</a> <div class="flex overflow-x-auto space-x-6 py-2">
<a href="#" class="hover:text-gray-300">Services</a> <router-link
<a href="#" class="hover:text-gray-300">Contact</a> v-for="item in mainNav"
:key="item.name"
:to="item.link"
class="py-2 px-3 hover:bg-red-600 transition whitespace-nowrap"
:class="{ 'bg-red-600': $route.path === item.link }"
>
{{ item.name }}
</router-link>
</div>
</div>
</nav> </nav>
<!-- User Profile --> <!-- 移动端菜单抽屉 -->
<div class="flex items-center space-x-4"> <el-drawer
<button class="text-sm bg-gray-800 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded"> v-model="showMobileMenu"
Personal Center title="菜单"
</button> direction="rtl"
<img src="https://via.placeholder.com/40" alt="User Avatar" class="h-8 w-8 rounded-full cursor-pointer"> size="280px"
</div> >
<div class="mobile-menu">
<!-- 用户信息 -->
<div v-if="globalState?.currentUser" class="user-info mb-6 p-4 bg-gray-50 rounded-lg">
<div class="flex items-center mb-3">
<el-avatar :src="globalState?.currentUser.avatar" :size="40" class="mr-3" />
<div>
<div class="font-medium">{{ globalState?.currentUser.username }}</div>
<div class="text-sm text-gray-500">{{ globalState?.currentUser.level }}</div>
</div>
</div>
<div class="text-sm text-gray-600">
余额¥{{ globalState?.currentUser.balance }}
</div>
</div>
<!-- 菜单项 -->
<div class="menu-items space-y-2">
<router-link
v-for="item in mobileMenuItems"
:key="item.name"
:to="item.link"
class="block p-3 rounded-lg hover:bg-gray-100 transition"
@click="showMobileMenu = false"
>
<el-icon class="mr-2"><component :is="item.icon" /></el-icon>
{{ item.name }}
</router-link>
</div>
<!-- 登录/退出 -->
<div class="mt-6 pt-6 border-t">
<el-button
v-if="!globalState?.currentUser"
type="primary"
class="w-full"
@click="$router.push('/login'); showMobileMenu = false"
>
登录
</el-button>
<el-button
v-else
type="danger"
plain
class="w-full"
@click="handleLogout"
>
退出登录
</el-button>
</div>
</div>
</el-drawer>
</header> </header>
</template> </template>
<style lang="scss" scoped> <script setup>
import { ref, computed, inject } from 'vue'
import { useRouter } from 'vue-router'
import {
Search,
ShoppingCart,
ArrowDown,
Menu,
House,
User,
List,
Star,
Wallet
} from '@element-plus/icons-vue'
</style> const router = useRouter()
const globalState = inject('globalState')
const searchKeyword = ref('')
const showMobileMenu = ref(false)
// const cartCount = computed(() => {
// return globalState.cartItems.reduce((total, item) => total + item.quantity, 0)
// })
const cartCount = 1;
const mainNav = [
{ name: '首页', link: '/' },
{ name: '天猫超市', link: '/products?category=supermarket' },
{ name: '电器城', link: '/products?category=electronics' },
{ name: '美妆', link: '/products?category=beauty' },
{ name: '女装', link: '/products?category=women' },
{ name: '男装', link: '/products?category=men' },
{ name: '母婴', link: '/products?category=baby' },
{ name: '运动户外', link: '/products?category=sports' },
{ name: '数码家电', link: '/products?category=digital' }
]
const mobileMenuItems = [
{ name: '首页', link: '/', icon: House },
{ name: '个人中心', link: '/profile', icon: User },
{ name: '我的订单', link: '/orders', icon: List },
{ name: '我的收藏', link: '/favorites', icon: Star },
{ name: '我的钱包', link: '/wallet', icon: Wallet }
]
const performSearch = () => {
if (searchKeyword.value.trim()) {
router.push(`/products?search=${encodeURIComponent(searchKeyword.value)}`)
searchKeyword.value = ''
}
}
const handleUserCommand = (command) => {
switch (command) {
case 'profile':
router.push('/profile')
break
case 'orders':
router.push('/orders')
break
case 'favorites':
router.push('/favorites')
break
case 'logout':
handleLogout()
break
}
}
const handleLogout = () => {
globalState.logout()
showMobileMenu.value = false
}
</script>
<style scoped>
.search-input :deep(.el-input-group__append) {
padding: 0;
}
.search-btn {
border-radius: 0;
border: none;
}
.cart-btn {
position: relative;
}
.mobile-menu {
padding: 0 16px;
}
</style>

View File

@@ -5,6 +5,8 @@ import './style.css'
import App from './App.vue' import App from './App.vue'
// 注册路由 // 注册路由
import router from './router' import router from './router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
const app = createApp(App) const app = createApp(App)
@@ -13,4 +15,5 @@ const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
app.use(Antd) app.use(Antd)
app.use(ElementPlus)
app.mount('#app') app.mount('#app')

View File

@@ -1,15 +1,52 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from "vue-router"
import { useUserStore } from '@/stores/user' import HomePage from "@/views/index/index.vue"
import MainLayout from '@/layouts/Header.vue' import LoginPage from "@/views/login/LoginPage.vue"
import ProductListPage from "@/views/product/ProductListPage.vue"
import OrderListPage from "@/views/order/OrderListPage.vue"
import ProfilePage from "@/views/profile/ProfilePage.vue"
// 在路由配置中添加个人中心路由
const routes = [ const routes = [
{ {
path: '/', path: "/",
component: MainLayout, name: "Home",
children: [ component: HomePage,
{ path: '', name: 'Home', component: () => import('@/views/index/index.vue') }, meta: { title: "奶酪商城 - 首页" },
] },
{
path: "/login",
name: "Login",
component: LoginPage,
meta: { title: "用户登录 - 奶酪商城" },
},
{
path: "/products",
name: "ProductList",
component: ProductListPage,
meta: { title: "商品列表 - 奶酪商城" },
},
{
path: "/orders",
name: "OrderList",
component: OrderListPage,
meta: { title: "我的订单 - 奶酪商城", requiresAuth: true },
},
{
path: "/profile",
name: "Profile",
component: ProfilePage,
meta: { title: "个人中心 - 奶酪商城", requiresAuth: true },
},
{
path: "/product/:id",
name: "ProductDetail",
component: () => import("@/views/product/ProductDetailPage.vue"),
meta: { title: "商品详情 - 奶酪商城" },
},
{
path: "/result/success",
name: "ResultSuccess",
component: () => import("@/views/result/Success.vue"),
meta: { title: "成功页面 - 奶酪商城" },
}, },
] ]
@@ -17,22 +54,31 @@ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes, routes,
scrollBehavior(to, from, savedPosition) { scrollBehavior(to, from, savedPosition) {
return savedPosition || { top: 0 } if (savedPosition) {
} return savedPosition
} else {
return { top: 0 }
}
},
}) })
router.beforeEach((to, from) => { // 路由守卫
const userStore = useUserStore() router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !userStore.user) { // 设置页面标题
return { name: 'Login', query: { redirect: to.fullPath } } if (to.meta.title) {
document.title = to.meta.title
} }
// 检查是否需要登录
if (to.meta.requiresAuth) {
const currentUser = JSON.parse(localStorage.getItem("currentUser") || "null")
if (!currentUser) {
next("/login")
return
}
}
next()
}) })
export default router export default router
router.beforeEach((to, from) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.user) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
})

View File

@@ -1,11 +1,275 @@
<script setup lang="ts">
</script>
<template> <template>
你好 <div class="home-page bg-gray-100">
<main class="container mx-auto px-4 pt-8 grid grid-cols-12 gap-4">
<!-- 侧边分类导航 -->
<div class="col-span-2 bg-white rounded-lg p-4 hidden md:block">
<h3 class="font-bold text-lg mb-4">所有分类</h3>
<CategoryMenu :categories="categories" />
</div>
<div class="col-span-10">
<!-- 轮播图 -->
<BannerCarousel :banners="banners" class="mb-6" />
</div>
<!-- 侧边分类导航 -->
<div class="col-span-2 bg-white rounded-lg p-4 hidden md:block">
<h3 class="font-bold text-lg mb-4">所有分类</h3>
<CategoryMenu :categories="categories" />
</div>
<!-- 主内容区 -->
<div class="col-span-10">
<!-- 限时抢购 -->
<FlashSaleSection
:products="flashSaleProducts"
:countdown="countdown"
@product-click="handleProductClick"
class="mb-6"
/>
</div>
<div class="col-span-12">
<!-- 热销商品 -->
<ProductSection
title="🔥 热销商品"
:products="hotProducts"
@product-click="handleProductClick"
class="mb-6"
/>
<!-- 新品上市 -->
<ProductSection
title="✨ 新品上市"
:products="newProducts"
@product-click="handleProductClick"
class="mb-6"
/>
<!-- 金牌卖家 -->
<ProductSection
title="👑 金牌卖家"
:products="premiumProducts"
@product-click="handleProductClick"
class="mb-6"
/>
<!-- 猜你喜欢 -->
<ProductSection
title="猜你喜欢"
:products="recommendedProducts"
@product-click="handleProductClick"
/>
</div>
</main>
</div>
</template> </template>
<style scoped> <script setup>
import { ref, reactive, onMounted, onUnmounted, inject } from 'vue'
</style> import CategoryMenu from '@/components/CategoryMenu.vue'
import BannerCarousel from '@/components/BannerCarousel.vue'
import FlashSaleSection from '@/components/FlashSaleSection.vue'
import ProductSection from '@/components/ProductSection.vue'
const globalState = inject('globalState')
// 倒计时状态
const countdown = reactive({
hours: '01',
minutes: '30',
seconds: '45'
})
let countdownInterval = null
// 分类数据
const categories = [
{
id: 1,
name: '女装/男装',
subcategories: [
{
name: '女装',
subItems: [
{ name: '连衣裙', id: 101 },
{ name: '女装T恤', id: 102 },
{ name: '女装外套', id: 103 },
{ name: '女装裤装', id: 104 }
]
},
{
name: '男装',
subItems: [
{ name: '男装衬衫', id: 201 },
{ name: '男装T恤', id: 202 },
{ name: '男装外套', id: 203 },
{ name: '男装裤装', id: 204 }
]
}
]
},
{
id: 2,
name: '手机数码',
subcategories: [
{
name: '手机通讯',
subItems: [
{ name: '手机', id: 301 },
{ name: '对讲机', id: 302 }
]
},
{
name: '数码配件',
subItems: [
{ name: '耳机', id: 401 },
{ name: '手表', id: 402 },
{ name: '充电器', id: 403 }
]
}
]
},
{
id: 3,
name: '家用电器',
subcategories: [
{
name: '大家电',
subItems: [
{ name: '电视', id: 501 },
{ name: '冰箱', id: 502 },
{ name: '洗衣机', id: 503 }
]
},
{
name: '小家电',
subItems: [
{ name: '电饭煲', id: 601 },
{ name: '微波炉', id: 602 },
{ name: '豆浆机', id: 603 }
]
}
]
}
]
// 轮播图数据
const banners = [
'https://cdn.seovx.com/ha/?mom=302&w=1200&h=400&_=1',
'https://cdn.seovx.com/ha/?mom=302&w=1200&h=400&_=2',
'https://cdn.seovx.com/ha/?mom=302&w=1200&h=400&_=3'
]
// 商品数据
const flashSaleProducts = ref([])
const hotProducts = ref([])
const newProducts = ref([])
const premiumProducts = ref([])
const recommendedProducts = ref([])
// 生成商品数据
const generateProducts = (count) => {
const products = []
const brands = ['时尚品牌', '优质品牌', '知名品牌', '国际品牌', '其他品牌']
for (let i = 0; i < count; i++) {
const basePrice = Math.floor(Math.random() * 500) + 20
const randSum = (Math.random() * 10 + 1).toFixed(2)
const finalPrice = (basePrice + parseFloat(randSum)).toFixed(2)
products.push({
id: Math.floor(Math.random() * 10000) + i,
title: '夏季爆款连衣裙时尚修身显瘦新款女装',
price: finalPrice,
cover: `https://cdn.seovx.com/ha/?mom=302&w=800&h=800&_=${i}`,
images: [
`https://cdn.seovx.com/ha/?mom=302&w=800&h=800&_=${i}1`,
`https://cdn.seovx.com/ha/?mom=302&w=800&h=800&_=${i}2`
],
desc: '夏季爆款连衣裙时尚修身显瘦新款女装,采用优质面料,舒适透气',
sold: Math.floor(Math.random() * 9999) + 10,
brand: brands[Math.floor(Math.random() * brands.length)]
})
}
return products
}
// 处理商品点击
const handleProductClick = (product) => {
// 添加到浏览历史
if (globalState?.currentUser) {
addToBrowsingHistory(product)
}
globalState.showProductDetail(product)
}
// 添加到浏览历史
const addToBrowsingHistory = (product) => {
let history = JSON.parse(localStorage.getItem('browsingHistory') || '[]')
// 移除已存在的相同商品
history = history.filter(item => item.id !== product.id)
// 添加到开头
history.unshift({
id: product.id,
title: product.title,
price: product.price,
cover: product.cover,
viewTime: new Date().toISOString()
})
// 保持最多50条记录
if (history.length > 50) {
history = history.slice(0, 50)
}
localStorage.setItem('browsingHistory', JSON.stringify(history))
}
// 启动倒计时
const startCountdown = () => {
let totalSeconds = 1 * 3600 + 30 * 60 + 45 // 1小时30分45秒
countdownInterval = setInterval(() => {
if (totalSeconds <= 0) {
totalSeconds = 2 * 3600 // 重置为2小时
}
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
countdown.hours = String(hours).padStart(2, '0')
countdown.minutes = String(minutes).padStart(2, '0')
countdown.seconds = String(seconds).padStart(2, '0')
totalSeconds--
}, 1000)
}
onMounted(() => {
// 生成商品数据
flashSaleProducts.value = generateProducts(4)
hotProducts.value = generateProducts(5)
newProducts.value = generateProducts(5)
premiumProducts.value = generateProducts(5)
recommendedProducts.value = generateProducts(10)
// 启动倒计时
startCountdown()
})
onUnmounted(() => {
if (countdownInterval) {
clearInterval(countdownInterval)
}
})
</script>

View File

@@ -0,0 +1,253 @@
<template>
<div class="login-page min-h-screen bg-gradient-to-br from-blue-400 to-purple-600 flex items-center justify-center p-4">
<div class="login-form w-full max-w-md bg-white/95 backdrop-blur-sm p-8 rounded-2xl shadow-2xl">
<!-- 头部 -->
<div class="text-center mb-8">
<div class="flex items-center justify-center mb-4">
<svg class="w-12 h-12 text-red-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"></path>
</svg>
</div>
<h1 class="text-3xl font-bold text-gray-800 mb-2">欢迎回来</h1>
<p class="text-gray-600">登录您的淘宝账户</p>
</div>
<!-- 登录表单 -->
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
@submit.prevent="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名或手机号"
size="large"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
size="large"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<div class="flex items-center justify-between mb-6">
<el-checkbox v-model="rememberMe">记住我</el-checkbox>
<el-link type="primary" :underline="false">忘记密码</el-link>
</div>
<el-button
type="primary"
size="large"
class="w-full"
:loading="loading"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-form>
<!-- 快速登录选项 -->
<div class="mt-6">
<div class="text-center text-sm text-gray-600 mb-4">或者使用以下方式快速登录</div>
<div class="grid grid-cols-3 gap-3">
<el-button
v-for="option in quickLoginOptions"
:key="option.type"
@click="quickLogin(option.type)"
class="quick-login-btn"
>
<div class="text-center">
<div class="text-2xl mb-1">{{ option.icon }}</div>
<div class="text-xs">{{ option.name }}</div>
</div>
</el-button>
</div>
</div>
<!-- 注册链接 -->
<div class="mt-8 text-center">
<span class="text-gray-600">还没有账户</span>
<el-link
type="primary"
:underline="false"
@click="globalState.showRegisterModal()"
class="ml-1"
>
立即注册
</el-link>
</div>
<!-- 返回首页 -->
<div class="mt-4 text-center">
<router-link
to="/"
class="text-gray-500 hover:text-gray-700 text-sm transition"
>
返回首页
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, inject, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const router = useRouter()
const globalState = inject('globalState')
const loginFormRef = ref()
const loading = ref(false)
const rememberMe = ref(false)
const loginForm = reactive({
username: '',
password: ''
})
const loginRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
]
}
const quickLoginOptions = [
{ type: 'demo', name: '演示账号', icon: '👤' },
{ type: 'admin', name: '管理员', icon: '👑' },
{ type: 'guest', name: '游客模式', icon: '🎯' }
]
const handleLogin = async () => {
if (!loginFormRef.value) return
try {
await loginFormRef.value.validate()
if (!loginForm.username || !loginForm.password) {
ElMessage.error('请输入用户名和密码')
return
}
loading.value = true
// 模拟登录延迟
await new Promise(resolve => setTimeout(resolve, 1000))
// 创建用户数据
const userData = {
id: Date.now(),
username: loginForm.username,
email: loginForm.username + '@example.com',
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100',
balance: Math.floor(Math.random() * 10000) + 1000,
joinDate: new Date().toISOString(),
level: 'VIP会员'
}
// 生成token
const token = 'fake_token_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
// 保存到localStorage
globalState.updateUser(userData)
localStorage.setItem('userToken', token)
loading.value = false
ElMessage.success('登录成功,正在跳转...')
// 跳转回首页
setTimeout(() => {
router.push('/')
}, 1500)
} catch (error) {
loading.value = false
}
}
const quickLogin = (type) => {
let userData
switch (type) {
case 'demo':
userData = {
id: 10001,
username: '演示用户',
email: 'demo@taobao.com',
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100',
balance: 5888.88,
joinDate: '2023-01-01T00:00:00.000Z',
level: 'VIP会员'
}
break
case 'admin':
userData = {
id: 10002,
username: '管理员',
email: 'admin@taobao.com',
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100',
balance: 99999.99,
joinDate: '2023-01-01T00:00:00.000Z',
level: '超级VIP'
}
break
case 'guest':
userData = {
id: 10003,
username: '游客用户',
email: 'guest@taobao.com',
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100',
balance: 0,
joinDate: new Date().toISOString(),
level: '普通用户'
}
break
}
const token = 'fake_token_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
globalState.updateUser(userData)
localStorage.setItem('userToken', token)
ElMessage.success('登录成功,正在跳转...')
setTimeout(() => {
router.push('/')
}, 1500)
}
// 检查是否已经登录
onMounted(() => {
if (globalState?.currentUser) {
ElMessage.success('您已经登录,正在跳转...')
setTimeout(() => {
router.push('/')
}, 1500)
}
})
</script>
<style lang="scss" scoped>
.quick-login-btn {
height: auto;
padding: 12px 8px;
}
.quick-login-btn:hover {
border-color: #ff2222;
color: #ff2222;
}
</style>

View File

@@ -0,0 +1,588 @@
<template>
<div class="order-list-page bg-gray-50 min-h-screen">
<main class="container mx-auto px-4 py-8">
<div class="max-w-6xl mx-auto">
<!-- 页面标题 -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-800 mb-2">我的订单</h1>
<p class="text-gray-600">管理您的所有订单信息</p>
</div>
<!-- 订单统计卡片 -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<div
v-for="stat in orderStats"
:key="stat.status"
class="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
:class="{ 'ring-2 ring-red-500 bg-red-50': activeTab === stat.status }"
@click="activeTab = stat.status"
>
<div class="text-center">
<div class="text-2xl mb-2">{{ stat.icon }}</div>
<div class="text-2xl font-bold text-gray-800 mb-1">{{ stat.count }}</div>
<div class="text-sm text-gray-600">{{ stat.label }}</div>
</div>
</div>
</div>
<!-- 订单筛选标签 -->
<div class="bg-white rounded-xl shadow-sm mb-6">
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="order-tabs">
<el-tab-pane
v-for="tab in orderTabs"
:key="tab.value"
:label="tab.label"
:name="tab.value"
/>
</el-tabs>
</div>
<!-- 订单列表 -->
<div v-loading="loading" class="order-list">
<!-- 空状态 -->
<div v-if="filteredOrders.length === 0" class="empty-orders bg-white rounded-xl p-12 text-center shadow-sm">
<div class="w-32 h-32 mx-auto mb-6 bg-gray-100 rounded-full flex items-center justify-center">
<div class="text-6xl">📋</div>
</div>
<h3 class="text-xl font-semibold text-gray-800 mb-2">暂无相关订单</h3>
<p class="text-gray-500 mb-6">您还没有{{ activeTab === 'all' ? '' : activeTab }}订单</p>
<el-button type="primary" size="large" @click="$router.push('/')">
立即购物
</el-button>
</div>
<!-- 订单卡片列表 -->
<div v-else class="space-y-6">
<div
v-for="order in filteredOrders"
:key="order.id"
class="order-card bg-white rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden"
>
<!-- 订单头部 -->
<div class="order-header bg-gray-50 px-6 py-4 border-b">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-6">
<div>
<span class="text-sm text-gray-500">订单号</span>
<span class="font-mono text-sm font-medium">{{ order.orderNumber }}</span>
</div>
<div>
<span class="text-sm text-gray-500">下单时间</span>
<span class="text-sm">{{ formatDate(order.createTime) }}</span>
</div>
</div>
<div class="flex items-center space-x-3">
<el-tag
:type="getStatusType(order.status)"
size="large"
class="px-4 py-1 font-medium"
>
{{ order.status }}
</el-tag>
<el-dropdown @command="(command) => handleOrderAction(command, order)">
<el-button text class="text-gray-600 hover:text-red-600">
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="detail">
<el-icon><View /></el-icon>查看详情
</el-dropdown-item>
<el-dropdown-item v-if="order.status === '已支付'" command="logistics">
<el-icon><View /></el-icon>查看物流
</el-dropdown-item>
<el-dropdown-item v-if="order.status === '已完成'" command="review">
<el-icon><Star /></el-icon>评价商品
</el-dropdown-item>
<el-dropdown-item v-if="order.status === '已完成'" command="repurchase">
<el-icon><RefreshRight /></el-icon>再次购买
</el-dropdown-item>
<el-dropdown-item v-if="order.status !== '已取消'" command="cancel" divided>
<el-icon><Close /></el-icon>取消订单
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<!-- 订单商品 -->
<div class="order-content p-6">
<div class="space-y-4">
<div
v-for="item in order.items"
:key="`${item.productId}-${item.color}-${item.size}`"
class="product-item flex space-x-4 p-4 rounded-lg hover:bg-gray-50 transition-colors"
>
<!-- 商品图片 -->
<div class="flex-shrink-0">
<img
:src="item.cover"
:alt="item.title"
class="w-20 h-20 object-cover rounded-lg cursor-pointer hover:scale-105 transition-transform"
@click="viewProduct(item)"
>
</div>
<!-- 商品信息 -->
<div class="flex-1 min-w-0">
<h4
class="font-medium text-gray-800 line-clamp-2 mb-2 cursor-pointer hover:text-red-600 transition-colors"
@click="viewProduct(item)"
>
{{ item.title }}
</h4>
<div class="flex items-center space-x-4 text-sm text-gray-500 mb-2">
<span class="px-2 py-1 bg-gray-100 rounded">{{ item.color }}</span>
<span class="px-2 py-1 bg-gray-100 rounded">{{ item.size }}</span>
<span>数量{{ item.quantity }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-lg font-bold text-red-600">¥{{ item.price }}</span>
<div class="flex space-x-2">
<el-button
v-if="order.status === '已完成'"
size="small"
@click="reviewProduct(item)"
>
评价
</el-button>
<el-button
size="small"
@click="buyAgain(item)"
>
再次购买
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 订单总计 -->
<div class="order-summary mt-6 pt-6 border-t bg-gray-50 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-600">
{{ order.items.reduce((sum, item) => sum + item.quantity, 0) }}件商品
<span class="mx-2">|</span>
运费<span class="text-green-600">免运费</span>
</div>
<div class="text-right">
<div class="text-sm text-gray-500 mb-1">应付总额</div>
<div class="text-2xl font-bold text-red-600">¥{{ order.totalAmount }}</div>
</div>
</div>
</div>
<!-- 订单操作按钮 -->
<div class="order-actions mt-6 flex justify-end space-x-3">
<el-button
v-if="order.status === '待支付'"
type="primary"
size="large"
@click="payOrder(order)"
>
立即支付
</el-button>
<el-button
v-if="order.status === '已发货'"
type="primary"
size="large"
@click="confirmReceive(order)"
>
确认收货
</el-button>
<el-button
v-if="order.status === '已完成'"
type="primary"
size="large"
@click="reviewOrder(order)"
>
评价商品
</el-button>
<el-button
size="large"
@click="viewOrderDetail(order)"
>
查看详情
</el-button>
<el-button
v-if="order.status === '待支付'"
size="large"
@click="cancelOrder(order)"
>
取消订单
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="filteredOrders.length > 0" class="mt-8 flex justify-center">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="totalOrders"
layout="prev, pager, next, jumper"
background
@current-change="handlePageChange"
/>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router'
import {
MoreFilled,
View,
Star,
RefreshRight,
Close
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const globalState = inject('globalState')
const loading = ref(false)
const activeTab = ref('all')
const currentPage = ref(1)
const pageSize = ref(5)
const orders = ref([])
const orderTabs = [
{ label: '全部订单', value: 'all' },
{ label: '待支付', value: '待支付' },
{ label: '已支付', value: '已支付' },
{ label: '已发货', value: '已发货' },
{ label: '已完成', value: '已完成' },
{ label: '已取消', value: '已取消' }
]
// 订单统计
const stats = ref([
{ status: 'all', label: '全部', icon: '📋', count: 0 },
{ status: '待支付', label: '待支付', icon: '💰', count: 0 },
{ status: '已发货', label: '已发货', icon: '🚚', count: 0 },
{ status: '已完成', label: '已完成', icon: '✅', count: 0 },
{ status: '已取消', label: '已取消', icon: '❌', count: 0 }
])
const orderStats = computed(() => {
// Reset counts
stats.value.forEach(s => s.count = 0);
orders.value.forEach(order => {
const stat = stats.value.find(s => s.status === order.status)
if (stat) stat.count++
})
return stats.value
})
onMounted(() => {
if (!globalState.currentUser) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
loadOrders()
})
const filteredOrders = computed(() => {
let filtered = orders.value
if (activeTab.value !== 'all') {
filtered = filtered.filter(order => order.status === activeTab.value)
}
// 分页
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filtered.slice(start, end)
})
const totalOrders = computed(() => {
if (activeTab.value === 'all') {
return orders.value.length
}
return orders.value.filter(order => order.status === activeTab.value).length
})
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
const getStatusType = (status) => {
const statusMap = {
'待支付': 'warning',
'已支付': 'primary',
'已发货': 'success',
'已完成': 'success',
'已取消': 'danger'
}
return statusMap[status] || 'info'
}
const handleTabChange = (tab) => {
activeTab.value = tab
currentPage.value = 1
}
const handlePageChange = (page) => {
currentPage.value = page
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const handleOrderAction = async (command, order) => {
switch (command) {
case 'detail':
viewOrderDetail(order)
break
case 'logistics':
viewLogistics(order)
break
case 'review':
reviewOrder(order)
break
case 'repurchase':
repurchaseOrder(order)
break
case 'cancel':
await cancelOrder(order)
break
}
}
const viewProduct = (item) => {
const product = {
id: item.productId,
title: item.title,
price: item.price,
cover: item.cover,
images: [item.cover],
desc: '商品详情描述',
sold: Math.floor(Math.random() * 1000),
brand: '品牌名称'
}
router.push(`/product/${product.id}`)
}
const payOrder = (order) => {
globalState.showPaymentModal(order.totalAmount)
}
const confirmReceive = async (order) => {
try {
await ElMessageBox.confirm('确认收到商品了吗?', '确认收货', {
confirmButtonText: '确认收货',
cancelButtonText: '取消',
type: 'warning'
})
order.status = '已完成'
updateOrderInStorage(order)
ElMessage.success('确认收货成功')
} catch {
// 用户取消
}
}
const reviewOrder = (order) => {
ElMessage.info('评价功能开发中...')
}
const reviewProduct = (item) => {
ElMessage.info('商品评价功能开发中...')
}
const buyAgain = (item) => {
const cartItem = {
productId: item.productId,
title: item.title,
price: item.price,
cover: item.cover,
color: item.color,
size: item.size,
quantity: 1
}
const existingItem = globalState.cartItems.find(cartItem =>
cartItem.productId === item.productId &&
cartItem.color === item.color &&
cartItem.size === item.size
)
if (existingItem) {
existingItem.quantity += 1
} else {
globalState.cartItems.push(cartItem)
}
localStorage.setItem('cart', JSON.stringify(globalState.cartItems))
ElMessage.success('商品已加入购物车')
}
const viewOrderDetail = (order) => {
ElMessage.info('订单详情页面开发中...')
}
const viewLogistics = (order) => {
ElMessage.info('物流查询功能开发中...')
}
const repurchaseOrder = (order) => {
order.items.forEach(item => {
const cartItem = {
productId: item.productId,
title: item.title,
price: item.price,
cover: item.cover,
color: item.color,
size: item.size,
quantity: item.quantity
}
const existingItem = globalState.cartItems.find(cartItem =>
cartItem.productId === item.productId &&
cartItem.color === item.color &&
cartItem.size === item.size
)
if (existingItem) {
existingItem.quantity += item.quantity
} else {
globalState.cartItems.push(cartItem)
}
})
localStorage.setItem('cart', JSON.stringify(globalState.cartItems))
ElMessage.success('商品已加入购物车')
globalState.showCart()
}
const cancelOrder = async (order) => {
try {
await ElMessageBox.confirm('确定要取消这个订单吗?', '取消订单', {
confirmButtonText: '确定取消',
cancelButtonText: '我再想想',
type: 'warning'
})
order.status = '已取消'
updateOrderInStorage(order)
ElMessage.success('订单已取消')
} catch {
// 用户取消
}
}
const updateOrderInStorage = (updatedOrder) => {
const orderIndex = orders.value.findIndex(order => order.id === updatedOrder.id)
if (orderIndex > -1) {
orders.value[orderIndex] = updatedOrder
localStorage.setItem('orders', JSON.stringify(orders.value))
}
}
const loadOrders = () => {
loading.value = true
setTimeout(() => {
const storedOrders = JSON.parse(localStorage.getItem('orders') || '[]')
if (storedOrders.length === 0 && globalState.currentUser) {
const sampleOrders = generateSampleOrders()
orders.value = sampleOrders
localStorage.setItem('orders', JSON.stringify(sampleOrders))
} else {
orders.value = storedOrders.filter(order => order.userId === globalState.currentUser?.id)
}
loading.value = false
}, 500)
}
const generateSampleOrders = () => {
const statuses = ['已完成', '已发货', '已支付', '待支付']
const sampleOrders = []
for (let i = 0; i < 8; i++) {
const status = statuses[Math.floor(Math.random() * statuses.length)]
const itemCount = Math.floor(Math.random() * 3) + 1
const items = []
for (let j = 0; j < itemCount; j++) {
items.push({
productId: Math.floor(Math.random() * 1000) + 1,
title: `高品质时尚单品 ${i + 1}-${j + 1} - 精选优质材料制作`,
price: (Math.random() * 200 + 50).toFixed(2),
cover: `https://cdn.seovx.com/ha/?mom=302&w=400&h=400&_=${i}${j}`,
color: ['红色', '蓝色', '黑色', '白色'][Math.floor(Math.random() * 4)],
size: ['S', 'M', 'L', 'XL'][Math.floor(Math.random() * 4)],
quantity: Math.floor(Math.random() * 3) + 1
})
}
const totalAmount = items.reduce((sum, item) => sum + parseFloat(item.price) * item.quantity, 0)
sampleOrders.push({
id: `order_${Date.now()}_${i}`,
orderNumber: `TB${Date.now()}${i}`,
items: items,
totalAmount: totalAmount.toFixed(2),
status: status,
createTime: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
userId: globalState.currentUser?.id
})
}
return sampleOrders.sort((a, b) => new Date(b.createTime) - new Date(a.createTime))
}
</script>
<style lang="scss" scoped>
.order-tabs :deep(.el-tabs__header) {
margin: 0;
padding: 0 24px;
}
.order-tabs :deep(.el-tabs__nav-wrap) {
padding: 16px 0;
}
.order-card {
border: 1px solid #f0f0f0;
}
.order-card:hover {
border-color: #ff4d4f;
}
.product-item:hover {
transform: translateY(-1px);
}
.empty-orders {
border: 2px dashed #e5e7eb;
}
:deep(.el-tag--large) {
padding: 8px 16px;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,841 @@
<template>
<div class="product-detail-page bg-gray-50 min-h-screen">
<main class="container mx-auto px-4 py-6">
<div v-if="loading" class="flex justify-center items-center h-96">
<el-icon class="is-loading text-4xl text-gray-400">
<Loading />
</el-icon>
</div>
<div v-else-if="product" class="max-w-7xl mx-auto">
<!-- 面包屑导航 -->
<el-breadcrumb separator="/" class="mb-6">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: '/products' }">商品列表</el-breadcrumb-item>
<el-breadcrumb-item>{{ product.title }}</el-breadcrumb-item>
</el-breadcrumb>
<div class="grid grid-cols-12 gap-8">
<!-- 商品图片区域 -->
<div class="col-span-12 lg:col-span-5">
<div class="bg-white rounded-lg p-6 sticky top-40">
<!-- 主图展示 -->
<div class="main-image-container relative mb-4">
<div
class="main-image-wrapper relative overflow-hidden rounded-lg"
@mousemove="handleMouseMove"
@mouseleave="showMagnifier = false"
@mouseenter="showMagnifier = true"
>
<img
ref="mainImageRef"
:src="currentImage"
:alt="product.title"
class="w-full h-96 object-cover cursor-crosshair"
>
<!-- 放大镜遮罩 -->
<div
v-show="showMagnifier"
class="magnifier-mask absolute border-2 border-red-500 bg-opacity-20 pointer-events-none"
:style="maskStyle"
></div>
</div>
</div>
<!-- 缩略图列表 -->
<div class="thumbnail-list">
<div class="flex space-x-2 overflow-x-auto pb-2">
<img
v-for="(image, index) in allImages"
:key="index"
:src="image"
:alt="`商品图片${index + 1}`"
class="thumbnail-item w-16 h-16 object-cover rounded cursor-pointer border-2 transition-all"
:class="{
'border-red-500 ring-2 ring-red-200': currentImage === image,
'border-gray-200 hover:border-red-300': currentImage !== image
}"
@click="currentImage = image"
>
</div>
</div>
<!-- 商品操作按钮 -->
<div class="product-actions mt-6 space-y-3">
<div class="flex space-x-3">
<el-button
type="danger"
size="large"
class="flex-1"
:disabled="!isSpecsSelected"
@click="handleBuyNow"
>
立即购买
</el-button>
<el-button
size="large"
class="flex-1"
:disabled="!isSpecsSelected"
@click="handleAddToCart"
>
加入购物车
</el-button>
</div>
<div class="flex space-x-3">
<el-button
class="flex-1"
@click="toggleFavorite"
:type="isFavorited ? 'danger' : 'default'"
>
<el-icon class="mr-1">
<Star v-if="isFavorited" />
<StarFilled v-else />
</el-icon>
{{ isFavorited ? '已收藏' : '收藏商品' }}
</el-button>
<el-button class="flex-1">
<el-icon class="mr-1">
<Share />
</el-icon>
分享
</el-button>
</div>
</div>
</div>
</div>
<!-- 商品信息区域 -->
<div class="col-span-12 lg:col-span-7">
<div class="bg-white rounded-lg p-6 mb-6">
<!-- 商品标题和价格 -->
<div class="product-header mb-6">
<h1 class="text-2xl font-bold text-gray-800 mb-4 leading-relaxed">{{ product.title }}</h1>
<div class="price-section mb-4">
<div class="flex items-baseline space-x-4 mb-2">
<span class="text-4xl font-bold text-red-600">¥{{ product.price }}</span>
<span class="text-lg text-gray-400 line-through">¥{{
(parseFloat(product.price) * 1.3).toFixed(2)
}}</span>
</div>
<div class="flex items-center space-x-6 text-sm text-gray-600">
<span>已售 {{ product.sold }} </span>
<div class="flex items-center">
<el-rate v-model="product.rating" disabled size="small" />
<span class="ml-1">({{ product.reviewCount || 0 }}条评价)</span>
</div>
</div>
</div>
<!-- 促销信息 -->
<div class="promotion-tags flex flex-wrap gap-2 mb-4">
<el-tag type="danger" size="small">限时特价</el-tag>
<el-tag type="warning" size="small">满200减30</el-tag>
<el-tag type="success" size="small">包邮</el-tag>
<el-tag type="info" size="small">7天无理由退货</el-tag>
</div>
</div>
<!-- 商品规格选择 -->
<div class="product-specs mb-8">
<!-- 颜色选择 -->
<div class="spec-group mb-6">
<div class="spec-label mb-3">
<span class="text-sm font-medium text-gray-700">颜色分类</span>
<span v-if="selectedSpecs.color" class="ml-2 text-sm text-gray-500">
已选{{ selectedSpecs.color }}
</span>
</div>
<div class="spec-options flex flex-wrap gap-3">
<div
v-for="color in colors"
:key="color.name"
class="spec-option color-option relative cursor-pointer"
:class="{ 'selected': selectedSpecs.color === color.name }"
@click="selectedSpecs.color = color.name"
>
<img
:src="color.image"
:alt="color.name"
class="w-16 h-16 object-cover rounded border-2"
:class="{
'border-red-500': selectedSpecs.color === color.name,
'border-gray-200': selectedSpecs.color !== color.name
}"
>
<span class="block text-xs text-center mt-1">{{ color.name }}</span>
<div
v-if="selectedSpecs.color === color.name"
class="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center"
>
<el-icon class="text-white text-xs">
<Check />
</el-icon>
</div>
</div>
</div>
</div>
<!-- 尺寸选择 -->
<div class="spec-group mb-6">
<div class="spec-label mb-3">
<span class="text-sm font-medium text-gray-700">尺码</span>
<span v-if="selectedSpecs.size" class="ml-2 text-sm text-gray-500">
已选{{ selectedSpecs.size }}
</span>
<el-button text type="primary" size="small" class="ml-2">尺码表</el-button>
</div>
<div class="spec-options flex flex-wrap gap-3">
<button
v-for="size in sizes"
:key="size.name"
class="size-option px-4 py-2 border rounded text-sm font-medium transition-all"
:class="{
'border-red-500 bg-red-50 text-red-600': selectedSpecs.size === size.name,
'border-gray-300 hover:border-red-300': selectedSpecs.size !== size.name,
'opacity-50 cursor-not-allowed': !size.available
}"
:disabled="!size.available"
@click="selectedSpecs.size = size.name"
>
{{ size.name }}
<span v-if="!size.available" class="block text-xs text-gray-400">缺货</span>
</button>
</div>
</div>
<!-- 数量选择 -->
<div class="spec-group mb-6">
<div class="spec-label mb-3">
<span class="text-sm font-medium text-gray-700">数量</span>
<span class="ml-2 text-sm text-gray-500">库存{{ stock }}</span>
</div>
<div class="quantity-selector flex items-center space-x-4">
<el-input-number
v-model="quantity"
:min="1"
:max="Math.min(stock, 99)"
size="large"
class="quantity-input"
/>
<span class="text-sm text-gray-500"></span>
</div>
</div>
</div>
<!-- 服务保障 -->
<div class="service-guarantee mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-3">服务保障</h3>
<div class="grid grid-cols-2 gap-4">
<div class="service-item flex items-center space-x-2">
<el-icon class="text-green-500">
<CircleCheckFilled />
</el-icon>
<span class="text-sm">正品保证</span>
</div>
<div class="service-item flex items-center space-x-2">
<el-icon class="text-blue-500">
<Clock />
</el-icon>
<span class="text-sm">快速发货</span>
</div>
<div class="service-item flex items-center space-x-2">
<el-icon class="text-orange-500">
<RefreshRight />
</el-icon>
<span class="text-sm">7天无理由退货</span>
</div>
<div class="service-item flex items-center space-x-2">
<el-icon class="text-purple-500">
<Service />
</el-icon>
<span class="text-sm">24小时客服</span>
</div>
</div>
</div>
</div>
<!-- 商品详情标签页 -->
<div class="bg-white rounded-lg">
<el-tabs
v-model="activeTab"
class="product-detail-tabs"
>
<el-tab-pane label="商品详情" name="detail"
:class="[
{ 'text-red-600 border-b-2 border-red-600': activeTab === 'detail' },
{ 'text-gray-600': activeTab !== 'detail' }
]"
>
<div class="detail-content p-6">
<div class="detail-images space-y-4">
<img
v-for="(image, index) in detailImages"
:key="index"
:src="image"
:alt="`详情图${index + 1}`"
class="w-full rounded-lg"
>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="规格参数" name="specs">
<div class="specs-content p-6 bg-white rounded-lg shadow-sm">
<!-- 标题区域 -->
<div class="mb-6">
<h3 class="text-xl font-semibold text-gray-800 mb-2">产品规格参数</h3>
<p class="text-gray-500 text-sm">详细了解产品的技术参数和性能指标</p>
</div>
<!-- 规格参数表格 -->
<div class="overflow-x-auto">
<table class="w-full">
<tbody>
<tr
v-for="spec in productSpecs"
:key="spec.name"
class="border-b border-gray-200 hover:bg-gray-50 transition-colors"
>
<td class="py-4 pr-8 text-gray-600 font-medium min-w-[120px]">
{{ spec.name }}
</td>
<td class="py-4 text-gray-800">
<!-- 根据参数类型显示不同样式 -->
<span
v-if="spec.type === 'number'"
class="font-mono"
>{{ spec.value }}</span>
<span
v-else-if="spec.type === 'status'"
:class="{
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium': true,
'bg-green-100 text-green-800': spec.value === '支持',
'bg-red-100 text-red-800': spec.value === '不支持',
'bg-blue-100 text-blue-800': spec.value === '可选'
}"
>{{ spec.value }}</span>
<span v-else>{{ spec.value }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 无数据状态 -->
<div
v-if="productSpecs.length === 0"
class="py-12 text-center"
>
<div class="flex flex-col items-center">
<el-empty description="暂无规格参数数据" />
<el-button
type="primary"
size="small"
class="mt-4"
@click="fetchProductSpecs"
>
刷新数据
</el-button>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane :label="`用户评价(${reviews.length})`" name="reviews">
<div class="reviews-content p-6">
<!-- 评价统计 -->
<div class="review-summary mb-6 p-4 bg-gray-50 rounded-lg">
<div class="flex items-center space-x-8">
<div class="text-center">
<div class="text-3xl font-bold text-red-600 mb-1">{{ averageRating.toFixed(1) }}</div>
<el-rate v-model="averageRating" disabled />
<div class="text-sm text-gray-500 mt-1">综合评分</div>
</div>
<div class="flex-1">
<div class="rating-bars space-y-2">
<div v-for="i in 5" :key="i" class="flex items-center space-x-2">
<span class="text-sm w-8">{{ 6 - i }}</span>
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div
class="bg-red-500 h-2 rounded-full"
:style="{ width: `${getRatingPercentage(6 - i)}%` }"
></div>
</div>
<span class="text-sm text-gray-500 w-12">{{ getRatingCount(6 - i) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 评价列表 -->
<div class="review-list space-y-6">
<div
v-for="review in reviews"
:key="review.id"
class="review-item pb-6 border-b border-gray-100 last:border-b-0"
>
<div class="flex items-start space-x-4">
<el-avatar :src="review.avatar" :size="40" />
<div class="flex-1">
<div class="flex items-center space-x-4 mb-2">
<span class="font-medium">{{ review.username }}</span>
<el-rate v-model="review.rating" disabled size="small" />
<span class="text-sm text-gray-500">{{ review.date }}</span>
</div>
<p class="text-gray-700 mb-3">{{ review.content }}</p>
<div v-if="review.images" class="review-images flex space-x-2">
<img
v-for="(img, index) in review.images"
:key="index"
:src="img"
:alt="`评价图${index + 1}`"
class="w-20 h-20 object-cover rounded cursor-pointer"
@click="previewImage(img)"
>
</div>
<div class="review-specs mt-2 text-sm text-gray-500">
规格{{ review.specs }}
</div>
</div>
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-12">
<div class="text-6xl mb-4">😕</div>
<h2 class="text-xl font-semibold text-gray-800 mb-2">商品不存在</h2>
<p class="text-gray-600 mb-4">抱歉您访问的商品可能已下架或不存在</p>
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
</div>
</main>
<!-- 放大镜显示区域 -->
<div
v-show="showMagnifier"
class="magnifier-viewer fixed border border-gray-300 rounded-lg overflow-hidden bg-white shadow-xl z-50"
:style="{
left: magnifierLeft + 'px',
top: magnifierTop + 'px',
width: '300px',
height: '300px',
backgroundImage: 'url(' + currentImage + ')',
backgroundSize: zoomLevel + '%',
backgroundPosition: backgroundPosition
}"
></div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, inject, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Loading,
Star,
StarFilled,
Share,
Check,
Clock,
RefreshRight,
CircleCheckFilled,
Service
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const globalState = inject('globalState')
const loading = ref(true)
const product = ref(null)
const currentImage = ref('')
const quantity = ref(1)
const activeTab = ref('detail')
const showMagnifier = ref(false)
const mainImageRef = ref(null)
// 放大镜相关
const maskStyle = ref({})
const backgroundPosition = ref('0% 0%')
const zoomLevel = ref(250)
const magnifierLeft = ref(0)
const magnifierTop = ref(0)
const selectedSpecs = reactive({
color: '',
size: ''
})
// 商品规格数据
const colors = [
{ name: '经典黑', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=black' },
{ name: '优雅白', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=white' },
{ name: '时尚红', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=red' },
{ name: '深海蓝', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=blue' }
]
const sizes = [
{ name: 'S', available: true },
{ name: 'M', available: true },
{ name: 'L', available: true },
{ name: 'XL', available: true },
{ name: 'XXL', available: false }
]
const stock = ref(99)
const averageRating = ref(4.5)
const productSpecs = [
{ name: '品牌', value: '时尚品牌' },
{ name: '材质', value: '优质棉质' },
{ name: '产地', value: '中国' },
{ name: '适用季节', value: '四季' },
{ name: '洗涤方式', value: '机洗' },
{ name: '厚度', value: '适中' }
]
const reviews = [
{
id: 1,
username: '用户***1',
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=50&h=50&_=1',
rating: 5,
date: '2023-05-15',
content: '这款产品质量非常好,穿着很舒适,非常满意这次购物体验。',
images: [
'https://cdn.seovx.com/ha/?mom=302&w=200&h=200&_=review1',
'https://cdn.seovx.com/ha/?mom=302&w=200&h=200&_=review2'
],
specs: '经典黑/M'
},
{
id: 2,
username: '用户***2',
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=50&h=50&_=2',
rating: 4,
date: '2023-05-10',
content: '款式很喜欢,尺码也合适,就是物流稍微慢了点。',
specs: '时尚红/L'
},
{
id: 3,
username: '用户***3',
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=50&h=50&_=3',
rating: 5,
date: '2023-05-05',
content: '已经是第二次购买了,质量一如既往的好,客服态度也很棒!',
images: [
'https://cdn.seovx.com/ha/?mom=302&w=200&h=200&_=review3'
],
specs: '深海蓝/XL'
}
]
const allImages = computed(() => {
if (!product.value) return []
return [product.value.cover, ...(product.value.images || [])]
})
const detailImages = computed(() => {
const baseImages = []
for (let i = 1; i <= 6; i++) {
baseImages.push(`https://cdn.seovx.com/ha/?mom=302&w=800&h=600&_=detail${i}`)
}
return baseImages
})
const currentPrice = computed(() => {
if (!product.value) return '0.00'
const basePrice = parseFloat(product.value.price)
const colorPrice = selectedSpecs.color ? 5 : 0
const sizePrice = selectedSpecs.size ? 10 : 0
return (basePrice + colorPrice + sizePrice).toFixed(2)
})
const isSpecsSelected = computed(() => {
return selectedSpecs.color && selectedSpecs.size
})
const isFavorited = computed(() => {
if (!globalState.currentUser || !product.value) return false
return globalState.favorites.some(fav => fav.id === product.value.id)
})
// 处理窗口大小变化和滚动事件
const updateMagnifierPosition = () => {
if (!mainImageRef.value) return
const rect = mainImageRef.value.getBoundingClientRect()
const containerPadding = 20 // 大图容器的内边距
const gap = 20 // 放大镜与大图之间的间距
// 计算放大镜的左侧位置:大图右侧 + 间距
magnifierLeft.value = rect.right + window.scrollX + gap
// 计算放大镜的顶部位置:与大图顶部对齐
magnifierTop.value = rect.top + window.scrollY
}
// 放大镜功能
const handleMouseMove = (event) => {
if (!showMagnifier.value || !mainImageRef.value) return
const rect = mainImageRef.value.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
const maskSize = 120
// 计算遮罩位置
const maskX = Math.max(0, Math.min(x - maskSize / 2, rect.width - maskSize))
const maskY = Math.max(0, Math.min(y - maskSize / 2, rect.height - maskSize))
maskStyle.value = {
left: `${maskX}px`,
top: `${maskY}px`,
width: `${maskSize}px`,
height: `${maskSize}px`
}
// 计算背景图片位置
const xPercent = (x / rect.width) * 100
const yPercent = (y / rect.height) * 100
backgroundPosition.value = `${xPercent}% ${yPercent}%`
}
const getRatingPercentage = (rating) => {
const count = reviews.filter(r => r.rating === rating).length
return (count / reviews.length) * 100
}
const getRatingCount = (rating) => {
return reviews.filter(r => r.rating === rating).length
}
const toggleFavorite = () => {
if (!globalState.currentUser) {
ElMessage.warning('请先登录')
return
}
const index = globalState.favorites.findIndex(fav => fav.id === product.value.id)
if (index > -1) {
globalState.favorites.splice(index, 1)
ElMessage.success('已取消收藏')
} else {
globalState.favorites.push({
id: product.value.id,
title: product.value.title,
price: product.value.price,
cover: product.value.cover,
addTime: new Date().toISOString()
})
ElMessage.success('已加入收藏')
}
localStorage.setItem('favorites', JSON.stringify(globalState.favorites))
}
const handleAddToCart = () => {
if (!isSpecsSelected.value) {
ElMessage.warning('请选择商品规格')
return
}
const cartItem = {
productId: product.value.id,
title: product.value.title,
price: parseFloat(currentPrice.value),
cover: product.value.cover,
color: selectedSpecs.color,
size: selectedSpecs.size,
quantity: quantity.value
}
const existingItem = globalState.cartItems.find(item =>
item.productId === cartItem.productId &&
item.color === cartItem.color &&
item.size === cartItem.size
)
if (existingItem) {
existingItem.quantity += cartItem.quantity
} else {
globalState.cartItems.push(cartItem)
}
localStorage.setItem('cart', JSON.stringify(globalState.cartItems))
ElMessage.success('已加入购物车')
}
const handleBuyNow = () => {
if (!isSpecsSelected.value) {
ElMessage.warning('请选择商品规格')
return
}
const totalAmount = (parseFloat(currentPrice.value) * quantity.value).toFixed(2)
globalState.showPaymentModal(totalAmount)
}
const previewImage = (imageUrl) => {
// 图片预览功能实现
console.log('预览图片:', imageUrl)
// 可以使用 Element Plus 的 Dialog 组件实现图片预览
}
const loadProduct = async () => {
loading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 500))
const productId = route.params.id
// 生成模拟商品数据
product.value = {
id: productId,
title: `高品质时尚单品 ${productId} - 精选优质材料制作,舒适透气,四季适穿`,
price: (Math.random() * 200 + 50).toFixed(2),
cover: `https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}`,
images: [
`https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}1`,
`https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}2`,
`https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}3`,
`https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}4`
],
desc: '这是一款优质的时尚单品,采用精选材料制作,工艺精良,品质保证。',
sold: Math.floor(Math.random() * 9999) + 100,
brand: '时尚品牌',
rating: Math.random() * 1 + 4,
reviewCount: Math.floor(Math.random() * 500) + 50
}
currentImage.value = product.value.cover
// 添加到浏览历史
if (globalState.currentUser) {
const historyItem = {
id: product.value.id,
title: product.value.title,
price: product.value.price,
cover: product.value.cover,
viewTime: new Date().toISOString()
}
let history = JSON.parse(localStorage.getItem('browsingHistory') || '[]')
history = history.filter(item => item.id !== product.value.id)
history.unshift(historyItem)
history = history.slice(0, 20)
localStorage.setItem('browsingHistory', JSON.stringify(history))
}
} catch (error) {
console.error('加载商品失败:', error)
product.value = null
} finally {
loading.value = false
}
}
onMounted(() => {
loadProduct()
// 初始化放大镜位置
updateMagnifierPosition()
// 监听窗口大小变化和滚动事件
window.addEventListener('resize', updateMagnifierPosition)
window.addEventListener('scroll', updateMagnifierPosition)
})
onUnmounted(() => {
// 移除事件监听器,防止内存泄漏
window.removeEventListener('resize', updateMagnifierPosition)
window.removeEventListener('scroll', updateMagnifierPosition)
})
</script>
<style lang="scss" scoped>
.main-image-wrapper {
position: relative;
}
.magnifier-mask {
width: 100px;
height: 100px;
}
.magnifier-viewer {
z-index: 1000;
}
.magnifier-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-item:hover {
transform: scale(1.05);
}
.spec-option.selected {
transform: scale(1.05);
}
.color-option {
transition: all 0.2s;
}
.color-option:hover {
transform: translateY(-2px);
}
.size-option:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.quantity-input :deep(.el-input-number) {
width: 120px;
}
.product-detail-tabs :deep(.el-tabs__header) {
margin: 0;
padding: 0 24px;
background: #f8f9fa;
}
.product-detail-tabs :deep(.el-tabs__nav-wrap) {
padding: 16px 0;
}
.review-item:hover {
background: #fafafa;
border-radius: 8px;
padding: 16px;
margin: -16px;
}
.rating-bars {
min-width: 200px;
}
@media (max-width: 1024px) {
.magnifier-viewer {
display: none;
}
}
</style>

View File

@@ -0,0 +1,761 @@
<template>
<div class="product-detail-page bg-gray-50 min-h-screen">
<main class="container mx-auto px-4 py-6">
<div v-if="loading" class="flex justify-center items-center h-96">
<el-icon class="is-loading text-4xl text-gray-400">
<Loading/>
</el-icon>
</div>
<div v-else-if="product" class="max-w-7xl mx-auto">
<!-- 面包屑导航 -->
<el-breadcrumb separator="/" class="mb-6">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: '/products' }">商品列表</el-breadcrumb-item>
<el-breadcrumb-item>{{ product.title }}</el-breadcrumb-item>
</el-breadcrumb>
<div class="grid grid-cols-12 gap-8">
<!-- 商品图片区域 -->
<div class="col-span-12 lg:col-span-5">
<div class="bg-white rounded-lg p-6 sticky top-40">
<!-- 主图展示 -->
<div class="main-image-container relative mb-4">
<div
class="main-image-wrapper relative overflow-hidden rounded-lg"
@mousemove="handleMouseMove"
@mouseleave="showMagnifier = false"
@mouseenter="showMagnifier = true"
>
<img
ref="mainImageRef"
:src="currentImage"
:alt="product.title"
class="w-full h-96 object-cover cursor-crosshair"
>
<!-- 放大镜遮罩 -->
<div
v-show="showMagnifier"
class="magnifier-mask absolute border-2 border-red-500 bg-opacity-20 pointer-events-none"
:style="maskStyle"
></div>
</div>
<!-- 放大镜显示区域 -->
<div
v-show="showMagnifier"
class="magnifier-viewer absolute top-0 left-full ml-4 w-96 h-96 border border-gray-300 rounded-lg overflow-hidden bg-white shadow-lg"
>
<img
:src="currentImage"
class="magnifier-image"
:style="magnifierStyle"
style="z-index: 999999;"
>
</div>
</div>
<!-- 缩略图列表 -->
<div class="thumbnail-list">
<div class="flex space-x-2 overflow-x-auto pb-2">
<img
v-for="(image, index) in allImages"
:key="index"
:src="image"
:alt="`商品图片${index + 1}`"
class="thumbnail-item w-16 h-16 object-cover rounded cursor-pointer border-2 transition-all"
:class="{
'border-red-500 ring-2 ring-red-200': currentImage === image,
'border-gray-200 hover:border-red-300': currentImage !== image
}"
@click="currentImage = image"
>
</div>
</div>
<!-- 商品操作按钮 -->
<div class="product-actions mt-6 space-y-3">
<div class="flex space-x-3">
<el-button
type="danger"
size="large"
class="flex-1"
:disabled="!isSpecsSelected"
@click="handleBuyNow"
>
立即购买
</el-button>
<el-button
size="large"
class="flex-1"
:disabled="!isSpecsSelected"
@click="handleAddToCart"
>
加入购物车
</el-button>
</div>
<div class="flex space-x-3">
<el-button
class="flex-1"
@click="toggleFavorite"
:type="isFavorited ? 'danger' : 'default'"
>
<el-icon class="mr-1">
<Star v-if="isFavorited"/>
<StarFilled v-else/>
</el-icon>
{{ isFavorited ? '已收藏' : '收藏商品' }}
</el-button>
<el-button class="flex-1">
<el-icon class="mr-1">
<Share/>
</el-icon>
分享
</el-button>
</div>
</div>
</div>
</div>
<!-- 商品信息区域 -->
<div class="col-span-12 lg:col-span-7">
<div class="bg-white rounded-lg p-6 mb-6">
<!-- 商品标题和价格 -->
<div class="product-header mb-6">
<h1 class="text-2xl font-bold text-gray-800 mb-4 leading-relaxed">{{ product.title }}</h1>
<div class="price-section mb-4">
<div class="flex items-baseline space-x-4 mb-2">
<span class="text-4xl font-bold text-red-600">¥{{ product.price }}</span>
<span class="text-lg text-gray-400 line-through">¥{{
(parseFloat(product.price) * 1.3).toFixed(2)
}}</span>
</div>
<div class="flex items-center space-x-6 text-sm text-gray-600">
<span>已售 {{ product.sold }} </span>
<div class="flex items-center">
<el-rate v-model="product.rating" disabled size="small"/>
<span class="ml-1">({{ product.reviewCount || 0 }}条评价)</span>
</div>
</div>
</div>
<!-- 促销信息 -->
<div class="promotion-tags flex flex-wrap gap-2 mb-4">
<el-tag type="danger" size="small">限时特价</el-tag>
<el-tag type="warning" size="small">满200减30</el-tag>
<el-tag type="success" size="small">包邮</el-tag>
<el-tag type="info" size="small">7天无理由退货</el-tag>
</div>
</div>
<!-- 商品规格选择 -->
<div class="product-specs mb-8">
<!-- 颜色选择 -->
<div class="spec-group mb-6">
<div class="spec-label mb-3">
<span class="text-sm font-medium text-gray-700">颜色分类</span>
<span v-if="selectedSpecs.color" class="ml-2 text-sm text-gray-500">
已选{{ selectedSpecs.color }}
</span>
</div>
<div class="spec-options flex flex-wrap gap-3">
<div
v-for="color in colors"
:key="color.name"
class="spec-option color-option relative cursor-pointer"
:class="{ 'selected': selectedSpecs.color === color.name }"
@click="selectedSpecs.color = color.name"
>
<img
:src="color.image"
:alt="color.name"
class="w-16 h-16 object-cover rounded border-2"
:class="{
'border-red-500': selectedSpecs.color === color.name,
'border-gray-200': selectedSpecs.color !== color.name
}"
>
<span class="block text-xs text-center mt-1">{{ color.name }}</span>
<div
v-if="selectedSpecs.color === color.name"
class="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center"
>
<el-icon class="text-white text-xs">
<Check/>
</el-icon>
</div>
</div>
</div>
</div>
<!-- 尺寸选择 -->
<div class="spec-group mb-6">
<div class="spec-label mb-3">
<span class="text-sm font-medium text-gray-700">尺码</span>
<span v-if="selectedSpecs.size" class="ml-2 text-sm text-gray-500">
已选{{ selectedSpecs.size }}
</span>
<el-button text type="primary" size="small" class="ml-2">尺码表</el-button>
</div>
<div class="spec-options flex flex-wrap gap-3">
<button
v-for="size in sizes"
:key="size.name"
class="size-option px-4 py-2 border rounded text-sm font-medium transition-all"
:class="{
'border-red-500 bg-red-50 text-red-600': selectedSpecs.size === size.name,
'border-gray-300 hover:border-red-300': selectedSpecs.size !== size.name,
'opacity-50 cursor-not-allowed': !size.available
}"
:disabled="!size.available"
@click="selectedSpecs.size = size.name"
>
{{ size.name }}
<span v-if="!size.available" class="block text-xs text-gray-400">缺货</span>
</button>
</div>
</div>
<!-- 数量选择 -->
<div class="spec-group mb-6">
<div class="spec-label mb-3">
<span class="text-sm font-medium text-gray-700">数量</span>
<span class="ml-2 text-sm text-gray-500">库存{{ stock }}</span>
</div>
<div class="quantity-selector flex items-center space-x-4">
<el-input-number
v-model="quantity"
:min="1"
:max="Math.min(stock, 99)"
size="large"
class="quantity-input"
/>
<span class="text-sm text-gray-500"></span>
</div>
</div>
</div>
<!-- 服务保障 -->
<div class="service-guarantee mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-3">服务保障</h3>
<div class="grid grid-cols-2 gap-4">
<div class="service-item flex items-center space-x-2">
<el-icon class="text-green-500">
<CircleCheckFilled/>
</el-icon>
<span class="text-sm">正品保证</span>
</div>
<div class="service-item flex items-center space-x-2">
<el-icon class="text-blue-500">
<Clock/>
</el-icon>
<span class="text-sm">快速发货</span>
</div>
<div class="service-item flex items-center space-x-2">
<el-icon class="text-orange-500">
<RefreshRight/>
</el-icon>
<span class="text-sm">7天无理由退货</span>
</div>
<div class="service-item flex items-center space-x-2">
<el-icon class="text-purple-500">
<Service/>
</el-icon>
<span class="text-sm">24小时客服</span>
</div>
</div>
</div>
</div>
<!-- 商品详情标签页 -->
<div class="bg-white rounded-lg">
<el-tabs
v-model="activeTab"
class="product-detail-tabs"
>
<el-tab-pane label="商品详情" name="detail"
:class="[
{ 'text-red-600 border-b-2 border-red-600': activeTab === 'detail' },
{ 'text-gray-600': activeTab !== 'detail' }
]"
>
<div class="detail-content p-6">
<div class="detail-images space-y-4">
<img
v-for="(image, index) in detailImages"
:key="index"
:src="image"
:alt="`详情图${index + 1}`"
class="w-full rounded-lg"
>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="规格参数" name="specs">
<div class="specs-content p-6">
<table class="w-full">
<tbody>
<tr v-for="spec in productSpecs" :key="spec.name" class="border-b">
<td class="py-3 pr-8 text-gray-600 font-medium">{{ spec.name }}</td>
<td class="py-3 text-gray-800">{{ spec.value }}</td>
</tr>
</tbody>
</table>
</div>
</el-tab-pane>
<el-tab-pane :label="`用户评价(${reviews.length})`" name="reviews">
<div class="reviews-content p-6">
<!-- 评价统计 -->
<div class="review-summary mb-6 p-4 bg-gray-50 rounded-lg">
<div class="flex items-center space-x-8">
<div class="text-center">
<div class="text-3xl font-bold text-red-600 mb-1">{{ averageRating.toFixed(1) }}</div>
<el-rate v-model="averageRating" disabled/>
<div class="text-sm text-gray-500 mt-1">综合评分</div>
</div>
<div class="flex-1">
<div class="rating-bars space-y-2">
<div v-for="i in 5" :key="i" class="flex items-center space-x-2">
<span class="text-sm w-8">{{ 6 - i }}</span>
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div
class="bg-red-500 h-2 rounded-full"
:style="{ width: `${getRatingPercentage(6 - i)}%` }"
></div>
</div>
<span class="text-sm text-gray-500 w-12">{{ getRatingCount(6 - i) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 评价列表 -->
<div class="review-list space-y-6">
<div
v-for="review in reviews"
:key="review.id"
class="review-item pb-6 border-b border-gray-100 last:border-b-0"
>
<div class="flex items-start space-x-4">
<el-avatar :src="review.avatar" :size="40"/>
<div class="flex-1">
<div class="flex items-center space-x-4 mb-2">
<span class="font-medium">{{ review.username }}</span>
<el-rate v-model="review.rating" disabled size="small"/>
<span class="text-sm text-gray-500">{{ review.date }}</span>
</div>
<p class="text-gray-700 mb-3">{{ review.content }}</p>
<div v-if="review.images" class="review-images flex space-x-2">
<img
v-for="(img, index) in review.images"
:key="index"
:src="img"
:alt="`评价图${index + 1}`"
class="w-20 h-20 object-cover rounded cursor-pointer"
@click="previewImage(img)"
>
</div>
<div class="review-specs mt-2 text-sm text-gray-500">
规格{{ review.specs }}
</div>
</div>
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-12">
<div class="text-6xl mb-4">😕</div>
<h2 class="text-xl font-semibold text-gray-800 mb-2">商品不存在</h2>
<p class="text-gray-600 mb-4">抱歉您访问的商品可能已下架或不存在</p>
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
</div>
</main>
</div>
</template>
<script setup>
import {ref, reactive, computed, onMounted, inject} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {
Loading,
Star,
StarFilled,
Share,
Check,
Clock,
RefreshRight,
CircleCheckFilled,
Service
} from '@element-plus/icons-vue'
import {ElMessage} from 'element-plus'
const route = useRoute()
const router = useRouter()
const globalState = inject('globalState')
const loading = ref(true)
const product = ref(null)
const currentImage = ref('')
const quantity = ref(1)
const activeTab = ref('detail')
const showMagnifier = ref(false)
const mainImageRef = ref(null)
// 放大镜相关
const maskStyle = ref({})
const magnifierStyle = ref({})
const selectedSpecs = reactive({
color: '',
size: ''
})
// 商品规格数据
const colors = [
{name: '经典黑', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=black'},
{name: '优雅白', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=white'},
{name: '时尚红', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=red'},
{name: '深海蓝', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=blue'}
]
const sizes = [
{name: 'S', available: true},
{name: 'M', available: true},
{name: 'L', available: true},
{name: 'XL', available: true},
{name: 'XXL', available: false}
]
const stock = ref(99)
const averageRating = ref(4.5)
const productSpecs = [
{name: '品牌', value: '时尚品牌'},
{name: '材质', value: '优质棉质'},
{name: '产地', value: '中国'},
{name: '适用季节', value: '四季'},
{name: '洗涤方式', value: '机洗'},
{name: '厚度', value: '适中'}
]
const reviews = [
{
id: 1,
username: '用户***1',
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=50&h=50&_=1',
rating: 5,
content: '质量很好,物流很快,非常满意!面料舒适,做工精细,值得推荐。',
date: '2023-12-01',
images: [
'https://cdn.seovx.com/ha/?mom=302&w=200&h=200&_=r1',
'https://cdn.seovx.com/ha/?mom=302&w=200&h=200&_=r2'
],
specs: '颜色:经典黑 尺码M'
},
{
id: 2,
username: '用户***2',
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=50&h=50&_=2',
rating: 4,
content: '款式不错,尺码合适,推荐购买。发货速度很快,包装也很好。',
date: '2023-11-28',
specs: '颜色:优雅白 尺码L'
},
{
id: 3,
username: '用户***3',
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=50&h=50&_=3',
rating: 5,
content: '超级喜欢这个颜色,质感很好,穿着很舒服,会回购的!',
date: '2023-11-25',
images: [
'https://cdn.seovx.com/ha/?mom=302&w=200&h=200&_=r3'
],
specs: '颜色:时尚红 尺码S'
}
]
const allImages = computed(() => {
if (!product.value) return []
return [product.value.cover, ...(product.value.images || [])]
})
const detailImages = computed(() => {
const baseImages = []
for (let i = 1; i <= 6; i++) {
baseImages.push(`https://cdn.seovx.com/ha/?mom=302&w=800&h=600&_=detail${i}`)
}
return baseImages
})
const isSpecsSelected = computed(() => {
return selectedSpecs.color && selectedSpecs.size
})
const isFavorited = computed(() => {
if (!globalState.currentUser || !product.value) return false
return globalState.favorites.some(fav => fav.id === product.value.id)
})
// 放大镜功能
const handleMouseMove = (event) => {
if (!showMagnifier.value || !mainImageRef.value) return
const rect = mainImageRef.value.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
const maskSize = 100
const scale = 2
// 计算遮罩位置
const maskX = Math.max(0, Math.min(x - maskSize / 2, rect.width - maskSize))
const maskY = Math.max(0, Math.min(y - maskSize / 2, rect.height - maskSize))
maskStyle.value = {
left: `${maskX}px`,
top: `${maskY}px`,
width: `${maskSize}px`,
height: `${maskSize}px`
}
// 计算放大图片位置
const bgX = -(maskX * scale)
const bgY = -(maskY * scale)
magnifierStyle.value = {
transform: `translate(${bgX}px, ${bgY}px) scale(${scale})`,
transformOrigin: 'top left'
}
}
const getRatingPercentage = (rating) => {
const count = reviews.filter(r => r.rating === rating).length
return (count / reviews.length) * 100
}
const getRatingCount = (rating) => {
return reviews.filter(r => r.rating === rating).length
}
const toggleFavorite = () => {
if (!globalState.currentUser) {
ElMessage.warning('请先登录')
return
}
const index = globalState.favorites.findIndex(fav => fav.id === product.value.id)
if (index > -1) {
globalState.favorites.splice(index, 1)
ElMessage.success('已取消收藏')
} else {
globalState.favorites.push({
id: product.value.id,
title: product.value.title,
price: product.value.price,
cover: product.value.cover,
addTime: new Date().toISOString()
})
ElMessage.success('已加入收藏')
}
localStorage.setItem('favorites', JSON.stringify(globalState.favorites))
}
const handleAddToCart = () => {
if (!isSpecsSelected.value) {
ElMessage.warning('请选择商品规格')
return
}
const cartItem = {
productId: product.value.id,
title: product.value.title,
price: parseFloat(product.value.price),
cover: product.value.cover,
color: selectedSpecs.color,
size: selectedSpecs.size,
quantity: quantity.value
}
const existingItem = globalState.cartItems.find(item =>
item.productId === cartItem.productId &&
item.color === cartItem.color &&
item.size === cartItem.size
)
if (existingItem) {
existingItem.quantity += cartItem.quantity
} else {
globalState.cartItems.push(cartItem)
}
localStorage.setItem('cart', JSON.stringify(globalState.cartItems))
ElMessage.success('已加入购物车')
}
const handleBuyNow = () => {
if (!isSpecsSelected.value) {
ElMessage.warning('请选择商品规格')
return
}
const totalAmount = (parseFloat(product.value.price) * quantity.value).toFixed(2)
globalState.showPaymentModal(totalAmount)
}
const previewImage = (image) => {
// 图片预览功能
ElMessage.info('图片预览功能开发中...')
}
const loadProduct = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500))
const productId = route.params.id
// 生成模拟商品数据
product.value = {
id: productId,
title: `高品质时尚单品 ${productId} - 精选优质材料制作,舒适透气,四季适穿`,
price: (Math.random() * 200 + 50).toFixed(2),
cover: `https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}`,
images: [
`https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}1`,
`https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}2`,
`https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}3`,
`https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}4`
],
desc: '这是一款优质的时尚单品,采用精选材料制作,工艺精良,品质保证。',
sold: Math.floor(Math.random() * 9999) + 100,
brand: '时尚品牌',
rating: Math.random() * 1 + 4,
reviewCount: Math.floor(Math.random() * 500) + 50
}
currentImage.value = product.value.cover
// 添加到浏览历史
if (globalState.currentUser) {
const historyItem = {
id: product.value.id,
title: product.value.title,
price: product.value.price,
cover: product.value.cover,
viewTime: new Date().toISOString()
}
let history = JSON.parse(localStorage.getItem('browsingHistory') || '[]')
history = history.filter(item => item.id !== product.value.id)
history.unshift(historyItem)
history = history.slice(0, 20) // 只保留最近20条
localStorage.setItem('browsingHistory', JSON.stringify(history))
}
} catch (error) {
console.error('加载商品失败:', error)
product.value = null
} finally {
loading.value = false
}
}
onMounted(() => {
loadProduct()
})
</script>
<style lang="scss" scoped>
.main-image-wrapper {
position: relative;
}
.magnifier-mask {
width: 100px;
height: 100px;
}
.magnifier-viewer {
z-index: 1000;
}
.magnifier-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-item:hover {
transform: scale(1.05);
}
.spec-option.selected {
transform: scale(1.05);
}
.color-option {
transition: all 0.2s;
}
.color-option:hover {
transform: translateY(-2px);
}
.size-option:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.quantity-input :deep(.el-input-number) {
width: 120px;
}
.product-detail-tabs :deep(.el-tabs__header) {
margin: 0;
padding: 0 24px;
background: #f8f9fa;
}
.product-detail-tabs :deep(.el-tabs__nav-wrap) {
padding: 16px 0;
}
.review-item:hover {
background: #fafafa;
border-radius: 8px;
padding: 16px;
margin: -16px;
}
.rating-bars {
min-width: 200px;
}
@media (max-width: 1024px) {
.magnifier-viewer {
display: none;
}
}
</style>

View File

@@ -0,0 +1,922 @@
<template>
<div class="product-detail-page bg-gray-50 min-h-screen">
<main class="container mx-auto px-4 py-6">
<div v-if="loading" class="flex justify-center items-center h-96">
<el-icon class="is-loading text-4xl text-gray-400">
<Loading />
</el-icon>
</div>
<div v-else-if="product" class="max-w-7xl mx-auto">
<!-- 面包屑导航 -->
<el-breadcrumb separator="/" class="mb-6">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: '/products' }">商品列表</el-breadcrumb-item>
<el-breadcrumb-item>{{ product.title }}</el-breadcrumb-item>
</el-breadcrumb>
<div class="grid grid-cols-12 gap-8">
<!-- 商品图片区域 -->
<div class="col-span-12 lg:col-span-5">
<div class="bg-white rounded-lg p-6 sticky top-40">
<!-- 主图展示 -->
<div class="main-image-container relative mb-4">
<div
class="main-image-wrapper relative overflow-hidden rounded-lg"
@mousemove="handleMouseMove"
@mouseleave="showMagnifier = false"
@mouseenter="showMagnifier = true"
>
<img
ref="mainImageRef"
:src="currentImage"
:alt="product.title"
class="w-full h-96 object-cover cursor-crosshair"
>
<!-- 放大镜遮罩 -->
<div
v-show="showMagnifier"
class="magnifier-mask absolute border-2 border-red-500 bg-opacity-20 pointer-events-none"
:style="maskStyle"
></div>
</div>
</div>
<!-- 缩略图列表 -->
<div class="thumbnail-list">
<div class="flex space-x-2 overflow-x-auto pb-2">
<img
v-for="(image, index) in allImages"
:key="index"
:src="image"
:alt="`商品图片${index + 1}`"
class="thumbnail-item w-16 h-16 object-cover rounded cursor-pointer border-2 transition-all"
:class="{
'border-red-500 ring-2 ring-red-200': currentImage === image,
'border-gray-200 hover:border-red-300': currentImage !== image
}"
@click="currentImage = image"
>
</div>
</div>
<!-- 商品操作按钮 -->
<div class="product-actions mt-6 space-y-3">
<div class="flex space-x-3">
<el-button
type="danger"
size="large"
class="flex-1"
:disabled="!isSpecsSelected"
@click="handleBuyNow"
>
立即购买
</el-button>
<el-button
size="large"
class="flex-1"
:disabled="!isSpecsSelected"
@click="handleAddToCart"
>
加入购物车
</el-button>
</div>
<div class="flex space-x-3">
<el-button
class="flex-1"
@click="toggleFavorite"
:type="isFavorited ? 'danger' : 'default'"
>
<el-icon class="mr-1">
<Star v-if="isFavorited" />
<StarFilled v-else />
</el-icon>
{{ isFavorited ? '已收藏' : '收藏商品' }}
</el-button>
<el-button class="flex-1">
<el-icon class="mr-1">
<Share />
</el-icon>
分享
</el-button>
</div>
</div>
</div>
</div>
<!-- 商品信息区域 -->
<div class="col-span-12 lg:col-span-7">
<div class="bg-white rounded-lg p-6 mb-6">
<!-- 商品标题和价格 -->
<div class="product-header mb-6">
<h1 class="text-2xl font-bold text-gray-800 mb-4 leading-relaxed">{{ product.title }}</h1>
<div class="price-section mb-4">
<div class="flex items-baseline space-x-4 mb-2">
<span class="text-4xl font-bold text-red-600">¥{{ product.price }}</span>
<span class="text-lg text-gray-400 line-through">¥{{
(parseFloat(product.price) * 1.3).toFixed(2)
}}</span>
</div>
<div class="flex items-center space-x-6 text-sm text-gray-600">
<span>已售 {{ product.sold }} </span>
<div class="flex items-center">
<el-rate v-model="product.rating" disabled size="small" />
<span class="ml-1">({{ product.reviewCount || 0 }}条评价)</span>
</div>
</div>
</div>
<!-- 优惠信息 -->
<el-card class="mb-4" shadow="never" body-style="padding: 16px;">
<template #header>
<span class="font-semibold">优惠活动</span>
</template>
<div class="space-y-2">
<el-tag type="danger" size="small" class="mr-2">满减</el-tag>
<span class="text-sm">¥200¥30</span>
<br>
<el-tag type="warning" size="small" class="mr-2">赠品</el-tag>
<span class="text-sm">购买即送精美礼品</span>
</div>
</el-card>
<!-- 促销信息 -->
<div class="promotion-tags flex flex-wrap gap-2 mb-4">
<el-tag type="danger" size="small">限时特价</el-tag>
<el-tag type="warning" size="small">满200减30</el-tag>
<el-tag type="success" size="small">包邮</el-tag>
<el-tag type="info" size="small">7天无理由退货</el-tag>
</div>
</div>
<!-- 商品规格选择 -->
<div class="product-specs mb-8">
<!-- 颜色选择 -->
<div class="spec-group mb-6">
<div class="spec-label mb-3">
<span class="text-sm font-medium text-gray-700">颜色分类</span>
<span v-if="selectedSpecs.color" class="ml-2 text-sm text-gray-500">
已选{{ selectedSpecs.color }}
</span>
</div>
<div class="spec-options flex flex-wrap gap-3">
<div
v-for="color in colors"
:key="color.name"
class="spec-option color-option relative cursor-pointer"
:class="{ 'selected': selectedSpecs.color === color.name }"
@click="selectedSpecs.color = color.name"
>
<img
:src="color.image"
:alt="color.name"
class="w-16 h-16 object-cover rounded border-2"
:class="{
'border-red-500': selectedSpecs.color === color.name,
'border-gray-200': selectedSpecs.color !== color.name
}"
>
<span class="block text-xs text-center mt-1">{{ color.name }}</span>
<div
v-if="selectedSpecs.color === color.name"
class="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center"
>
<el-icon class="text-white text-xs">
<Check />
</el-icon>
</div>
</div>
</div>
</div>
<!-- 尺寸选择 -->
<div class="spec-group mb-6">
<div class="spec-label mb-3">
<span class="text-sm font-medium text-gray-700">尺码</span>
<span v-if="selectedSpecs.size" class="ml-2 text-sm text-gray-500">
已选{{ selectedSpecs.size }}
</span>
<el-button text type="primary" size="small" class="ml-2">尺码表</el-button>
</div>
<div class="spec-options flex flex-wrap gap-3">
<button
v-for="size in sizes"
:key="size.name"
class="size-option px-4 py-2 border rounded text-sm font-medium transition-all cursor-pointer"
:class="{
'border-red-500 bg-red-50 text-red-600': selectedSpecs.size === size.name,
'border-gray-300 hover:border-red-300': selectedSpecs.size !== size.name,
'opacity-50 cursor-not-allowed': !size.available
}"
:disabled="!size.available"
@click="selectedSpecs.size = size.name"
>
{{ size.name }}
<span v-if="!size.available" class="block text-xs text-gray-400">缺货</span>
</button>
</div>
</div>
<!-- 数量选择 -->
<div class="spec-group mb-6">
<div class="spec-label mb-3">
<span class="text-sm font-medium text-gray-700">数量</span>
<span class="ml-2 text-sm text-gray-500">库存{{ stock }}</span>
</div>
<div class="quantity-selector flex items-center space-x-4">
<el-input-number
v-model="quantity"
:min="1"
:max="Math.min(stock, 99)"
size="large"
class="quantity-input"
/>
<span class="text-sm text-gray-500"></span>
</div>
</div>
</div>
<!-- 服务保障 -->
<div class="service-guarantee mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-3">服务保障</h3>
<div class="grid grid-cols-2 gap-4">
<div class="service-item flex items-center space-x-2">
<el-icon class="text-green-500">
<CircleCheckFilled />
</el-icon>
<span class="text-sm">正品保证</span>
</div>
<div class="service-item flex items-center space-x-2">
<el-icon class="text-blue-500">
<Clock />
</el-icon>
<span class="text-sm">快速发货</span>
</div>
<div class="service-item flex items-center space-x-2">
<el-icon class="text-orange-500">
<RefreshRight />
</el-icon>
<span class="text-sm">7天无理由退货</span>
</div>
<div class="service-item flex items-center space-x-2">
<el-icon class="text-purple-500">
<Service />
</el-icon>
<span class="text-sm">24小时客服</span>
</div>
</div>
</div>
</div>
<!-- 商品详情标签页 -->
<div class="bg-white rounded-lg">
<el-tabs
v-model="activeTab"
class="product-detail-tabs"
>
<el-tab-pane label="商品详情" name="detail"
:class="[
{ 'text-red-600 border-b-2 border-red-600': activeTab === 'detail' },
{ 'text-gray-600': activeTab !== 'detail' }
]"
>
<div class="detail-content p-6">
<div class="detail-images space-y-4">
<!-- <img-->
<!-- v-for="(image, index) in detailImages"-->
<!-- :key="index"-->
<!-- :src="image"-->
<!-- :alt="`详情图${index + 1}`"-->
<!-- class="w-full rounded-lg"-->
<!-- >-->
<img
src="/public/demo-img/2.jpg"
:alt="`详情图${index + 1}`"
class="w-full rounded-lg"
>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="规格参数" name="specs">
<div class="specs-content p-6 bg-white rounded-lg shadow-sm">
<!-- 标题区域 -->
<div class="mb-6">
<h3 class="text-xl font-semibold text-gray-800 mb-2">产品规格参数</h3>
<p class="text-gray-500 text-sm">详细了解产品的技术参数和性能指标</p>
</div>
<!-- 规格参数表格 -->
<div class="overflow-x-auto">
<table class="w-full">
<tbody>
<tr
v-for="spec in productSpecs"
:key="spec.name"
class="border-b border-gray-200 hover:bg-gray-50 transition-colors"
>
<td class="py-4 pr-8 text-gray-600 font-medium min-w-[120px]">
{{ spec.name }}
</td>
<td class="py-4 text-gray-800">
<!-- 根据参数类型显示不同样式 -->
<span
v-if="spec.type === 'number'"
class="font-mono"
>{{ spec.value }}</span>
<span
v-else-if="spec.type === 'status'"
:class="{
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium': true,
'bg-green-100 text-green-800': spec.value === '支持',
'bg-red-100 text-red-800': spec.value === '不支持',
'bg-blue-100 text-blue-800': spec.value === '可选'
}"
>{{ spec.value }}</span>
<span v-else>{{ spec.value }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 无数据状态 -->
<div
v-if="productSpecs.length === 0"
class="py-12 text-center"
>
<div class="flex flex-col items-center">
<el-empty description="暂无规格参数数据" />
<el-button
type="primary"
size="small"
class="mt-4"
@click="fetchProductSpecs"
>
刷新数据
</el-button>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane :label="`用户评价(${reviews.length})`" name="reviews">
<div class="reviews-content p-6">
<!-- 评价统计 -->
<div class="review-summary mb-6 p-4 bg-gray-50 rounded-lg">
<div class="flex items-center space-x-8">
<div class="text-center">
<div class="text-3xl font-bold text-red-600 mb-1">{{ averageRating.toFixed(1) }}</div>
<el-rate v-model="averageRating" disabled />
<div class="text-sm text-gray-500 mt-1">综合评分</div>
</div>
<div class="flex-1">
<div class="rating-bars space-y-2">
<div v-for="i in 5" :key="i" class="flex items-center space-x-2">
<span class="text-sm w-8">{{ 6 - i }}</span>
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div
class="bg-red-500 h-2 rounded-full"
:style="{ width: `${getRatingPercentage(6 - i)}%` }"
></div>
</div>
<span class="text-sm text-gray-500 w-12">{{ getRatingCount(6 - i) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 评价列表 -->
<div class="review-list space-y-6">
<div
v-for="review in reviews"
:key="review.id"
class="review-item pb-6 border-b border-gray-100 last:border-b-0 transition-all duration-300 hover:bg-gray-50 p-4 rounded-lg"
>
<div class="flex items-start space-x-4">
<el-avatar :src="review.avatar" :size="40" />
<div class="flex-1">
<div class="flex items-center space-x-4 mb-2">
<span class="font-medium">{{ review.username }}</span>
<el-rate v-model="review.rating" disabled size="small" />
<span class="text-sm text-gray-500">{{ review.date }}</span>
</div>
<p class="text-gray-700 mb-3">{{ review.content }}</p>
<div v-if="review.images" class="review-images flex space-x-2 flex-wrap">
<img
v-for="(img, index) in review.images"
:key="index"
:src="img"
:alt="`评价图${index + 1}`"
class="w-20 h-20 object-cover rounded cursor-pointer transition-transform duration-300 hover:scale-105"
@click="previewImage(img)"
>
</div>
<div class="review-specs mt-2 text-sm text-gray-500">
规格{{ review.specs }}
</div>
<!-- 回复区域 -->
<div v-if="review.replies && review.replies.length > 0" class="mt-4 pl-4 border-l-2 border-gray-200 space-y-3">
<div v-for="(reply, replyIndex) in review.replies" :key="replyIndex" class="bg-gray-50 p-3 rounded">
<div class="flex items-center justify-between mb-1">
<span class="font-medium text-gray-800">{{ reply.username }}</span>
<span class="text-xs text-gray-500">{{ reply.date }}</span>
</div>
<p class="text-sm text-gray-700">{{ reply.content }}</p>
</div>
</div>
<!-- 回复表单 -->
<div v-if="review.showReplyForm" class="mt-4">
<el-input
v-model="review.replyContent"
type="textarea"
:rows="2"
placeholder="写下你的回复..."
></el-input>
<div class="flex justify-end mt-2">
<el-button size="small" @click="review.showReplyForm = false">取消</el-button>
<el-button size="small" type="primary" @click="replyToReview(review)">回复</el-button>
</div>
</div>
<!-- 回复按钮 -->
<div class="mt-3">
<el-button
size="small"
type="text"
@click="toggleReplyForm(review)"
class="text-primary hover:text-primary/80 transition-colors"
>
回复
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-12">
<div class="text-6xl mb-4">😕</div>
<h2 class="text-xl font-semibold text-gray-800 mb-2">商品不存在</h2>
<p class="text-gray-600 mb-4">抱歉您访问的商品可能已下架或不存在</p>
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
</div>
</main>
<!-- 放大镜显示区域 -->
<div
v-show="showMagnifier"
class="magnifier-viewer fixed border border-gray-300 rounded-lg overflow-hidden bg-white shadow-xl z-50"
:style="{
left: magnifierLeft + 'px',
top: '200px',
width: '450px',
height: '450px',
backgroundImage: 'url(' + currentImage + ')',
backgroundSize: zoomLevel + '%',
backgroundPosition: backgroundPosition
}"
></div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, inject, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Loading,
Star,
StarFilled,
Share,
Check,
Clock,
RefreshRight,
CircleCheckFilled,
Service
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const globalState = inject('globalState')
const loading = ref(true)
const product = ref(null)
const currentImage = ref('')
const quantity = ref(1)
const activeTab = ref('detail')
const showMagnifier = ref(false)
const mainImageRef = ref(null)
// 放大镜相关
const maskStyle = ref({})
const backgroundPosition = ref('0% 0%')
const zoomLevel = ref(250)
const magnifierLeft = ref(0)
const magnifierTop = ref(0)
const selectedSpecs = reactive({
color: '',
size: ''
})
// 商品规格数据
const colors = [
{ name: '经典黑', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=black' },
{ name: '优雅白', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=white' },
{ name: '时尚红', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=red' },
{ name: '深海蓝', image: 'https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=blue' }
]
const sizes = [
{ name: 'S', available: true },
{ name: 'M', available: true },
{ name: 'L', available: true },
{ name: 'XL', available: true },
{ name: 'XXL', available: false }
]
const stock = ref(99)
const averageRating = ref(4.5)
const productSpecs = [
{ name: '品牌', value: '时尚品牌' },
{ name: '材质', value: '优质棉质' },
{ name: '产地', value: '中国' },
{ name: '适用季节', value: '四季' },
{ name: '洗涤方式', value: '机洗' },
{ name: '厚度', value: '适中' }
]
const reviews = [
{
id: 1,
username: '用户***1',
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=50&h=50&_=1',
rating: 5,
date: '2023-05-15',
content: '这款产品质量非常好,穿着很舒适,非常满意这次购物体验。',
images: [
'https://cdn.seovx.com/ha/?mom=302&w=200&h=200&_=review1',
'https://cdn.seovx.com/ha/?mom=302&w=200&h=200&_=review2'
],
specs: '经典黑/M'
},
{
id: 2,
username: '用户***2',
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=50&h=50&_=2',
rating: 4,
date: '2023-05-10',
content: '款式很喜欢,尺码也合适,就是物流稍微慢了点。',
specs: '时尚红/L'
},
{
id: 3,
username: '用户***3',
avatar: 'https://cdn.seovx.com/ha/?mom=302&w=50&h=50&_=3',
rating: 5,
date: '2023-05-05',
content: '已经是第二次购买了,质量一如既往的好,客服态度也很棒!',
images: [
'https://cdn.seovx.com/ha/?mom=302&w=200&h=200&_=review3'
],
specs: '深海蓝/XL'
}
]
const allImages = computed(() => {
if (!product.value) return []
return [product.value.cover, ...(product.value.images || [])]
})
const detailImages = computed(() => {
const baseImages = []
for (let i = 1; i <= 6; i++) {
baseImages.push(`https://cdn.seovx.com/ha/?mom=302&w=800&h=600&_=detail${i}`)
}
return baseImages
})
const currentPrice = computed(() => {
if (!product.value) return '0.00'
const basePrice = parseFloat(product.value.price)
const colorPrice = selectedSpecs.color ? 5 : 0
const sizePrice = selectedSpecs.size ? 10 : 0
return (basePrice + colorPrice + sizePrice).toFixed(2)
})
const isSpecsSelected = computed(() => {
return selectedSpecs.color && selectedSpecs.size
})
const isFavorited = computed(() => {
if (!globalState.currentUser || !product.value) return false
return globalState.favorites.some(fav => fav.id === product.value.id)
})
// 处理窗口大小变化和滚动事件
const updateMagnifierPosition = () => {
if (!mainImageRef.value) return
const rect = mainImageRef.value.getBoundingClientRect()
const containerPadding = 20 // 大图容器的内边距
const gap = 20 // 放大镜与大图之间的间距
// 计算放大镜的左侧位置:大图右侧 + 间距
magnifierLeft.value = rect.right + window.scrollX + gap
// 计算放大镜的顶部位置:与大图顶部对齐
// magnifierTop.value = rect.top + window.scrollY
}
// 放大镜功能
const handleMouseMove = (event) => {
if (!showMagnifier.value || !mainImageRef.value) return
const rect = mainImageRef.value.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
const maskSize = 120
// 计算遮罩位置
const maskX = Math.max(0, Math.min(x - maskSize / 2, rect.width - maskSize))
const maskY = Math.max(0, Math.min(y - maskSize / 2, rect.height - maskSize))
maskStyle.value = {
left: `${maskX}px`,
top: `${maskY}px`,
width: `${maskSize}px`,
height: `${maskSize}px`
}
// 计算背景图片位置
const xPercent = (x / rect.width) * 100
const yPercent = (y / rect.height) * 100
backgroundPosition.value = `${xPercent}% ${yPercent}%`
}
const getRatingPercentage = (rating) => {
const count = reviews.filter(r => r.rating === rating).length
return (count / reviews.length) * 100
}
const getRatingCount = (rating) => {
return reviews.filter(r => r.rating === rating).length
}
const toggleFavorite = () => {
if (!globalState.currentUser) {
ElMessage.warning('请先登录')
return
}
const index = globalState.favorites.findIndex(fav => fav.id === product.value.id)
if (index > -1) {
globalState.favorites.splice(index, 1)
ElMessage.success('已取消收藏')
} else {
globalState.favorites.push({
id: product.value.id,
title: product.value.title,
price: product.value.price,
cover: product.value.cover,
addTime: new Date().toISOString()
})
ElMessage.success('已加入收藏')
}
localStorage.setItem('favorites', JSON.stringify(globalState.favorites))
}
const handleAddToCart = () => {
if (!isSpecsSelected.value) {
ElMessage.warning('请选择商品规格')
return
}
const cartItem = {
productId: product.value.id,
title: product.value.title,
price: parseFloat(currentPrice.value),
cover: product.value.cover,
color: selectedSpecs.color,
size: selectedSpecs.size,
quantity: quantity.value
}
const existingItem = globalState.cartItems.find(item =>
item.productId === cartItem.productId &&
item.color === cartItem.color &&
item.size === cartItem.size
)
if (existingItem) {
existingItem.quantity += cartItem.quantity
} else {
globalState.cartItems.push(cartItem)
}
localStorage.setItem('cart', JSON.stringify(globalState.cartItems))
ElMessage.success('已加入购物车')
}
const handleBuyNow = () => {
if (!isSpecsSelected.value) {
ElMessage.warning('请选择商品规格')
return
}
const totalAmount = (parseFloat(currentPrice.value) * quantity.value).toFixed(2)
globalState.showPaymentModal(totalAmount)
}
const previewImage = (imageUrl) => {
// 图片预览功能实现
console.log('预览图片:', imageUrl)
// 可以使用 Element Plus 的 Dialog 组件实现图片预览
}
const loadProduct = async () => {
loading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 500))
const productId = route.params.id
// 生成模拟商品数据
product.value = {
id: productId,
title: `高品质时尚单品 ${productId} - 精选优质材料制作,舒适透气,四季适穿`,
price: (Math.random() * 200 + 50).toFixed(2),
cover: `https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}`,
images: [
`https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}1`,
`https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}2`,
`https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}3`,
`https://cdn.seovx.com/ha/?mom=302&w=600&h=600&_=${productId}4`
],
desc: '这是一款优质的时尚单品,采用精选材料制作,工艺精良,品质保证。',
sold: Math.floor(Math.random() * 9999) + 100,
brand: '时尚品牌',
rating: Math.random() * 1 + 4,
reviewCount: Math.floor(Math.random() * 500) + 50
}
currentImage.value = product.value.cover
// 添加到浏览历史
if (globalState.currentUser) {
const historyItem = {
id: product.value.id,
title: product.value.title,
price: product.value.price,
cover: product.value.cover,
viewTime: new Date().toISOString()
}
let history = JSON.parse(localStorage.getItem('browsingHistory') || '[]')
history = history.filter(item => item.id !== product.value.id)
history.unshift(historyItem)
history = history.slice(0, 20)
localStorage.setItem('browsingHistory', JSON.stringify(history))
}
} catch (error) {
console.error('加载商品失败:', error)
product.value = null
} finally {
loading.value = false
}
}
onMounted(() => {
loadProduct()
// 初始化放大镜位置
updateMagnifierPosition()
// 监听窗口大小变化和滚动事件
window.addEventListener('resize', updateMagnifierPosition)
window.addEventListener('scroll', updateMagnifierPosition)
})
onUnmounted(() => {
// 移除事件监听器,防止内存泄漏
window.removeEventListener('resize', updateMagnifierPosition)
window.removeEventListener('scroll', updateMagnifierPosition)
})
</script>
<style lang="scss" scoped>
/* 使用更具体的选择器 */
/* Vue 3 写法 */
::v-deep .product-detail-tabs .el-tabs__item {
color: #dc2626 !important;
}
::v-deep .product-detail-tabs .el-tabs__item.is-active {
color: #dc2626 !important;
font-weight: bold;
}
::v-deep .product-detail-tabs .el-tabs__active-bar {
background-color: #dc2626 !important;
height: 2px;
}
.main-image-wrapper {
position: relative;
}
.magnifier-mask {
position: absolute;
width: 120px;
height: 120px;
background: rgba(255, 255, 255, 0.2);
border: 3px solid #fff;
box-shadow: 0 0 15px rgba(0,0,0,0.3), inset 0 0 15px rgba(0,0,0,0.1);
pointer-events: none;
cursor: crosshair;
z-index: 5;
border-radius: 50%;
transform: scale(0.8);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.magnifier-viewer {
z-index: 1000;
}
.magnifier-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-item:hover {
transform: scale(1.05);
}
.spec-option.selected {
transform: scale(1.05);
}
.color-option {
transition: all 0.2s;
}
.color-option:hover {
transform: translateY(-2px);
}
.size-option:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.quantity-input :deep(.el-input-number) {
width: 120px;
}
.product-detail-tabs :deep(.el-tabs__header) {
margin: 0;
padding: 0 24px;
background: #f8f9fa;
}
.product-detail-tabs :deep(.el-tabs__nav-wrap) {
padding: 16px 0;
}
.review-item:hover {
background: #fafafa;
border-radius: 8px;
padding: 16px;
margin: -16px;
}
.rating-bars {
min-width: 200px;
}
@media (max-width: 1024px) {
.magnifier-viewer {
display: none;
}
}
</style>

View File

@@ -0,0 +1,414 @@
<template>
<div class="product-list-page bg-gray-100 min-h-screen">
<main class="container mx-auto px-4 py-6">
<!-- <EFBFBD><EFBFBD><EFBFBD>屑导航 -->
<el-breadcrumb separator="/" class="mb-6">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>商品列表</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentCategory">{{ currentCategory }}</el-breadcrumb-item>
</el-breadcrumb>
<div class="grid grid-cols-12 gap-6">
<!-- 侧边筛选 -->
<div class="col-span-3 hidden lg:block">
<div class="bg-white rounded-lg p-4 sticky top-20">
<h3 class="font-bold mb-4">筛选条件</h3>
<!-- 分类筛选 -->
<div class="filter-section mb-6">
<h4 class="font-medium mb-3">商品分类</h4>
<div class="space-y-2">
<label
v-for="category in categories"
:key="category.id"
class="flex items-center cursor-pointer hover:text-red-600"
>
<el-checkbox
v-model="filters.categories"
:label="category.id"
class="mr-2"
/>
{{ category.name }}
</label>
</div>
</div>
<!-- 价格筛选 -->
<div class="filter-section mb-6">
<h4 class="font-medium mb-3">价格区间</h4>
<div class="space-y-2">
<label
v-for="priceRange in priceRanges"
:key="priceRange.id"
class="flex items-center cursor-pointer hover:text-red-600"
>
<el-radio
v-model="filters.priceRange"
:label="priceRange.id"
class="mr-2"
/>
{{ priceRange.label }}
</label>
</div>
<!-- 自定义价格 -->
<div class="mt-3 flex space-x-2">
<el-input
v-model="customPrice.min"
placeholder="最低价"
size="small"
type="number"
/>
<span class="self-center">-</span>
<el-input
v-model="customPrice.max"
placeholder="最高价"
size="small"
type="number"
/>
</div>
</div>
<!-- 品牌筛选 -->
<div class="filter-section mb-6">
<h4 class="font-medium mb-3">品牌</h4>
<div class="space-y-2">
<label
v-for="brand in brands"
:key="brand"
class="flex items-center cursor-pointer hover:text-red-600"
>
<el-checkbox
v-model="filters.brands"
:label="brand"
class="mr-2"
/>
{{ brand }}
</label>
</div>
</div>
<!-- 重置按钮 -->
<el-button class="w-full" @click="resetFilters">
重置筛选
</el-button>
</div>
</div>
<!-- 商品列表 -->
<div class="col-span-12 lg:col-span-9">
<!-- 排序和视图切换 -->
<div class="bg-white rounded-lg p-4 mb-4 flex items-center justify-between">
<div class="flex items-center space-x-4">
<span class="text-gray-600">排序</span>
<el-radio-group v-model="sortBy" size="small">
<el-radio-button label="default">默认</el-radio-button>
<el-radio-button label="price-asc">价格</el-radio-button>
<el-radio-button label="price-desc">价格</el-radio-button>
<el-radio-button label="sales">销量</el-radio-button>
<el-radio-button label="rating">评分</el-radio-button>
</el-radio-group>
</div>
<div class="flex items-center space-x-2">
<span class="text-gray-600">视图</span>
<el-radio-group v-model="viewMode" size="small">
<el-radio-button label="grid">
<el-icon><Grid /></el-icon>
</el-radio-button>
<el-radio-button label="list">
<el-icon><List /></el-icon>
</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 商品网格/列表 -->
<div v-loading="loading" class="min-h-[400px]">
<!-- 网格视图 -->
<div
v-if="viewMode === 'grid'"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
>
<ProductCard
v-for="product in filteredProducts"
:key="product.id"
:product="product"
@click="handleProductClick(product)"
/>
</div>
<!-- 列表视图 -->
<div v-else class="space-y-4">
<div
v-for="product in filteredProducts"
:key="product.id"
class="bg-white rounded-lg p-4 flex space-x-4 hover:shadow-lg transition cursor-pointer"
@click="handleProductClick(product)"
>
<img
:src="product.cover"
:alt="product.title"
class="w-32 h-32 object-cover rounded"
>
<div class="flex-1">
<h3 class="font-medium mb-2 line-clamp-2">{{ product.title }}</h3>
<p class="text-gray-600 text-sm mb-2 line-clamp-2">{{ product.desc }}</p>
<div class="flex items-center justify-between">
<div>
<span class="text-red-600 font-bold text-xl">¥{{ product.price }}</span>
<span class="text-gray-500 text-sm ml-2">已售{{ product.sold }}</span>
</div>
<div class="flex items-center">
<el-rate v-model="product.rating" disabled size="small" />
<span class="text-sm text-gray-500 ml-1">({{ product.reviewCount || 0 }})</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="mt-8 flex justify-center">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[12, 24, 48, 96]"
:total="totalProducts"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, inject, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Grid, List } from '@element-plus/icons-vue'
import ProductCard from '../../components/ProductCard.vue'
const route = useRoute()
const router = useRouter()
const globalState = inject('globalState')
const loading = ref(false)
const viewMode = ref('grid')
const sortBy = ref('default')
const currentPage = ref(1)
const pageSize = ref(24)
const totalProducts = ref(0)
const filters = reactive({
categories: [],
brands: [],
priceRange: ''
})
const customPrice = reactive({
min: '',
max: ''
})
const allProducts = ref([])
const currentCategory = ref('')
const categories = [
{ id: 'women', name: '女装' },
{ id: 'men', name: '男装' },
{ id: 'electronics', name: '数码' },
{ id: 'beauty', name: '美妆' },
{ id: 'home', name: '家居' }
]
const brands = ['时尚品牌', '优质品牌', '知名品牌', '国际品牌', '其他品牌']
const priceRanges = [
{ id: '0-50', label: '¥0-50' },
{ id: '50-100', label: '¥50-100' },
{ id: '100-200', label: '¥100-200' },
{ id: '200-500', label: '¥200-500' },
{ id: '500+', label: '¥500以上' }
]
// 筛选后的商品
const filteredProducts = computed(() => {
let products = [...allProducts.value]
// 分类筛选
if (filters.categories.length > 0) {
products = products.filter(product =>
filters.categories.includes(product.category)
)
}
// 品牌筛选
if (filters.brands.length > 0) {
products = products.filter(product =>
filters.brands.includes(product.brand)
)
}
// 价格筛选
if (filters.priceRange) {
products = products.filter(product => {
const price = parseFloat(product.price)
switch (filters.priceRange) {
case '0-50': return price <= 50
case '50-100': return price > 50 && price <= 100
case '100-200': return price > 100 && price <= 200
case '200-500': return price > 200 && price <= 500
case '500+': return price > 500
default: return true
}
})
}
// 自定义价格筛选
if (customPrice.min || customPrice.max) {
products = products.filter(product => {
const price = parseFloat(product.price)
const min = parseFloat(customPrice.min) || 0
const max = parseFloat(customPrice.max) || Infinity
return price >= min && price <= max
})
}
// 排序
switch (sortBy.value) {
case 'price-asc':
products.sort((a, b) => parseFloat(a.price) - parseFloat(b.price))
break
case 'price-desc':
products.sort((a, b) => parseFloat(b.price) - parseFloat(a.price))
break
case 'sales':
products.sort((a, b) => b.sold - a.sold)
break
case 'rating':
products.sort((a, b) => (b.rating || 4.5) - (a.rating || 4.5))
break
}
totalProducts.value = products.length
// 分页
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return products.slice(start, end)
})
// 生成商品数据
const generateProducts = (count = 100) => {
const products = []
for (let i = 0; i < count; i++) {
const basePrice = Math.floor(Math.random() * 500) + 20
const category = categories[Math.floor(Math.random() * categories.length)]
const brand = brands[Math.floor(Math.random() * brands.length)]
products.push({
id: i + 1,
title: `${category.name}商品 ${i + 1} - 高品质时尚单品`,
price: basePrice.toFixed(2),
cover: `https://cdn.seovx.com/ha/?mom=302&w=400&h=400&_=${i}`,
images: [
`https://cdn.seovx.com/ha/?mom=302&w=800&h=800&_=${i}1`,
`https://cdn.seovx.com/ha/?mom=302&w=800&h=800&_=${i}2`
],
desc: `这是一款优质的${category.name}商品,采用精选材料制作,工艺精良,品质保证。`,
sold: Math.floor(Math.random() * 9999) + 10,
brand: brand,
category: category.id,
rating: Math.random() * 1 + 4,
reviewCount: Math.floor(Math.random() * 500) + 10
})
}
return products
}
const handleProductClick = (product) => {
globalState.showProductDetail(product)
}
const resetFilters = () => {
filters.categories = []
filters.brands = []
filters.priceRange = ''
customPrice.min = ''
customPrice.max = ''
currentPage.value = 1
}
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
}
const handleCurrentChange = (page) => {
currentPage.value = page
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// 监听路由变化
const currentSearch = ref('')
watch(() => route.query, (newQuery) => {
// 根据路由参数设置筛选条件
if (newQuery.category) {
const category = categories.find(cat => cat.id === newQuery.category)
if (category) {
currentCategory.value = category.name
filters.categories = [newQuery.category]
}
} else {
currentCategory.value = ''
filters.categories = []
}
if (newQuery.search && newQuery.search !== currentSearch.value) {
currentSearch.value = newQuery.search
// 处理搜索
const searchTerm = newQuery.search.toLowerCase()
allProducts.value = generateProducts().filter(product =>
product.title.toLowerCase().includes(searchTerm) ||
product.desc.toLowerCase().includes(searchTerm)
)
} else if (!newQuery.search) {
currentSearch.value = ''
allProducts.value = generateProducts()
}
}, { immediate: true })
onMounted(() => {
loading.value = true
// 模拟加载延迟
setTimeout(() => {
allProducts.value = generateProducts()
loading.value = false
}, 500)
})
</script>
<style lang="scss" scoped>
.filter-section {
@apply border-b border-gray-100 pb-4;
}
.filter-section:last-child {
@apply border-b-0;
}
</style>

View File

@@ -0,0 +1,489 @@
<template>
<div class="profile-page bg-gray-100 min-h-screen">
<main class="container mx-auto px-4 py-6">
<div class="grid grid-cols-12 gap-6">
<!-- 侧边导航 -->
<div class="col-span-3 hidden md:block">
<div class="bg-white rounded-lg p-4 sticky top-20">
<div class="user-info text-center mb-6 pb-6 border-b">
<el-avatar :src="globalState.currentUser?.avatar" :size="80" class="mb-3" />
<h3 class="font-bold text-lg">{{ globalState.currentUser?.username }}</h3>
<p class="text-gray-500 text-sm">{{ globalState.currentUser?.level }}</p>
</div>
<nav class="space-y-1">
<button
v-for="item in menuItems"
:key="item.key"
class="menu-item w-full text-left p-3 rounded hover:bg-red-50 hover:text-red-600 transition"
:class="{ 'bg-red-50 text-red-600': activeMenu === item.key }"
@click="activeMenu = item.key"
>
<el-icon class="mr-2"><component :is="item.icon" /></el-icon>
{{ item.label }}
</button>
</nav>
</div>
</div>
<!-- 主内容区 -->
<div class="col-span-12 md:col-span-9">
<div class="bg-white rounded-lg p-6">
<!-- 个人信息 -->
<div v-if="activeMenu === 'profile'" class="profile-section">
<h2 class="text-xl font-bold mb-6">个人信息</h2>
<el-form :model="userForm" label-width="100px" class="max-w-md">
<el-form-item label="头像">
<div class="flex items-center space-x-4">
<el-avatar :src="userForm.avatar" :size="60" />
<el-button @click="changeAvatar">更换头像</el-button>
</div>
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="userForm.username" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="userForm.email" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="userForm.phone" />
</el-form-item>
<el-form-item label="生日">
<el-date-picker
v-model="userForm.birthday"
type="date"
placeholder="选择日期"
/>
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="userForm.gender">
<el-radio label="male"></el-radio>
<el-radio label="female"></el-radio>
<el-radio label="other">其他</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveProfile">保存修改</el-button>
</el-form-item>
</el-form>
</div>
<!-- 账户安全 -->
<div v-else-if="activeMenu === 'security'" class="security-section">
<h2 class="text-xl font-bold mb-6">账户安全</h2>
<div class="space-y-6">
<div class="security-item p-4 border border-gray-200 rounded-lg">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium">登录密码</h4>
<p class="text-gray-500 text-sm">定期更换密码可以提高账户安全性</p>
</div>
<el-button @click="changePassword">修改密码</el-button>
</div>
</div>
<div class="security-item p-4 border border-gray-200 rounded-lg">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium">手机绑定</h4>
<p class="text-gray-500 text-sm">{{ userForm.phone || '未绑定手机号' }}</p>
</div>
<el-button>{{ userForm.phone ? '更换' : '绑定' }}手机</el-button>
</div>
</div>
<div class="security-item p-4 border border-gray-200 rounded-lg">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium">邮箱绑定</h4>
<p class="text-gray-500 text-sm">{{ userForm.email }}</p>
</div>
<el-button>更换邮箱</el-button>
</div>
</div>
</div>
</div>
<!-- 我的钱包 -->
<div v-else-if="activeMenu === 'wallet'" class="wallet-section">
<h2 class="text-xl font-bold mb-6">我的钱包</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="wallet-card bg-gradient-to-r from-red-500 to-pink-500 text-white p-6 rounded-lg">
<h3 class="text-lg font-medium mb-2">账户余额</h3>
<p class="text-3xl font-bold">¥{{ globalState.currentUser?.balance || 0 }}</p>
<el-button
type="primary"
class="mt-4 bg-white text-red-500 hover:bg-gray-100"
@click="globalState.showRechargeModal()"
>
立即充值
</el-button>
</div>
<div class="wallet-card bg-gradient-to-r from-blue-500 to-cyan-500 text-white p-6 rounded-lg">
<h3 class="text-lg font-medium mb-2">优惠券</h3>
<p class="text-3xl font-bold">{{ coupons.length }}</p>
<el-button
type="primary"
class="mt-4 bg-white text-blue-500 hover:bg-gray-100"
@click="viewCoupons"
>
查看优惠券
</el-button>
</div>
<div class="wallet-card bg-gradient-to-r from-green-500 to-emerald-500 text-white p-6 rounded-lg">
<h3 class="text-lg font-medium mb-2">积分</h3>
<p class="text-3xl font-bold">{{ points }}</p>
<el-button
type="primary"
class="mt-4 bg-white text-green-500 hover:bg-gray-100"
@click="viewPoints"
>
积分商城
</el-button>
</div>
</div>
<!-- 交易记录 -->
<div class="transaction-history">
<h3 class="text-lg font-bold mb-4">交易记录</h3>
<el-table :data="transactions" style="width: 100%">
<el-table-column prop="date" label="时间" width="180" />
<el-table-column prop="type" label="类型" width="120" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="amount" label="金额" width="120">
<template #default="scope">
<span :class="scope.row.amount > 0 ? 'text-green-600' : 'text-red-600'">
{{ scope.row.amount > 0 ? '+' : '' }}¥{{ Math.abs(scope.row.amount) }}
</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 收货地址 -->
<div v-else-if="activeMenu === 'address'" class="address-section">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">收货地址</h2>
<el-button type="primary" @click="addAddress">新增地址</el-button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="address in addresses"
:key="address.id"
class="address-card p-4 border border-gray-200 rounded-lg hover:border-red-300 transition"
:class="{ 'border-red-500 bg-red-50': address.isDefault }"
>
<div class="flex items-start justify-between mb-3">
<div>
<h4 class="font-medium">{{ address.name }}</h4>
<p class="text-gray-600">{{ address.phone }}</p>
</div>
<el-tag v-if="address.isDefault" type="danger" size="small">默认</el-tag>
</div>
<p class="text-gray-600 mb-3">{{ address.fullAddress }}</p>
<div class="flex space-x-2">
<el-button size="small" @click="editAddress(address)">编辑</el-button>
<el-button
v-if="!address.isDefault"
size="small"
@click="setDefaultAddress(address)"
>
设为默认
</el-button>
<el-button
size="small"
type="danger"
@click="deleteAddress(address)"
:disabled="address.isDefault"
>
删除
</el-button>
</div>
</div>
</div>
</div>
<!-- 浏览历史 -->
<div v-else-if="activeMenu === 'history'" class="history-section">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">浏览历史</h2>
<el-button @click="clearHistory">清空历史</el-button>
</div>
<div v-if="browsingHistory.length === 0" class="text-center py-12">
<div class="text-6xl mb-4">👀</div>
<p class="text-gray-500">暂无浏览记录</p>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div
v-for="item in browsingHistory"
:key="item.id"
class="history-item bg-white border border-gray-200 rounded-lg p-3 hover:shadow-md transition cursor-pointer"
@click="viewHistoryProduct(item)"
>
<img
:src="item.cover"
:alt="item.title"
class="w-full h-32 object-cover rounded mb-2"
>
<h4 class="font-medium text-sm line-clamp-2 mb-1">{{ item.title }}</h4>
<p class="text-red-600 font-bold">¥{{ item.price }}</p>
<p class="text-xs text-gray-500">{{ formatDate(item.viewTime) }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router'
import {
User,
Lock,
Wallet,
Location,
Clock,
Setting
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const globalState = inject('globalState')
// It's crucial to define activeMenu before the conditional check.
const activeMenu = ref('profile')
// Check if the user is logged in only once, at the top level.
if (!globalState.currentUser) {
ElMessage.warning('请先登录')
router.push('/login')
}
const coupons = ref([])
const points = ref(1580)
const transactions = ref([])
const addresses = ref([])
const browsingHistory = ref([])
const menuItems = [
{ key: 'profile', label: '个人信息', icon: User },
{ key: 'security', label: '账户安全', icon: Lock },
{ key: 'wallet', label: '我的钱包', icon: Wallet },
{ key: 'address', label: '收货地址', icon: Location },
{ key: 'history', label: '浏览历史', icon: Clock }
]
const userForm = reactive({
username: '',
email: '',
phone: '',
avatar: '',
birthday: '',
gender: ''
})
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN')
}
const saveProfile = () => {
// 更新用户信息
const updatedUser = {
...globalState.currentUser,
...userForm
}
globalState.updateUser(updatedUser)
ElMessage.success('个人信息保存成功')
}
const changeAvatar = () => {
// 模拟更换头像
const newAvatar = `https://cdn.seovx.com/ha/?mom=302&w=100&h=100&_=${Date.now()}`
userForm.avatar = newAvatar
ElMessage.success('头像更换成功')
}
const changePassword = () => {
ElMessage.info('密码修改功能开发中...')
}
const viewCoupons = () => {
ElMessage.info('优惠券功能开发中...')
}
const viewPoints = () => {
ElMessage.info('积分商城功能开发中...')
}
const addAddress = () => {
ElMessage.info('新增地址功能开发中...')
}
const editAddress = (address) => {
ElMessage.info('编辑地址功能开发中...')
}
const setDefaultAddress = (address) => {
addresses.value.forEach(addr => addr.isDefault = false)
address.isDefault = true
ElMessage.success('默认地址设置成功')
}
const deleteAddress = async (address) => {
try {
await ElMessageBox.confirm('确定要删除这个地址吗?', '删除地址', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const index = addresses.value.findIndex(addr => addr.id === address.id)
if (index > -1) {
addresses.value.splice(index, 1)
ElMessage.success('地址删除成功')
}
} catch {
// 用户取消
}
}
const clearHistory = async () => {
try {
await ElMessageBox.confirm('确定要清空浏览历史吗?', '清空历史', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
browsingHistory.value = []
localStorage.removeItem('browsingHistory')
ElMessage.success('浏览历史已清空')
} catch {
// 用户取消
}
}
const viewHistoryProduct = (item) => {
const product = {
id: item.id,
title: item.title,
price: item.price,
cover: item.cover,
images: [item.cover],
desc: '商品详情描述',
sold: Math.floor(Math.random() * 1000),
brand: '品牌名称'
}
globalState.showProductDetail(product)
}
const loadUserData = () => {
if (globalState.currentUser) {
Object.assign(userForm, globalState.currentUser)
}
// 加载优惠券
coupons.value = [
{ id: 1, name: '新用户专享', amount: 10, minAmount: 100 },
{ id: 2, name: '限时优惠', amount: 30, minAmount: 200 }
]
// 加载交易记录
transactions.value = [
{
date: '2023-12-01 14:30',
type: '充值',
description: '账户充值',
amount: 500
},
{
date: '2023-11-30 10:15',
type: '消费',
description: '购买商品',
amount: -128.50
},
{
date: '2023-11-28 16:20',
type: '退款',
description: '订单退款',
amount: 89.90
}
]
// 加载收货地址
addresses.value = [
{
id: 1,
name: '张三',
phone: '138****8888',
fullAddress: '北京市朝阳区某某街道某某小区1号楼101室',
isDefault: true
},
{
id: 2,
name: '李四',
phone: '139****9999',
fullAddress: '上海市浦东新区某某路某某大厦2楼',
isDefault: false
}
]
// 加载浏览历史
browsingHistory.value = JSON.parse(localStorage.getItem('browsingHistory') || '[]')
}
onMounted(() => {
if (globalState.currentUser) {
loadUserData()
}
})
</script>
<style lang="scss" scoped>
.menu-item.active {
@apply bg-red-50 text-red-600;
}
.wallet-card {
transition: transform 0.2s;
}
.wallet-card:hover {
transform: translateY(-2px);
}
.address-card:hover {
@apply shadow-md;
}
.history-item:hover {
@apply border-red-300;
}
</style>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
</script>
<template>
<el-result
icon="success"
title="购买成功"
sub-title="商品购买成功可以在-我的订单-中查看"
>
<template #extra>
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
</template>
</el-result>
</template>
<style scoped lang="scss">
</style>

View File

@@ -15,7 +15,7 @@ export default defineConfig({
} }
}, },
server: { server: {
port: 8888, port: 8000,
proxy: { proxy: {
'/api': { '/api': {
// target: 'http://127.0.0.1:18007/api/', // target: 'http://127.0.0.1:18007/api/',