初始化
This commit is contained in:
@@ -9,8 +9,6 @@
|
|||||||
"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",
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
@@ -21,6 +19,9 @@
|
|||||||
"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-easy-lightbox": "^1.19.0",
|
||||||
"vue-router": "^4.2.5"
|
"vue-router": "^4.2.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
74
pnpm-lock.yaml
generated
74
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)
|
||||||
|
'@fortawesome/fontawesome-free':
|
||||||
|
specifier: ^6.7.2
|
||||||
|
version: 6.7.2
|
||||||
'@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,9 +26,6 @@ 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
|
||||||
@@ -44,6 +44,9 @@ importers:
|
|||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.13
|
specifier: ^3.5.13
|
||||||
version: 3.5.13
|
version: 3.5.13
|
||||||
|
vue-easy-lightbox:
|
||||||
|
specifier: ^1.19.0
|
||||||
|
version: 1.19.0(vue@3.5.13)
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.2.5
|
specifier: ^4.2.5
|
||||||
version: 4.5.1(vue@3.5.13)
|
version: 4.5.1(vue@3.5.13)
|
||||||
@@ -263,6 +266,10 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@fortawesome/fontawesome-free@6.7.2':
|
||||||
|
resolution: {integrity: sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
'@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 +302,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 +389,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==}
|
||||||
@@ -506,28 +496,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==}
|
||||||
@@ -632,9 +618,6 @@ packages:
|
|||||||
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 +642,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'}
|
||||||
@@ -851,28 +831,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,12 +869,6 @@ 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:
|
|
||||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
|
||||||
|
|
||||||
lodash.throttle@4.1.1:
|
|
||||||
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
|
|
||||||
|
|
||||||
lodash@4.17.21:
|
lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
@@ -1081,6 +1051,12 @@ packages:
|
|||||||
'@vue/composition-api':
|
'@vue/composition-api':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vue-easy-lightbox@1.19.0:
|
||||||
|
resolution: {integrity: sha512-YxLXgjEn91UF3DuK1y8u3Pyx2sJ7a/MnBpkyrBSQkvU1glzEJASyAZ7N+5yDpmxBQDVMwCsL2VmxWGIiFrWCgA==}
|
||||||
|
engines: {node: '>=14.18.3'}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.0.0
|
||||||
|
|
||||||
vue-router@4.5.1:
|
vue-router@4.5.1:
|
||||||
resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==}
|
resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1229,6 +1205,8 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.25.3':
|
'@esbuild/win32-x64@0.25.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@fortawesome/fontawesome-free@6.7.2': {}
|
||||||
|
|
||||||
'@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':
|
||||||
@@ -1547,12 +1525,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 +1553,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
|
||||||
@@ -1787,10 +1757,6 @@ snapshots:
|
|||||||
|
|
||||||
lodash-es@4.17.21: {}
|
lodash-es@4.17.21: {}
|
||||||
|
|
||||||
lodash.debounce@4.0.8: {}
|
|
||||||
|
|
||||||
lodash.throttle@4.1.1: {}
|
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
loose-envify@1.4.0:
|
||||||
@@ -1944,6 +1910,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.13
|
vue: 3.5.13
|
||||||
|
|
||||||
|
vue-easy-lightbox@1.19.0(vue@3.5.13):
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.13
|
||||||
|
|
||||||
vue-router@4.5.1(vue@3.5.13):
|
vue-router@4.5.1(vue@3.5.13):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-api': 6.6.4
|
'@vue/devtools-api': 6.6.4
|
||||||
|
|||||||
17
src/App.vue
17
src/App.vue
@@ -1,10 +1,15 @@
|
|||||||
<script setup>
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<router-view />
|
<router-view />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang='scss' scoped>
|
<script setup>
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { useUserStore } from '@/stores/user';
|
||||||
|
|
||||||
</style>
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 检查用户会话
|
||||||
|
userStore.checkSession();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
95
src/components/AudioMessage.vue
Normal file
95
src/components/AudioMessage.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-gray-100 rounded-full p-3 flex items-center gap-3 min-w-60">
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
@click="togglePlay"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
>
|
||||||
|
<component :is="isPlaying ? PauseOutlined : CaretRightOutlined" />
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<!-- 音波动画 -->
|
||||||
|
<div class="flex items-center gap-1 h-8 mb-1">
|
||||||
|
<div
|
||||||
|
v-for="i in 20"
|
||||||
|
:key="i"
|
||||||
|
class="bg-blue-500 rounded-full transition-all duration-150"
|
||||||
|
:class="isPlaying ? 'animate-pulse' : ''"
|
||||||
|
:style="{
|
||||||
|
width: '3px',
|
||||||
|
height: isPlaying ? `${Math.random() * 20 + 10}px` : '4px'
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 进度条 -->
|
||||||
|
<div class="w-full bg-gray-300 rounded-full h-1">
|
||||||
|
<div
|
||||||
|
class="bg-blue-500 h-1 rounded-full transition-all duration-100"
|
||||||
|
:style="{ width: `${progress}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-600 flex-shrink-0">
|
||||||
|
{{ formatDuration(message.duration || 0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onUnmounted } from 'vue';
|
||||||
|
import { CaretRightOutlined, PauseOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPlaying = ref(false);
|
||||||
|
const progress = ref(0);
|
||||||
|
const audio = ref(null);
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (!audio.value) {
|
||||||
|
audio.value = new Audio(props.message.content);
|
||||||
|
|
||||||
|
audio.value.addEventListener('timeupdate', () => {
|
||||||
|
if (audio.value.duration) {
|
||||||
|
progress.value = (audio.value.currentTime / audio.value.duration) * 100;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.value.addEventListener('ended', () => {
|
||||||
|
isPlaying.value = false;
|
||||||
|
progress.value = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlaying.value) {
|
||||||
|
audio.value.pause();
|
||||||
|
isPlaying.value = false;
|
||||||
|
} else {
|
||||||
|
audio.value.play();
|
||||||
|
isPlaying.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (audio.value) {
|
||||||
|
audio.value.pause();
|
||||||
|
audio.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
21
src/components/ChatArea.vue
Normal file
21
src/components/ChatArea.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- 聊天头部 -->
|
||||||
|
<ChatHeader />
|
||||||
|
|
||||||
|
<!-- 消息区域 -->
|
||||||
|
<MessageList class="flex-1" />
|
||||||
|
|
||||||
|
<!-- 输入区域 -->
|
||||||
|
<MessageInput />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useChatStore } from '@/stores/chat';
|
||||||
|
import ChatHeader from './ChatHeader.vue';
|
||||||
|
import MessageList from './MessageList.vue';
|
||||||
|
import MessageInput from './MessageInput.vue';
|
||||||
|
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
</script>
|
||||||
33
src/components/ChatHeader.vue
Normal file
33
src/components/ChatHeader.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-5 border-b border-gray-200 bg-white">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold text-lg mr-4"
|
||||||
|
:style="{ background: chatStore.currentFriend?.color }"
|
||||||
|
>
|
||||||
|
{{ chatStore.currentFriend?.name.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800">{{ chatStore.currentFriend?.name }}</h3>
|
||||||
|
<p class="text-sm flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 rounded-full"
|
||||||
|
:class="isFriendOnline ? 'bg-green-500' : 'bg-red-500'"
|
||||||
|
></span>
|
||||||
|
<span :class="isFriendOnline ? 'text-green-600' : 'text-red-600'">
|
||||||
|
{{ isFriendOnline ? '在线' : '离线' }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useChatStore } from '@/stores/chat';
|
||||||
|
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const isFriendOnline = ref(true); // 简化处理,实际应该根据WebSocket状态判断
|
||||||
|
</script>
|
||||||
43
src/components/ConnectionStatus.vue
Normal file
43
src/components/ConnectionStatus.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="fixed top-5 right-5 px-3 py-2 rounded-full text-sm font-medium z-50 flex items-center gap-2 transition-all"
|
||||||
|
:class="statusClass"
|
||||||
|
>
|
||||||
|
<component :is="iconComponent" :class="{ 'animate-spin': chatStore.connectionStatus === 'connecting' }" />
|
||||||
|
{{ chatStore.connectionStatusText }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useChatStore } from '@/stores/chat';
|
||||||
|
import { WifiOutlined, DisconnectOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
|
const statusClass = computed(() => {
|
||||||
|
switch (chatStore.connectionStatus) {
|
||||||
|
case 'connected':
|
||||||
|
return 'bg-green-500 text-white';
|
||||||
|
case 'connecting':
|
||||||
|
return 'bg-yellow-500 text-white';
|
||||||
|
case 'disconnected':
|
||||||
|
return 'bg-red-500 text-white';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500 text-white';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconComponent = computed(() => {
|
||||||
|
switch (chatStore.connectionStatus) {
|
||||||
|
case 'connected':
|
||||||
|
return WifiOutlined;
|
||||||
|
case 'connecting':
|
||||||
|
return LoadingOutlined;
|
||||||
|
case 'disconnected':
|
||||||
|
return DisconnectOutlined;
|
||||||
|
default:
|
||||||
|
return WifiOutlined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
30
src/components/EmojiPicker.vue
Normal file
30
src/components/EmojiPicker.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<div class="emoji-picker-container bg-white border border-gray-200 rounded-lg p-3 shadow-lg">
|
||||||
|
<div class="grid grid-cols-8 gap-2 max-w-xs">
|
||||||
|
<div
|
||||||
|
v-for="emoji in emojiList"
|
||||||
|
:key="emoji"
|
||||||
|
class="w-8 h-8 flex items-center justify-center cursor-pointer rounded hover:bg-gray-100 text-lg transition-all hover:scale-110"
|
||||||
|
@click="$emit('select', emoji)"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
defineEmits(['select']);
|
||||||
|
|
||||||
|
const emojiList = ref([
|
||||||
|
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣',
|
||||||
|
'😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰',
|
||||||
|
'😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜',
|
||||||
|
'🤪', '🤨', '🧐', '🤓', '😎', '🤩', '🥳', '😏',
|
||||||
|
'😒', '😞', '😔', '😟', '😕', '🙁', '☹️', '😣',
|
||||||
|
'😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠',
|
||||||
|
'😡', '🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨'
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
58
src/components/FileUploadPreview.vue
Normal file
58
src/components/FileUploadPreview.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mb-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{{uploadPreview.type}}
|
||||||
|
<!-- 文件缩略图 -->
|
||||||
|
<div class="w-16 h-16 rounded-lg overflow-hidden bg-gray-200 flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
v-if="uploadPreview.type === 'image'"
|
||||||
|
:src="uploadPreview.url"
|
||||||
|
alt="预览"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
v-else-if="uploadPreview.type === 'video'"
|
||||||
|
:src="uploadPreview.url"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
<FileOutlined v-else class="text-2xl text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件信息 -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-gray-800 truncate">{{ uploadPreview.name }}</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ formatFileSize(uploadPreview.size) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a-button type="primary" size="small" @click="sendFile">
|
||||||
|
<template #icon><SendOutlined /></template>
|
||||||
|
发送
|
||||||
|
</a-button>
|
||||||
|
<a-button size="small" @click="cancelUpload">
|
||||||
|
<template #icon><CloseOutlined /></template>
|
||||||
|
取消
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue';
|
||||||
|
import { FileOutlined, SendOutlined, CloseOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const uploadPreview = inject('uploadPreview');
|
||||||
|
const sendFile = inject('sendFile');
|
||||||
|
const cancelUpload = inject('cancelUpload');
|
||||||
|
|
||||||
|
const formatFileSize = (bytes) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
109
src/components/FriendList.vue
Normal file
109
src/components/FriendList.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full bg-white border-r border-gray-200">
|
||||||
|
<!-- 头部 -->
|
||||||
|
<div class="p-6 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl font-bold text-blue-600">聊天</h2>
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
@click="themeStore.toggleTheme"
|
||||||
|
:title="themeStore.isDarkMode ? '切换到亮色模式' : '切换到暗色模式'"
|
||||||
|
class="flex items-center gap-1 px-3 py-1 rounded-full bg-gray-100 hover:bg-blue-500 hover:text-white transition-all"
|
||||||
|
>
|
||||||
|
<span v-if="themeStore.isDarkMode">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 40 40"><g fill="none" stroke-miterlimit="10"><path fill="#ffe236" stroke="#231f20" d="M10.82 19.9a9.179 9.179 0 0 0 15.671 6.491a9.18 9.18 0 0 0 0-12.982a9.18 9.18 0 0 0-12.982 0A9.18 9.18 0 0 0 10.82 19.9ZM23.43 6s.13-.72-1.1-2.93A11 11 0 0 0 20.72.79a1.05 1.05 0 0 0-1.45 0c-.623.667-1.16 1.41-1.6 2.21a8.8 8.8 0 0 0-1.11 3a1.1 1.1 0 0 0 .75 1.11c.88.208 1.785.299 2.69.27a10.3 10.3 0 0 0 2.68-.32A1.09 1.09 0 0 0 23.43 6Zm-6.86 28.05s-.13.72 1.1 2.93a11 11 0 0 0 1.61 2.23a1.05 1.05 0 0 0 1.45 0c.622-.668 1.16-1.41 1.6-2.21a8.9 8.9 0 0 0 1.11-2.93a1.1 1.1 0 0 0-.75-1.11a10.3 10.3 0 0 0-2.69-.34a10.3 10.3 0 0 0-2.69.32a1.09 1.09 0 0 0-.75 1.1zM12.49 7.64S12.07 7 9.64 6.35a11.6 11.6 0 0 0-2.72-.45a1.06 1.06 0 0 0-1 1c.037.922.188 1.835.45 2.72a8.8 8.8 0 0 0 1.26 2.88a1.1 1.1 0 0 0 1.37.26a10.6 10.6 0 0 0 2.12-1.68A10 10 0 0 0 12.75 9a1.09 1.09 0 0 0-.25-1.3zm15.02 24.72s.42.61 2.85 1.29c.886.258 1.799.409 2.72.45a1.06 1.06 0 0 0 1-1a11.2 11.2 0 0 0-.45-2.72a8.8 8.8 0 0 0-1.28-2.85a1.1 1.1 0 0 0-1.32-.26a10.6 10.6 0 0 0-2.12 1.68a10 10 0 0 0-1.68 2.13a1.09 1.09 0 0 0 .25 1.3zM6 16.57s-.72-.13-2.93 1.1a11 11 0 0 0-2.28 1.61a1.05 1.05 0 0 0 0 1.45c.667.623 1.41 1.16 2.21 1.6a8.8 8.8 0 0 0 3 1.11a1.1 1.1 0 0 0 1.11-.75c.208-.88.299-1.785.27-2.69a10.3 10.3 0 0 0-.32-2.68A1.09 1.09 0 0 0 6 16.57Zm28.05 6.86s.72.13 2.93-1.1a11 11 0 0 0 2.23-1.61a1.05 1.05 0 0 0 0-1.45a11.2 11.2 0 0 0-2.21-1.6a8.9 8.9 0 0 0-2.93-1.11a1.1 1.1 0 0 0-1.11.75a10.3 10.3 0 0 0-.34 2.69c-.013.907.095 1.811.32 2.69a1.09 1.09 0 0 0 1.1.75zM7.64 27.51s-.61.42-1.29 2.85a11.6 11.6 0 0 0-.45 2.72a1.06 1.06 0 0 0 1 1a11.2 11.2 0 0 0 2.72-.45a8.8 8.8 0 0 0 2.85-1.28a1.1 1.1 0 0 0 .26-1.32a10.6 10.6 0 0 0-1.68-2.12A10 10 0 0 0 9 27.25a1.09 1.09 0 0 0-1.3.25zm24.72-15.02s.61-.42 1.29-2.85c.258-.886.409-1.799.45-2.72a1.06 1.06 0 0 0-1-1a11.2 11.2 0 0 0-2.72.45a8.8 8.8 0 0 0-2.88 1.26A1.1 1.1 0 0 0 27.24 9a10.6 10.6 0 0 0 1.68 2.12a10 10 0 0 0 2.13 1.68a1.09 1.09 0 0 0 1.3-.25z" stroke-width="1"/><path stroke="#fff" stroke-linecap="round" d="M23.44 13.86a4.8 4.8 0 0 1 2.3 2.06" stroke-width="1"/></g></svg>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 64 64"><circle cx="32" cy="32.12" r="31.875" fill="#f5eb35"/><g fill="#e0cf35"><circle cx="29.32" cy="53.02" r="9.226"/><path d="M41.904 24.487a3.918 3.918 0 1 1-7.836-.002a3.918 3.918 0 0 1 7.836.002"/><circle cx="5.967" cy="36.54" r="3.845"/><circle cx="6.313" cy="18.917" r="2.195"/><path d="M20.967 19.656a3.433 3.433 0 1 1-6.866 0a3.433 3.433 0 0 1 6.866 0"/><circle cx="42.896" cy="11.07" r="4.835"/></g></svg>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs">{{ themeStore.isDarkMode ? '亮色' : '暗色' }}</span>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索框 -->
|
||||||
|
<a-input
|
||||||
|
v-model:value="searchQuery"
|
||||||
|
placeholder="搜索联系人..."
|
||||||
|
class="rounded-full"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<SearchOutlined class="text-gray-400" />
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 好友列表 -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-2">
|
||||||
|
<a-spin :spinning="loadingFriends" tip="加载好友中...">
|
||||||
|
<div v-if="!loadingFriends">
|
||||||
|
<div
|
||||||
|
v-for="friend in filteredFriends"
|
||||||
|
:key="friend.id"
|
||||||
|
class="friend-item p-3 rounded-xl mb-2 cursor-pointer transition-all duration-200 hover:bg-gray-100"
|
||||||
|
:class="{ 'bg-blue-50 border-l-4 border-blue-500': chatStore.currentFriend?.id === friend.id }"
|
||||||
|
@click="handleSwitchFriend(friend)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold text-lg mr-3 relative"
|
||||||
|
:style="{ background: friend.color }"
|
||||||
|
>
|
||||||
|
{{ friend.name.charAt(0) }}
|
||||||
|
<!-- 未读消息徽章 -->
|
||||||
|
<a-badge
|
||||||
|
v-if="friend.unreadCount > 0"
|
||||||
|
:count="friend.unreadCount"
|
||||||
|
class="absolute -top-1 -right-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="font-semibold text-gray-800 truncate">{{ friend.name }}</h4>
|
||||||
|
<p class="text-sm text-gray-500 truncate">{{ friend.lastMessage || '点击开始聊天' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户信息 -->
|
||||||
|
<div class="p-4 border-t border-gray-200 bg-gray-50">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<UserOutlined />
|
||||||
|
<span>当前用户: <span class="font-semibold text-blue-600">{{ userStore.currentUser?.name }}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useUserStore } from '@/stores/user';
|
||||||
|
import { useChatStore } from '@/stores/chat';
|
||||||
|
import { useThemeStore } from '@/stores/theme';
|
||||||
|
import { SearchOutlined, UserOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const loadingFriends = ref(false);
|
||||||
|
|
||||||
|
// 过滤后的好友列表
|
||||||
|
const filteredFriends = computed(() => {
|
||||||
|
if (!searchQuery.value) return chatStore.friends;
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
return chatStore.friends.filter(friend =>
|
||||||
|
friend.name.toLowerCase().includes(query) ||
|
||||||
|
friend.id.toString().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换好友
|
||||||
|
const handleSwitchFriend = (friend) => {
|
||||||
|
chatStore.switchFriend(friend, userStore.currentUser.id);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
91
src/components/MediaPreview.vue
Normal file
91
src/components/MediaPreview.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 图片预览组件(使用 vue-easy-lightbox) -->
|
||||||
|
<vue-easy-lightbox
|
||||||
|
:visible="showImagePreview"
|
||||||
|
:imgs="[{ src: mediaData?.url }]"
|
||||||
|
@hide="closePreview"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 视频预览组件(带毛玻璃效果和关闭按钮) -->
|
||||||
|
<div
|
||||||
|
v-if="showVideoPreview"
|
||||||
|
class="fixed inset-0 z-50 overflow-auto"
|
||||||
|
@click.self="closePreview"
|
||||||
|
>
|
||||||
|
<!-- 毛玻璃背景 -->
|
||||||
|
<div class="fixed inset-0 backdrop-filter backdrop-blur-lg bg-black bg-opacity-70"></div>
|
||||||
|
|
||||||
|
<!-- 主内容容器 -->
|
||||||
|
<div class="relative flex items-center justify-center min-h-screen w-full p-4">
|
||||||
|
<!-- 毛玻璃关闭按钮 -->
|
||||||
|
<button
|
||||||
|
class="fixed top-6 right-6 z-50 rounded-full p-3 backdrop-filter backdrop-blur-md bg-white/20 hover:bg-white/30 transition-all"
|
||||||
|
@click="closePreview"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 视频播放器 -->
|
||||||
|
<div class="relative z-10 max-w-4xl w-full">
|
||||||
|
<video
|
||||||
|
ref="videoPlayer"
|
||||||
|
:src="mediaData?.url"
|
||||||
|
class="w-full rounded-xl shadow-2xl aspect-video"
|
||||||
|
controls
|
||||||
|
autoplay
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 大播放按钮(非播放状态时显示) -->
|
||||||
|
<div
|
||||||
|
v-show="!playing"
|
||||||
|
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
||||||
|
@click="playVideo"
|
||||||
|
>
|
||||||
|
<div class="backdrop-filter backdrop-blur-md bg-black/30 p-6 rounded-full">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue';
|
||||||
|
import VueEasyLightbox from 'vue-easy-lightbox';
|
||||||
|
import {useMediaPreview} from "@/composables/useMediaPreview.js";
|
||||||
|
|
||||||
|
// 引入组合函数
|
||||||
|
const { visible, mediaData, closePreview } = useMediaPreview();
|
||||||
|
|
||||||
|
// 区分图片和视频预览状态
|
||||||
|
const showImagePreview = computed(() => visible.value && mediaData.value?.type === 'image');
|
||||||
|
const showVideoPreview = computed(() => visible.value && mediaData.value?.type === 'video');
|
||||||
|
|
||||||
|
// 视频播放控制
|
||||||
|
const videoPlayer = ref(null);
|
||||||
|
const playing = ref(false);
|
||||||
|
|
||||||
|
const playVideo = () => {
|
||||||
|
if (videoPlayer.value) {
|
||||||
|
videoPlayer.value.play();
|
||||||
|
playing.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当媒体数据变化时重置播放状态
|
||||||
|
watch(mediaData, () => {
|
||||||
|
playing.value = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
221
src/components/MessageInput.vue
Normal file
221
src/components/MessageInput.vue
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-5 border-t border-gray-200 bg-white">
|
||||||
|
<!-- 文件上传预览 -->
|
||||||
|
<FileUploadPreview v-if="uploadPreview" />
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- 功能按钮 -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a-tooltip title="发送图片">
|
||||||
|
<a-button type="text" shape="circle" @click="triggerFileInput('image')">
|
||||||
|
<PictureOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
|
||||||
|
<a-tooltip title="发送视频">
|
||||||
|
<a-button type="text" shape="circle" @click="triggerFileInput('video')">
|
||||||
|
<VideoCameraOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
|
||||||
|
<a-tooltip title="选择表情">
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
:class="{ 'text-blue-500': showEmojiPicker }"
|
||||||
|
@click="toggleEmojiPicker"
|
||||||
|
>
|
||||||
|
<SmileOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
|
||||||
|
<a-tooltip title="按住录音">
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
:class="{ 'text-red-500 animate-pulse': isRecording }"
|
||||||
|
@mousedown="startRecording"
|
||||||
|
@mouseup="stopRecording"
|
||||||
|
@mouseleave="stopRecording"
|
||||||
|
@touchstart="startRecording"
|
||||||
|
@touchend="stopRecording"
|
||||||
|
>
|
||||||
|
<AudioOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表情选择器 -->
|
||||||
|
<EmojiPicker v-if="showEmojiPicker" @select="insertEmoji" />
|
||||||
|
|
||||||
|
<!-- 输入框和发送按钮 -->
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<a-textarea
|
||||||
|
ref="messageInput"
|
||||||
|
v-model:value="messageText"
|
||||||
|
placeholder="输入消息..."
|
||||||
|
:auto-size="{ minRows: 1, maxRows: 4 }"
|
||||||
|
class="resize-none"
|
||||||
|
@keydown.enter.prevent="handleEnterKey"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
:disabled="!messageText.trim() && !uploadPreview"
|
||||||
|
@click="sendTextMessage"
|
||||||
|
>
|
||||||
|
<SendOutlined />
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 隐藏的文件输入框 -->
|
||||||
|
<input
|
||||||
|
ref="imageInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileUpload"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="videoInput"
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, nextTick, inject } from 'vue';
|
||||||
|
import { useUserStore } from '@/stores/user';
|
||||||
|
import { useChatStore } from '@/stores/chat';
|
||||||
|
import { useFileUpload } from '@/composables/useFileUpload';
|
||||||
|
import { useRecording } from '@/composables/useRecording';
|
||||||
|
import { sendMessage } from '@/utils/request';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
PictureOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
SmileOutlined,
|
||||||
|
AudioOutlined,
|
||||||
|
SendOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import EmojiPicker from './EmojiPicker.vue';
|
||||||
|
import FileUploadPreview from './FileUploadPreview.vue';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
|
const messageText = ref('');
|
||||||
|
const messageInput = ref(null);
|
||||||
|
const showEmojiPicker = ref(false);
|
||||||
|
|
||||||
|
// 使用文件上传组合式函数
|
||||||
|
const {
|
||||||
|
uploadPreview,
|
||||||
|
imageInput,
|
||||||
|
videoInput,
|
||||||
|
triggerFileInput,
|
||||||
|
handleFileUpload
|
||||||
|
} = useFileUpload();
|
||||||
|
|
||||||
|
// 使用录音组合式函数
|
||||||
|
const { isRecording, startRecording, stopRecording } = useRecording();
|
||||||
|
|
||||||
|
// 发送文本消息
|
||||||
|
const sendTextMessage = async () => {
|
||||||
|
const content = messageText.value.trim();
|
||||||
|
if (!content && !uploadPreview.value) return;
|
||||||
|
|
||||||
|
let messageContent = content;
|
||||||
|
let messageType = 'text';
|
||||||
|
|
||||||
|
// 如果有文件预览,发送文件
|
||||||
|
if (uploadPreview.value) {
|
||||||
|
messageContent = uploadPreview.value.url;
|
||||||
|
messageType = uploadPreview.value.type;
|
||||||
|
uploadPreview.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建消息对象
|
||||||
|
const messageObj = {
|
||||||
|
type: messageType,
|
||||||
|
content: messageContent,
|
||||||
|
time: new Date().toLocaleTimeString().slice(0, 5),
|
||||||
|
senderId: userStore.currentUser.id,
|
||||||
|
read: true,
|
||||||
|
duration: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加到本地消息列表
|
||||||
|
chatStore.addMessage(messageObj, userStore.currentUser.id);
|
||||||
|
|
||||||
|
// 发送到服务器
|
||||||
|
try {
|
||||||
|
await sendMessage({
|
||||||
|
senderId: userStore.currentUser.id,
|
||||||
|
receiverId: chatStore.currentFriend.id,
|
||||||
|
type: messageType,
|
||||||
|
content: messageContent
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送消息失败:', error);
|
||||||
|
message.error('消息发送失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空输入框
|
||||||
|
messageText.value = '';
|
||||||
|
|
||||||
|
// 重置文本框高度
|
||||||
|
nextTick(() => {
|
||||||
|
if (messageInput.value) {
|
||||||
|
messageInput.value.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理回车键
|
||||||
|
const handleEnterKey = (event) => {
|
||||||
|
if (event.shiftKey) {
|
||||||
|
// Shift + Enter 换行
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Enter 发送消息
|
||||||
|
event.preventDefault();
|
||||||
|
sendTextMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换表情选择器
|
||||||
|
const toggleEmojiPicker = () => {
|
||||||
|
showEmojiPicker.value = !showEmojiPicker.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 插入表情
|
||||||
|
const insertEmoji = (emoji) => {
|
||||||
|
messageText.value += emoji;
|
||||||
|
showEmojiPicker.value = false;
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
if (messageInput.value) {
|
||||||
|
messageInput.value.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 点击外部关闭表情选择器
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (!event.target.closest('.emoji-picker-container')) {
|
||||||
|
showEmojiPicker.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
inject('clickOutsideHandler', handleClickOutside);
|
||||||
|
</script>
|
||||||
95
src/components/MessageItem.vue
Normal file
95
src/components/MessageItem.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="inline-block max-w-xs md:max-w-md lg:max-w-lg xl:max-w-xl"
|
||||||
|
:class="isSent ? 'ml-auto' : 'mr-auto'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="rounded-2xl p-4 shadow-sm"
|
||||||
|
:class="[
|
||||||
|
isSent
|
||||||
|
? 'bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-br-sm'
|
||||||
|
: 'bg-white text-gray-800 border border-gray-200 rounded-bl-sm'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- 图片消息 - 已修复 -->
|
||||||
|
<div
|
||||||
|
v-if="message.type === 'image'"
|
||||||
|
class="cursor-pointer relative"
|
||||||
|
@click="previewMedia(message.content, 'image')"
|
||||||
|
>
|
||||||
|
<a-image
|
||||||
|
:src="message.content"
|
||||||
|
:alt="'图片消息'"
|
||||||
|
class="rounded-lg max-w-full"
|
||||||
|
:preview="false"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black/50 bg-opacity-30 rounded-lg">
|
||||||
|
<!-- 已移除不必要的放大镜图标 -->
|
||||||
|
<SearchOutlined class="text-white text-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视频消息 - 已修复 -->
|
||||||
|
<div
|
||||||
|
v-else-if="message.type === 'video'"
|
||||||
|
class="cursor-pointer relative"
|
||||||
|
@click="previewMedia(message.content, 'video')"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
:src="message.content"
|
||||||
|
class="rounded-lg max-w-full max-h-64"
|
||||||
|
preload="metadata"
|
||||||
|
@error="handleVideoError"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center bg-black/50 bg-opacity-30 rounded-lg">
|
||||||
|
<PlayCircleOutlined class="text-white text-3xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 音频消息 -->
|
||||||
|
<AudioMessage v-else-if="message.type === 'audio'" :message="message" />
|
||||||
|
|
||||||
|
<!-- 文本消息 -->
|
||||||
|
<div v-else class="text-base leading-relaxed">
|
||||||
|
{{ message.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息时间和状态 -->
|
||||||
|
<div class="flex items-center justify-end gap-1 mt-2 text-xs opacity-70">
|
||||||
|
<span>{{ message.time }}</span>
|
||||||
|
<CheckOutlined v-if="isSent" class="text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps } from 'vue';
|
||||||
|
import { PlayCircleOutlined, CheckOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { useMediaPreview } from '@/composables/useMediaPreview';
|
||||||
|
import AudioMessage from './AudioMessage.vue';
|
||||||
|
|
||||||
|
const { previewMedia } = useMediaPreview();
|
||||||
|
|
||||||
|
const handleImageError = (event) => {
|
||||||
|
console.error('图片加载失败:', event.target.src);
|
||||||
|
event.target.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoError = (event) => {
|
||||||
|
console.error('视频加载失败:', event.target.src);
|
||||||
|
event.target.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isSent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
64
src/components/MessageList.vue
Normal file
64
src/components/MessageList.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="messagesContainer"
|
||||||
|
class="flex-1 p-5 overflow-y-auto bg-gray-50 message-list-background"
|
||||||
|
>
|
||||||
|
<a-spin :spinning="loadingMessages" tip="加载消息中...">
|
||||||
|
<div v-if="!loadingMessages">
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-if="!chatStore.messages.length && chatStore.currentFriend" class="text-center text-gray-500 py-10">
|
||||||
|
<MessageOutlined class="text-4xl mb-2" />
|
||||||
|
<p>开始与 {{ chatStore.currentFriend.name }} 对话吧!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息列表 -->
|
||||||
|
<div
|
||||||
|
v-for="(msg, index) in chatStore.messages"
|
||||||
|
:key="index"
|
||||||
|
class="mb-4 clear-both"
|
||||||
|
:class="msg.senderId === userStore.currentUser.id ? 'text-right' : 'text-left'"
|
||||||
|
>
|
||||||
|
<MessageItem :message="msg" :is-sent="msg.senderId === userStore.currentUser.id" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, nextTick, watch } from 'vue';
|
||||||
|
import { useUserStore } from '@/stores/user';
|
||||||
|
import { useChatStore } from '@/stores/chat';
|
||||||
|
import { MessageOutlined } from '@ant-design/icons-vue';
|
||||||
|
import MessageItem from './MessageItem.vue';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
|
const messagesContainer = ref(null);
|
||||||
|
const loadingMessages = ref(false);
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
nextTick(() => {
|
||||||
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听消息变化,自动滚动到底部
|
||||||
|
watch(() => chatStore.messages.length, () => {
|
||||||
|
scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听当前好友变化,重新加载消息
|
||||||
|
watch(() => chatStore.currentFriend, () => {
|
||||||
|
scrollToBottom();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.message-list-background {
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" opacity="0.03"><rect width="100" height="100" fill="none"/><path d="M0,0 L100,100 M100,0 L0,100" stroke="currentColor"/></svg>')
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
src/components/RecordingIndicator.vue
Normal file
12
src/components/RecordingIndicator.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50">
|
||||||
|
<div class="bg-white rounded-lg p-6 flex items-center gap-4 shadow-xl">
|
||||||
|
<div class="w-4 h-4 bg-red-500 rounded-full animate-pulse"></div>
|
||||||
|
<span class="text-lg font-medium">正在录音... 松开发送</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 录音状态指示器组件
|
||||||
|
</script>
|
||||||
96
src/composables/useFileUpload.js
Normal file
96
src/composables/useFileUpload.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { ref } from "vue"
|
||||||
|
import { message } from "ant-design-vue"
|
||||||
|
|
||||||
|
export function useFileUpload() {
|
||||||
|
const uploadPreview = ref(null)
|
||||||
|
const imageInput = ref(null)
|
||||||
|
const videoInput = ref(null)
|
||||||
|
const currentFileType = ref("")
|
||||||
|
|
||||||
|
const triggerFileInput = (type) => {
|
||||||
|
currentFileType.value = type
|
||||||
|
|
||||||
|
if (type === "image" && imageInput.value) {
|
||||||
|
imageInput.value.value = ""
|
||||||
|
imageInput.value.click()
|
||||||
|
} else if (type === "video" && videoInput.value) {
|
||||||
|
videoInput.value.value = ""
|
||||||
|
videoInput.value.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileUpload = (event) => {
|
||||||
|
if (event.target.files.length === 0) return
|
||||||
|
|
||||||
|
const file = event.target.files[0]
|
||||||
|
|
||||||
|
// 确定文件类型
|
||||||
|
let detectedType = ""
|
||||||
|
if (event.target === imageInput.value) {
|
||||||
|
detectedType = "image"
|
||||||
|
} else if (event.target === videoInput.value) {
|
||||||
|
detectedType = "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedType) {
|
||||||
|
currentFileType.value = detectedType
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件大小检查
|
||||||
|
const maxSize = currentFileType.value === "image" ? 10 * 1024 * 1024 : 50 * 1024 * 1024
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
const maxSizeMB = maxSize / 1024 / 1024
|
||||||
|
message.error(`文件大小超过限制!${currentFileType.value === "image" ? "图片" : "视频"}最大${maxSizeMB}MB`)
|
||||||
|
event.target.value = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件类型检查
|
||||||
|
const validImageTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/bmp"]
|
||||||
|
const validVideoTypes = ["video/mp4", "video/avi", "video/mov", "video/wmv", "video/flv", "video/webm", "video/mkv"]
|
||||||
|
|
||||||
|
if (currentFileType.value === "image" && !validImageTypes.includes(file.type)) {
|
||||||
|
message.error("请选择有效的图片格式 (JPEG, PNG, GIF, WebP, BMP)")
|
||||||
|
event.target.value = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentFileType.value === "video" && !validVideoTypes.includes(file.type)) {
|
||||||
|
message.error("请选择有效的视频格式 (MP4, AVI, MOV, WMV, FLV, WebM, MKV)")
|
||||||
|
event.target.value = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
uploadPreview.value = {
|
||||||
|
type: currentFileType.value,
|
||||||
|
url: e.target.result,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
file: file,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.onerror = (error) => {
|
||||||
|
console.error("文件读取失败:", error)
|
||||||
|
message.error("文件读取失败,请重试")
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
event.target.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelUpload = () => {
|
||||||
|
uploadPreview.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadPreview,
|
||||||
|
imageInput,
|
||||||
|
videoInput,
|
||||||
|
triggerFileInput,
|
||||||
|
handleFileUpload,
|
||||||
|
cancelUpload,
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/composables/useMediaPreview.js
Normal file
23
src/composables/useMediaPreview.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const mediaData = ref(null)
|
||||||
|
|
||||||
|
export function useMediaPreview() {
|
||||||
|
const previewMedia = (url, type) => {
|
||||||
|
mediaData.value = { url, type }
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closePreview = () => {
|
||||||
|
visible.value = false
|
||||||
|
mediaData.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
visible,
|
||||||
|
mediaData,
|
||||||
|
previewMedia,
|
||||||
|
closePreview,
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/composables/useRecording.js
Normal file
56
src/composables/useRecording.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { ref } from "vue"
|
||||||
|
import { message } from "ant-design-vue"
|
||||||
|
|
||||||
|
export function useRecording() {
|
||||||
|
const isRecording = ref(false)
|
||||||
|
const mediaRecorder = ref(null)
|
||||||
|
const audioChunks = ref([])
|
||||||
|
|
||||||
|
const startRecording = () => {
|
||||||
|
if (isRecording.value) return
|
||||||
|
|
||||||
|
navigator.mediaDevices
|
||||||
|
.getUserMedia({ audio: true })
|
||||||
|
.then((stream) => {
|
||||||
|
isRecording.value = true
|
||||||
|
audioChunks.value = []
|
||||||
|
|
||||||
|
mediaRecorder.value = new MediaRecorder(stream)
|
||||||
|
|
||||||
|
mediaRecorder.value.ondataavailable = (event) => {
|
||||||
|
audioChunks.value.push(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder.value.onstop = () => {
|
||||||
|
const audioBlob = new Blob(audioChunks.value, { type: "audio/wav" })
|
||||||
|
const audioUrl = URL.createObjectURL(audioBlob)
|
||||||
|
|
||||||
|
// 这里可以触发发送音频消息的事件
|
||||||
|
// 或者调用父组件的方法
|
||||||
|
|
||||||
|
// 停止所有音频轨道
|
||||||
|
stream.getTracks().forEach((track) => track.stop())
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder.value.start()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("录音失败:", error)
|
||||||
|
message.error("无法访问麦克风,请检查权限设置")
|
||||||
|
isRecording.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRecording = () => {
|
||||||
|
if (!isRecording.value || !mediaRecorder.value) return
|
||||||
|
|
||||||
|
isRecording.value = false
|
||||||
|
mediaRecorder.value.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRecording,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/composables/useWebSocket.js
Normal file
79
src/composables/useWebSocket.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useUserStore } from "@/stores/user"
|
||||||
|
import { useChatStore } from "@/stores/chat"
|
||||||
|
|
||||||
|
export function useWebSocket() {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
|
||||||
|
const connectWebSocket = () => {
|
||||||
|
try {
|
||||||
|
chatStore.connectionStatus = "connecting"
|
||||||
|
|
||||||
|
const wsUrl = "ws://localhost:12080/ws"
|
||||||
|
console.log("连接WebSocket:", wsUrl)
|
||||||
|
|
||||||
|
chatStore.socket = new WebSocket(wsUrl)
|
||||||
|
|
||||||
|
chatStore.socket.onopen = () => {
|
||||||
|
console.log("WebSocket连接已建立")
|
||||||
|
chatStore.connectionStatus = "connected"
|
||||||
|
|
||||||
|
// 发送绑定请求
|
||||||
|
if (userStore.currentUser) {
|
||||||
|
const bindMsg = {
|
||||||
|
request_type: "bind",
|
||||||
|
sender_user_id: userStore.currentUser.id.toString(),
|
||||||
|
}
|
||||||
|
chatStore.socket.send(JSON.stringify(bindMsg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatStore.socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data)
|
||||||
|
console.log("收到消息:", message)
|
||||||
|
|
||||||
|
// 检查是否是系统消息
|
||||||
|
if (message.clientId) return
|
||||||
|
|
||||||
|
chatStore.handleIncomingMessage(message, userStore.currentUser.id)
|
||||||
|
} catch (e) {
|
||||||
|
console.error("解析消息失败:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatStore.socket.onclose = (event) => {
|
||||||
|
console.log("WebSocket连接已关闭, 代码:", event.code, "原因:", event.reason)
|
||||||
|
chatStore.connectionStatus = "disconnected"
|
||||||
|
|
||||||
|
// 智能重连策略
|
||||||
|
if (event.code !== 1000) {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log("尝试重新连接WebSocket...")
|
||||||
|
connectWebSocket()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatStore.socket.onerror = (error) => {
|
||||||
|
console.error("WebSocket错误:", error)
|
||||||
|
chatStore.connectionStatus = "disconnected"
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("连接WebSocket失败:", error)
|
||||||
|
chatStore.connectionStatus = "disconnected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disconnectWebSocket = () => {
|
||||||
|
if (chatStore.socket) {
|
||||||
|
chatStore.socket.close()
|
||||||
|
chatStore.socket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectWebSocket,
|
||||||
|
disconnectWebSocket,
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/main.js
25
src/main.js
@@ -1,16 +1,19 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from "vue"
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from "pinia"
|
||||||
import Antd from 'ant-design-vue'
|
import Antd from "ant-design-vue"
|
||||||
import './style.css'
|
import router from "./router"
|
||||||
import App from './App.vue'
|
import App from "./App.vue"
|
||||||
// 注册路由
|
|
||||||
import router from './router'
|
import "ant-design-vue/dist/reset.css"
|
||||||
|
import "./style.css"
|
||||||
|
import VueEasyLightbox from "vue-easy-lightbox";
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
// 注册Ant Design组件
|
app.use(VueEasyLightbox);
|
||||||
app.use(createPinia())
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(Antd)
|
app.use(Antd)
|
||||||
app.mount('#app')
|
|
||||||
|
app.mount("#app")
|
||||||
|
|||||||
@@ -1,38 +1,40 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from "vue-router"
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from "@/stores/user"
|
||||||
import MainLayout from '@/layouts/Header.vue'
|
|
||||||
|
|
||||||
// 在路由配置中添加个人中心路由
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: "/login",
|
||||||
component: MainLayout,
|
name: "Login",
|
||||||
children: [
|
component: () => import("@/views/Login.vue"),
|
||||||
{ path: '', name: 'Home', component: () => import('@/views/index/index.vue') },
|
meta: { requiresGuest: true },
|
||||||
]
|
},
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "Chat",
|
||||||
|
component: () => import("@/views/Chat.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/:pathMatch(.*)*",
|
||||||
|
redirect: "/",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes,
|
routes,
|
||||||
scrollBehavior(to, from, savedPosition) {
|
|
||||||
return savedPosition || { top: 0 }
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to, from) => {
|
router.beforeEach((to, from, next) => {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
if (to.meta.requiresAuth && !userStore.user) {
|
|
||||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
|
||||||
|
next("/login")
|
||||||
|
} else if (to.meta.requiresGuest && userStore.isLoggedIn) {
|
||||||
|
next("/")
|
||||||
|
} else {
|
||||||
|
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 } }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
176
src/stores/chat.js
Normal file
176
src/stores/chat.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { defineStore } from "pinia"
|
||||||
|
import { ref, computed } from "vue"
|
||||||
|
|
||||||
|
export const useChatStore = defineStore("chat", () => {
|
||||||
|
const friends = ref([])
|
||||||
|
const currentFriend = ref(null)
|
||||||
|
const messages = ref([])
|
||||||
|
const connectionStatus = ref("disconnected")
|
||||||
|
const socket = ref(null)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const isConnected = computed(() => connectionStatus.value === "connected")
|
||||||
|
|
||||||
|
const connectionStatusText = computed(() => {
|
||||||
|
switch (connectionStatus.value) {
|
||||||
|
case "connected":
|
||||||
|
return "已连接"
|
||||||
|
case "connecting":
|
||||||
|
return "连接中..."
|
||||||
|
case "disconnected":
|
||||||
|
return "已断开"
|
||||||
|
default:
|
||||||
|
return "未知状态"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const connectionStatusIcon = computed(() => {
|
||||||
|
switch (connectionStatus.value) {
|
||||||
|
case "connected":
|
||||||
|
return "wifi"
|
||||||
|
case "connecting":
|
||||||
|
return "loading"
|
||||||
|
case "disconnected":
|
||||||
|
return "disconnect"
|
||||||
|
default:
|
||||||
|
return "question"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取聊天历史记录
|
||||||
|
const getChatHistory = (friendId, currentUserId) => {
|
||||||
|
const chatKey = `chat_${currentUserId}_${friendId}`
|
||||||
|
const chatData = localStorage.getItem(chatKey)
|
||||||
|
return chatData ? JSON.parse(chatData) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存聊天历史记录
|
||||||
|
const saveChatHistory = (friendId, currentUserId, messageList) => {
|
||||||
|
const chatKey = `chat_${currentUserId}_${friendId}`
|
||||||
|
localStorage.setItem(chatKey, JSON.stringify(messageList))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载好友列表
|
||||||
|
const loadFriends = (presetUsers, currentUserId) => {
|
||||||
|
const friendList = presetUsers.filter((user) => user.id !== currentUserId)
|
||||||
|
|
||||||
|
friendList.forEach((friend) => {
|
||||||
|
const chatHistory = getChatHistory(friend.id, currentUserId)
|
||||||
|
|
||||||
|
if (chatHistory.length > 0) {
|
||||||
|
const lastMsg = chatHistory[chatHistory.length - 1]
|
||||||
|
let lastMessage = lastMsg.content
|
||||||
|
if (lastMsg.type === "image") lastMessage = "[图片]"
|
||||||
|
if (lastMsg.type === "video") lastMessage = "[视频]"
|
||||||
|
if (lastMsg.type === "audio") lastMessage = "[语音]"
|
||||||
|
|
||||||
|
friend.lastMessage = lastMessage.length > 20 ? lastMessage.substring(0, 20) + "..." : lastMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
friend.unreadCount = chatHistory.filter((msg) => msg.senderId === friend.id && !msg.read).length
|
||||||
|
})
|
||||||
|
|
||||||
|
friends.value = friendList
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换当前聊天好友
|
||||||
|
const switchFriend = (friend, currentUserId) => {
|
||||||
|
currentFriend.value = friend
|
||||||
|
friend.unreadCount = 0
|
||||||
|
|
||||||
|
// 加载聊天记录
|
||||||
|
const chatHistory = getChatHistory(friend.id, currentUserId)
|
||||||
|
chatHistory.forEach((msg) => {
|
||||||
|
if (msg.senderId !== currentUserId) {
|
||||||
|
msg.read = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
messages.value = chatHistory
|
||||||
|
saveChatHistory(friend.id, currentUserId, chatHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加消息
|
||||||
|
const addMessage = (message, currentUserId) => {
|
||||||
|
messages.value.push(message)
|
||||||
|
|
||||||
|
if (currentFriend.value) {
|
||||||
|
saveChatHistory(currentFriend.value.id, currentUserId, messages.value)
|
||||||
|
|
||||||
|
// 更新好友列表中的最后消息
|
||||||
|
const friend = friends.value.find((f) => f.id === currentFriend.value.id)
|
||||||
|
if (friend) {
|
||||||
|
let lastMessage = message.content
|
||||||
|
if (message.type === "image") lastMessage = "[图片]"
|
||||||
|
if (message.type === "video") lastMessage = "[视频]"
|
||||||
|
if (message.type === "audio") lastMessage = "[语音]"
|
||||||
|
friend.lastMessage = lastMessage.length > 20 ? lastMessage.substring(0, 20) + "..." : lastMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理接收到的消息
|
||||||
|
const handleIncomingMessage = (messageData, currentUserId) => {
|
||||||
|
const senderId = messageData.sender_user_id
|
||||||
|
const receiverId = messageData.receiver_user_id
|
||||||
|
|
||||||
|
if (receiverId !== currentUserId) return
|
||||||
|
|
||||||
|
const newMessage = {
|
||||||
|
type: getMessageType(messageData.message_type),
|
||||||
|
content: messageData.content,
|
||||||
|
time: new Date().toLocaleTimeString().slice(0, 5),
|
||||||
|
senderId: senderId,
|
||||||
|
read: false,
|
||||||
|
duration: messageData.duration || 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存消息到本地存储
|
||||||
|
const chatHistory = getChatHistory(senderId, currentUserId)
|
||||||
|
chatHistory.push(newMessage)
|
||||||
|
saveChatHistory(senderId, currentUserId, chatHistory)
|
||||||
|
|
||||||
|
// 如果消息来自当前聊天用户,直接显示
|
||||||
|
if (currentFriend.value && senderId == currentFriend.value.id) {
|
||||||
|
newMessage.read = true
|
||||||
|
messages.value.push(newMessage)
|
||||||
|
saveChatHistory(senderId, currentUserId, messages.value)
|
||||||
|
} else {
|
||||||
|
// 更新未读消息计数
|
||||||
|
const friend = friends.value.find((f) => f.id == senderId)
|
||||||
|
if (friend) {
|
||||||
|
friend.unreadCount = (friend.unreadCount || 0) + 1
|
||||||
|
|
||||||
|
// 更新最后消息
|
||||||
|
let lastMessage = newMessage.content
|
||||||
|
if (newMessage.type === "image") lastMessage = "[图片]"
|
||||||
|
if (newMessage.type === "video") lastMessage = "[视频]"
|
||||||
|
if (newMessage.type === "audio") lastMessage = "[语音]"
|
||||||
|
friend.lastMessage = lastMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据消息类型编号获取类型字符串
|
||||||
|
const getMessageType = (type) => {
|
||||||
|
const types = ["text", "image", "audio", "video", "prescription", "medical-record", "video-call"]
|
||||||
|
return types[type] || "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
friends,
|
||||||
|
currentFriend,
|
||||||
|
messages,
|
||||||
|
connectionStatus,
|
||||||
|
socket,
|
||||||
|
isConnected,
|
||||||
|
connectionStatusText,
|
||||||
|
connectionStatusIcon,
|
||||||
|
getChatHistory,
|
||||||
|
saveChatHistory,
|
||||||
|
loadFriends,
|
||||||
|
switchFriend,
|
||||||
|
addMessage,
|
||||||
|
handleIncomingMessage,
|
||||||
|
}
|
||||||
|
})
|
||||||
38
src/stores/theme.js
Normal file
38
src/stores/theme.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineStore } from "pinia"
|
||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
export const useThemeStore = defineStore("theme", () => {
|
||||||
|
const isDarkMode = ref(false)
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
isDarkMode.value = !isDarkMode.value
|
||||||
|
localStorage.setItem("chatTheme", isDarkMode.value ? "dark" : "light")
|
||||||
|
updateTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTheme = () => {
|
||||||
|
if (isDarkMode.value) {
|
||||||
|
document.documentElement.setAttribute("data-theme", "dark")
|
||||||
|
document.documentElement.classList.add("dark")
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute("data-theme")
|
||||||
|
document.documentElement.classList.remove("dark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTheme = () => {
|
||||||
|
const savedTheme = localStorage.getItem("chatTheme")
|
||||||
|
if (savedTheme) {
|
||||||
|
isDarkMode.value = savedTheme === "dark"
|
||||||
|
} else {
|
||||||
|
isDarkMode.value = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
}
|
||||||
|
updateTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDarkMode,
|
||||||
|
toggleTheme,
|
||||||
|
loadTheme,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,87 +1,48 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from "pinia"
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from "vue"
|
||||||
import {loginApi, registerApi} from "@/api/request/user.js";
|
|
||||||
import {message, notification} from "ant-design-vue";
|
|
||||||
|
|
||||||
export const useUserStore = defineStore('user', () => {
|
export const useUserStore = defineStore("user", () => {
|
||||||
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
|
const currentUser = ref(null)
|
||||||
const modal = ref(localStorage.getItem('authModal') || 'false')
|
const isLoggedIn = computed(() => !!currentUser.value)
|
||||||
|
|
||||||
// 添加初始化状态校验
|
// 预设用户数据
|
||||||
const initUser = () => {
|
const presetUsers = ref([
|
||||||
const userData = localStorage.getItem('user')
|
{ id: "1", name: "张三", avatar: "avatar1", color: "#4cc9f0" },
|
||||||
if (!userData) return null
|
{ id: "2", name: "李四", avatar: "avatar2", color: "#ff6b6b" },
|
||||||
try {
|
{ id: "3", name: "王五", avatar: "avatar3", color: "#6a0dad" },
|
||||||
return JSON.parse(userData)
|
{ id: "4", name: "赵六", avatar: "avatar4", color: "#20b2aa" },
|
||||||
} catch {
|
{ id: "5", name: "钱七", avatar: "avatar5", color: "#ffa500" },
|
||||||
localStorage.removeItem('user')
|
{ id: "6", name: "孙八", avatar: "avatar6", color: "#9acd32" },
|
||||||
localStorage.removeItem('token')
|
{ id: "7", name: "周九", avatar: "avatar7", color: "#ff1493" },
|
||||||
return null
|
{ id: "8", name: "吴十", avatar: "avatar8", color: "#4682b4" },
|
||||||
|
{ id: "9", name: "郑十一", avatar: "avatar9", color: "#c71585" },
|
||||||
|
{ id: "10", name: "王十二", avatar: "avatar10", color: "#2e8b57" },
|
||||||
|
])
|
||||||
|
|
||||||
|
const login = (user) => {
|
||||||
|
currentUser.value = user
|
||||||
|
localStorage.setItem("chatUser", JSON.stringify(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
currentUser.value = null
|
||||||
|
localStorage.removeItem("chatUser")
|
||||||
|
localStorage.removeItem("token")
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkSession = () => {
|
||||||
|
const savedUser = localStorage.getItem("chatUser")
|
||||||
|
if (savedUser) {
|
||||||
|
currentUser.value = JSON.parse(savedUser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!user.value?.nick_name)
|
|
||||||
|
|
||||||
function login(credentials, functionName) {
|
|
||||||
console.log(functionName, 'sssssssssssss')
|
|
||||||
loginApi({
|
|
||||||
account: credentials.username,
|
|
||||||
password: credentials.password,
|
|
||||||
remember: credentials.remember,
|
|
||||||
}).then((res) => {
|
|
||||||
const userData = {
|
|
||||||
nick_name: res.nick_name,
|
|
||||||
role_name: res.role_name,
|
|
||||||
role_value: res.role_value,
|
|
||||||
}
|
|
||||||
user.value = userData;
|
|
||||||
localStorage.setItem('user', JSON.stringify(userData))
|
|
||||||
localStorage.setItem('token', res.token)
|
|
||||||
notification.success({
|
|
||||||
message: '登录成功',
|
|
||||||
description: `欢迎回来${res.nick_name}`,
|
|
||||||
})
|
|
||||||
functionName()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
function register(credentials, functionName) {
|
|
||||||
registerApi({
|
|
||||||
account: credentials.username,
|
|
||||||
password: credentials.password,
|
|
||||||
email: credentials.email,
|
|
||||||
code: credentials.code,
|
|
||||||
}).then((res) => {
|
|
||||||
const userData = {
|
|
||||||
nick_name: res.nick_name,
|
|
||||||
role_name: res.role_name,
|
|
||||||
role_value: res.role_value,
|
|
||||||
}
|
|
||||||
user.value = userData;
|
|
||||||
localStorage.setItem('user', JSON.stringify(userData))
|
|
||||||
localStorage.setItem('token', res.token)
|
|
||||||
message.success('注册成功')
|
|
||||||
functionName()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
user.value = null
|
|
||||||
localStorage.removeItem('user')
|
|
||||||
localStorage.removeItem('token')
|
|
||||||
}
|
|
||||||
|
|
||||||
function setModal(value) {
|
|
||||||
modal.value = modal.value === 'false' ? 'true' : 'false';
|
|
||||||
localStorage.setItem('authModal', value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
modal,
|
currentUser,
|
||||||
user,
|
isLoggedIn,
|
||||||
isAuthenticated,
|
presetUsers,
|
||||||
setModal,
|
|
||||||
login,
|
login,
|
||||||
register,
|
logout,
|
||||||
logout
|
checkSession,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
134
src/style.css
134
src/style.css
@@ -1 +1,133 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tailwindcss/utilities";
|
||||||
|
|
||||||
|
/* 自定义CSS变量 */
|
||||||
|
:root {
|
||||||
|
--primary: #4361ee;
|
||||||
|
--secondary: #3f37c9;
|
||||||
|
--success: #4cc9f0;
|
||||||
|
--light: #f8f9fa;
|
||||||
|
--dark: #212529;
|
||||||
|
--light-blue: #e3f2fd;
|
||||||
|
--light-green: #f1f8e9;
|
||||||
|
--accent: #ff6b6b;
|
||||||
|
--gray: #6c757d;
|
||||||
|
|
||||||
|
/* 亮色模式变量 */
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f8f9fa;
|
||||||
|
--bg-tertiary: #e9ecef;
|
||||||
|
--text-primary: #212529;
|
||||||
|
--text-secondary: #6c757d;
|
||||||
|
--border-color: #e1e5eb;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.1);
|
||||||
|
--message-bg-sent: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||||
|
--message-bg-received: #ffffff;
|
||||||
|
--message-text-sent: #ffffff;
|
||||||
|
--message-text-received: #212529;
|
||||||
|
--input-bg: #ffffff;
|
||||||
|
--header-bg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
/* 暗色模式变量 */
|
||||||
|
--bg-primary: #1a1a1a;
|
||||||
|
--bg-secondary: #2d2d2d;
|
||||||
|
--bg-tertiary: #404040;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #b0b0b0;
|
||||||
|
--border-color: #404040;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.3);
|
||||||
|
--message-bg-sent: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||||
|
--message-bg-received: #2d2d2d;
|
||||||
|
--message-text-sent: #ffffff;
|
||||||
|
--message-text-received: #ffffff;
|
||||||
|
--input-bg: #2d2d2d;
|
||||||
|
--header-bg: #2d2d2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全局样式 */
|
||||||
|
* {
|
||||||
|
font-family: "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ant Design 组件样式覆盖 */
|
||||||
|
.ant-input:focus,
|
||||||
|
.ant-input-focused {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(67, 97, 238, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn-primary:hover {
|
||||||
|
background: var(--secondary);
|
||||||
|
border-color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画 */
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.chat-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.friend-list {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-area {
|
||||||
|
min-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
86
src/utils/request.js
Normal file
86
src/utils/request.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import axios from "axios"
|
||||||
|
import { message } from "ant-design-vue"
|
||||||
|
|
||||||
|
// 创建 Axios 实例
|
||||||
|
const service = axios.create({
|
||||||
|
baseURL: "/api/",
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
service.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem("token")
|
||||||
|
if (token) {
|
||||||
|
config.headers["Authorization"] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error("Request error:", error)
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
service.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const { code, result, message: msg } = response.data
|
||||||
|
if (code === 0) {
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
if (code === 401) {
|
||||||
|
message.error(msg)
|
||||||
|
localStorage.removeItem("token")
|
||||||
|
localStorage.removeItem("chatUser")
|
||||||
|
window.location.href = "/login"
|
||||||
|
return Promise.reject(new Error(msg))
|
||||||
|
}
|
||||||
|
message.error(msg)
|
||||||
|
return Promise.reject(new Error(msg))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error("Response error:", error)
|
||||||
|
message.error("网络请求失败")
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 封装请求方法
|
||||||
|
export const get = (url, params = {}) => {
|
||||||
|
return service.get(url, { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const post = (url, data = {}) => {
|
||||||
|
return service.post(url, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const upload = (url, file) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", file)
|
||||||
|
return service.post(url, formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket消息发送API
|
||||||
|
export const sendMessage = (data) => {
|
||||||
|
const messageTypeMap = {
|
||||||
|
text: 0,
|
||||||
|
image: 1,
|
||||||
|
audio: 2,
|
||||||
|
video: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
return post("send-to-user", {
|
||||||
|
sender_user_id: data.senderId,
|
||||||
|
receiver_user_id: data.receiverId,
|
||||||
|
message_type: messageTypeMap[data.type] || 0,
|
||||||
|
message_content: data.content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default service
|
||||||
108
src/views/Chat.vue
Normal file
108
src/views/Chat.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-screen flex bg-gradient-to-br from-blue-900 via-purple-900 to-pink-300 p-5">
|
||||||
|
<!-- 背景装饰 -->
|
||||||
|
<div class="fixed inset-0 pointer-events-none z-0">
|
||||||
|
<div class="absolute w-72 h-72 bg-blue-500 rounded-full opacity-10 top-10 left-10 animate-float"></div>
|
||||||
|
<div class="absolute w-48 h-48 bg-cyan-400 rounded-full opacity-10 top-60 right-15 animate-float-delayed"></div>
|
||||||
|
<div class="absolute w-36 h-36 bg-red-400 rounded-full opacity-10 bottom-10 left-20 animate-float-slow"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 连接状态指示器 -->
|
||||||
|
<ConnectionStatus />
|
||||||
|
|
||||||
|
<!-- 录音状态指示器 -->
|
||||||
|
<RecordingIndicator v-if="isRecording" />
|
||||||
|
|
||||||
|
<!-- 主聊天容器 -->
|
||||||
|
<div class="flex-1 max-w-7xl mx-auto bg-white/90 backdrop-blur-sm rounded-2xl shadow-2xl overflow-hidden flex z-10">
|
||||||
|
<!-- 好友列表 -->
|
||||||
|
<FriendList class="w-80 min-w-80" />
|
||||||
|
|
||||||
|
<!-- 聊天区域 -->
|
||||||
|
<ChatArea class="flex-1 min-w-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 媒体预览模态框 -->
|
||||||
|
<MediaPreview />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, provide } from 'vue';
|
||||||
|
import { useUserStore } from '@/stores/user';
|
||||||
|
import { useChatStore } from '@/stores/chat';
|
||||||
|
import { useThemeStore } from '@/stores/theme';
|
||||||
|
import { useWebSocket } from '@/composables/useWebSocket';
|
||||||
|
import { useRecording } from '@/composables/useRecording';
|
||||||
|
import FriendList from '@/components/FriendList.vue';
|
||||||
|
import ChatArea from '@/components/ChatArea.vue';
|
||||||
|
import ConnectionStatus from '@/components/ConnectionStatus.vue';
|
||||||
|
import RecordingIndicator from '@/components/RecordingIndicator.vue';
|
||||||
|
import MediaPreview from '@/components/MediaPreview.vue';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
|
// 初始化WebSocket连接
|
||||||
|
const { connectWebSocket, disconnectWebSocket } = useWebSocket();
|
||||||
|
|
||||||
|
// 初始化录音功能
|
||||||
|
const { isRecording } = useRecording();
|
||||||
|
|
||||||
|
// 提供录音状态给子组件
|
||||||
|
provide('isRecording', isRecording);
|
||||||
|
|
||||||
|
// 检查用户会话
|
||||||
|
userStore.checkSession();
|
||||||
|
|
||||||
|
// 加载主题
|
||||||
|
themeStore.loadTheme();
|
||||||
|
|
||||||
|
// 加载好友列表
|
||||||
|
const currentUser = userStore.currentUser;
|
||||||
|
const presetUsers = userStore.presetUsers;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (currentUser) {
|
||||||
|
chatStore.loadFriends(presetUsers, currentUser.id);
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
disconnectWebSocket();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes float {
|
||||||
|
0% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
50% { transform: translateY(-20px) rotate(10deg); }
|
||||||
|
100% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-delayed {
|
||||||
|
0% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
50% { transform: translateY(-15px) rotate(-5deg); }
|
||||||
|
100% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-slow {
|
||||||
|
0% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
50% { transform: translateY(-10px) rotate(5deg); }
|
||||||
|
100% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 12s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float-delayed {
|
||||||
|
animation: float-delayed 15s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float-slow {
|
||||||
|
animation: float-slow 18s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
84
src/views/Login.vue
Normal file
84
src/views/Login.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-900 via-purple-900 to-pink-300 p-5">
|
||||||
|
<!-- 背景装饰 -->
|
||||||
|
<div class="fixed inset-0 pointer-events-none z-0">
|
||||||
|
<div class="absolute w-72 h-72 bg-blue-500 rounded-full opacity-10 top-10 left-10 animate-float"></div>
|
||||||
|
<div class="absolute w-48 h-48 bg-cyan-400 rounded-full opacity-10 top-60 right-15 animate-float-delayed"></div>
|
||||||
|
<div class="absolute w-36 h-36 bg-red-400 rounded-full opacity-10 bottom-10 left-20 animate-float-slow"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-card class="w-full max-w-4xl bg-white/90 backdrop-blur-sm shadow-2xl border-0 z-10">
|
||||||
|
<template #title>
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800 mb-2">高级WebSocket聊天系统</h1>
|
||||||
|
<p class="text-gray-600">选择以下用户之一登录聊天系统</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mt-6">
|
||||||
|
<div
|
||||||
|
v-for="user in userStore.presetUsers"
|
||||||
|
:key="user.id"
|
||||||
|
class="user-card p-4 border-2 border-transparent rounded-xl text-center bg-gray-50 hover:bg-gray-100 cursor-pointer transition-all duration-300 hover:transform hover:-translate-y-1 hover:border-blue-500 hover:shadow-lg"
|
||||||
|
@click="handleLogin(user)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-16 h-16 rounded-full mx-auto mb-3 flex items-center justify-center text-white text-xl font-bold"
|
||||||
|
:style="{ background: user.color }"
|
||||||
|
>
|
||||||
|
{{ user.name.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<h4 class="text-lg font-semibold text-gray-800 mb-1">{{ user.name }}</h4>
|
||||||
|
<p class="text-sm text-gray-500">ID: {{ user.id }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useUserStore } from '@/stores/user';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleLogin = (user) => {
|
||||||
|
userStore.login(user);
|
||||||
|
message.success(`欢迎回来,${user.name}!`);
|
||||||
|
router.push('/');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes float {
|
||||||
|
0% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
50% { transform: translateY(-20px) rotate(10deg); }
|
||||||
|
100% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-delayed {
|
||||||
|
0% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
50% { transform: translateY(-15px) rotate(-5deg); }
|
||||||
|
100% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-slow {
|
||||||
|
0% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
50% { transform: translateY(-10px) rotate(5deg); }
|
||||||
|
100% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 12s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float-delayed {
|
||||||
|
animation: float-delayed 15s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float-slow {
|
||||||
|
animation: float-slow 18s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
// target: 'http://127.0.0.1:18007/api/',
|
// target: 'http://127.0.0.1:18007/api/',
|
||||||
target: 'http://127.0.0.1:18001/api/',
|
target: 'http://127.0.0.1:12080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api/, '')
|
rewrite: (path) => path.replace(/^\/api/, '')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user