feat: add file management module and enhance IM chat UI

This commit is contained in:
向宁 2026-05-17 22:47:38 +08:00
parent e75e364e64
commit c503018843
14 changed files with 1045 additions and 100 deletions

View File

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

View 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 },
});
}

View File

@ -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);
}

View File

@ -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';

View File

@ -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();

View File

@ -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;

View File

@ -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."
}
}

View File

@ -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} 吗?存储桶必须为空才能删除。"
}
}

View File

@ -19,6 +19,7 @@ export const overridesPreferences = defineOverridesPreferences({
// overrides
app: {
name: import.meta.env.VITE_APP_TITLE,
enableRefreshToken: true,
},
});

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

View File

@ -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;
}

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

View File

@ -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,16 +90,52 @@ 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];
const lastMsg = msgs.at(-1);
if (lastMsg) {
await imStore.markAsRead(conversationId, lastMsg.id);
}
}
}
async function handleSend(e?: any) {
@ -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"
/>
<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>
</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>

View File

@ -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/, ''),
},
},
},
},