fix: UI部分完成
This commit is contained in:
@@ -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
187
pnpm-lock.yaml
generated
@@ -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
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
BIN
public/demo-img/2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
276
src/App.vue
276
src/App.vue
@@ -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>
|
||||||
34
src/components/BannerCarousel.vue
Normal file
34
src/components/BannerCarousel.vue
Normal 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>
|
||||||
59
src/components/CategoryMenu.vue
Normal file
59
src/components/CategoryMenu.vue
Normal 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>
|
||||||
58
src/components/FlashSaleSection.vue
Normal file
58
src/components/FlashSaleSection.vue
Normal 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>
|
||||||
224
src/components/PaymentModal.vue
Normal file
224
src/components/PaymentModal.vue
Normal 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>
|
||||||
98
src/components/ProductCard.vue
Normal file
98
src/components/ProductCard.vue
Normal 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>
|
||||||
721
src/components/ProductDetailModal.vue
Normal file
721
src/components/ProductDetailModal.vue
Normal 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>
|
||||||
39
src/components/ProductSection.vue
Normal file
39
src/components/ProductSection.vue
Normal 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>
|
||||||
11
src/components/RechargeModal.vue
Normal file
11
src/components/RechargeModal.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
135
src/components/RegisterModal.vue
Normal file
135
src/components/RegisterModal.vue
Normal 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>
|
||||||
202
src/components/ShoppingCartDrawer.vue
Normal file
202
src/components/ShoppingCartDrawer.vue
Normal 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>
|
||||||
11
src/components/SuccessModal.vue
Normal file
11
src/components/SuccessModal.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
56
src/layouts/Footer.vue
Normal file
56
src/layouts/Footer.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 } }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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>
|
||||||
253
src/views/login/LoginPage.vue
Normal file
253
src/views/login/LoginPage.vue
Normal 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>
|
||||||
588
src/views/order/OrderListPage.vue
Normal file
588
src/views/order/OrderListPage.vue
Normal 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>
|
||||||
841
src/views/product/ProductDetailPage-dev.vue
Normal file
841
src/views/product/ProductDetailPage-dev.vue
Normal 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>
|
||||||
761
src/views/product/ProductDetailPage-old.vue
Normal file
761
src/views/product/ProductDetailPage-old.vue
Normal 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>
|
||||||
922
src/views/product/ProductDetailPage.vue
Normal file
922
src/views/product/ProductDetailPage.vue
Normal 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>
|
||||||
414
src/views/product/ProductListPage.vue
Normal file
414
src/views/product/ProductListPage.vue
Normal 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>
|
||||||
489
src/views/profile/ProfilePage.vue
Normal file
489
src/views/profile/ProfilePage.vue
Normal 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>
|
||||||
19
src/views/result/Success.vue
Normal file
19
src/views/result/Success.vue
Normal 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>
|
||||||
@@ -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/',
|
||||||
|
|||||||
Reference in New Issue
Block a user