优化了本地存储和一些组件细分,完成了复制图片、适配可以直接发送的功能

This commit is contained in:
2025-07-03 15:36:34 +08:00
parent adf9f73431
commit bcee1e7cc6
34 changed files with 5954 additions and 790 deletions

View File

@@ -1,95 +1,165 @@
<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="{
<div
class="rounded-full p-3 flex items-center gap-3 min-w-60"
:class="audioContainerClasses"
>
<a-button
type="primary"
shape="circle"
size="large"
@click="togglePlay"
class="flex-shrink-0"
:class="{ 'bg-white text-blue-500 hover:bg-gray-100': isSent }"
>
<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="rounded-full transition-all duration-150"
:class="waveBarClasses"
:style="{
width: '3px',
height: isPlaying ? `${Math.random() * 20 + 10}px` : '4px'
height: isPlaying ? `${getWaveHeight(i)}px` : '4px',
animationDelay: `${i * 50}ms`
}"
></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>
</div>
<!-- 进度条 -->
<div class="w-full rounded-full h-1" :class="progressTrackClasses">
<div
class="h-1 rounded-full transition-all duration-100"
:class="progressBarClasses"
:style="{ width: `${progress}%` }"
></div>
</div>
</div>
<div class="text-xs flex-shrink-0" :class="durationTextClasses">
{{ formatDuration(message.duration || 0) }}
</div>
</div>
</template>
<script setup>
import { ref, onUnmounted } from 'vue';
import { ref, onUnmounted, computed } from 'vue';
import { CaretRightOutlined, PauseOutlined } from '@ant-design/icons-vue';
const props = defineProps({
message: {
type: Object,
required: true
}
message: {
type: Object,
required: true
},
isSent: {
type: Boolean,
default: false
}
});
const isPlaying = ref(false);
const progress = ref(0);
const audio = ref(null);
// 样式计算属性
const audioContainerClasses = computed(() => {
if (props.isSent) {
return 'bg-white/20 backdrop-blur-sm';
} else {
return 'bg-gray-100 dark:bg-gray-600';
}
});
const waveBarClasses = computed(() => {
if (props.isSent) {
return ['bg-white', { 'animate-wave': isPlaying.value }];
} else {
return ['bg-blue-500', { 'animate-wave': isPlaying.value }];
}
});
const progressTrackClasses = computed(() => {
if (props.isSent) {
return 'bg-white/30';
} else {
return 'bg-gray-300 dark:bg-gray-500';
}
});
const progressBarClasses = computed(() => {
if (props.isSent) {
return 'bg-white';
} else {
return 'bg-blue-500';
}
});
const durationTextClasses = computed(() => {
if (props.isSent) {
return 'text-white/80';
} else {
return 'text-gray-600 dark:text-gray-300';
}
});
// 生成微信风格的波形高度
const getWaveHeight = (index) => {
const baseHeight = 4;
const maxHeight = 20;
const wavePattern = Math.sin((index * 0.5) + (Date.now() * 0.01)) * 0.5 + 0.5;
return baseHeight + (maxHeight - baseHeight) * wavePattern;
};
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;
}
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')}`;
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;
}
if (audio.value) {
audio.value.pause();
audio.value = null;
}
});
</script>
<style scoped>
@keyframes wave {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(1.5); }
}
.animate-wave {
animation: wave 1s ease-in-out infinite;
}
</style>

View File

@@ -1,21 +1,18 @@
<template>
<div class="flex flex-col h-full">
<!-- 聊天头部 -->
<ChatHeader />
<!-- 消息区域 -->
<MessageList class="flex-1" />
<!-- 输入区域 -->
<MessageInput />
</div>
<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

@@ -1,27 +1,27 @@
<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">
<div class="p-5 border-b border-gray-200 bg-white dark:bg-gray-800 dark:border-gray-700">
<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 dark:text-gray-200">{{ 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'">
<span :class="isFriendOnline ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
{{ isFriendOnline ? '在线' : '离线' }}
</span>
</p>
</div>
</div>
</p>
</div>
</div>
</div>
</template>
<script setup>

View File

