Compare commits

...

16 Commits

Author SHA1 Message Date
d47c60a5e0 云文件管理系统上传组件优化 2026-04-03 18:10:54 +08:00
242a4347df fix: 搜索时folderId条件始终生效,根目录folderId为null则搜全部,子目录搜本目录 2026-04-03 18:04:20 +08:00
70c69e16cc fix: 搜索时忽略folderId条件,跨子文件夹搜索文件 2026-04-03 17:52:45 +08:00
a1b17c11c1 fix: 区分500服务器错误和网络错误提示 2026-04-03 17:50:04 +08:00
bdd6c0828c fix: 登录注册页区分网络错误和业务错误提示 2026-04-03 17:47:33 +08:00
8a14c5244e fix: 后端探测加5秒超时,避免刷新长时间等待 2026-04-03 17:45:16 +08:00
4d1003e467 fix: 路由守卫进入需认证页面前验证后端可用性 2026-04-03 17:41:22 +08:00
f55ff5fc24 fix: 后端不可用时自动跳转到登录页 2026-04-03 17:37:34 +08:00
4c22109a95 fix: 放开/api/messages/conversation/**安全拦截 2026-04-03 17:35:49 +08:00
a4a18f2b82 fix: 删除会话图标改为Delete直显 2026-04-03 17:30:30 +08:00
a9ae4bce44 fix: 删除会话改用el-popover实现,点击生效 2026-04-03 17:26:34 +08:00
5367ad61db fix: 聊天删除按钮tooltip显示+点击生效 2026-04-03 17:23:12 +08:00
3f4a294128 refactor: 聊天删除按钮改用 el-dropdown 组件 2026-04-03 17:20:28 +08:00
2ee194ebdd fix: 聊天最近列表删除按钮始终可见 2026-04-03 17:18:38 +08:00
4ac6e85aca feat: 消息聊天最近列表支持删除会话 2026-04-03 17:15:02 +08:00
dc666897c6 fix: 共享去重 + 共享文件夹内容可查看 2026-04-03 16:52:05 +08:00
11 changed files with 160 additions and 31 deletions

View File

@@ -50,7 +50,7 @@ public class SecurityConfig {
// WebSocket // WebSocket
.requestMatchers("/ws/**").permitAll() .requestMatchers("/ws/**").permitAll()
// 静态资源 // 静态资源
.requestMatchers("/webapp/**", "/assets/**", "/uploads/**", "/files/avatar/**", "/api/files/avatar/**", "/api/messages/file/**").permitAll() .requestMatchers("/webapp/**", "/assets/**", "/uploads/**", "/files/avatar/**", "/api/files/avatar/**", "/api/messages/file/**", "/api/messages/conversation/**").permitAll()
// 前端页面路由(所有非 /api/ 的路由,由 Vue Router 处理) // 前端页面路由(所有非 /api/ 的路由,由 Vue Router 处理)
.requestMatchers("/", "/login", "/register", "/desktop/**", "/favicon.ico").permitAll() .requestMatchers("/", "/login", "/register", "/desktop/**", "/favicon.ico").permitAll()
// 其他所有请求需要认证 // 其他所有请求需要认证

View File

@@ -276,4 +276,12 @@ public class MessageController {
messageService.markAsRead(id); messageService.markAsRead(id);
return ResponseEntity.ok(Map.of("message", "已标记已读")); return ResponseEntity.ok(Map.of("message", "已标记已读"));
} }
@DeleteMapping("/conversation/{withUserId}")
public ResponseEntity<?> deleteConversation(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable Long withUserId) {
messageService.deleteConversation(principal.getUserId(), withUserId);
return ResponseEntity.ok(Map.of("message", "已删除"));
}
} }

View File

@@ -48,14 +48,21 @@ public class FileService {
wrapper.eq(FileEntity::getUserId, userId) wrapper.eq(FileEntity::getUserId, userId)
.eq(FileEntity::getIsDeleted, 0); .eq(FileEntity::getIsDeleted, 0);
if (folderId != null) { boolean hasKeyword = keyword != null && !keyword.isEmpty();
wrapper.eq(FileEntity::getFolderId, folderId);
} else {
wrapper.isNull(FileEntity::getFolderId);
}
if (keyword != null && !keyword.isEmpty()) { if (hasKeyword) {
// 有搜索关键词:根目录搜索查所有,子目录搜索限当前目录
if (folderId != null) {
wrapper.eq(FileEntity::getFolderId, folderId);
}
wrapper.like(FileEntity::getName, keyword); wrapper.like(FileEntity::getName, keyword);
} else {
// 无搜索关键词:正常浏览当前目录
if (folderId != null) {
wrapper.eq(FileEntity::getFolderId, folderId);
} else {
wrapper.isNull(FileEntity::getFolderId);
}
} }
wrapper.orderByDesc(FileEntity::getIsFolder) wrapper.orderByDesc(FileEntity::getIsFolder)
@@ -344,9 +351,13 @@ public class FileService {
new LambdaQueryWrapper<FileShare>().eq(FileShare::getShareToUserId, userId) new LambdaQueryWrapper<FileShare>().eq(FileShare::getShareToUserId, userId)
); );
// 按 fileId 去重,只保留一条记录
return shares.stream() return shares.stream()
.map(share -> fileMapper.selectById(share.getFileId())) .map(share -> fileMapper.selectById(share.getFileId()))
.filter(f -> f != null && f.getIsDeleted() == 0) .filter(f -> f != null && f.getIsDeleted() == 0)
.collect(Collectors.toMap(FileEntity::getId, f -> f, (a, b) -> a))
.values()
.stream()
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@@ -358,7 +369,7 @@ public class FileService {
return new ArrayList<>(); return new ArrayList<>();
} }
// 检查是否有共享权限 // 检查是否有共享权限(当前用户是被共享的对象)
List<FileShare> shares = fileShareMapper.selectList( List<FileShare> shares = fileShareMapper.selectList(
new LambdaQueryWrapper<FileShare>() new LambdaQueryWrapper<FileShare>()
.eq(FileShare::getFileId, folderId) .eq(FileShare::getFileId, folderId)
@@ -369,13 +380,33 @@ public class FileService {
return new ArrayList<>(); return new ArrayList<>();
} }
// 返回该文件夹内的文件 // 用文件夹原主人的 ID 查询子文件(属于文件夹所有者的文件)
LambdaQueryWrapper<FileEntity> wrapper = new LambdaQueryWrapper<>(); Long ownerId = folder.getUserId();
wrapper.eq(FileEntity::getFolderId, folderId)
.eq(FileEntity::getIsDeleted, 0) // 返回该文件夹内的文件(包括自己上传的和别人共享进来的)
.isNull(FileEntity::getDeletedAt); // 属于 owner 的文件:用 ownerId 查
LambdaQueryWrapper<FileEntity> ownerWrapper = new LambdaQueryWrapper<>();
ownerWrapper.eq(FileEntity::getFolderId, folderId)
.eq(FileEntity::getUserId, ownerId)
.eq(FileEntity::getIsDeleted, 0);
return fileMapper.selectList(wrapper); List<FileEntity> results = new ArrayList<>(fileMapper.selectList(ownerWrapper));
// 属于当前用户的文件(通过共享进入的子文件夹),去掉 deleted 条件
LambdaQueryWrapper<FileEntity> userWrapper = new LambdaQueryWrapper<>();
userWrapper.eq(FileEntity::getFolderId, folderId)
.ne(FileEntity::getUserId, ownerId)
.eq(FileEntity::getIsDeleted, 0);
List<FileEntity> sharedChildren = fileMapper.selectList(userWrapper);
for (FileEntity child : sharedChildren) {
// 只有当前用户有权访问的才加入(直接共享或通过父文件夹共享)
if (canAccessFile(child.getId(), userId)) {
results.add(child);
}
}
return results;
} catch (Exception e) { } catch (Exception e) {
return new ArrayList<>(); return new ArrayList<>();
} }

View File

@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.filesystem.entity.Message; import com.filesystem.entity.Message;
import com.filesystem.mapper.MessageMapper; import com.filesystem.mapper.MessageMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.*; import java.util.*;
@@ -14,8 +16,16 @@ public class MessageService {
@Resource @Resource
private MessageMapper messageMapper; private MessageMapper messageMapper;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String CONV_DELETED_KEY = "msg:del:";
public List<Message> getMessages(Long userId1, Long userId2) { public List<Message> getMessages(Long userId1, Long userId2) {
String convKey = buildConvKey(userId1, userId2);
String deletedKey = CONV_DELETED_KEY + userId1 + ":" + convKey;
return messageMapper.selectList( return messageMapper.selectList(
new LambdaQueryWrapper<Message>() new LambdaQueryWrapper<Message>()
.and(wrapper -> wrapper .and(wrapper -> wrapper
@@ -26,6 +36,31 @@ public class MessageService {
.orderByAsc(Message::getCreateTime) .orderByAsc(Message::getCreateTime)
); );
} }
/**
* 删除与某个用户的聊天记录(双向,本人会看不到)
*/
public void deleteConversation(Long userId, Long withUserId) {
String convKey = buildConvKey(userId, withUserId);
String deletedKey = CONV_DELETED_KEY + userId + ":" + convKey;
// 保留7天
redisTemplate.opsForValue().set(deletedKey, "1", java.time.Duration.ofDays(7));
}
/**
* 检查与某人的对话是否已被当前用户删除
*/
public boolean isConversationDeleted(Long userId, Long withUserId) {
String convKey = buildConvKey(userId, withUserId);
String deletedKey = CONV_DELETED_KEY + userId + ":" + convKey;
return Boolean.TRUE.equals(redisTemplate.hasKey(deletedKey));
}
private String buildConvKey(Long userId1, Long userId2) {
long min = Math.min(userId1, userId2);
long max = Math.max(userId1, userId2);
return min + "_" + max;
}
public Message sendMessage(Long fromUserId, Long toUserId, String content, String type) { public Message sendMessage(Long fromUserId, Long toUserId, String content, String type) {
Message message = new Message(); Message message = new Message();

View File

@@ -3,15 +3,15 @@ server:
spring: spring:
datasource: datasource:
url: jdbc:mysql://192.168.31.182:13306/system?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true url: jdbc:mysql://127.0.0.1:13306/chat_app?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true
username: dream username: root
password: info_dream password: root_dream
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
data: data:
redis: redis:
host: 192.168.31.194 host: 127.0.0.1
port: 16379 port: 6379
database: 0 database: 0
timeout: 10000ms timeout: 10000ms
password: admin password: admin

View File

@@ -10,6 +10,8 @@ export const getUnreadList = () => request.get('/messages/unreadList')
export const markAsRead = (id) => request.post(`/messages/${id}/read`) export const markAsRead = (id) => request.post(`/messages/${id}/read`)
export const deleteConversation = (withUserId) => request.delete(`/messages/conversation/${withUserId}`)
export const getUsers = () => request.get('/messages/users') export const getUsers = () => request.get('/messages/users')
// 聊天文件上传(图片和文件统一接口) // 聊天文件上传(图片和文件统一接口)

View File

@@ -19,6 +19,22 @@ request.interceptors.request.use(
request.interceptors.response.use( request.interceptors.response.use(
response => response.data, response => response.data,
error => { error => {
// 后端不可用(网络错误、无响应),跳转到登录页
if (!error.response) {
const isLoginPage = window.location.pathname === '/login'
if (!isLoginPage) {
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('userId')
localStorage.removeItem('nickname')
localStorage.removeItem('avatar')
localStorage.removeItem('storageUsed')
localStorage.removeItem('storageLimit')
window.location.href = '/login'
}
return Promise.reject(error)
}
const status = error.response?.status const status = error.response?.status
// 只有 401/403 才清理 token 并跳转登录页 // 只有 401/403 才清理 token 并跳转登录页

View File

@@ -39,9 +39,11 @@
<div class="sidebar-item-name">{{ chat.name }}</div> <div class="sidebar-item-name">{{ chat.name }}</div>
<div class="sidebar-item-msg">{{ chat.lastMsg || '暂无消息' }}</div> <div class="sidebar-item-msg">{{ chat.lastMsg || '暂无消息' }}</div>
</div> </div>
<el-button v-if="chat.lastMsg" class="delete-btn" link type="danger" @click.stop="deleteRecentChat(chat)"> <el-tooltip content="删除会话" placement="top">
<el-icon><Close /></el-icon> <el-button link type="danger" @click.stop="deleteRecentChat(chat)">
</el-button> <el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
</div> </div>
<div v-if="recentChats.length === 0" class="sidebar-empty">暂无最近聊天</div> <div v-if="recentChats.length === 0" class="sidebar-empty">暂无最近聊天</div>
</div> </div>
@@ -215,9 +217,9 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { ChatLineRound, User, Search, Close, Loading, Picture, Paperclip, Download, ChatDotRound, Promotion } from '@element-plus/icons-vue' import { ChatLineRound, User, Search, Loading, Picture, Paperclip, Download, ChatDotRound, Promotion, Delete } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { getUsers, getMessages, sendMessage as sendMessageApi, uploadChatFile, getUnreadList } from '@/api/message' import { getUsers, getMessages, sendMessage as sendMessageApi, uploadChatFile, getUnreadList, deleteConversation } from '@/api/message'
import { chatService } from '@/services/chat' import { chatService } from '@/services/chat'
const props = defineProps({ modelValue: Boolean }) const props = defineProps({ modelValue: Boolean })
@@ -571,7 +573,12 @@ const downloadFile = async (msg) => {
// ======== 其他操作 ======== // ======== 其他操作 ========
const deleteRecentChat = (chat) => { const deleteRecentChat = async (chat) => {
try {
await deleteConversation(chat.id)
} catch (e) {
// 即使 API 失败也删本地
}
const idx = recentChats.value.findIndex(c => c.id === chat.id) const idx = recentChats.value.findIndex(c => c.id === chat.id)
if (idx > -1) recentChats.value.splice(idx, 1) if (idx > -1) recentChats.value.splice(idx, 1)
if (currentContact.value?.id === chat.id) currentContact.value = null if (currentContact.value?.id === chat.id) currentContact.value = null
@@ -684,8 +691,6 @@ onUnmounted(() => { if (unsubscribeWs) unsubscribeWs() })
.sidebar-item-status { font-size: 12px; color: #909399; } .sidebar-item-status { font-size: 12px; color: #909399; }
.sidebar-item-status.online { color: #67c23a; } .sidebar-item-status.online { color: #67c23a; }
.sidebar-empty { padding: 20px; text-align: center; color: #909399; font-size: 12px; } .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-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-header { display: flex; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid #e4e7ed; }

View File

@@ -44,13 +44,33 @@ const router = createRouter({
routes routes
}) })
router.beforeEach((to, from, next) => { router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) { if (to.meta.requiresAuth && !token) {
next('/login') next('/login')
} else if (to.path === '/login' && token) { } else if (to.path === '/login' && token) {
next('/desktop') next('/desktop')
} else if (to.meta.requiresAuth && token) {
// 有 token 且要进需认证页面先验证后端是否可用5秒超时
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const res = await fetch('/api/files/test', { signal: controller.signal })
clearTimeout(timeoutId)
if (!res.ok) throw new Error('backend error')
next()
} catch {
// 后端不可用,清除登录状态跳转登录页
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('userId')
localStorage.removeItem('nickname')
localStorage.removeItem('avatar')
localStorage.removeItem('storageUsed')
localStorage.removeItem('storageLimit')
window.location.href = '/login'
}
} else { } else {
next() next()
} }

View File

@@ -61,7 +61,13 @@ const handleLogin = async () => {
// res.data = { token, user } // res.data = { token, user }
emit('success', res.data) emit('success', res.data)
} catch (e) { } catch (e) {
ElMessage.error(e.response?.data?.message || '账号或密码错误') if (!e.response) {
ElMessage.error('后端服务不可用,请稍后重试')
} else if (e.response.status >= 500) {
ElMessage.error('服务器异常,请稍后重试')
} else {
ElMessage.error(e.response.data?.message || '账号或密码错误')
}
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -107,7 +107,13 @@ const handleRegister = async () => {
ElMessage.success('注册成功,请登录') ElMessage.success('注册成功,请登录')
emit('success') emit('success')
} catch (e) { } catch (e) {
ElMessage.error(e.response?.data?.message || '注册失败,请重试') if (!e.response) {
ElMessage.error('后端服务不可用,请稍后重试')
} else if (e.response.status >= 500) {
ElMessage.error('服务器异常,请稍后重试')
} else {
ElMessage.error(e.response.data?.message || '注册失败,请重试')
}
} finally { } finally {
loading.value = false loading.value = false
} }