优化了本地存储和一些组件细分,完成了复制图片、适配可以直接发送的功能
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
244
src/components/CustomInput.vue
Normal file
244
src/components/CustomInput.vue
Normal 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>
|
||||
335
src/components/CustomModal.vue
Normal file
335
src/components/CustomModal.vue
Normal 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>
|
||||
311
src/components/CustomTextarea.vue
Normal file
311
src/components/CustomTextarea.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
49
src/components/EmptyState.vue
Normal file
49
src/components/EmptyState.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
69
src/components/FriendsManagement.vue
Normal file
69
src/components/FriendsManagement.vue
Normal 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>
|
||||
66
src/components/GroupsManagement.vue
Normal file
66
src/components/GroupsManagement.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
400
src/components/MessageBubble.vue
Normal file
400
src/components/MessageBubble.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
90
src/components/MomentsView.vue
Normal file
90
src/components/MomentsView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
518
src/components/SettingsModal.vue
Normal file
518
src/components/SettingsModal.vue
Normal 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>
|
||||
273
src/components/SideNavigation.vue
Normal file
273
src/components/SideNavigation.vue
Normal 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>
|
||||
400
src/components/UserProfileModal.vue
Normal file
400
src/components/UserProfileModal.vue
Normal 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>
|
||||
577
src/components/VideoCallComponent.vue
Normal file
577
src/components/VideoCallComponent.vue
Normal 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>
|
||||
125
src/components/VirtualList.vue
Normal file
125
src/components/VirtualList.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
143
src/utils/db.js
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user