From adf9f73431d636ffb1b97cb04cf3fead14c1dfa7 Mon Sep 17 00:00:00 2001 From: lq Date: Wed, 2 Jul 2025 16:54:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 +- pnpm-lock.yaml | 74 +++------ src/App.vue | 17 +- src/components/AudioMessage.vue | 95 +++++++++++ src/components/ChatArea.vue | 21 +++ src/components/ChatHeader.vue | 33 ++++ src/components/ConnectionStatus.vue | 43 +++++ src/components/EmojiPicker.vue | 30 ++++ src/components/FileUploadPreview.vue | 58 +++++++ src/components/FriendList.vue | 109 +++++++++++++ src/components/MediaPreview.vue | 91 +++++++++++ src/components/MessageInput.vue | 221 ++++++++++++++++++++++++++ src/components/MessageItem.vue | 95 +++++++++++ src/components/MessageList.vue | 64 ++++++++ src/components/RecordingIndicator.vue | 12 ++ src/composables/useFileUpload.js | 96 +++++++++++ src/composables/useMediaPreview.js | 23 +++ src/composables/useRecording.js | 56 +++++++ src/composables/useWebSocket.js | 79 +++++++++ src/main.js | 25 +-- src/router/index.js | 46 +++--- src/stores/chat.js | 176 ++++++++++++++++++++ src/stores/theme.js | 38 +++++ src/stores/user.js | 119 +++++--------- src/style.css | 134 +++++++++++++++- src/utils/request.js | 86 ++++++++++ src/views/Chat.vue | 108 +++++++++++++ src/views/Login.vue | 84 ++++++++++ vite.config.js | 2 +- 29 files changed, 1866 insertions(+), 174 deletions(-) create mode 100644 src/components/AudioMessage.vue create mode 100644 src/components/ChatArea.vue create mode 100644 src/components/ChatHeader.vue create mode 100644 src/components/ConnectionStatus.vue create mode 100644 src/components/EmojiPicker.vue create mode 100644 src/components/FileUploadPreview.vue create mode 100644 src/components/FriendList.vue create mode 100644 src/components/MediaPreview.vue create mode 100644 src/components/MessageInput.vue create mode 100644 src/components/MessageItem.vue create mode 100644 src/components/MessageList.vue create mode 100644 src/components/RecordingIndicator.vue create mode 100644 src/composables/useFileUpload.js create mode 100644 src/composables/useMediaPreview.js create mode 100644 src/composables/useRecording.js create mode 100644 src/composables/useWebSocket.js create mode 100644 src/stores/chat.js create mode 100644 src/stores/theme.js create mode 100644 src/utils/request.js create mode 100644 src/views/Chat.vue create mode 100644 src/views/Login.vue diff --git a/package.json b/package.json index 3c82eaa..1d69479 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,6 @@ "preview": "vite preview" }, "dependencies": { - "tailwindcss": "^4.1.4", - "vue": "^3.5.13", "@ant-design-vue/pro-layout": "^3.2.5", "@ant-design/icons-vue": "^7.0.1", "@fortawesome/fontawesome-free": "^6.7.2", @@ -21,6 +19,9 @@ "lucide-vue-next": "^0.507.0", "pinia": "^2.1.7", "swiper": "^11.0.5", + "tailwindcss": "^4.1.4", + "vue": "^3.5.13", + "vue-easy-lightbox": "^1.19.0", "vue-router": "^4.2.5" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 061cee9..87b22cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@ant-design/icons-vue': specifier: ^7.0.1 version: 7.0.1(vue@3.5.13) + '@fortawesome/fontawesome-free': + specifier: ^6.7.2 + version: 6.7.2 '@tailwindcss/vite': specifier: ^4.1.4 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: specifier: ^4.1.1 version: 4.2.6(vue@3.5.13) - aos: - specifier: ^2.3.4 - version: 2.3.4 axios: specifier: ^1.6.2 version: 1.9.0 @@ -44,6 +44,9 @@ importers: vue: specifier: ^3.5.13 version: 3.5.13 + vue-easy-lightbox: + specifier: ^1.19.0 + version: 1.19.0(vue@3.5.13) vue-router: specifier: ^4.2.5 version: 4.5.1(vue@3.5.13) @@ -263,6 +266,10 @@ packages: cpu: [x64] 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': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} @@ -295,42 +302,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -388,67 +389,56 @@ packages: resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.40.0': resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.40.0': resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.40.0': resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.40.0': resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.40.0': resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.40.0': resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.40.0': resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.40.0': resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.40.0': resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.40.0': resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.40.0': resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==} @@ -506,28 +496,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.4': resolution: {integrity: sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.4': resolution: {integrity: sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.4': resolution: {integrity: sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.4': resolution: {integrity: sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==} @@ -632,9 +618,6 @@ packages: peerDependencies: vue: '>=3.2.0' - aos@2.3.4: - resolution: {integrity: sha512-zh/ahtR2yME4I51z8IttIt4lC1Nw0ktsFtmeDzID1m9naJnWXhCoARaCgNOGXb5CLy3zm+wqmRAEgMYB5E2HUw==} - array-tree-filter@2.1.0: resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==} @@ -659,9 +642,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - classlist-polyfill@1.2.0: - resolution: {integrity: sha512-GzIjNdcEtH4ieA2S8NmrSxv7DfEV5fmixQeyTmqmRmRJPGpRBaSnA2a0VrCjyT8iW8JjEdMbKzDotAJf+ajgaQ==} - combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -851,28 +831,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.29.2: resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.29.2: resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.29.2: resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.29.2: resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==} @@ -893,12 +869,6 @@ packages: lodash-es@4.17.21: 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: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -1081,6 +1051,12 @@ packages: '@vue/composition-api': 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: resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==} peerDependencies: @@ -1229,6 +1205,8 @@ snapshots: '@esbuild/win32-x64@0.25.3': optional: true + '@fortawesome/fontawesome-free@6.7.2': {} + '@jridgewell/sourcemap-codec@1.5.0': {} '@parcel/watcher-android-arm64@2.5.1': @@ -1547,12 +1525,6 @@ snapshots: vue-types: 3.0.2(vue@3.5.13) 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: {} async-validator@4.2.5: {} @@ -1581,8 +1553,6 @@ snapshots: dependencies: readdirp: 4.1.2 - classlist-polyfill@1.2.0: {} - combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -1787,10 +1757,6 @@ snapshots: lodash-es@4.17.21: {} - lodash.debounce@4.0.8: {} - - lodash.throttle@4.1.1: {} - lodash@4.17.21: {} loose-envify@1.4.0: @@ -1944,6 +1910,10 @@ snapshots: dependencies: 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): dependencies: '@vue/devtools-api': 6.6.4 diff --git a/src/App.vue b/src/App.vue index 3602e60..da327a1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,10 +1,15 @@ - - - +const userStore = useUserStore(); + +onMounted(() => { + // 检查用户会话 + userStore.checkSession(); +}); + diff --git a/src/components/AudioMessage.vue b/src/components/AudioMessage.vue new file mode 100644 index 0000000..d9b6a95 --- /dev/null +++ b/src/components/AudioMessage.vue @@ -0,0 +1,95 @@ + + + diff --git a/src/components/ChatArea.vue b/src/components/ChatArea.vue new file mode 100644 index 0000000..90e4cae --- /dev/null +++ b/src/components/ChatArea.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/ChatHeader.vue b/src/components/ChatHeader.vue new file mode 100644 index 0000000..958fa1a --- /dev/null +++ b/src/components/ChatHeader.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/ConnectionStatus.vue b/src/components/ConnectionStatus.vue new file mode 100644 index 0000000..cf32971 --- /dev/null +++ b/src/components/ConnectionStatus.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/EmojiPicker.vue b/src/components/EmojiPicker.vue new file mode 100644 index 0000000..b595e56 --- /dev/null +++ b/src/components/EmojiPicker.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/FileUploadPreview.vue b/src/components/FileUploadPreview.vue new file mode 100644 index 0000000..a504040 --- /dev/null +++ b/src/components/FileUploadPreview.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/components/FriendList.vue b/src/components/FriendList.vue new file mode 100644 index 0000000..5d13261 --- /dev/null +++ b/src/components/FriendList.vue @@ -0,0 +1,109 @@ + + + diff --git a/src/components/MediaPreview.vue b/src/components/MediaPreview.vue new file mode 100644 index 0000000..e1b973f --- /dev/null +++ b/src/components/MediaPreview.vue @@ -0,0 +1,91 @@ + + + \ No newline at end of file diff --git a/src/components/MessageInput.vue b/src/components/MessageInput.vue new file mode 100644 index 0000000..25e2d1a --- /dev/null +++ b/src/components/MessageInput.vue @@ -0,0 +1,221 @@ + + + diff --git a/src/components/MessageItem.vue b/src/components/MessageItem.vue new file mode 100644 index 0000000..3c616e0 --- /dev/null +++ b/src/components/MessageItem.vue @@ -0,0 +1,95 @@ + + + \ No newline at end of file diff --git a/src/components/MessageList.vue b/src/components/MessageList.vue new file mode 100644 index 0000000..28cff89 --- /dev/null +++ b/src/components/MessageList.vue @@ -0,0 +1,64 @@ + + + + \ No newline at end of file diff --git a/src/components/RecordingIndicator.vue b/src/components/RecordingIndicator.vue new file mode 100644 index 0000000..2ee6299 --- /dev/null +++ b/src/components/RecordingIndicator.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/composables/useFileUpload.js b/src/composables/useFileUpload.js new file mode 100644 index 0000000..66bc93c --- /dev/null +++ b/src/composables/useFileUpload.js @@ -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, + } +} diff --git a/src/composables/useMediaPreview.js b/src/composables/useMediaPreview.js new file mode 100644 index 0000000..6edd95f --- /dev/null +++ b/src/composables/useMediaPreview.js @@ -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, + } +} diff --git a/src/composables/useRecording.js b/src/composables/useRecording.js new file mode 100644 index 0000000..481c6df --- /dev/null +++ b/src/composables/useRecording.js @@ -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, + } +} diff --git a/src/composables/useWebSocket.js b/src/composables/useWebSocket.js new file mode 100644 index 0000000..8fbcad6 --- /dev/null +++ b/src/composables/useWebSocket.js @@ -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, + } +} diff --git a/src/main.js b/src/main.js index efadd61..291c778 100644 --- a/src/main.js +++ b/src/main.js @@ -1,16 +1,19 @@ -import { createApp } from 'vue' -import { createPinia } from 'pinia' -import Antd from 'ant-design-vue' -import './style.css' -import App from './App.vue' -// 注册路由 -import router from './router' +import { createApp } from "vue" +import { createPinia } from "pinia" +import Antd from "ant-design-vue" +import router from "./router" +import App from "./App.vue" + +import "ant-design-vue/dist/reset.css" +import "./style.css" +import VueEasyLightbox from "vue-easy-lightbox"; const app = createApp(App) +const pinia = createPinia() - -// 注册Ant Design组件 -app.use(createPinia()) +app.use(pinia) +app.use(VueEasyLightbox); app.use(router) app.use(Antd) -app.mount('#app') + +app.mount("#app") diff --git a/src/router/index.js b/src/router/index.js index 796771d..77448b2 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,38 +1,40 @@ -import { createRouter, createWebHistory } from 'vue-router' -import { useUserStore } from '@/stores/user' -import MainLayout from '@/layouts/Header.vue' +import { createRouter, createWebHistory } from "vue-router" +import { useUserStore } from "@/stores/user" -// 在路由配置中添加个人中心路由 const routes = [ { - path: '/', - component: MainLayout, - children: [ - { path: '', name: 'Home', component: () => import('@/views/index/index.vue') }, - ] + path: "/login", + name: "Login", + component: () => import("@/views/Login.vue"), + meta: { requiresGuest: true }, + }, + { + path: "/", + name: "Chat", + component: () => import("@/views/Chat.vue"), + meta: { requiresAuth: true }, + }, + { + path: "/:pathMatch(.*)*", + redirect: "/", }, ] const router = createRouter({ history: createWebHistory(), routes, - scrollBehavior(to, from, savedPosition) { - return savedPosition || { top: 0 } - } }) -router.beforeEach((to, from) => { +router.beforeEach((to, from, next) => { 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 - -router.beforeEach((to, from) => { - const userStore = useUserStore() - if (to.meta.requiresAuth && !userStore.user) { - return { name: 'Login', query: { redirect: to.fullPath } } - } -}) diff --git a/src/stores/chat.js b/src/stores/chat.js new file mode 100644 index 0000000..9a964b6 --- /dev/null +++ b/src/stores/chat.js @@ -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, + } +}) diff --git a/src/stores/theme.js b/src/stores/theme.js new file mode 100644 index 0000000..3f9f086 --- /dev/null +++ b/src/stores/theme.js @@ -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, + } +}) diff --git a/src/stores/user.js b/src/stores/user.js index 5987aad..fdff796 100644 --- a/src/stores/user.js +++ b/src/stores/user.js @@ -1,87 +1,48 @@ -import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import {loginApi, registerApi} from "@/api/request/user.js"; -import {message, notification} from "ant-design-vue"; +import { defineStore } from "pinia" +import { ref, computed } from "vue" -export const useUserStore = defineStore('user', () => { - const user = ref(JSON.parse(localStorage.getItem('user') || 'null')) - const modal = ref(localStorage.getItem('authModal') || 'false') +export const useUserStore = defineStore("user", () => { + const currentUser = ref(null) + const isLoggedIn = computed(() => !!currentUser.value) - // 添加初始化状态校验 - const initUser = () => { - const userData = localStorage.getItem('user') - if (!userData) return null - try { - return JSON.parse(userData) - } catch { - localStorage.removeItem('user') - localStorage.removeItem('token') - return null + // 预设用户数据 + const presetUsers = ref([ + { id: "1", name: "张三", avatar: "avatar1", color: "#4cc9f0" }, + { id: "2", name: "李四", avatar: "avatar2", color: "#ff6b6b" }, + { id: "3", name: "王五", avatar: "avatar3", color: "#6a0dad" }, + { id: "4", name: "赵六", avatar: "avatar4", color: "#20b2aa" }, + { id: "5", name: "钱七", avatar: "avatar5", color: "#ffa500" }, + { id: "6", name: "孙八", avatar: "avatar6", color: "#9acd32" }, + { id: "7", name: "周九", avatar: "avatar7", color: "#ff1493" }, + { 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 { - modal, - user, - isAuthenticated, - setModal, + currentUser, + isLoggedIn, + presetUsers, login, - register, - logout + logout, + checkSession, } -}) \ No newline at end of file +}) diff --git a/src/style.css b/src/style.css index a461c50..599424c 100644 --- a/src/style.css +++ b/src/style.css @@ -1 +1,133 @@ -@import "tailwindcss"; \ No newline at end of file +@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; + } +} diff --git a/src/utils/request.js b/src/utils/request.js new file mode 100644 index 0000000..eb866e2 --- /dev/null +++ b/src/utils/request.js @@ -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 diff --git a/src/views/Chat.vue b/src/views/Chat.vue new file mode 100644 index 0000000..a08ff9b --- /dev/null +++ b/src/views/Chat.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/src/views/Login.vue b/src/views/Login.vue new file mode 100644 index 0000000..ad7555a --- /dev/null +++ b/src/views/Login.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/vite.config.js b/vite.config.js index df31b24..8ab9b7e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -19,7 +19,7 @@ export default defineConfig({ proxy: { '/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, rewrite: (path) => path.replace(/^\/api/, '') }