@@ -0,0 +1,244 @@
<template>
<div class="custom-input-container" :class="{ 'dark': isDark, 'focused': isFocused, 'disabled': disabled }">
<div class="input-prefix" v-if="prefixIcon || $slots.prefix">
<slot name="prefix">
<i :class="prefixIcon" v-if="prefixIcon"></i>
</slot>
</div>
<input
ref="inputRef"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:maxlength="maxlength"
class="custom-input"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
/>
<div class="input-suffix" v-if="suffixIcon || $slots.suffix || clearable">
<button
v-if="clearable && modelValue && !disabled"
class="clear-btn"
@click="handleClear"
type="button"
>
<i class="fas fa-times"></i>
</button>
<slot name="suffix">
<i :class="suffixIcon" v-if="suffixIcon"></i>
</slot>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useThemeStore } from '@/stores/theme';
const themeStore = useThemeStore();
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
},
type: {
type: String,
default: 'text'
},
placeholder: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
},
clearable: {
type: Boolean,
default: false
},
prefixIcon: {
type: String,
default: ''
},
suffixIcon: {
type: String,
default: ''
},
maxlength: {
type: Number,
default: undefined
}
});
const emit = defineEmits(['update:modelValue', 'focus', 'blur', 'clear', 'keydown']);
const inputRef = ref(null);
const isFocused = ref(false);
const isDark = computed(() => themeStore.isDarkMode);
const handleInput = (event) => {
emit('update:modelValue', event.target.value);
};
const handleFocus = (event) => {
isFocused.value = true;
emit('focus', event);
};
const handleBlur = (event) => {
isFocused.value = false;
emit('blur', event);
};
const handleClear = () => {
emit('update:modelValue', '');
emit('clear');
inputRef.value?.focus();
};
const handleKeydown = (event) => {
emit('keydown', event);
};
const focus = () => {
inputRef.value?.focus();
};
const blur = () => {
inputRef.value?.blur();
};
defineExpose({
focus,
blur
});
</script>
<style scoped>
.custom-input-container {
position: relative;
display: flex;
align-items: center;
background: #ffffff;
border: 2px solid #e2e8f0;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.custom-input-container:hover {
border-color: #cbd5e1;
}
.custom-input-container.focused {
border-color: #4361ee;
box-shadow: 0 0 0 4px rgba(67, 97, 238, 0.1);
}
.custom-input-container.disabled {
background: #f8fafc;
border-color: #e2e8f0;
cursor: not-allowed;
}
.custom-input-container.dark {
background: #374151;
border-color: #4b5563;
}
.custom-input-container.dark:hover {
border-color: #6b7280;
}
.custom-input-container.dark.focused {
border-color: #4361ee;
box-shadow: 0 0 0 4px rgba(67, 97, 238, 0.2);
}
.custom-input-container.dark.disabled {
background: #1f2937;
border-color: #374151;
}
.input-prefix,
.input-suffix {
display: flex;
align-items: center;
padding: 0 12px;
color: #64748b;
font-size: 14px;
}
.custom-input-container.dark .input-prefix,
.custom-input-container.dark .input-suffix {
color: #9ca3af;
}
.custom-input {
flex: 1;
border: none;
outline: none;
background: transparent;
padding: 12px 16px;
font-size: 14px;
color: #1a202c;
line-height: 1.5;
}
.custom-input::placeholder {
color: #94a3b8;
}
.custom-input:disabled {
cursor: not-allowed;
color: #94a3b8;
}
.custom-input-container.dark .custom-input {
color: #f7fafc;
}
.custom-input-container.dark .custom-input::placeholder {
color: #6b7280;
}
.custom-input-container.dark .custom-input:disabled {
color: #6b7280;
}
.clear-btn {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:hover {
color: #64748b;
background: #f1f5f9;
}
.custom-input-container.dark .clear-btn:hover {
color: #d1d5db;
background: #4b5563;
}
</style>

View File

@@ -0,0 +1,335 @@
<template>
<teleport to="body">
<div
v-if="visible"
class="modal-overlay"
:class="{ 'dark': isDark }"
@click="handleOverlayClick"
>
<div
class="modal-container"
:class="[size, { 'dark': isDark }]"
@click.stop
>
<!-- 模态框头部 -->
<div class="modal-header" v-if="showHeader">
<h3 class="modal-title">{{ title }}</h3>
<button
class="modal-close-btn"
@click="handleClose"
v-if="closable"
>
<i class="fas fa-times"></i>
</button>
</div>
<!-- 模态框内容 -->
<div class="modal-body">
<slot></slot>
</div>
<!-- 模态框底部 -->
<div class="modal-footer" v-if="showFooter">
<slot name="footer">
<button
class="modal-btn modal-btn-cancel"
@click="handleCancel"
v-if="showCancel"
>
{{ cancelText }}
</button>
<button
class="modal-btn modal-btn-confirm"
@click="handleConfirm"
v-if="showConfirm"
>
{{ confirmText }}
</button>
</slot>
</div>
</div>
</div>
</teleport>
</template>
<script setup>
import { computed } from 'vue';
import { useThemeStore } from '@/stores/theme';
const themeStore = useThemeStore();
const props = defineProps({
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
size: {
type: String,
default: 'medium', // small, medium, large, full
validator: (value) => ['small', 'medium', 'large', 'full'].includes(value)
},
closable: {
type: Boolean,
default: true
},
maskClosable: {
type: Boolean,
default: true
},
showHeader: {
type: Boolean,
default: true
},
showFooter: {
type: Boolean,
default: false
},
showCancel: {
type: Boolean,
default: true
},
showConfirm: {
type: Boolean,
default: true
},
cancelText: {
type: String,
default: '取消'
},
confirmText: {
type: String,
default: '确定'
}
});
const emit = defineEmits(['close', 'cancel', 'confirm']);
const isDark = computed(() => themeStore.isDarkMode);
const handleOverlayClick = () => {
if (props.maskClosable) {
handleClose();
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
emit('cancel');
handleClose();
};
const handleConfirm = () => {
emit('confirm');
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.3s ease;
}
.modal-overlay.dark {
background: rgba(0, 0, 0, 0.7);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-container.dark {
background: #2d2d2d;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-container.small {
width: 400px;
}
.modal-container.medium {
width: 600px;
}
.modal-container.large {
width: 800px;
}
.modal-container.full {
width: 95vw;
height: 95vh;
}
.modal-header {
padding: 24px 24px 16px;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-container.dark .modal-header {
border-bottom-color: #374151;
}
.modal-title {
font-size: 20px;
font-weight: 600;
color: #1a202c;
margin: 0;
}
.modal-container.dark .modal-title {
color: #f7fafc;
}
.modal-close-btn {
width: 32px;
height: 32px;
border: none;
background: #f7fafc;
border-radius: 8px;
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.modal-close-btn:hover {
background: #e2e8f0;
color: #475569;
}
.modal-container.dark .modal-close-btn {
background: #374151;
color: #9ca3af;
}
.modal-container.dark .modal-close-btn:hover {
background: #4b5563;
color: #d1d5db;
}
.modal-body {
padding: 24px;
flex: 1;
overflow-y: auto;
}
.modal-footer {
padding: 16px 24px 24px;
border-top: 1px solid #e2e8f0;
display: flex;
gap: 12px;
justify-content: flex-end;
}
.modal-container.dark .modal-footer {
border-top-color: #374151;
}
.modal-btn {
padding: 10px 20px;
border-radius: 8px;
border: none;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-width: 80px;
}
.modal-btn-cancel {
background: #f1f5f9;
color: #475569;
}
.modal-btn-cancel:hover {
background: #e2e8f0;
}
.modal-btn-confirm {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
}
.modal-btn-confirm:hover {
background: linear-gradient(135deg, #3f37c9, #3730a3);
transform: translateY(-1px);
}
.modal-container.dark .modal-btn-cancel {
background: #374151;
color: #d1d5db;
}
.modal-container.dark .modal-btn-cancel:hover {
background: #4b5563;
}
/* 响应式设计 */
@media (max-width: 768px) {
.modal-container.small,
.modal-container.medium,
.modal-container.large {
width: 95vw;
margin: 0 10px;
}
.modal-header {
padding: 20px 20px 12px;
}
.modal-body {
padding: 20px;
}
.modal-footer {
padding: 12px 20px 20px;
}
}
</style>

View File

@@ -0,0 +1,311 @@
<template>
<div
class="custom-textarea-container"
:class="{ 'dark': isDark, 'focused': isFocused, 'disabled': disabled }"
@drop="handleDrop"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
>
<textarea
ref="textareaRef"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:maxlength="maxlength"
:rows="rows"
class="custom-textarea"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
@paste="handlePaste"
></textarea>
<div class="textarea-actions" v-if="clearable || $slots.actions">
<button
v-if="clearable && modelValue && !disabled"
class="clear-btn"
@click="handleClear"
type="button"
>
<i class="fas fa-times"></i>
</button>
<slot name="actions"></slot>
</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue';
import { useThemeStore } from '@/stores/theme';
const themeStore = useThemeStore();
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
},
clearable: {
type: Boolean,
default: false
},
maxlength: {
type: Number,
default: undefined
},
rows: {
type: Number,
default: 3
},
autoResize: {
type: Boolean,
default: false
},
maxRows: {
type: Number,
default: 6
},
detectPaste: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'focus', 'blur', 'clear', 'keydown', 'paste-file']);
const textareaRef = ref(null);
const isFocused = ref(false);
const isDark = computed(() => themeStore.isDarkMode);
const handleInput = (event) => {
emit('update:modelValue', event.target.value);
if (props.autoResize) {
autoResize();
}
};
const handleFocus = (event) => {
isFocused.value = true;
emit('focus', event);
};
const handleBlur = (event) => {
isFocused.value = false;
emit('blur', event);
};
const handleClear = () => {
emit('update:modelValue', '');
emit('clear');
textareaRef.value?.focus();
if (props.autoResize) {
autoResize();
}
};
const handleKeydown = (event) => {
emit('keydown', event);
};
const handlePaste = (event) => {
if (!props.detectPaste) return;
const items = event.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
event.preventDefault();
const file = item.getAsFile();
if (file) {
emit('paste-file', file);
}
break;
}
}
};
const handleDrop = (event) => {
event.preventDefault();
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
emit('paste-file', files[0]);
}
};
const handleDragOver = (event) => {
event.preventDefault();
};
const handleDragEnter = (event) => {
event.preventDefault();
};
const handleDragLeave = (event) => {
event.preventDefault();
};
const autoResize = () => {
nextTick(() => {
if (!textareaRef.value) return;
const textarea = textareaRef.value;
textarea.style.height = 'auto';
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight);
const maxHeight = lineHeight * props.maxRows;
const scrollHeight = textarea.scrollHeight;
textarea.style.height = Math.min(scrollHeight, maxHeight) + 'px';
});
};
const focus = () => {
textareaRef.value?.focus();
};
const blur = () => {
textareaRef.value?.blur();
};
// 监听内容变化自动调整高度
watch(() => props.modelValue, () => {
if (props.autoResize) {
autoResize();
}
});
defineExpose({
focus,
blur
});
</script>
<style scoped>
.custom-textarea-container {
position: relative;
background: #ffffff;
border: 2px solid #e2e8f0;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.custom-textarea-container:hover {
border-color: #cbd5e1;
}
.custom-textarea-container.focused {
border-color: #4361ee;
box-shadow: 0 0 0 4px rgba(67, 97, 238, 0.1);
}
.custom-textarea-container.disabled {
background: #f8fafc;
border-color: #e2e8f0;
cursor: not-allowed;
}
.custom-textarea-container.dark {
background: #374151;
border-color: #4b5563;
}
.custom-textarea-container.dark:hover {
border-color: #6b7280;
}
.custom-textarea-container.dark.focused {
border-color: #4361ee;
box-shadow: 0 0 0 4px rgba(67, 97, 238, 0.2);
}
.custom-textarea-container.dark.disabled {
background: #1f2937;
border-color: #374151;
}
.custom-textarea {
width: 100%;
border: none;
outline: none;
background: transparent;
padding: 12px 16px;
font-size: 14px;
color: #1a202c;
line-height: 1.5;
resize: none;
font-family: inherit;
}
.custom-textarea::placeholder {
color: #94a3b8;
}
.custom-textarea:disabled {
cursor: not-allowed;
color: #94a3b8;
}
.custom-textarea-container.dark .custom-textarea {
color: #f7fafc;
}
.custom-textarea-container.dark .custom-textarea::placeholder {
color: #6b7280;
}
.custom-textarea-container.dark .custom-textarea:disabled {
color: #6b7280;
}
.textarea-actions {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 8px;
}
.clear-btn {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:hover {
color: #64748b;
background: #f1f5f9;
}
.custom-textarea-container.dark .clear-btn:hover {
color: #d1d5db;
background: #4b5563;
}
</style>

View File

@@ -1,16 +1,16 @@
<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 class="emoji-picker-container bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 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 dark:hover:bg-gray-600 text-lg transition-all hover:scale-110"
@click="$emit('select', emoji)"
>
{{ emoji }}
</div>
</div>
</div>
</template>
<script setup>
@@ -19,12 +19,12 @@ import { ref } from 'vue';
defineEmits(['select']);
const emojiList = ref([
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣',
'😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰',
'😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜',
'🤪', '🤨', '🧐', '🤓', '😎', '🤩', '🥳', '😏',
'😒', '😞', '😔', '😟', '😕', '🙁', '☹️', '😣',
'😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠',
'😡', '🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨'
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣',
'😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰',
'😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜',
'🤪', '🤨', '🧐', '🤓', '😎', '🤩', '🥳', '😏',
'😒', '😞', '😔', '😟', '😕', '🙁', '☹️', '😣',
'😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠',
'😡', '🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨'
]);
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div class="flex flex-col items-center justify-center h-full bg-gray-50 dark:bg-gray-900">
<!-- SVG 插画 -->
<div class="mb-8">
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 聊天气泡 -->
<ellipse cx="70" cy="80" rx="35" ry="25" fill="#E3F2FD" stroke="#2196F3" stroke-width="2"/>
<ellipse cx="130" cy="120" rx="35" ry="25" fill="#F3E5F5" stroke="#9C27B0" stroke-width="2"/>
<!-- 消息线条 -->
<line x1="45" y1="85" x2="55" y2="85" stroke="#2196F3" stroke-width="2" stroke-linecap="round"/>
<line x1="45" y1="90" x2="65" y2="90" stroke="#2196F3" stroke-width="2" stroke-linecap="round"/>
<line x1="45" y1="95" x2="60" y2="95" stroke="#2196F3" stroke-width="2" stroke-linecap="round"/>
<line x1="110" y1="115" x2="125" y2="115" stroke="#9C27B0" stroke-width="2" stroke-linecap="round"/>
<line x1="110" y1="120" x2="140" y2="120" stroke="#9C27B0" stroke-width="2" stroke-linecap="round"/>
<line x1="110" y1="125" x2="135" y2="125" stroke="#9C27B0" stroke-width="2" stroke-linecap="round"/>
<!-- 装饰性元素 -->
<circle cx="50" cy="50" r="3" fill="#FFB74D"/>
<circle cx="150" cy="60" r="3" fill="#81C784"/>
<circle cx="40" cy="140" r="3" fill="#F06292"/>
<circle cx="160" cy="150" r="3" fill="#64B5F6"/>
<!-- 连接线 -->
<path d="M70 105 Q100 110 130 95" stroke="#E0E0E0" stroke-width="2" fill="none" stroke-dasharray="5,5"/>
</svg>
</div>
<!-- 文字提示 -->
<div class="text-center">
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">
开始聊天吧
</h3>
<p class="text-gray-500 dark:text-gray-400 max-w-md">
从左侧选择一个联系人开始对话享受愉快的聊天体验
</p>
</div>
<!-- 装饰性动画元素 -->
<div class="absolute top-20 left-20 w-4 h-4 bg-blue-200 rounded-full animate-bounce opacity-60"></div>
<div class="absolute bottom-20 right-20 w-6 h-6 bg-purple-200 rounded-full animate-pulse opacity-60"></div>
<div class="absolute top-40 right-40 w-3 h-3 bg-green-200 rounded-full animate-ping opacity-60"></div>
</div>
</template>
<script setup>
// 空状态组件 - 当没有选择聊天用户时显示
</script>

View File

@@ -1,43 +1,42 @@
<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="mb-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<div class="flex items-center gap-3">
<!-- 文件缩略图 -->
<div class="w-16 h-16 rounded-lg overflow-hidden bg-gray-200 dark:bg-gray-600 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-1 min-w-0">
<div class="font-medium text-gray-800 dark:text-gray-200 truncate">{{ uploadPreview.name }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ 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 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>
@@ -49,10 +48,10 @@ 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];
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

@@ -1,109 +1,339 @@
<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="friend-list" :class="{ 'dark': themeStore.isDarkMode }">
<div class="list-header">
<h2 class="list-title">
{{ getListTitle() }}
<button
class="theme-toggle"
@click="themeStore.toggleTheme"
:title="themeStore.isDarkMode ? '切换到亮色模式' : '切换到暗色模式'"
>
<i class="fas" :class="themeStore.isDarkMode ? 'fa-sun' : 'fa-moon'"></i>
</button>
</h2>
<!-- 好友列表 -->
<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 class="search-container">
<CustomInput
v-model="searchQuery"
placeholder="搜索联系人..."
:prefix-icon="'fas fa-search'"
class="search-input"
/>
</div>
</div>
<div class="friends-container">
<VirtualList
v-if="currentView === 'chat'"
:items="filteredFriends"
:item-height="80"
class="friends-list"
>
<template #default="{ item: friend }">
<div
class="friend-item"
:class="{ 'active': chatStore.currentFriend?.id === friend.id }"
@click="selectFriend(friend)"
>
<div
class="friend-avatar"
:style="{ background: friend.color }"
>
{{ friend.name.charAt(0) }}
</div>
<div class="friend-info">
<div class="friend-name">{{ friend.name }}</div>
<div class="friend-message">{{ friend.lastMessage || '点击开始聊天' }}</div>
</div>
<div class="friend-meta">
<div class="message-time" v-if="friend.lastMessageTime">
{{ friend.lastMessageTime }}
</div>
<div
v-if="friend.unreadCount > 0"
class="unread-badge"
>
{{ friend.unreadCount > 99 ? '99+' : friend.unreadCount }}
</div>
</div>
</div>
</template>
</VirtualList>
<FriendsManagement v-else-if="currentView === 'friends'" />
<GroupsManagement v-else-if="currentView === 'groups'" />
<MomentsView v-else-if="currentView === 'moments'" />
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useUserStore } from '@/stores/user';
import { ref, computed, watch } from 'vue';
import { useChatStore } from '@/stores/chat';
import { useThemeStore } from '@/stores/theme';
import { SearchOutlined, UserOutlined } from '@ant-design/icons-vue';
import CustomInput from './CustomInput.vue';
import VirtualList from './VirtualList.vue';
import FriendsManagement from './FriendsManagement.vue';
import GroupsManagement from './GroupsManagement.vue';
import MomentsView from './MomentsView.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 props = defineProps({
currentView: {
type: String,
default: 'chat'
}
});
// 切换好友
const handleSwitchFriend = (friend) => {
chatStore.switchFriend(friend, userStore.currentUser.id);
const searchQuery = ref('');
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 getListTitle = () => {
const titles = {
chat: '聊天',
friends: '好友',
groups: '群聊',
moments: '圈子'
};
return titles[props.currentView] || '聊天';
};
const selectFriend = (friend) => {
chatStore.setCurrentFriend(friend);
// 清除未读消息
friend.unreadCount = 0;
};
// 监听当前视图变化,清空搜索
watch(() => props.currentView, () => {
searchQuery.value = '';
});
</script>
<style scoped>
.friend-list {
width: 320px;
min-width: 320px;
height: 100%;
background: #ffffff;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.friend-list.dark {
background: #2d2d2d;
border-right-color: #374151;
}
.list-header {
padding: 24px 20px;
border-bottom: 1px solid #e2e8f0;
background: #ffffff;
}
.friend-list.dark .list-header {
background: #2d2d2d;
border-bottom-color: #374151;
}
.list-title {
font-size: 24px;
font-weight: 700;
color: #1a202c;
margin: 0 0 20px 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.friend-list.dark .list-title {
color: #f7fafc;
}
.theme-toggle {
background: #f7fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 8px 12px;
cursor: pointer;
transition: all 0.3s ease;
color: #64748b;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.theme-toggle:hover {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
transform: scale(1.05);
border-color: transparent;
}
.friend-list.dark .theme-toggle {
background: #374151;
border-color: #4b5563;
color: #d1d5db;
}
.search-container {
position: relative;
}
.search-input {
width: 100%;
}
.friends-container {
flex: 1;
overflow: hidden;
}
.friends-list {
height: 100%;
}
.friend-item {
display: flex;
align-items: center;
padding: 16px 20px;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid #f1f5f9;
position: relative;
}
.friend-item:hover {
background: #f8fafc;
}
.friend-item.active {
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
border-left: 4px solid #4361ee;
}
.friend-list.dark .friend-item {
border-bottom-color: #374151;
}
.friend-list.dark .friend-item:hover {
background: #374151;
}
.friend-list.dark .friend-item.active {
background: linear-gradient(135deg, rgba(67, 97, 238, 0.2), rgba(63, 55, 201, 0.1));
}
.friend-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 18px;
margin-right: 12px;
flex-shrink: 0;
}
.friend-info {
flex: 1;
min-width: 0;
margin-right: 12px;
}
.friend-name {
font-size: 16px;
font-weight: 600;
color: #1a202c;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.friend-list.dark .friend-name {
color: #f7fafc;
}
.friend-message {
font-size: 14px;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.friend-list.dark .friend-message {
color: #9ca3af;
}
.friend-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.message-time {
font-size: 12px;
color: #94a3b8;
}
.friend-list.dark .message-time {
color: #6b7280;
}
.unread-badge {
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
color: white;
border-radius: 12px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
min-width: 20px;
text-align: center;
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
}
/* 响应式设计 */
@media (max-width: 768px) {
.friend-list {
width: 100%;
min-width: unset;
}
.list-header {
padding: 20px 16px;
}
.friend-item {
padding: 14px 16px;
}
.friend-avatar {
width: 44px;
height: 44px;
font-size: 16px;
}
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div class="friends-management h-full bg-gray-50 dark:bg-gray-900">
<div class="p-6">
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-6">好友管理</h1>
<!-- 搜索和筛选 -->
<div class="flex gap-4 mb-6">
<CustomInput
v-model="searchQuery"
placeholder="搜索好友..."
prefix-icon="fas fa-search"
clearable
class="flex-1"
/>
<select class="px-4 py-2 border border-gray-300 rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200">
<option value="all">全部好友</option>
<option value="online">在线好友</option>
<option value="offline">离线好友</option>
</select>
</div>
<!-- 好友网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div
v-for="friend in filteredFriends"
:key="friend.id"
class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm hover:shadow-md transition-all duration-200"
>
<div class="text-center">
<div
class="w-16 h-16 rounded-full mx-auto mb-3 flex items-center justify-center text-white font-bold text-xl"
:style="{ background: friend.color }"
>
{{ friend.name.charAt(0) }}
</div>
<h3 class="font-semibold text-gray-800 dark:text-gray-200 mb-1">{{ friend.name }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">ID: {{ friend.id }}</p>
<div class="flex gap-2 justify-center">
<button class="px-3 py-1 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600 transition-colors">
聊天
</button>
<button class="px-3 py-1 bg-gray-500 text-white rounded-lg text-sm hover:bg-gray-600 transition-colors">
详情
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useChatStore } from '@/stores/chat';
import CustomInput from './CustomInput.vue';
const chatStore = useChatStore();
const searchQuery = ref('');
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)
);
});
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div class="groups-management h-full bg-gray-50 dark:bg-gray-900">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">群聊管理</h1>
<button class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-plus mr-2"></i>
创建群聊
</button>
</div>
<!-- 群聊列表 -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
<div
v-for="group in mockGroups"
:key="group.id"
class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm hover:shadow-md transition-all duration-200"
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl flex items-center justify-center text-white font-bold text-lg mr-3">
{{ group.name.charAt(0) }}
</div>
<div>
<h3 class="font-semibold text-gray-800 dark:text-gray-200">{{ group.name }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ group.memberCount }} </p>
</div>
</div>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">{{ group.description }}</p>
<div class="flex gap-2">
<button class="flex-1 px-3 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600 transition-colors">
进入群聊
</button>
<button class="px-3 py-2 bg-gray-500 text-white rounded-lg text-sm hover:bg-gray-600 transition-colors">
设置
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const mockGroups = ref([
{
id: 1,
name: '技术交流群',
memberCount: 128,
description: '分享技术心得,讨论前沿技术'
},
{
id: 2,
name: '项目讨论组',
memberCount: 45,
description: '项目进度讨论和问题解决'
},
{
id: 3,
name: '休闲聊天室',
memberCount: 89,
description: '轻松愉快的日常交流'
}
]);
</script>

View File

