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
|
||||
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 { 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<ImApi.GroupInfo>(`/groups/${groupId}`, data);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -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} 吗?存储桶必须为空才能删除。"
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ export const overridesPreferences = defineOverridesPreferences({
|
||||
// overrides
|
||||
app: {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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">
|
||||
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 { 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 { useImStore } from '#/store';
|
||||
|
||||
const InputSearch = Input.Search;
|
||||
|
||||
const userStore = useUserStore();
|
||||
const imStore = useImStore();
|
||||
|
||||
const inputMessage = ref('');
|
||||
const sending = ref(false);
|
||||
const uploading = ref(false);
|
||||
const showCreateGroup = ref(false);
|
||||
const showGroupManage = ref(false);
|
||||
const showEmojiPicker = ref(false);
|
||||
const newGroupName = ref('');
|
||||
const newGroupDesc = ref('');
|
||||
const selectedMemberIds = ref<string[]>([]);
|
||||
const memberSearchLoading = ref(false);
|
||||
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 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 activeConversation = computed(() => imStore.activeConversation);
|
||||
@ -35,7 +64,7 @@ const activeMessages = computed(() => imStore.activeMessages);
|
||||
const sortedConversations = computed(() =>
|
||||
[...imStore.conversations].toSorted((a, b) => {
|
||||
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;
|
||||
}),
|
||||
);
|
||||
@ -61,15 +90,51 @@ 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) {
|
||||
imStore.setActiveConversation(conversationId);
|
||||
await imStore.loadMessages(conversationId);
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
// Resolve image URLs
|
||||
for (const msg of imStore.activeMessages) {
|
||||
if (msg.contentType === 1 && msg.metadata?.imageUrl) {
|
||||
resolveImageUrl(msg);
|
||||
}
|
||||
}
|
||||
const msgs = imStore.activeMessages;
|
||||
if (msgs.length > 0) {
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
await imStore.markAsRead(conversationId, lastMsg.id);
|
||||
const lastMsg = msgs.at(-1);
|
||||
if (lastMsg) {
|
||||
await imStore.markAsRead(conversationId, lastMsg.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,6 +149,7 @@ async function handleSend(e?: any) {
|
||||
try {
|
||||
await imStore.sendMessage(activeConversationId.value, 0, content);
|
||||
inputMessage.value = '';
|
||||
showEmojiPicker.value = false;
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
} 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) {
|
||||
try {
|
||||
await imStore.startPrivateChat(user.id);
|
||||
@ -150,7 +290,7 @@ async function onScroll() {
|
||||
if (!messageListRef.value || !activeConversationId.value) return;
|
||||
if (messageListRef.value.scrollTop === 0) {
|
||||
const msgs = imStore.activeMessages;
|
||||
const firstMsgId = msgs.length > 0 ? msgs[0].id : undefined;
|
||||
const firstMsgId = msgs.length > 0 ? msgs[0]!.id : undefined;
|
||||
if (firstMsgId) {
|
||||
await imStore.loadMessages(activeConversationId.value, firstMsgId);
|
||||
}
|
||||
@ -166,10 +306,12 @@ onMounted(async () => {
|
||||
await imStore.connect();
|
||||
await imStore.loadConversations();
|
||||
await imStore.loadUnreadCounts();
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
imStore.disconnect();
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -213,13 +355,9 @@ onUnmounted(() => {
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ sortedConversations.length }} 个会话
|
||||
</span>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="showCreateGroup = true"
|
||||
>
|
||||
<Button type="primary" size="small" @click="showCreateGroup = true">
|
||||
创建群聊
|
||||
</a-button>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div
|
||||
@ -227,17 +365,16 @@ onUnmounted(() => {
|
||||
: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)"
|
||||
>
|
||||
<a-avatar v-if="conv.type === 2" :size="40">
|
||||
<Avatar v-if="conv.type === 2" :size="40">
|
||||
{{ conv.groupName?.charAt(0) || '群' }}
|
||||
</a-avatar>
|
||||
<a-avatar v-else :size="40">
|
||||
</Avatar>
|
||||
<Avatar v-else :size="40">
|
||||
{{ conv.otherUserName?.charAt(0) || 'U' }}
|
||||
</a-avatar>
|
||||
</Avatar>
|
||||
|
||||
<div class="ml-3 flex-1 overflow-hidden">
|
||||
<div class="truncate text-sm font-medium text-foreground">
|
||||
@ -252,7 +389,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-badge
|
||||
<Badge
|
||||
v-if="conv.unreadCount > 0"
|
||||
:count="conv.unreadCount"
|
||||
:overflow-count="99"
|
||||
@ -264,7 +401,7 @@ onUnmounted(() => {
|
||||
<!-- Contacts panel -->
|
||||
<template v-else>
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<a-input-search
|
||||
<InputSearch
|
||||
v-model:value="contactSearchQuery"
|
||||
placeholder="搜索用户名或邮箱"
|
||||
@search="imStore.searchContacts(contactSearchQuery || undefined)"
|
||||
@ -272,16 +409,16 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<a-spin :spinning="imStore.contactsLoading">
|
||||
<Spin :spinning="imStore.contactsLoading">
|
||||
<div
|
||||
v-for="user in imStore.contacts"
|
||||
:key="user.id"
|
||||
class="flex cursor-pointer items-center px-4 py-3 transition-colors hover:bg-accent"
|
||||
@click="handleStartChat(user)"
|
||||
>
|
||||
<a-avatar :size="40">
|
||||
<Avatar :size="40">
|
||||
{{ user.username.charAt(0).toUpperCase() }}
|
||||
</a-avatar>
|
||||
</Avatar>
|
||||
<div class="ml-3 flex-1 overflow-hidden">
|
||||
<div class="truncate text-sm font-medium text-foreground">
|
||||
{{ user.username }}
|
||||
@ -293,17 +430,15 @@ onUnmounted(() => {
|
||||
<span
|
||||
v-if="imStore.onlineUsers.has(user.id)"
|
||||
class="mr-2 inline-block h-2 w-2 rounded-full bg-green-500"
|
||||
/>
|
||||
></span>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!imStore.contactsLoading && imStore.contacts.length === 0
|
||||
"
|
||||
v-if="!imStore.contactsLoading && imStore.contacts.length === 0"
|
||||
class="px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
没有找到用户
|
||||
</div>
|
||||
</a-spin>
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@ -321,15 +456,16 @@ onUnmounted(() => {
|
||||
: activeConversation.otherUserName || '私聊'
|
||||
}}
|
||||
</span>
|
||||
<a-button
|
||||
<Button
|
||||
v-if="activeConversation.type === 2"
|
||||
size="small"
|
||||
@click="showGroupManage = true"
|
||||
>
|
||||
群管理
|
||||
</a-button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div
|
||||
ref="messageListRef"
|
||||
class="flex-1 overflow-y-auto px-4 py-4"
|
||||
@ -352,22 +488,54 @@ onUnmounted(() => {
|
||||
: 'bg-muted text-foreground'
|
||||
"
|
||||
>
|
||||
<!-- Text -->
|
||||
<template v-if="msg.contentType === 0">
|
||||
{{ msg.content }}
|
||||
</template>
|
||||
<!-- Image -->
|
||||
<template v-else-if="msg.contentType === 1">
|
||||
<img
|
||||
:src="msg.metadata?.imageUrl"
|
||||
class="max-w-[200px] rounded-lg"
|
||||
v-if="imagePreviewCache[msg.id]"
|
||||
: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>
|
||||
<!-- 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">
|
||||
<span class="text-2xl">{{ msg.content }}</span>
|
||||
<span class="text-3xl">{{ msg.content }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
[{{
|
||||
['文本', '图片', '文件', '系统', '表情'][msg.contentType]
|
||||
}}]
|
||||
<!-- System -->
|
||||
<template v-else-if="msg.contentType === 3">
|
||||
<span class="text-xs italic opacity-80">{{ msg.content }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-muted-foreground/60">
|
||||
@ -376,16 +544,80 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 border-t border-border px-4 py-3">
|
||||
<a-textarea
|
||||
v-model:value="inputMessage"
|
||||
placeholder="输入消息..."
|
||||
:auto-size="{ minRows: 1, maxRows: 4 }"
|
||||
@press-enter="handleSend"
|
||||
<!-- Input area -->
|
||||
<div class="emoji-picker-area relative border-t border-border">
|
||||
<!-- Emoji picker popup -->
|
||||
<div
|
||||
v-if="showEmojiPicker"
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="sending || uploading"
|
||||
@click="handleSend"
|
||||
>
|
||||
发送
|
||||
</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"
|
||||
/>
|
||||
<a-button type="primary" @click="handleSend" :loading="sending">
|
||||
发送
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -398,20 +630,20 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Create Group Modal -->
|
||||
<a-modal
|
||||
<Modal
|
||||
v-model:open="showCreateGroup"
|
||||
title="创建群聊"
|
||||
@ok="handleCreateGroup"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="群名称">
|
||||
<a-input v-model:value="newGroupName" />
|
||||
</a-form-item>
|
||||
<a-form-item label="群描述">
|
||||
<a-input v-model:value="newGroupDesc" />
|
||||
</a-form-item>
|
||||
<a-form-item label="选择成员">
|
||||
<a-select
|
||||
<Form layout="vertical">
|
||||
<FormItem label="群名称">
|
||||
<Input v-model:value="newGroupName" />
|
||||
</FormItem>
|
||||
<FormItem label="群描述">
|
||||
<Input v-model:value="newGroupDesc" />
|
||||
</FormItem>
|
||||
<FormItem label="选择成员">
|
||||
<Select
|
||||
v-model:value="selectedMemberIds"
|
||||
mode="multiple"
|
||||
placeholder="搜索并选择成员"
|
||||
@ -419,16 +651,16 @@ onUnmounted(() => {
|
||||
:loading="memberSearchLoading"
|
||||
@search="handleMemberSearch"
|
||||
>
|
||||
<a-select-option
|
||||
<SelectOption
|
||||
v-for="user in imStore.contacts"
|
||||
:key="user.id"
|
||||
:value="user.id"
|
||||
>
|
||||
{{ user.username }} ({{ user.email }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -11,6 +11,11 @@ export default defineConfig(async () => {
|
||||
target: 'http://localhost:5211',
|
||||
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