云文件系统初始化
This commit is contained in:
727
web-vue/src/components/ChatDialog.vue
Normal file
727
web-vue/src/components/ChatDialog.vue
Normal file
@@ -0,0 +1,727 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="消息"
|
||||
width="60%"
|
||||
:close-on-click-modal="false"
|
||||
class="chat-dialog custom-dialog"
|
||||
@opened="onDialogOpen"
|
||||
@closed="onDialogClose"
|
||||
>
|
||||
<div class="chat-container">
|
||||
<!-- 左侧:通讯录 + 最近聊天 -->
|
||||
<div class="chat-sidebar">
|
||||
<div class="sidebar-tabs">
|
||||
<div class="sidebar-tab" :class="{ active: sidebarTab === 'recent' }" @click="sidebarTab = 'recent'">
|
||||
<el-icon><ChatLineRound /></el-icon>
|
||||
<span>最近</span>
|
||||
</div>
|
||||
<div class="sidebar-tab" :class="{ active: sidebarTab === 'contacts' }" @click="sidebarTab = 'contacts'">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>通讯录</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-search">
|
||||
<el-input v-model="searchKeyword" placeholder="搜索用户" clearable>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 最近聊天列表 -->
|
||||
<div v-if="sidebarTab === 'recent'" class="sidebar-list">
|
||||
<div v-for="chat in recentChats" :key="chat.id" class="sidebar-item" :class="{ active: currentContact?.id === chat.id }" @click="selectContact(chat)">
|
||||
<el-badge :value="chat.unread || 0" :hidden="!chat.unread" :max="99">
|
||||
<el-avatar :size="36" :src="chat.avatar || undefined" :style="{ background: chat.avatar ? 'transparent' : chat.color }">
|
||||
{{ chat.name?.[0] || '?' }}
|
||||
</el-avatar>
|
||||
</el-badge>
|
||||
<div class="sidebar-item-info">
|
||||
<div class="sidebar-item-name">{{ chat.name }}</div>
|
||||
<div class="sidebar-item-msg">{{ chat.lastMsg || '暂无消息' }}</div>
|
||||
</div>
|
||||
<el-button v-if="chat.lastMsg" class="delete-btn" link type="danger" @click.stop="deleteRecentChat(chat)">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="recentChats.length === 0" class="sidebar-empty">暂无最近聊天</div>
|
||||
</div>
|
||||
|
||||
<!-- 通讯录列表 -->
|
||||
<div v-else class="sidebar-list">
|
||||
<div v-for="contact in filteredContacts" :key="contact.id" class="sidebar-item" :class="{ active: currentContact?.id === contact.id }" @click="selectContact(contact)">
|
||||
<el-avatar :size="36" :src="contact.avatar || undefined" :style="{ background: contact.avatar ? 'transparent' : contact.color }">
|
||||
{{ contact.name?.[0] || '?' }}
|
||||
</el-avatar>
|
||||
<div class="sidebar-item-info">
|
||||
<div class="sidebar-item-name">{{ contact.nickname || contact.username }}</div>
|
||||
<div class="sidebar-item-status" :class="{ online: contact.online }">
|
||||
{{ contact.online ? '在线' : '离线' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredContacts.length === 0" class="sidebar-empty">暂无联系人</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧聊天区域 -->
|
||||
<div class="chat-main" v-if="currentContact">
|
||||
<div class="chat-header">
|
||||
<el-avatar :size="32" :src="currentContact.avatar || undefined" :style="{ background: currentContact.avatar ? 'transparent' : currentContact.color }">
|
||||
{{ currentContact.name?.[0] || currentContact.username?.[0] || '?' }}
|
||||
</el-avatar>
|
||||
<span class="chat-title">{{ currentContact.nickname || currentContact.username || currentContact.name }}</span>
|
||||
<span class="chat-status" :class="{ online: currentContact.online }">
|
||||
{{ currentContact.online ? '在线' : '离线' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="chat-messages" ref="messagesRef">
|
||||
<div v-if="loadingMessages" class="messages-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon> 加载中..
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-for="msg in currentMessages" :key="msg.id" class="message-wrapper" :class="{ self: msg.isSelf }">
|
||||
<el-popover placement="left" :width="200" trigger="hover">
|
||||
<template #reference>
|
||||
<el-avatar
|
||||
:size="30"
|
||||
:src="msg.isSelf ? (userStore.avatar || undefined) : (msg.fromAvatar || undefined)"
|
||||
:style="{ background: msg.isSelf ? (userStore.avatar ? 'transparent' : '#409eff') : (msg.fromAvatar ? 'transparent' : (msg.fromColor || '#909399')) }"
|
||||
class="msg-avatar"
|
||||
>
|
||||
{{ (msg.isSelf ? currentUserName : (msg.fromNickname || msg.fromUsername))?.[0] || '?' }}
|
||||
</el-avatar>
|
||||
</template>
|
||||
<div class="user-info-popover">
|
||||
<div class="user-info-row">
|
||||
<span class="user-info-label">账号:</span>
|
||||
<span class="user-info-value">{{ msg.isSelf ? currentUserName : (msg.fromUsername || '未知') }}</span>
|
||||
</div>
|
||||
<div class="user-info-row">
|
||||
<span class="user-info-label">昵称:</span>
|
||||
<span class="user-info-value">{{ msg.isSelf ? (currentUserNickname || currentUserName) : (msg.fromNickname || '未知') }}</span>
|
||||
</div>
|
||||
<div class="user-info-row signature-row">
|
||||
<span class="user-info-label">签名:</span>
|
||||
<div class="user-info-signature">
|
||||
{{ msg.isSelf ? (userStore.signature || '暂无') : (msg.fromSignature || '暂无') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
<div class="message-content">
|
||||
<div class="message-info">
|
||||
<span class="sender-name">{{ msg.isSelf ? '' : (msg.fromNickname || msg.fromUsername) }}</span>
|
||||
<span class="message-time">{{ formatTime(msg.createTime) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 发送中 -->
|
||||
<div v-if="msg.sending" class="message-bubble message-sending">
|
||||
<template v-if="msg.tempType === 'image'">
|
||||
<div class="uploading-placeholder">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>图片发送中...</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="msg.tempType === 'file'">
|
||||
<div class="uploading-placeholder">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>{{ msg.fileName || '文件' }} 发送中...</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ msg.content }}</template>
|
||||
</div>
|
||||
|
||||
<!-- 发送失败 -->
|
||||
<div v-else-if="msg.failed" class="message-bubble message-failed" @click="retrySend(msg)">
|
||||
<template v-if="msg.tempType === 'image'">
|
||||
<div>图片发送失败,点击重试</div>
|
||||
</template>
|
||||
<template v-else-if="msg.tempType === 'file'">
|
||||
<div>文件发送失败,点击重试</div>
|
||||
</template>
|
||||
<template v-else>{{ msg.content }} <span class="failed-hint">(发送失败,点击重试)</span></template>
|
||||
</div>
|
||||
|
||||
<!-- 已发送 -->
|
||||
<template v-else>
|
||||
<div v-if="msg.type === 'text' || !msg.type" class="message-bubble">{{ msg.content }}</div>
|
||||
<div v-else-if="msg.type === 'image'" class="message-image">
|
||||
<el-image :src="getMediaUrl(msg.content)" fit="cover" :preview-src-list="[getMediaUrl(msg.content)]" :preview-teleported="true" />
|
||||
</div>
|
||||
<div v-else-if="msg.type === 'file'" class="message-file">
|
||||
<div class="file-icon" :style="{ background: getFileColor(msg.fileName) }">
|
||||
<span>{{ getFileExt(msg.fileName) }}</span>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<div class="file-name" :title="msg.fileName">{{ msg.fileName || '文件' }}</div>
|
||||
<div class="file-size">{{ formatSize(msg.fileSize) }}</div>
|
||||
</div>
|
||||
<el-button link @click="downloadFile(msg)">
|
||||
<el-icon><Download /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="chat-toolbar">
|
||||
<el-tooltip content="发送图片">
|
||||
<el-button link :disabled="uploading" @click="triggerImageUpload">
|
||||
<el-icon :size="18"><Picture /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="发送文件">
|
||||
<el-button link :disabled="uploading" @click="triggerFileUpload">
|
||||
<el-icon :size="18"><Paperclip /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="表情">
|
||||
<el-popover placement="top" :width="400" trigger="click" v-model:visible="emojiVisible">
|
||||
<template #reference>
|
||||
<el-button link style="font-size:18px">😊</el-button>
|
||||
</template>
|
||||
<div class="emoji-panel">
|
||||
<span v-for="emoji in emojis" :key="emoji" class="emoji-item" @click="sendEmoji(emoji)">{{ emoji }}</span>
|
||||
</div>
|
||||
</el-popover>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<div class="chat-input-area">
|
||||
<el-input v-model="inputText" type="textarea" :rows="4" placeholder="输入消息,Enter 发送" resize="none" @keydown.enter.exact.prevent="sendMessage" />
|
||||
<el-button type="primary" @click="sendMessage" class="send-btn"><el-icon><Promotion /></el-icon>发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="chat-empty">
|
||||
<el-icon :size="48"><ChatDotRound /></el-icon>
|
||||
<p>选择联系人开始聊天</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的文件选择器 -->
|
||||
<input ref="imageInputRef" type="file" accept="image/*" style="display:none" @change="handleImageSelect" />
|
||||
<input ref="fileInputRef" type="file" style="display:none" @change="handleFileSelect" />
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ChatLineRound, User, Search, Close, Loading, Picture, Paperclip, Download, ChatDotRound, Promotion } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { getUsers, getMessages, sendMessage as sendMessageApi, uploadChatFile, getUnreadList } from '@/api/message'
|
||||
import { chatService } from '@/services/chat'
|
||||
|
||||
const props = defineProps({ modelValue: Boolean })
|
||||
const emit = defineEmits(['update:modelValue', 'unread-change'])
|
||||
const userStore = useUserStore()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const sidebarTab = ref('recent')
|
||||
const searchKeyword = ref('')
|
||||
const contacts = ref([])
|
||||
const recentChats = ref([])
|
||||
const messages = ref({})
|
||||
const onlineUsers = ref(new Set())
|
||||
const currentUserName = ref('')
|
||||
const currentUserNickname = ref('')
|
||||
const currentContact = ref(null)
|
||||
const inputText = ref('')
|
||||
const loadingMessages = ref(false)
|
||||
const emojiVisible = ref(false)
|
||||
const messagesRef = ref(null)
|
||||
const imageInputRef = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
const uploading = ref(false)
|
||||
let unsubscribeWs = null
|
||||
let tempMsgId = 0
|
||||
|
||||
const emojis = ['😀','😂','😍','🥰','😎','🤔','😅','😭','🎉','👍','❤️','🔥','😱','🙏','💪','😘','😜','😊','😁','😃','😄','😆','😉','😌','😋','😛','😲','😞','😠','😡','😢','😤','😥','😦','😧','😨','😩','😪','😫','😬','😮','😯','😰','😳','😴','😵','😶','😷','😸','😹','😺','😻','😼','😽','😾','😿','🙀','🙁','🙂','🙃','🙄']
|
||||
const colors = ['#f56c6c', '#e6a23c', '#67c23a', '#409eff', '#909399', '#c71585', '#00bcd4']
|
||||
|
||||
const currentMessages = computed(() => {
|
||||
if (!currentContact.value) return []
|
||||
return messages.value[currentContact.value.id] || []
|
||||
})
|
||||
|
||||
const filteredContacts = computed(() => {
|
||||
if (!searchKeyword.value) return contacts.value
|
||||
const kw = searchKeyword.value.toLowerCase()
|
||||
return contacts.value.filter(c =>
|
||||
(c.username || '').toLowerCase().includes(kw) ||
|
||||
(c.nickname || '').toLowerCase().includes(kw)
|
||||
)
|
||||
})
|
||||
|
||||
// ======== 工具函数 ========
|
||||
|
||||
const getMediaUrl = (url) => url || ''
|
||||
const formatTime = (date) => {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const diff = now - d
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
|
||||
return d.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (!size) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
|
||||
return size.toFixed(2) + ' ' + units[i]
|
||||
}
|
||||
|
||||
const getFileExt = (name) => {
|
||||
if (!name) return '?'
|
||||
const ext = name.includes('.') ? name.split('.').pop().toUpperCase().slice(0, 4) : '?'
|
||||
return ext
|
||||
}
|
||||
|
||||
const getFileColor = (name) => {
|
||||
if (!name) return '#909399'
|
||||
const ext = name.split('.').pop().toLowerCase()
|
||||
const colorMap = {
|
||||
pdf: '#e74c3c', doc: '#2980b9', docx: '#2980b9', xls: '#27ae60', xlsx: '#27ae60',
|
||||
ppt: '#e67e22', pptx: '#e67e22', zip: '#8e44ad', rar: '#8e44ad', '7z': '#8e44ad',
|
||||
txt: '#95a5a6', md: '#95a5a6', csv: '#27ae60', mp3: '#e91e63', mp4: '#9c27b0',
|
||||
avi: '#9c27b0', mov: '#9c27b0', jpg: '#00bcd4', jpeg: '#00bcd4', png: '#00bcd4',
|
||||
gif: '#00bcd4', svg: '#00bcd4', webp: '#00bcd4', psd: '#1565c0', ai: '#f57c00'
|
||||
}
|
||||
return colorMap[ext] || '#909399'
|
||||
}
|
||||
|
||||
// ======== 联系人 & 消息加载 ========
|
||||
|
||||
const loadContacts = async () => {
|
||||
try {
|
||||
const res = await getUsers()
|
||||
contacts.value = (res.data || []).map((u, i) => ({
|
||||
...u,
|
||||
id: u.id,
|
||||
name: u.nickname || u.username,
|
||||
color: colors[i % colors.length],
|
||||
online: onlineUsers.value.has(Number(u.id)),
|
||||
unread: 0,
|
||||
lastMsg: ''
|
||||
}))
|
||||
} catch (e) {
|
||||
ElMessage.error('加载联系人失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadUnreadChats = async () => {
|
||||
try {
|
||||
const res = await getUnreadList()
|
||||
const list = res.data || []
|
||||
if (list.length === 0) return
|
||||
// 加载联系人后再填充未读数据
|
||||
list.forEach(item => {
|
||||
const contact = contacts.value.find(c => Number(c.id) === Number(item.userId))
|
||||
if (!contact) {
|
||||
// 联系人列表中找不到,用未读信息创建
|
||||
const i = recentChats.value.length
|
||||
const newContact = {
|
||||
id: item.userId,
|
||||
username: item.username || '',
|
||||
nickname: item.nickname || item.username || '',
|
||||
avatar: item.avatar || '',
|
||||
signature: item.signature || '',
|
||||
name: item.nickname || item.username || '',
|
||||
color: colors[i % colors.length],
|
||||
online: false,
|
||||
unread: item.unread || 0,
|
||||
lastMsg: item.lastMsg || ''
|
||||
}
|
||||
recentChats.value.push(newContact)
|
||||
} else {
|
||||
// 已在联系人列表中,更新最近聊天
|
||||
const chat = recentChats.value.find(c => c.id === contact.id)
|
||||
if (chat) {
|
||||
chat.unread = item.unread || 0
|
||||
chat.lastMsg = item.lastMsg || chat.lastMsg
|
||||
} else {
|
||||
contact.unread = item.unread || 0
|
||||
contact.lastMsg = item.lastMsg || ''
|
||||
recentChats.value.push({ ...contact })
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const selectContact = async (contact) => {
|
||||
currentContact.value = contact
|
||||
contact.unread = 0
|
||||
loadingMessages.value = true
|
||||
try {
|
||||
const res = await getMessages({ userId: contact.id })
|
||||
messages.value[contact.id] = (res.data || []).map(msg => {
|
||||
const isSelf = String(msg.fromUserId) === String(userStore.userId)
|
||||
return {
|
||||
...msg,
|
||||
isSelf,
|
||||
sending: false,
|
||||
failed: false,
|
||||
fromColor: colors[Math.abs(Number(isSelf ? userStore.userId : msg.fromUserId) % colors.length)]
|
||||
}
|
||||
})
|
||||
updateRecentChat(contact, '')
|
||||
} catch (e) {
|
||||
ElMessage.error('加载消息失败')
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
const updateRecentChat = (contact, lastMsg) => {
|
||||
let chat = recentChats.value.find(c => c.id === contact.id)
|
||||
if (!chat) {
|
||||
chat = { ...contact }
|
||||
recentChats.value.unshift(chat)
|
||||
}
|
||||
if (lastMsg) chat.lastMsg = lastMsg
|
||||
chat.unread = 0
|
||||
}
|
||||
|
||||
// ======== 发送消息 ========
|
||||
|
||||
const sendMessage = () => {
|
||||
const text = inputText.value.trim()
|
||||
if (!text || !currentContact.value) return
|
||||
inputText.value = ''
|
||||
|
||||
// 文本消息不创建临时消息,直接通过 WebSocket 发送,等待回调显示
|
||||
chatService.send({
|
||||
type: 'chat',
|
||||
toUserId: currentContact.value.id,
|
||||
content: text,
|
||||
msgType: 'text'
|
||||
})
|
||||
}
|
||||
|
||||
const sendEmoji = (emoji) => {
|
||||
inputText.value += emoji
|
||||
emojiVisible.value = false
|
||||
}
|
||||
|
||||
// ======== 图片 & 文件上传 ========
|
||||
|
||||
const triggerImageUpload = () => { if (!uploading.value) imageInputRef.value?.click() }
|
||||
const triggerFileUpload = () => { if (!uploading.value) fileInputRef.value?.click() }
|
||||
|
||||
const pushMessage = (msg) => {
|
||||
const contactId = msg.isSelf ? msg.toUserId : msg.fromUserId
|
||||
if (!messages.value[contactId]) messages.value[contactId] = []
|
||||
messages.value[contactId].push(msg)
|
||||
}
|
||||
|
||||
const handleImageSelect = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !currentContact.value) return
|
||||
e.target.value = ''
|
||||
uploading.value = true
|
||||
|
||||
// 先在聊天窗口显示"发送中"
|
||||
const tempMsg = {
|
||||
id: ++tempMsgId,
|
||||
fromUserId: userStore.userId,
|
||||
toUserId: currentContact.value.id,
|
||||
content: '',
|
||||
type: 'image',
|
||||
tempType: 'image',
|
||||
isSelf: true,
|
||||
sending: true,
|
||||
failed: false,
|
||||
createTime: new Date(),
|
||||
fromColor: colors[Math.abs(Number(userStore.userId) % colors.length)]
|
||||
}
|
||||
pushMessage(tempMsg)
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await uploadChatFile(formData)
|
||||
chatService.send({
|
||||
type: 'chat',
|
||||
toUserId: currentContact.value.id,
|
||||
content: res.url,
|
||||
msgType: 'image'
|
||||
})
|
||||
updateRecentChat(currentContact.value, '[图片]')
|
||||
} catch (err) {
|
||||
// 标记失败
|
||||
const list = messages.value[currentContact.value.id]
|
||||
if (list) {
|
||||
const idx = list.findIndex(m => m.id === tempMsg.id)
|
||||
if (idx > -1) {
|
||||
list[idx].sending = false
|
||||
list[idx].failed = true
|
||||
}
|
||||
}
|
||||
ElMessage.error('图片上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !currentContact.value) return
|
||||
e.target.value = ''
|
||||
uploading.value = true
|
||||
|
||||
const tempMsg = {
|
||||
id: ++tempMsgId,
|
||||
fromUserId: userStore.userId,
|
||||
toUserId: currentContact.value.id,
|
||||
content: '',
|
||||
type: 'file',
|
||||
tempType: 'file',
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
isSelf: true,
|
||||
sending: true,
|
||||
failed: false,
|
||||
createTime: new Date(),
|
||||
fromColor: colors[Math.abs(Number(userStore.userId) % colors.length)]
|
||||
}
|
||||
pushMessage(tempMsg)
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await uploadChatFile(formData)
|
||||
chatService.send({
|
||||
type: 'chat',
|
||||
toUserId: currentContact.value.id,
|
||||
content: res.url,
|
||||
msgType: 'file',
|
||||
fileName: file.name,
|
||||
fileSize: file.size
|
||||
})
|
||||
updateRecentChat(currentContact.value, '[文件]')
|
||||
} catch (err) {
|
||||
const list = messages.value[currentContact.value.id]
|
||||
if (list) {
|
||||
const idx = list.findIndex(m => m.id === tempMsg.id)
|
||||
if (idx > -1) {
|
||||
list[idx].sending = false
|
||||
list[idx].failed = true
|
||||
}
|
||||
}
|
||||
ElMessage.error('文件上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ======== 文件下载 ========
|
||||
|
||||
const downloadFile = async (msg) => {
|
||||
try {
|
||||
const res = await fetch(msg.content)
|
||||
if (!res.ok) throw new Error('下载失败')
|
||||
const blob = await res.blob()
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = msg.fileName || '文件'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(a.href)
|
||||
} catch (e) {
|
||||
ElMessage.error('下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ======== 其他操作 ========
|
||||
|
||||
const deleteRecentChat = (chat) => {
|
||||
const idx = recentChats.value.findIndex(c => c.id === chat.id)
|
||||
if (idx > -1) recentChats.value.splice(idx, 1)
|
||||
if (currentContact.value?.id === chat.id) currentContact.value = null
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ======== WebSocket ========
|
||||
|
||||
const onDialogOpen = () => {
|
||||
loadContacts().then(() => loadUnreadChats())
|
||||
currentUserName.value = userStore.username || ''
|
||||
currentUserNickname.value = userStore.nickname || ''
|
||||
chatService.connect()
|
||||
unsubscribeWs = chatService.onMessage((data) => {
|
||||
if (data.type === 'chat') {
|
||||
const msg = data.message
|
||||
const isSelf = Number(msg.fromUserId) === Number(userStore.userId)
|
||||
msg.isSelf = isSelf
|
||||
msg.sending = false
|
||||
msg.failed = false
|
||||
msg.fromColor = colors[Math.abs(Number(msg.fromUserId) % colors.length)]
|
||||
const targetUserId = isSelf ? msg.toUserId : msg.fromUserId
|
||||
if (!messages.value[targetUserId]) messages.value[targetUserId] = []
|
||||
const list = messages.value[targetUserId]
|
||||
// 清除同类型的临时消息(sending 状态的)
|
||||
if (isSelf && (msg.type === 'image' || msg.type === 'file')) {
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
if (list[i].sending && list[i].tempType === msg.type) {
|
||||
list.splice(i, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// 去重
|
||||
const exists = list.some(m => m.id === msg.id)
|
||||
if (!exists) list.push(msg)
|
||||
// 滚动
|
||||
if (currentContact.value && Number(currentContact.value.id) === Number(targetUserId)) {
|
||||
nextTick(() => scrollToBottom())
|
||||
}
|
||||
// 更新最近聊天
|
||||
if (!isSelf) {
|
||||
const contact = contacts.value.find(c => Number(c.id) === Number(msg.fromUserId))
|
||||
if (contact) {
|
||||
const lastMsg = msg.type === 'file' ? '[文件]' : (msg.type === 'image' ? '[图片]' : msg.content)
|
||||
updateRecentChat(contact, lastMsg)
|
||||
// 如果当前不在这个聊天窗口,增加未读计数
|
||||
const currentChatId = currentContact.value ? Number(currentContact.value.id) : null
|
||||
if (currentChatId !== Number(msg.fromUserId)) {
|
||||
const chat = recentChats.value.find(c => Number(c.id) === Number(msg.fromUserId))
|
||||
if (chat) chat.unread = (chat.unread || 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (data.type === 'online') {
|
||||
onlineUsers.value = new Set(data.users)
|
||||
contacts.value.forEach(c => { c.online = onlineUsers.value.has(Number(c.id)) })
|
||||
recentChats.value.forEach(c => { c.online = onlineUsers.value.has(Number(c.id)) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onDialogClose = () => {
|
||||
currentContact.value = null
|
||||
inputText.value = ''
|
||||
if (unsubscribeWs) { unsubscribeWs(); unsubscribeWs = null }
|
||||
// 通知导航栏刷新未读数
|
||||
emit('unread-change')
|
||||
}
|
||||
|
||||
onMounted(() => { loadContacts() })
|
||||
onUnmounted(() => { if (unsubscribeWs) unsubscribeWs() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-container { display: flex; height: 600px; gap: 12px; }
|
||||
.chat-sidebar { width: 280px; display: flex; flex-direction: column; border-right: 1px solid #e4e7ed; }
|
||||
.sidebar-tabs { display: flex; border-bottom: 1px solid #e4e7ed; }
|
||||
.sidebar-tab { flex: 1; padding: 12px; text-align: center; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.3s; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 13px; }
|
||||
.sidebar-tab.active { border-bottom-color: #409eff; color: #409eff; }
|
||||
.sidebar-search { padding: 8px; }
|
||||
.sidebar-list { flex: 1; overflow-y: auto; }
|
||||
.sidebar-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; transition: all 0.2s; border-left: 3px solid transparent; }
|
||||
.sidebar-item:hover { background: #f5f7fa; }
|
||||
.sidebar-item.active { background: #f0f9ff; border-left-color: #409eff; }
|
||||
.sidebar-item-info { flex: 1; min-width: 0; }
|
||||
.sidebar-item-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar-item-msg { font-size: 12px; color: #909399; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar-item-status { font-size: 12px; color: #909399; }
|
||||
.sidebar-item-status.online { color: #67c23a; }
|
||||
.sidebar-empty { padding: 20px; text-align: center; color: #909399; font-size: 12px; }
|
||||
.delete-btn { opacity: 0; transition: opacity 0.2s; }
|
||||
.sidebar-item:hover .delete-btn { opacity: 1; }
|
||||
|
||||
.chat-main { flex: 1; display: flex; flex-direction: column; }
|
||||
.chat-header { display: flex; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid #e4e7ed; }
|
||||
.chat-title { flex: 1; font-weight: 500; font-size: 14px; }
|
||||
.chat-status { font-size: 12px; color: #909399; }
|
||||
.chat-status.online { color: #67c23a; }
|
||||
|
||||
.chat-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.messages-loading { display: flex; align-items: center; justify-content: center; height: 100%; color: #909399; }
|
||||
|
||||
.message-wrapper { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.message-wrapper.self { flex-direction: row-reverse; }
|
||||
.msg-avatar { flex-shrink: 0; cursor: pointer; }
|
||||
.message-content { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||
.message-wrapper.self .message-content { align-items: flex-end; }
|
||||
.message-info { display: flex; gap: 8px; font-size: 12px; color: #909399; }
|
||||
.sender-name { font-weight: 500; }
|
||||
|
||||
.message-bubble { background: #f0f0f0; padding: 8px 12px; border-radius: 8px; word-break: break-word; max-width: 300px; font-size: 13px; }
|
||||
.message-wrapper.self .message-bubble { background: #409eff; color: white; }
|
||||
|
||||
/* 发送中 */
|
||||
.message-sending { opacity: 0.6; }
|
||||
.uploading-placeholder { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #909399; }
|
||||
|
||||
/* 发送失败 */
|
||||
.message-failed { background: #fef0f0 !important; color: #f56c6c !important; cursor: pointer; }
|
||||
.message-failed:hover { background: #fde2e2 !important; }
|
||||
.failed-hint { font-size: 11px; opacity: 0.8; }
|
||||
|
||||
/* 图片消息 */
|
||||
.message-image { max-width: 200px; }
|
||||
.message-image :deep(.el-image) { border-radius: 8px; overflow: hidden; }
|
||||
.message-image :deep(.el-image img) { display: block; }
|
||||
|
||||
/* 文件消息 */
|
||||
.message-file { display: flex; align-items: center; gap: 10px; background: #f8f9fa; padding: 10px 12px; border-radius: 8px; max-width: 260px; border: 1px solid #ebeef5; }
|
||||
.message-wrapper.self .message-file { background: #ecf5ff; border-color: #b3d8ff; }
|
||||
.file-icon { width: 36px; height: 36px; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.file-icon span { font-size: 10px; font-weight: 700; color: white; letter-spacing: 0.5px; }
|
||||
.file-info { flex: 1; min-width: 0; }
|
||||
.file-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #303133; }
|
||||
.file-size { font-size: 11px; color: #909399; margin-top: 2px; }
|
||||
|
||||
.message-emoji { font-size: 32px; }
|
||||
|
||||
.chat-toolbar { display: flex; gap: 8px; padding: 8px; border-top: 1px solid #e4e7ed; }
|
||||
.chat-input-area { display: flex; flex-direction: column; gap: 8px; padding: 8px; border-top: 1px solid #e4e7ed; }
|
||||
.chat-input-area :deep(.el-textarea) { flex: 1; }
|
||||
.send-btn { align-self: flex-end; }
|
||||
|
||||
.emoji-panel { display: grid; grid-template-columns: repeat(10, 1fr); gap: 8px; }
|
||||
.emoji-item { font-size: 20px; cursor: pointer; text-align: center; padding: 4px; border-radius: 4px; transition: all 0.2s; }
|
||||
.emoji-item:hover { background: #f0f0f0; transform: scale(1.2); }
|
||||
|
||||
.user-info-popover { display: flex; flex-direction: column; gap: 8px; padding: 8px 0; }
|
||||
.user-info-row { display: flex; gap: 8px; font-size: 12px; line-height: 1.5; }
|
||||
.user-info-label { font-weight: 500; min-width: 50px; color: #303133; }
|
||||
.user-info-value { color: #606266; word-break: break-all; flex: 1; }
|
||||
.signature-row { flex-direction: column; align-items: flex-start; }
|
||||
.user-info-signature { color: #606266; font-size: 12px; line-height: 1.4; word-break: break-all; margin-top: 4px; }
|
||||
|
||||
.chat-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #909399; gap: 12px; }
|
||||
.chat-empty p { margin: 0; font-size: 14px; }
|
||||
|
||||
.chat-dialog :deep(.el-dialog) { height: 720px; margin: auto !important; top: 50% !important; transform: translateY(-50%) !important; }
|
||||
</style>
|
||||
90
web-vue/src/components/FileSidebar.vue
Normal file
90
web-vue/src/components/FileSidebar.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
class="sidebar-menu"
|
||||
@select="handleSelect"
|
||||
>
|
||||
<el-menu-item index="my-files">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>我的文档</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="my-share">
|
||||
<el-icon><Share /></el-icon>
|
||||
<span>我的共享</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="shared-to-me">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>共享给我</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="trash">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>回收站</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<div class="storage-info">
|
||||
<div class="storage-title">存储空间</div>
|
||||
<el-progress :percentage="storagePercent" :stroke-width="8" />
|
||||
<div class="storage-text">{{ usedStorage }} / {{ totalStorage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineEmits, defineProps } from 'vue'
|
||||
import { Document, Share, User, Delete } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
activeMenu: { type: String, default: 'my-files' },
|
||||
storagePercent: { type: Number, default: 35 },
|
||||
usedStorage: { type: String, default: '3.5 GB' },
|
||||
totalStorage: { type: String, default: '10 GB' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const handleSelect = (index) => {
|
||||
emit('select', index)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
flex: 1;
|
||||
border-right: none;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item) {
|
||||
height: 46px;
|
||||
line-height: 46px;
|
||||
}
|
||||
|
||||
.storage-info {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.storage-title {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.storage-text {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
199
web-vue/src/components/FileTable.vue
Normal file
199
web-vue/src/components/FileTable.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="file-table">
|
||||
<el-table
|
||||
:data="files"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
height="100%"
|
||||
@selection-change="$emit('selection-change', $event)"
|
||||
@row-dblclick="$emit('row-dblclick', $event)"
|
||||
>
|
||||
<el-table-column type="selection" width="50" />
|
||||
|
||||
<el-table-column label="文件名" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="file-name-cell">
|
||||
<el-icon :size="20" :color="getFileIconColor(row)">
|
||||
<component :is="getFileIcon(row)" />
|
||||
</el-icon>
|
||||
<span class="file-name">{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="大小" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.type === 'folder' ? '-' : formatSize(row.size) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="修改时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="menuType === 'trash'">
|
||||
<el-button link type="primary" @click="$emit('restore', row)">
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
<span style="margin-left: 2px">还原</span>
|
||||
</el-button>
|
||||
<el-button link type="danger" @click="$emit('delete-permanent', row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span style="margin-left: 2px">彻底删除</span>
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else-if="menuType === 'my-files'">
|
||||
<el-button link @click="$emit('preview', row)" v-if="canPreview(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
<span style="margin-left: 2px">预览</span>
|
||||
</el-button>
|
||||
<el-button link @click="$emit('download', row)" v-else-if="row.type !== 'folder'">
|
||||
<el-icon><Download /></el-icon>
|
||||
<span style="margin-left: 2px">下载</span>
|
||||
</el-button>
|
||||
<el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, row)">
|
||||
<el-button link>
|
||||
<span>更多</span>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="rename">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>重命名</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="share">
|
||||
<el-icon><Share /></el-icon>
|
||||
<span>共享</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="download" v-if="row.type !== 'folder' && canPreview(row)">
|
||||
<el-icon><Download /></el-icon>
|
||||
<span>下载</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="preview" v-if="!canPreview(row) && row.type !== 'folder'">
|
||||
<el-icon><View /></el-icon>
|
||||
<span>预览</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span style="color: #f56c6c">删除</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
<template v-else-if="menuType === 'my-share'">
|
||||
<el-button link @click="$emit('preview', row)" v-if="canPreview(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
<span style="margin-left: 2px">预览</span>
|
||||
</el-button>
|
||||
<el-button link @click="$emit('download', row)" v-if="row.type !== 'folder'">
|
||||
<el-icon><Download /></el-icon>
|
||||
<span style="margin-left: 2px">下载</span>
|
||||
</el-button>
|
||||
<el-button link type="warning" @click="$emit('cancel-share', row)" v-if="!inFolder">
|
||||
<el-icon><CloseBold /></el-icon>
|
||||
<span style="margin-left: 2px">取消共享</span>
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else-if="menuType === 'shared-to-me'">
|
||||
<el-button link @click="$emit('preview', row)" v-if="canPreview(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
<span style="margin-left: 2px">预览</span>
|
||||
</el-button>
|
||||
<el-button link @click="$emit('download', row)" v-if="row.type !== 'folder'">
|
||||
<el-icon><Download /></el-icon>
|
||||
<span style="margin-left: 2px">下载</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Document, Folder, Picture, VideoPlay, Headset, RefreshLeft, Delete, View, Share, Download, CloseBold, Edit } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
files: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
menuType: { type: String, default: 'my-files' },
|
||||
inFolder: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['selection-change', 'row-dblclick', 'preview', 'share', 'download', 'delete', 'restore', 'delete-permanent', 'cancel-share', 'rename'])
|
||||
|
||||
const handleCommand = (command, row) => {
|
||||
switch (command) {
|
||||
case 'rename':
|
||||
emit('rename', row)
|
||||
break
|
||||
case 'share':
|
||||
emit('share', row)
|
||||
break
|
||||
case 'delete':
|
||||
emit('delete', row)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const getFileIcon = (file) => {
|
||||
if (file.type === 'folder') return Folder
|
||||
if (file.type === 'image') return Picture
|
||||
if (file.type === 'video') return VideoPlay
|
||||
if (file.type === 'audio') return Headset
|
||||
return Document
|
||||
}
|
||||
|
||||
const getFileIconColor = (file) => {
|
||||
if (file.type === 'folder') return '#f7b32b'
|
||||
if (file.type === 'image') return '#67c23a'
|
||||
if (file.type === 'video') return '#409eff'
|
||||
if (file.type === 'audio') return '#e6a23c'
|
||||
return '#909399'
|
||||
}
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (!size) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024
|
||||
i++
|
||||
}
|
||||
return size.toFixed(2) + ' ' + units[i]
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const canPreview = (file) => {
|
||||
if (file.type === 'folder') return false
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'txt', 'md', 'json', 'xml', 'log', 'js', 'ts', 'vue', 'java', 'py', 'css', 'html'].includes(ext)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-table {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.file-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
138
web-vue/src/components/FileToolbar.vue
Normal file
138
web-vue/src/components/FileToolbar.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="file-toolbar">
|
||||
<!-- 第一行:视图切换 + 搜索框 + 上传/新建 -->
|
||||
<div class="toolbar-top">
|
||||
<el-button-group>
|
||||
<el-button :type="currentMode === 'table' ? 'primary' : ''" @click="changeMode('table')">
|
||||
<el-icon><List /></el-icon>
|
||||
</el-button>
|
||||
<el-button :type="currentMode === 'grid' ? 'primary' : ''" @click="changeMode('grid')">
|
||||
<el-icon><Grid /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索文件..."
|
||||
clearable
|
||||
class="search-input"
|
||||
@keyup.enter="doSearch"
|
||||
@input="onSearchInput"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
|
||||
<el-button-group v-if="menuType === 'my-files'">
|
||||
<el-button type="primary" @click="$emit('upload')">
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span style="margin-left: 4px">上传文件</span>
|
||||
</el-button>
|
||||
<el-button @click="$emit('newFolder')">
|
||||
<el-icon><FolderAdd /></el-icon>
|
||||
<span style="margin-left: 4px">新建文件夹</span>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<el-button type="danger" v-if="menuType === 'trash'" @click="$emit('emptyTrash')">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span style="margin-left: 4px">清空回收站</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:前进后退 + 路径 -->
|
||||
<div class="toolbar-bottom">
|
||||
<div class="path-controls">
|
||||
<el-button v-if="showBack" @click="$emit('goBack')" circle title="返回上级">
|
||||
<el-icon><Back /></el-icon>
|
||||
</el-button>
|
||||
<span class="current-path">{{ currentPath }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { List, Grid, Upload, FolderAdd, Delete, Back, Search } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
viewMode: { type: String, default: 'table' },
|
||||
searchKeyword: { type: String, default: '' },
|
||||
menuType: { type: String, default: 'my-files' },
|
||||
currentPath: { type: String, default: '/' },
|
||||
showBack: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:viewMode',
|
||||
'update:searchKeyword',
|
||||
'search',
|
||||
'refresh',
|
||||
'upload',
|
||||
'newFolder',
|
||||
'emptyTrash',
|
||||
'goBack'
|
||||
])
|
||||
|
||||
const currentMode = ref(props.viewMode)
|
||||
const searchText = ref(props.searchKeyword)
|
||||
|
||||
watch(() => props.viewMode, (val) => {
|
||||
currentMode.value = val
|
||||
})
|
||||
|
||||
watch(() => props.searchKeyword, (val) => {
|
||||
searchText.value = val
|
||||
})
|
||||
|
||||
const changeMode = (mode) => {
|
||||
currentMode.value = mode
|
||||
emit('update:viewMode', mode)
|
||||
}
|
||||
|
||||
const onSearchInput = (val) => {
|
||||
emit('update:searchKeyword', val)
|
||||
}
|
||||
|
||||
const doSearch = () => {
|
||||
emit('search')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.toolbar-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.path-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.current-path {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
53
web-vue/src/components/FolderDialog.vue
Normal file
53
web-vue/src/components/FolderDialog.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="新建文件夹"
|
||||
width="400px"
|
||||
class="custom-dialog"
|
||||
>
|
||||
<el-input
|
||||
v-model="folderName"
|
||||
placeholder="请输入文件夹名称"
|
||||
clearable
|
||||
@keyup.enter="handleCreate"
|
||||
autofocus
|
||||
>
|
||||
<template #prefix><el-icon><Folder /></el-icon></template>
|
||||
</el-input>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
<span style="margin-left: 4px">取消</span>
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleCreate" :disabled="!folderName.trim()">
|
||||
<el-icon><Check /></el-icon>
|
||||
<span style="margin-left: 4px">保存</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Close, Check, Folder } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({ modelValue: Boolean })
|
||||
const emit = defineEmits(['update:modelValue', 'create'])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const folderName = ref('')
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (v) folderName.value = ''
|
||||
})
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!folderName.value.trim()) return
|
||||
emit('create', folderName.value.trim())
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
105
web-vue/src/components/PreviewDialog.vue
Normal file
105
web-vue/src/components/PreviewDialog.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="previewFile?.name || '预览'"
|
||||
width="80%"
|
||||
top="5vh"
|
||||
class="custom-dialog"
|
||||
>
|
||||
<div class="preview-content">
|
||||
<img v-if="isImage" :src="previewUrl" class="preview-image" />
|
||||
<iframe v-else-if="isPdf" :src="previewUrl" class="preview-iframe" />
|
||||
<pre v-else-if="isText" class="preview-text">{{ previewContent }}</pre>
|
||||
<div v-else class="preview-unsupported">
|
||||
<el-icon :size="64"><Document /></el-icon>
|
||||
<p>此文件类型暂不支持预览</p>
|
||||
<el-button type="primary" @click="$emit('download', previewFile)">
|
||||
<el-icon><Download /></el-icon>
|
||||
<span style="margin-left: 4px">下载文件</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Document, Download } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
previewFile: Object,
|
||||
previewUrl: String,
|
||||
previewContent: String
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'download'])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const isImage = computed(() => {
|
||||
if (!props.previewFile) return false
|
||||
const ext = props.previewFile.name.split('.').pop()?.toLowerCase()
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)
|
||||
})
|
||||
|
||||
const isPdf = computed(() => {
|
||||
if (!props.previewFile) return false
|
||||
return props.previewFile.name.toLowerCase().endsWith('.pdf')
|
||||
})
|
||||
|
||||
const isText = computed(() => {
|
||||
if (!props.previewFile) return false
|
||||
const ext = props.previewFile.name.split('.').pop()?.toLowerCase()
|
||||
return ['txt', 'md', 'json', 'xml', 'log', 'js', 'ts', 'vue', 'java', 'py', 'css', 'html'].includes(ext)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-content {
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.preview-unsupported {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.preview-unsupported p {
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
274
web-vue/src/components/ProfileDialog.vue
Normal file
274
web-vue/src/components/ProfileDialog.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="个人信息"
|
||||
width="520px"
|
||||
class="custom-dialog"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="profile-container">
|
||||
<!-- 上方头像区域 -->
|
||||
<div class="profile-top">
|
||||
<div class="avatar-wrapper">
|
||||
<el-avatar :size="88" :src="avatarUrl" class="profile-avatar">
|
||||
{{ !avatarUrl ? ((userStore.nickname || userStore.username)?.[0]?.toUpperCase() || '?') : '' }}
|
||||
</el-avatar>
|
||||
<div class="avatar-overlay" @click="triggerAvatarUpload">
|
||||
<el-icon :size="20"><Camera /></el-icon>
|
||||
<span>更换</span>
|
||||
</div>
|
||||
<input ref="avatarInputRef" type="file" accept="image/*" style="display:none" @change="handleAvatarChange" />
|
||||
</div>
|
||||
<div class="username-text">@{{ userStore.nickname || userStore.username }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<el-divider />
|
||||
|
||||
<!-- 下方表单区域 -->
|
||||
<div class="profile-bottom">
|
||||
<el-form label-position="right" label-width="70px" class="profile-form" :model="form" :rules="rules" ref="formRef">
|
||||
<el-form-item label="账号">
|
||||
<el-input :model-value="userStore.username" disabled :prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="form.nickname" placeholder="设置你的昵称" maxlength="20" show-word-limit :prefix-icon="UserFilled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="电话" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入电话号码" maxlength="11" :prefix-icon="Phone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="请输入邮箱地址" maxlength="50" :prefix-icon="Message" />
|
||||
</el-form-item>
|
||||
<el-form-item label="签名" prop="signature" class="signature-item">
|
||||
<el-input
|
||||
v-model="form.signature"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="写点什么介绍自己吧..."
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
resize="none"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="visible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
<span style="margin-left: 4px">取消</span>
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">
|
||||
<el-icon><Check /></el-icon>
|
||||
<span style="margin-left: 4px">保存</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, reactive, watch } from 'vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Camera, User, UserFilled, Close, Check, Phone, Message } from '@element-plus/icons-vue'
|
||||
import request from '@/api/request'
|
||||
|
||||
const props = defineProps({ modelValue: Boolean })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const userStore = useUserStore()
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const avatarUrl = ref('')
|
||||
const avatarInputRef = ref(null)
|
||||
const formRef = ref(null)
|
||||
const saving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
nickname: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
signature: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
nickname: [{ max: 20, message: '昵称最多20个字符', trigger: 'blur' }],
|
||||
phone: [
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
|
||||
],
|
||||
signature: [{ max: 100, message: '签名最多100个字符', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (v) {
|
||||
avatarUrl.value = userStore.avatar || ''
|
||||
form.nickname = userStore.nickname || ''
|
||||
form.phone = userStore.phone || ''
|
||||
form.email = userStore.email || ''
|
||||
form.signature = userStore.signature || ''
|
||||
}
|
||||
})
|
||||
|
||||
const triggerAvatarUpload = () => {
|
||||
avatarInputRef.value?.click()
|
||||
}
|
||||
|
||||
const handleAvatarChange = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
ElMessage.warning('图片大小不能超过2MB')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('avatar', file)
|
||||
const res = await request.post('/users/avatar', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
const newUrl = res.data?.url || ''
|
||||
avatarUrl.value = newUrl
|
||||
userStore.setUser({ avatar: newUrl })
|
||||
ElMessage.success('头像更新成功')
|
||||
} catch {
|
||||
ElMessage.error('头像上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await request.put('/users/profile', {
|
||||
nickname: form.nickname,
|
||||
phone: form.phone,
|
||||
email: form.email,
|
||||
signature: form.signature
|
||||
})
|
||||
userStore.setUser({
|
||||
nickname: form.nickname,
|
||||
phone: form.phone,
|
||||
email: form.email,
|
||||
signature: form.signature
|
||||
})
|
||||
ElMessage.success('保存成功')
|
||||
visible.value = false
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
background: linear-gradient(135deg, #409eff, #66b1ff);
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
border: 3px solid #e4e7ed;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.avatar-wrapper:hover .profile-avatar {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.avatar-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 28px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border-radius: 0 0 44px 44px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.avatar-wrapper:hover .avatar-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.username-text {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-top :deep(.el-divider) {
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.profile-bottom {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-form :deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.profile-form :deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.signature-item :deep(.el-form-item__content) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-form :deep(.el-input.is-disabled .el-input__inner) {
|
||||
background: #f5f7fa;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
104
web-vue/src/components/RenameDialog.vue
Normal file
104
web-vue/src/components/RenameDialog.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="重命名"
|
||||
width="400px"
|
||||
class="custom-dialog"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form :model="form" :rules="rules" ref="formRef">
|
||||
<el-form-item prop="name">
|
||||
<el-input
|
||||
v-model="form.name"
|
||||
placeholder="请输入新名称"
|
||||
clearable
|
||||
@keyup.enter="handleConfirm"
|
||||
autofocus
|
||||
>
|
||||
<template #prefix><el-icon><Document /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
<span style="margin-left: 4px">取消</span>
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleConfirm" :disabled="!form.name.trim() || form.name === originalName">
|
||||
<el-icon><Check /></el-icon>
|
||||
<span style="margin-left: 4px">保存</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, reactive, watch } from 'vue'
|
||||
import { Close, Check, Document } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
file: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'rename'])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const formRef = ref(null)
|
||||
const originalName = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入名称', trigger: 'blur' },
|
||||
{ max: 100, message: '名称最多100个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 获取扩展名
|
||||
const getExtension = (filename) => {
|
||||
if (!filename || filename.indexOf('.') === -1) return ''
|
||||
return filename.slice(filename.lastIndexOf('.'))
|
||||
}
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (v && props.file) {
|
||||
originalName.value = props.file.name || ''
|
||||
form.name = props.file.name || ''
|
||||
}
|
||||
})
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
let newName = form.name.trim()
|
||||
|
||||
// 如果是文件(不是文件夹),自动补回扩展名
|
||||
if (props.file?.type !== 'folder') {
|
||||
const originalExt = getExtension(originalName.value)
|
||||
const newExt = getExtension(newName)
|
||||
|
||||
// 如果用户没输入扩展名,自动补回原来的
|
||||
if (originalExt && !newExt) {
|
||||
newName = newName + originalExt
|
||||
}
|
||||
}
|
||||
|
||||
// 名称没变不提交
|
||||
if (newName === originalName.value) {
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
|
||||
emit('rename', { id: props.file.id, name: newName })
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
90
web-vue/src/components/ShareDialog.vue
Normal file
90
web-vue/src/components/ShareDialog.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="共享文件"
|
||||
width="500px"
|
||||
class="custom-dialog"
|
||||
>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="文件名">
|
||||
<el-input :value="file?.name" disabled>
|
||||
<template #prefix><el-icon><Document /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="共享给">
|
||||
<el-select v-model="selectedUsers" multiple placeholder="选择用户" style="width: 100%">
|
||||
<el-option
|
||||
v-for="user in userList"
|
||||
:key="user.id"
|
||||
:label="user.nickname || user.username"
|
||||
:value="user.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="权限">
|
||||
<el-radio-group v-model="permission">
|
||||
<el-radio value="view">仅查看</el-radio>
|
||||
<el-radio value="edit">可编辑</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
<span style="margin-left: 4px">取消</span>
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleShare" :disabled="selectedUsers.length === 0">
|
||||
<el-icon><Check /></el-icon>
|
||||
<span style="margin-left: 4px">保存</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Close, Check, Document } from '@element-plus/icons-vue'
|
||||
import { getUsers } from '@/api/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
file: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'share'])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const selectedUsers = ref([])
|
||||
const permission = ref('view')
|
||||
const userList = ref([])
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const res = await getUsers()
|
||||
userList.value = res.data || []
|
||||
} catch (e) {
|
||||
ElMessage.error('获取用户列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (v) {
|
||||
selectedUsers.value = []
|
||||
permission.value = 'view'
|
||||
loadUsers()
|
||||
}
|
||||
})
|
||||
|
||||
const handleShare = () => {
|
||||
emit('share', {
|
||||
users: selectedUsers.value,
|
||||
permission: permission.value
|
||||
})
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
195
web-vue/src/components/TopNavbar.vue
Normal file
195
web-vue/src/components/TopNavbar.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="top-navbar">
|
||||
<div class="navbar-left">
|
||||
<el-icon :size="22" color="#ffffff"><Folder /></el-icon>
|
||||
<span class="system-title">云文件管理系统</span>
|
||||
</div>
|
||||
<div class="navbar-right">
|
||||
<!-- 消息图标 -->
|
||||
<el-badge :value="totalUnread" :hidden="totalUnread === 0" class="msg-badge" :class="{ 'has-unread': totalUnread > 0 }">
|
||||
<el-button link class="navbar-icon-btn" @click="openChat">
|
||||
<el-icon :size="20" color="#ffffff"><ChatDotRound /></el-icon>
|
||||
</el-button>
|
||||
</el-badge>
|
||||
|
||||
<!-- 用户头像下拉 -->
|
||||
<el-dropdown trigger="click" @command="handleUserCommand">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="30" :src="userStore.avatar || undefined" style="background:#409eff;font-size:13px">
|
||||
{{ (userStore.nickname || userStore.username)?.[0]?.toUpperCase() || 'U' }}
|
||||
</el-avatar>
|
||||
<span class="username">{{ userStore.nickname || userStore.username }}</span>
|
||||
<el-icon :size="12" color="#ffffff"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><User /></el-icon> 个人信息
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">
|
||||
<el-icon><SwitchButton /></el-icon> 退出系统
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- 聊天弹窗 -->
|
||||
<ChatDialog v-model="chatVisible" @unread-change="updateUnread" />
|
||||
|
||||
<!-- 个人信息弹窗 -->
|
||||
<ProfileDialog v-model="profileVisible" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
import { Folder, ChatDotRound, ArrowDown, User, SwitchButton } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import ChatDialog from './ChatDialog.vue'
|
||||
import ProfileDialog from './ProfileDialog.vue'
|
||||
import { chatService } from '@/services/chat'
|
||||
import { getUnreadCount } from '@/api/message'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const chatVisible = ref(false)
|
||||
const profileVisible = ref(false)
|
||||
const totalUnread = ref(0)
|
||||
|
||||
const updateUnread = () => {
|
||||
checkUnread()
|
||||
}
|
||||
|
||||
const openChat = () => {
|
||||
chatVisible.value = true
|
||||
// 打开聊天时清零未读数
|
||||
totalUnread.value = 0
|
||||
}
|
||||
|
||||
// 定期检查未读消息
|
||||
let pollTimer = null
|
||||
let wsUnsubscribe = null
|
||||
|
||||
const checkUnread = () => {
|
||||
getUnreadCount().then(res => {
|
||||
totalUnread.value = res.data.count || 0
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkUnread()
|
||||
pollTimer = setInterval(checkUnread, 30000)
|
||||
|
||||
// 监听 WebSocket 消息,增加未读计数
|
||||
wsUnsubscribe = chatService.onMessage((data) => {
|
||||
if (data.type === 'chat') {
|
||||
const isSelf = Number(data.message.fromUserId) === Number(userStore.userId)
|
||||
// 只在对方发来消息且聊天窗口未打开时增加未读
|
||||
if (!isSelf && !chatVisible.value) {
|
||||
totalUnread.value++
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
if (wsUnsubscribe) wsUnsubscribe()
|
||||
})
|
||||
|
||||
const handleUserCommand = (command) => {
|
||||
if (command === 'profile') {
|
||||
profileVisible.value = true
|
||||
} else if (command === 'logout') {
|
||||
ElMessageBox.confirm('确定退出登录吗?', '提示', { type: 'warning' }).then(() => {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.top-navbar {
|
||||
height: 50px;
|
||||
background: #009688;
|
||||
border-bottom: 1px solid #00796b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.system-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.msg-badge :deep(.el-badge__content) {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.msg-badge.has-unread :deep(.el-badge__content) {
|
||||
animation: flash 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.navbar-icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.navbar-icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 20px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #ffffff;
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
168
web-vue/src/components/UploadDialog.vue
Normal file
168
web-vue/src/components/UploadDialog.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="上传文件"
|
||||
width="650px"
|
||||
class="custom-dialog"
|
||||
>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
drag
|
||||
multiple
|
||||
:limit="11"
|
||||
:auto-upload="false"
|
||||
:on-change="handleChange"
|
||||
:on-exceed="handleExceed"
|
||||
:file-list="fileList"
|
||||
:show-file-list="false"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">拖拽文件到此处,或<em>点击选择</em>(最多10个)</div>
|
||||
</el-upload>
|
||||
|
||||
<div class="file-list-preview" v-if="fileList.length > 0">
|
||||
<div class="file-list-header">
|
||||
<span>文件名</span>
|
||||
<span>大小</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
<div class="file-list-body">
|
||||
<div v-for="(file, index) in fileList" :key="file.uid" class="file-list-item">
|
||||
<span class="file-name-col">{{ file.name }}</span>
|
||||
<span class="file-size-col">{{ formatSize(file.size) }}</span>
|
||||
<span class="file-action-col">
|
||||
<el-button type="danger" link @click="removeFile(index)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
<span style="margin-left: 4px">取消</span>
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleUpload" :loading="uploading">
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span style="margin-left: 4px">开始上传</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { UploadFilled, Close, Upload, Delete } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
uploading: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'upload'])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const fileList = ref([])
|
||||
|
||||
const handleChange = (file, list) => {
|
||||
if (list.length > 10) {
|
||||
ElMessage.warning('最多一次上传10个文件')
|
||||
fileList.value = list.slice(0, 10)
|
||||
return
|
||||
}
|
||||
fileList.value = list
|
||||
}
|
||||
|
||||
const handleExceed = () => {
|
||||
ElMessage.warning('最多一次上传10个文件')
|
||||
}
|
||||
|
||||
const removeFile = (index) => {
|
||||
fileList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (!size) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024
|
||||
i++
|
||||
}
|
||||
return size.toFixed(2) + ' ' + units[i]
|
||||
}
|
||||
|
||||
const handleUpload = () => {
|
||||
if (fileList.value.length === 0) {
|
||||
ElMessage.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
emit('upload', fileList.value.map(f => f.raw))
|
||||
}
|
||||
|
||||
// 对外暴露方法
|
||||
defineExpose({
|
||||
clear: () => { fileList.value = [] },
|
||||
close: () => { visible.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-list-preview {
|
||||
margin-top: 16px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: #f5f7fa;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.file-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-name-col {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.file-size-col {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.file-action-col {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user