@@ -1,91 +1,626 @@
<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"
v-if="showImagePreview"
class="media-preview-overlay"
:class="{ 'dark': themeStore.isDarkMode }"
@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>
<div class="preview-container">
<button class="preview-close-btn" @click="closePreview">
<i class="fas fa-times"></i>
</button>
<!-- 视频播放器 -->
<div class="relative z-10 max-w-4xl w-full">
<div class="image-preview-content">
<img
:src="mediaData?.url"
:alt="'图片预览'"
class="preview-image"
@load="handleImageLoad"
/>
</div>
<div class="preview-actions">
<button class="action-btn" @click="downloadMedia" title="下载">
<i class="fas fa-download"></i>
</button>
<button class="action-btn" @click="shareMedia" title="分享">
<i class="fas fa-share"></i>
</button>
</div>
</div>
</div>
<!-- 视频预览组件 -->
<div
v-if="showVideoPreview"
class="media-preview-overlay video-preview"
:class="{ 'dark': themeStore.isDarkMode }"
@click.self="closePreview"
>
<div class="video-preview-container" :class="{ 'portrait': isPortrait }">
<button class="preview-close-btn" @click="closePreview">
<i class="fas fa-times"></i>
</button>
<div class="video-content">
<video
ref="videoPlayer"
:src="mediaData?.url"
class="w-full rounded-xl shadow-2xl aspect-video"
class="preview-video"
controls
autoplay
@loadedmetadata="handleVideoLoad"
@play="handleVideoPlay"
@pause="handleVideoPause"
/>
<!-- 大播放按钮非播放状态时显示 -->
<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 class="video-controls" v-if="showControls">
<div class="controls-top">
<div class="video-title">视频预览</div>
<div class="video-info">
{{ formatDuration(currentTime) }} / {{ formatDuration(duration) }}
</div>
</div>
<div class="controls-bottom">
<button class="control-btn" @click="togglePlay">
<i class="fas" :class="isPlaying ? 'fa-pause' : 'fa-play'"></i>
</button>
<div class="progress-container">
<div class="progress-bar" @click="seekTo">
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
<div class="progress-thumb" :style="{ left: progressPercent + '%' }"></div>
</div>
</div>
<button class="control-btn" @click="toggleMute">
<i class="fas" :class="isMuted ? 'fa-volume-mute' : 'fa-volume-up'"></i>
</button>
<div class="volume-container">
<input
type="range"
min="0"
max="1"
step="0.1"
v-model="volume"
class="volume-slider"
@input="updateVolume"
/>
</div>
<button class="control-btn" @click="toggleFullscreen">
<i class="fas fa-expand"></i>
</button>
</div>
</div>
</div>
<div class="video-actions">
<button class="action-btn" @click="downloadMedia" title="下载">
<i class="fas fa-download"></i>
</button>
<button class="action-btn" @click="shareMedia" title="分享">
<i class="fas fa-share"></i>
</button>
<button class="action-btn" @click="togglePictureInPicture" title="画中画">
<i class="fas fa-external-link-alt"></i>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue';
import VueEasyLightbox from 'vue-easy-lightbox';
import {useMediaPreview} from "@/composables/useMediaPreview.js";
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import { useMediaPreview } from "@/composables/useMediaPreview.js";
import { useThemeStore } from '@/stores/theme';
// 引入组合函数
const themeStore = useThemeStore();
const { visible, mediaData, closePreview } = useMediaPreview();
// 区分图片和视频预览状态
// 视频相关状态
const videoPlayer = ref(null);
const isPlaying = ref(false);
const isMuted = ref(false);
const volume = ref(1);
const currentTime = ref(0);
const duration = ref(0);
const showControls = ref(true);
const isPortrait = ref(false);
const controlsTimeout = ref(null);
// 计算属性
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 progressPercent = computed(() => {
if (duration.value === 0) return 0;
return (currentTime.value / duration.value) * 100;
});
const playVideo = () => {
// 视频加载完成
const handleVideoLoad = () => {
if (videoPlayer.value) {
videoPlayer.value.play();
playing.value = true;
duration.value = videoPlayer.value.duration;
// 检测视频方向
const video = videoPlayer.value;
isPortrait.value = video.videoHeight > video.videoWidth;
// 监听时间更新
video.addEventListener('timeupdate', updateTime);
}
};
// 当媒体数据变化时重置播放状态
// 图片加载完成
const handleImageLoad = () => {
// 可以在这里添加图片加载完成的逻辑
};
// 更新播放时间
const updateTime = () => {
if (videoPlayer.value) {
currentTime.value = videoPlayer.value.currentTime;
}
};
// 视频播放/暂停事件
const handleVideoPlay = () => {
isPlaying.value = true;
hideControlsAfterDelay();
};
const handleVideoPause = () => {
isPlaying.value = false;
showControls.value = true;
clearTimeout(controlsTimeout.value);
};
// 控制功能
const togglePlay = () => {
if (videoPlayer.value) {
if (isPlaying.value) {
videoPlayer.value.pause();
} else {
videoPlayer.value.play();
}
}
};
const toggleMute = () => {
if (videoPlayer.value) {
videoPlayer.value.muted = !videoPlayer.value.muted;
isMuted.value = videoPlayer.value.muted;
}
};
const updateVolume = () => {
if (videoPlayer.value) {
videoPlayer.value.volume = volume.value;
isMuted.value = volume.value === 0;
}
};
const seekTo = (event) => {
if (videoPlayer.value) {
const rect = event.target.getBoundingClientRect();
const percent = (event.clientX - rect.left) / rect.width;
videoPlayer.value.currentTime = percent * duration.value;
}
};
const toggleFullscreen = () => {
if (videoPlayer.value) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoPlayer.value.requestFullscreen();
}
}
};
const togglePictureInPicture = async () => {
if (videoPlayer.value) {
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else {
await videoPlayer.value.requestPictureInPicture();
}
} catch (error) {
console.error('画中画功能不支持:', error);
}
}
};
// 隐藏控制栏
const hideControlsAfterDelay = () => {
clearTimeout(controlsTimeout.value);
controlsTimeout.value = setTimeout(() => {
if (isPlaying.value) {
showControls.value = false;
}
}, 3000);
};
// 显示控制栏
const showControlsTemporarily = () => {
showControls.value = true;
if (isPlaying.value) {
hideControlsAfterDelay();
}
};
// 格式化时间
const formatDuration = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// 下载媒体
const downloadMedia = () => {
if (mediaData.value?.url) {
const link = document.createElement('a');
link.href = mediaData.value.url;
link.download = `media_${Date.now()}`;
link.click();
}
};
// 分享媒体
const shareMedia = async () => {
if (navigator.share && mediaData.value?.url) {
try {
await navigator.share({
title: '分享媒体',
url: mediaData.value.url
});
} catch (error) {
console.log('分享取消或失败');
}
} else {
// 复制链接到剪贴板
navigator.clipboard.writeText(mediaData.value?.url || '');
alert('链接已复制到剪贴板');
}
};
// 键盘事件处理
const handleKeydown = (event) => {
if (!visible.value) return;
switch (event.code) {
case 'Escape':
closePreview();
break;
case 'Space':
if (showVideoPreview.value) {
event.preventDefault();
togglePlay();
}
break;
case 'ArrowLeft':
if (showVideoPreview.value && videoPlayer.value) {
event.preventDefault();
videoPlayer.value.currentTime -= 10;
}
break;
case 'ArrowRight':
if (showVideoPreview.value && videoPlayer.value) {
event.preventDefault();
videoPlayer.value.currentTime += 10;
}
break;
}
};
// 监听媒体数据变化
watch(mediaData, () => {
playing.value = false;
if (mediaData.value?.type === 'video') {
isPlaying.value = false;
currentTime.value = 0;
duration.value = 0;
showControls.value = true;
}
});
</script>
onMounted(() => {
document.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
clearTimeout(controlsTimeout.value);
});
</script>
<style scoped>
.media-preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
animation: fadeIn 0.3s ease;
}
.media-preview-overlay.dark {
background: rgba(0, 0, 0, 0.95);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.preview-container {
position: relative;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
}
.video-preview-container {
position: relative;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
background: #000;
border-radius: 12px;
overflow: hidden;
}
.video-preview-container.portrait {
max-width: 60vw;
}
.preview-close-btn {
position: absolute;
top: -50px;
right: 0;
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.3s ease;
z-index: 10;
}
.preview-close-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.image-preview-content {
position: relative;
}
.preview-image {
max-width: 100%;
max-height: 80vh;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.video-content {
position: relative;
width: 100%;
height: 100%;
}
.preview-video {
width: 100%;
height: 100%;
max-height: 80vh;
object-fit: contain;
}
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
color: white;
padding: 20px;
transition: all 0.3s ease;
}
.controls-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.video-title {
font-size: 16px;
font-weight: 600;
}
.video-info {
font-size: 14px;
opacity: 0.8;
}
.controls-bottom {
display: flex;
align-items: center;
gap: 16px;
}
.control-btn {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.3s ease;
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.progress-container {
flex: 1;
margin: 0 16px;
}
.progress-bar {
position: relative;
height: 6px;
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
cursor: pointer;
}
.progress-fill {
height: 100%;
background: #4361ee;
border-radius: 3px;
transition: width 0.1s ease;
}
.progress-thumb {
position: absolute;
top: -4px;
width: 14px;
height: 14px;
background: #4361ee;
border-radius: 50%;
transform: translateX(-50%);
transition: left 0.1s ease;
}
.volume-container {
width: 80px;
}
.volume-slider {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
background: #4361ee;
border-radius: 50%;
cursor: pointer;
}
.preview-actions,
.video-actions {
position: absolute;
top: 20px;
right: 60px;
display: flex;
gap: 12px;
}
.action-btn {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.3s ease;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
/* 响应式设计 */
@media (max-width: 768px) {
.preview-container,
.video-preview-container {
max-width: 95vw;
max-height: 95vh;
}
.video-preview-container.portrait {
max-width: 95vw;
}
.preview-close-btn {
top: -40px;
width: 36px;
height: 36px;
font-size: 16px;
}
.video-controls {
padding: 16px;
}
.controls-bottom {
gap: 12px;
}
.control-btn {
width: 36px;
height: 36px;
font-size: 14px;
}
.volume-container {
width: 60px;
}
.preview-actions,
.video-actions {
top: 16px;
right: 50px;
gap: 8px;
}
.action-btn {
width: 36px;
height: 36px;
font-size: 14px;
}
}
</style>

View File

@@ -0,0 +1,400 @@
<template>
<div class="flex items-start gap-3 mb-4" :class="isSent ? 'flex-row-reverse' : 'flex-row'">
<!-- 头像 -->
<div
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm flex-shrink-0"
:style="avatarStyle"
>
{{ senderInfo.avatar }}
</div>
<!-- 消息内容区域 -->
<div class="flex flex-col max-w-xs md:max-w-md lg:max-w-lg xl:max-w-xl" :class="isSent ? 'items-end' : 'items-start'">
<!-- 发送者名称 -->
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1 px-2">
{{ senderInfo.name }}
</div>
<!-- 消息气泡 -->
<div
class="rounded-2xl p-4 shadow-sm relative"
:class="bubbleClasses"
>
<!-- 图片消息 -->
<div
v-if="message.type === 'image'"
class="cursor-pointer relative"
@click="previewMedia(message.content, 'image')"
>
<img
:src="message.content"
:alt="'图片消息'"
class="rounded-lg max-w-full"
@error="handleImageError"
/>
<div class="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black/50 rounded-lg">
<i class="fas fa-search-plus text-white text-xl"></i>
</div>
</div>
<!-- 视频消息 -->
<div
v-else-if="message.type === 'video'"
class="cursor-pointer relative video-message"
@click="previewMedia(message.content, 'video')"
>
<video
:src="message.content"
class="rounded-lg max-w-full video-thumbnail"
preload="metadata"
@error="handleVideoError"
/>
<div class="video-overlay">
<div class="play-button">
<i class="fas fa-play"></i>
</div>
<div class="video-duration" v-if="message.duration">
{{ formatDuration(message.duration) }}
</div>
</div>
</div>
<!-- URL链接消息 -->
<div v-else-if="message.type === 'url'" class="url-message">
<div class="url-preview" @click="openUrl(message.url)">
<div class="url-favicon">
<img :src="message.favicon" :alt="message.domain" v-if="message.favicon" />
<i class="fas fa-link" v-else></i>
</div>
<div class="url-content">
<div class="url-title">{{ message.title || message.url }}</div>
<div class="url-description" v-if="message.description">
{{ message.description }}
</div>
<div class="url-domain">{{ message.domain }}</div>
</div>
<div class="url-thumbnail" v-if="message.thumbnail">
<img :src="message.thumbnail" :alt="message.title" />
</div>
</div>
<div class="url-text" v-if="message.text">
{{ message.text }}
</div>
</div>
<!-- 音频消息 -->
<AudioMessage v-else-if="message.type === 'audio'" :message="message" :is-sent="isSent" />
<!-- 文本消息 -->
<div v-else class="text-base leading-relaxed whitespace-pre-wrap">
<span v-html="formatTextWithLinks(message.content)"></span>
</div>
</div>
<!-- 消息时间和状态 -->
<div class="flex items-center gap-1 mt-1 text-xs text-gray-400 dark:text-gray-500 px-2">
<span>{{ message.time }}</span>
<i v-if="isSent && message.read" class="fas fa-check-double text-green-500"></i>
<i v-else-if="isSent && !message.read" class="fas fa-clock text-gray-400"></i>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useMediaPreview } from '@/composables/useMediaPreview';
import { useThemeStore } from '@/stores/theme';
import AudioMessage from './AudioMessage.vue';
const previewMedia = useMediaPreview();
const themeStore = useThemeStore();
const props = defineProps({
message: {
type: Object,
required: true
},
isSent: {
type: Boolean,
default: false
},
senderInfo: {
type: Object,
required: true
}
});
const avatarStyle = computed(() => ({
background: props.senderInfo.color
}));
const bubbleClasses = computed(() => {
const baseClasses = ['message-bubble'];
if (props.isSent) {
baseClasses.push('sent');
} else {
baseClasses.push('received');
}
if (themeStore.isDarkMode) {
baseClasses.push('dark');
}
return baseClasses;
});
const formatDuration = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatTextWithLinks = (text) => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-link">$1</a>');
};
const openUrl = (url) => {
window.open(url, '_blank', 'noopener,noreferrer');
};
</script>
<style scoped>
.message-bubble {
position: relative;
max-width: 100%;
word-wrap: break-word;
transition: all 0.2s ease;
}
.message-bubble.sent {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
border-bottom-right-radius: 4px;
}
.message-bubble.received {
background: white;
color: #1a202c;
border: 1px solid #e2e8f0;
border-bottom-left-radius: 4px;
}
.message-bubble.received.dark {
background: #374151;
color: #f7fafc;
border-color: #4b5563;
}
.video-message {
position: relative;
border-radius: 12px;
overflow: hidden;
max-width: 300px;
}
.video-thumbnail {
width: 100%;
height: auto;
max-height: 200px;
object-fit: cover;
display: block;
}
.video-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.3) 100%);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.video-message:hover .video-overlay {
opacity: 1;
}
.play-button {
width: 60px;
height: 60px;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.play-button:hover {
background: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
.video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.url-message {
max-width: 400px;
}
.url-preview {
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
background: white;
}
.url-preview:hover {
border-color: #4361ee;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.15);
}
.message-bubble.sent .url-preview {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.message-bubble.received.dark .url-preview {
border-color: #4b5563;
background: #4b5563;
}
.url-preview {
display: flex;
align-items: flex-start;
padding: 12px;
gap: 12px;
}
.url-favicon {
width: 20px;
height: 20px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.url-favicon img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 2px;
}
.url-content {
flex: 1;
min-width: 0;
}
.url-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
color: #1a202c;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.message-bubble.sent .url-title {
color: white;
}
.message-bubble.received.dark .url-title {
color: #f7fafc;
}
.url-description {
font-size: 12px;
color: #64748b;
margin-bottom: 4px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.message-bubble.sent .url-description {
color: rgba(255, 255, 255, 0.8);
}
.message-bubble.received.dark .url-description {
color: #9ca3af;
}
.url-domain {
font-size: 11px;
color: #94a3b8;
font-weight: 500;
}
.message-bubble.sent .url-domain {
color: rgba(255, 255, 255, 0.7);
}
.message-bubble.received.dark .url-domain {
color: #6b7280;
}
.url-thumbnail {
width: 60px;
height: 60px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.url-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.url-text {
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
:deep(.text-link) {
color: #4361ee;
text-decoration: underline;
transition: color 0.2s ease;
}
:deep(.text-link:hover) {
color: #3f37c9;
}
.message-bubble.sent :deep(.text-link) {
color: rgba(255, 255, 255, 0.9);
}
.message-bubble.sent :deep(.text-link:hover) {
color: white;
}
</style>

View File

@@ -1,221 +1,529 @@
<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 class="message-input-area" :class="{ 'dark': themeStore.isDarkMode }">
<!-- 文件上传预览 -->
<FileUploadPreview v-if="uploadPreview" />
<div class="input-container">
<!-- 功能按钮区域 -->
<div class="function-buttons">
<button
class="function-btn"
@click="triggerFileInput('image')"
title="发送图片"
>
<i class="fas fa-image"></i>
<div class="btn-ripple"></div>
</button>
<button
class="function-btn"
@click="triggerFileInput('video')"
title="发送视频"
>
<i class="fas fa-video"></i>
<div class="btn-ripple"></div>
</button>
<button
class="function-btn"
:class="{ 'active': showEmojiPicker }"
@click="toggleEmojiPicker"
title="选择表情"
>
<i class="fas fa-smile"></i>
<div class="btn-ripple"></div>
</button>
<button
class="function-btn record-btn"
:class="{ 'recording': isRecording }"
@mousedown="startRecording"
@mouseup="stopRecording"
@mouseleave="stopRecording"
@touchstart="startRecording"
@touchend="stopRecording"
title="按住录音"
>
<i class="fas fa-microphone"></i>
<div class="btn-ripple"></div>
<div class="recording-wave" v-if="isRecording"></div>
</button>
</div>
<!-- 表情选择器 -->
<EmojiPicker v-if="showEmojiPicker" @select="insertEmoji" />
<!-- 输入框区域 -->
<div class="input-section">
<CustomTextarea
ref="messageInputRef"
v-model="messageText"
placeholder="输入消息... (支持拖拽文件)"
:auto-resize="true"
:max-rows="4"
:detect-paste="true"
class="message-textarea"
@keydown="handleKeydown"
@paste-file="handlePasteFile"
/>
<button
class="send-btn"
:disabled="!messageText.trim() && !uploadPreview"
@click="sendTextMessage"
title="发送消息"
>
<i class="fas fa-paper-plane"></i>
<div class="send-ripple"></div>
</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 { ref, nextTick, provide, onMounted, onUnmounted } from 'vue';
import { useUserStore } from '@/stores/user';
import { useChatStore } from '@/stores/chat';
import { useThemeStore } from '@/stores/theme';
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 CustomTextarea from './CustomTextarea.vue';
import EmojiPicker from './EmojiPicker.vue';
import FileUploadPreview from './FileUploadPreview.vue';
const userStore = useUserStore();
const chatStore = useChatStore();
const themeStore = useThemeStore();
const messageText = ref('');
const messageInput = ref(null);
const messageInputRef = ref(null);
const showEmojiPicker = ref(false);
// 使用文件上传组合式函数
const {
uploadPreview,
imageInput,
videoInput,
triggerFileInput,
handleFileUpload
uploadPreview,
imageInput,
videoInput,
triggerFileInput,
handleFileUpload,
cancelUpload
} = 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 sendUploadedFile = () => {
if (!uploadPreview.value) return;
const messageObj = {
type: uploadPreview.value.type,
content: uploadPreview.value.url,
time: new Date().toLocaleTimeString().slice(0, 5),
senderId: userStore.currentUser.id,
read: true,
duration: 0
};
chatStore.addMessage(messageObj, userStore.currentUser.id);
// 发送到服务器
sendMessage({
senderId: userStore.currentUser.id,
receiverId: chatStore.currentFriend.id,
type: uploadPreview.value.type,
content: uploadPreview.value.url
}).catch(error => {
console.error('发送文件失败:', error);
message.error('文件发送失败');
});
uploadPreview.value = null;
};
// 处理回车键
const handleEnterKey = (event) => {
if (event.shiftKey) {
// Shift + Enter 换行
return;
} else {
// Enter 发送消息
event.preventDefault();
sendTextMessage();
// 提供给子组件
provide('uploadPreview', uploadPreview);
provide('cancelUpload', cancelUpload);
provide('sendFile', sendUploadedFile);
// 发送文本消息
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 (messageInputRef.value) {
messageInputRef.value.focus();
}
});
};
// 处理键盘事件
const handleKeydown = (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendTextMessage();
}
};
// 处理粘贴文件
const handlePasteFile = (file) => {
console.log('检测到粘贴文件:', file);
// 根据文件类型自动选择处理方式
if (file.type.startsWith('image/')) {
handleFileFromPaste(file, 'image');
} else if (file.type.startsWith('video/')) {
handleFileFromPaste(file, 'video');
} else {
message.warning('不支持的文件类型');
}
};
// 处理粘贴的文件
const handleFileFromPaste = (file, type) => {
// 文件大小检查
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
if (file.size > maxSize) {
const maxSizeMB = maxSize / 1024 / 1024;
message.error(`文件大小超过限制!${type === 'image' ? '图片' : '视频'}最大${maxSizeMB}MB`);
return;
}
const reader = new FileReader();
reader.onload = (e) => {
uploadPreview.value = {
type: type,
url: e.target.result,
name: file.name,
size: file.size,
file: file
};
};
reader.onerror = (error) => {
console.error('文件读取失败:', error);
message.error('文件读取失败,请重试');
};
reader.readAsDataURL(file);
};
// 切换表情选择器
const toggleEmojiPicker = () => {
showEmojiPicker.value = !showEmojiPicker.value;
showEmojiPicker.value = !showEmojiPicker.value;
};
// 插入表情
const insertEmoji = (emoji) => {
messageText.value += emoji;
showEmojiPicker.value = false;
nextTick(() => {
if (messageInput.value) {
messageInput.value.focus();
}
});
};
messageText.value += emoji;
showEmojiPicker.value = false;
// 点击外部关闭表情选择器
const handleClickOutside = (event) => {
if (!event.target.closest('.emoji-picker-container')) {
showEmojiPicker.value = false;
nextTick(() => {
if (messageInputRef.value) {
messageInputRef.value.focus();
}
});
};
inject('clickOutsideHandler', handleClickOutside);
// 监听录音完成事件
const handleAudioRecorded = (event) => {
const { url, duration } = event.detail;
const messageObj = {
type: 'audio',
content: url,
time: new Date().toLocaleTimeString().slice(0, 5),
senderId: userStore.currentUser.id,
read: true,
duration: duration
};
chatStore.addMessage(messageObj, userStore.currentUser.id);
// 发送到服务器
sendMessage({
senderId: userStore.currentUser.id,
receiverId: chatStore.currentFriend.id,
type: 'audio',
content: url
}).catch(error => {
console.error('发送音频失败:', error);
message.error('音频发送失败');
});
};
onMounted(() => {
window.addEventListener('audioRecorded', handleAudioRecorded);
// 点击外部关闭表情选择器
document.addEventListener('click', (event) => {
if (!event.target.closest('.emoji-picker') && !event.target.closest('.function-btn')) {
showEmojiPicker.value = false;
}
});
});
onUnmounted(() => {
window.removeEventListener('audioRecorded', handleAudioRecorded);
});
</script>
<style scoped>
.message-input-area {
padding: 20px;
border-top: 1px solid #e2e8f0;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
position: relative;
}
.message-input-area.dark {
border-top-color: #374151;
background: linear-gradient(135deg, #2d2d2d 0%, #1f2937 100%);
}
.input-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.function-buttons {
display: flex;
gap: 12px;
align-items: center;
}
.function-btn {
position: relative;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.function-btn:hover {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
transform: translateY(-2px) scale(1.05);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.3);
}
.function-btn.active {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
transform: scale(1.1);
}
.function-btn.recording {
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
color: white;
animation: pulse-recording 1s infinite;
}
@keyframes pulse-recording {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.recording-wave {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60px;
height: 60px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
animation: wave-expand 1.5s infinite;
}
@keyframes wave-expand {
0% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0;
}
}
.btn-ripple {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: all 0.6s ease;
}
.function-btn:active .btn-ripple {
width: 100px;
height: 100px;
}
.message-input-area.dark .function-btn {
background: linear-gradient(135deg, #374151, #4b5563);
color: #9ca3af;
}
.message-input-area.dark .function-btn:hover {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
}
.input-section {
display: flex;
align-items: end;
gap: 12px;
position: relative;
}
.message-textarea {
flex: 1;
}
.send-btn {
position: relative;
width: 52px;
height: 52px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
}
.send-btn:hover:not(:disabled) {
transform: translateY(-2px) scale(1.05);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.4);
}
.send-btn:disabled {
background: #94a3b8;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.send-ripple {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transform: translate(-50%, -50%);
transition: all 0.6s ease;
}
.send-btn:active:not(:disabled) .send-ripple {
width: 80px;
height: 80px;
}
.hidden {
display: none;
}
/* 响应式设计 */
@media (max-width: 768px) {
.function-buttons {
gap: 8px;
}
.function-btn {
width: 40px;
height: 40px;
font-size: 16px;
}
.send-btn {
width: 48px;
height: 48px;
font-size: 16px;
}
}
</style>

View File

@@ -1,36 +1,39 @@
<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>
<div
ref="messagesContainer"
class="flex-1 p-5 overflow-y-auto bg-gray-50 dark:bg-gray-900 message-list-background"
>
<a-spin :spinning="loadingMessages" tip="加载消息中...">
<!-- 空状态 -->
<div v-if="!chatStore.messages.length && chatStore.currentFriend" class="text-center text-gray-500 dark:text-gray-400 py-10">
<MessageOutlined class="text-4xl mb-2" />
<p>开始与 {{ chatStore.currentFriend.name }} 对话吧</p>
</div>
<!-- 消息列表 -->
<div v-if="!loadingMessages">
<div
v-for="(msg, index) in chatStore.messages"
:key="index"
class="mb-6 clear-both"
>
<MessageBubble
:message="msg"
:is-sent="msg.senderId === userStore.currentUser.id"
:sender-info="getSenderInfo(msg)"
/>
</div>
</div>
</a-spin>
</div>
</template>
<script setup>
import { ref, nextTick, watch } from 'vue';
import { ref, nextTick, watch, computed } from 'vue';
import { useUserStore } from '@/stores/user';
import { useChatStore } from '@/stores/chat';
import { MessageOutlined } from '@ant-design/icons-vue';
import MessageItem from './MessageItem.vue';
import MessageBubble from './MessageBubble.vue';
const userStore = useUserStore();
const chatStore = useChatStore();
@@ -38,27 +41,46 @@ const chatStore = useChatStore();
const messagesContainer = ref(null);
const loadingMessages = ref(false);
// 获取发送者信息
const getSenderInfo = (message) => {
if (message.senderId === userStore.currentUser.id) {
return {
name: userStore.currentUser.name,
avatar: userStore.currentUser.name.charAt(0),
color: userStore.currentUser.color || '#4cc9f0'
}
} else {
return {
name: chatStore.currentFriend?.name || '未知用户',
avatar: chatStore.currentFriend?.name.charAt(0) || '?',
color: chatStore.currentFriend?.color || '#ff6b6b'
}
}
}
// 滚动到底部
const scrollToBottom = () => {
if (messagesContainer.value) {
nextTick(() => {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
});
}
if (messagesContainer.value) {
nextTick(() => {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
});
}
};
// 监听消息变化,自动滚动到底部
watch(() => chatStore.messages.length, () => {
scrollToBottom();
scrollToBottom();
});
// 监听当前好友变化,重新加载消息
watch(() => chatStore.currentFriend, () => {
scrollToBottom();
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>')
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>');
background-size: 200px 200px;
}
</style>
</style>

View File

@@ -0,0 +1,90 @@
<template>
<div class="moments-view h-full bg-gray-50 dark:bg-gray-900">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">朋友圈</h1>
<button class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-plus mr-2"></i>
发布动态
</button>
</div>
<!-- 动态列表 -->
<div class="space-y-6">
<div
v-for="moment in mockMoments"
:key="moment.id"
class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm"
>
<div class="flex items-center mb-4">
<div
class="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold mr-3"
:style="{ background: moment.user.color }"
>
{{ moment.user.name.charAt(0) }}
</div>
<div>
<h3 class="font-semibold text-gray-800 dark:text-gray-200">{{ moment.user.name }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ moment.time }}</p>
</div>
</div>
<p class="text-gray-700 dark:text-gray-300 mb-4">{{ moment.content }}</p>
<div v-if="moment.images" class="grid grid-cols-3 gap-2 mb-4">
<img
v-for="(image, index) in moment.images"
:key="index"
:src="image"
class="w-full h-24 object-cover rounded-lg"
/>
</div>
<div class="flex items-center gap-4 text-gray-500 dark:text-gray-400">
<button class="flex items-center gap-1 hover:text-blue-500 transition-colors">
<i class="fas fa-heart"></i>
<span>{{ moment.likes }}</span>
</button>
<button class="flex items-center gap-1 hover:text-blue-500 transition-colors">
<i class="fas fa-comment"></i>
<span>{{ moment.comments }}</span>
</button>
<button class="flex items-center gap-1 hover:text-blue-500 transition-colors">
<i class="fas fa-share"></i>
<span>分享</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const mockMoments = ref([
{
id: 1,
user: { name: '张三', color: '#4cc9f0' },
content: '今天天气真不错,适合出去走走!',
time: '2小时前',
likes: 12,
comments: 3,
images: ['/placeholder.svg?height=100&width=100']
},
{
id: 2,
user: { name: '李四', color: '#ff6b6b' },
content: '刚完成了一个很有挑战性的项目,感觉很有成就感!',
time: '5小时前',
likes: 25,
comments: 8
},
{
id: 3,
user: { name: '王五', color: '#6a0dad' },
content: '分享一些学习心得,希望对大家有帮助。',
time: '1天前',
likes: 18,
comments: 5
}
]);
</script>

View File

@@ -1,12 +1,17 @@
<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
v-if="isRecording"
class="fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50"
>
<div class="bg-white dark:bg-gray-800 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 text-gray-800 dark:text-gray-200">正在录音... 松开发送</span>
</div>
</div>
</template>
<script setup>
// 录音状态指示器组件
import { inject } from 'vue';
const isRecording = inject('isRecording');
</script>

View File

@@ -0,0 +1,518 @@
<template>
<CustomModal
title="设置"
size="large"
@close="$emit('close')"
>
<div class="settings-content" :class="{ 'dark': themeStore.isDarkMode }">
<!-- 设置导航 -->
<div class="settings-nav">
<div
v-for="tab in settingsTabs"
:key="tab.key"
class="nav-tab"
:class="{ 'active': activeTab === tab.key }"
@click="activeTab = tab.key"
>
<i :class="tab.icon"></i>
<span>{{ tab.label }}</span>
</div>
</div>
<!-- 设置内容 -->
<div class="settings-body">
<!-- 通用设置 -->
<div v-if="activeTab === 'general'" class="settings-panel">
<h3 class="panel-title">通用设置</h3>
<div class="setting-group">
<div class="setting-item">
<div class="setting-info">
<div class="setting-name">主题模式</div>
<div class="setting-desc">选择亮色或暗色主题</div>
</div>
<div class="theme-switcher">
<button
class="theme-option"
:class="{ 'active': !themeStore.isDarkMode }"
@click="themeStore.setTheme(false)"
>
<i class="fas fa-sun"></i>
<span>亮色</span>
</button>
<button
class="theme-option"
:class="{ 'active': themeStore.isDarkMode }"
@click="themeStore.setTheme(true)"
>
<i class="fas fa-moon"></i>
<span>暗色</span>
</button>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-name">语言</div>
<div class="setting-desc">选择界面语言</div>
</div>
<select class="setting-select">
<option value="zh-CN">简体中文</option>
<option value="en-US">English</option>
</select>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-name">字体大小</div>
<div class="setting-desc">调整界面字体大小</div>
</div>
<div class="font-size-slider">
<input type="range" min="12" max="18" value="14" class="slider">
<span class="size-label">14px</span>
</div>
</div>
</div>
</div>
<!-- 通知设置 -->
<div v-if="activeTab === 'notifications'" class="settings-panel">
<h3 class="panel-title">通知设置</h3>
<div class="setting-group">
<div class="setting-item">
<div class="setting-info">
<div class="setting-name">桌面通知</div>
<div class="setting-desc">接收新消息的桌面通知</div>
</div>
<label class="toggle-switch">
<input type="checkbox" v-model="settings.desktopNotifications">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-name">声音提醒</div>
<div class="setting-desc">新消息时播放提示音</div>
</div>
<label class="toggle-switch">
<input type="checkbox" v-model="settings.soundNotifications">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-name">消息预览</div>
<div class="setting-desc">在通知中显示消息内容</div>
</div>
<label class="toggle-switch">
<input type="checkbox" v-model="settings.messagePreview">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<!-- 聊天设置 -->
<div v-if="activeTab === 'chat'" class="settings-panel">
<h3 class="panel-title">聊天设置</h3>
<div class="setting-group">
<div class="setting-item">
<div class="setting-info">
<div class="setting-name">Enter键发送</div>
<div class="setting-desc">按Enter键直接发送消息</div>
</div>
<label class="toggle-switch">
<input type="checkbox" v-model="settings.enterToSend">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-name">自动下载图片</div>
<div class="setting-desc">自动下载并显示图片</div>
</div>
<label class="toggle-switch">
<input type="checkbox" v-model="settings.autoDownloadImages">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-name">消息时间显示</div>
<div class="setting-desc">显示消息发送时间</div>
</div>
<label class="toggle-switch">
<input type="checkbox" v-model="settings.showMessageTime">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<!-- 账户设置 -->
<div v-if="activeTab === 'account'" class="settings-panel">
<h3 class="panel-title">账户设置</h3>
<div class="setting-group">
<div class="setting-item">
<div class="setting-info">
<div class="setting-name">清除聊天记录</div>
<div class="setting-desc">删除所有本地聊天记录</div>
</div>
<button class="danger-btn" @click="clearChatHistory">
清除记录
</button>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-name">导出数据</div>
<div class="setting-desc">导出聊天记录和设置</div>
</div>
<button class="primary-btn">
导出数据
</button>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-name">退出登录</div>
<div class="setting-desc">退出当前账户</div>
</div>
<button class="danger-btn" @click="logout">
退出登录
</button>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<button class="modal-btn modal-btn-cancel" @click="$emit('close')">
取消
</button>
<button class="modal-btn modal-btn-confirm" @click="saveSettings">
保存设置
</button>
</template>
</CustomModal>
</template>
<script setup>
import { ref, reactive } from 'vue';
import { useThemeStore } from '@/stores/theme';
import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router';
import CustomModal from './CustomModal.vue';
const themeStore = useThemeStore();
const userStore = useUserStore();
const router = useRouter();
const emit = defineEmits(['close']);
const activeTab = ref('general');
const settingsTabs = ref([
{ key: 'general', label: '通用', icon: 'fas fa-cog' },
{ key: 'notifications', label: '通知', icon: 'fas fa-bell' },
{ key: 'chat', label: '聊天', icon: 'fas fa-comments' },
{ key: 'account', label: '账户', icon: 'fas fa-user' }
]);
const settings = reactive({
desktopNotifications: true,
soundNotifications: true,
messagePreview: true,
enterToSend: true,
autoDownloadImages: true,
showMessageTime: true
});
const clearChatHistory = () => {
if (confirm('确定要清除所有聊天记录吗?此操作不可恢复。')) {
// 清除本地存储的聊天记录
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith('chat_')) {
localStorage.removeItem(key);
}
});
alert('聊天记录已清除');
}
};
const logout = () => {
if (confirm('确定要退出登录吗?')) {
userStore.logout();
emit('close');
router.push('/login');
}
};
const saveSettings = () => {
// 保存设置到本地存储
localStorage.setItem('chatSettings', JSON.stringify(settings));
emit('close');
};
</script>
<style scoped>
.settings-content {
display: flex;
height: 500px;
gap: 0;
margin: -24px;
}
.settings-nav {
width: 200px;
background: #f8fafc;
border-right: 1px solid #e2e8f0;
padding: 24px 0;
}
.settings-content.dark .settings-nav {
background: #374151;
border-right-color: #4b5563;
}
.nav-tab {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
cursor: pointer;
transition: all 0.2s ease;
color: #64748b;
}
.nav-tab:hover {
background: #e2e8f0;
color: #475569;
}
.nav-tab.active {
background: #4361ee;
color: white;
border-right: 3px solid #3f37c9;
}
.settings-content.dark .nav-tab:hover {
background: #4b5563;
color: #d1d5db;
}
.nav-tab i {
width: 16px;
font-size: 14px;
}
.settings-body {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.panel-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
color: #1a202c;
}
.settings-content.dark .panel-title {
color: #f7fafc;
}
.setting-group {
display: flex;
flex-direction: column;
gap: 24px;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid #e2e8f0;
}
.settings-content.dark .setting-item {
border-bottom-color: #4b5563;
}
.setting-info {
flex: 1;
}
.setting-name {
font-size: 16px;
font-weight: 500;
color: #1a202c;
margin-bottom: 4px;
}
.settings-content.dark .setting-name {
color: #f7fafc;
}
.setting-desc {
font-size: 14px;
color: #64748b;
}
.settings-content.dark .setting-desc {
color: #9ca3af;
}
.theme-switcher {
display: flex;
background: #e2e8f0;
border-radius: 8px;
padding: 4px;
}
.settings-content.dark .theme-switcher {
background: #4b5563;
}
.theme-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
color: #64748b;
font-size: 14px;
}
.theme-option.active {
background: white;
color: #4361ee;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.settings-content.dark .theme-option.active {
background: #2d2d2d;
color: #4361ee;
}
.setting-select {
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: white;
color: #1a202c;
font-size: 14px;
min-width: 120px;
}
.settings-content.dark .setting-select {
background: #4b5563;
border-color: #6b7280;
color: #f7fafc;
}
.font-size-slider {
display: flex;
align-items: center;
gap: 12px;
}
.slider {
width: 120px;
}
.size-label {
font-size: 14px;
color: #64748b;
min-width: 40px;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 48px;
height: 24px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #cbd5e1;
border-radius: 24px;
transition: all 0.3s ease;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: all 0.3s ease;
}
input:checked + .toggle-slider {
background: #4361ee;
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
}
.primary-btn,
.danger-btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.primary-btn {
background: #4361ee;
color: white;
}
.primary-btn:hover {
background: #3f37c9;
}
.danger-btn {
background: #ff6b6b;
color: white;
}
.danger-btn:hover {
background: #ff5252;
}
</style>

View File

@@ -0,0 +1,273 @@
<template>
<div class="side-navigation" :class="{ 'dark': themeStore.isDarkMode }">
<!-- 用户头像区域 -->
<div class="user-avatar-section">
<div
class="user-avatar-container"
@click="showUserProfile = true"
>
<div
class="user-avatar"
:style="{ background: userStore.currentUser?.color || '#4cc9f0' }"
>
{{ userStore.currentUser?.name?.charAt(0) || 'U' }}
</div>
<div class="online-indicator"></div>
</div>
</div>
<!-- 导航菜单 -->
<nav class="navigation-menu">
<div
v-for="item in navigationItems"
:key="item.key"
class="nav-item"
:class="{ 'active': activeNav === item.key }"
@click="handleNavClick(item.key)"
:title="item.label"
>
<div class="nav-icon">
<i :class="item.icon"></i>
</div>
<span class="nav-label">{{ item.label }}</span>
</div>
</nav>
<!-- 底部设置 -->
<div class="bottom-actions">
<div
class="nav-item settings-item"
@click="showSettings = true"
title="设置"
>
<div class="nav-icon">
<i class="fas fa-cog"></i>
</div>
<span class="nav-label">设置</span>
</div>
</div>
<!-- 用户信息弹窗 -->
<UserProfileModal
v-if="showUserProfile"
@close="showUserProfile = false"
/>
<!-- 设置弹窗 -->
<SettingsModal
v-if="showSettings"
@close="showSettings = false"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useUserStore } from '@/stores/user';
import { useThemeStore } from '@/stores/theme';
import UserProfileModal from './UserProfileModal.vue';
import SettingsModal from './SettingsModal.vue';
const userStore = useUserStore();
const themeStore = useThemeStore();
const activeNav = ref('chat');
const showUserProfile = ref(false);
const showSettings = ref(false);
const navigationItems = ref([
{
key: 'chat',
label: '聊天',
icon: 'fas fa-comments'
},
{
key: 'friends',
label: '好友',
icon: 'fas fa-user-friends'
},
{
key: 'groups',
label: '群聊',
icon: 'fas fa-users'
},
{
key: 'moments',
label: '圈子',
icon: 'fas fa-globe'
}
]);
const emit = defineEmits(['nav-change']);
const handleNavClick = (key) => {
activeNav.value = key;
emit('nav-change', key);
};
</script>
<style scoped>
.side-navigation {
width: 80px;
height: 100%;
background: linear-gradient(180deg, #4361ee 0%, #3f37c9 100%);
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
position: relative;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
}
.side-navigation.dark {
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.3);
}
.user-avatar-section {
margin-bottom: 30px;
}
.user-avatar-container {
position: relative;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.user-avatar-container:hover {
transform: scale(1.1);
}
.user-avatar {
width: 45px;
height: 45px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 18px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
}
.online-indicator {
position: absolute;
bottom: 2px;
right: 2px;
width: 12px;
height: 12px;
background: #4caf50;
border: 2px solid white;
border-radius: 50%;
animation: pulse-online 2s infinite;
}
@keyframes pulse-online {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.navigation-menu {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
}
.nav-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 8px;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
min-height: 70px;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.nav-item.active {
background: rgba(255, 255, 255, 0.25);
transform: scale(1.1);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
}
.nav-icon {
color: white;
font-size: 20px;
transition: all 0.3s ease;
}
.nav-item:hover .nav-icon {
transform: scale(1.1);
}
.nav-label {
color: white;
font-size: 10px;
font-weight: 500;
text-align: center;
opacity: 0.9;
transition: all 0.3s ease;
}
.nav-item:hover .nav-label {
opacity: 1;
transform: scale(1.05);
}
.nav-item.active .nav-label {
opacity: 1;
font-weight: 600;
}
.bottom-actions {
margin-top: auto;
}
.settings-item {
background: rgba(255, 255, 255, 0.08);
}
.settings-item:hover {
background: rgba(255, 255, 255, 0.15);
}
/* 响应式设计 */
@media (max-width: 768px) {
.side-navigation {
width: 70px;
padding: 15px 0;
}
.user-avatar {
width: 40px;
height: 40px;
font-size: 16px;
}
.nav-item {
min-height: 60px;
padding: 10px 6px;
}
.nav-icon {
font-size: 18px;
}
.nav-label {
font-size: 9px;
}
}
</style>

View File

@@ -0,0 +1,400 @@
<template>
<CustomModal
:visible="true"
title="个人信息"
size="medium"
@close="$emit('close')"
>
<div class="user-profile-content" :class="{ 'dark': themeStore.isDarkMode }">
<!-- 用户头像和基本信息 -->
<div class="profile-header">
<div class="avatar-section">
<div
class="user-avatar-large"
:style="{ background: userStore.currentUser?.color || '#4cc9f0' }"
>
{{ userStore.currentUser?.name?.charAt(0) || 'U' }}
</div>
<button class="avatar-edit-btn">
<i class="fas fa-camera"></i>
</button>
</div>
<div class="user-info">
<h2 class="user-name">{{ userStore.currentUser?.name || '未知用户' }}</h2>
<p class="user-id">ID: {{ userStore.currentUser?.id || '000000' }}</p>
<div class="user-status">
<div class="status-indicator online"></div>
<span>在线</span>
</div>
</div>
</div>
<!-- 统计信息 -->
<div class="stats-section">
<div class="stat-item">
<div class="stat-number">{{ friendsCount }}</div>
<div class="stat-label">好友</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ groupsCount }}</div>
<div class="stat-label">群聊</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ messagesCount }}</div>
<div class="stat-label">消息</div>
</div>
</div>
<!-- 个人设置 -->
<div class="settings-section">
<h3 class="section-title">个人设置</h3>
<div class="setting-item">
<div class="setting-info">
<i class="fas fa-user setting-icon"></i>
<div>
<div class="setting-name">昵称</div>
<div class="setting-desc">{{ userStore.currentUser?.name || '未设置' }}</div>
</div>
</div>
<button class="setting-action">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<div class="setting-item">
<div class="setting-info">
<i class="fas fa-palette setting-icon"></i>
<div>
<div class="setting-name">主题颜色</div>
<div class="setting-desc">个性化你的聊天界面</div>
</div>
</div>
<div class="color-picker">
<div
v-for="color in themeColors"
:key="color"
class="color-option"
:class="{ 'active': userStore.currentUser?.color === color }"
:style="{ background: color }"
@click="changeThemeColor(color)"
></div>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<i class="fas fa-bell setting-icon"></i>
<div>
<div class="setting-name">消息通知</div>
<div class="setting-desc">管理通知设置</div>
</div>
</div>
<button class="setting-action">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<div class="setting-item">
<div class="setting-info">
<i class="fas fa-shield-alt setting-icon"></i>
<div>
<div class="setting-name">隐私设置</div>
<div class="setting-desc">控制谁可以联系你</div>
</div>
</div>
<button class="setting-action">
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
</div>
<template #footer>
<button class="modal-btn modal-btn-cancel" @click="$emit('close')">
关闭
</button>
<button class="modal-btn modal-btn-confirm">
保存更改
</button>
</template>
</CustomModal>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useUserStore } from '@/stores/user';
import { useThemeStore } from '@/stores/theme';
import { useChatStore } from '@/stores/chat';
import CustomModal from './CustomModal.vue';
const userStore = useUserStore();
const themeStore = useThemeStore();
const chatStore = useChatStore();
const emit = defineEmits(['close']);
const themeColors = ref([
'#4cc9f0', '#ff6b6b', '#6a0dad', '#20b2aa',
'#ffa500', '#9acd32', '#ff1493', '#4682b4',
'#c71585', '#2e8b57', '#4361ee', '#3f37c9'
]);
const friendsCount = computed(() => chatStore.friends?.length || 0);
const groupsCount = computed(() => 0); // 暂时为0后续实现群聊功能
const messagesCount = computed(() => {
// 计算总消息数
return chatStore.friends?.reduce((total, friend) => {
const chatHistory = JSON.parse(localStorage.getItem(`chat_${userStore.currentUser.id}_${friend.id}`) || '[]');
return total + chatHistory.length;
}, 0) || 0;
});
const changeThemeColor = (color) => {
if (userStore.currentUser) {
userStore.currentUser.color = color;
// 这里可以添加保存到后端的逻辑
}
};
</script>
<style scoped>
.user-profile-content {
padding: 0;
}
.user-profile-content.dark {
color: #f7fafc;
}
.profile-header {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
padding: 32px 24px;
display: flex;
align-items: center;
gap: 24px;
margin: -24px -24px 24px -24px;
}
.avatar-section {
position: relative;
}
.user-avatar-large {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: bold;
color: white;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
}
.avatar-edit-btn {
position: absolute;
bottom: 0;
right: 0;
width: 28px;
height: 28px;
border-radius: 50%;
background: white;
color: #4361ee;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transition: all 0.2s ease;
}
.avatar-edit-btn:hover {
transform: scale(1.1);
}
.user-info {
flex: 1;
}
.user-name {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px 0;
}
.user-id {
font-size: 14px;
opacity: 0.8;
margin: 0 0 12px 0;
}
.user-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: #4caf50;
animation: pulse 2s infinite;
}
.stats-section {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
margin-bottom: 32px;
padding: 24px;
background: #f8fafc;
border-radius: 12px;
}
.user-profile-content.dark .stats-section {
background: #374151;
}
.stat-item {
text-align: center;
}
.stat-number {
font-size: 28px;
font-weight: 700;
color: #4361ee;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #64748b;
}
.user-profile-content.dark .stat-label {
color: #9ca3af;
}
.settings-section {
margin-top: 24px;
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: #1a202c;
}
.user-profile-content.dark .section-title {
color: #f7fafc;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid #e2e8f0;
transition: all 0.2s ease;
}
.setting-item:hover {
background: #f8fafc;
margin: 0 -24px;
padding: 16px 24px;
border-radius: 8px;
}
.user-profile-content.dark .setting-item {
border-bottom-color: #4b5563;
}
.user-profile-content.dark .setting-item:hover {
background: #374151;
}
.setting-info {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
}
.setting-icon {
width: 20px;
color: #4361ee;
font-size: 16px;
}
.setting-name {
font-size: 16px;
font-weight: 500;
color: #1a202c;
margin-bottom: 2px;
}
.user-profile-content.dark .setting-name {
color: #f7fafc;
}
.setting-desc {
font-size: 14px;
color: #64748b;
}
.user-profile-content.dark .setting-desc {
color: #9ca3af;
}
.setting-action {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 8px;
border-radius: 6px;
transition: all 0.2s ease;
}
.setting-action:hover {
background: #e2e8f0;
color: #64748b;
}
.user-profile-content.dark .setting-action:hover {
background: #4b5563;
color: #d1d5db;
}
.color-picker {
display: flex;
gap: 8px;
}
.color-option {
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s ease;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.active {
border-color: white;
box-shadow: 0 0 0 2px #4361ee;
}
</style>

View File

@@ -0,0 +1,577 @@
<template>
<div
v-if="isVisible"
class="video-call-container"
:class="{ 'minimized': isMinimized, 'dark': themeStore.isDarkMode }"
:style="{ left: position.x + 'px', top: position.y + 'px' }"
@mousedown="startDrag"
>
<!-- 拖拽头部 -->
<div class="call-header" v-if="!isMinimized">
<div class="call-info">
<div class="caller-avatar" :style="{ background: callerInfo.color }">
{{ callerInfo.name.charAt(0) }}
</div>
<div class="caller-details">
<div class="caller-name">{{ callerInfo.name }}</div>
<div class="call-status">{{ callStatus }}</div>
</div>
</div>
<div class="call-actions">
<button class="action-btn minimize-btn" @click="toggleMinimize">
<i class="fas fa-minus"></i>
</button>
<button class="action-btn close-btn" @click="endCall">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 最小化状态 -->
<div v-if="isMinimized" class="minimized-content" @click="toggleMinimize">
<div class="mini-avatar" :style="{ background: callerInfo.color }">
{{ callerInfo.name.charAt(0) }}
</div>
<div class="mini-info">
<div class="mini-name">{{ callerInfo.name }}</div>
<div class="mini-status">{{ callStatus }}</div>
</div>
<div class="call-indicator">
<div class="pulse-dot"></div>
</div>
</div>
<!-- 视频区域 -->
<div v-if="!isMinimized" class="video-area">
<!-- 远程视频 -->
<div class="remote-video-container">
<video
v-if="hasRemoteVideo"
ref="remoteVideo"
class="remote-video"
autoplay
playsinline
></video>
<div v-else class="video-placeholder">
<div class="placeholder-avatar" :style="{ background: callerInfo.color }">
{{ callerInfo.name.charAt(0) }}
</div>
<div class="placeholder-text">{{ callerInfo.name }}</div>
</div>
</div>
<!-- 本地视频 -->
<div class="local-video-container" v-if="hasLocalVideo">
<video
ref="localVideo"
class="local-video"
autoplay
playsinline
muted
></video>
</div>
</div>
<!-- 控制栏 -->
<div v-if="!isMinimized" class="call-controls">
<button
class="control-btn"
:class="{ 'active': isMicOn }"
@click="toggleMic"
:title="isMicOn ? '关闭麦克风' : '开启麦克风'"
>
<i class="fas" :class="isMicOn ? 'fa-microphone' : 'fa-microphone-slash'"></i>
</button>
<button
class="control-btn"
:class="{ 'active': isCameraOn }"
@click="toggleCamera"
:title="isCameraOn ? '关闭摄像头' : '开启摄像头'"
>
<i class="fas" :class="isCameraOn ? 'fa-video' : 'fa-video-slash'"></i>
</button>
<button
class="control-btn speaker-btn"
@click="toggleSpeaker"
:title="isSpeakerOn ? '关闭扬声器' : '开启扬声器'"
>
<i class="fas" :class="isSpeakerOn ? 'fa-volume-up' : 'fa-volume-mute'"></i>
</button>
<button
class="control-btn end-call-btn"
@click="endCall"
title="结束通话"
>
<i class="fas fa-phone-slash"></i>
</button>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue';
import { useThemeStore } from '@/stores/theme';
const themeStore = useThemeStore();
const props = defineProps({
callerInfo: {
type: Object,
required: true
},
callType: {
type: String,
default: 'video', // 'video' or 'audio'
validator: (value) => ['video', 'audio'].includes(value)
},
isIncoming: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['end-call', 'accept-call', 'reject-call']);
// 状态管理
const isVisible = ref(true);
const isMinimized = ref(false);
const isMicOn = ref(true);
const isCameraOn = ref(props.callType === 'video');
const isSpeakerOn = ref(true);
const hasLocalVideo = ref(false);
const hasRemoteVideo = ref(false);
// 拖拽相关
const position = reactive({ x: 100, y: 100 });
const isDragging = ref(false);
const dragOffset = reactive({ x: 0, y: 0 });
// 视频元素引用
const localVideo = ref(null);
const remoteVideo = ref(null);
// 媒体流
const localStream = ref(null);
const remoteStream = ref(null);
// 通话状态
const callStatus = computed(() => {
if (props.isIncoming) {
return '来电中...';
}
return '通话中';
});
// 初始化媒体设备
const initializeMedia = async () => {
try {
const constraints = {
audio: true,
video: props.callType === 'video'
};
localStream.value = await navigator.mediaDevices.getUserMedia(constraints);
if (localVideo.value && props.callType === 'video') {
localVideo.value.srcObject = localStream.value;
hasLocalVideo.value = true;
}
// 检查是否有摄像头
const videoTracks = localStream.value.getVideoTracks();
if (videoTracks.length === 0) {
isCameraOn.value = false;
}
} catch (error) {
console.error('无法访问媒体设备:', error);
isCameraOn.value = false;
if (error.name === 'NotAllowedError') {
alert('请允许访问摄像头和麦克风');
}
}
};
// 控制功能
const toggleMic = () => {
if (localStream.value) {
const audioTracks = localStream.value.getAudioTracks();
audioTracks.forEach(track => {
track.enabled = !track.enabled;
});
isMicOn.value = !isMicOn.value;
}
};
const toggleCamera = () => {
if (localStream.value) {
const videoTracks = localStream.value.getVideoTracks();
videoTracks.forEach(track => {
track.enabled = !track.enabled;
});
isCameraOn.value = !isCameraOn.value;
hasLocalVideo.value = isCameraOn.value;
}
};
const toggleSpeaker = () => {
isSpeakerOn.value = !isSpeakerOn.value;
if (remoteVideo.value) {
remoteVideo.value.muted = !isSpeakerOn.value;
}
};
const toggleMinimize = () => {
isMinimized.value = !isMinimized.value;
};
const endCall = () => {
// 停止所有媒体流
if (localStream.value) {
localStream.value.getTracks().forEach(track => track.stop());
}
isVisible.value = false;
emit('end-call');
};
// 拖拽功能
const startDrag = (event) => {
if (isMinimized.value || event.target.closest('.call-controls')) return;
isDragging.value = true;
dragOffset.x = event.clientX - position.x;
dragOffset.y = event.clientY - position.y;
document.addEventListener('mousemove', handleDrag);
document.addEventListener('mouseup', stopDrag);
};
const handleDrag = (event) => {
if (!isDragging.value) return;
position.x = event.clientX - dragOffset.x;
position.y = event.clientY - dragOffset.y;
// 限制在窗口范围内
const maxX = window.innerWidth - 320;
const maxY = window.innerHeight - 240;
position.x = Math.max(0, Math.min(position.x, maxX));
position.y = Math.max(0, Math.min(position.y, maxY));
};
const stopDrag = () => {
isDragging.value = false;
document.removeEventListener('mousemove', handleDrag);
document.removeEventListener('mouseup', stopDrag);
};
onMounted(() => {
initializeMedia();
// 设置初始位置(屏幕右上角)
position.x = window.innerWidth - 340;
position.y = 20;
});
onUnmounted(() => {
if (localStream.value) {
localStream.value.getTracks().forEach(track => track.stop());
}
document.removeEventListener('mousemove', handleDrag);
document.removeEventListener('mouseup', stopDrag);
});
</script>
<style scoped>
.video-call-container {
position: fixed;
z-index: 9999;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
width: 320px;
height: 240px;
}
.video-call-container.dark {
background: #2d2d2d;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}
.video-call-container.minimized {
width: 200px;
height: 60px;
cursor: pointer;
}
.call-header {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: move;
}
.call-info {
display: flex;
align-items: center;
gap: 12px;
}
.caller-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 14px;
}
.caller-details {
display: flex;
flex-direction: column;
}
.caller-name {
font-size: 14px;
font-weight: 600;
}
.call-status {
font-size: 12px;
opacity: 0.8;
}
.call-actions {
display: flex;
gap: 8px;
}
.action-btn {
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.2);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.close-btn:hover {
background: #ff6b6b;
}
.minimized-content {
display: flex;
align-items: center;
padding: 12px 16px;
gap: 12px;
height: 100%;
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
}
.mini-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 14px;
flex-shrink: 0;
}
.mini-info {
flex: 1;
min-width: 0;
}
.mini-name {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-status {
font-size: 12px;
opacity: 0.8;
}
.call-indicator {
display: flex;
align-items: center;
}
.pulse-dot {
width: 8px;
height: 8px;
background: #4caf50;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.7; }
100% { transform: scale(1); opacity: 1; }
}
.video-area {
position: relative;
flex: 1;
background: #000;
overflow: hidden;
}
.remote-video-container {
width: 100%;
height: 100%;
position: relative;
}
.remote-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
}
.placeholder-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
margin-bottom: 12px;
}
.placeholder-text {
font-size: 16px;
font-weight: 500;
}
.local-video-container {
position: absolute;
bottom: 12px;
right: 12px;
width: 80px;
height: 60px;
border-radius: 8px;
overflow: hidden;
border: 2px solid white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.local-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.call-controls {
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
padding: 12px;
display: flex;
justify-content: center;
gap: 16px;
}
.control-btn {
width: 44px;
height: 44px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.3s ease;
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.control-btn.active {
background: #4caf50;
}
.end-call-btn {
background: #ff6b6b;
}
.end-call-btn:hover {
background: #ff5252;
}
/* 响应式设计 */
@media (max-width: 768px) {
.video-call-container {
width: 280px;
height: 200px;
}
.video-call-container.minimized {
width: 180px;
height: 50px;
}
.local-video-container {
width: 60px;
height: 45px;
bottom: 8px;
right: 8px;
}
.control-btn {
width: 40px;
height: 40px;
font-size: 14px;
}
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div
ref="containerRef"
class="virtual-list-container"
:style="{ height: containerHeight + 'px' }"
@scroll="handleScroll"
>
<div
class="virtual-list-phantom"
:style="{ height: totalHeight + 'px' }"
></div>
<div
class="virtual-list-content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="item in visibleItems"
:key="item.id"
class="virtual-list-item"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item" :index="item.index"></slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
const props = defineProps({
items: {
type: Array,
required: true
},
itemHeight: {
type: Number,
default: 80
},
containerHeight: {
type: Number,
default: 400
}
});
const containerRef = ref(null);
const scrollTop = ref(0);
// 计算总高度
const totalHeight = computed(() => props.items.length * props.itemHeight);
// 计算可见区域的起始索引
const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight));
// 计算可见区域的结束索引
const endIndex = computed(() => {
const visibleCount = Math.ceil(props.containerHeight / props.itemHeight);
return Math.min(startIndex.value + visibleCount + 1, props.items.length - 1);
});
// 计算可见的项目
const visibleItems = computed(() => {
return props.items.slice(startIndex.value, endIndex.value + 1).map((item, index) => ({
...item,
index: startIndex.value + index
}));
});
// 计算偏移量
const offsetY = computed(() => startIndex.value * props.itemHeight);
// 处理滚动事件
const handleScroll = (event) => {
scrollTop.value = event.target.scrollTop;
};
// 监听容器大小变化
const resizeObserver = ref(null);
onMounted(() => {
if (containerRef.value) {
resizeObserver.value = new ResizeObserver((entries) => {
for (const entry of entries) {
const { height } = entry.contentRect;
if (height !== props.containerHeight) {
// 可以在这里触发高度变化事件
}
}
});
resizeObserver.value.observe(containerRef.value);
}
});
onUnmounted(() => {
if (resizeObserver.value) {
resizeObserver.value.disconnect();
}
});
</script>
<style scoped>
.virtual-list-container {
position: relative;
overflow-y: auto;
}
.virtual-list-phantom {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: -1;
}
.virtual-list-content {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.virtual-list-item {
box-sizing: border-box;
}
</style>

View File

@@ -1,4 +1,4 @@
import { ref } from "vue"
import { ref, nextTick } from "vue"
import { message } from "ant-design-vue"
export function useFileUpload() {
@@ -10,37 +10,35 @@ export function useFileUpload() {
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()
}
nextTick(() => {
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
if (!event.target.files || event.target.files.length === 0) return
const file = event.target.files[0]
// 确定文件类型
let detectedType = ""
let detectedType = currentFileType.value
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
const maxSize = detectedType === "image" ? 10 * 1024 * 1024 : 50 * 1024 * 1024
if (file.size > maxSize) {
const maxSizeMB = maxSize / 1024 / 1024
message.error(`文件大小超过限制!${currentFileType.value === "image" ? "图片" : "视频"}最大${maxSizeMB}MB`)
message.error(`文件大小超过限制!${detectedType === "image" ? "图片" : "视频"}最大${maxSizeMB}MB`)
event.target.value = ""
return
}
@@ -49,13 +47,13 @@ export function useFileUpload() {
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)) {
if (detectedType === "image" && !validImageTypes.includes(file.type)) {
message.error("请选择有效的图片格式 (JPEG, PNG, GIF, WebP, BMP)")
event.target.value = ""
return
}
if (currentFileType.value === "video" && !validVideoTypes.includes(file.type)) {
if (detectedType === "video" && !validVideoTypes.includes(file.type)) {
message.error("请选择有效的视频格式 (MP4, AVI, MOV, WMV, FLV, WebM, MKV)")
event.target.value = ""
return
@@ -64,7 +62,7 @@ export function useFileUpload() {
const reader = new FileReader()
reader.onload = (e) => {
uploadPreview.value = {
type: currentFileType.value,
type: detectedType,
url: e.target.result,
name: file.name,
size: file.size,

View File

@@ -2,55 +2,65 @@ 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 isRecording = ref(false)
const mediaRecorder = ref(null)
const audioChunks = ref([])
const recordingStartTime = ref(0)
const startRecording = () => {
if (isRecording.value) return
const startRecording = () => {
if (isRecording.value) return
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
isRecording.value = true
audioChunks.value = []
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
isRecording.value = true
audioChunks.value = []
recordingStartTime.value = Date.now()
mediaRecorder.value = new MediaRecorder(stream)
mediaRecorder.value = new MediaRecorder(stream)
mediaRecorder.value.ondataavailable = (event) => {
audioChunks.value.push(event.data)
}
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)
mediaRecorder.value.onstop = () => {
const audioBlob = new Blob(audioChunks.value, { type: "audio/wav" })
const audioUrl = URL.createObjectURL(audioBlob)
const duration = Math.floor((Date.now() - recordingStartTime.value) / 1000)
// 这里可以触发发送音频消息事件
// 或者调用父组件的方法
// 触发音频消息发送事件
const event = new CustomEvent("audioRecorded", {
detail: {
url: audioUrl,
duration: duration,
blob: audioBlob,
},
})
window.dispatchEvent(event)
// 停止所有音频轨道
stream.getTracks().forEach((track) => track.stop())
}
// 停止所有音频轨道
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
mediaRecorder.value.start()
})
.catch((error) => {
console.error("录音失败:", error)
message.error("无法访问麦克风,请检查权限设置")
isRecording.value = false
})
}
mediaRecorder.value.stop()
}
const stopRecording = () => {
if (!isRecording.value || !mediaRecorder.value) return
isRecording.value = false
mediaRecorder.value.stop()
}
return {
isRecording,
startRecording,
stopRecording,
}
return {
isRecording,
startRecording,
stopRecording,
}
}

View File

@@ -9,7 +9,7 @@ export function useWebSocket() {
try {
chatStore.connectionStatus = "connecting"
const wsUrl = "ws://localhost:12080/ws"
const wsUrl = "ws://g-ws.nailaoyun.cn/ws"
console.log("连接WebSocket:", wsUrl)
chatStore.socket = new WebSocket(wsUrl)

View File

@@ -7,6 +7,7 @@ import App from "./App.vue"
import "ant-design-vue/dist/reset.css"
import "./style.css"
import VueEasyLightbox from "vue-easy-lightbox";
import '@fortawesome/fontawesome-free/css/all.min.css'
const app = createApp(App)
const pinia = createPinia()

View File

@@ -1,5 +1,6 @@
import { defineStore } from "pinia"
import { ref, computed } from "vue"
import { chatDB } from "@/utils/db"
export const useChatStore = defineStore("chat", () => {
const friends = ref([])
@@ -37,80 +38,121 @@ export const useChatStore = defineStore("chat", () => {
}
})
// 获取聊天历史记录
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 initDB = async () => {
try {
await chatDB.init()
console.log("数据库初始化成功")
} catch (error) {
console.error("数据库初始化失败:", error)
}
}
// 加载好友列表
const loadFriends = (presetUsers, currentUserId) => {
const friendList = presetUsers.filter((user) => user.id !== currentUserId)
const loadFriends = async (presetUsers, currentUserId) => {
try {
// 从数据库获取好友列表
let friendList = await chatDB.getFriends(currentUserId)
friendList.forEach((friend) => {
const chatHistory = getChatHistory(friend.id, currentUserId)
// 如果数据库中没有好友,使用预设用户初始化
if (friendList.length === 0) {
friendList = presetUsers.filter((user) => user.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
// 保存到数据库
for (const friend of friendList) {
await chatDB.saveFriend(
{
...friend,
lastMessage: "",
unreadCount: 0,
},
currentUserId,
)
}
}
friend.unreadCount = chatHistory.filter((msg) => msg.senderId === friend.id && !msg.read).length
})
friends.value = friendList
friends.value = friendList
} catch (error) {
console.error("加载好友列表失败:", error)
// 降级到预设用户
const friendList = presetUsers.filter((user) => user.id !== currentUserId)
friendList.forEach((friend) => {
friend.lastMessage = ""
friend.unreadCount = 0
})
friends.value = friendList
}
}
// 切换当前聊天好友
const switchFriend = (friend, currentUserId) => {
const switchFriend = async (friend, currentUserId) => {
currentFriend.value = friend
// 清空未读消息计数
friend.unreadCount = 0
await chatDB.clearUnreadCount(friend.id)
// 加载聊天记录
const chatHistory = getChatHistory(friend.id, currentUserId)
chatHistory.forEach((msg) => {
if (msg.senderId !== currentUserId) {
msg.read = true
}
})
try {
const chatHistory = await chatDB.getChatHistory(currentUserId, friend.id)
messages.value = chatHistory.sort((a, b) => a.timestamp - b.timestamp)
} catch (error) {
console.error("加载聊天记录失败:", error)
messages.value = []
}
}
// 切换当前聊天好友
const setCurrentFriend = async (friend, currentUserId) => {
currentFriend.value = friend
messages.value = chatHistory
saveChatHistory(friend.id, currentUserId, chatHistory)
// 清空未读消息计数
friend.unreadCount = 0
await chatDB.clearUnreadCount(friend.id)
// 加载聊天记录
try {
const chatHistory = await chatDB.getChatHistory(currentUserId, friend.id)
console.log("chatHistory:", chatHistory)
console.log("currentUserId:", currentUserId)
console.log("friend.id:", friend.id)
messages.value = chatHistory.sort((a, b) => a.timestamp - b.timestamp)
console.log("messages.value:", messages.value)
} catch (error) {
console.error("加载聊天记录失败:", error)
messages.value = []
}
}
// 添加消息
const addMessage = (message, currentUserId) => {
const addMessage = async (message, currentUserId) => {
messages.value.push(message)
if (currentFriend.value) {
saveChatHistory(currentFriend.value.id, currentUserId, messages.value)
try {
// 保存到数据库
await chatDB.saveMessage(message, currentUserId, currentFriend.value.id)
// 更新好友列表中的最后消息
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 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
// 更新数据库中的好友信息
await chatDB.updateFriendLastMessage(friend.id, currentUserId, friend.lastMessage, 0)
}
} catch (error) {
console.error("保存消息失败:", error)
}
}
}
// 处理接收到的消息
const handleIncomingMessage = (messageData, currentUserId) => {
const handleIncomingMessage = async (messageData, currentUserId) => {
const senderId = messageData.sender_user_id
const receiverId = messageData.receiver_user_id
@@ -123,31 +165,36 @@ export const useChatStore = defineStore("chat", () => {
senderId: senderId,
read: false,
duration: messageData.duration || 0,
timestamp: Date.now(),
}
// 保存消息到本地存储
const chatHistory = getChatHistory(senderId, currentUserId)
chatHistory.push(newMessage)
saveChatHistory(senderId, currentUserId, chatHistory)
try {
// 保存消息到数据库
await chatDB.saveMessage(newMessage, currentUserId, senderId)
// 如果消息来自当前聊天用户,直接显示
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
// 如果消息来自当前聊天用户,直接显示
if (currentFriend.value && senderId == currentFriend.value.id) {
newMessage.read = true
messages.value.push(newMessage)
} 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
// 更新最后消息
let lastMessage = newMessage.content
if (newMessage.type === "image") lastMessage = "[图片]"
if (newMessage.type === "video") lastMessage = "[视频]"
if (newMessage.type === "audio") lastMessage = "[语音]"
friend.lastMessage = lastMessage
// 更新数据库
await chatDB.updateFriendLastMessage(friend.id, currentUserId, lastMessage, friend.unreadCount)
}
}
} catch (error) {
console.error("处理接收消息失败:", error)
}
}
@@ -166,10 +213,10 @@ export const useChatStore = defineStore("chat", () => {
isConnected,
connectionStatusText,
connectionStatusIcon,
getChatHistory,
saveChatHistory,
initDB,
loadFriends,
switchFriend,
setCurrentFriend,
addMessage,
handleIncomingMessage,
}

View File

@@ -49,7 +49,7 @@
/* 全局样式 */
* {
font-family: "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
sans-serif;
sans-serif;
}
body {
@@ -81,8 +81,8 @@ body {
/* Ant Design 组件样式覆盖 */
.ant-input:focus,
.ant-input-focused {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(67, 97, 238, 0.2);
border-color: var(--primary) !important;
box-shadow: 0 0 0 2px rgba(67, 97, 238, 0.2) !important;
}
.ant-btn-primary {
@@ -95,6 +95,78 @@ body {
border-color: var(--secondary);
}
/* 暗色模式下的Ant Design组件样式 */
[data-theme="dark"] .ant-input {
background-color: #374151 !important;
border-color: #4b5563 !important;
color: #f3f4f6 !important;
}
[data-theme="dark"] .ant-input::placeholder {
color: #9ca3af !important;
}
[data-theme="dark"] .ant-input:focus,
[data-theme="dark"] .ant-input-focused {
background-color: #4b5563 !important;
border-color: var(--primary) !important;
}
[data-theme="dark"] .ant-textarea {
background-color: #374151 !important;
border-color: #4b5563 !important;
color: #f3f4f6 !important;
}
[data-theme="dark"] .ant-btn {
background-color: #374151 !important;
border-color: #4b5563 !important;
color: #f3f4f6 !important;
}
[data-theme="dark"] .ant-btn:hover {
background-color: #4b5563 !important;
border-color: #6b7280 !important;
}
[data-theme="dark"] .ant-dropdown {
background-color: #374151 !important;
}
[data-theme="dark"] .ant-dropdown .ant-dropdown-menu {
background-color: #374151 !important;
border-color: #4b5563 !important;
}
[data-theme="dark"] .ant-dropdown .ant-dropdown-menu-item {
color: #f3f4f6 !important;
}
[data-theme="dark"] .ant-dropdown .ant-dropdown-menu-item:hover {
background-color: #4b5563 !important;
}
[data-theme="dark"] .ant-modal {
background-color: #374151 !important;
}
[data-theme="dark"] .ant-modal-content {
background-color: #374151 !important;
}
[data-theme="dark"] .ant-modal-header {
background-color: #374151 !important;
border-bottom-color: #4b5563 !important;
}
[data-theme="dark"] .ant-modal-title {
color: #f3f4f6 !important;
}
[data-theme="dark"] .ant-modal-body {
color: #f3f4f6 !important;
}
/* 动画 */
@keyframes pulse {
0% {

143
src/utils/db.js Normal file
View File

@@ -0,0 +1,143 @@
// IndexedDB 数据库工具类
class ChatDB {
constructor() {
this.dbName = "ChatApp"
this.version = 1
this.db = null
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve(this.db)
}
request.onupgradeneeded = (event) => {
const db = event.target.result
// 创建聊天记录表
if (!db.objectStoreNames.contains("messages")) {
const messageStore = db.createObjectStore("messages", { keyPath: "id", autoIncrement: true })
messageStore.createIndex("chatKey", "chatKey", { unique: false })
messageStore.createIndex("timestamp", "timestamp", { unique: false })
}
// 创建用户表
if (!db.objectStoreNames.contains("users")) {
const userStore = db.createObjectStore("users", { keyPath: "id" })
}
// 创建好友表
if (!db.objectStoreNames.contains("friends")) {
const friendStore = db.createObjectStore("friends", { keyPath: "id" })
friendStore.createIndex("userId", "userId", { unique: false })
}
}
})
}
// 保存消息
async saveMessage(message, currentUserId, friendId) {
const transaction = this.db.transaction(["messages"], "readwrite")
const store = transaction.objectStore("messages")
const messageData = {
...message,
chatKey: `${currentUserId}_${friendId}`,
timestamp: Date.now(),
}
return store.add(messageData)
}
// 获取聊天记录
async getChatHistory(currentUserId, friendId) {
const transaction = this.db.transaction(["messages"], "readonly")
const store = transaction.objectStore("messages")
const index = store.index("chatKey")
const chatKey = `${currentUserId}_${friendId}`
const request = index.getAll(chatKey)
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result || [])
request.onerror = () => reject(request.error)
})
}
// 保存好友信息
async saveFriend(friend, currentUserId) {
const transaction = this.db.transaction(["friends"], "readwrite")
const store = transaction.objectStore("friends")
const friendData = {
...friend,
userId: currentUserId,
updatedAt: Date.now(),
}
return store.put(friendData)
}
// 获取好友列表
async getFriends(currentUserId) {
const transaction = this.db.transaction(["friends"], "readonly")
const store = transaction.objectStore("friends")
const index = store.index("userId")
const request = index.getAll(currentUserId)
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result || [])
request.onerror = () => reject(request.error)
})
}
// 更新好友最后消息
async updateFriendLastMessage(friendId, currentUserId, lastMessage, unreadCount = 0) {
const transaction = this.db.transaction(["friends"], "readwrite")
const store = transaction.objectStore("friends")
const request = store.get(friendId)
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const friend = request.result
if (friend) {
friend.lastMessage = lastMessage
friend.unreadCount = unreadCount
friend.updatedAt = Date.now()
store.put(friend)
}
resolve()
}
request.onerror = () => reject(request.error)
})
}
// 清空未读消息
async clearUnreadCount(friendId) {
const transaction = this.db.transaction(["friends"], "readwrite")
const store = transaction.objectStore("friends")
const request = store.get(friendId)
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const friend = request.result
if (friend) {
friend.unreadCount = 0
store.put(friend)
}
resolve()
}
request.onerror = () => reject(request.error)
})
}
}
export const chatDB = new ChatDB()

View File

@@ -9,44 +9,87 @@ const service = axios.create({
// 请求拦截器
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)
},
(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) {
(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)
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)
},
},
(error) => {
console.error("Response error:", error)
// 详细的错误处理
if (error.code === "ECONNABORTED") {
console.log("请求超时,请检查网络连接")
} else if (error.response?.status === 0 || error.message.includes("CORS")) {
console.log("CORS错误检测到尝试备用方案...")
handleCORSError(error.config)
} else if (error.response?.status >= 400 && error.response?.status < 500) {
console.log("客户端错误:", error.response.status, error.response.data)
} else if (error.response?.status >= 500) {
console.log("服务器错误:", error.response.status)
} else {
console.log("网络错误:", error.message)
}
message.error("网络请求失败")
return Promise.reject(error)
},
)
// CORS错误的备用处理方案
const handleCORSError = (config) => {
console.log("执行CORS错误备用方案...")
// /* "http://g-ws.nailaoyun.cn" + */
const apiUrl = config.url.replace("/api", "")
return fetch(apiUrl, {
method: config.method.toUpperCase(),
headers: {
"Content-Type": "application/json",
...config.headers,
},
mode: "cors",
body: config.data ? JSON.stringify(config.data) : undefined,
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return response.json()
})
.catch((error) => {
console.error("备用方案也失败:", error)
throw error
})
}
// 封装请求方法
export const get = (url, params = {}) => {
return service.get(url, { params })
@@ -75,12 +118,34 @@ export const sendMessage = (data) => {
video: 3,
}
return post("send-to-user", {
// 直接使用固定的后端接口地址
const apiUrl = "/api/send-to-user"
const requestData = {
sender_user_id: data.senderId,
receiver_user_id: data.receiverId,
message_type: messageTypeMap[data.type] || 0,
message_content: data.content,
})
}
console.log("发送消息到API:", apiUrl, requestData)
return axios
.post(apiUrl, requestData, {
headers: {
"Content-Type": "application/json",
},
timeout: 15000,
withCredentials: false,
})
.then((response) => {
console.log("消息发送成功:", response.data)
return response.data
})
.catch((error) => {
console.error("消息发送失败:", error)
throw error
})
}
export default service

