fix: 聊天功能(部分)
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
This commit is contained in:
@@ -1,40 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useAntdDesignTokens } from '@vben/hooks';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { useUserStore as useVbenUserStore } from '@vben/stores';
|
||||
|
||||
import { App, ConfigProvider, theme } from 'ant-design-vue';
|
||||
|
||||
import { antdLocale } from '#/locales';
|
||||
import { useWebSocket } from '#/views/business/chat/composables/useWebSocket';
|
||||
import { useChatStore } from '#/views/business/chat/stores/chat';
|
||||
import { useUserStore } from '#/views/business/chat/stores/user';
|
||||
|
||||
defineOptions({ name: 'App' });
|
||||
// 定义颜色代码
|
||||
const redBoldYellowBg = '\u001B[1;31;43m'; // 1=加粗 31=红色文字 43=黄色背景
|
||||
const reset = '\u001B[0m';
|
||||
|
||||
console.log(
|
||||
`${redBoldYellowBg}......................阿弥陀佛...................... \n` +
|
||||
` _oo0oo_ \n` +
|
||||
` o8888888o \n` +
|
||||
` 88" . "88 \n` +
|
||||
` (| -_- |) \n` +
|
||||
` 0\\ = /0 \n` +
|
||||
` ___/‘---’\\___ \n` +
|
||||
` .' \\| |/ '. \n` +
|
||||
` / \\\\||| : |||// \\ \n` +
|
||||
` / _||||| -卍-|||||_ \\ \n` +
|
||||
` | | \\\\\\ - /// | | \n` +
|
||||
` | \\_| ''\\---/'' |_/ | \n` +
|
||||
` \\ .-\\__ '-' ___/-. / \n` +
|
||||
` ___'. .' /--.--\\ '. .'___ \n` +
|
||||
` ."" ‘< ‘.___\\_<|>_/___.’>’ "". \n` +
|
||||
` | | : ‘- \\‘.;‘\\ _ /’;.’/ - ’ : | | \n` +
|
||||
` \\ \\ ‘_. \\_ __\\ /__ _/ .-’ / / \n` +
|
||||
` =====‘-.____‘.___ \\_____/___.-’___.-’===== \n` +
|
||||
` ‘=---=’ \n` +
|
||||
` \n` +
|
||||
`....................佛祖保佑 ,永无BUG...................${reset}`,
|
||||
const { connectWebSocket } = useWebSocket();
|
||||
const userStore = useUserStore();
|
||||
const chatStore = useChatStore();
|
||||
const vbenUserStore = useVbenUserStore();
|
||||
|
||||
// 用户信息加载状态跟踪
|
||||
const userInfoLoaded = ref(false);
|
||||
|
||||
// 监听userInfo加载完成
|
||||
watch(
|
||||
() => vbenUserStore.userInfo,
|
||||
(newUserInfo) => {
|
||||
if (newUserInfo) {
|
||||
console.log('用户信息已加载:', newUserInfo)
|
||||
userStore.currentUser = newUserInfo;
|
||||
userStore.login(newUserInfo);
|
||||
userInfoLoaded.value = true;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
@@ -45,7 +44,6 @@ const tokenTheme = computed(() => {
|
||||
? [theme.darkAlgorithm]
|
||||
: [theme.defaultAlgorithm];
|
||||
|
||||
// antd 紧凑模式算法
|
||||
if (preferences.app.compact) {
|
||||
algorithm.push(theme.compactAlgorithm);
|
||||
}
|
||||
@@ -55,6 +53,27 @@ const tokenTheme = computed(() => {
|
||||
token: tokens,
|
||||
};
|
||||
});
|
||||
|
||||
const initWebsocket = () => {
|
||||
// 确保用户信息已加载
|
||||
if (!userStore.currentUser) return;
|
||||
|
||||
// 初始化数据库
|
||||
chatStore.initDB();
|
||||
|
||||
// 加载好友列表(使用用户ID)
|
||||
chatStore.loadFriends(userStore.presetUsers, userStore.currentUser?.id);
|
||||
|
||||
// 创建WebSocket连接
|
||||
connectWebSocket();
|
||||
};
|
||||
|
||||
// 当userInfo加载完成后再执行初始化
|
||||
watch(userInfoLoaded, (loaded) => {
|
||||
if (loaded) {
|
||||
initWebsocket();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -3,6 +3,9 @@ import { unmountGlobalLoading } from '@vben/utils';
|
||||
|
||||
import { overridesPreferences } from './preferences';
|
||||
|
||||
// eslint-disable-next-line n/no-extraneous-import
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||
|
||||
/**
|
||||
* 应用初始化完成之后再进行页面加载渲染
|
||||
*/
|
||||
|
||||
10
apps/web-antd/src/views/business/chat/api/index.ts
Normal file
10
apps/web-antd/src/views/business/chat/api/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'chat-friends/';
|
||||
/**
|
||||
* 获取当前登录的诊所信息
|
||||
* @param data
|
||||
*/
|
||||
export async function getChatFriendsListApi(data: any = {}) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded-xl p-4 flex items-center gap-4 min-w-72 max-w-md cursor-pointer transition-all duration-200 hover:opacity-90"
|
||||
:class="audioContainerClasses"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<!-- 播放状态指示器 -->
|
||||
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-full"
|
||||
:class="playIconBgClasses">
|
||||
<component
|
||||
:is="isPlaying ? PauseOutlined : CaretRightOutlined"
|
||||
class="text-white"
|
||||
style="font-size: 16px; transform: translateX(1px)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<!-- 高级音波动画 -->
|
||||
<div class="flex items-end gap-1 h-8 mb-2">
|
||||
<div
|
||||
v-for="i in 20"
|
||||
:key="i"
|
||||
class="rounded-full transition-all duration-300 ease-out"
|
||||
:class="waveBarClasses"
|
||||
:style="{
|
||||
width: '3px',
|
||||
height: isPlaying ? `${getWaveHeight(i)}px` : '4px',
|
||||
animationDelay: `${i * 40}ms`
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条和时间显示 -->
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<!-- 当前时间 -->
|
||||
<div class="text-xs font-medium flex-shrink-0" :class="timeTextClasses">
|
||||
{{ formatDuration(currentTime) }}
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="flex-1 rounded-full h-1.5 overflow-hidden" :class="progressTrackClasses">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300 ease-out"
|
||||
:class="progressBarClasses"
|
||||
:style="{ width: `${progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 剩余时间 -->
|
||||
<div class="text-xs font-medium flex-shrink-0" :class="timeTextClasses">
|
||||
-{{ formatDuration(remainingTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 总时长显示 -->
|
||||
<div class="text-sm font-medium flex-shrink-0 min-w-12 text-right" :class="durationTextClasses">
|
||||
{{ formatDuration(audioDuration) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref, onUnmounted, computed, watch} from 'vue';
|
||||
import {CaretRightOutlined, PauseOutlined} from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isSent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const isPlaying = ref(false);
|
||||
const progress = ref(0);
|
||||
const audio = ref(null);
|
||||
const audioDuration = ref(0);
|
||||
const currentTime = ref(0);
|
||||
const remainingTime = computed(() => Math.max(0, audioDuration.value - currentTime.value));
|
||||
|
||||
// 直接获取音频时长
|
||||
const getAudioDuration = () => {
|
||||
// 如果消息对象中已经包含时长,直接使用
|
||||
if (props.message.duration && props.message.duration > 0) {
|
||||
audioDuration.value = props.message.duration;
|
||||
return;
|
||||
}
|
||||
|
||||
// 否则创建临时音频获取元数据
|
||||
const tempAudio = new Audio(props.message.content);
|
||||
tempAudio.addEventListener('loadedmetadata', () => {
|
||||
if (tempAudio.duration && tempAudio.duration > 0) {
|
||||
audioDuration.value = Math.round(tempAudio.duration);
|
||||
}
|
||||
});
|
||||
|
||||
// 设置超时以防无法获取元数据
|
||||
setTimeout(() => {
|
||||
if (audioDuration.value === 0 && tempAudio.duration > 0) {
|
||||
audioDuration.value = Math.round(tempAudio.duration);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 初始化时获取音频时长
|
||||
getAudioDuration();
|
||||
|
||||
// 样式计算属性
|
||||
const audioContainerClasses = computed(() => {
|
||||
if (props.isSent) {
|
||||
return 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg';
|
||||
} else {
|
||||
return 'bg-gray-50 dark:bg-gray-700 border border-gray-100 dark:border-gray-600 shadow';
|
||||
}
|
||||
});
|
||||
|
||||
const playIconBgClasses = computed(() => {
|
||||
if (props.isSent) {
|
||||
return 'bg-white/20';
|
||||
} else {
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
});
|
||||
|
||||
const waveBarClasses = computed(() => {
|
||||
if (props.isSent) {
|
||||
return ['bg-white/80', {'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-200 dark:bg-gray-600';
|
||||
}
|
||||
});
|
||||
|
||||
const progressBarClasses = computed(() => {
|
||||
if (props.isSent) {
|
||||
return 'bg-white';
|
||||
} else {
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
});
|
||||
|
||||
const durationTextClasses = computed(() => {
|
||||
if (props.isSent) {
|
||||
return 'text-white/90';
|
||||
} else {
|
||||
return 'text-gray-700 dark:text-gray-300';
|
||||
}
|
||||
});
|
||||
|
||||
const timeTextClasses = computed(() => {
|
||||
if (props.isSent) {
|
||||
return 'text-white/80';
|
||||
} else {
|
||||
return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
});
|
||||
|
||||
// 生成高级波形高度
|
||||
const getWaveHeight = (index) => {
|
||||
const baseHeight = 4;
|
||||
const maxHeight = 20;
|
||||
// 使用更平滑的正弦波叠加
|
||||
const wavePattern =
|
||||
Math.sin((index * 0.4) + (Date.now() * 0.008)) * 0.4 +
|
||||
Math.cos((index * 0.7) + (Date.now() * 0.01)) * 0.3;
|
||||
const normalized = Math.max(0, wavePattern * 0.5 + 0.5);
|
||||
return baseHeight + (maxHeight - baseHeight) * normalized;
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!audio.value) {
|
||||
audio.value = new Audio(props.message.content);
|
||||
|
||||
// 确保获取音频时长
|
||||
audio.value.addEventListener('loadedmetadata', () => {
|
||||
if (audio.value.duration && audio.value.duration > 0) {
|
||||
audioDuration.value = Math.round(audio.value.duration);
|
||||
}
|
||||
});
|
||||
|
||||
audio.value.addEventListener('timeupdate', () => {
|
||||
if (audio.value.duration) {
|
||||
currentTime.value = Math.round(audio.value.currentTime);
|
||||
progress.value = (currentTime.value / audioDuration.value) * 100;
|
||||
}
|
||||
});
|
||||
|
||||
audio.value.addEventListener('ended', () => {
|
||||
isPlaying.value = false;
|
||||
progress.value = 0;
|
||||
currentTime.value = 0;
|
||||
});
|
||||
}
|
||||
|
||||
if (isPlaying.value) {
|
||||
audio.value.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
audio.value.play();
|
||||
isPlaying.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
if (audio.value) {
|
||||
audio.value.pause();
|
||||
audio.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes wave {
|
||||
0%, 100% {
|
||||
transform: scaleY(0.7);
|
||||
opacity: 0.7;
|
||||
}
|
||||
25% {
|
||||
transform: scaleY(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(0.8);
|
||||
opacity: 0.8;
|
||||
}
|
||||
75% {
|
||||
transform: scaleY(1.1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-wave {
|
||||
animation: wave 1.2s ease-in-out infinite;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,495 @@
|
||||
<template>
|
||||
<div class="call-invite-overlay" :class="{ 'dark': themeStore.isDarkMode }">
|
||||
<div class="call-invite-dialog">
|
||||
<!-- 来电信息 -->
|
||||
<div class="caller-info">
|
||||
<div class="caller-avatar" :style="{ background: callerInfo.color }">
|
||||
{{ callerInfo.nick_name.charAt(0) }}
|
||||
</div>
|
||||
<div class="caller-details">
|
||||
<h3 class="caller-name">{{ callerInfo.nick_name }}</h3>
|
||||
<p class="call-type">{{ callType === 'video' ? '视频通话' : '语音通话' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通话状态 -->
|
||||
<div class="call-status">
|
||||
<div class="status-text">{{ statusText }}</div>
|
||||
<div class="call-animation">
|
||||
<div class="pulse-ring"></div>
|
||||
<div class="pulse-ring delay-1"></div>
|
||||
<div class="pulse-ring delay-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="call-actions">
|
||||
<button
|
||||
v-if="isVisible && isIncoming"
|
||||
class="action-btn accept-btn"
|
||||
@click="acceptCall"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<i class="fas fa-phone"></i>
|
||||
<span>接听</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action-btn reject-btn"
|
||||
@click="rejectCall"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<i class="fas fa-phone-slash"></i>
|
||||
<span>{{ isVisible && isIncoming ? '拒绝' : '取消' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 等待状态 -->
|
||||
<div v-if="isVisible && isProcessing" class="processing-overlay">
|
||||
<div class="spinner"></div>
|
||||
<div class="processing-text">{{ processingText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref, computed, onMounted, onUnmounted} from 'vue'
|
||||
import {useThemeStore} from '../stores/theme.ts'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const props = defineProps({
|
||||
isVisible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
callerInfo: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
callType: {
|
||||
type: String,
|
||||
default: 'video',
|
||||
validator: (value) => ['video', 'audio'].includes(value)
|
||||
},
|
||||
isIncoming: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
callId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['accept', 'reject', 'timeout'])
|
||||
|
||||
const isProcessing = ref(false)
|
||||
const timeoutTimer = ref(null)
|
||||
const callDuration = ref(0)
|
||||
const durationTimer = ref(null)
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (isProcessing.value) {
|
||||
return '连接中...'
|
||||
}
|
||||
|
||||
if (props.isIncoming) {
|
||||
return '邀请您进行通话'
|
||||
}
|
||||
|
||||
return '等待对方接听...'
|
||||
})
|
||||
|
||||
const processingText = computed(() => {
|
||||
if (props.isIncoming) {
|
||||
return '正在接听...'
|
||||
}
|
||||
return '正在取消...'
|
||||
})
|
||||
|
||||
const acceptCall = async () => {
|
||||
if (isProcessing.value) return
|
||||
|
||||
isProcessing.value = true
|
||||
|
||||
try {
|
||||
await emit('accept', {
|
||||
callId: props.callId,
|
||||
callType: props.callType
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('接听通话失败:', error)
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const rejectCall = async () => {
|
||||
if (isProcessing.value) return
|
||||
|
||||
isProcessing.value = true
|
||||
|
||||
try {
|
||||
await emit('reject', {
|
||||
callId: props.callId,
|
||||
callType: props.callType,
|
||||
reason: props.isIncoming ? '用户拒绝' : '用户取消'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('拒绝通话失败:', error)
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startTimeout = () => {
|
||||
// 来电30秒超时,拨出60秒超时
|
||||
const timeout = props.isIncoming ? 30000 : 60000
|
||||
|
||||
timeoutTimer.value = setTimeout(() => {
|
||||
emit('timeout', {
|
||||
callId: props.callId,
|
||||
reason: '超时未响应'
|
||||
})
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
const startDurationTimer = () => {
|
||||
durationTimer.value = setInterval(() => {
|
||||
callDuration.value++
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const clearTimers = () => {
|
||||
if (timeoutTimer.value) {
|
||||
clearTimeout(timeoutTimer.value)
|
||||
timeoutTimer.value = null
|
||||
}
|
||||
|
||||
if (durationTimer.value) {
|
||||
clearInterval(durationTimer.value)
|
||||
durationTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 键盘事件处理
|
||||
const handleKeydown = (event) => {
|
||||
if (!props.isVisible) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
if (props.isIncoming) {
|
||||
acceptCall()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
rejectCall()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.isVisible) {
|
||||
startTimeout()
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
|
||||
// 请求通知权限
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimers()
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.call-invite-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.call-invite-dialog {
|
||||
background: white;
|
||||
border-radius: 24px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
min-width: 320px;
|
||||
max-width: 400px;
|
||||
position: relative;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark .call-invite-dialog {
|
||||
background: #2d2d2d;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.caller-info {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.caller-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.caller-name {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark .caller-name {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.call-type {
|
||||
font-size: 16px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark .call-type {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.call-status {
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 18px;
|
||||
color: #374151;
|
||||
margin-bottom: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark .status-text {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.call-animation {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 2px solid #4361ee;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.pulse-ring.delay-1 {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.pulse-ring.delay-2 {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.call-actions {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn span {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
position: absolute;
|
||||
bottom: -24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.accept-btn {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.accept-btn:hover:not(:disabled) {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.reject-btn {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.reject-btn:hover:not(:disabled) {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.processing-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark .processing-overlay {
|
||||
background: rgba(45, 45, 45, 0.9);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top: 3px solid #4361ee;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.processing-text {
|
||||
font-size: 16px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.call-invite-overlay.dark .processing-text {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.call-invite-dialog {
|
||||
margin: 20px;
|
||||
padding: 24px;
|
||||
min-width: unset;
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.caller-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.caller-name {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.call-actions {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- 聊天头部 -->
|
||||
<ChatHeader/>
|
||||
|
||||
<!-- 消息区域 -->
|
||||
<MessageList class="flex-1"/>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<MessageInput/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ChatHeader from './ChatHeader.vue';
|
||||
import MessageList from './MessageList.vue';
|
||||
import MessageInput from './MessageInput.vue';
|
||||
</script>
|
||||
434
apps/web-antd/src/views/business/chat/components/ChatHeader.vue
Normal file
434
apps/web-antd/src/views/business/chat/components/ChatHeader.vue
Normal file
@@ -0,0 +1,434 @@
|
||||
<template>
|
||||
<div class="chat-header" :class="{ 'dark': themeStore.isDarkMode }">
|
||||
<div class="header-content">
|
||||
<div class="friend-info">
|
||||
<div
|
||||
class="friend-avatar"
|
||||
:style="{ background: chatStore.currentFriend?.color }"
|
||||
>
|
||||
{{ chatStore.currentFriend?.nick_name.charAt(0) }}
|
||||
</div>
|
||||
|
||||
<div class="friend-details">
|
||||
<h3 class="friend-name">{{ chatStore.currentFriend?.nick_name }}</h3>
|
||||
<p class="friend-status">
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="isFriendOnline ? 'online' : 'offline'"
|
||||
></span>
|
||||
<span class="status-text">
|
||||
{{ isFriendOnline ? '在线' : '离线' }}
|
||||
</span>
|
||||
<span v-if="callStatus" class="call-status-text">
|
||||
{{ callStatusText }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<!-- 语音通话按钮 -->
|
||||
<button
|
||||
class="action-btn voice-call-btn"
|
||||
@click="startVoiceCall"
|
||||
title="语音通话"
|
||||
>
|
||||
<i class="fas fa-phone"></i>
|
||||
</button>
|
||||
|
||||
<!-- 视频通话按钮 -->
|
||||
<button
|
||||
class="action-btn video-call-btn"
|
||||
@click="startVideoCall"
|
||||
title="视频通话"
|
||||
>
|
||||
<i class="fas fa-video"></i>
|
||||
</button>
|
||||
|
||||
<!-- 更多操作 -->
|
||||
<button
|
||||
class="action-btn more-btn"
|
||||
@click="showMoreActions = !showMoreActions"
|
||||
title="更多操作"
|
||||
>
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</button>
|
||||
|
||||
<!-- 更多操作菜单 -->
|
||||
<div v-if="showMoreActions" class="more-actions-menu">
|
||||
<div class="menu-item" @click="clearChatHistory">
|
||||
<i class="fas fa-trash"></i>
|
||||
<span>清空聊天记录</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="viewFriendProfile">
|
||||
<i class="fas fa-user"></i>
|
||||
<span>查看资料</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref, computed} from 'vue';
|
||||
import {useChatStore} from '../stores/chat.ts';
|
||||
import {useThemeStore} from '../stores/theme.ts';
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const isFriendOnline = ref(true);
|
||||
const showMoreActions = ref(false);
|
||||
|
||||
// 获取当前通话状态
|
||||
const callStatus = computed(() => {
|
||||
if (!chatStore.currentFriend) return null;
|
||||
|
||||
// 检查是否正在与当前好友通话
|
||||
if (chatStore.currentCall && chatStore.currentCall.peerId === chatStore.currentFriend.id) {
|
||||
return chatStore.callConnectionStatus || '通话中';
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
// 获取通话状态文本
|
||||
const callStatusText = computed(() => {
|
||||
if (!callStatus.value) return '';
|
||||
|
||||
switch (callStatus.value) {
|
||||
case '等待对方接听...':
|
||||
return '📞 呼叫中...';
|
||||
case '通话中':
|
||||
return '📞 通话中';
|
||||
case '对方已拒绝':
|
||||
return '❌ 已拒绝';
|
||||
case '对方无人接听':
|
||||
return '🕒 未接听';
|
||||
case '对方忙线中':
|
||||
return '🚫 忙线中';
|
||||
default:
|
||||
return '📞 ' + callStatus.value;
|
||||
}
|
||||
});
|
||||
|
||||
// 点击外部关闭菜单
|
||||
const handleClickOutside = (e) => {
|
||||
if (!e.target.closest('.more-btn') && !e.target.closest('.more-actions-menu')) {
|
||||
showMoreActions.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
|
||||
// 开始语音通话
|
||||
const startVoiceCall = () => {
|
||||
if (!chatStore.currentFriend) {
|
||||
alert('请先选择一个好友');
|
||||
return;
|
||||
}
|
||||
|
||||
chatStore.startCall(chatStore.currentFriend.id, 'audio');
|
||||
};
|
||||
|
||||
// 开始视频通话
|
||||
const startVideoCall = () => {
|
||||
if (!chatStore.currentFriend) {
|
||||
alert('请先选择一个好友');
|
||||
return;
|
||||
}
|
||||
|
||||
chatStore.startCall(chatStore.currentFriend.id, 'video');
|
||||
};
|
||||
|
||||
// 清空聊天记录
|
||||
const clearChatHistory = () => {
|
||||
if (confirm('确定要清空与该好友的聊天记录吗?')) {
|
||||
chatStore.messages = [];
|
||||
showMoreActions.value = false;
|
||||
console.log('聊天记录已清空');
|
||||
}
|
||||
};
|
||||
|
||||
// 查看好友资料
|
||||
const viewFriendProfile = () => {
|
||||
showMoreActions.value = false;
|
||||
console.log('查看好友资料:', chatStore.currentFriend);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-header {
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 16px 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-header.dark {
|
||||
background: #2d2d2d;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.friend-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.friend-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.friend-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-header.dark .friend-name {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.friend-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.chat-header.dark .status-text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.status-text.online {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.status-text.offline {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* 添加通话状态样式 */
|
||||
.call-status-text {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
color: #3b82f6;
|
||||
font-weight: 500;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-header.dark .call-status-text {
|
||||
color: #93c5fd;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.voice-call-btn {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.voice-call-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.video-call-btn {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.video-call-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.more-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.chat-header.dark .more-btn {
|
||||
background: #374151;
|
||||
color: #9ca3af;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.chat-header.dark .more-btn:hover {
|
||||
background: #4b5563;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.more-actions-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
padding: 8px 0;
|
||||
min-width: 160px;
|
||||
z-index: 1000;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chat-header.dark .more-actions-menu {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.chat-header.dark .menu-item {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.chat-header.dark .menu-item:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.menu-item i {
|
||||
width: 16px;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.chat-header.dark .menu-item i {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.chat-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.friend-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.friend-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.call-status-text {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import {
|
||||
DisconnectOutlined,
|
||||
LoadingOutlined,
|
||||
WifiOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { useChatStore } from '../stores/chat.ts';
|
||||
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const statusClass = computed(() => {
|
||||
switch (chatStore.connectionStatus) {
|
||||
case 'connected': {
|
||||
return 'bg-green-500 text-white';
|
||||
}
|
||||
case 'connecting': {
|
||||
return 'bg-yellow-500 text-white';
|
||||
}
|
||||
case 'disconnected': {
|
||||
return 'bg-red-500 text-white';
|
||||
}
|
||||
default: {
|
||||
return 'bg-gray-500 text-white';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
switch (chatStore.connectionStatus) {
|
||||
case 'connected': {
|
||||
return WifiOutlined;
|
||||
}
|
||||
case 'connecting': {
|
||||
return LoadingOutlined;
|
||||
}
|
||||
case 'disconnected': {
|
||||
return DisconnectOutlined;
|
||||
}
|
||||
default: {
|
||||
return WifiOutlined;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="statusClass"
|
||||
class="fixed left-5 bottom-5 z-50 flex items-center gap-2 rounded-full px-3 py-2 text-sm font-medium transition-all"
|
||||
>
|
||||
<component
|
||||
:is="iconComponent"
|
||||
:class="{ 'animate-spin': chatStore.connectionStatus === 'connecting' }"
|
||||
/>
|
||||
{{ chatStore.connectionStatusText }}
|
||||
</div>
|
||||
</template>
|
||||
245
apps/web-antd/src/views/business/chat/components/CustomInput.vue
Normal file
245
apps/web-antd/src/views/business/chat/components/CustomInput.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<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.ts';
|
||||
|
||||
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>
|
||||
302
apps/web-antd/src/views/business/chat/components/CustomModal.vue
Normal file
302
apps/web-antd/src/views/business/chat/components/CustomModal.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div v-if="visible" class="modal-overlay" @click="handleOverlayClick">
|
||||
<div
|
||||
class="modal-container"
|
||||
:class="[sizeClass, { 'dark': themeStore.isDarkMode }]"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 模态框头部 -->
|
||||
<div class="modal-header">
|
||||
<slot name="header">
|
||||
<h2 class="modal-title">{{ title }}</h2>
|
||||
</slot>
|
||||
<button class="close-btn" @click="$emit('close')" v-if="closable">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 模态框内容 -->
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- 模态框底部 -->
|
||||
<div class="modal-footer" v-if="$slots.footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed, onMounted, onUnmounted} from 'vue';
|
||||
import {useThemeStore} from '../stores/theme.ts';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium',
|
||||
validator: (value) => ['small', 'medium', 'large', 'full'].includes(value)
|
||||
},
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
maskClosable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
return `modal-${props.size}`;
|
||||
});
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
if (props.maskClosable) {
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscKey = (event) => {
|
||||
if (event.key === 'Escape' && props.closable) {
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleEscKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleEscKey);
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-container.dark {
|
||||
background: #2d2d2d;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* 尺寸变体 */
|
||||
.modal-small {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal-medium {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.modal-large {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.modal-full {
|
||||
width: 95vw;
|
||||
height: 95vh;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 30px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-container.dark .modal-header {
|
||||
border-bottom-color: #4b5563;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px 30px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.modal-container.dark .modal-body {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 20px 30px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-container.dark .modal-footer {
|
||||
background: #374151;
|
||||
border-top-color: #4b5563;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
:deep(.modal-btn) {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.modal-btn-cancel) {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
:deep(.modal-btn-cancel:hover) {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
:deep(.modal-btn-confirm) {
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
:deep(.modal-btn-confirm:hover) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
|
||||
}
|
||||
|
||||
.modal-container.dark :deep(.modal-btn-cancel) {
|
||||
background: #4b5563;
|
||||
color: #d1d5db;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.modal-container.dark :deep(.modal-btn-cancel:hover) {
|
||||
background: #6b7280;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.modal-overlay {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-small,
|
||||
.modal-medium,
|
||||
.modal-large {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.modal-full {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-body,
|
||||
.modal-footer {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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.ts';
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
defineEmits(['select']);
|
||||
|
||||
const emojiList = ref([
|
||||
'😀',
|
||||
'😃',
|
||||
'😄',
|
||||
'😁',
|
||||
'😆',
|
||||
'😅',
|
||||
'😂',
|
||||
'🤣',
|
||||
'😊',
|
||||
'😇',
|
||||
'🙂',
|
||||
'🙃',
|
||||
'😉',
|
||||
'😌',
|
||||
'😍',
|
||||
'🥰',
|
||||
'😘',
|
||||
'😗',
|
||||
'😙',
|
||||
'😚',
|
||||
'😋',
|
||||
'😛',
|
||||
'😝',
|
||||
'😜',
|
||||
'🤪',
|
||||
'🤨',
|
||||
'🧐',
|
||||
'🤓',
|
||||
'😎',
|
||||
'🤩',
|
||||
'🥳',
|
||||
'😏',
|
||||
'😒',
|
||||
'😞',
|
||||
'😔',
|
||||
'😟',
|
||||
'😕',
|
||||
'🙁',
|
||||
'☹️',
|
||||
'😣',
|
||||
'😖',
|
||||
'😫',
|
||||
'😩',
|
||||
'🥺',
|
||||
'😢',
|
||||
'😭',
|
||||
'😤',
|
||||
'😠',
|
||||
'😡',
|
||||
'🤬',
|
||||
'🤯',
|
||||
'😳',
|
||||
'🥵',
|
||||
'🥶',
|
||||
'😱',
|
||||
'😨',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="emoji-picker-container max-w-xs rounded-lg border border-gray-200 bg-white p-3 shadow-lg dark:border-gray-600 dark:bg-gray-700"
|
||||
>
|
||||
<div class="grid max-w-xs grid-cols-8 gap-2">
|
||||
<div
|
||||
v-for="emoji in emojiList"
|
||||
:key="emoji"
|
||||
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-lg transition-all hover:scale-110 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
@click="$emit('select', emoji)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
198
apps/web-antd/src/views/business/chat/components/EmptyState.vue
Normal file
198
apps/web-antd/src/views/business/chat/components/EmptyState.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const isDark = ref(false);
|
||||
|
||||
// 检查系统偏好或本地存储
|
||||
onMounted(() => {
|
||||
const savedPreference = localStorage.getItem('darkMode');
|
||||
const systemPreference = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)',
|
||||
).matches;
|
||||
|
||||
isDark.value =
|
||||
savedPreference === null ? systemPreference : savedPreference === 'true';
|
||||
|
||||
updateDarkClass();
|
||||
});
|
||||
|
||||
// 切换模式
|
||||
function toggleDarkMode() {
|
||||
isDark.value = !isDark.value;
|
||||
localStorage.setItem('darkMode', isDark.value);
|
||||
updateDarkClass();
|
||||
}
|
||||
|
||||
// 更新HTML class
|
||||
function updateDarkClass() {
|
||||
if (isDark.value) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center bg-white dark:bg-[#1a1a1a]"
|
||||
>
|
||||
<!-- 模式切换按钮 -->
|
||||
<!-- <div class="absolute top-4 right-4">-->
|
||||
<!-- <button @click="toggleDarkMode" class="p-2 rounded-full bg-gray-200 dark:bg-gray-700">-->
|
||||
<!-- <svg v-if="isDark" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-yellow-300"-->
|
||||
<!-- viewBox="0 0 20 20" fill="currentColor">-->
|
||||
<!-- <path fill-rule="evenodd"-->
|
||||
<!-- d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"-->
|
||||
<!-- clip-rule="evenodd"/>-->
|
||||
<!-- </svg>-->
|
||||
<!-- <svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-700"-->
|
||||
<!-- viewBox="0 0 20 20" fill="currentColor">-->
|
||||
<!-- <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>-->
|
||||
<!-- </svg>-->
|
||||
<!-- </button>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- SVG 插画 -->
|
||||
<div class="mb-8">
|
||||
<svg
|
||||
fill="none"
|
||||
height="200"
|
||||
viewBox="0 0 200 200"
|
||||
width="200"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<!-- 聊天气泡 -->
|
||||
<ellipse
|
||||
class="fill-blue-100 stroke-blue-500 dark:fill-blue-900 dark:stroke-blue-300"
|
||||
cx="70"
|
||||
cy="80"
|
||||
rx="35"
|
||||
ry="25"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<ellipse
|
||||
class="fill-purple-100 stroke-purple-500 dark:fill-purple-900 dark:stroke-purple-300"
|
||||
cx="130"
|
||||
cy="120"
|
||||
rx="35"
|
||||
ry="25"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
<!-- 消息线条 -->
|
||||
<line
|
||||
class="stroke-blue-500 dark:stroke-blue-300"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
x1="45"
|
||||
x2="55"
|
||||
y1="85"
|
||||
y2="85"
|
||||
/>
|
||||
<line
|
||||
class="stroke-blue-500 dark:stroke-blue-300"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
x1="45"
|
||||
x2="65"
|
||||
y1="90"
|
||||
y2="90"
|
||||
/>
|
||||
<line
|
||||
class="stroke-blue-500 dark:stroke-blue-300"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
x1="45"
|
||||
x2="60"
|
||||
y1="95"
|
||||
y2="95"
|
||||
/>
|
||||
|
||||
<line
|
||||
class="stroke-purple-500 dark:stroke-purple-300"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
x1="110"
|
||||
x2="125"
|
||||
y1="115"
|
||||
y2="115"
|
||||
/>
|
||||
<line
|
||||
class="stroke-purple-500 dark:stroke-purple-300"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
x1="110"
|
||||
x2="140"
|
||||
y1="120"
|
||||
y2="120"
|
||||
/>
|
||||
<line
|
||||
class="stroke-purple-500 dark:stroke-purple-300"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
x1="110"
|
||||
x2="135"
|
||||
y1="125"
|
||||
y2="125"
|
||||
/>
|
||||
|
||||
<!-- 装饰性元素 -->
|
||||
<circle
|
||||
class="fill-orange-500 dark:fill-orange-300"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="3"
|
||||
/>
|
||||
<circle
|
||||
class="fill-green-500 dark:fill-green-300"
|
||||
cx="150"
|
||||
cy="60"
|
||||
r="3"
|
||||
/>
|
||||
<circle
|
||||
class="fill-pink-500 dark:fill-pink-300"
|
||||
cx="40"
|
||||
cy="140"
|
||||
r="3"
|
||||
/>
|
||||
<circle
|
||||
class="fill-blue-500 dark:fill-blue-300"
|
||||
cx="160"
|
||||
cy="150"
|
||||
r="3"
|
||||
/>
|
||||
|
||||
<!-- 连接线 -->
|
||||
<path
|
||||
class="stroke-gray-300 dark:stroke-gray-500"
|
||||
d="M70 105 Q100 110 130 95"
|
||||
fill="none"
|
||||
stroke-dasharray="5,5"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 文字提示 -->
|
||||
<div class="text-center">
|
||||
<h3 class="mb-2 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
开始聊天吧!
|
||||
</h3>
|
||||
<p class="max-w-md text-gray-600 dark:text-gray-400">
|
||||
从左侧选择一个联系人开始对话,享受愉快的聊天体验
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 装饰性动画元素 -->
|
||||
<div
|
||||
class="absolute left-20 top-20 h-4 w-4 animate-bounce rounded-full bg-blue-100 opacity-80 dark:bg-blue-800"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-20 right-20 h-6 w-6 animate-pulse rounded-full bg-purple-100 opacity-80 dark:bg-purple-800"
|
||||
></div>
|
||||
<div
|
||||
class="absolute right-40 top-40 h-3 w-3 animate-ping rounded-full bg-green-100 opacity-80 dark:bg-green-800"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<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 dark:text-gray-200 truncate">{{
|
||||
uploadPreview.nick_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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {inject} from 'vue';
|
||||
import {FileOutlined, SendOutlined, CloseOutlined} from '@ant-design/icons-vue';
|
||||
|
||||
const uploadPreview = inject('uploadPreview');
|
||||
const sendFile = inject('sendFile');
|
||||
const cancelUpload = inject('cancelUpload');
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
</script>
|
||||
353
apps/web-antd/src/views/business/chat/components/FriendList.vue
Normal file
353
apps/web-antd/src/views/business/chat/components/FriendList.vue
Normal file
@@ -0,0 +1,353 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Avatar } from 'ant-design-vue';
|
||||
import { useChatStore } from '../stores/chat.ts';
|
||||
import { useThemeStore } from '../stores/theme.ts';
|
||||
import { useUserStore } from '../stores/user.ts';
|
||||
import CustomInput from './CustomInput.vue';
|
||||
import FriendsManagement from './FriendsManagement.vue';
|
||||
import GroupsManagement from './GroupsManagement.vue';
|
||||
import MomentsView from './MomentsView.vue';
|
||||
import VirtualList from './VirtualList.vue';
|
||||
|
||||
const props = defineProps({
|
||||
currentView: {
|
||||
type: String,
|
||||
default: 'chat',
|
||||
},
|
||||
});
|
||||
const chatStore = useChatStore();
|
||||
const userStore = useUserStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const searchQuery = ref('');
|
||||
|
||||
const filteredFriends = computed(() => {
|
||||
if (!searchQuery.value) return chatStore.friends;
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return chatStore.friends.filter(
|
||||
(friend) =>
|
||||
friend.nick_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);
|
||||
// 清除未读消息并加载聊天记录
|
||||
chatStore.switchFriend(friend, userStore.currentUser.id);
|
||||
};
|
||||
|
||||
// 监听当前视图变化,清空搜索
|
||||
watch(
|
||||
() => props.currentView,
|
||||
() => {
|
||||
searchQuery.value = '';
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ dark: themeStore.isDarkMode }" class="friend-list">
|
||||
<div class="list-header">
|
||||
<h2 class="list-title">
|
||||
{{ getListTitle() }}
|
||||
<button
|
||||
:title="themeStore.isDarkMode ? '切换到亮色模式' : '切换到暗色模式'"
|
||||
class="theme-toggle"
|
||||
@click="themeStore.toggleTheme"
|
||||
>
|
||||
<i
|
||||
:class="themeStore.isDarkMode ? 'fa-sun' : 'fa-moon'"
|
||||
class="fas"
|
||||
></i>
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
<div class="search-container">
|
||||
<CustomInput
|
||||
v-model="searchQuery"
|
||||
class="search-input"
|
||||
placeholder="搜索联系人..."
|
||||
prefix-icon="fas fa-search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="friends-container">
|
||||
<VirtualList
|
||||
v-if="currentView === 'chat'"
|
||||
:item-height="80"
|
||||
:items="filteredFriends"
|
||||
class="friends-list"
|
||||
>
|
||||
<template #default="{ item: friend }">
|
||||
<div
|
||||
:class="{ active: chatStore.currentFriend?.id === friend.id }"
|
||||
class="friend-item"
|
||||
@click="selectFriend(friend)"
|
||||
>
|
||||
<div :style="{ background: friend.color }" class="friend-avatar">
|
||||
<Avatar v-if="friend.avatar" :src="friend.avatar" />
|
||||
<Avatar v-else>{{ friend.nick_name.charAt(0) }}</Avatar>
|
||||
</div>
|
||||
|
||||
<div class="friend-info">
|
||||
<div class="friend-name">{{ friend.nick_name }}</div>
|
||||
<div class="friend-message">
|
||||
{{ friend.lastMessage || '点击开始聊天' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="friend-meta">
|
||||
<div v-if="friend.lastMessageTime" class="message-time">
|
||||
{{ 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>
|
||||
|
||||
<style scoped>
|
||||
.friend-list {
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.friend-list.dark {
|
||||
background: #1a1a1a;
|
||||
border-right-color: #374151;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.friend-list.dark .list-header {
|
||||
background: #1a1a1a;
|
||||
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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.friends-list {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="friends-management" :class="{ 'dark': themeStore.isDarkMode }">
|
||||
<div class="management-content">
|
||||
<h1 class="page-title">好友管理</h1>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="search-filter-section">
|
||||
<CustomInput
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索好友..."
|
||||
prefix-icon="fas fa-search"
|
||||
clearable
|
||||
class="search-input"
|
||||
/>
|
||||
<select class="filter-select">
|
||||
<option value="all">全部好友</option>
|
||||
<option value="online">在线好友</option>
|
||||
<option value="offline">离线好友</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 好友网格 -->
|
||||
<div class="friends-grid">
|
||||
<div
|
||||
v-for="friend in filteredFriends"
|
||||
:key="friend.id"
|
||||
class="friend-card"
|
||||
>
|
||||
<div class="card-content">
|
||||
<div
|
||||
class="friend-avatar"
|
||||
:style="{ background: friend.color }"
|
||||
>
|
||||
{{ friend.nick_name.charAt(0) }}
|
||||
</div>
|
||||
<h3 class="friend-name">{{ friend.nick_name }}</h3>
|
||||
<p class="friend-id">ID: {{ friend.id }}</p>
|
||||
<div class="friend-actions">
|
||||
<button class="action-btn primary-btn" @click="startChat(friend)">
|
||||
聊天
|
||||
</button>
|
||||
<button class="action-btn secondary-btn">
|
||||
详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref, computed} from 'vue';
|
||||
import {useChatStore} from '../stores/chat.ts';
|
||||
import {useUserStore} from '../stores/user.ts';
|
||||
import {useThemeStore} from '../stores/theme.ts';
|
||||
import CustomInput from './CustomInput.vue';
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const userStore = useUserStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const searchQuery = ref('');
|
||||
|
||||
const filteredFriends = computed(() => {
|
||||
if (!searchQuery.value) return chatStore.friends;
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return chatStore.friends.filter(friend =>
|
||||
friend.nick_name.toLowerCase().includes(query) ||
|
||||
friend.id.toString().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const startChat = (friend) => {
|
||||
chatStore.setCurrentFriend(friend);
|
||||
chatStore.switchFriend(friend, userStore.currentUser.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.friends-management {
|
||||
height: 100%;
|
||||
background: #f8fafc;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.friends-management.dark {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.management-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.friends-management.dark .page-title {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.search-filter-section {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
color: #1a202c;
|
||||
font-size: 14px;
|
||||
min-width: 140px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.friends-management.dark .filter-select {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.friends-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.friend-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.friend-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.friends-management.dark .friend-card {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.friends-management.dark .friend-card:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.friend-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.friend-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.friends-management.dark .friend-name {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.friend-id {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.friends-management.dark .friend-id {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.friend-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.friends-management.dark .secondary-btn {
|
||||
background: #4b5563;
|
||||
color: #d1d5db;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.friends-management.dark .secondary-btn:hover {
|
||||
background: #6b7280;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.management-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.search-filter-section {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.friends-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div class="groups-management" :class="{ 'dark': themeStore.isDarkMode }">
|
||||
<div class="management-content">
|
||||
<div class="header-section">
|
||||
<h1 class="page-title">群聊管理</h1>
|
||||
<button class="create-btn">
|
||||
<i class="fas fa-plus"></i>
|
||||
创建群聊
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 群聊列表 -->
|
||||
<div class="groups-grid">
|
||||
<div
|
||||
v-for="group in mockGroups"
|
||||
:key="group.id"
|
||||
class="group-card"
|
||||
>
|
||||
<div class="card-content">
|
||||
<div class="group-header">
|
||||
<div class="group-avatar">
|
||||
{{ group.nick_name.charAt(0) }}
|
||||
</div>
|
||||
<div class="group-info">
|
||||
<h3 class="group-name">{{ group.nick_name }}</h3>
|
||||
<p class="group-members">{{ group.memberCount }} 人</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="group-description">{{ group.description }}</p>
|
||||
<div class="group-actions">
|
||||
<button class="action-btn primary-btn">
|
||||
进入群聊
|
||||
</button>
|
||||
<button class="action-btn secondary-btn">
|
||||
设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref} from 'vue';
|
||||
import {useThemeStore} from '../stores/theme.ts';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const mockGroups = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: '技术交流群',
|
||||
memberCount: 128,
|
||||
description: '分享技术心得,讨论前沿技术'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '项目讨论组',
|
||||
memberCount: 45,
|
||||
description: '项目进度讨论和问题解决'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '休闲聊天室',
|
||||
memberCount: 89,
|
||||
description: '轻松愉快的日常交流'
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.groups-management {
|
||||
height: 100%;
|
||||
background: #f8fafc;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.groups-management.dark {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.management-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.groups-management.dark .page-title {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(67, 97, 238, 0.3);
|
||||
}
|
||||
|
||||
.groups-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.group-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.group-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.groups-management.dark .group-card {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.groups-management.dark .group-card:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.group-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.groups-management.dark .group-name {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.group-members {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.groups-management.dark .group-members {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.group-description {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0 0 20px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.groups-management.dark .group-description {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.group-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.groups-management.dark .secondary-btn {
|
||||
background: #4b5563;
|
||||
color: #d1d5db;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.groups-management.dark .secondary-btn:hover {
|
||||
background: #6b7280;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.management-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.groups-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,637 @@
|
||||
<template>
|
||||
<!-- 图片预览组件 -->
|
||||
<!-- <div
|
||||
v-if="showImagePreview"
|
||||
class="media-preview-overlay"
|
||||
:class="{ 'dark': themeStore.isDarkMode }"
|
||||
@click.self="hidePreview"
|
||||
>
|
||||
<div class="preview-container">
|
||||
<button class="preview-close-btn" @click="hidePreview">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
|
||||
<div class="image-preview-content">
|
||||
<img
|
||||
:src="mediaData"
|
||||
: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>-->
|
||||
<Image
|
||||
:visible="showImagePreview"
|
||||
:imgs="[{ src: mediaData }]"
|
||||
@hide="hidePreview"
|
||||
/>
|
||||
|
||||
<!-- 视频预览组件 -->
|
||||
<div
|
||||
v-if="showVideoPreview"
|
||||
class="media-preview-overlay video-preview"
|
||||
:class="{ 'dark': themeStore.isDarkMode }"
|
||||
@click.self="hidePreview"
|
||||
>
|
||||
<div class="video-preview-container" :class="{ 'portrait': isPortrait }">
|
||||
<button class="preview-close-btn" @click="hidePreview">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
|
||||
<div class="video-content">
|
||||
<video
|
||||
ref="videoPlayer"
|
||||
:src="mediaData"
|
||||
class="preview-video"
|
||||
controls
|
||||
autoplay
|
||||
@loadedmetadata="handleVideoLoad"
|
||||
@play="handleVideoPlay"
|
||||
@pause="handleVideoPause"
|
||||
/>
|
||||
|
||||
<!-- 自定义视频控制栏 -->
|
||||
<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, computed, watch, onMounted, onUnmounted} from 'vue';
|
||||
import {useMediaPreview} from "../composables/useMediaPreview.ts";
|
||||
import { Image } from 'ant-design-vue';
|
||||
// import VueEasyLightbox from 'vue-easy-lightbox';
|
||||
import {useThemeStore} from '../stores/theme.ts';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const {visible, mediaData, hidePreview, previewType} = 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 && previewType.value === 'image');
|
||||
const showVideoPreview = computed(() => visible.value && previewType.value === 'video');
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (duration.value === 0) return 0;
|
||||
return (currentTime.value / duration.value) * 100;
|
||||
});
|
||||
|
||||
// 视频加载完成
|
||||
const handleVideoLoad = () => {
|
||||
if (videoPlayer.value) {
|
||||
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':
|
||||
hidePreview();
|
||||
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, () => {
|
||||
if (previewType.value === 'video') {
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
duration.value = 0;
|
||||
showControls.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,464 @@
|
||||
<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 cursor-pointer"
|
||||
:style="avatarStyle"
|
||||
@click="showUserProfile"
|
||||
>
|
||||
{{ 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.nick_name }}
|
||||
</div>
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
<div
|
||||
class="rounded-2xl p-4 shadow-sm relative"
|
||||
:class="bubbleClasses"
|
||||
>
|
||||
<!-- 图片消息 -->
|
||||
<div
|
||||
v-if="message.type === 'image'"
|
||||
class="cursor-pointer relative"
|
||||
@click="handlePreviewMedia(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="handlePreviewMedia(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-if="message.type === 'video-call' || message.type === 'voice-call'"
|
||||
class="call-message">
|
||||
<div class="call-info">
|
||||
<i class="fas" :class="message.type === 'video-call' ? 'fa-video' : 'fa-phone'"></i>
|
||||
<span>{{ getCallStatusText(message.callStatus) }}</span>
|
||||
</div>
|
||||
<div class="call-duration" v-if="message.duration">
|
||||
通话时长: {{ formatDuration(message.duration) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本消息 -->
|
||||
<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 previewMedia from '../composables/useMediaPreview.ts';
|
||||
import {useThemeStore} from '../stores/theme.ts';
|
||||
import AudioMessage from './AudioMessage.vue';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isSent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
senderInfo: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['show-user-profile']);
|
||||
|
||||
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 handlePreviewMedia = (content, type) => {
|
||||
previewMedia(content, type);
|
||||
};
|
||||
|
||||
const showUserProfile = () => {
|
||||
emit('show-user-profile', props.senderInfo);
|
||||
};
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
const getCallStatusText = (status) => {
|
||||
const statusMap = {
|
||||
'invite': '发起通话',
|
||||
'accepted': '通话已接通',
|
||||
'rejected': '通话被拒绝',
|
||||
'ended': '通话已结束',
|
||||
'missed': '未接通话'
|
||||
};
|
||||
return statusMap[status] || '通话消息';
|
||||
};
|
||||
|
||||
const handleImageError = (event) => {
|
||||
event.target.src = '/placeholder.svg?height=200&width=300';
|
||||
};
|
||||
|
||||
const handleVideoError = (event) => {
|
||||
console.error('视频加载失败:', event);
|
||||
};
|
||||
</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;
|
||||
}
|
||||
|
||||
.call-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.call-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.call-info i {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.call-duration {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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-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>
|
||||
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<div class="message-input-area" :class="{ 'dark': themeStore.isDarkMode }">
|
||||
<!-- 文件上传预览 -->
|
||||
<FileUploadPreview v-if="uploadPreview"/>
|
||||
<!-- 表情选择器 -->
|
||||
<EmojiPicker class="mb-10" v-if="showEmojiPicker" @select="insertEmoji"/>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
<!-- 录音状态指示器 -->
|
||||
<!-- <RecordingIndicator />-->
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 输入框区域 -->
|
||||
<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, provide, onMounted, onUnmounted} from 'vue';
|
||||
import {useUserStore} from '../stores/user.ts';
|
||||
import {useChatStore} from '../stores/chat.ts';
|
||||
import {useThemeStore} from '../stores/theme.ts';
|
||||
import {useFileUpload} from '../composables/useFileUpload.ts';
|
||||
import {useRecording} from '../composables/useRecording.ts';
|
||||
import {sendMessage} from '../utils/request.ts';
|
||||
import {message} from 'ant-design-vue';
|
||||
import CustomTextarea from './CustomTextarea.vue';
|
||||
import EmojiPicker from './EmojiPicker.vue';
|
||||
import FileUploadPreview from './FileUploadPreview.vue';
|
||||
import RecordingIndicator from "../components/RecordingIndicator.vue";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const chatStore = useChatStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const messageText = ref('');
|
||||
const messageInputRef = ref(null);
|
||||
const showEmojiPicker = ref(false);
|
||||
|
||||
// 使用文件上传组合式函数
|
||||
const {
|
||||
uploadPreview,
|
||||
imageInput,
|
||||
videoInput,
|
||||
triggerFileInput,
|
||||
handleFileUpload,
|
||||
cancelUpload
|
||||
} = useFileUpload();
|
||||
|
||||
// 使用录音组合式函数
|
||||
const {isRecording, startRecording, stopRecording} = useRecording();
|
||||
|
||||
// 发送上传的文件
|
||||
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,
|
||||
receiverId: chatStore.currentFriend.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;
|
||||
};
|
||||
|
||||
// 提供给子组件
|
||||
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
|
||||
};
|
||||
|
||||
// return console.log('发送消息:' + userStore.currentUser.id, messageObj);
|
||||
// 添加到本地消息列表
|
||||
chatStore.addMessage(messageObj, userStore.currentUser.id);
|
||||
|
||||
// 发送到服务器
|
||||
try {
|
||||
await sendMessage({
|
||||
senderId: userStore.currentUser.id,
|
||||
receiverId: chatStore.currentFriend.id,
|
||||
roomId: chatStore.currentFriend.room_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.nick_name,
|
||||
size: file.size,
|
||||
file: file
|
||||
};
|
||||
};
|
||||
|
||||
reader.onerror = (error) => {
|
||||
console.error('文件读取失败:', error);
|
||||
message.error('文件读取失败,请重试');
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
// 切换表情选择器
|
||||
const toggleEmojiPicker = () => {
|
||||
showEmojiPicker.value = !showEmojiPicker.value;
|
||||
};
|
||||
|
||||
// 插入表情
|
||||
const insertEmoji = (emoji) => {
|
||||
messageText.value += emoji;
|
||||
showEmojiPicker.value = false;
|
||||
|
||||
nextTick(() => {
|
||||
if (messageInputRef.value) {
|
||||
messageInputRef.value.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 监听录音完成事件
|
||||
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>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-block max-w-xs md:max-w-md lg:max-w-lg xl:max-w-xl"
|
||||
:class="isSent ? 'ml-auto' : 'mr-auto'"
|
||||
>
|
||||
<div
|
||||
class="rounded-2xl p-4 shadow-sm"
|
||||
:class="[
|
||||
isSent
|
||||
? 'bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-br-sm'
|
||||
: 'bg-white text-gray-800 border border-gray-200 rounded-bl-sm'
|
||||
]"
|
||||
>
|
||||
<!-- 图片消息 - 已修复 -->
|
||||
<div
|
||||
v-if="message.type === 'image'"
|
||||
class="cursor-pointer relative"
|
||||
@click="previewMedia(message.content, 'image')"
|
||||
>
|
||||
<a-image
|
||||
:src="message.content"
|
||||
:alt="'图片消息'"
|
||||
class="rounded-lg max-w-full"
|
||||
:preview="false"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black/50 bg-opacity-30 rounded-lg">
|
||||
<!-- 已移除不必要的放大镜图标 -->
|
||||
<SearchOutlined class="text-white text-xl"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频消息 - 已修复 -->
|
||||
<div
|
||||
v-else-if="message.type === 'video'"
|
||||
class="cursor-pointer relative"
|
||||
@click="previewMedia(message.content, 'video')"
|
||||
>
|
||||
<video
|
||||
:src="message.content"
|
||||
class="rounded-lg max-w-full max-h-64"
|
||||
preload="metadata"
|
||||
@error="handleVideoError"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/50 bg-opacity-30 rounded-lg">
|
||||
<PlayCircleOutlined class="text-white text-3xl"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 音频消息 -->
|
||||
<AudioMessage v-else-if="message.type === 'audio'" :message="message"/>
|
||||
|
||||
<!-- 文本消息 -->
|
||||
<div v-else class="text-base leading-relaxed">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
|
||||
<!-- 消息时间和状态 -->
|
||||
<div class="flex items-center justify-end gap-1 mt-2 text-xs opacity-70">
|
||||
<span>{{ message.time }}</span>
|
||||
<CheckOutlined v-if="isSent" class="text-green-400"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {defineProps} from 'vue';
|
||||
import {PlayCircleOutlined, CheckOutlined, SearchOutlined} from '@ant-design/icons-vue';
|
||||
import {useMediaPreview} from '../composables/useMediaPreview.ts';
|
||||
import AudioMessage from './AudioMessage.vue';
|
||||
|
||||
const {previewMedia} = useMediaPreview();
|
||||
|
||||
const handleImageError = (event) => {
|
||||
console.error('图片加载失败:', event.target.src);
|
||||
event.target.style.display = 'none';
|
||||
};
|
||||
|
||||
const handleVideoError = (event) => {
|
||||
console.error('视频加载失败:', event.target.src);
|
||||
event.target.style.display = 'none';
|
||||
};
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isSent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<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.nick_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, computed} from 'vue';
|
||||
import {useUserStore} from '../stores/user.ts';
|
||||
import {useChatStore} from '../stores/chat.ts';
|
||||
import {MessageOutlined} from '@ant-design/icons-vue';
|
||||
import MessageBubble from './MessageBubble.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const messagesContainer = ref(null);
|
||||
const loadingMessages = ref(false);
|
||||
|
||||
// 获取发送者信息
|
||||
const getSenderInfo = (message) => {
|
||||
if (message.senderId === userStore.currentUser.id) {
|
||||
return {
|
||||
name: userStore.currentUser.nick_name,
|
||||
avatar: userStore.currentUser.nick_name.charAt(0),
|
||||
color: userStore.currentUser.color || '#4cc9f0'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
name: chatStore.currentFriend?.nick_name || '未知用户',
|
||||
avatar: chatStore.currentFriend?.nick_name.charAt(0) || '?',
|
||||
color: chatStore.currentFriend?.color || '#ff6b6b'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
if (messagesContainer.value) {
|
||||
nextTick(() => {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 监听消息变化,自动滚动到底部
|
||||
watch(() => chatStore.messages.length, () => {
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
// 监听当前好友变化,重新加载消息
|
||||
watch(() => chatStore.currentFriend, () => {
|
||||
scrollToBottom();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.message-list-background {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" opacity="0.03"><rect width="100" height="100" fill="none"/><path d="M0,0 L100,100 M100,0 L0,100" stroke="currentColor"/></svg>');
|
||||
background-size: 200px 200px;
|
||||
}
|
||||
</style>
|
||||
446
apps/web-antd/src/views/business/chat/components/MomentsView.vue
Normal file
446
apps/web-antd/src/views/business/chat/components/MomentsView.vue
Normal file
@@ -0,0 +1,446 @@
|
||||
<template>
|
||||
<div class="moments-view" :class="{ 'dark': themeStore.isDarkMode }">
|
||||
<div class="moments-content">
|
||||
<h1 class="page-title">朋友圈</h1>
|
||||
|
||||
<!-- 发布动态 -->
|
||||
<div class="post-composer">
|
||||
<div class="composer-header">
|
||||
<div class="user-avatar" :style="{ background: userStore.currentUser?.color }">
|
||||
{{ userStore.currentUser?.nick_name?.charAt(0) }}
|
||||
</div>
|
||||
<CustomTextarea
|
||||
v-model="newPostContent"
|
||||
placeholder="分享新鲜事..."
|
||||
class="post-input"
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
<div class="composer-actions">
|
||||
<div class="media-buttons">
|
||||
<button class="media-btn">
|
||||
<i class="fas fa-image"></i>
|
||||
图片
|
||||
</button>
|
||||
<button class="media-btn">
|
||||
<i class="fas fa-video"></i>
|
||||
视频
|
||||
</button>
|
||||
</div>
|
||||
<button class="publish-btn" :disabled="!newPostContent.trim()">
|
||||
发布
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 动态列表 -->
|
||||
<div class="moments-list">
|
||||
<div
|
||||
v-for="moment in mockMoments"
|
||||
:key="moment.id"
|
||||
class="moment-card"
|
||||
>
|
||||
<div class="moment-header">
|
||||
<div class="author-avatar" :style="{ background: moment.author.color }">
|
||||
{{ moment.author.nick_name.charAt(0) }}
|
||||
</div>
|
||||
<div class="author-info">
|
||||
<h4 class="author-name">{{ moment.author.nick_name }}</h4>
|
||||
<p class="post-time">{{ moment.time }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="moment-content">
|
||||
<p class="moment-text">{{ moment.content }}</p>
|
||||
<div v-if="moment.images" class="moment-images">
|
||||
<img
|
||||
v-for="(image, index) in moment.images"
|
||||
:key="index"
|
||||
:src="image"
|
||||
:alt="`图片${index + 1}`"
|
||||
class="moment-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="moment-actions">
|
||||
<button class="action-btn like-btn">
|
||||
<i class="fas fa-heart"></i>
|
||||
{{ moment.likes }}
|
||||
</button>
|
||||
<button class="action-btn comment-btn">
|
||||
<i class="fas fa-comment"></i>
|
||||
{{ moment.comments }}
|
||||
</button>
|
||||
<button class="action-btn share-btn">
|
||||
<i class="fas fa-share"></i>
|
||||
分享
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref} from 'vue';
|
||||
import {useUserStore} from '../stores/user.ts';
|
||||
import {useThemeStore} from '../stores/theme.ts';
|
||||
import CustomTextarea from './CustomTextarea.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const newPostContent = ref('');
|
||||
|
||||
const mockMoments = ref([
|
||||
{
|
||||
id: 1,
|
||||
author: {
|
||||
name: '张三',
|
||||
color: '#ff6b6b'
|
||||
},
|
||||
content: '今天天气真不错,出去走走心情都变好了!',
|
||||
time: '2小时前',
|
||||
likes: 12,
|
||||
comments: 3,
|
||||
images: ['/placeholder.svg?height=200&width=200']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
author: {
|
||||
name: '李四',
|
||||
color: '#4ecdc4'
|
||||
},
|
||||
content: '刚完成了一个新项目,感觉很有成就感!',
|
||||
time: '5小时前',
|
||||
likes: 8,
|
||||
comments: 2
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
author: {
|
||||
name: '王五',
|
||||
color: '#45b7d1'
|
||||
},
|
||||
content: '分享一张美丽的日落照片',
|
||||
time: '1天前',
|
||||
likes: 25,
|
||||
comments: 7,
|
||||
images: ['/placeholder.svg?height=300&width=400']
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.moments-view {
|
||||
height: 100%;
|
||||
background: #f8fafc;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.moments-view.dark {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.moments-content {
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.moments-view.dark .page-title {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.post-composer {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.moments-view.dark .post-composer {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.composer-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.post-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.media-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.media-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.media-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.moments-view.dark .media-btn {
|
||||
background: #4b5563;
|
||||
border-color: #6b7280;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.moments-view.dark .media-btn:hover {
|
||||
background: #6b7280;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.publish-btn {
|
||||
padding: 8px 20px;
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.publish-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
|
||||
}
|
||||
|
||||
.publish-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.moments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.moment-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.moment-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.moments-view.dark .moment-card {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.moments-view.dark .moment-card:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.moment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.author-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.author-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.moments-view.dark .author-name {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.post-time {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.moments-view.dark .post-time {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.moment-content {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.moment-text {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.moments-view.dark .moment-text {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.moment-images {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.moment-image {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.moment-image:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.moment-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.moments-view.dark .moment-actions {
|
||||
border-top-color: #4b5563;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.moments-view.dark .action-btn {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.moments-view.dark .action-btn:hover {
|
||||
background: #4b5563;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.like-btn:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.comment-btn:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.share-btn:hover {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.moments-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.media-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.moment-images {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<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';
|
||||
import {useRecording} from "../composables/useRecording.ts";
|
||||
|
||||
// 使用录音组合式函数
|
||||
const {isRecording} = useRecording();
|
||||
</script>
|
||||
@@ -0,0 +1,845 @@
|
||||
<template>
|
||||
<CustomModal
|
||||
:visible="isVisible"
|
||||
title="设置"
|
||||
size="large"
|
||||
@close="closeModal"
|
||||
>
|
||||
<div class="settings-content" :class="{ 'dark': themeStore.isDarkMode }">
|
||||
<!-- 设置导航 -->
|
||||
<div class="settings-nav">
|
||||
<div
|
||||
v-for="section in settingSections"
|
||||
:key="section.key"
|
||||
class="nav-item"
|
||||
:class="{ 'active': currentSection === section.key }"
|
||||
@click="currentSection = section.key"
|
||||
>
|
||||
<i :class="section.icon"></i>
|
||||
<span>{{ section.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置内容 -->
|
||||
<div class="settings-main">
|
||||
<!-- 通用设置 -->
|
||||
<div v-if="currentSection === 'general'" class="setting-section">
|
||||
<h3 class="section-title">通用设置</h3>
|
||||
|
||||
<div class="setting-group">
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<label>主题模式</label>
|
||||
<span class="setting-desc">选择应用的外观主题</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.theme" @change="updateTheme">
|
||||
<option value="light">亮色模式</option>
|
||||
<option value="dark">暗色模式</option>
|
||||
<option value="auto">跟随系统</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<label>语言设置</label>
|
||||
<span class="setting-desc">选择应用显示语言</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.language">
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en-US">English</option>
|
||||
<option value="ja-JP">日本語</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<label>字体大小</label>
|
||||
<span class="setting-desc">调整聊天界面的字体大小</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input
|
||||
type="range"
|
||||
v-model="settings.fontSize"
|
||||
min="12"
|
||||
max="20"
|
||||
step="1"
|
||||
class="slider"
|
||||
/>
|
||||
<span class="value-display">{{ settings.fontSize }}px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知设置 -->
|
||||
<div v-if="currentSection === 'notifications'" class="setting-section">
|
||||
<h3 class="section-title">通知设置</h3>
|
||||
|
||||
<div class="setting-group">
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<label>桌面通知</label>
|
||||
<span class="setting-desc">接收新消息的桌面通知</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="settings.desktopNotifications"
|
||||
@change="updateNotificationPermission"
|
||||
/>
|
||||
<span class="slider-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<label>声音提醒</label>
|
||||
<span class="setting-desc">新消息时播放提示音</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="settings.soundNotifications"/>
|
||||
<span class="slider-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<label>通话通知</label>
|
||||
<span class="setting-desc">接收语音和视频通话邀请</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="settings.callNotifications"/>
|
||||
<span class="slider-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐私设置 -->
|
||||
<div v-if="currentSection === 'privacy'" class="setting-section">
|
||||
<h3 class="section-title">隐私设置</h3>
|
||||
|
||||
<div class="setting-group">
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<label>在线状态</label>
|
||||
<span class="setting-desc">是否向其他用户显示在线状态</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="settings.showOnlineStatus"/>
|
||||
<span class="slider-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<label>已读回执</label>
|
||||
<span class="setting-desc">发送消息已读状态给对方</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="settings.readReceipts"/>
|
||||
<span class="slider-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<label>自动下载</label>
|
||||
<span class="setting-desc">自动下载接收到的图片和文件</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="settings.autoDownload"/>
|
||||
<span class="slider-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关于 -->
|
||||
<div v-if="currentSection === 'about'" class="setting-section">
|
||||
<h3 class="section-title">关于应用</h3>
|
||||
|
||||
<div class="about-content">
|
||||
<div class="app-info">
|
||||
<div class="app-icon">
|
||||
<i class="fas fa-comments"></i>
|
||||
</div>
|
||||
<div class="app-details">
|
||||
<h4>WebSocket 聊天系统</h4>
|
||||
<p>版本 1.0.0</p>
|
||||
<p>基于 Vue 3 + WebSocket 构建的现代化聊天应用</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-list">
|
||||
<h5>主要功能</h5>
|
||||
<ul>
|
||||
<li>实时消息传输</li>
|
||||
<li>语音视频通话</li>
|
||||
<li>文件传输</li>
|
||||
<li>群聊功能</li>
|
||||
<li>暗色模式</li>
|
||||
<li>响应式设计</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button class="btn secondary" @click="checkUpdate">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
检查更新
|
||||
</button>
|
||||
<button class="btn secondary" @click="clearCache">
|
||||
<i class="fas fa-trash"></i>
|
||||
清除缓存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<button class="btn secondary" @click="resetSettings">
|
||||
重置设置
|
||||
</button>
|
||||
<div class="footer-actions">
|
||||
<button class="btn secondary" @click="closeModal">
|
||||
取消
|
||||
</button>
|
||||
<button class="btn primary" @click="saveSettings">
|
||||
保存设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CustomModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref, reactive, watch, onMounted} from 'vue'
|
||||
import {useThemeStore} from '../stores/theme.ts'
|
||||
import CustomModal from './CustomModal.vue'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const isVisible = ref(false)
|
||||
const currentSection = ref('general')
|
||||
|
||||
const settingSections = [
|
||||
{key: 'general', label: '通用', icon: 'fas fa-cog'},
|
||||
{key: 'notifications', label: '通知', icon: 'fas fa-bell'},
|
||||
{key: 'privacy', label: '隐私', icon: 'fas fa-shield-alt'},
|
||||
{key: 'about', label: '关于', icon: 'fas fa-info-circle'}
|
||||
]
|
||||
|
||||
const settings = reactive({
|
||||
theme: 'light',
|
||||
language: 'zh-CN',
|
||||
fontSize: 14,
|
||||
desktopNotifications: true,
|
||||
soundNotifications: true,
|
||||
callNotifications: true,
|
||||
showOnlineStatus: true,
|
||||
readReceipts: true,
|
||||
autoDownload: true
|
||||
})
|
||||
|
||||
const loadSettings = () => {
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('chat-settings')
|
||||
if (savedSettings) {
|
||||
Object.assign(settings, JSON.parse(savedSettings))
|
||||
}
|
||||
|
||||
// 同步主题设置
|
||||
settings.theme = themeStore.isDarkMode ? 'dark' : 'light'
|
||||
} catch (error) {
|
||||
console.error('加载设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = () => {
|
||||
try {
|
||||
localStorage.setItem('chat-settings', JSON.stringify(settings))
|
||||
|
||||
// 应用设置
|
||||
applySettings()
|
||||
|
||||
// 显示保存成功提示
|
||||
showNotification('设置已保存', 'success')
|
||||
|
||||
closeModal()
|
||||
} catch (error) {
|
||||
console.error('保存设置失败:', error)
|
||||
showNotification('保存设置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const applySettings = () => {
|
||||
// 应用主题
|
||||
// if (settings.theme === 'dark') {
|
||||
// themeStore.toggleTheme(true)
|
||||
// } else if (settings.theme === 'light') {
|
||||
// themeStore.setDarkMode(false)
|
||||
// } else {
|
||||
// // 跟随系统
|
||||
// const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
// themeStore.setDarkMode(prefersDark)
|
||||
// }
|
||||
//
|
||||
// // 应用字体大小
|
||||
// document.documentElement.style.setProperty('--base-font-size', `${settings.fontSize}px`)
|
||||
}
|
||||
|
||||
const resetSettings = () => {
|
||||
if (confirm('确定要重置所有设置吗?')) {
|
||||
Object.assign(settings, {
|
||||
theme: 'light',
|
||||
language: 'zh-CN',
|
||||
fontSize: 14,
|
||||
desktopNotifications: true,
|
||||
soundNotifications: true,
|
||||
callNotifications: true,
|
||||
showOnlineStatus: true,
|
||||
readReceipts: true,
|
||||
autoDownload: true
|
||||
})
|
||||
|
||||
showNotification('设置已重置', 'info')
|
||||
}
|
||||
}
|
||||
|
||||
const updateTheme = () => {
|
||||
applySettings()
|
||||
}
|
||||
|
||||
const updateNotificationPermission = async () => {
|
||||
if (settings.desktopNotifications && 'Notification' in window) {
|
||||
if (Notification.permission === 'default') {
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
settings.desktopNotifications = false
|
||||
showNotification('通知权限被拒绝', 'warning')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkUpdate = () => {
|
||||
showNotification('当前已是最新版本', 'info')
|
||||
}
|
||||
|
||||
const clearCache = () => {
|
||||
if (confirm('确定要清除所有缓存数据吗?')) {
|
||||
try {
|
||||
// 清除localStorage中的聊天数据
|
||||
const keysToRemove = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key && (key.startsWith('chat-') || key.startsWith('message-'))) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key))
|
||||
|
||||
showNotification('缓存已清除', 'success')
|
||||
} catch (error) {
|
||||
console.error('清除缓存失败:', error)
|
||||
showNotification('清除缓存失败', 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showNotification = (message, type = 'info') => {
|
||||
// 这里应该调用全局通知组件
|
||||
console.log(`[${type.toUpperCase()}] ${message}`)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
isVisible.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(() => props.visible, (newValue) => {
|
||||
isVisible.value = newValue
|
||||
if (newValue) {
|
||||
loadSettings()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
applySettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-content {
|
||||
display: flex;
|
||||
height: 500px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-content.dark {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
width: 200px;
|
||||
background: #f8fafc;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
padding: 16px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-content.dark .settings-nav {
|
||||
background: #374151;
|
||||
border-right-color: #4b5563;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #e2e8f0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #4361ee;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.settings-content.dark .nav-item {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.settings-content.dark .nav-item:hover {
|
||||
background: #4b5563;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.settings-main {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
margin: 0 0 24px 0;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.settings-content.dark .section-title {
|
||||
color: #f7fafc;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
border-color: #4361ee;
|
||||
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.1);
|
||||
}
|
||||
|
||||
.settings-content.dark .setting-item {
|
||||
background: #4b5563;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.settings-content.dark .setting-item:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setting-info label {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1a202c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.settings-content.dark .setting-info label {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.setting-desc {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.settings-content.dark .setting-desc {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-control select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: #1a202c;
|
||||
font-size: 14px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.settings-content.dark .setting-control select {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.slider {
|
||||
width: 120px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: #e2e8f0;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #4361ee;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.value-display {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #4361ee;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider-switch {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #e2e8f0;
|
||||
transition: 0.3s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.slider-switch:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider-switch {
|
||||
background-color: #4361ee;
|
||||
}
|
||||
|
||||
input:checked + .slider-switch:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.about-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.app-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.settings-content.dark .app-info {
|
||||
background: #4b5563;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.app-details h4 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.settings-content.dark .app-details h4 {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.app-details p {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.settings-content.dark .app-details p {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.settings-content.dark .feature-list {
|
||||
background: #4b5563;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.feature-list h5 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.settings-content.dark .feature-list h5 {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.feature-list ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
padding: 6px 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.feature-list li:before {
|
||||
content: "✓";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #10b981;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.settings-content.dark .feature-list li {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.3);
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.btn.secondary:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.settings-content.dark .btn.secondary {
|
||||
background: #4b5563;
|
||||
color: #d1d5db;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.settings-content.dark .btn.secondary:hover {
|
||||
background: #6b7280;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.settings-content.dark .modal-footer {
|
||||
border-top-color: #374151;
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.settings-content {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-main {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,387 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useChatStore } from '../stores/chat.ts';
|
||||
import { useThemeStore } from '../stores/theme.ts';
|
||||
import { useUserStore } from '../stores/user.ts';
|
||||
import SettingsModal from './SettingsModal.vue';
|
||||
import UserProfileModal from './UserProfileModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
currentView: {
|
||||
type: String,
|
||||
default: 'chat',
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['view-change']);
|
||||
const themeStore = useThemeStore();
|
||||
const userStore = useUserStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const showSettingsModal = ref(false);
|
||||
const showProfileModal = ref(false);
|
||||
|
||||
const navItems = computed(() => [
|
||||
{
|
||||
key: 'chat',
|
||||
label: '聊天',
|
||||
icon: 'fas fa-comment-dots',
|
||||
badge: getUnreadCount(),
|
||||
},
|
||||
{
|
||||
key: 'friends',
|
||||
label: '好友',
|
||||
icon: 'fas fa-user-friends',
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
key: 'groups',
|
||||
label: '群聊',
|
||||
icon: 'fas fa-users',
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
key: 'moments',
|
||||
label: '圈子',
|
||||
icon: 'fas fa-globe',
|
||||
badge: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const getUnreadCount = () => {
|
||||
const totalUnread = chatStore.friends.reduce((total, friend) => {
|
||||
return total + (friend.unreadCount || 0);
|
||||
}, 0);
|
||||
|
||||
return totalUnread > 0
|
||||
? totalUnread > 99
|
||||
? '99+'
|
||||
: totalUnread.toString()
|
||||
: null;
|
||||
};
|
||||
|
||||
const switchView = (key) => {
|
||||
emit('nav-change', key);
|
||||
};
|
||||
|
||||
const showSettings = () => {
|
||||
showSettingsModal.value = true;
|
||||
};
|
||||
|
||||
const showProfile = () => {
|
||||
showProfileModal.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ dark: themeStore.isDarkMode }" class="side-navigation">
|
||||
<div class="nav-header">
|
||||
<!-- <div class="logo">-->
|
||||
<!-- <i class="fas fa-comments"></i>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div-->
|
||||
<!-- class="nav-item profile-item"-->
|
||||
<!-- @click="showProfile"-->
|
||||
<!-- title="个人资料"-->
|
||||
<!-- >-->
|
||||
<!-- <div class="nav-icon user-avatar" :style="{ background: userStore.currentUser?.color }">-->
|
||||
<!-- {{ userStore.currentUser.nick_name.charAt(0) }}-->
|
||||
<!-- </div>-->
|
||||
<!-- <!– <div class="nav-label">我的</div>–>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
|
||||
<nav class="nav-menu">
|
||||
<div
|
||||
v-for="item in navItems"
|
||||
:key="item.key"
|
||||
:class="{ active: currentView === item.key }"
|
||||
:title="item.label"
|
||||
class="nav-item"
|
||||
@click="switchView(item.key)"
|
||||
>
|
||||
<div class="nav-icon">
|
||||
<i :class="item.icon"></i>
|
||||
</div>
|
||||
<div class="nav-label">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div v-if="item.badge" class="nav-badge">
|
||||
{{ item.badge }}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="nav-footer">
|
||||
<div class="nav-item settings-item" title="设置" @click="showSettings">
|
||||
<div class="nav-icon">
|
||||
<i class="fas fa-cog"></i>
|
||||
</div>
|
||||
<div class="nav-label">设置</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置模态框 -->
|
||||
<SettingsModal
|
||||
:visible="showSettingsModal"
|
||||
@close="showSettingsModal = false"
|
||||
/>
|
||||
|
||||
<!-- 用户资料模态框 -->
|
||||
<UserProfileModal
|
||||
:user-info="userStore.currentUser"
|
||||
:visible="showProfileModal"
|
||||
@close="showProfileModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.side-navigation {
|
||||
width: 80px;
|
||||
height: 100vh;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
border-right: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.side-navigation.dark {
|
||||
background: linear-gradient(180deg, #1a1a1a 0%, #2d2d2d 100%);
|
||||
border-right-color: #374151;
|
||||
}
|
||||
|
||||
.nav-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
|
||||
animation: gradient-shift 3s ease infinite;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(67, 97, 238, 0.1);
|
||||
color: #4361ee;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.side-navigation.dark .nav-item {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.side-navigation.dark .nav-item:hover {
|
||||
background: rgba(67, 97, 238, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.side-navigation.dark .nav-item.active {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 18px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 48px;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.nav-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.settings-item:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.side-navigation.dark .settings-item {
|
||||
background: #374151;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.side-navigation.dark .settings-item:hover {
|
||||
background: #4b5563;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.profile-item {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.profile-item:hover {
|
||||
background: linear-gradient(135deg, #059669, #047857);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.side-navigation {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
padding: 8px 16px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.nav-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
justify-content: space-around;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-footer {
|
||||
flex-direction: row;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,506 @@
|
||||
<template>
|
||||
<CustomModal
|
||||
:visible="isVisible"
|
||||
title="用户资料"
|
||||
size="medium"
|
||||
@close="closeModal"
|
||||
>
|
||||
<div class="user-profile-content" :class="{ 'dark': themeStore.isDarkMode }">
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<div class="profile-header">
|
||||
<div class="user-avatar" :style="{ background: userInfo.color }">
|
||||
{{ userInfo.nick_name?.charAt(0) }}
|
||||
</div>
|
||||
<div class="user-basic-info">
|
||||
<h2 class="user-name">{{ userInfo.nick_name }}</h2>
|
||||
<p class="user-id">ID: {{ userInfo.id }}</p>
|
||||
<div class="user-status">
|
||||
<span class="status-dot" :class="isOnline ? 'online' : 'offline'"></span>
|
||||
<span class="status-text">{{ isOnline ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户详细信息 -->
|
||||
<div class="profile-details">
|
||||
<div class="detail-section">
|
||||
<h3 class="section-title">个人信息</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>昵称</label>
|
||||
<span>{{ userInfo.nick_name || '未设置' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>用户ID</label>
|
||||
<span>{{ userInfo.id }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>注册时间</label>
|
||||
<span>{{ formatDate(userInfo.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>最后在线</label>
|
||||
<span>{{ formatDate(userInfo.lastSeen) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天统计 -->
|
||||
<div class="detail-section">
|
||||
<h3 class="section-title">聊天统计</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ chatStats.messageCount || 0 }}</div>
|
||||
<div class="stat-label">消息数量</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ chatStats.callCount || 0 }}</div>
|
||||
<div class="stat-label">通话次数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ formatDuration(chatStats.totalCallTime || 0) }}</div>
|
||||
<div class="stat-label">通话时长</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="profile-actions">
|
||||
<button class="action-btn primary-btn" @click="startChat">
|
||||
<i class="fas fa-comment"></i>
|
||||
发送消息
|
||||
</button>
|
||||
<button class="action-btn secondary-btn" @click="startVoiceCall">
|
||||
<i class="fas fa-phone"></i>
|
||||
语音通话
|
||||
</button>
|
||||
<button class="action-btn secondary-btn" @click="startVideoCall">
|
||||
<i class="fas fa-video"></i>
|
||||
视频通话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CustomModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref, computed, watch} from 'vue'
|
||||
import {useThemeStore} from '../stores/theme.ts'
|
||||
import {useChatStore} from '../stores/chat.ts'
|
||||
import {useUserStore} from '../stores/user.ts'
|
||||
import CustomModal from './CustomModal.vue'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const chatStore = useChatStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
userInfo: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'start-chat', 'start-call'])
|
||||
|
||||
const isVisible = computed(() => props.visible)
|
||||
const isOnline = ref(true) // 简化处理,实际应该从服务器获取
|
||||
|
||||
// 聊天统计数据
|
||||
const chatStats = ref({
|
||||
messageCount: 0,
|
||||
callCount: 0,
|
||||
totalCallTime: 0
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '未知'
|
||||
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) { // 1分钟内
|
||||
return '刚刚'
|
||||
} else if (diff < 3600000) { // 1小时内
|
||||
return `${Math.floor(diff / 60000)}分钟前`
|
||||
} else if (diff < 86400000) { // 24小时内
|
||||
return `${Math.floor(diff / 3600000)}小时前`
|
||||
} else if (diff < 604800000) { // 7天内
|
||||
return `${Math.floor(diff / 86400000)}天前`
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds) => {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}秒`
|
||||
} else if (seconds < 3600) {
|
||||
return `${Math.floor(seconds / 60)}分钟`
|
||||
} else {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return `${hours}小时${minutes}分钟`
|
||||
}
|
||||
}
|
||||
|
||||
// 加载聊天统计
|
||||
const loadChatStats = async () => {
|
||||
try {
|
||||
// 这里应该从数据库或API获取统计数据
|
||||
// 暂时使用模拟数据
|
||||
chatStats.value = {
|
||||
messageCount: Math.floor(Math.random() * 1000),
|
||||
callCount: Math.floor(Math.random() * 50),
|
||||
totalCallTime: Math.floor(Math.random() * 7200)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载聊天统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 开始聊天
|
||||
const startChat = () => {
|
||||
emit('start-chat', props.userInfo)
|
||||
closeModal()
|
||||
}
|
||||
|
||||
// 开始语音通话
|
||||
const startVoiceCall = () => {
|
||||
emit('start-call', {
|
||||
user: props.userInfo,
|
||||
type: 'audio'
|
||||
})
|
||||
closeModal()
|
||||
}
|
||||
|
||||
// 开始视频通话
|
||||
const startVideoCall = () => {
|
||||
emit('start-call', {
|
||||
user: props.userInfo,
|
||||
type: 'video'
|
||||
})
|
||||
closeModal()
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 监听用户信息变化
|
||||
watch(() => props.userInfo, (newUserInfo) => {
|
||||
if (newUserInfo && newUserInfo.id) {
|
||||
loadChatStats()
|
||||
}
|
||||
}, {immediate: true})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-profile-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.user-profile-content.dark {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
}
|
||||
|
||||
.user-profile-content.dark .profile-header {
|
||||
border-bottom-color: #374151;
|
||||
background: linear-gradient(135deg, #374151 0%, #4b5563 100%);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-basic-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .user-name {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0 0 12px 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .user-id {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .status-text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.profile-details {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.detail-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
margin: 0 0 16px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .section-title {
|
||||
color: #f7fafc;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .detail-item label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
font-size: 14px;
|
||||
color: #1a202c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .detail-item span {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .stat-item {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #4361ee;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .stat-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
padding: 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .profile-actions {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.3);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.user-profile-content.dark .secondary-btn {
|
||||
background: #4b5563;
|
||||
color: #d1d5db;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.user-profile-content.dark .secondary-btn:hover {
|
||||
background: #6b7280;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.profile-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
129
apps/web-antd/src/views/business/chat/components/VirtualList.vue
Normal file
129
apps/web-antd/src/views/business/chat/components/VirtualList.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } 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>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
:style="{ height: `${containerHeight}px` }"
|
||||
class="virtual-list-container"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div
|
||||
:style="{ height: `${totalHeight}px` }"
|
||||
class="virtual-list-phantom"
|
||||
></div>
|
||||
<div
|
||||
:style="{ transform: `translateY(${offsetY}px)` }"
|
||||
class="virtual-list-content"
|
||||
>
|
||||
<div
|
||||
v-for="item in visibleItems"
|
||||
:key="item.id"
|
||||
:style="{ height: `${itemHeight}px` }"
|
||||
class="virtual-list-item"
|
||||
>
|
||||
<slot :index="item.index" :item="item"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,115 @@
|
||||
import {nextTick, ref} from 'vue';
|
||||
|
||||
import {message} from 'ant-design-vue';
|
||||
|
||||
export function useFileUpload() {
|
||||
const uploadPreview = ref(null);
|
||||
const imageInput = ref(null);
|
||||
const videoInput = ref(null);
|
||||
const currentFileType = ref('');
|
||||
|
||||
const triggerFileInput = (type) => {
|
||||
currentFileType.value = type;
|
||||
|
||||
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 || event.target.files.length === 0) return;
|
||||
|
||||
const file = event.target.files[0];
|
||||
|
||||
// 确定文件类型
|
||||
let detectedType = currentFileType.value;
|
||||
if (event.target === imageInput.value) {
|
||||
detectedType = 'image';
|
||||
} else if (event.target === videoInput.value) {
|
||||
detectedType = 'video';
|
||||
}
|
||||
|
||||
// 文件大小检查
|
||||
const maxSize =
|
||||
detectedType === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
const maxSizeMB = maxSize / 1024 / 1024;
|
||||
message.error(
|
||||
`文件大小超过限制!${detectedType === 'image' ? '图片' : '视频'}最大${maxSizeMB}MB`,
|
||||
);
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// 文件类型检查
|
||||
const validImageTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/bmp',
|
||||
];
|
||||
const validVideoTypes = [
|
||||
'video/mp4',
|
||||
'video/avi',
|
||||
'video/mov',
|
||||
'video/wmv',
|
||||
'video/flv',
|
||||
'video/webm',
|
||||
'video/mkv',
|
||||
];
|
||||
|
||||
if (detectedType === 'image' && !validImageTypes.includes(file.type)) {
|
||||
message.error('请选择有效的图片格式 (JPEG, PNG, GIF, WebP, BMP)');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (detectedType === 'video' && !validVideoTypes.includes(file.type)) {
|
||||
message.error(
|
||||
'请选择有效的视频格式 (MP4, AVI, MOV, WMV, FLV, WebM, MKV)',
|
||||
);
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', (e) => {
|
||||
uploadPreview.value = {
|
||||
type: detectedType,
|
||||
url: e.target.result,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
file,
|
||||
};
|
||||
});
|
||||
|
||||
reader.onerror = (error) => {
|
||||
console.error('文件读取失败:', error);
|
||||
message.error('文件读取失败,请重试');
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const cancelUpload = () => {
|
||||
uploadPreview.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
uploadPreview,
|
||||
imageInput,
|
||||
videoInput,
|
||||
triggerFileInput,
|
||||
handleFileUpload,
|
||||
cancelUpload,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {ref} from "vue"
|
||||
|
||||
const visible = ref(false)
|
||||
const mediaData = ref(null)
|
||||
const previewType = ref("image")
|
||||
|
||||
export function useMediaPreview() {
|
||||
const showPreview = (content, type = "image") => {
|
||||
console.log("显示媒体预览:", content, type)
|
||||
mediaData.value = content
|
||||
previewType.value = type
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
const hidePreview = () => {
|
||||
visible.value = false
|
||||
mediaData.value = null
|
||||
previewType.value = "image"
|
||||
}
|
||||
|
||||
return {
|
||||
visible,
|
||||
mediaData,
|
||||
previewType,
|
||||
showPreview,
|
||||
hidePreview,
|
||||
}
|
||||
}
|
||||
|
||||
// 导出一个函数供组件直接使用
|
||||
export default function previewMedia(content, type = "image") {
|
||||
const {showPreview} = useMediaPreview()
|
||||
showPreview(content, type)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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 recordingStartTime = ref(0)
|
||||
|
||||
const startRecording = () => {
|
||||
if (isRecording.value) return
|
||||
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({audio: true})
|
||||
.then((stream) => {
|
||||
isRecording.value = true
|
||||
audioChunks.value = []
|
||||
recordingStartTime.value = Date.now()
|
||||
|
||||
mediaRecorder.value = new MediaRecorder(stream)
|
||||
|
||||
mediaRecorder.value.ondataavailable = (event) => {
|
||||
audioChunks.value.push(event.data)
|
||||
}
|
||||
|
||||
mediaRecorder.value.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunks.value, {type: "audio/wav"})
|
||||
const audioUrl = URL.createObjectURL(audioBlob)
|
||||
const duration = Math.floor((Date.now() - recordingStartTime.value) / 1000)
|
||||
|
||||
console.log(' aaa', 'ssssssssssssssss')
|
||||
// 触发音频消息发送事件
|
||||
const event = new CustomEvent("audioRecorded", {
|
||||
detail: {
|
||||
url: audioUrl,
|
||||
duration: duration,
|
||||
blob: audioBlob,
|
||||
},
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
|
||||
// 停止所有音频轨道
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
mediaRecorder.value.start()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("录音失败:", error)
|
||||
message.error("无法访问麦克风,请检查权限设置")
|
||||
isRecording.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (!isRecording.value || !mediaRecorder.value) return
|
||||
|
||||
isRecording.value = false
|
||||
mediaRecorder.value.stop()
|
||||
}
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
}
|
||||
}
|
||||
904
apps/web-antd/src/views/business/chat/composables/useWebRTC.ts
Normal file
904
apps/web-antd/src/views/business/chat/composables/useWebRTC.ts
Normal file
@@ -0,0 +1,904 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useChatStore } from '../stores/chat';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { callAPI } from '../utils/request.ts';
|
||||
|
||||
export function useWebRTC() {
|
||||
const localStream = ref(null);
|
||||
const remoteStream = ref(null);
|
||||
const peerConnection = ref(null);
|
||||
const isConnected = ref(false);
|
||||
const isConnecting = ref(false);
|
||||
|
||||
// ICE候选缓存和状态管理
|
||||
const pendingIceCandidates = ref([]);
|
||||
const isRemoteDescriptionSet = ref(false);
|
||||
const connectionAttempts = ref(0);
|
||||
const maxConnectionAttempts = 3;
|
||||
|
||||
// 修复问题1: 添加视频元素回调注册机制
|
||||
const videoElementCallbacks = ref(new Set());
|
||||
|
||||
const userStore = useUserStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// 改进ICE服务器配置
|
||||
const configuration = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun2.l.google.com:19302' },
|
||||
{ urls: 'stun:stun3.l.google.com:19302' },
|
||||
{ urls: 'stun:stun4.l.google.com:19302' },
|
||||
],
|
||||
iceTransportPolicy: 'all',
|
||||
iceCandidatePoolSize: 10,
|
||||
bundlePolicy: 'max-bundle',
|
||||
rtcpMuxPolicy: 'require',
|
||||
};
|
||||
|
||||
// 时间戳日志函数
|
||||
const logWithTime = (level, message, ...args) => {
|
||||
const timestamp = new Date().toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
});
|
||||
|
||||
switch (level) {
|
||||
case 'error': {
|
||||
console.error(`[${timestamp}] ${message}`, ...args);
|
||||
break;
|
||||
}
|
||||
case 'log': {
|
||||
console.log(`[${timestamp}] ${message}`, ...args);
|
||||
break;
|
||||
}
|
||||
case 'warn': {
|
||||
console.warn(`[${timestamp}] ${message}`, ...args);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.log(`[${timestamp}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取本地媒体流
|
||||
const getLocalMedia = async (constraints = { audio: true, video: true }) => {
|
||||
try {
|
||||
logWithTime('log', '🎥 开始获取本地媒体流', constraints);
|
||||
|
||||
if (localStream.value) {
|
||||
logWithTime('log', '🔄 停止现有本地媒体流');
|
||||
localStream.value.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
logWithTime('log', `停止轨道: ${track.kind} - ${track.label}`);
|
||||
});
|
||||
localStream.value = null;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
localStream.value = stream;
|
||||
logWithTime(
|
||||
'log',
|
||||
'✅ 本地媒体流获取成功',
|
||||
stream.getTracks().map((t) => `${t.kind}:${t.label}`),
|
||||
);
|
||||
return stream;
|
||||
} catch (error) {
|
||||
logWithTime('error', '❌ 获取本地媒体流失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理缓存的ICE候选
|
||||
const processPendingIceCandidates = async () => {
|
||||
if (
|
||||
!peerConnection.value ||
|
||||
!isRemoteDescriptionSet.value ||
|
||||
pendingIceCandidates.value.length === 0
|
||||
) {
|
||||
logWithTime(
|
||||
'log',
|
||||
`🧊 跳过处理ICE候选 - PC:${!!peerConnection.value}, RD:${isRemoteDescriptionSet.value}, 候选数:${pendingIceCandidates.value.length}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logWithTime(
|
||||
'log',
|
||||
`🧊 开始处理 ${pendingIceCandidates.value.length} 个缓存的ICE候选`,
|
||||
);
|
||||
|
||||
const candidates = [...pendingIceCandidates.value];
|
||||
pendingIceCandidates.value = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (
|
||||
peerConnection.value &&
|
||||
peerConnection.value.connectionState !== 'closed'
|
||||
) {
|
||||
const iceCandidate = candidate.candidate
|
||||
? candidate
|
||||
: new RTCIceCandidate(candidate);
|
||||
await peerConnection.value.addIceCandidate(iceCandidate);
|
||||
logWithTime(
|
||||
'log',
|
||||
'✅ 缓存ICE候选添加成功:',
|
||||
`${iceCandidate.candidate?.slice(0, 50)}...`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logWithTime('error', '❌ 缓存ICE候选添加失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 修复问题1: 注册视频元素更新回调
|
||||
const registerVideoCallback = (callback) => {
|
||||
videoElementCallbacks.value.add(callback);
|
||||
logWithTime(
|
||||
'log',
|
||||
'📹 注册视频元素回调,当前回调数量:',
|
||||
videoElementCallbacks.value.size,
|
||||
);
|
||||
|
||||
// 如果已经有远程流,立即调用回调
|
||||
if (remoteStream.value) {
|
||||
logWithTime('log', '📹 立即调用新注册的回调,因为远程流已存在');
|
||||
try {
|
||||
callback(remoteStream.value);
|
||||
} catch (error) {
|
||||
logWithTime('error', '❌ 调用视频回调失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
videoElementCallbacks.value.delete(callback);
|
||||
logWithTime('log', '📹 取消注册视频元素回调');
|
||||
};
|
||||
};
|
||||
|
||||
// 修复问题1: 通知所有注册的视频元素更新
|
||||
const notifyVideoElementsUpdate = (stream) => {
|
||||
logWithTime(
|
||||
'log',
|
||||
'📹 通知所有视频元素更新,回调数量:',
|
||||
videoElementCallbacks.value.size,
|
||||
);
|
||||
|
||||
videoElementCallbacks.value.forEach((callback) => {
|
||||
try {
|
||||
callback(stream);
|
||||
} catch (error) {
|
||||
logWithTime('error', '❌ 调用视频回调失败:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 修复问题1: 改进远程轨道处理,直接通知组件更新
|
||||
const setupRemoteTrackHandling = (pc) => {
|
||||
pc.ontrack = (event) => {
|
||||
logWithTime('log', '📹 接收到远程轨道事件:', {
|
||||
streams: event.streams?.length,
|
||||
track: event.track?.kind,
|
||||
trackId: event.track?.id,
|
||||
trackState: event.track?.readyState,
|
||||
trackEnabled: event.track?.enabled,
|
||||
trackMuted: event.track?.muted,
|
||||
});
|
||||
|
||||
if (event.streams && event.streams.length > 0) {
|
||||
const stream = event.streams[0];
|
||||
const tracks = stream.getTracks();
|
||||
|
||||
if (tracks.length === 0) {
|
||||
logWithTime('warn', '⚠️ 远程流没有轨道');
|
||||
return;
|
||||
}
|
||||
|
||||
logWithTime('log', '✅ 设置远程流:', {
|
||||
id: stream.id,
|
||||
tracks: tracks.map(
|
||||
(t) => `${t.kind}:${t.id}:${t.readyState}:${t.enabled}:${t.muted}`,
|
||||
),
|
||||
active: stream.active,
|
||||
});
|
||||
|
||||
// 清理旧的远程流
|
||||
if (remoteStream.value && remoteStream.value.id !== stream.id) {
|
||||
logWithTime('log', '🔄 清理旧的远程流');
|
||||
remoteStream.value.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
logWithTime('log', `停止旧远程轨道: ${track.kind}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 设置新的远程流
|
||||
remoteStream.value = stream;
|
||||
|
||||
// 修复问题1: 直接通知所有注册的视频元素
|
||||
notifyVideoElementsUpdate(stream);
|
||||
|
||||
// 监听流和轨道的状态变化
|
||||
stream.onactive = () => {
|
||||
logWithTime('log', '📹 远程流变为活跃状态');
|
||||
notifyVideoElementsUpdate(stream);
|
||||
};
|
||||
|
||||
stream.oninactive = () => {
|
||||
logWithTime('warn', '⚠️ 远程流变为非活跃状态');
|
||||
};
|
||||
|
||||
stream.onaddtrack = (trackEvent) => {
|
||||
logWithTime('log', '📹 远程流添加新轨道:', trackEvent.track.kind);
|
||||
setupTrackListeners(trackEvent.track);
|
||||
notifyVideoElementsUpdate(stream);
|
||||
};
|
||||
|
||||
stream.onremovetrack = (trackEvent) => {
|
||||
logWithTime('warn', '⚠️ 远程流移除轨道:', trackEvent.track.kind);
|
||||
};
|
||||
|
||||
// 为每个轨道设置监听器
|
||||
tracks.forEach((track, index) => {
|
||||
setupTrackListeners(track, index);
|
||||
});
|
||||
|
||||
// 延迟通知,确保DOM已更新
|
||||
setTimeout(() => {
|
||||
notifyVideoElementsUpdate(stream);
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
notifyVideoElementsUpdate(stream);
|
||||
}, 500);
|
||||
} else if (event.track) {
|
||||
// 如果没有流,尝试从轨道创建流
|
||||
logWithTime('log', '🔄 从轨道创建远程流');
|
||||
const newStream = new MediaStream([event.track]);
|
||||
remoteStream.value = newStream;
|
||||
setupTrackListeners(event.track);
|
||||
|
||||
logWithTime('log', '✅ 从轨道创建的流:', {
|
||||
id: newStream.id,
|
||||
tracks: newStream.getTracks().map((t) => `${t.kind}:${t.id}`),
|
||||
});
|
||||
|
||||
// 通知视频元素更新
|
||||
notifyVideoElementsUpdate(newStream);
|
||||
|
||||
setTimeout(() => {
|
||||
notifyVideoElementsUpdate(newStream);
|
||||
}, 100);
|
||||
} else {
|
||||
logWithTime('warn', '⚠️ ontrack事件没有接收到有效的媒体流或轨道');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 为轨道设置完整的状态监听
|
||||
const setupTrackListeners = (track, index = 0) => {
|
||||
logWithTime(
|
||||
'log',
|
||||
`设置轨道${index}监听器: ${track.kind} - ${track.readyState} - ${track.enabled}`,
|
||||
);
|
||||
|
||||
track.addEventListener('ended', () => {
|
||||
logWithTime('warn', `远程轨道结束: ${track.kind} - ${track.id}`);
|
||||
// 轨道结束时尝试重新协商
|
||||
if (
|
||||
peerConnection.value &&
|
||||
peerConnection.value.connectionState === 'connected'
|
||||
) {
|
||||
logWithTime('log', '轨道结束,可能需要重新协商');
|
||||
}
|
||||
});
|
||||
|
||||
track.onmute = () => {
|
||||
logWithTime('warn', `远程轨道静音: ${track.kind} - ${track.id}`);
|
||||
};
|
||||
|
||||
track.onunmute = () => {
|
||||
logWithTime('log', `远程轨道取消静音: ${track.kind} - ${track.id}`);
|
||||
// 取消静音时触发视频更新
|
||||
if (track.kind === 'video' && remoteStream.value) {
|
||||
notifyVideoElementsUpdate(remoteStream.value);
|
||||
}
|
||||
};
|
||||
|
||||
// 确保轨道是启用的
|
||||
if (!track.enabled) {
|
||||
logWithTime('warn', `⚠️ 远程轨道未启用,尝试启用: ${track.kind}`);
|
||||
track.enabled = true;
|
||||
}
|
||||
|
||||
// 定期检查轨道状态
|
||||
const checkInterval = setInterval(() => {
|
||||
if (track.readyState === 'ended') {
|
||||
clearInterval(checkInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
track.kind === 'video' &&
|
||||
track.readyState === 'live' &&
|
||||
track.enabled &&
|
||||
!track.muted && // 视频轨道状态良好,确保视频元素正在播放
|
||||
remoteStream.value
|
||||
) {
|
||||
notifyVideoElementsUpdate(remoteStream.value);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// 清理定时器
|
||||
track.addEventListener('ended', () => {
|
||||
clearInterval(checkInterval);
|
||||
});
|
||||
};
|
||||
|
||||
// 连接重试机制
|
||||
const attemptReconnection = async (callId, peerId) => {
|
||||
if (connectionAttempts.value >= maxConnectionAttempts) {
|
||||
logWithTime('error', '❌ 达到最大重连次数,停止重试');
|
||||
chatStore.callStatus = 'failed';
|
||||
return false;
|
||||
}
|
||||
|
||||
connectionAttempts.value++;
|
||||
logWithTime(
|
||||
'log',
|
||||
`🔄 尝试重新连接 (${connectionAttempts.value}/${maxConnectionAttempts})`,
|
||||
);
|
||||
|
||||
try {
|
||||
// 清理现有连接
|
||||
cleanupPeerConnection();
|
||||
|
||||
// 重新创建连接
|
||||
createPeerConnection(callId, peerId);
|
||||
|
||||
// 重置状态
|
||||
isRemoteDescriptionSet.value = false;
|
||||
pendingIceCandidates.value = [];
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logWithTime('error', '❌ 重连失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 改进PeerConnection创建逻辑
|
||||
const createPeerConnection = (callId, peerId) => {
|
||||
logWithTime('log', '🔗 创建PeerConnection', { callId, peerId });
|
||||
|
||||
if (peerConnection.value) {
|
||||
logWithTime('log', '🔄 关闭现有PeerConnection');
|
||||
cleanupPeerConnection();
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
isConnected.value = false;
|
||||
isConnecting.value = false;
|
||||
isRemoteDescriptionSet.value = false;
|
||||
pendingIceCandidates.value = [];
|
||||
|
||||
const pc = new RTCPeerConnection(configuration);
|
||||
peerConnection.value = pc;
|
||||
|
||||
// 添加本地流到连接
|
||||
if (localStream.value) {
|
||||
logWithTime('log', '📤 添加本地流轨道到PeerConnection');
|
||||
localStream.value.getTracks().forEach((track) => {
|
||||
logWithTime('log', `添加轨道: ${track.kind} - ${track.label}`);
|
||||
pc.addTrack(track, localStream.value);
|
||||
});
|
||||
}
|
||||
|
||||
// ICE候选事件处理
|
||||
pc.onicecandidate = async (event) => {
|
||||
if (event.candidate) {
|
||||
logWithTime(
|
||||
'log',
|
||||
'📡 发送ICE候选:',
|
||||
`${event.candidate.candidate?.slice(0, 50)}...`,
|
||||
);
|
||||
try {
|
||||
await callAPI.sendCandidate(
|
||||
userStore.currentUser?.id,
|
||||
peerId,
|
||||
callId,
|
||||
event.candidate,
|
||||
6,
|
||||
);
|
||||
logWithTime('log', '✅ ICE候选发送成功');
|
||||
} catch (error) {
|
||||
logWithTime('error', '❌ 发送ICE候选失败:', error);
|
||||
}
|
||||
} else {
|
||||
logWithTime('log', '📡 ICE候选收集完成');
|
||||
}
|
||||
};
|
||||
|
||||
// 设置改进的远程轨道处理
|
||||
setupRemoteTrackHandling(pc);
|
||||
|
||||
// 连接状态监听
|
||||
pc.onconnectionstatechange = () => {
|
||||
const state = pc.connectionState;
|
||||
logWithTime('log', '🔗 连接状态变化:', state);
|
||||
|
||||
switch (state) {
|
||||
case 'closed': {
|
||||
isConnected.value = false;
|
||||
isConnecting.value = false;
|
||||
logWithTime('log', '🔒 WebRTC连接已关闭');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'connected': {
|
||||
isConnected.value = true;
|
||||
isConnecting.value = false;
|
||||
connectionAttempts.value = 0; // 重置重试计数
|
||||
chatStore.callStatus = 'ongoing';
|
||||
chatStore.callConnectionStatus = '通话已连接';
|
||||
logWithTime('log', '✅ WebRTC连接已建立');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'connecting': {
|
||||
isConnecting.value = true;
|
||||
chatStore.callConnectionStatus = '正在连接...';
|
||||
logWithTime('log', '🔄 WebRTC正在连接');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'disconnected': {
|
||||
isConnected.value = false;
|
||||
isConnecting.value = false;
|
||||
chatStore.callStatus = 'disconnected';
|
||||
chatStore.callConnectionStatus = '连接已断开';
|
||||
logWithTime('warn', '⚠️ WebRTC连接断开');
|
||||
|
||||
// 连接断开时尝试重连
|
||||
setTimeout(() => {
|
||||
if (chatStore.callStatus === 'disconnected') {
|
||||
attemptReconnection(callId, peerId);
|
||||
}
|
||||
}, 2000);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'failed': {
|
||||
isConnected.value = false;
|
||||
isConnecting.value = false;
|
||||
chatStore.callStatus = 'failed';
|
||||
chatStore.callConnectionStatus = '连接失败';
|
||||
logWithTime('error', '❌ WebRTC连接失败');
|
||||
|
||||
// 连接失败时尝试重连
|
||||
setTimeout(() => {
|
||||
if (chatStore.callStatus === 'failed') {
|
||||
attemptReconnection(callId, peerId);
|
||||
}
|
||||
}, 3000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ICE连接状态监听和重连逻辑
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
const iceState = pc.iceConnectionState;
|
||||
logWithTime('log', '🧊 ICE连接状态变化:', iceState);
|
||||
|
||||
switch (iceState) {
|
||||
case 'checking': {
|
||||
logWithTime('log', '🧊 ICE正在检查连接');
|
||||
break;
|
||||
}
|
||||
case 'closed': {
|
||||
logWithTime('log', '🔒 ICE连接已关闭');
|
||||
break;
|
||||
}
|
||||
case 'completed': {
|
||||
logWithTime('log', '✅ ICE连接完成');
|
||||
break;
|
||||
}
|
||||
case 'connected': {
|
||||
logWithTime('log', '✅ ICE连接成功');
|
||||
connectionAttempts.value = 0; // 重置重试计数
|
||||
break;
|
||||
}
|
||||
case 'disconnected': {
|
||||
logWithTime('warn', '⚠️ ICE连接断开');
|
||||
|
||||
// ICE断开时等待一段时间看是否能恢复
|
||||
setTimeout(() => {
|
||||
if (pc.iceConnectionState === 'disconnected') {
|
||||
logWithTime('log', '🔄 ICE连接断开超时,尝试重连');
|
||||
attemptReconnection(callId, peerId);
|
||||
}
|
||||
}, 5000);
|
||||
break;
|
||||
}
|
||||
case 'failed': {
|
||||
logWithTime('error', '❌ ICE连接失败');
|
||||
chatStore.callConnectionStatus = '网络连接失败';
|
||||
|
||||
// ICE连接失败时重新协商
|
||||
setTimeout(() => {
|
||||
if (pc.iceConnectionState === 'failed') {
|
||||
logWithTime('log', '🔄 ICE连接失败,尝试重新协商');
|
||||
// 通知需要重新协商
|
||||
const event = new CustomEvent('iceConnectionFailed', {
|
||||
detail: { callId, peerId },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
}, 2000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ICE收集状态监听
|
||||
pc.onicegatheringstatechange = () => {
|
||||
logWithTime('log', '🧊 ICE收集状态:', pc.iceGatheringState);
|
||||
};
|
||||
|
||||
// 信令状态监听
|
||||
pc.onsignalingstatechange = () => {
|
||||
logWithTime('log', '📡 信令状态:', pc.signalingState);
|
||||
};
|
||||
|
||||
return pc;
|
||||
};
|
||||
|
||||
// 清理PeerConnection
|
||||
const cleanupPeerConnection = () => {
|
||||
if (!peerConnection.value) return;
|
||||
|
||||
try {
|
||||
const senders = peerConnection.value.getSenders();
|
||||
senders.forEach((sender) => {
|
||||
if (sender.track) {
|
||||
logWithTime('log', `停止发送器轨道: ${sender.track.kind}`);
|
||||
sender.track.stop();
|
||||
}
|
||||
});
|
||||
|
||||
const receivers = peerConnection.value.getReceivers();
|
||||
receivers.forEach((receiver) => {
|
||||
if (receiver.track) {
|
||||
logWithTime('log', `停止接收器轨道: ${receiver.track.kind}`);
|
||||
receiver.track.stop();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logWithTime('error', '❌ 清理发送器/接收器时出错:', error);
|
||||
}
|
||||
|
||||
// 清理事件监听器
|
||||
peerConnection.value.onicecandidate = null;
|
||||
peerConnection.value.ontrack = null;
|
||||
peerConnection.value.onconnectionstatechange = null;
|
||||
peerConnection.value.oniceconnectionstatechange = null;
|
||||
peerConnection.value.onicegatheringstatechange = null;
|
||||
peerConnection.value.onsignalingstatechange = null;
|
||||
peerConnection.value.ondatachannel = null;
|
||||
|
||||
peerConnection.value.close();
|
||||
peerConnection.value = null;
|
||||
};
|
||||
|
||||
// 创建Offer
|
||||
const createOffer = async (callId, peerId) => {
|
||||
logWithTime('log', '📤 创建Offer', { callId, peerId });
|
||||
|
||||
if (!peerConnection.value) {
|
||||
logWithTime('log', '🔗 PeerConnection不存在,先创建');
|
||||
createPeerConnection(callId, peerId);
|
||||
}
|
||||
|
||||
try {
|
||||
const offerOptions = {
|
||||
offerToReceiveAudio: true,
|
||||
offerToReceiveVideo: true,
|
||||
voiceActivityDetection: true,
|
||||
iceRestart: connectionAttempts.value > 0, // 重连时启用ICE重启
|
||||
};
|
||||
|
||||
const offer = await peerConnection.value.createOffer(offerOptions);
|
||||
await peerConnection.value.setLocalDescription(offer);
|
||||
|
||||
logWithTime('log', '✅ Offer创建成功:', {
|
||||
type: offer.type,
|
||||
sdpLength: offer.sdp.length,
|
||||
hasAudio: offer.sdp.includes('m=audio'),
|
||||
hasVideo: offer.sdp.includes('m=video'),
|
||||
iceRestart: offerOptions.iceRestart,
|
||||
});
|
||||
|
||||
return offer;
|
||||
} catch (error) {
|
||||
logWithTime('error', '❌ 创建Offer失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 创建Answer
|
||||
const createAnswer = async (offer, callId, peerId) => {
|
||||
logWithTime('log', '📥 处理Offer并创建Answer');
|
||||
|
||||
if (!peerConnection.value) {
|
||||
logWithTime('log', '🔗 PeerConnection不存在,先创建');
|
||||
createPeerConnection(callId, peerId);
|
||||
}
|
||||
|
||||
try {
|
||||
logWithTime('log', '📥 设置远程Offer');
|
||||
const remoteDesc = new RTCSessionDescription(offer);
|
||||
await peerConnection.value.setRemoteDescription(remoteDesc);
|
||||
|
||||
isRemoteDescriptionSet.value = true;
|
||||
logWithTime('log', '✅ 远程描述设置完成,开始处理缓存的ICE候选');
|
||||
|
||||
await processPendingIceCandidates();
|
||||
|
||||
const answer = await peerConnection.value.createAnswer();
|
||||
await peerConnection.value.setLocalDescription(answer);
|
||||
|
||||
logWithTime('log', '✅ Answer创建成功:', {
|
||||
type: answer.type,
|
||||
sdpLength: answer.sdp.length,
|
||||
hasAudio: answer.sdp.includes('m=audio'),
|
||||
hasVideo: answer.sdp.includes('m=video'),
|
||||
});
|
||||
|
||||
return answer;
|
||||
} catch (error) {
|
||||
logWithTime('error', '❌ 创建Answer失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 设置远程Answer
|
||||
const setRemoteAnswer = async (answer) => {
|
||||
logWithTime('log', '📥 设置远程Answer');
|
||||
|
||||
if (!peerConnection.value) {
|
||||
logWithTime('error', '❌ PeerConnection不存在');
|
||||
throw new Error('PeerConnection不存在');
|
||||
}
|
||||
|
||||
try {
|
||||
const remoteDesc = new RTCSessionDescription(answer);
|
||||
await peerConnection.value.setRemoteDescription(remoteDesc);
|
||||
|
||||
isRemoteDescriptionSet.value = true;
|
||||
logWithTime('log', '✅ 远程Answer设置成功,开始处理缓存的ICE候选');
|
||||
|
||||
await processPendingIceCandidates();
|
||||
} catch (error) {
|
||||
logWithTime('error', '❌ 设置远程Answer失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ICE候选添加逻辑
|
||||
const addIceCandidate = async (candidate) => {
|
||||
logWithTime(
|
||||
'log',
|
||||
'🧊 收到ICE候选:',
|
||||
`${candidate.candidate?.slice(0, 50)}...`,
|
||||
);
|
||||
|
||||
if (!candidate || !candidate.candidate) {
|
||||
logWithTime('warn', '⚠️ 无效的ICE候选:', candidate);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!peerConnection.value) {
|
||||
logWithTime('warn', '⚠️ PeerConnection不存在,缓存ICE候选');
|
||||
pendingIceCandidates.value.push(candidate);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
peerConnection.value.connectionState === 'closed' ||
|
||||
peerConnection.value.connectionState === 'failed'
|
||||
) {
|
||||
logWithTime(
|
||||
'warn',
|
||||
'⚠️ PeerConnection状态异常,缓存ICE候选:',
|
||||
peerConnection.value.connectionState,
|
||||
);
|
||||
pendingIceCandidates.value.push(candidate);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isRemoteDescriptionSet.value) {
|
||||
logWithTime('warn', '⚠️ 远程描述未设置,缓存ICE候选');
|
||||
pendingIceCandidates.value.push(candidate);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const iceCandidate = candidate.candidate
|
||||
? candidate
|
||||
: new RTCIceCandidate(candidate);
|
||||
await peerConnection.value.addIceCandidate(iceCandidate);
|
||||
logWithTime('log', '✅ ICE候选添加成功');
|
||||
return true;
|
||||
} catch (error) {
|
||||
logWithTime('error', '❌ 添加ICE候选失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 切换摄像头
|
||||
const toggleCamera = (enable) => {
|
||||
if (!localStream.value) {
|
||||
logWithTime('warn', '⚠️ 本地流不存在,无法切换摄像头');
|
||||
return;
|
||||
}
|
||||
|
||||
const videoTracks = localStream.value.getVideoTracks();
|
||||
if (videoTracks.length > 0) {
|
||||
videoTracks.forEach((track) => {
|
||||
track.enabled = enable;
|
||||
logWithTime(
|
||||
'log',
|
||||
`📹 摄像头${enable ? '开启' : '关闭'}: ${track.label}`,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
logWithTime('warn', '⚠️ 没有找到视频轨道');
|
||||
}
|
||||
};
|
||||
|
||||
// 切换麦克风
|
||||
const toggleMicrophone = (enable) => {
|
||||
if (!localStream.value) {
|
||||
logWithTime('warn', '⚠️ 本地流不存在,无法切换麦克风');
|
||||
return;
|
||||
}
|
||||
|
||||
const audioTracks = localStream.value.getAudioTracks();
|
||||
if (audioTracks.length > 0) {
|
||||
audioTracks.forEach((track) => {
|
||||
track.enabled = enable;
|
||||
logWithTime(
|
||||
'log',
|
||||
`🎤 麦克风${enable ? '开启' : '关闭'}: ${track.label}`,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
logWithTime('warn', '⚠️ 没有找到音频轨道');
|
||||
}
|
||||
};
|
||||
|
||||
// 释放媒体资源
|
||||
const releaseMediaResources = () => {
|
||||
logWithTime('log', '🔄 开始释放媒体资源');
|
||||
|
||||
if (localStream.value) {
|
||||
logWithTime('log', '🔄 释放本地媒体流');
|
||||
localStream.value.getTracks().forEach((track) => {
|
||||
logWithTime(
|
||||
'log',
|
||||
`停止本地轨道: ${track.kind} - ${track.label} - 状态: ${track.readyState}`,
|
||||
);
|
||||
track.stop();
|
||||
|
||||
setTimeout(() => {
|
||||
if (track.readyState === 'ended') {
|
||||
logWithTime('log', `✅ 轨道已完全停止: ${track.kind}`);
|
||||
} else {
|
||||
logWithTime(
|
||||
'warn',
|
||||
`⚠️ 轨道未完全停止: ${track.kind} - ${track.readyState}`,
|
||||
);
|
||||
try {
|
||||
track.stop();
|
||||
} catch (error) {
|
||||
logWithTime('error', '强制停止轨道失败:', error);
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
localStream.value = null;
|
||||
}
|
||||
|
||||
if (remoteStream.value) {
|
||||
logWithTime('log', '🔄 释放远程媒体流');
|
||||
remoteStream.value.getTracks().forEach((track) => {
|
||||
logWithTime('log', `停止远程轨道: ${track.kind}`);
|
||||
track.stop();
|
||||
});
|
||||
remoteStream.value = null;
|
||||
|
||||
// 通知视频元素清理
|
||||
notifyVideoElementsUpdate(null);
|
||||
}
|
||||
|
||||
logWithTime('log', '✅ 媒体资源释放完成');
|
||||
};
|
||||
|
||||
// 关闭连接
|
||||
const closeConnection = () => {
|
||||
logWithTime('log', '🔄 开始关闭WebRTC连接');
|
||||
|
||||
pendingIceCandidates.value = [];
|
||||
isRemoteDescriptionSet.value = false;
|
||||
connectionAttempts.value = 0;
|
||||
logWithTime('log', '🧊 清空ICE候选缓存和状态');
|
||||
|
||||
releaseMediaResources();
|
||||
cleanupPeerConnection();
|
||||
|
||||
isConnected.value = false;
|
||||
isConnecting.value = false;
|
||||
|
||||
logWithTime('log', '✅ WebRTC连接已完全关闭,所有资源已释放');
|
||||
};
|
||||
|
||||
// 强制清理所有资源
|
||||
const forceCleanup = () => {
|
||||
logWithTime('log', '🚨 强制清理所有WebRTC资源');
|
||||
|
||||
try {
|
||||
pendingIceCandidates.value = [];
|
||||
isRemoteDescriptionSet.value = false;
|
||||
connectionAttempts.value = 0;
|
||||
|
||||
// 清理所有回调
|
||||
videoElementCallbacks.value.clear();
|
||||
|
||||
closeConnection();
|
||||
|
||||
setTimeout(() => {
|
||||
localStream.value = null;
|
||||
remoteStream.value = null;
|
||||
peerConnection.value = null;
|
||||
isConnected.value = false;
|
||||
isConnecting.value = false;
|
||||
|
||||
logWithTime('log', '✅ 强制清理完成');
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
logWithTime('error', '❌ 强制清理时出错:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
localStream,
|
||||
remoteStream,
|
||||
peerConnection,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
pendingIceCandidates,
|
||||
isRemoteDescriptionSet,
|
||||
connectionAttempts,
|
||||
getLocalMedia,
|
||||
createPeerConnection,
|
||||
createOffer,
|
||||
createAnswer,
|
||||
setRemoteAnswer,
|
||||
addIceCandidate,
|
||||
toggleCamera,
|
||||
toggleMicrophone,
|
||||
closeConnection,
|
||||
releaseMediaResources,
|
||||
forceCleanup,
|
||||
attemptReconnection,
|
||||
registerVideoCallback, // 修复问题1: 新增方法
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
import { useChatStore } from '../stores/chat';
|
||||
import { useUserStore } from '../stores/user';
|
||||
// useVbenUserStore
|
||||
export function useWebSocket() {
|
||||
const userStore = useUserStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const socket = ref();
|
||||
const isConnected = ref(false);
|
||||
const isConnecting = ref(false);
|
||||
const reconnectAttempts = ref(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
const reconnectDelay = ref(1000);
|
||||
|
||||
const connectWebSocket = () => {
|
||||
if (isConnecting.value || isConnected.value) return;
|
||||
|
||||
isConnecting.value = true;
|
||||
chatStore.connectionStatus = 'connecting';
|
||||
|
||||
try {
|
||||
// const wsUrl = import.meta.env.VITE_WS_URL || "wss://g-ws.nailaoyun.cn/ws"
|
||||
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:12080/ws';
|
||||
// const wsUrl = import.meta.env.VITE_WS_URL || "wss://g-ws.nailaoyun.cn/ws"
|
||||
socket.value = new WebSocket(wsUrl);
|
||||
|
||||
socket.value.addEventListener('open', handleOpen);
|
||||
socket.value.onmessage = handleMessage;
|
||||
socket.value.addEventListener('close', handleClose);
|
||||
socket.value.onerror = handleError;
|
||||
} catch (error) {
|
||||
console.error('WebSocket连接失败:', error);
|
||||
isConnecting.value = false;
|
||||
chatStore.connectionStatus = 'disconnected';
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = (event) => {
|
||||
console.log('WebSocket连接已建立');
|
||||
isConnected.value = true;
|
||||
isConnecting.value = false;
|
||||
reconnectAttempts.value = 0;
|
||||
reconnectDelay.value = 1000;
|
||||
chatStore.connectionStatus = 'connected';
|
||||
chatStore.socket = socket.value;
|
||||
console.log(userStore.currentUser, 'ssssssssss')
|
||||
|
||||
// 绑定当前用户
|
||||
if (userStore.currentUser?.id) {
|
||||
bindUser(userStore.currentUser.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('收到WebSocket消息:', data);
|
||||
|
||||
// 优先处理通话信令(无论是否在当前聊天窗口)
|
||||
if (data.call_id && data.call_status) {
|
||||
handleCallSignal(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理普通消息 - 添加来源判断防止循环
|
||||
if (data.sender_user_id && data.receiver_user_id) {
|
||||
chatStore.handleIncomingMessage(data, userStore.currentUser?.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析WebSocket消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (event) => {
|
||||
console.log('WebSocket连接已关闭:', event.code, event.reason);
|
||||
isConnected.value = false;
|
||||
isConnecting.value = false;
|
||||
chatStore.connectionStatus = 'disconnected';
|
||||
chatStore.socket = null;
|
||||
|
||||
// 自动重连
|
||||
if (reconnectAttempts.value < maxReconnectAttempts) {
|
||||
setTimeout(() => {
|
||||
reconnectAttempts.value++;
|
||||
reconnectDelay.value = Math.min(reconnectDelay.value * 2, 30_000);
|
||||
console.log(
|
||||
`尝试重连 (${reconnectAttempts.value}/${maxReconnectAttempts})`,
|
||||
);
|
||||
connectWebSocket();
|
||||
}, reconnectDelay.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (error) => {
|
||||
console.error('WebSocket错误:', error);
|
||||
isConnecting.value = false;
|
||||
chatStore.connectionStatus = 'disconnected';
|
||||
};
|
||||
|
||||
const handleCallSignal = (data) => {
|
||||
console.log('收到通话信令:', data);
|
||||
chatStore.handleCallSignal(data);
|
||||
};
|
||||
|
||||
const bindUser = (userId) => {
|
||||
if (!isConnected.value || !userId) return;
|
||||
|
||||
const bindMessage = {
|
||||
request_type: 'bind',
|
||||
user_type: 'doctor',
|
||||
sender_user_id: `doctor-${userId}`,
|
||||
};
|
||||
|
||||
send(bindMessage);
|
||||
};
|
||||
|
||||
const send = (message) => {
|
||||
if (!isConnected.value || !socket.value) {
|
||||
console.error('WebSocket未连接,无法发送消息');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
socket.value.send(JSON.stringify(message));
|
||||
console.log('发送消息:', message);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = (receiverUserId, messageType, content) => {
|
||||
const message = {
|
||||
request_type: 'send_message',
|
||||
sender_user_id: userStore.currentUser?.id,
|
||||
receiver_user_id: receiverUserId,
|
||||
message_type: messageType,
|
||||
message_content: content,
|
||||
};
|
||||
|
||||
return send(message);
|
||||
};
|
||||
|
||||
const sendCallSignal = (signal) => {
|
||||
return send({
|
||||
request_type: 'call_signal',
|
||||
...signal,
|
||||
sender_user_id: userStore.currentUser?.id,
|
||||
});
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
if (socket.value) {
|
||||
socket.value.close();
|
||||
socket.value = null;
|
||||
}
|
||||
isConnected.value = false;
|
||||
isConnecting.value = false;
|
||||
chatStore.connectionStatus = 'disconnected';
|
||||
chatStore.socket = null;
|
||||
};
|
||||
|
||||
// 生成唯一ID
|
||||
const generateCallId = () => {
|
||||
return Date.now().toString() + Math.random().toString(36).slice(2, 11);
|
||||
};
|
||||
|
||||
// onMounted(() => {
|
||||
// connectWebSocket();
|
||||
// });
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect();
|
||||
});
|
||||
|
||||
return {
|
||||
socket,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connectWebSocket,
|
||||
disconnect,
|
||||
send,
|
||||
sendMessage,
|
||||
sendCallSignal,
|
||||
bindUser,
|
||||
generateCallId,
|
||||
};
|
||||
}
|
||||
211
apps/web-antd/src/views/business/chat/index.vue
Normal file
211
apps/web-antd/src/views/business/chat/index.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import ChatArea from './components/ChatArea.vue';
|
||||
import ConnectionStatus from './components/ConnectionStatus.vue';
|
||||
import EmptyState from './components/EmptyState.vue';
|
||||
import FriendList from './components/FriendList.vue';
|
||||
import FriendsManagement from './components/FriendsManagement.vue';
|
||||
import GroupsManagement from './components/GroupsManagement.vue';
|
||||
import MediaPreview from './components/MediaPreview.vue';
|
||||
import MomentsView from './components/MomentsView.vue';
|
||||
// import SideNavigation from './components/SideNavigation.vue';
|
||||
import VideoCallComponent from './components/VideoCallComponent.vue';
|
||||
import { useChatStore } from './stores/chat.ts';
|
||||
import { useThemeStore } from './stores/theme.ts';
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const currentNav = ref('chat');
|
||||
|
||||
// 视频通话相关状态
|
||||
const showVideoCall = ref(false);
|
||||
const callType = ref('video');
|
||||
const isIncoming = ref(false);
|
||||
const callerInfo = ref(null);
|
||||
|
||||
// 初始化录音功能
|
||||
// const { isRecording } = useRecording();
|
||||
|
||||
// 处理导航切换
|
||||
const handleNavChange = (nav) => {
|
||||
currentNav.value = nav;
|
||||
};
|
||||
|
||||
// 监听通话状态变化
|
||||
watch(
|
||||
() => chatStore.callStatus,
|
||||
(status) => {
|
||||
// 只有当通话状态是活跃状态时才显示视频组件
|
||||
showVideoCall.value =
|
||||
status === 'connecting' ||
|
||||
status === 'calling' ||
|
||||
status === 'ringing' ||
|
||||
status === 'ongoing';
|
||||
},
|
||||
);
|
||||
|
||||
// 监听来电
|
||||
watch(
|
||||
() => chatStore.incomingCall,
|
||||
(call) => {
|
||||
if (call) {
|
||||
isIncoming.value = true;
|
||||
callerInfo.value = chatStore.friends.find((f) => f.id === call.callerId);
|
||||
callType.value = call.callType;
|
||||
} else {
|
||||
isIncoming.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 监听当前通话
|
||||
watch(
|
||||
() => chatStore.currentCall,
|
||||
(call) => {
|
||||
if (call) {
|
||||
callerInfo.value = chatStore.friends.find((f) => f.id === call.peerId);
|
||||
callType.value = call.callType;
|
||||
isIncoming.value = false;
|
||||
} else {
|
||||
// 当通话结束时重置状态
|
||||
showVideoCall.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 处理接听通话
|
||||
const handleAcceptCall = () => {
|
||||
chatStore.acceptCall();
|
||||
};
|
||||
|
||||
// 处理拒绝通话
|
||||
const handleRejectCall = () => {
|
||||
chatStore.rejectCall();
|
||||
};
|
||||
|
||||
// 处理结束通话 - 修复挂断逻辑
|
||||
const handleEndCall = () => {
|
||||
chatStore.endCall();
|
||||
};
|
||||
|
||||
// 加载主题
|
||||
themeStore.loadTheme();
|
||||
|
||||
onUnmounted(() => {
|
||||
// disconnectWebSocket();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
style="height: 95vh;"
|
||||
class="flex bg-gradient-to-br from-blue-900 via-purple-900 to-pink-300 p-5"
|
||||
>
|
||||
<!-- <!– 背景装饰 –>-->
|
||||
<!-- <div class="pointer-events-none fixed inset-0 z-0">-->
|
||||
<!-- <div-->
|
||||
<!-- class="animate-float absolute left-10 top-10 h-72 w-72 rounded-full bg-blue-500 opacity-10"-->
|
||||
<!-- ></div>-->
|
||||
<!-- <div-->
|
||||
<!-- class="right-15 animate-float-delayed absolute top-60 h-48 w-48 rounded-full bg-cyan-400 opacity-10"-->
|
||||
<!-- ></div>-->
|
||||
<!-- <div-->
|
||||
<!-- class="animate-float-slow absolute bottom-10 left-20 h-36 w-36 rounded-full bg-red-400 opacity-10"-->
|
||||
<!-- ></div>-->
|
||||
<!-- </div>-->
|
||||
|
||||
|
||||
<!-- <!– 录音状态指示器 –>-->
|
||||
<!-- <RecordingIndicator />-->
|
||||
|
||||
<!-- 视频通话组件(移动到此处) -->
|
||||
<VideoCallComponent
|
||||
v-if="showVideoCall"
|
||||
:call-type="callType"
|
||||
:caller-info="callerInfo"
|
||||
:is-incoming="isIncoming"
|
||||
@accept-call="handleAcceptCall"
|
||||
@end-call="handleEndCall"
|
||||
@reject-call="handleRejectCall"
|
||||
/>
|
||||
|
||||
<!-- 主聊天容器 -->
|
||||
<div
|
||||
class="z-10 mx-auto flex flex-1 overflow-hidden rounded-2xl bg-white/90 shadow-2xl backdrop-blur-sm"
|
||||
>
|
||||
<!-- 连接状态指示器 -->
|
||||
<ConnectionStatus />
|
||||
<!-- <!– 侧边导航 –>-->
|
||||
<!-- <SideNavigation-->
|
||||
<!-- :current-view="currentNav"-->
|
||||
<!-- @nav-change="handleNavChange"-->
|
||||
<!-- />-->
|
||||
|
||||
<!-- 好友列表 -->
|
||||
<FriendList class="w-80 min-w-80" />
|
||||
|
||||
<!-- 聊天区域 -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<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>
|
||||
<style scoped>
|
||||
/*@keyframes float {
|
||||
0% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(10deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-delayed {
|
||||
0% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-15px) rotate(-5deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-slow {
|
||||
0% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px) rotate(5deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 12s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.animate-float-delayed {
|
||||
animation: float-delayed 15s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.animate-float-slow {
|
||||
animation: float-slow 18s infinite ease-in-out;
|
||||
}*/
|
||||
</style>
|
||||
1239
apps/web-antd/src/views/business/chat/stores/chat.ts
Normal file
1239
apps/web-antd/src/views/business/chat/stores/chat.ts
Normal file
File diff suppressed because it is too large
Load Diff
38
apps/web-antd/src/views/business/chat/stores/theme.ts
Normal file
38
apps/web-antd/src/views/business/chat/stores/theme.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {defineStore} from "pinia"
|
||||
import {ref} from "vue"
|
||||
|
||||
export const useThemeStore = defineStore("theme", () => {
|
||||
const isDarkMode = ref(false)
|
||||
|
||||
const toggleTheme = () => {
|
||||
isDarkMode.value = !isDarkMode.value
|
||||
localStorage.setItem("chatTheme", isDarkMode.value ? "dark" : "light")
|
||||
updateTheme()
|
||||
}
|
||||
|
||||
const updateTheme = () => {
|
||||
if (isDarkMode.value) {
|
||||
document.documentElement.setAttribute("data-theme", "dark")
|
||||
document.documentElement.classList.add("dark")
|
||||
} else {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
document.documentElement.classList.remove("dark")
|
||||
}
|
||||
}
|
||||
|
||||
const loadTheme = () => {
|
||||
const savedTheme = localStorage.getItem("chatTheme")
|
||||
if (savedTheme) {
|
||||
isDarkMode.value = savedTheme === "dark"
|
||||
} else {
|
||||
isDarkMode.value = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
}
|
||||
updateTheme()
|
||||
}
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
toggleTheme,
|
||||
loadTheme,
|
||||
}
|
||||
})
|
||||
114
apps/web-antd/src/views/business/chat/stores/user.ts
Normal file
114
apps/web-antd/src/views/business/chat/stores/user.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const currentUser = ref();
|
||||
const isLoggedIn = computed(() => !!currentUser.value);
|
||||
|
||||
// 预设用户数据
|
||||
const presetUsers = ref([
|
||||
{
|
||||
id: '1',
|
||||
room_id: '10001',
|
||||
name: '张三',
|
||||
avatar: 'avatar1',
|
||||
color: '#4cc9f0',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
room_id: '10002',
|
||||
name: '李四',
|
||||
avatar: 'avatar2',
|
||||
color: '#ff6b6b',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
room_id: '10003',
|
||||
name: '王五',
|
||||
avatar: 'avatar3',
|
||||
color: '#6a0dad',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
room_id: '10004',
|
||||
name: '赵六',
|
||||
avatar: 'avatar4',
|
||||
color: '#20b2aa',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
room_id: '10005',
|
||||
name: '钱七',
|
||||
avatar: 'avatar5',
|
||||
color: '#ffa500',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
room_id: '10006',
|
||||
name: '孙八',
|
||||
avatar: 'avatar6',
|
||||
color: '#9acd32',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
room_id: '10007',
|
||||
name: '周九',
|
||||
avatar: 'avatar7',
|
||||
color: '#ff1493',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
room_id: '10008',
|
||||
name: '吴十',
|
||||
avatar: 'avatar8',
|
||||
color: '#4682b4',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
room_id: '10009',
|
||||
name: '郑十一',
|
||||
avatar: 'avatar9',
|
||||
color: '#c71585',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
room_id: '100010',
|
||||
name: '王十二',
|
||||
avatar: 'avatar10',
|
||||
color: '#2e8b57',
|
||||
},
|
||||
]);
|
||||
|
||||
const login = (user: any) => {
|
||||
currentUser.value = user;
|
||||
localStorage.setItem('chatUser', JSON.stringify(user));
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
currentUser.value = null;
|
||||
localStorage.removeItem('chatUser');
|
||||
localStorage.removeItem('token');
|
||||
};
|
||||
|
||||
const checkSession = () => {
|
||||
const savedUser = localStorage.getItem('chatUser');
|
||||
if (savedUser) {
|
||||
currentUser.value = JSON.parse(savedUser);
|
||||
}
|
||||
};
|
||||
|
||||
const setClientId = (clientId: number | string) => {
|
||||
currentUser.value.clientId = clientId;
|
||||
};
|
||||
|
||||
return {
|
||||
currentUser,
|
||||
isLoggedIn,
|
||||
presetUsers,
|
||||
login,
|
||||
logout,
|
||||
checkSession,
|
||||
setClientId,
|
||||
};
|
||||
});
|
||||
153
apps/web-antd/src/views/business/chat/utils/db.ts
Normal file
153
apps/web-antd/src/views/business/chat/utils/db.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// IndexedDB 数据库工具类
|
||||
class ChatDB {
|
||||
constructor() {
|
||||
this.dbName = 'ChatApp';
|
||||
this.version = 1;
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
// 清空未读消息
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// 获取聊天记录
|
||||
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 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 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 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 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 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const chatDB = new ChatDB();
|
||||
360
apps/web-antd/src/views/business/chat/utils/request.ts
Normal file
360
apps/web-antd/src/views/business/chat/utils/request.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { message } from 'ant-design-vue';
|
||||
import axios from 'axios';
|
||||
|
||||
// 创建 Axios 实例
|
||||
const service = axios.create({
|
||||
baseURL: '/im-api/',
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('Request error:', error);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
const { code, result, message: msg } = response.data;
|
||||
if (code === 0) {
|
||||
return result;
|
||||
} else {
|
||||
if (code === 401) {
|
||||
message.error(msg);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('chatUser');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
message.error(msg);
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Response error:', error);
|
||||
|
||||
// 详细的错误处理
|
||||
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错误备用方案...');
|
||||
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 });
|
||||
};
|
||||
|
||||
export const post = (url, data = {}) => {
|
||||
return service.post(url, data);
|
||||
};
|
||||
|
||||
export const upload = (url, file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return service.post(url, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 发送普通消息API
|
||||
export const sendMessage = (data) => {
|
||||
const apiUrl = '/im-api/send-to-user';
|
||||
|
||||
// 消息类型映射 - 根据用户提供的完整映射更新
|
||||
const messageTypeMap = {
|
||||
text: 0,
|
||||
image: 1,
|
||||
audio: 2,
|
||||
video: 3,
|
||||
prescription: 4,
|
||||
'medical-record': 5,
|
||||
'video-call': 6,
|
||||
'audio-call': 7,
|
||||
file: 8,
|
||||
};
|
||||
|
||||
const requestData = {
|
||||
room_id: data.roomId,
|
||||
sender_user_id: 'doctor-' + data.senderId.toString(),
|
||||
receiver_user_id: data.receiverId.toString(),
|
||||
message_type: messageTypeMap[data.type] || 0,
|
||||
message_content: data.content || '',
|
||||
// 添加来源标记
|
||||
isFromHttp: true,
|
||||
};
|
||||
|
||||
// 如果包含通话信令,添加call_id和call_status
|
||||
if (data.callId) {
|
||||
requestData.call_id = data.callId;
|
||||
}
|
||||
if (data.callStatus) {
|
||||
requestData.call_status = data.callStatus;
|
||||
}
|
||||
|
||||
return axios
|
||||
.post(apiUrl, requestData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 15_000,
|
||||
withCredentials: false,
|
||||
})
|
||||
.then((response) => {
|
||||
console.log('消息发送成功:', response.data);
|
||||
return response.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('消息发送失败:', error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
// 专门的通话信令发送方法
|
||||
export const sendCallSignal = (signalData) => {
|
||||
const apiUrl = '/api/send-to-user';
|
||||
|
||||
// 验证必要参数
|
||||
if (!signalData.sender_user_id) {
|
||||
throw new Error('缺少发送方用户ID');
|
||||
}
|
||||
if (!signalData.receiver_user_id) {
|
||||
throw new Error('缺少接收方用户ID');
|
||||
}
|
||||
if (!signalData.call_id) {
|
||||
throw new Error('缺少通话ID');
|
||||
}
|
||||
if (!signalData.call_status) {
|
||||
throw new Error('缺少通话状态');
|
||||
}
|
||||
|
||||
// 构建通话信令请求数据
|
||||
const requestData = {
|
||||
sender_user_id: signalData.sender_user_id,
|
||||
receiver_user_id: signalData.receiver_user_id,
|
||||
message_type: signalData.message_type || 6, // 默认视频通话
|
||||
message_content:
|
||||
signalData.message_content || JSON.stringify(signalData.data || {}),
|
||||
call_id: signalData.call_id,
|
||||
call_status: signalData.call_status,
|
||||
// 添加来源标记
|
||||
isFromHttp: true,
|
||||
};
|
||||
|
||||
console.log('发送通话信令到API:', apiUrl, requestData);
|
||||
|
||||
return axios
|
||||
.post(apiUrl, requestData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 15_000,
|
||||
withCredentials: false,
|
||||
})
|
||||
.then((response) => {
|
||||
console.log('通话信令发送成功:', response.data);
|
||||
return response.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('通话信令发送失败:', error);
|
||||
|
||||
// 提供更详细的错误信息
|
||||
if (error.response) {
|
||||
console.error('响应错误:', error.response.data);
|
||||
console.error('响应状态:', error.response.status);
|
||||
console.error('响应头:', error.response.headers);
|
||||
} else if (error.request) {
|
||||
console.error('请求错误:', error.request);
|
||||
} else {
|
||||
console.error('配置错误:', error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
// 通话相关的便捷方法
|
||||
export const callAPI = {
|
||||
// 发起通话邀请
|
||||
invite: (senderUserId, receiverUserId, callId, callType = 6) => {
|
||||
return sendCallSignal({
|
||||
sender_user_id: senderUserId,
|
||||
receiver_user_id: receiverUserId,
|
||||
call_id: callId,
|
||||
call_status: 'invite',
|
||||
message_type: callType, // 6=视频, 7=语音
|
||||
});
|
||||
},
|
||||
|
||||
// 接受通话
|
||||
accept: (senderUserId, receiverUserId, callId, callType = 6) => {
|
||||
return sendCallSignal({
|
||||
sender_user_id: senderUserId,
|
||||
receiver_user_id: receiverUserId,
|
||||
call_id: callId,
|
||||
call_status: 'accepted',
|
||||
message_type: callType,
|
||||
});
|
||||
},
|
||||
|
||||
// 拒绝通话
|
||||
reject: (senderUserId, receiverUserId, callId, callType = 6) => {
|
||||
return sendCallSignal({
|
||||
sender_user_id: senderUserId,
|
||||
receiver_user_id: receiverUserId,
|
||||
call_id: callId,
|
||||
call_status: 'rejected',
|
||||
message_type: callType,
|
||||
});
|
||||
},
|
||||
|
||||
// 结束通话
|
||||
end: (senderUserId, receiverUserId, callId, callType = 6) => {
|
||||
return sendCallSignal({
|
||||
sender_user_id: senderUserId,
|
||||
receiver_user_id: receiverUserId,
|
||||
call_id: callId,
|
||||
call_status: 'ended',
|
||||
message_type: callType,
|
||||
});
|
||||
},
|
||||
|
||||
// 发送WebRTC Offer
|
||||
sendOffer: (
|
||||
senderUserId,
|
||||
receiverUserId,
|
||||
callId,
|
||||
offerData,
|
||||
callType = 6,
|
||||
) => {
|
||||
return sendCallSignal({
|
||||
sender_user_id: senderUserId,
|
||||
receiver_user_id: receiverUserId,
|
||||
call_id: callId,
|
||||
call_status: 'offer',
|
||||
message_type: callType,
|
||||
data: offerData,
|
||||
});
|
||||
},
|
||||
|
||||
// 发送WebRTC Answer
|
||||
sendAnswer: (
|
||||
senderUserId,
|
||||
receiverUserId,
|
||||
callId,
|
||||
answerData,
|
||||
callType = 6,
|
||||
) => {
|
||||
return sendCallSignal({
|
||||
sender_user_id: senderUserId,
|
||||
receiver_user_id: receiverUserId,
|
||||
call_id: callId,
|
||||
call_status: 'answer',
|
||||
message_type: callType,
|
||||
data: answerData,
|
||||
});
|
||||
},
|
||||
|
||||
// 发送ICE候选
|
||||
sendCandidate: (
|
||||
senderUserId,
|
||||
receiverUserId,
|
||||
callId,
|
||||
candidateData,
|
||||
callType = 6,
|
||||
) => {
|
||||
return sendCallSignal({
|
||||
sender_user_id: senderUserId,
|
||||
receiver_user_id: receiverUserId,
|
||||
call_id: callId,
|
||||
call_status: 'candidate',
|
||||
message_type: callType,
|
||||
data: candidateData,
|
||||
});
|
||||
},
|
||||
|
||||
// 发送忙线状态
|
||||
sendBusy: (senderUserId, receiverUserId, callId, callType = 6) => {
|
||||
return sendCallSignal({
|
||||
sender_user_id: senderUserId,
|
||||
receiver_user_id: receiverUserId,
|
||||
call_id: callId,
|
||||
call_status: 'busy',
|
||||
message_type: callType,
|
||||
});
|
||||
},
|
||||
|
||||
// 发送无人接听
|
||||
sendNoAnswer: (senderUserId, receiverUserId, callId, callType = 6) => {
|
||||
return sendCallSignal({
|
||||
sender_user_id: senderUserId,
|
||||
receiver_user_id: receiverUserId,
|
||||
call_id: callId,
|
||||
call_status: 'no-answer',
|
||||
message_type: callType,
|
||||
});
|
||||
},
|
||||
|
||||
// 发送通话失败
|
||||
sendFailed: (senderUserId, receiverUserId, callId, callType = 6) => {
|
||||
return sendCallSignal({
|
||||
sender_user_id: senderUserId,
|
||||
receiver_user_id: receiverUserId,
|
||||
call_id: callId,
|
||||
call_status: 'failed',
|
||||
message_type: callType,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default service;
|
||||
@@ -51,42 +51,42 @@ const [Modal, modalApi] = useVbenModal({
|
||||
console.log(values, 'ssssssssssss');
|
||||
if (values) {
|
||||
isUpdate.value = update;
|
||||
// getPharmacistEditInfoApi(values.id).then((res) => {
|
||||
// formApi.setValues(res);
|
||||
// });
|
||||
// formApi.setValues(values);
|
||||
getPharmacistEditInfoApi(values.id).then((res) => {
|
||||
formApi.setValues(res);
|
||||
});
|
||||
formApi.setValues(values);
|
||||
|
||||
const img = 'https://img2.baidu.com/it/u=3046024791,55863177&fm=253&fmt=auto&app=138&f=JPEG';
|
||||
formApi.setValues({
|
||||
name: '测试药师',
|
||||
mobile: '15110000000',
|
||||
idcard: '134111000022225555',
|
||||
type: 1,
|
||||
sign_type: 2,
|
||||
qualification: img,
|
||||
practicing: img,
|
||||
title: img,
|
||||
card_up: img,
|
||||
card_down: img,
|
||||
sign_image: 'http://demo-1.nailaoyun.cn/p/image/qm1.png',
|
||||
avatar: img,
|
||||
});
|
||||
// const img = 'https://img2.baidu.com/it/u=3046024791,55863177&fm=253&fmt=auto&app=138&f=JPEG';
|
||||
// formApi.setValues({
|
||||
// name: '测试药师',
|
||||
// mobile: '15110000000',
|
||||
// idcard: '134111000022225555',
|
||||
// type: 1,
|
||||
// sign_type: 2,
|
||||
// qualification: img,
|
||||
// practicing: img,
|
||||
// title: img,
|
||||
// card_up: img,
|
||||
// card_down: img,
|
||||
// sign_image: 'http://demo-1.nailaoyun.cn/p/image/qm1.png',
|
||||
// avatar: img,
|
||||
// });
|
||||
} else {
|
||||
const img = 'https://img2.baidu.com/it/u=3046024791,55863177&fm=253&fmt=auto&app=138&f=JPEG';
|
||||
formApi.setValues({
|
||||
name: '测试药师',
|
||||
mobile: '15110000000',
|
||||
idcard: '134111000022225555',
|
||||
type: 1,
|
||||
sign_type: 2,
|
||||
qualification: img,
|
||||
practicing: img,
|
||||
title: img,
|
||||
card_up: img,
|
||||
card_down: img,
|
||||
sign_image: 'http://demo-1.nailaoyun.cn/p/image/qm1.png',
|
||||
avatar: img,
|
||||
});
|
||||
// const img = 'https://img2.baidu.com/it/u=3046024791,55863177&fm=253&fmt=auto&app=138&f=JPEG';
|
||||
// formApi.setValues({
|
||||
// name: '测试药师',
|
||||
// mobile: '15110000000',
|
||||
// idcard: '134111000022225555',
|
||||
// type: 1,
|
||||
// sign_type: 2,
|
||||
// qualification: img,
|
||||
// practicing: img,
|
||||
// title: img,
|
||||
// card_up: img,
|
||||
// card_down: img,
|
||||
// sign_image: 'http://demo-1.nailaoyun.cn/p/image/qm1.png',
|
||||
// avatar: img,
|
||||
// });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@ export default defineConfig(async () => {
|
||||
// target: 'http://api.xiaokang88.com/api/admin/',
|
||||
ws: true,
|
||||
},
|
||||
'/im-api': {
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/im-api/, ''),
|
||||
// mock代理目标地址
|
||||
// target: 'https://api.xiaokang88.com/api/admin/',
|
||||
target: 'http://localhost:12080/api/',
|
||||
// target: 'http://api.xiaokang88.com/api/admin/',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -111,7 +111,9 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@vueup/vue-quill": "^1.2.0",
|
||||
"axios": "^1.10.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"js-base64": "^3.7.7",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
550
pnpm-lock.yaml
generated
550
pnpm-lock.yaml
generated
@@ -12,51 +12,387 @@ catalogs:
|
||||
'@changesets/cli':
|
||||
specifier: ^2.27.11
|
||||
version: 2.27.11
|
||||
'@changesets/git':
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
'@clack/prompts':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0
|
||||
'@commitlint/cli':
|
||||
specifier: ^19.6.1
|
||||
version: 19.6.1
|
||||
'@commitlint/config-conventional':
|
||||
specifier: ^19.6.0
|
||||
version: 19.6.0
|
||||
'@eslint/js':
|
||||
specifier: ^9.17.0
|
||||
version: 9.17.0
|
||||
'@iconify/json':
|
||||
specifier: ^2.2.286
|
||||
version: 2.2.286
|
||||
'@iconify/tailwind':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
'@iconify/vue':
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
'@intlify/core-base':
|
||||
specifier: ^10.0.5
|
||||
version: 10.0.5
|
||||
'@intlify/unplugin-vue-i18n':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
'@jspm/generator':
|
||||
specifier: ^2.4.1
|
||||
version: 2.4.1
|
||||
'@manypkg/get-packages':
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2
|
||||
'@playwright/test':
|
||||
specifier: ^1.49.1
|
||||
version: 1.49.1
|
||||
'@pnpm/workspace.read-manifest':
|
||||
specifier: ^1000.0.1
|
||||
version: 1000.0.1
|
||||
'@stylistic/stylelint-plugin':
|
||||
specifier: ^3.1.1
|
||||
version: 3.1.1
|
||||
'@tailwindcss/nesting':
|
||||
specifier: 0.0.0-insiders.565cd3e
|
||||
version: 0.0.0-insiders.565cd3e
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.15
|
||||
version: 0.5.15
|
||||
'@tanstack/vue-query':
|
||||
specifier: ^5.62.8
|
||||
version: 5.62.8
|
||||
'@tanstack/vue-store':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
'@types/archiver':
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
'@types/eslint':
|
||||
specifier: ^9.6.1
|
||||
version: 9.6.1
|
||||
'@types/html-minifier-terser':
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
'@types/lodash.clonedeep':
|
||||
specifier: ^4.5.9
|
||||
version: 4.5.9
|
||||
'@types/lodash.get':
|
||||
specifier: ^4.4.9
|
||||
version: 4.4.9
|
||||
'@types/lodash.isequal':
|
||||
specifier: ^4.5.8
|
||||
version: 4.5.8
|
||||
'@types/node':
|
||||
specifier: ^22.10.2
|
||||
version: 22.10.2
|
||||
'@types/nprogress':
|
||||
specifier: ^0.2.3
|
||||
version: 0.2.3
|
||||
'@types/postcss-import':
|
||||
specifier: ^14.0.3
|
||||
version: 14.0.3
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.5
|
||||
version: 1.5.5
|
||||
'@types/sortablejs':
|
||||
specifier: ^1.15.8
|
||||
version: 1.15.8
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.14.7
|
||||
version: 4.14.7
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
'@vitejs/plugin-vue-jsx':
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
'@vue/shared':
|
||||
specifier: ^3.5.13
|
||||
version: 3.5.13
|
||||
'@vue/test-utils':
|
||||
specifier: ^2.4.6
|
||||
version: 2.4.6
|
||||
'@vueuse/core':
|
||||
specifier: ^12.0.0
|
||||
version: 12.0.0
|
||||
'@vueuse/integrations':
|
||||
specifier: ^12.0.0
|
||||
version: 12.0.0
|
||||
ant-design-vue:
|
||||
specifier: ^4.2.6
|
||||
version: 4.2.6
|
||||
archiver:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1
|
||||
autoprefixer:
|
||||
specifier: ^10.4.20
|
||||
version: 10.4.20
|
||||
axios:
|
||||
specifier: ^1.7.9
|
||||
version: 1.7.9
|
||||
axios-mock-adapter:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
cac:
|
||||
specifier: ^6.7.14
|
||||
version: 6.7.14
|
||||
chalk:
|
||||
specifier: ^5.4.0
|
||||
version: 5.4.0
|
||||
cheerio:
|
||||
specifier: 1.0.0
|
||||
version: 1.0.0
|
||||
circular-dependency-scanner:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
commitlint-plugin-function-rules:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
consola:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
cross-env:
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3
|
||||
cspell:
|
||||
specifier: ^8.17.1
|
||||
version: 8.17.1
|
||||
cssnano:
|
||||
specifier: ^7.0.6
|
||||
version: 7.0.6
|
||||
cz-git:
|
||||
specifier: ^1.11.0
|
||||
version: 1.11.0
|
||||
czg:
|
||||
specifier: ^1.11.0
|
||||
version: 1.11.0
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
defu:
|
||||
specifier: ^6.1.4
|
||||
version: 6.1.4
|
||||
depcheck:
|
||||
specifier: ^1.4.7
|
||||
version: 1.4.7
|
||||
dotenv:
|
||||
specifier: ^16.4.7
|
||||
version: 16.4.7
|
||||
echarts:
|
||||
specifier: ^5.5.1
|
||||
version: 5.5.1
|
||||
eslint:
|
||||
specifier: ^9.17.0
|
||||
version: 9.17.0
|
||||
eslint-config-turbo:
|
||||
specifier: ^2.3.3
|
||||
version: 2.3.3
|
||||
eslint-plugin-command:
|
||||
specifier: ^0.2.7
|
||||
version: 0.2.7
|
||||
eslint-plugin-eslint-comments:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
eslint-plugin-import-x:
|
||||
specifier: ^4.6.1
|
||||
version: 4.6.1
|
||||
eslint-plugin-jsdoc:
|
||||
specifier: ^50.6.1
|
||||
version: 50.6.1
|
||||
eslint-plugin-jsonc:
|
||||
specifier: ^2.18.2
|
||||
version: 2.18.2
|
||||
eslint-plugin-n:
|
||||
specifier: ^17.15.1
|
||||
version: 17.15.1
|
||||
eslint-plugin-no-only-tests:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
eslint-plugin-perfectionist:
|
||||
specifier: ^3.9.1
|
||||
version: 3.9.1
|
||||
eslint-plugin-prettier:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
eslint-plugin-regexp:
|
||||
specifier: ^2.7.0
|
||||
version: 2.7.0
|
||||
eslint-plugin-unicorn:
|
||||
specifier: ^56.0.1
|
||||
version: 56.0.1
|
||||
eslint-plugin-unused-imports:
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.4
|
||||
eslint-plugin-vitest:
|
||||
specifier: ^0.5.4
|
||||
version: 0.5.4
|
||||
eslint-plugin-vue:
|
||||
specifier: ^9.32.0
|
||||
version: 9.32.0
|
||||
execa:
|
||||
specifier: ^9.5.2
|
||||
version: 9.5.2
|
||||
find-up:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
get-port:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
globals:
|
||||
specifier: ^15.14.0
|
||||
version: 15.14.0
|
||||
happy-dom:
|
||||
specifier: ^15.11.7
|
||||
version: 15.11.7
|
||||
html-minifier-terser:
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
husky:
|
||||
specifier: ^9.1.7
|
||||
version: 9.1.7
|
||||
is-ci:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
jsonc-eslint-parser:
|
||||
specifier: ^2.4.0
|
||||
version: 2.4.0
|
||||
lint-staged:
|
||||
specifier: ^15.2.11
|
||||
version: 15.2.11
|
||||
lodash.clonedeep:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0
|
||||
lodash.get:
|
||||
specifier: ^4.4.2
|
||||
version: 4.4.2
|
||||
lodash.isequal:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0
|
||||
lucide-vue-next:
|
||||
specifier: ^0.469.0
|
||||
version: 0.469.0
|
||||
nitropack:
|
||||
specifier: ^2.10.4
|
||||
version: 2.10.4
|
||||
nprogress:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
ora:
|
||||
specifier: ^8.1.1
|
||||
version: 8.1.1
|
||||
pinia-plugin-persistedstate:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
pkg-types:
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
playwright:
|
||||
specifier: ^1.49.1
|
||||
version: 1.49.1
|
||||
postcss:
|
||||
specifier: ^8.4.49
|
||||
version: 8.4.49
|
||||
postcss-antd-fixes:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
postcss-html:
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.0
|
||||
postcss-import:
|
||||
specifier: ^16.1.0
|
||||
version: 16.1.0
|
||||
postcss-preset-env:
|
||||
specifier: ^10.1.2
|
||||
version: 10.1.2
|
||||
postcss-scss:
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.9
|
||||
prettier:
|
||||
specifier: ^3.4.2
|
||||
version: 3.4.2
|
||||
prettier-plugin-tailwindcss:
|
||||
specifier: ^0.6.9
|
||||
version: 0.6.9
|
||||
publint:
|
||||
specifier: ^0.2.12
|
||||
version: 0.2.12
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
radix-vue:
|
||||
specifier: ^1.9.11
|
||||
version: 1.9.11
|
||||
resolve.exports:
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3
|
||||
rimraf:
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
rollup:
|
||||
specifier: ^4.28.1
|
||||
version: 4.28.1
|
||||
rollup-plugin-visualizer:
|
||||
specifier: ^5.12.0
|
||||
version: 5.12.0
|
||||
sass:
|
||||
specifier: 1.80.6
|
||||
version: 1.80.6
|
||||
sortablejs:
|
||||
specifier: ^1.15.6
|
||||
version: 1.15.6
|
||||
stylelint:
|
||||
specifier: ^16.12.0
|
||||
version: 16.12.0
|
||||
stylelint-config-recess-order:
|
||||
specifier: ^5.1.1
|
||||
version: 5.1.1
|
||||
stylelint-config-recommended:
|
||||
specifier: ^14.0.1
|
||||
version: 14.0.1
|
||||
stylelint-config-recommended-scss:
|
||||
specifier: ^14.1.0
|
||||
version: 14.1.0
|
||||
stylelint-config-recommended-vue:
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.0
|
||||
stylelint-config-standard:
|
||||
specifier: ^36.0.1
|
||||
version: 36.0.1
|
||||
stylelint-order:
|
||||
specifier: ^6.0.4
|
||||
version: 6.0.4
|
||||
stylelint-prettier:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
stylelint-scss:
|
||||
specifier: ^6.10.0
|
||||
version: 6.10.0
|
||||
tailwind-merge:
|
||||
specifier: ^2.5.5
|
||||
version: 2.5.5
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.17
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7
|
||||
theme-colors:
|
||||
specifier: ^0.1.0
|
||||
version: 0.1.0
|
||||
turbo:
|
||||
specifier: ^2.3.3
|
||||
version: 2.3.3
|
||||
@@ -66,15 +402,60 @@ catalogs:
|
||||
unbuild:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
vee-validate:
|
||||
specifier: ^4.14.7
|
||||
version: 4.14.7
|
||||
vite:
|
||||
specifier: ^6.0.5
|
||||
version: 6.0.5
|
||||
vite-plugin-compression:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
vite-plugin-dts:
|
||||
specifier: 4.2.1
|
||||
version: 4.2.1
|
||||
vite-plugin-html:
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2
|
||||
vite-plugin-lazy-import:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7
|
||||
vite-plugin-pwa:
|
||||
specifier: ^0.21.1
|
||||
version: 0.21.1
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: ^7.6.8
|
||||
version: 7.6.8
|
||||
vitest:
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.8
|
||||
vue-eslint-parser:
|
||||
specifier: ^9.4.3
|
||||
version: 9.4.3
|
||||
vue-i18n:
|
||||
specifier: ^10.0.5
|
||||
version: 10.0.5
|
||||
vue-router:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0
|
||||
vue-tsc:
|
||||
specifier: ^2.1.10
|
||||
version: 2.1.10
|
||||
vxe-pc-ui:
|
||||
specifier: ^4.3.40
|
||||
version: 4.3.40
|
||||
vxe-table:
|
||||
specifier: ^4.9.33
|
||||
version: 4.9.33
|
||||
watermark-js-plus:
|
||||
specifier: ^1.5.7
|
||||
version: 1.5.7
|
||||
zod:
|
||||
specifier: ^3.24.1
|
||||
version: 3.24.1
|
||||
zod-defaults:
|
||||
specifier: ^0.1.3
|
||||
version: 0.1.3
|
||||
|
||||
overrides:
|
||||
'@ast-grep/napi': ^0.31.1
|
||||
@@ -88,9 +469,15 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@fortawesome/fontawesome-free':
|
||||
specifier: ^6.7.2
|
||||
version: 6.7.2
|
||||
'@vueup/vue-quill':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(vue@3.5.13(typescript@5.6.3))
|
||||
axios:
|
||||
specifier: ^1.10.0
|
||||
version: 1.10.0
|
||||
html2canvas:
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.1
|
||||
@@ -196,10 +583,10 @@ importers:
|
||||
version: 3.0.1(typescript@5.6.3)(vue-tsc@2.1.10(typescript@5.6.3))(vue@3.5.13(typescript@5.6.3))
|
||||
vite:
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1)
|
||||
version: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1)
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.8(@types/node@22.10.2)(happy-dom@15.11.7)(less@4.2.1)(terser@5.37.0)
|
||||
version: 2.1.8(@types/node@22.10.2)(happy-dom@15.11.7)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)
|
||||
vue:
|
||||
specifier: ^3.5.13
|
||||
version: 3.5.13(typescript@5.6.3)
|
||||
@@ -930,7 +1317,7 @@ importers:
|
||||
version: 12.0.0(typescript@5.7.2)
|
||||
'@vueuse/integrations':
|
||||
specifier: 'catalog:'
|
||||
version: 12.0.0(async-validator@4.2.5)(axios@1.7.9)(change-case@5.4.4)(focus-trap@7.6.2)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.7.2)
|
||||
version: 12.0.0(async-validator@4.2.5)(axios@1.10.0)(change-case@5.4.4)(focus-trap@7.6.2)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.7.2)
|
||||
qrcode:
|
||||
specifier: 'catalog:'
|
||||
version: 1.5.4
|
||||
@@ -2804,6 +3191,10 @@ packages:
|
||||
'@floating-ui/vue@1.1.5':
|
||||
resolution: {integrity: sha512-ynL1p5Z+woPVSwgMGqeDrx6HrJfGIDzFyESFkyqJKilGW1+h/8yVY29Khn0LaU6wHBRwZ13ntG6reiHWK6jyzw==}
|
||||
|
||||
'@fortawesome/fontawesome-free@6.7.2':
|
||||
resolution: {integrity: sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
'@gar/promisify@1.1.3':
|
||||
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
|
||||
|
||||
@@ -2867,20 +3258,20 @@ packages:
|
||||
resolution: {integrity: sha512-6GT1BJ852gZ0gItNZN2krX5QAmea+cmdjMvsWohArAZ3GmHdnNANEcF9JjPXAMRtQ6Ux5E269ymamg/+WU6tQA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/message-compiler@12.0.0-alpha.2':
|
||||
resolution: {integrity: sha512-PD9C+oQbb7BF52hec0+vLnScaFkvnfX+R7zSbODYuRo/E2niAtGmHd0wPvEMsDhf9Z9b8f/qyDsVeZnD/ya9Ug==}
|
||||
'@intlify/message-compiler@12.0.0-alpha.3':
|
||||
resolution: {integrity: sha512-mDDTN3gfYOHhBnpnlby19UHyvMaOnzdlpsIrxUfs44R/vCATfn8pMOkE8PXD2t410xkocEj3FpDcC9XC/0v4Dg==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/shared@10.0.5':
|
||||
resolution: {integrity: sha512-bmsP4L2HqBF6i6uaMqJMcFBONVjKt+siGluRq4Ca4C0q7W2eMaVZr8iCgF9dKbcVXutftkC7D6z2SaSMmLiDyA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/shared@11.1.3':
|
||||
resolution: {integrity: sha512-pTFBgqa/99JRA2H1qfyqv97MKWJrYngXBA/I0elZcYxvJgcCw3mApAoPW3mJ7vx3j+Ti0FyKUFZ4hWxdjKaxvA==}
|
||||
'@intlify/shared@11.1.10':
|
||||
resolution: {integrity: sha512-6ZW/f3Zzjxfa1Wh0tYQI5pLKUtU+SY7l70pEG+0yd0zjcsYcK0EBt6Fz30Dy0tZhEqemziQQy2aNU3GJzyrMUA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/shared@12.0.0-alpha.2':
|
||||
resolution: {integrity: sha512-P2DULVX9nz3y8zKNqLw9Es1aAgQ1JGC+kgpx5q7yLmrnAKkPR5MybQWoEhxanefNJgUY5ehsgo+GKif59SrncA==}
|
||||
'@intlify/shared@12.0.0-alpha.3':
|
||||
resolution: {integrity: sha512-ryaNYBvxQjyJUmVuBBg+HHUsmGnfxcEUPR0NCeG4/K9N2qtyFE35C80S15IN6iYFE2MGWLN7HfOSyg0MXZIc9w==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/unplugin-vue-i18n@6.0.1':
|
||||
@@ -4137,6 +4528,9 @@ packages:
|
||||
peerDependencies:
|
||||
axios: '>= 0.17.0'
|
||||
|
||||
axios@1.10.0:
|
||||
resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==}
|
||||
|
||||
axios@1.7.9:
|
||||
resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==}
|
||||
|
||||
@@ -9505,7 +9899,7 @@ snapshots:
|
||||
'@babel/traverse': 7.26.4
|
||||
'@babel/types': 7.26.3
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.0
|
||||
debug: 4.4.0(supports-color@9.4.0)
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@@ -10166,7 +10560,7 @@ snapshots:
|
||||
'@babel/parser': 7.26.3
|
||||
'@babel/template': 7.25.9
|
||||
'@babel/types': 7.26.3
|
||||
debug: 4.4.0
|
||||
debug: 4.4.0(supports-color@9.4.0)
|
||||
globals: 11.12.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -11302,6 +11696,8 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@fortawesome/fontawesome-free@6.7.2': {}
|
||||
|
||||
'@gar/promisify@1.1.3': {}
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
@@ -11343,8 +11739,8 @@ snapshots:
|
||||
|
||||
'@intlify/bundle-utils@10.0.0(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))':
|
||||
dependencies:
|
||||
'@intlify/message-compiler': 12.0.0-alpha.2
|
||||
'@intlify/shared': 12.0.0-alpha.2
|
||||
'@intlify/message-compiler': 12.0.0-alpha.3
|
||||
'@intlify/shared': 12.0.0-alpha.3
|
||||
acorn: 8.14.0
|
||||
escodegen: 2.1.0
|
||||
estree-walker: 2.0.2
|
||||
@@ -11365,23 +11761,23 @@ snapshots:
|
||||
'@intlify/shared': 10.0.5
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@intlify/message-compiler@12.0.0-alpha.2':
|
||||
'@intlify/message-compiler@12.0.0-alpha.3':
|
||||
dependencies:
|
||||
'@intlify/shared': 12.0.0-alpha.2
|
||||
'@intlify/shared': 12.0.0-alpha.3
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@intlify/shared@10.0.5': {}
|
||||
|
||||
'@intlify/shared@11.1.3': {}
|
||||
'@intlify/shared@11.1.10': {}
|
||||
|
||||
'@intlify/shared@12.0.0-alpha.2': {}
|
||||
'@intlify/shared@12.0.0-alpha.3': {}
|
||||
|
||||
'@intlify/unplugin-vue-i18n@6.0.1(@vue/compiler-dom@3.5.13)(eslint@9.17.0(jiti@2.4.2))(rollup@4.28.1)(typescript@5.7.2)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@2.4.2))
|
||||
'@intlify/bundle-utils': 10.0.0(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))
|
||||
'@intlify/shared': 11.1.3
|
||||
'@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@11.1.3)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))
|
||||
'@intlify/shared': 11.1.10
|
||||
'@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@11.1.10)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))
|
||||
'@rollup/pluginutils': 5.1.4(rollup@4.28.1)
|
||||
'@typescript-eslint/scope-manager': 8.18.1
|
||||
'@typescript-eslint/typescript-estree': 8.18.1(typescript@5.7.2)
|
||||
@@ -11403,11 +11799,11 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@intlify/vue-i18n-extensions@7.0.0(@intlify/shared@11.1.3)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))':
|
||||
'@intlify/vue-i18n-extensions@7.0.0(@intlify/shared@11.1.10)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))':
|
||||
dependencies:
|
||||
'@babel/parser': 7.26.3
|
||||
optionalDependencies:
|
||||
'@intlify/shared': 11.1.3
|
||||
'@intlify/shared': 11.1.10
|
||||
'@vue/compiler-dom': 3.5.13
|
||||
vue: 3.5.13(typescript@5.7.2)
|
||||
vue-i18n: 10.0.5(vue@3.5.13(typescript@5.7.2))
|
||||
@@ -12391,7 +12787,7 @@ snapshots:
|
||||
'@babel/core': 7.26.0
|
||||
'@babel/plugin-transform-typescript': 7.26.3(@babel/core@7.26.0)
|
||||
'@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.26.0)
|
||||
vite: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1)
|
||||
vite: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1)
|
||||
vue: 3.5.13(typescript@5.6.3)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -12403,7 +12799,7 @@ snapshots:
|
||||
|
||||
'@vitejs/plugin-vue@5.2.1(vite@6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1))(vue@3.5.13(typescript@5.6.3))':
|
||||
dependencies:
|
||||
vite: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1)
|
||||
vite: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1)
|
||||
vue: 3.5.13(typescript@5.6.3)
|
||||
|
||||
'@vitest/expect@2.1.8':
|
||||
@@ -12420,15 +12816,6 @@ snapshots:
|
||||
magic-string: 0.30.17
|
||||
optionalDependencies:
|
||||
vite: 5.4.11(@types/node@22.10.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)
|
||||
optional: true
|
||||
|
||||
'@vitest/mocker@2.1.8(vite@5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.8
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.17
|
||||
optionalDependencies:
|
||||
vite: 5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0)
|
||||
|
||||
'@vitest/pretty-format@2.1.8':
|
||||
dependencies:
|
||||
@@ -12650,14 +13037,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/integrations@12.0.0(async-validator@4.2.5)(axios@1.7.9)(change-case@5.4.4)(focus-trap@7.6.2)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.7.2)':
|
||||
'@vueuse/integrations@12.0.0(async-validator@4.2.5)(axios@1.10.0)(change-case@5.4.4)(focus-trap@7.6.2)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@vueuse/core': 12.0.0(typescript@5.7.2)
|
||||
'@vueuse/shared': 12.0.0(typescript@5.7.2)
|
||||
vue: 3.5.13(typescript@5.7.2)
|
||||
optionalDependencies:
|
||||
async-validator: 4.2.5
|
||||
axios: 1.7.9
|
||||
axios: 1.10.0
|
||||
change-case: 5.4.4
|
||||
focus-trap: 7.6.2
|
||||
nprogress: 0.2.0
|
||||
@@ -12918,6 +13305,14 @@ snapshots:
|
||||
fast-deep-equal: 3.1.3
|
||||
is-buffer: 2.0.5
|
||||
|
||||
axios@1.10.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.9
|
||||
form-data: 4.0.1
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
axios@1.7.9:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.9
|
||||
@@ -13694,10 +14089,6 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.0:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.0(supports-color@9.4.0):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -15375,7 +15766,7 @@ snapshots:
|
||||
dependencies:
|
||||
chalk: 5.3.0
|
||||
commander: 12.1.0
|
||||
debug: 4.4.0
|
||||
debug: 4.4.0(supports-color@9.4.0)
|
||||
execa: 8.0.1
|
||||
lilconfig: 3.1.3
|
||||
listr2: 8.2.5
|
||||
@@ -18124,25 +18515,6 @@ snapshots:
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
optional: true
|
||||
|
||||
vite-node@2.1.8(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.0
|
||||
es-module-lexer: 1.5.4
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vite-plugin-compression@0.5.1(vite@6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1)):
|
||||
dependencies:
|
||||
@@ -18264,18 +18636,6 @@ snapshots:
|
||||
less: 4.2.1
|
||||
sass: 1.80.6
|
||||
terser: 5.37.0
|
||||
optional: true
|
||||
|
||||
vite@5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0):
|
||||
dependencies:
|
||||
esbuild: 0.24.0
|
||||
postcss: 8.4.49
|
||||
rollup: 4.28.1
|
||||
optionalDependencies:
|
||||
'@types/node': 22.10.2
|
||||
fsevents: 2.3.3
|
||||
less: 4.2.1
|
||||
terser: 5.37.0
|
||||
|
||||
vite@6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1):
|
||||
dependencies:
|
||||
@@ -18291,19 +18651,6 @@ snapshots:
|
||||
terser: 5.37.0
|
||||
yaml: 2.6.1
|
||||
|
||||
vite@6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1):
|
||||
dependencies:
|
||||
esbuild: 0.24.0
|
||||
postcss: 8.4.49
|
||||
rollup: 4.28.1
|
||||
optionalDependencies:
|
||||
'@types/node': 22.10.2
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.4.2
|
||||
less: 4.2.1
|
||||
terser: 5.37.0
|
||||
yaml: 2.6.1
|
||||
|
||||
vitest@2.1.8(@types/node@22.10.2)(happy-dom@15.11.7)(less@4.2.1)(sass@1.80.6)(terser@5.37.0):
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.8
|
||||
@@ -18339,43 +18686,6 @@ snapshots:
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
optional: true
|
||||
|
||||
vitest@2.1.8(@types/node@22.10.2)(happy-dom@15.11.7)(less@4.2.1)(terser@5.37.0):
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.8
|
||||
'@vitest/mocker': 2.1.8(vite@5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0))
|
||||
'@vitest/pretty-format': 2.1.8
|
||||
'@vitest/runner': 2.1.8
|
||||
'@vitest/snapshot': 2.1.8
|
||||
'@vitest/spy': 2.1.8
|
||||
'@vitest/utils': 2.1.8
|
||||
chai: 5.1.2
|
||||
debug: 4.4.0
|
||||
expect-type: 1.1.0
|
||||
magic-string: 0.30.17
|
||||
pathe: 1.1.2
|
||||
std-env: 3.8.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.1
|
||||
tinypool: 1.0.2
|
||||
tinyrainbow: 1.2.0
|
||||
vite: 5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0)
|
||||
vite-node: 2.1.8(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.10.2
|
||||
happy-dom: 15.11.7
|
||||
transitivePeerDependencies:
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vscode-languageserver-textdocument@1.0.12: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user