feat: add file management module and enhance IM chat UI
This commit is contained in:
parent
e75e364e64
commit
c503018843
@ -14,3 +14,7 @@ VITE_DEVTOOLS=false
|
|||||||
|
|
||||||
# 是否注入全局loading
|
# 是否注入全局loading
|
||||||
VITE_INJECT_APP_LOADING=true
|
VITE_INJECT_APP_LOADING=true
|
||||||
|
|
||||||
|
# 文件系统服务
|
||||||
|
VITE_FILE_API_URL=/file-api
|
||||||
|
VITE_FILE_API_KEY=your-api-key
|
||||||
|
|||||||
84
apps/web-antd/src/api/core/file-management.ts
Normal file
84
apps/web-antd/src/api/core/file-management.ts
Normal file
@ -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<FileApi.ListFilesResult>('/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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import { RequestClient } from '@vben/request';
|
import { defaultResponseInterceptor, RequestClient } from '@vben/request';
|
||||||
import { useAccessStore } from '@vben/stores';
|
import { useAccessStore } from '@vben/stores';
|
||||||
|
|
||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
const imRequestClient = new RequestClient({
|
const imRequestClient = new RequestClient({
|
||||||
baseURL: 'http://localhost:5212/api',
|
baseURL: 'http://localhost:5212/api',
|
||||||
|
responseReturn: 'data',
|
||||||
});
|
});
|
||||||
|
|
||||||
imRequestClient.addRequestInterceptor({
|
imRequestClient.addRequestInterceptor({
|
||||||
@ -17,15 +18,13 @@ imRequestClient.addRequestInterceptor({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
imRequestClient.addResponseInterceptor({
|
imRequestClient.addResponseInterceptor(
|
||||||
fulfilled: (response) => {
|
defaultResponseInterceptor({
|
||||||
const data = response.data as any;
|
codeField: 'code',
|
||||||
if (data?.code === 0) {
|
dataField: 'data',
|
||||||
response.data = data.data;
|
successCode: 0,
|
||||||
}
|
}),
|
||||||
return response;
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export namespace ImApi {
|
export namespace ImApi {
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
@ -135,7 +134,7 @@ export async function createGroupApi(data: {
|
|||||||
|
|
||||||
export async function updateGroupApi(
|
export async function updateGroupApi(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
data: { avatar?: string; description?: string; name?: string; },
|
data: { avatar?: string; description?: string; name?: string },
|
||||||
) {
|
) {
|
||||||
return imRequestClient.put<ImApi.GroupInfo>(`/groups/${groupId}`, data);
|
return imRequestClient.put<ImApi.GroupInfo>(`/groups/${groupId}`, data);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
|
export * from './file-management';
|
||||||
export * from './im';
|
export * from './im';
|
||||||
export * from './menu';
|
export * from './menu';
|
||||||
export * from './user';
|
|
||||||
export { signalRService } from './signalr';
|
export { signalRService } from './signalr';
|
||||||
|
export * from './user';
|
||||||
|
|||||||
@ -1,22 +1,25 @@
|
|||||||
import * as signalR from '@microsoft/signalr';
|
|
||||||
import { useAccessStore } from '@vben/stores';
|
|
||||||
|
|
||||||
import type { ImApi } from './im';
|
import type { ImApi } from './im';
|
||||||
|
|
||||||
class SignalRService {
|
import { useAccessStore } from '@vben/stores';
|
||||||
private connection: signalR.HubConnection | null = null;
|
|
||||||
private reconnectAttempts = 0;
|
|
||||||
private maxReconnectAttempts = 10;
|
|
||||||
|
|
||||||
onMessageReceived: ((message: ImApi.Message) => void) | null = null;
|
import * as signalR from '@microsoft/signalr';
|
||||||
|
|
||||||
|
class SignalRService {
|
||||||
onMessageRead:
|
onMessageRead:
|
||||||
| ((data: { messageId: string; userId: string }) => void)
|
| ((data: { messageId: string; userId: string }) => void)
|
||||||
| null = null;
|
| null = null;
|
||||||
|
onMessageReceived: ((message: ImApi.Message) => void) | null = null;
|
||||||
|
onUserOffline: ((data: { userId: string }) => void) | null = null;
|
||||||
|
|
||||||
|
onUserOnline: ((data: { userId: string }) => void) | null = null;
|
||||||
onUserTyping:
|
onUserTyping:
|
||||||
| ((data: { conversationId: string; userId: string }) => void)
|
| ((data: { conversationId: string; userId: string }) => void)
|
||||||
| null = null;
|
| null = null;
|
||||||
onUserOnline: ((data: { userId: string }) => void) | null = null;
|
get isConnected() {
|
||||||
onUserOffline: ((data: { userId: string }) => void) | null = null;
|
return this.connection?.state === signalR.HubConnectionState.Connected;
|
||||||
|
}
|
||||||
|
private connection: null | signalR.HubConnection = null;
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
@ -28,7 +31,7 @@ class SignalRService {
|
|||||||
.withUrl('http://localhost:5212/hubs/chat', {
|
.withUrl('http://localhost:5212/hubs/chat', {
|
||||||
accessTokenFactory: () => token,
|
accessTokenFactory: () => token,
|
||||||
})
|
})
|
||||||
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
|
.withAutomaticReconnect([0, 2000, 5000, 10_000, 30_000])
|
||||||
.configureLogging(signalR.LogLevel.Warning)
|
.configureLogging(signalR.LogLevel.Warning)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@ -80,27 +83,23 @@ class SignalRService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async markAsRead(conversationId: string, messageId: string) {
|
||||||
|
await this.connection?.invoke('MarkAsRead', { conversationId, messageId });
|
||||||
|
}
|
||||||
|
|
||||||
async sendMessage(request: {
|
async sendMessage(request: {
|
||||||
conversationId: string;
|
|
||||||
contentType: number;
|
|
||||||
content: string;
|
content: string;
|
||||||
|
contentType: number;
|
||||||
|
conversationId: string;
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
}) {
|
}) {
|
||||||
await this.connection?.invoke('SendMessage', request);
|
await this.connection?.invoke('SendMessage', request);
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAsRead(conversationId: string, messageId: string) {
|
|
||||||
await this.connection?.invoke('MarkAsRead', { conversationId, messageId });
|
|
||||||
}
|
|
||||||
|
|
||||||
async typing(conversationId: string) {
|
async typing(conversationId: string) {
|
||||||
await this.connection?.invoke('Typing', conversationId);
|
await this.connection?.invoke('Typing', conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
get isConnected() {
|
|
||||||
return this.connection?.state === signalR.HubConnectionState.Connected;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const signalRService = new SignalRService();
|
export const signalRService = new SignalRService();
|
||||||
|
|||||||
@ -111,3 +111,67 @@ export const requestClient = createRequestClient(apiURL, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const baseRequestClient = new RequestClient({ baseURL: 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;
|
||||||
|
|||||||
@ -11,5 +11,29 @@
|
|||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"workspace": "Workspace"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,5 +11,29 @@
|
|||||||
"title": "概览",
|
"title": "概览",
|
||||||
"analytics": "分析页",
|
"analytics": "分析页",
|
||||||
"workspace": "工作台"
|
"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} 吗?存储桶必须为空才能删除。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export const overridesPreferences = defineOverridesPreferences({
|
|||||||
// overrides
|
// overrides
|
||||||
app: {
|
app: {
|
||||||
name: import.meta.env.VITE_APP_TITLE,
|
name: import.meta.env.VITE_APP_TITLE,
|
||||||
|
enableRefreshToken: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
28
apps/web-antd/src/router/routes/modules/file-management.ts
Normal file
28
apps/web-antd/src/router/routes/modules/file-management.ts
Normal file
@ -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;
|
||||||
@ -161,9 +161,7 @@ export const useImStore = defineStore('im', () => {
|
|||||||
try {
|
try {
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const data = await searchUsersApi(query);
|
const data = await searchUsersApi(query);
|
||||||
contacts.value = data.filter(
|
contacts.value = data.filter((u) => u.id !== userStore.userInfo?.userId);
|
||||||
(u) => u.id !== userStore.userInfo?.userId,
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
contactsLoading.value = false;
|
contactsLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
482
apps/web-antd/src/views/file-management/files/index.vue
Normal file
482
apps/web-antd/src/views/file-management/files/index.vue
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { FileApi } from '#/api/core';
|
||||||
|
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Page } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
message,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Upload,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createBucketApi,
|
||||||
|
deleteBucketApi,
|
||||||
|
deleteFileApi,
|
||||||
|
downloadFileApi,
|
||||||
|
getFileContentApi,
|
||||||
|
getFilePreviewApi,
|
||||||
|
listBucketsApi,
|
||||||
|
listFilesApi,
|
||||||
|
uploadFileApi,
|
||||||
|
} from '#/api/core';
|
||||||
|
|
||||||
|
// --- 状态 ---
|
||||||
|
|
||||||
|
const buckets = ref<string[]>([]);
|
||||||
|
const selectedBucket = ref<string>('');
|
||||||
|
const files = ref<FileApi.FileInfo[]>([]);
|
||||||
|
const continuationToken = ref<string | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const prefix = ref('');
|
||||||
|
const searchValue = ref('');
|
||||||
|
|
||||||
|
// 预览
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const previewLoading = ref(false);
|
||||||
|
const previewUrl = ref('');
|
||||||
|
const previewContent = ref('');
|
||||||
|
const previewType = ref<'image' | 'pdf' | 'text' | null>(null);
|
||||||
|
const previewFileName = ref('');
|
||||||
|
|
||||||
|
// 创建存储桶
|
||||||
|
const createBucketVisible = ref(false);
|
||||||
|
const newBucketName = ref('');
|
||||||
|
const createBucketLoading = ref(false);
|
||||||
|
|
||||||
|
// --- 工具函数 ---
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
if (!iso) return '-';
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileExt(key: string): string {
|
||||||
|
const parts = key.split('.');
|
||||||
|
return parts.length > 1 ? parts.at(-1)!.toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(key: string): string {
|
||||||
|
const ext = getFileExt(key);
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
jpg: '🖼️',
|
||||||
|
jpeg: '🖼️',
|
||||||
|
png: '🖼️',
|
||||||
|
gif: '🖼️',
|
||||||
|
webp: '🖼️',
|
||||||
|
svg: '🖼️',
|
||||||
|
pdf: '📄',
|
||||||
|
doc: '📝',
|
||||||
|
docx: '📝',
|
||||||
|
xls: '📊',
|
||||||
|
xlsx: '📊',
|
||||||
|
ppt: '📊',
|
||||||
|
pptx: '📊',
|
||||||
|
zip: '📦',
|
||||||
|
rar: '📦',
|
||||||
|
'7z': '📦',
|
||||||
|
tar: '📦',
|
||||||
|
gz: '📦',
|
||||||
|
mp4: '🎬',
|
||||||
|
mp3: '🎵',
|
||||||
|
wav: '🎵',
|
||||||
|
md: '📋',
|
||||||
|
txt: '📋',
|
||||||
|
json: '📋',
|
||||||
|
yaml: '📋',
|
||||||
|
yml: '📋',
|
||||||
|
xml: '📋',
|
||||||
|
csv: '📋',
|
||||||
|
};
|
||||||
|
return map[ext] || '📎';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreviewable(key: string): boolean {
|
||||||
|
const ext = getFileExt(key);
|
||||||
|
return [
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'png',
|
||||||
|
'gif',
|
||||||
|
'webp',
|
||||||
|
'svg',
|
||||||
|
'pdf',
|
||||||
|
'md',
|
||||||
|
'txt',
|
||||||
|
'json',
|
||||||
|
'yaml',
|
||||||
|
'yml',
|
||||||
|
'xml',
|
||||||
|
'csv',
|
||||||
|
].includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewType(key: string): 'image' | 'pdf' | 'text' {
|
||||||
|
const ext = getFileExt(key);
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
|
||||||
|
if (ext === 'pdf') return 'pdf';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 数据加载 ---
|
||||||
|
|
||||||
|
async function loadBuckets() {
|
||||||
|
try {
|
||||||
|
const resp = await listBucketsApi();
|
||||||
|
buckets.value = resp?.buckets ?? [];
|
||||||
|
if (buckets.value.length > 0 && !selectedBucket.value) {
|
||||||
|
selectedBucket.value = buckets.value[0]!;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles(append = false) {
|
||||||
|
if (!selectedBucket.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = {
|
||||||
|
bucket_name: selectedBucket.value,
|
||||||
|
max_keys: 50,
|
||||||
|
};
|
||||||
|
if (prefix.value) params.prefix = prefix.value;
|
||||||
|
if (append && continuationToken.value) {
|
||||||
|
params.token = continuationToken.value;
|
||||||
|
}
|
||||||
|
const result = await listFilesApi(params as any);
|
||||||
|
if (append) {
|
||||||
|
files.value = [...files.value, ...(result?.Files ?? [])];
|
||||||
|
} else {
|
||||||
|
files.value = result?.Files ?? [];
|
||||||
|
}
|
||||||
|
continuationToken.value = result?.NextContinuationToken ?? null;
|
||||||
|
} catch {
|
||||||
|
// error handled by interceptor
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 操作 ---
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
prefix.value = searchValue.value;
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
searchValue.value = '';
|
||||||
|
prefix.value = '';
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(options: any) {
|
||||||
|
const file = options.file as File;
|
||||||
|
if (!selectedBucket.value) {
|
||||||
|
message.warning($t('page.fileManagement.selectBucket'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
await uploadFileApi(selectedBucket.value, file);
|
||||||
|
message.success($t('page.fileManagement.uploadSuccess'));
|
||||||
|
loadFiles();
|
||||||
|
} catch {
|
||||||
|
// error handled by interceptor
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(key: string) {
|
||||||
|
try {
|
||||||
|
await deleteFileApi(selectedBucket.value, key);
|
||||||
|
message.success($t('page.fileManagement.deleteSuccess'));
|
||||||
|
loadFiles();
|
||||||
|
} catch {
|
||||||
|
// error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload(key: string) {
|
||||||
|
try {
|
||||||
|
const blob = await downloadFileApi(selectedBucket.value, key);
|
||||||
|
const url = URL.createObjectURL(new Blob([blob as any]));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = key.split('/').pop() || key;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
// error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePreview(key: string) {
|
||||||
|
previewFileName.value = key.split('/').pop() || key;
|
||||||
|
previewType.value = getPreviewType(key);
|
||||||
|
previewVisible.value = true;
|
||||||
|
previewLoading.value = true;
|
||||||
|
previewUrl.value = '';
|
||||||
|
previewContent.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (previewType.value === 'text') {
|
||||||
|
const resp = await getFileContentApi(selectedBucket.value, key);
|
||||||
|
previewContent.value = resp?.content ?? '';
|
||||||
|
} else {
|
||||||
|
const resp = await getFilePreviewApi(selectedBucket.value, key);
|
||||||
|
previewUrl.value = resp?.url ?? '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// error handled by interceptor
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateBucket() {
|
||||||
|
if (!newBucketName.value.trim()) return;
|
||||||
|
createBucketLoading.value = true;
|
||||||
|
try {
|
||||||
|
await createBucketApi(newBucketName.value.trim());
|
||||||
|
message.success('创建成功');
|
||||||
|
createBucketVisible.value = false;
|
||||||
|
newBucketName.value = '';
|
||||||
|
await loadBuckets();
|
||||||
|
if (buckets.value.length > 0) {
|
||||||
|
selectedBucket.value = buckets.value[buckets.value.length - 1]!;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// error handled by interceptor
|
||||||
|
} finally {
|
||||||
|
createBucketLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteBucket() {
|
||||||
|
if (!selectedBucket.value) return;
|
||||||
|
try {
|
||||||
|
await deleteBucketApi(selectedBucket.value);
|
||||||
|
message.success('删除成功');
|
||||||
|
await loadBuckets();
|
||||||
|
selectedBucket.value = buckets.value[0] ?? '';
|
||||||
|
} catch {
|
||||||
|
// error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 表格列 ---
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: $t('page.fileManagement.fileName'),
|
||||||
|
dataIndex: 'Key',
|
||||||
|
key: 'Key',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('page.fileManagement.fileSize'),
|
||||||
|
dataIndex: 'Size',
|
||||||
|
key: 'Size',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('page.fileManagement.lastModified'),
|
||||||
|
dataIndex: 'LastModified',
|
||||||
|
key: 'LastModified',
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: $t('page.fileManagement.actions'),
|
||||||
|
key: 'actions',
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- 生命周期 ---
|
||||||
|
|
||||||
|
watch(selectedBucket, () => {
|
||||||
|
files.value = [];
|
||||||
|
continuationToken.value = null;
|
||||||
|
if (selectedBucket.value) {
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadBuckets();
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMore = computed(() => continuationToken.value !== null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<div class="p-4">
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<Select
|
||||||
|
v-model:value="selectedBucket"
|
||||||
|
:placeholder="$t('page.fileManagement.selectBucket')"
|
||||||
|
class="min-w-[200px]"
|
||||||
|
:options="buckets.map((b) => ({ label: b, value: b }))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button @click="createBucketVisible = true">
|
||||||
|
{{ $t('page.fileManagement.createBucket') }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Popconfirm
|
||||||
|
v-if="selectedBucket"
|
||||||
|
:title="$t('page.fileManagement.deleteBucketConfirm', { name: selectedBucket })"
|
||||||
|
@confirm="handleDeleteBucket"
|
||||||
|
>
|
||||||
|
<Button danger>
|
||||||
|
{{ $t('page.fileManagement.deleteBucket') }}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
|
||||||
|
<div class="flex-1" />
|
||||||
|
|
||||||
|
<Input.Search
|
||||||
|
v-model:value="searchValue"
|
||||||
|
:placeholder="$t('page.fileManagement.searchPlaceholder')"
|
||||||
|
class="w-[250px]"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Upload
|
||||||
|
:custom-request="handleUpload"
|
||||||
|
:show-upload-list="false"
|
||||||
|
:disabled="!selectedBucket"
|
||||||
|
>
|
||||||
|
<Button :loading="uploading" type="primary">
|
||||||
|
{{ $t('page.fileManagement.upload') }}
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
|
||||||
|
<Button @click="handleRefresh">
|
||||||
|
{{ $t('page.fileManagement.title') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件表格 -->
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<Table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="files"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="Key"
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'Key'">
|
||||||
|
<span class="mr-1">{{ getFileIcon(record.Key) }}</span>
|
||||||
|
<span>{{ record.Key }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'Size'">
|
||||||
|
{{ formatFileSize(record.Size) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'LastModified'">
|
||||||
|
{{ formatTime(record.LastModified) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<Space>
|
||||||
|
<Button size="small" @click="handleDownload(record.Key)">
|
||||||
|
{{ $t('page.fileManagement.download') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="isPreviewable(record.Key)"
|
||||||
|
size="small"
|
||||||
|
@click="handlePreview(record.Key)"
|
||||||
|
>
|
||||||
|
{{ $t('page.fileManagement.preview') }}
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
:title="$t('page.fileManagement.deleteConfirm', { name: record.Key })"
|
||||||
|
@confirm="handleDelete(record.Key)"
|
||||||
|
>
|
||||||
|
<Button danger size="small">
|
||||||
|
{{ $t('page.fileManagement.delete') }}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template #emptyText>
|
||||||
|
<div class="py-8 text-gray-400">
|
||||||
|
{{ $t('page.fileManagement.noFiles') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<!-- 加载更多 -->
|
||||||
|
<div v-if="hasMore" class="mt-4 text-center">
|
||||||
|
<Button :loading="loading" @click="loadFiles(true)">
|
||||||
|
{{ $t('page.fileManagement.loadMore') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预览弹窗 -->
|
||||||
|
<Modal
|
||||||
|
v-model:open="previewVisible"
|
||||||
|
:title="previewFileName"
|
||||||
|
width="800px"
|
||||||
|
:footer="null"
|
||||||
|
>
|
||||||
|
<Spin :spinning="previewLoading">
|
||||||
|
<div v-if="previewType === 'image'" class="text-center">
|
||||||
|
<img :src="previewUrl" class="max-w-full" :alt="previewFileName" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="previewType === 'pdf'">
|
||||||
|
<iframe :src="previewUrl" class="h-[600px] w-full" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="previewType === 'text'">
|
||||||
|
<pre class="max-h-[600px] overflow-auto rounded bg-gray-50 p-4 text-sm">{{ previewContent }}</pre>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- 创建存储桶弹窗 -->
|
||||||
|
<Modal
|
||||||
|
v-model:open="createBucketVisible"
|
||||||
|
:title="$t('page.fileManagement.createBucket')"
|
||||||
|
:confirm-loading="createBucketLoading"
|
||||||
|
@ok="handleCreateBucket"
|
||||||
|
>
|
||||||
|
<div class="py-4">
|
||||||
|
<label class="mb-2 block text-sm font-medium">
|
||||||
|
{{ $t('page.fileManagement.bucketName') }}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
v-model:value="newBucketName"
|
||||||
|
:placeholder="$t('page.fileManagement.bucketName')"
|
||||||
|
@press-enter="handleCreateBucket"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
@ -1,32 +1,61 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ImApi } from '#/api/core/im';
|
import type { ImApi } from '#/api/core/im';
|
||||||
|
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
import { useUserStore } from '@vben/stores';
|
import { useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
Input,
|
||||||
|
message,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
SelectOption,
|
||||||
|
Spin,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { uploadFileApi } from '#/api/core/file-management';
|
||||||
|
import { fileRequestClient } from '#/api/request';
|
||||||
import { createGroupApi } from '#/api';
|
import { createGroupApi } from '#/api';
|
||||||
import { useImStore } from '#/store';
|
import { useImStore } from '#/store';
|
||||||
|
|
||||||
|
const InputSearch = Input.Search;
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const imStore = useImStore();
|
const imStore = useImStore();
|
||||||
|
|
||||||
const inputMessage = ref('');
|
const inputMessage = ref('');
|
||||||
const sending = ref(false);
|
const sending = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
const showCreateGroup = ref(false);
|
const showCreateGroup = ref(false);
|
||||||
const showGroupManage = ref(false);
|
const showGroupManage = ref(false);
|
||||||
|
const showEmojiPicker = ref(false);
|
||||||
const newGroupName = ref('');
|
const newGroupName = ref('');
|
||||||
const newGroupDesc = ref('');
|
const newGroupDesc = ref('');
|
||||||
const selectedMemberIds = ref<string[]>([]);
|
const selectedMemberIds = ref<string[]>([]);
|
||||||
const memberSearchLoading = ref(false);
|
const memberSearchLoading = ref(false);
|
||||||
const messageListRef = ref<HTMLElement | null>(null);
|
const messageListRef = ref<HTMLElement | null>(null);
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const imageInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Sidebar tab
|
|
||||||
const sidebarTab = ref<'contacts' | 'messages'>('messages');
|
const sidebarTab = ref<'contacts' | 'messages'>('messages');
|
||||||
const contactSearchQuery = ref('');
|
const contactSearchQuery = ref('');
|
||||||
let contactSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
let contactSearchTimer: null | ReturnType<typeof setTimeout> = null;
|
||||||
|
|
||||||
|
const EMOJI_LIST = [
|
||||||
|
'😀', '😂', '🤣', '😊', '😍', '🥰', '😘', '😎',
|
||||||
|
'🤔', '😅', '😢', '😭', '😤', '🥺', '😱', '🤗',
|
||||||
|
'👍', '👎', '👏', '🙌', '🤝', '💪', '🎉', '🔥',
|
||||||
|
'❤️', '💔', '⭐', '🌟', '💯', '✅', '❌', '⚡',
|
||||||
|
'🚀', '🎯', '💡', '📌', '📎', '📝', '💬', '🔔',
|
||||||
|
];
|
||||||
|
|
||||||
|
const imagePreviewCache = reactive<Record<string, string>>({});
|
||||||
|
|
||||||
const activeConversationId = computed(() => imStore.activeConversationId);
|
const activeConversationId = computed(() => imStore.activeConversationId);
|
||||||
const activeConversation = computed(() => imStore.activeConversation);
|
const activeConversation = computed(() => imStore.activeConversation);
|
||||||
@ -35,7 +64,7 @@ const activeMessages = computed(() => imStore.activeMessages);
|
|||||||
const sortedConversations = computed(() =>
|
const sortedConversations = computed(() =>
|
||||||
[...imStore.conversations].toSorted((a, b) => {
|
[...imStore.conversations].toSorted((a, b) => {
|
||||||
const timeA = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
|
const timeA = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
|
||||||
const timeB = b.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
|
const timeB = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
|
||||||
return timeB - timeA;
|
return timeB - timeA;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -61,17 +90,53 @@ function formatTime(dateStr: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImage(url: string) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveImageUrl(msg: ImApi.Message) {
|
||||||
|
const previewPath = msg.metadata?.imageUrl;
|
||||||
|
if (!previewPath) return;
|
||||||
|
|
||||||
|
if (imagePreviewCache[msg.id]) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fileRequestClient.get<{ url: string }>(
|
||||||
|
previewPath.replace(/^\/file-api/, ''),
|
||||||
|
);
|
||||||
|
if (result.url) {
|
||||||
|
imagePreviewCache[msg.id] = result.url;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// preview failed, leave image broken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function selectConversation(conversationId: string) {
|
async function selectConversation(conversationId: string) {
|
||||||
imStore.setActiveConversation(conversationId);
|
imStore.setActiveConversation(conversationId);
|
||||||
await imStore.loadMessages(conversationId);
|
await imStore.loadMessages(conversationId);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
|
// Resolve image URLs
|
||||||
|
for (const msg of imStore.activeMessages) {
|
||||||
|
if (msg.contentType === 1 && msg.metadata?.imageUrl) {
|
||||||
|
resolveImageUrl(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
const msgs = imStore.activeMessages;
|
const msgs = imStore.activeMessages;
|
||||||
if (msgs.length > 0) {
|
if (msgs.length > 0) {
|
||||||
const lastMsg = msgs[msgs.length - 1];
|
const lastMsg = msgs.at(-1);
|
||||||
|
if (lastMsg) {
|
||||||
await imStore.markAsRead(conversationId, lastMsg.id);
|
await imStore.markAsRead(conversationId, lastMsg.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSend(e?: any) {
|
async function handleSend(e?: any) {
|
||||||
if (e?.shiftKey) return;
|
if (e?.shiftKey) return;
|
||||||
@ -84,6 +149,7 @@ async function handleSend(e?: any) {
|
|||||||
try {
|
try {
|
||||||
await imStore.sendMessage(activeConversationId.value, 0, content);
|
await imStore.sendMessage(activeConversationId.value, 0, content);
|
||||||
inputMessage.value = '';
|
inputMessage.value = '';
|
||||||
|
showEmojiPicker.value = false;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
} catch {
|
} catch {
|
||||||
@ -93,6 +159,80 @@ async function handleSend(e?: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSendEmoji(emoji: string) {
|
||||||
|
if (!activeConversationId.value) return;
|
||||||
|
try {
|
||||||
|
await imStore.sendMessage(activeConversationId.value, 4, emoji);
|
||||||
|
showEmojiPicker.value = false;
|
||||||
|
await nextTick();
|
||||||
|
scrollToBottom();
|
||||||
|
} catch {
|
||||||
|
message.error('发送失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImageUpload(e: Event) {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!file || !activeConversationId.value) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
message.warning('请选择图片文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
await uploadFileApi('im-files', file);
|
||||||
|
const imageUrl = `/file-api/files/preview?bucket_name=im-files&object_key=${encodeURIComponent(file.name)}`;
|
||||||
|
await imStore.sendMessage(activeConversationId.value, 1, '[图片]', {
|
||||||
|
imageUrl,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
scrollToBottom();
|
||||||
|
} catch {
|
||||||
|
message.error('图片上传失败');
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
(e.target as HTMLInputElement).value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileUpload(e: Event) {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!file || !activeConversationId.value) return;
|
||||||
|
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
await uploadFileApi('im-files', file);
|
||||||
|
const fileUrl = `/file-api/files/download?bucket_name=im-files&object_key=${encodeURIComponent(file.name)}`;
|
||||||
|
await imStore.sendMessage(activeConversationId.value, 2, file.name, {
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
fileUrl,
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
scrollToBottom();
|
||||||
|
} catch {
|
||||||
|
message.error('文件上传失败');
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
(e.target as HTMLInputElement).value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEmojiPicker() {
|
||||||
|
showEmojiPicker.value = !showEmojiPicker.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.closest('.emoji-picker-area')) {
|
||||||
|
showEmojiPicker.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleStartChat(user: ImApi.User) {
|
async function handleStartChat(user: ImApi.User) {
|
||||||
try {
|
try {
|
||||||
await imStore.startPrivateChat(user.id);
|
await imStore.startPrivateChat(user.id);
|
||||||
@ -150,7 +290,7 @@ async function onScroll() {
|
|||||||
if (!messageListRef.value || !activeConversationId.value) return;
|
if (!messageListRef.value || !activeConversationId.value) return;
|
||||||
if (messageListRef.value.scrollTop === 0) {
|
if (messageListRef.value.scrollTop === 0) {
|
||||||
const msgs = imStore.activeMessages;
|
const msgs = imStore.activeMessages;
|
||||||
const firstMsgId = msgs.length > 0 ? msgs[0].id : undefined;
|
const firstMsgId = msgs.length > 0 ? msgs[0]!.id : undefined;
|
||||||
if (firstMsgId) {
|
if (firstMsgId) {
|
||||||
await imStore.loadMessages(activeConversationId.value, firstMsgId);
|
await imStore.loadMessages(activeConversationId.value, firstMsgId);
|
||||||
}
|
}
|
||||||
@ -166,10 +306,12 @@ onMounted(async () => {
|
|||||||
await imStore.connect();
|
await imStore.connect();
|
||||||
await imStore.loadConversations();
|
await imStore.loadConversations();
|
||||||
await imStore.loadUnreadCounts();
|
await imStore.loadUnreadCounts();
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
imStore.disconnect();
|
imStore.disconnect();
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -213,13 +355,9 @@ onUnmounted(() => {
|
|||||||
<span class="text-sm text-muted-foreground">
|
<span class="text-sm text-muted-foreground">
|
||||||
{{ sortedConversations.length }} 个会话
|
{{ sortedConversations.length }} 个会话
|
||||||
</span>
|
</span>
|
||||||
<a-button
|
<Button type="primary" size="small" @click="showCreateGroup = true">
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
@click="showCreateGroup = true"
|
|
||||||
>
|
|
||||||
创建群聊
|
创建群聊
|
||||||
</a-button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
@ -227,17 +365,16 @@ onUnmounted(() => {
|
|||||||
:key="conv.id"
|
:key="conv.id"
|
||||||
class="flex cursor-pointer items-center px-4 py-3 transition-colors hover:bg-accent"
|
class="flex cursor-pointer items-center px-4 py-3 transition-colors hover:bg-accent"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-primary/10 dark:bg-accent':
|
'bg-primary/10 dark:bg-accent': conv.id === activeConversationId,
|
||||||
conv.id === activeConversationId,
|
|
||||||
}"
|
}"
|
||||||
@click="selectConversation(conv.id)"
|
@click="selectConversation(conv.id)"
|
||||||
>
|
>
|
||||||
<a-avatar v-if="conv.type === 2" :size="40">
|
<Avatar v-if="conv.type === 2" :size="40">
|
||||||
{{ conv.groupName?.charAt(0) || '群' }}
|
{{ conv.groupName?.charAt(0) || '群' }}
|
||||||
</a-avatar>
|
</Avatar>
|
||||||
<a-avatar v-else :size="40">
|
<Avatar v-else :size="40">
|
||||||
{{ conv.otherUserName?.charAt(0) || 'U' }}
|
{{ conv.otherUserName?.charAt(0) || 'U' }}
|
||||||
</a-avatar>
|
</Avatar>
|
||||||
|
|
||||||
<div class="ml-3 flex-1 overflow-hidden">
|
<div class="ml-3 flex-1 overflow-hidden">
|
||||||
<div class="truncate text-sm font-medium text-foreground">
|
<div class="truncate text-sm font-medium text-foreground">
|
||||||
@ -252,7 +389,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-badge
|
<Badge
|
||||||
v-if="conv.unreadCount > 0"
|
v-if="conv.unreadCount > 0"
|
||||||
:count="conv.unreadCount"
|
:count="conv.unreadCount"
|
||||||
:overflow-count="99"
|
:overflow-count="99"
|
||||||
@ -264,7 +401,7 @@ onUnmounted(() => {
|
|||||||
<!-- Contacts panel -->
|
<!-- Contacts panel -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="border-b border-border px-3 py-2">
|
<div class="border-b border-border px-3 py-2">
|
||||||
<a-input-search
|
<InputSearch
|
||||||
v-model:value="contactSearchQuery"
|
v-model:value="contactSearchQuery"
|
||||||
placeholder="搜索用户名或邮箱"
|
placeholder="搜索用户名或邮箱"
|
||||||
@search="imStore.searchContacts(contactSearchQuery || undefined)"
|
@search="imStore.searchContacts(contactSearchQuery || undefined)"
|
||||||
@ -272,16 +409,16 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<a-spin :spinning="imStore.contactsLoading">
|
<Spin :spinning="imStore.contactsLoading">
|
||||||
<div
|
<div
|
||||||
v-for="user in imStore.contacts"
|
v-for="user in imStore.contacts"
|
||||||
:key="user.id"
|
:key="user.id"
|
||||||
class="flex cursor-pointer items-center px-4 py-3 transition-colors hover:bg-accent"
|
class="flex cursor-pointer items-center px-4 py-3 transition-colors hover:bg-accent"
|
||||||
@click="handleStartChat(user)"
|
@click="handleStartChat(user)"
|
||||||
>
|
>
|
||||||
<a-avatar :size="40">
|
<Avatar :size="40">
|
||||||
{{ user.username.charAt(0).toUpperCase() }}
|
{{ user.username.charAt(0).toUpperCase() }}
|
||||||
</a-avatar>
|
</Avatar>
|
||||||
<div class="ml-3 flex-1 overflow-hidden">
|
<div class="ml-3 flex-1 overflow-hidden">
|
||||||
<div class="truncate text-sm font-medium text-foreground">
|
<div class="truncate text-sm font-medium text-foreground">
|
||||||
{{ user.username }}
|
{{ user.username }}
|
||||||
@ -293,17 +430,15 @@ onUnmounted(() => {
|
|||||||
<span
|
<span
|
||||||
v-if="imStore.onlineUsers.has(user.id)"
|
v-if="imStore.onlineUsers.has(user.id)"
|
||||||
class="mr-2 inline-block h-2 w-2 rounded-full bg-green-500"
|
class="mr-2 inline-block h-2 w-2 rounded-full bg-green-500"
|
||||||
/>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="!imStore.contactsLoading && imStore.contacts.length === 0"
|
||||||
!imStore.contactsLoading && imStore.contacts.length === 0
|
|
||||||
"
|
|
||||||
class="px-4 py-8 text-center text-sm text-muted-foreground"
|
class="px-4 py-8 text-center text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
没有找到用户
|
没有找到用户
|
||||||
</div>
|
</div>
|
||||||
</a-spin>
|
</Spin>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@ -321,15 +456,16 @@ onUnmounted(() => {
|
|||||||
: activeConversation.otherUserName || '私聊'
|
: activeConversation.otherUserName || '私聊'
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
<a-button
|
<Button
|
||||||
v-if="activeConversation.type === 2"
|
v-if="activeConversation.type === 2"
|
||||||
size="small"
|
size="small"
|
||||||
@click="showGroupManage = true"
|
@click="showGroupManage = true"
|
||||||
>
|
>
|
||||||
群管理
|
群管理
|
||||||
</a-button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
<div
|
<div
|
||||||
ref="messageListRef"
|
ref="messageListRef"
|
||||||
class="flex-1 overflow-y-auto px-4 py-4"
|
class="flex-1 overflow-y-auto px-4 py-4"
|
||||||
@ -352,22 +488,54 @@ onUnmounted(() => {
|
|||||||
: 'bg-muted text-foreground'
|
: 'bg-muted text-foreground'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
<!-- Text -->
|
||||||
<template v-if="msg.contentType === 0">
|
<template v-if="msg.contentType === 0">
|
||||||
{{ msg.content }}
|
{{ msg.content }}
|
||||||
</template>
|
</template>
|
||||||
|
<!-- Image -->
|
||||||
<template v-else-if="msg.contentType === 1">
|
<template v-else-if="msg.contentType === 1">
|
||||||
<img
|
<img
|
||||||
:src="msg.metadata?.imageUrl"
|
v-if="imagePreviewCache[msg.id]"
|
||||||
class="max-w-[200px] rounded-lg"
|
:src="imagePreviewCache[msg.id]"
|
||||||
|
class="max-w-[200px] cursor-pointer rounded-lg"
|
||||||
|
@click="openImage(imagePreviewCache[msg.id]!)"
|
||||||
/>
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="cursor-pointer text-xs underline"
|
||||||
|
@click="resolveImageUrl(msg)"
|
||||||
|
>
|
||||||
|
[图片] 点击加载
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- File -->
|
||||||
|
<template v-else-if="msg.contentType === 2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-lg">📎</span>
|
||||||
|
<a
|
||||||
|
v-if="msg.metadata?.fileUrl"
|
||||||
|
:href="msg.metadata.fileUrl"
|
||||||
|
target="_blank"
|
||||||
|
class="underline"
|
||||||
|
>
|
||||||
|
{{ msg.content }}
|
||||||
|
</a>
|
||||||
|
<span v-else>{{ msg.content }}</span>
|
||||||
|
<span
|
||||||
|
v-if="msg.metadata?.fileSize"
|
||||||
|
class="text-xs opacity-70"
|
||||||
|
>
|
||||||
|
({{ formatFileSize(msg.metadata.fileSize) }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- Emoji -->
|
||||||
<template v-else-if="msg.contentType === 4">
|
<template v-else-if="msg.contentType === 4">
|
||||||
<span class="text-2xl">{{ msg.content }}</span>
|
<span class="text-3xl">{{ msg.content }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<!-- System -->
|
||||||
[{{
|
<template v-else-if="msg.contentType === 3">
|
||||||
['文本', '图片', '文件', '系统', '表情'][msg.contentType]
|
<span class="text-xs italic opacity-80">{{ msg.content }}</span>
|
||||||
}}]
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-[11px] text-muted-foreground/60">
|
<div class="mt-1 text-[11px] text-muted-foreground/60">
|
||||||
@ -376,16 +544,80 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 border-t border-border px-4 py-3">
|
<!-- Input area -->
|
||||||
<a-textarea
|
<div class="emoji-picker-area relative border-t border-border">
|
||||||
v-model:value="inputMessage"
|
<!-- Emoji picker popup -->
|
||||||
placeholder="输入消息..."
|
<div
|
||||||
:auto-size="{ minRows: 1, maxRows: 4 }"
|
v-if="showEmojiPicker"
|
||||||
@press-enter="handleSend"
|
class="absolute bottom-full right-0 mb-2 grid w-[280px] grid-cols-8 gap-1 rounded-lg border border-border bg-popover p-2 shadow-lg"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="emoji in EMOJI_LIST"
|
||||||
|
:key="emoji"
|
||||||
|
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-lg hover:bg-accent"
|
||||||
|
@click="handleSendEmoji(emoji)"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex items-center gap-1 border-b border-border px-3 py-1.5">
|
||||||
|
<button
|
||||||
|
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-base hover:bg-accent"
|
||||||
|
title="表情"
|
||||||
|
@click="toggleEmojiPicker"
|
||||||
|
>
|
||||||
|
😊
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-base hover:bg-accent"
|
||||||
|
title="图片"
|
||||||
|
@click="imageInputRef?.click()"
|
||||||
|
>
|
||||||
|
🖼️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-base hover:bg-accent"
|
||||||
|
title="文件"
|
||||||
|
@click="fileInputRef?.click()"
|
||||||
|
>
|
||||||
|
📎
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input row -->
|
||||||
|
<div class="flex gap-2 px-3 py-2">
|
||||||
|
<textarea
|
||||||
|
v-model="inputMessage"
|
||||||
|
class="flex-1 resize-none rounded-md border border-border bg-transparent px-3 py-1.5 text-sm outline-none focus:border-primary"
|
||||||
|
placeholder="输入消息... (Shift+Enter 换行)"
|
||||||
|
:rows="1"
|
||||||
|
@keydown.enter="handleSend"
|
||||||
/>
|
/>
|
||||||
<a-button type="primary" @click="handleSend" :loading="sending">
|
<Button
|
||||||
|
type="primary"
|
||||||
|
:loading="sending || uploading"
|
||||||
|
@click="handleSend"
|
||||||
|
>
|
||||||
发送
|
发送
|
||||||
</a-button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden file inputs -->
|
||||||
|
<input
|
||||||
|
ref="imageInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleImageUpload"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileUpload"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -398,20 +630,20 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Group Modal -->
|
<!-- Create Group Modal -->
|
||||||
<a-modal
|
<Modal
|
||||||
v-model:open="showCreateGroup"
|
v-model:open="showCreateGroup"
|
||||||
title="创建群聊"
|
title="创建群聊"
|
||||||
@ok="handleCreateGroup"
|
@ok="handleCreateGroup"
|
||||||
>
|
>
|
||||||
<a-form layout="vertical">
|
<Form layout="vertical">
|
||||||
<a-form-item label="群名称">
|
<FormItem label="群名称">
|
||||||
<a-input v-model:value="newGroupName" />
|
<Input v-model:value="newGroupName" />
|
||||||
</a-form-item>
|
</FormItem>
|
||||||
<a-form-item label="群描述">
|
<FormItem label="群描述">
|
||||||
<a-input v-model:value="newGroupDesc" />
|
<Input v-model:value="newGroupDesc" />
|
||||||
</a-form-item>
|
</FormItem>
|
||||||
<a-form-item label="选择成员">
|
<FormItem label="选择成员">
|
||||||
<a-select
|
<Select
|
||||||
v-model:value="selectedMemberIds"
|
v-model:value="selectedMemberIds"
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
placeholder="搜索并选择成员"
|
placeholder="搜索并选择成员"
|
||||||
@ -419,16 +651,16 @@ onUnmounted(() => {
|
|||||||
:loading="memberSearchLoading"
|
:loading="memberSearchLoading"
|
||||||
@search="handleMemberSearch"
|
@search="handleMemberSearch"
|
||||||
>
|
>
|
||||||
<a-select-option
|
<SelectOption
|
||||||
v-for="user in imStore.contacts"
|
v-for="user in imStore.contacts"
|
||||||
:key="user.id"
|
:key="user.id"
|
||||||
:value="user.id"
|
:value="user.id"
|
||||||
>
|
>
|
||||||
{{ user.username }} ({{ user.email }})
|
{{ user.username }} ({{ user.email }})
|
||||||
</a-select-option>
|
</SelectOption>
|
||||||
</a-select>
|
</Select>
|
||||||
</a-form-item>
|
</FormItem>
|
||||||
</a-form>
|
</Form>
|
||||||
</a-modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -11,6 +11,11 @@ export default defineConfig(async () => {
|
|||||||
target: 'http://localhost:5211',
|
target: 'http://localhost:5211',
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
|
'/file-api': {
|
||||||
|
changeOrigin: true,
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
rewrite: (path) => path.replace(/^\/file-api/, ''),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user