云文件系统初始化

This commit is contained in:
2026-04-01 22:39:11 +08:00
commit 3a20f6e7ed
74 changed files with 8693 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>