View File

@@ -1,49 +1,67 @@
<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 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 />
<!-- 主聊天容器 -->
<div class="flex-1 max-w-7xl mx-auto bg-white/90 backdrop-blur-sm rounded-2xl shadow-2xl overflow-hidden flex z-10">
<!-- 侧边导航 -->
<SideNavigation @nav-change="handleNavChange" />
<!-- 好友列表 -->
<FriendList
class="w-80 min-w-80"
/>
<!-- 聊天区域 -->
<div class="flex-1 min-w-0">
<ChatArea v-if="chatStore.currentFriend && currentNav === 'chat'" />
<FriendsManagement v-else-if="currentNav === 'friends'" />
<GroupsManagement v-else-if="currentNav === 'groups'" />
<MomentsView v-else-if="currentNav === 'moments'" />
<EmptyState v-else />
</div>
</div>
<!-- 媒体预览模态框 -->
<MediaPreview />
</div>
</template>
<script setup>
import { onMounted, onUnmounted, provide } from 'vue';
import { ref, 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 SideNavigation from '@/components/SideNavigation.vue';
import FriendList from '@/components/FriendList.vue';
import ChatArea from '@/components/ChatArea.vue';
import EmptyState from '@/components/EmptyState.vue';
import ConnectionStatus from '@/components/ConnectionStatus.vue';
import RecordingIndicator from '@/components/RecordingIndicator.vue';
import MediaPreview from '@/components/MediaPreview.vue';
import FriendsManagement from '@/components/FriendsManagement.vue';
import GroupsManagement from '@/components/GroupsManagement.vue';
import MomentsView from '@/components/MomentsView.vue';
const userStore = useUserStore();
const chatStore = useChatStore();
const themeStore = useThemeStore();
const currentNav = ref('chat');
// 初始化WebSocket连接
const { connectWebSocket, disconnectWebSocket } = useWebSocket();
@@ -53,56 +71,63 @@ const { isRecording } = useRecording();
// 提供录音状态给子组件
provide('isRecording', isRecording);
// 处理导航切换
const handleNavChange = (nav) => {
currentNav.value = nav;
};
// 检查用户会话
userStore.checkSession();
// 加载主题
themeStore.loadTheme();
// 加载好友列表
const currentUser = userStore.currentUser;
const presetUsers = userStore.presetUsers;
onMounted(() => {
if (currentUser) {
chatStore.loadFriends(presetUsers, currentUser.id);
connectWebSocket();
}
onMounted(async () => {
if (userStore.currentUser) {
// 初始化数据库
await chatStore.initDB();
// 加载好友列表
await chatStore.loadFriends(userStore.presetUsers, userStore.currentUser.id);
// 连接WebSocket
connectWebSocket();
}
});
onUnmounted(() => {
disconnectWebSocket();
disconnectWebSocket();
});
</script>
<style scoped>
@keyframes float {
0% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(10deg); }
100% { transform: translateY(0px) rotate(0deg); }
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); }
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); }
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;
animation: float 12s infinite ease-in-out;
}
.animate-float-delayed {
animation: float-delayed 15s infinite ease-in-out;
animation: float-delayed 15s infinite ease-in-out;
}
.animate-float-slow {
animation: float-slow 18s infinite ease-in-out;
animation: float-slow 18s infinite ease-in-out;
}
</style>

