From c503018843abceb454a6d300eba4dd890b688242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=91=E5=AE=81?= <1772105645@qq.com> Date: Sun, 17 May 2026 22:47:38 +0800 Subject: [PATCH] feat: add file management module and enhance IM chat UI --- apps/web-antd/.env.development | 4 + apps/web-antd/src/api/core/file-management.ts | 84 +++ apps/web-antd/src/api/core/im.ts | 21 +- apps/web-antd/src/api/core/index.ts | 3 +- apps/web-antd/src/api/core/signalr.ts | 41 +- apps/web-antd/src/api/request.ts | 64 +++ .../src/locales/langs/en-US/page.json | 24 + .../src/locales/langs/zh-CN/page.json | 24 + apps/web-antd/src/preferences.ts | 1 + .../router/routes/modules/file-management.ts | 28 + apps/web-antd/src/store/im.ts | 4 +- .../src/views/file-management/files/index.vue | 482 ++++++++++++++++++ apps/web-antd/src/views/im/index.vue | 360 ++++++++++--- apps/web-antd/vite.config.ts | 5 + 14 files changed, 1045 insertions(+), 100 deletions(-) create mode 100644 apps/web-antd/src/api/core/file-management.ts create mode 100644 apps/web-antd/src/router/routes/modules/file-management.ts create mode 100644 apps/web-antd/src/views/file-management/files/index.vue diff --git a/apps/web-antd/.env.development b/apps/web-antd/.env.development index c138f48..4bd575c 100644 --- a/apps/web-antd/.env.development +++ b/apps/web-antd/.env.development @@ -14,3 +14,7 @@ VITE_DEVTOOLS=false # 是否注入全局loading VITE_INJECT_APP_LOADING=true + +# 文件系统服务 +VITE_FILE_API_URL=/file-api +VITE_FILE_API_KEY=your-api-key diff --git a/apps/web-antd/src/api/core/file-management.ts b/apps/web-antd/src/api/core/file-management.ts new file mode 100644 index 0000000..d5bda37 --- /dev/null +++ b/apps/web-antd/src/api/core/file-management.ts @@ -0,0 +1,84 @@ +import { fileRequestClient } from '#/api/request'; + +// --- 类型定义(file_system Go 响应为 PascalCase) --- + +export namespace FileApi { + export interface FileInfo { + Key: string; + Size: number; + LastModified: string; + ETag: string; + } + + export interface ListFilesResult { + Files: FileInfo[]; + NextContinuationToken: string | null; + } + + export interface ListFilesParams { + bucket_name: string; + prefix?: string; + max_keys?: number; + token?: string; + } +} + +// --- 文件操作 --- + +export async function listFilesApi(params: FileApi.ListFilesParams) { + return fileRequestClient.get('/files/list', { + params, + }); +} + +export async function uploadFileApi(bucketName: string, file: File) { + const formData = new FormData(); + formData.append('bucket_name', bucketName); + formData.append('file', file); + return fileRequestClient.post<{ message: string }>('/files/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); +} + +export async function deleteFileApi(bucketName: string, objectKey: string) { + return fileRequestClient.delete<{ message: string }>('/files/delete', { + data: { bucket_name: bucketName, object_key: objectKey }, + }); +} + +export async function downloadFileApi(bucketName: string, objectKey: string) { + return fileRequestClient.get('/files/download', { + params: { bucket_name: bucketName, object_key: objectKey }, + responseType: 'blob', + }); +} + +export async function getFilePreviewApi(bucketName: string, objectKey: string) { + return fileRequestClient.get<{ url: string }>('/files/preview', { + params: { bucket_name: bucketName, object_key: objectKey }, + }); +} + +export async function getFileContentApi(bucketName: string, objectKey: string) { + return fileRequestClient.get<{ content: string }>('/files/content', { + params: { bucket_name: bucketName, object_key: objectKey }, + }); +} + +// --- 存储桶操作 --- + +export async function listBucketsApi() { + return fileRequestClient.get<{ buckets: string[] }>('/buckets'); +} + +export async function createBucketApi(bucketName: string) { + return fileRequestClient.post<{ message: string }>('/buckets', { + bucket_name: bucketName, + }); +} + +export async function deleteBucketApi(bucketName: string) { + return fileRequestClient.delete<{ message: string }>('/buckets', { + data: { bucket_name: bucketName }, + }); +} diff --git a/apps/web-antd/src/api/core/im.ts b/apps/web-antd/src/api/core/im.ts index fb453fc..b8344a2 100644 --- a/apps/web-antd/src/api/core/im.ts +++ b/apps/web-antd/src/api/core/im.ts @@ -1,10 +1,11 @@ -import { RequestClient } from '@vben/request'; +import { defaultResponseInterceptor, RequestClient } from '@vben/request'; import { useAccessStore } from '@vben/stores'; import { requestClient } from '#/api/request'; const imRequestClient = new RequestClient({ baseURL: 'http://localhost:5212/api', + responseReturn: 'data', }); imRequestClient.addRequestInterceptor({ @@ -17,15 +18,13 @@ imRequestClient.addRequestInterceptor({ }, }); -imRequestClient.addResponseInterceptor({ - fulfilled: (response) => { - const data = response.data as any; - if (data?.code === 0) { - response.data = data.data; - } - return response; - }, -}); +imRequestClient.addResponseInterceptor( + defaultResponseInterceptor({ + codeField: 'code', + dataField: 'data', + successCode: 0, + }), +); export namespace ImApi { export interface Conversation { @@ -135,7 +134,7 @@ export async function createGroupApi(data: { export async function updateGroupApi( groupId: string, - data: { avatar?: string; description?: string; name?: string; }, + data: { avatar?: string; description?: string; name?: string }, ) { return imRequestClient.put(`/groups/${groupId}`, data); } diff --git a/apps/web-antd/src/api/core/index.ts b/apps/web-antd/src/api/core/index.ts index 02598d9..c4e6c80 100644 --- a/apps/web-antd/src/api/core/index.ts +++ b/apps/web-antd/src/api/core/index.ts @@ -1,5 +1,6 @@ export * from './auth'; +export * from './file-management'; export * from './im'; export * from './menu'; -export * from './user'; export { signalRService } from './signalr'; +export * from './user'; diff --git a/apps/web-antd/src/api/core/signalr.ts b/apps/web-antd/src/api/core/signalr.ts index 765c62e..c8ec08d 100644 --- a/apps/web-antd/src/api/core/signalr.ts +++ b/apps/web-antd/src/api/core/signalr.ts @@ -1,22 +1,25 @@ -import * as signalR from '@microsoft/signalr'; -import { useAccessStore } from '@vben/stores'; - import type { ImApi } from './im'; -class SignalRService { - private connection: signalR.HubConnection | null = null; - private reconnectAttempts = 0; - private maxReconnectAttempts = 10; +import { useAccessStore } from '@vben/stores'; - onMessageReceived: ((message: ImApi.Message) => void) | null = null; +import * as signalR from '@microsoft/signalr'; + +class SignalRService { onMessageRead: | ((data: { messageId: string; userId: string }) => void) | null = null; + onMessageReceived: ((message: ImApi.Message) => void) | null = null; + onUserOffline: ((data: { userId: string }) => void) | null = null; + + onUserOnline: ((data: { userId: string }) => void) | null = null; onUserTyping: | ((data: { conversationId: string; userId: string }) => void) | null = null; - onUserOnline: ((data: { userId: string }) => void) | null = null; - onUserOffline: ((data: { userId: string }) => void) | null = null; + get isConnected() { + return this.connection?.state === signalR.HubConnectionState.Connected; + } + private connection: null | signalR.HubConnection = null; + private reconnectAttempts = 0; async connect() { const accessStore = useAccessStore(); @@ -28,7 +31,7 @@ class SignalRService { .withUrl('http://localhost:5212/hubs/chat', { accessTokenFactory: () => token, }) - .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) + .withAutomaticReconnect([0, 2000, 5000, 10_000, 30_000]) .configureLogging(signalR.LogLevel.Warning) .build(); @@ -80,27 +83,23 @@ class SignalRService { } } + async markAsRead(conversationId: string, messageId: string) { + await this.connection?.invoke('MarkAsRead', { conversationId, messageId }); + } + async sendMessage(request: { - conversationId: string; - contentType: number; content: string; + contentType: number; + conversationId: string; metadata?: any; replyToId?: string; }) { await this.connection?.invoke('SendMessage', request); } - async markAsRead(conversationId: string, messageId: string) { - await this.connection?.invoke('MarkAsRead', { conversationId, messageId }); - } - async typing(conversationId: string) { await this.connection?.invoke('Typing', conversationId); } - - get isConnected() { - return this.connection?.state === signalR.HubConnectionState.Connected; - } } export const signalRService = new SignalRService(); diff --git a/apps/web-antd/src/api/request.ts b/apps/web-antd/src/api/request.ts index 288dddd..3f41c68 100644 --- a/apps/web-antd/src/api/request.ts +++ b/apps/web-antd/src/api/request.ts @@ -111,3 +111,67 @@ export const requestClient = createRequestClient(apiURL, { }); export const baseRequestClient = new RequestClient({ baseURL: apiURL }); + +// 文件系统独立客户端(file_system 服务,端口 8080) +const fileApiURL = import.meta.env.VITE_FILE_API_URL || '/file-api'; +const fileApiKey = import.meta.env.VITE_FILE_API_KEY || ''; + +const fileClient = new RequestClient({ baseURL: fileApiURL }); + +fileClient.addRequestInterceptor({ + fulfilled: async (config) => { + if (fileApiKey) { + config.headers['X-API-Key'] = fileApiKey; + } + const accessStore = useAccessStore(); + if (accessStore.accessToken) { + config.headers.Authorization = `Bearer ${accessStore.accessToken}`; + } + return config; + }, +}); + +fileClient.addResponseInterceptor({ + fulfilled: (response) => response.data, + rejected: (error) => { + const data = error?.response?.data; + const msg = data?.error || data?.message || error?.message || '请求失败'; + message.error(msg); + return Promise.reject(error); + }, +}); + +async function fileRefreshToken() { + const accessStore = useAccessStore(); + const resp = await refreshTokenApi(); + const newToken = resp.data; + accessStore.setAccessToken(newToken); + return newToken; +} + +async function fileReAuthenticate() { + const accessStore = useAccessStore(); + const authStore = useAuthStore(); + accessStore.setAccessToken(null); + if ( + preferences.app.loginExpiredMode === 'modal' && + accessStore.isAccessChecked + ) { + accessStore.setLoginExpired(true); + } else { + await authStore.logout(); + } +} + +fileClient.addResponseInterceptor( + authenticateResponseInterceptor({ + client: fileClient, + doReAuthenticate: fileReAuthenticate, + doRefreshToken: fileRefreshToken, + enableRefreshToken: preferences.app.enableRefreshToken, + formatToken: (token: null | string) => + token ? `Bearer ${token}` : null, + }), +); + +export const fileRequestClient = fileClient; diff --git a/apps/web-antd/src/locales/langs/en-US/page.json b/apps/web-antd/src/locales/langs/en-US/page.json index 39f1641..a8fe699 100644 --- a/apps/web-antd/src/locales/langs/en-US/page.json +++ b/apps/web-antd/src/locales/langs/en-US/page.json @@ -11,5 +11,29 @@ "title": "Dashboard", "analytics": "Analytics", "workspace": "Workspace" + }, + "fileManagement": { + "title": "File Management", + "files": "File Browser", + "upload": "Upload", + "delete": "Delete", + "deleteConfirm": "Are you sure you want to delete {name}? This action cannot be undone.", + "download": "Download", + "preview": "Preview", + "bucket": "Bucket", + "selectBucket": "Select a bucket", + "searchPlaceholder": "Search file prefix...", + "noFiles": "No files found", + "loadMore": "Load More", + "uploadSuccess": "File uploaded successfully", + "deleteSuccess": "File deleted successfully", + "fileName": "File Name", + "fileSize": "Size", + "lastModified": "Last Modified", + "actions": "Actions", + "createBucket": "Create Bucket", + "bucketName": "Bucket Name", + "deleteBucket": "Delete Bucket", + "deleteBucketConfirm": "Are you sure you want to delete bucket {name}? The bucket must be empty." } } diff --git a/apps/web-antd/src/locales/langs/zh-CN/page.json b/apps/web-antd/src/locales/langs/zh-CN/page.json index 2192d1d..21d56ee 100644 --- a/apps/web-antd/src/locales/langs/zh-CN/page.json +++ b/apps/web-antd/src/locales/langs/zh-CN/page.json @@ -11,5 +11,29 @@ "title": "概览", "analytics": "分析页", "workspace": "工作台" + }, + "fileManagement": { + "title": "文件管理", + "files": "文件浏览", + "upload": "上传文件", + "delete": "删除", + "deleteConfirm": "确定要删除文件 {name} 吗?此操作不可撤销。", + "download": "下载", + "preview": "预览", + "bucket": "存储桶", + "selectBucket": "请选择存储桶", + "searchPlaceholder": "搜索文件名前缀...", + "noFiles": "暂无文件", + "loadMore": "加载更多", + "uploadSuccess": "上传成功", + "deleteSuccess": "删除成功", + "fileName": "文件名", + "fileSize": "大小", + "lastModified": "修改时间", + "actions": "操作", + "createBucket": "创建存储桶", + "bucketName": "存储桶名称", + "deleteBucket": "删除存储桶", + "deleteBucketConfirm": "确定要删除存储桶 {name} 吗?存储桶必须为空才能删除。" } } diff --git a/apps/web-antd/src/preferences.ts b/apps/web-antd/src/preferences.ts index 3f56c47..1f9d5b7 100644 --- a/apps/web-antd/src/preferences.ts +++ b/apps/web-antd/src/preferences.ts @@ -19,6 +19,7 @@ export const overridesPreferences = defineOverridesPreferences({ // overrides app: { name: import.meta.env.VITE_APP_TITLE, + enableRefreshToken: true, }, }); diff --git a/apps/web-antd/src/router/routes/modules/file-management.ts b/apps/web-antd/src/router/routes/modules/file-management.ts new file mode 100644 index 0000000..f91bf34 --- /dev/null +++ b/apps/web-antd/src/router/routes/modules/file-management.ts @@ -0,0 +1,28 @@ +import type { RouteRecordRaw } from 'vue-router'; + +import { $t } from '#/locales'; + +const routes: RouteRecordRaw[] = [ + { + meta: { + icon: 'lucide:folder-open', + order: 10, + title: $t('page.fileManagement.title'), + }, + name: 'FileManagement', + path: '/file-management', + children: [ + { + name: 'FileManager', + path: '/file-manager', + component: () => import('#/views/file-management/files/index.vue'), + meta: { + icon: 'lucide:hard-drive', + title: $t('page.fileManagement.files'), + }, + }, + ], + }, +]; + +export default routes; diff --git a/apps/web-antd/src/store/im.ts b/apps/web-antd/src/store/im.ts index b40317e..9fc8c55 100644 --- a/apps/web-antd/src/store/im.ts +++ b/apps/web-antd/src/store/im.ts @@ -161,9 +161,7 @@ export const useImStore = defineStore('im', () => { try { const userStore = useUserStore(); const data = await searchUsersApi(query); - contacts.value = data.filter( - (u) => u.id !== userStore.userInfo?.userId, - ); + contacts.value = data.filter((u) => u.id !== userStore.userInfo?.userId); } finally { contactsLoading.value = false; } diff --git a/apps/web-antd/src/views/file-management/files/index.vue b/apps/web-antd/src/views/file-management/files/index.vue new file mode 100644 index 0000000..77933f9 --- /dev/null +++ b/apps/web-antd/src/views/file-management/files/index.vue @@ -0,0 +1,482 @@ + + + diff --git a/apps/web-antd/src/views/im/index.vue b/apps/web-antd/src/views/im/index.vue index 26627c9..18fd5db 100644 --- a/apps/web-antd/src/views/im/index.vue +++ b/apps/web-antd/src/views/im/index.vue @@ -1,32 +1,61 @@ @@ -213,13 +355,9 @@ onUnmounted(() => { {{ sortedConversations.length }} 个会话 - +
{ :key="conv.id" class="flex cursor-pointer items-center px-4 py-3 transition-colors hover:bg-accent" :class="{ - 'bg-primary/10 dark:bg-accent': - conv.id === activeConversationId, + 'bg-primary/10 dark:bg-accent': conv.id === activeConversationId, }" @click="selectConversation(conv.id)" > - + {{ conv.groupName?.charAt(0) || '群' }} - - + + {{ conv.otherUserName?.charAt(0) || 'U' }} - +
@@ -252,7 +389,7 @@ onUnmounted(() => {
- {
@@ -321,15 +456,16 @@ onUnmounted(() => { : activeConversation.otherUserName || '私聊' }} - 群管理 - +
+
{ : 'bg-muted text-foreground' " > + + + + + -