feat: add floating message box window

This commit is contained in:
Tim
2025-08-25 17:18:34 +08:00
parent 0ee58df868
commit df71cf901b
4 changed files with 146 additions and 16 deletions

View File

@@ -1,6 +1,6 @@
<template>
<div id="app">
<div class="header-container">
<div v-if="!isFloatMode" class="header-container">
<HeaderComponent
ref="header"
@toggle-menu="menuVisible = !menuVisible"
@@ -9,19 +9,28 @@
</div>
<div class="main-container">
<div class="menu-container" v-click-outside="handleMenuOutside">
<div v-if="!isFloatMode" class="menu-container" v-click-outside="handleMenuOutside">
<MenuComponent :visible="!hideMenu && menuVisible" @item-click="menuVisible = false" />
</div>
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
<div
class="content"
:class="{ 'menu-open': menuVisible && !hideMenu && !isFloatMode }"
:style="isFloatMode ? { paddingTop: '0px', minHeight: '100vh' } : {}"
>
<NuxtPage keepalive />
</div>
<div v-if="showNewPostIcon && isMobile" class="app-new-post-icon" @click="goToNewPost">
<div
v-if="showNewPostIcon && isMobile && !isFloatMode"
class="app-new-post-icon"
@click="goToNewPost"
>
<i class="fas fa-edit"></i>
</div>
</div>
<GlobalPopups />
<ConfirmDialog />
<MessageFloatWindow v-if="!isFloatMode" />
</div>
</template>
@@ -30,6 +39,7 @@ import HeaderComponent from '~/components/HeaderComponent.vue'
import MenuComponent from '~/components/MenuComponent.vue'
import GlobalPopups from '~/components/GlobalPopups.vue'
import ConfirmDialog from '~/components/ConfirmDialog.vue'
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
import { useIsMobile } from '~/utils/screen'
const isMobile = useIsMobile()
@@ -52,6 +62,7 @@ const hideMenu = computed(() => {
})
const header = useTemplateRef('header')
const isFloatMode = computed(() => useRoute().query.float !== undefined)
onMounted(() => {
if (typeof window !== 'undefined') {

View File

@@ -0,0 +1,65 @@
<template>
<div v-if="floatRoute" class="message-float-window">
<iframe :src="iframeSrc" frameborder="0"></iframe>
<div class="float-actions">
<i class="fas fa-expand" @click="expand"></i>
</div>
</div>
</template>
<script setup>
const floatRoute = useState('messageFloatRoute')
const iframeSrc = computed(() => {
if (!floatRoute.value) return ''
return floatRoute.value + (floatRoute.value.includes('?') ? '&' : '?') + 'float=1'
})
function expand() {
if (!floatRoute.value) return
const target = floatRoute.value
floatRoute.value = null
navigateTo(target)
}
</script>
<style scoped>
.message-float-window {
position: fixed;
bottom: 0;
right: 0;
width: 400px;
height: 60vh;
max-height: 90vh;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
z-index: 2000;
display: flex;
flex-direction: column;
}
.message-float-window iframe {
width: 100%;
flex: 1;
}
.float-actions {
position: absolute;
top: 4px;
right: 8px;
}
.float-actions i {
cursor: pointer;
}
@media (max-width: 480px) {
.message-float-window {
width: 100%;
right: 0;
left: 0;
height: 100vh;
}
}
</style>

View File

@@ -1,12 +1,17 @@
<template>
<div class="chat-container">
<div class="chat-container" :class="{ float: isFloatMode }">
<div v-if="!loading" class="chat-header">
<NuxtLink to="/message-box" class="back-button">
<i class="fas fa-arrow-left"></i>
</NuxtLink>
<h2 class="participant-name">
{{ isChannel ? conversationName : otherParticipant?.username }}
</h2>
<div class="header-main">
<NuxtLink to="/message-box" class="back-button">
<i class="fas fa-arrow-left"></i>
</NuxtLink>
<h2 class="participant-name">
{{ isChannel ? conversationName : otherParticipant?.username }}
</h2>
</div>
<div v-if="!isFloatMode" class="header-actions" @click="minimize">
<i class="fas fa-window-minimize"></i>
</div>
</div>
<div class="messages-list" ref="messagesListEl">
@@ -97,6 +102,8 @@ const loadingMore = ref(false)
let scrollInterval = null
const conversationName = ref('')
const isChannel = ref(false)
const isFloatMode = computed(() => route.query.float !== undefined)
const floatRoute = useState('messageFloatRoute')
const hasMoreMessages = computed(() => currentPage.value < totalPages.value - 1)
@@ -156,7 +163,7 @@ async function fetchMessages(page = 0) {
...item,
src: item.sender.avatar,
iconClick: () => {
navigateTo(`/users/${item.sender.id}`, { replace: true })
openUser(item.sender.id)
},
}))
@@ -236,7 +243,7 @@ async function sendMessage(content, clearInput) {
...newMessage,
src: newMessage.sender.avatar,
iconClick: () => {
navigateTo(`/users/${newMessage.sender.id}`, { replace: true })
openUser(newMessage.sender.id)
},
})
clearInput()
@@ -322,7 +329,7 @@ watch(isConnected, (newValue) => {
...message,
src: message.sender.avatar,
iconClick: () => {
navigateTo(`/users/${message.sender.id}`, { replace: true })
openUser(message.sender.id)
},
})
// 实时收到消息时自动标记为已读
@@ -376,6 +383,19 @@ onUnmounted(() => {
}
disconnect()
})
function minimize() {
floatRoute.value = route.fullPath
navigateTo('/')
}
function openUser(id) {
if (isFloatMode.value && typeof window !== 'undefined') {
window.top.location.href = `/users/${id}`
} else {
navigateTo(`/users/${id}`, { replace: true })
}
}
</script>
<style scoped>
@@ -388,8 +408,13 @@ onUnmounted(() => {
position: relative;
}
.chat-container.float {
height: 100vh;
}
.chat-header {
display: flex;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
@@ -400,6 +425,15 @@ onUnmounted(() => {
backdrop-filter: var(--blur-10);
}
.header-main {
display: flex;
align-items: center;
}
.header-actions i {
cursor: pointer;
}
.back-button {
font-size: 18px;
color: var(--text-color-primary);

View File

@@ -1,5 +1,8 @@
<template>
<div class="messages-container">
<div v-if="!isFloatMode" class="float-control">
<i class="fas fa-window-minimize" @click="minimize"></i>
</div>
<div class="tabs">
<div :class="['tab', { active: activeTab === 'messages' }]" @click="activeTab = 'messages'">
站内信
@@ -114,8 +117,8 @@
</template>
<script setup>
import { ref, onUnmounted, watch, onActivated } from 'vue'
import { useRouter } from 'vue-router'
import { ref, onUnmounted, watch, onActivated, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { getToken, fetchCurrentUser } from '~/utils/auth'
import { toast } from '~/main'
import { useWebSocket } from '~/composables/useWebSocket'
@@ -142,6 +145,9 @@ let subscription = null
const activeTab = ref('messages')
const channels = ref([])
const loadingChannels = ref(false)
const route = useRoute()
const isFloatMode = computed(() => route.query.float !== undefined)
const floatRoute = useState('messageFloatRoute')
async function fetchConversations() {
const token = getToken()
@@ -274,12 +280,26 @@ onUnmounted(() => {
function goToConversation(id) {
router.push(`/message-box/${id}`)
}
function minimize() {
floatRoute.value = route.fullPath
navigateTo('/')
}
</script>
<style scoped>
.messages-container {
}
.float-control {
text-align: right;
padding: 8px 12px;
}
.float-control i {
cursor: pointer;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--normal-border-color);