View File

@@ -1,39 +1,117 @@
<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 class="min-h-screen relative overflow-hidden bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-900">
<!-- 动态背景 -->
<div class="absolute inset-0">
<!-- 渐变网格 -->
<div class="absolute inset-0 opacity-20 jbwg"></div>
<!-- 浮动装饰元素 -->
<div class="floating-shapes">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
<div class="shape shape-3"></div>
<div class="shape shape-4"></div>
<div class="shape shape-5"></div>
<div class="shape shape-6"></div>
</div>
<!-- 光效 -->
<div class="absolute top-0 left-1/4 w-96 h-96 bg-blue-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob"></div>
<div class="absolute top-0 right-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob animation-delay-2000"></div>
<div class="absolute -bottom-8 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob animation-delay-4000"></div>
</div>
<!-- 主内容 -->
<div class="relative z-10 flex items-center justify-center min-h-screen p-6">
<div class="w-full max-w-6xl">
<!-- 玻璃态卡片 -->
<div class="backdrop-blur-xl bg-white/10 border border-white/20 rounded-3xl shadow-2xl overflow-hidden">
<!-- 头部区域 -->
<div class="relative p-8 text-center">
<!-- 装饰性图标 -->
<div class="inline-flex items-center justify-center w-20 h-20 mb-6 bg-gradient-to-r from-blue-500 to-purple-600 rounded-2xl shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4 bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
WebSocket 聊天系统
</h1>
<p class="text-xl text-white/80 mb-2">现代化实时通讯平台</p>
<p class="text-white/60">选择用户身份开始您的聊天之旅</p>
</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="p-8 pt-0">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
<div
v-for="user in userStore.presetUsers"
:key="user.id"
class="user-card group relative"
@click="handleLogin(user)"
>
<!-- 卡片背景 -->
<div class="absolute inset-0 bg-gradient-to-br from-white/20 to-white/5 backdrop-blur-sm border border-white/20 rounded-2xl transition-all duration-300 group-hover:from-white/30 group-hover:to-white/10 group-hover:border-white/40 group-hover:shadow-xl group-hover:scale-105"></div>
<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 class="relative p-6 text-center cursor-pointer">
<!-- 头像 -->
<div class="relative mb-4">
<div
class="w-16 h-16 mx-auto rounded-2xl flex items-center justify-center text-white text-xl font-bold shadow-lg transition-transform duration-300 group-hover:scale-110"
:style="{ background: `linear-gradient(135deg, ${user.color}, ${adjustColor(user.color, -20)})` }"
>
{{ user.name.charAt(0) }}
</div>
<!-- 在线状态指示器 -->
<div class="absolute -bottom-1 -right-1 w-5 h-5 bg-green-500 border-2 border-white rounded-full shadow-sm"></div>
</div>
<!-- 用户信息 -->
<h4 class="text-lg font-semibold text-white mb-1 group-hover:text-blue-200 transition-colors">
{{ user.name }}
</h4>
<p class="text-sm text-white/60 group-hover:text-white/80 transition-colors">
ID: {{ user.id }}
</p>
<!-- 悬停效果 -->
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/20 to-purple-500/20 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
</div>
</div>
</a-card>
</div>
</div>
<!-- 底部装饰 -->
<div class="px-8 pb-8">
<div class="flex items-center justify-center space-x-4 text-white/40 text-sm">
<span class="flex items-center space-x-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
<span>安全加密</span>
</span>
<span></span>
<span class="flex items-center space-x-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg>
<span>实时通讯</span>
</span>
<span></span>
<span class="flex items-center space-x-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<span>多媒体支持</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
@@ -45,40 +123,138 @@ const userStore = useUserStore();
const router = useRouter();
const handleLogin = (user) => {
userStore.login(user);
message.success(`欢迎回来,${user.name}`);
router.push('/');
userStore.login(user);
message.success({
content: `欢迎回来,${user.name}`,
duration: 2,
style: {
marginTop: '20vh',
}
});
router.push('/');
};
// 颜色调整函数
const adjustColor = (color, amount) => {
const usePound = color[0] === '#';
const col = usePound ? color.slice(1) : color;
const num = parseInt(col, 16);
let r = (num >> 16) + amount;
let g = (num >> 8 & 0x00FF) + amount;
let b = (num & 0x0000FF) + amount;
r = r > 255 ? 255 : r < 0 ? 0 : r;
g = g > 255 ? 255 : g < 0 ? 0 : g;
b = b > 255 ? 255 : b < 0 ? 0 : b;
return (usePound ? '#' : '') + (r << 16 | g << 8 | b).toString(16).padStart(6, '0');
};
</script>
<style scoped>
/* 浮动动画 */
@keyframes float {
0% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(10deg); }
100% { transform: translateY(0px) rotate(0deg); }
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(5deg); }
}
@keyframes float-delayed {
0% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-15px) rotate(-5deg); }
100% { transform: translateY(0px) rotate(0deg); }
@keyframes blob {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(30px, -50px) scale(1.1); }
66% { transform: translate(-20px, 20px) scale(0.9); }
100% { transform: translate(0px, 0px) scale(1); }
}
@keyframes float-slow {
0% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-10px) rotate(5deg); }
100% { transform: translateY(0px) rotate(0deg); }
.jbwg {
background: url(data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%239C92AC" fill-opacity="0.1"%3E%3Ccircle cx="30" cy="30" r="2"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E);
}
.animate-float {
animation: float 12s infinite ease-in-out;
.animate-blob {
animation: blob 7s infinite;
}
.animate-float-delayed {
animation: float-delayed 15s infinite ease-in-out;
.animation-delay-2000 {
animation-delay: 2s;
}
.animate-float-slow {
animation: float-slow 18s infinite ease-in-out;
.animation-delay-4000 {
animation-delay: 4s;
}
/* 浮动装饰形状 */
.floating-shapes {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
}
.shape {
position: absolute;
background: linear-gradient(45deg, rgba(255,255,255,0.1), rgba(255,255,255,0.05));
border-radius: 50%;
animation: float 6s ease-in-out infinite;
}
.shape-1 {
width: 80px;
height: 80px;
top: 10%;
left: 10%;
animation-delay: 0s;
}
.shape-2 {
width: 120px;
height: 120px;
top: 20%;
right: 10%;
animation-delay: 1s;
}
.shape-3 {
width: 60px;
height: 60px;
top: 60%;
left: 20%;
animation-delay: 2s;
}
.shape-4 {
width: 100px;
height: 100px;
bottom: 20%;
right: 20%;
animation-delay: 3s;
}
.shape-5 {
width: 40px;
height: 40px;
top: 40%;
left: 60%;
animation-delay: 4s;
}
.shape-6 {
width: 90px;
height: 90px;
bottom: 40%;
left: 40%;
animation-delay: 5s;
}
/* 用户卡片悬停效果 */
.user-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.user-card:hover {
transform: translateY(-8px);
}
/* 响应式调整 */
@media (max-width: 768px) {
.shape {
display: none;
}
}
</style>