初始化

This commit is contained in:
2025-07-02 16:54:52 +08:00
parent 67f9f51816
commit adf9f73431
29 changed files with 1866 additions and 174 deletions

View File

@@ -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": {

74
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -1,10 +1,15 @@
<script setup>
</script>
<template>
<router-view />
</template>
<style lang='scss' scoped>
<script setup>
import { onMounted } from 'vue';
import { useUserStore } from '@/stores/user';
</style>
const userStore = useUserStore();
onMounted(() => {
// 检查用户会话
userStore.checkSession();
});
</script>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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,
}
}

View 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,
}
}

View 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,
}
}

View 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,
}
}

View File

@@ -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")

View File

@@ -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 } }
}
})

176
src/stores/chat.js Normal file
View 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
View 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,
}
})

View File

@@ -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 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()
})
const logout = () => {
currentUser.value = null
localStorage.removeItem("chatUser")
localStorage.removeItem("token")
}
function logout() {
user.value = null
localStorage.removeItem('user')
localStorage.removeItem('token')
const checkSession = () => {
const savedUser = localStorage.getItem("chatUser")
if (savedUser) {
currentUser.value = JSON.parse(savedUser)
}
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,
}
})

View File

@@ -1 +1,133 @@
@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
View 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
View 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
View 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>

View File

@@ -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/, '')
}