feat: 集成 IM 即时通讯模块(SignalR + 聊天 UI)

新增 IM 功能模块,对接独立 IM 后端服务:
- API 层:独立 request client 直连 IM 后端(5212),与 rag-backend 代理隔离
- SignalR:实时消息收发、已读回执、在线状态、输入提示
- Pinia Store:会话列表、消息历史、未读计数、在线用户状态管理
- 聊天页面:会话列表 + 消息区域 + 群聊创建,支持文本/图片/表情消息
- 路由:/chat 页面接入主导航
This commit is contained in:
向宁 2026-05-17 17:06:52 +08:00
parent 27bcfde6c9
commit dfa70b7ef4
10 changed files with 1114 additions and 0 deletions

View File

@ -26,6 +26,7 @@
"#/*": "./src/*"
},
"dependencies": {
"@microsoft/signalr": "^10.0.0",
"@vben/access": "workspace:*",
"@vben/common-ui": "workspace:*",
"@vben/constants": "workspace:*",

View File

@ -0,0 +1,146 @@
import { RequestClient } from '@vben/request';
import { useAccessStore } from '@vben/stores';
const imRequestClient = new RequestClient({
baseURL: 'http://localhost:5212/api',
});
imRequestClient.addRequestInterceptor({
fulfilled: async (config) => {
const accessStore = useAccessStore();
if (accessStore.accessToken) {
config.headers.Authorization = `Bearer ${accessStore.accessToken}`;
}
return config;
},
});
imRequestClient.addResponseInterceptor({
fulfilled: (response) => {
const data = response.data as any;
if (data?.code === 0) {
response.data = data.data;
}
return response;
},
});
export namespace ImApi {
export interface Conversation {
id: string;
type: number;
lastMessageContent: string | null;
lastMessageAt: string | null;
unreadCount: number;
groupName: string | null;
groupAvatar: string | null;
otherUserId: string | null;
otherUserName: string | null;
otherUserAvatar: string | null;
}
export interface Message {
id: string;
conversationId: string;
senderId: string;
senderName: string;
senderAvatar: string | null;
contentType: number;
content: string;
metadata: MessageMetadata | null;
replyToId: string | null;
createdAt: string;
}
export interface MessageMetadata {
fileName?: string | null;
fileSize?: number | null;
fileUrl?: string | null;
imageUrl?: string | null;
imageWidth?: number | null;
imageHeight?: number | null;
emojiCode?: string | null;
}
export interface MessageHistory {
messages: Message[];
hasMore: boolean;
}
export interface UnreadCounts {
items: { conversationId: string; unreadCount: number }[];
total: number;
}
export interface GroupInfo {
id: string;
conversationId: string;
name: string;
avatar: string | null;
description: string | null;
ownerId: string;
memberCount: number;
createdAt: string;
}
export interface GroupMember {
userId: string;
userName: string;
avatar: string | null;
role: number;
joinedAt: string;
}
}
export async function getConversationsApi() {
return imRequestClient.get<ImApi.Conversation[]>('/conversations');
}
export async function getMessageHistoryApi(
conversationId: string,
beforeId?: string,
limit: number = 50,
) {
const params: Record<string, any> = { limit };
if (beforeId) params.beforeId = beforeId;
return imRequestClient.get<ImApi.MessageHistory>(
`/conversations/${conversationId}/messages`,
{ params },
);
}
export async function getUnreadCountApi() {
return imRequestClient.get<ImApi.UnreadCounts>('/unread');
}
export async function createGroupApi(data: {
name: string;
avatar?: string;
description?: string;
memberUserIds: string[];
}) {
return imRequestClient.post<ImApi.GroupInfo>('/groups', data);
}
export async function updateGroupApi(
groupId: string,
data: { name?: string; avatar?: string; description?: string },
) {
return imRequestClient.put<ImApi.GroupInfo>(`/groups/${groupId}`, data);
}
export async function deleteGroupApi(groupId: string) {
return imRequestClient.delete(`/groups/${groupId}`);
}
export async function addGroupMemberApi(groupId: string, userIds: string[]) {
return imRequestClient.post(`/groups/${groupId}/members`, { userIds });
}
export async function removeGroupMemberApi(groupId: string, userId: string) {
return imRequestClient.delete(`/groups/${groupId}/members/${userId}`);
}
export async function getGroupMembersApi(groupId: string) {
return imRequestClient.get<ImApi.GroupMember[]>(`/groups/${groupId}/members`);
}

View File

@ -1,3 +1,5 @@
export * from './auth';
export * from './im';
export * from './menu';
export * from './user';
export { signalRService } from './signalr';

View File

@ -0,0 +1,106 @@
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;
onMessageReceived: ((message: ImApi.Message) => void) | null = null;
onMessageRead:
| ((data: { messageId: string; 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;
async connect() {
const accessStore = useAccessStore();
const token = accessStore.accessToken;
if (!token) return;
this.connection = new signalR.HubConnectionBuilder()
.withUrl('http://localhost:5212/hubs/chat', {
accessTokenFactory: () => token,
})
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
.configureLogging(signalR.LogLevel.Warning)
.build();
this.connection.on('OnMessageReceived', (message: ImApi.Message) => {
this.onMessageReceived?.(message);
});
this.connection.on(
'OnMessageRead',
(data: { messageId: string; userId: string }) => {
this.onMessageRead?.(data);
},
);
this.connection.on(
'OnUserTyping',
(data: { conversationId: string; userId: string }) => {
this.onUserTyping?.(data);
},
);
this.connection.on('OnUserOnline', (data: { userId: string }) => {
this.onUserOnline?.(data);
});
this.connection.on('OnUserOffline', (data: { userId: string }) => {
this.onUserOffline?.(data);
});
this.connection.onreconnected(() => {
this.reconnectAttempts = 0;
});
this.connection.onclose(() => {
this.reconnectAttempts++;
});
try {
await this.connection.start();
} catch (error) {
console.error('SignalR connection error:', error);
}
}
async disconnect() {
if (this.connection) {
await this.connection.stop();
this.connection = null;
}
}
async sendMessage(request: {
conversationId: string;
contentType: number;
content: 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

@ -0,0 +1,25 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:message-square',
order: 5,
title: '即时通讯',
},
name: 'Chat',
path: '/chat',
children: [
{
name: 'ChatMain',
path: '/chat',
component: () => import('#/views/im/index.vue'),
meta: {
title: '聊天',
},
},
],
},
];
export default routes;

View File

@ -0,0 +1,176 @@
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
import type { ImApi } from '#/api/core/im';
import {
getConversationsApi,
getMessageHistoryApi,
getUnreadCountApi,
} from '#/api/core/im';
import { signalRService } from '#/api/core/signalr';
export const useImStore = defineStore('im', () => {
const conversations = ref<ImApi.Conversation[]>([]);
const activeConversationId = ref<string | null>(null);
const messages = ref<Map<string, ImApi.Message[]>>(new Map());
const unreadCounts = ref<Map<string, number>>(new Map());
const onlineUsers = ref<Set<string>>(new Set());
const connected = ref(false);
const typingUsers = ref<Map<string, Set<string>>>(new Map());
const totalUnread = computed(() => {
let total = 0;
for (const count of unreadCounts.value.values()) {
total += count;
}
return total;
});
const activeMessages = computed(() => {
if (!activeConversationId.value) return [];
return messages.value.get(activeConversationId.value) ?? [];
});
const activeConversation = computed(() => {
if (!activeConversationId.value) return null;
return conversations.value.find((c) => c.id === activeConversationId.value);
});
async function connect() {
signalRService.onMessageReceived = (message) => {
const convMessages = messages.value.get(message.conversationId) ?? [];
convMessages.push(message);
messages.value.set(message.conversationId, convMessages);
// Update conversation last message
const conv = conversations.value.find(
(c) => c.id === message.conversationId,
);
if (conv) {
conv.lastMessageContent =
message.contentType === 0
? message.content.substring(0, 50)
: `[${['文本', '图片', '文件', '系统', '表情'][message.contentType] || '消息'}]`;
conv.lastMessageAt = message.createdAt;
}
// Increment unread if not active conversation
if (message.conversationId !== activeConversationId.value) {
const current = unreadCounts.value.get(message.conversationId) ?? 0;
unreadCounts.value.set(message.conversationId, current + 1);
const conv2 = conversations.value.find(
(c) => c.id === message.conversationId,
);
if (conv2) conv2.unreadCount++;
}
};
signalRService.onUserOnline = (data) => {
onlineUsers.value.add(data.userId);
};
signalRService.onUserOffline = (data) => {
onlineUsers.value.delete(data.userId);
};
signalRService.onUserTyping = (data) => {
if (!typingUsers.value.has(data.conversationId)) {
typingUsers.value.set(data.conversationId, new Set());
}
typingUsers.value.get(data.conversationId)?.add(data.userId);
setTimeout(() => {
typingUsers.value.get(data.conversationId)?.delete(data.userId);
}, 3000);
};
await signalRService.connect();
connected.value = signalRService.isConnected;
}
async function disconnect() {
await signalRService.disconnect();
connected.value = false;
}
async function loadConversations() {
const data = await getConversationsApi();
conversations.value = data;
}
async function loadMessages(conversationId: string, beforeId?: string) {
const data = await getMessageHistoryApi(conversationId, beforeId);
if (beforeId) {
const existing = messages.value.get(conversationId) ?? [];
messages.value.set(conversationId, [...data.messages, ...existing]);
} else {
messages.value.set(conversationId, data.messages);
}
return data.hasMore;
}
async function loadUnreadCounts() {
const data = await getUnreadCountApi();
const map = new Map<string, number>();
for (const item of data.items) {
map.set(item.conversationId, item.unreadCount);
}
unreadCounts.value = map;
}
async function sendMessage(
conversationId: string,
contentType: number,
content: string,
metadata?: any,
replyToId?: string,
) {
await signalRService.sendMessage({
conversationId,
contentType,
content,
metadata,
replyToId,
});
}
async function markAsRead(conversationId: string, messageId: string) {
await signalRService.markAsRead(conversationId, messageId);
unreadCounts.value.set(conversationId, 0);
const conv = conversations.value.find((c) => c.id === conversationId);
if (conv) conv.unreadCount = 0;
}
async function typing(conversationId: string) {
await signalRService.typing(conversationId);
}
function setActiveConversation(conversationId: string | null) {
activeConversationId.value = conversationId;
}
return {
// State
conversations,
activeConversationId,
messages,
unreadCounts,
onlineUsers,
connected,
typingUsers,
// Computed
totalUnread,
activeMessages,
activeConversation,
// Actions
connect,
disconnect,
loadConversations,
loadMessages,
loadUnreadCounts,
sendMessage,
markAsRead,
typing,
setActiveConversation,
};
});

View File

@ -1 +1,2 @@
export * from './auth';
export { useImStore } from './im';

View File

@ -0,0 +1,408 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useUserStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { createGroupApi } from '#/api';
import { useImStore } from '#/store';
const userStore = useUserStore();
const imStore = useImStore();
const inputMessage = ref('');
const sending = ref(false);
const showCreateGroup = ref(false);
const showGroupManage = ref(false);
const newGroupName = ref('');
const newGroupDesc = ref('');
const messageListRef = ref<HTMLElement | null>(null);
const activeConversationId = computed(() => imStore.activeConversationId);
const activeConversation = computed(() => imStore.activeConversation);
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(b.lastMessageAt).getTime() : 0;
return timeB - timeA;
}),
);
function isSelfMessage(msg: any) {
return msg.senderId === userStore.userInfo?.userId;
}
function formatTime(dateStr: string) {
const date = new Date(dateStr);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
}
return date.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
async function selectConversation(conversationId: string) {
imStore.setActiveConversation(conversationId);
await imStore.loadMessages(conversationId);
await nextTick();
scrollToBottom();
// Mark as read
const msgs = imStore.activeMessages;
if (msgs.length > 0) {
const lastMsg = msgs[msgs.length - 1];
await imStore.markAsRead(conversationId, lastMsg.id);
}
}
async function handleSend(e?: any) {
if (e?.shiftKey) return; // Shift+Enter for newline
e?.preventDefault?.();
const content = inputMessage.value.trim();
if (!content || !activeConversationId.value) return;
sending.value = true;
try {
await imStore.sendMessage(activeConversationId.value, 0, content);
inputMessage.value = '';
await nextTick();
scrollToBottom();
} catch {
message.error('发送失败');
} finally {
sending.value = false;
}
}
async function handleCreateGroup() {
if (!newGroupName.value.trim()) {
message.warning('请输入群名称');
return;
}
try {
await createGroupApi({
name: newGroupName.value,
description: newGroupDesc.value || undefined,
memberUserIds: [],
});
message.success('群聊创建成功');
showCreateGroup.value = false;
newGroupName.value = '';
newGroupDesc.value = '';
await imStore.loadConversations();
} catch {
message.error('创建失败');
}
}
function scrollToBottom() {
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
}
}
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;
if (firstMsgId) {
await imStore.loadMessages(activeConversationId.value, firstMsgId);
}
}
}
onMounted(async () => {
await imStore.connect();
await imStore.loadConversations();
await imStore.loadUnreadCounts();
});
onUnmounted(() => {
imStore.disconnect();
});
</script>
<template>
<div class="chat-container">
<div class="chat-sidebar">
<div class="sidebar-header">
<h3 style=" padding: 16px;margin: 0">消息</h3>
<a-button type="primary" size="small" @click="showCreateGroup = true">
创建群聊
</a-button>
</div>
<div class="conversation-list">
<div
v-for="conv in sortedConversations"
:key="conv.id"
class="conversation-item"
:class="{ active: conv.id === activeConversationId }"
@click="selectConversation(conv.id)"
>
<a-avatar v-if="conv.type === 2" :size="40">
{{ conv.groupName?.charAt(0) || '群' }}
</a-avatar>
<a-avatar v-else :size="40">
{{ conv.otherUserName?.charAt(0) || 'U' }}
</a-avatar>
<div class="conversation-info">
<div class="conversation-name">
{{
conv.type === 2 ? conv.groupName : conv.otherUserName || '私聊'
}}
</div>
<div class="conversation-last-msg">
{{ conv.lastMessageContent || '暂无消息' }}
</div>
</div>
<a-badge
v-if="conv.unreadCount > 0"
:count="conv.unreadCount"
:overflow-count="99"
/>
</div>
</div>
</div>
<div class="chat-main">
<template v-if="activeConversation">
<div class="chat-header">
<span>
{{
activeConversation.type === 2
? activeConversation.groupName
: activeConversation.otherUserName || '私聊'
}}
</span>
<a-button
v-if="activeConversation.type === 2"
size="small"
@click="showGroupManage = true"
>
群管理
</a-button>
</div>
<div ref="messageListRef" class="message-list" @scroll="onScroll">
<div
v-for="msg in activeMessages"
:key="msg.id"
class="message-item"
:class="{ 'message-self': isSelfMessage(msg) }"
>
<div class="message-sender">
{{ msg.senderName || msg.senderId.substring(0, 8) }}
</div>
<div class="message-content">
<template v-if="msg.contentType === 0">
{{ msg.content }}
</template>
<template v-else-if="msg.contentType === 1">
<img
:src="msg.metadata?.imageUrl"
style="max-width: 200px; border-radius: 8px"
/>
</template>
<template v-else-if="msg.contentType === 4">
<span style="font-size: 24px">{{ msg.content }}</span>
</template>
<template v-else>
[{{
['文本', '图片', '文件', '系统', '表情'][msg.contentType]
}}]
</template>
</div>
<div class="message-time">{{ formatTime(msg.createdAt) }}</div>
</div>
</div>
<div class="chat-input">
<a-textarea
v-model:value="inputMessage"
placeholder="输入消息..."
:auto-size="{ minRows: 1, maxRows: 4 }"
@press-enter="handleSend"
/>
<a-button type="primary" @click="handleSend" :loading="sending">
发送
</a-button>
</div>
</template>
<div v-else class="chat-empty">
<p>选择一个会话开始聊天</p>
</div>
</div>
<!-- Create Group Modal -->
<a-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>
</a-modal>
</div>
</template>
<style scoped>
.chat-container {
display: flex;
height: calc(100vh - 120px);
overflow: hidden;
background: #fff;
border-radius: 8px;
}
.chat-sidebar {
display: flex;
flex-direction: column;
width: 300px;
border-right: 1px solid #f0f0f0;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.conversation-list {
flex: 1;
overflow-y: auto;
}
.conversation-item {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s;
}
.conversation-item:hover {
background: #f5f5f5;
}
.conversation-item.active {
background: #e6f4ff;
}
.conversation-info {
flex: 1;
margin-left: 12px;
overflow: hidden;
}
.conversation-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
}
.conversation-last-msg {
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: #999;
white-space: nowrap;
}
.chat-main {
display: flex;
flex: 1;
flex-direction: column;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
font-weight: 500;
border-bottom: 1px solid #f0f0f0;
}
.message-list {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.message-item {
margin-bottom: 16px;
}
.message-item.message-self {
text-align: right;
}
.message-sender {
margin-bottom: 4px;
font-size: 12px;
color: #999;
}
.message-content {
display: inline-block;
max-width: 60%;
padding: 8px 12px;
overflow-wrap: anywhere;
background: #f0f0f0;
border-radius: 8px;
}
.message-item.message-self .message-content {
color: #fff;
background: #1677ff;
}
.message-time {
margin-top: 4px;
font-size: 11px;
color: #ccc;
}
.chat-input {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
}
.chat-input .ant-input {
flex: 1;
}
.chat-empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
color: #999;
}
</style>

93
pnpm-lock.yaml generated
View File

@ -591,6 +591,9 @@ importers:
apps/web-antd:
dependencies:
'@microsoft/signalr':
specifier: ^10.0.0
version: 10.0.0
'@vben/access':
specifier: workspace:*
version: link:../../packages/effects/access
@ -3327,6 +3330,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@microsoft/signalr@10.0.0':
resolution: {integrity: sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==}
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
@ -6221,6 +6227,10 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
eventsource@2.0.2:
resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==}
engines: {node: '>=12.0.0'}
execa@9.6.1:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
@ -6291,6 +6301,9 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
fetch-cookie@2.2.0:
resolution: {integrity: sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==}
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@ -8060,6 +8073,9 @@ packages:
prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
psl@1.15.0:
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
publint@0.3.18:
resolution: {integrity: sha512-JRJFeBTrfx4qLwEuGFPk+haJOJN97KnPuK01yj+4k/Wj5BgoOK5uNsivporiqBjk2JDaslg7qJOhGRnpltGeog==}
engines: {node: '>=18'}
@ -8092,6 +8108,9 @@ packages:
quansync@1.0.0:
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@ -8211,6 +8230,9 @@ packages:
require-package-name@2.0.1:
resolution: {integrity: sha512-uuoJ1hU/k6M0779t3VMVIYpb2VMJk05cehCaABFhXaibcbvfgR8wKiozLjVFSzJPmQMRqIcO0HMyTFqfV09V6Q==}
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
@ -8515,6 +8537,9 @@ packages:
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@ -8970,6 +8995,10 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tough-cookie@4.1.4:
resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
engines: {node: '>=6'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
@ -9150,6 +9179,10 @@ packages:
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
engines: {node: '>= 4.0.0'}
universalify@0.2.0:
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
engines: {node: '>= 4.0.0'}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
@ -9305,6 +9338,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
url-parse@1.5.10:
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@ -9696,6 +9732,18 @@ packages:
resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==}
engines: {node: ^20.17.0 || >=22.9.0}
ws@7.5.10:
resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
engines: {node: '>=8.3.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
ws@8.20.0:
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
engines: {node: '>=10.0.0'}
@ -11690,6 +11738,18 @@ snapshots:
- encoding
- supports-color
'@microsoft/signalr@10.0.0':
dependencies:
abort-controller: 3.0.0
eventsource: 2.0.2
fetch-cookie: 2.2.0
node-fetch: 2.7.0
ws: 7.5.10
transitivePeerDependencies:
- bufferutil
- encoding
- utf-8-validate
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@ -14613,6 +14673,8 @@ snapshots:
events@3.3.0: {}
eventsource@2.0.2: {}
execa@9.6.1:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
@ -14683,6 +14745,11 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
fetch-cookie@2.2.0:
dependencies:
set-cookie-parser: 2.7.2
tough-cookie: 4.1.4
fflate@0.8.2: {}
figures@6.1.0:
@ -16509,6 +16576,10 @@ snapshots:
prr@1.0.1:
optional: true
psl@1.15.0:
dependencies:
punycode: 2.3.1
publint@0.3.18:
dependencies:
'@publint/pack': 0.1.4
@ -16540,6 +16611,8 @@ snapshots:
quansync@1.0.0: {}
querystringify@2.2.0: {}
queue-microtask@1.2.3: {}
radix3@1.1.2: {}
@ -16687,6 +16760,8 @@ snapshots:
require-package-name@2.0.1: {}
requires-port@1.0.0: {}
resize-observer-polyfill@1.5.1: {}
resolve-dir@1.0.1:
@ -17005,6 +17080,8 @@ snapshots:
set-blocking@2.0.0: {}
set-cookie-parser@2.7.2: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@ -17517,6 +17594,13 @@ snapshots:
totalist@3.0.1: {}
tough-cookie@4.1.4:
dependencies:
psl: 1.15.0
punycode: 2.3.1
universalify: 0.2.0
url-parse: 1.5.10
tr46@0.0.3: {}
tr46@1.0.1:
@ -17713,6 +17797,8 @@ snapshots:
universalify@0.1.2: {}
universalify@0.2.0: {}
universalify@2.0.1: {}
unplugin-dts@1.0.0-beta.6(esbuild@0.27.7)(rolldown@1.0.0-rc.17)(rollup@4.60.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3)):
@ -17845,6 +17931,11 @@ snapshots:
dependencies:
punycode: 2.3.1
url-parse@1.5.10:
dependencies:
querystringify: 2.2.0
requires-port: 1.0.0
util-deprecate@1.0.2: {}
valibot@1.3.1(typescript@6.0.3):
@ -18323,6 +18414,8 @@ snapshots:
dependencies:
signal-exit: 4.1.0
ws@7.5.10: {}
ws@8.20.0: {}
wsl-utils@0.1.0:

156
vben-admin.code-workspace Normal file
View File

@ -0,0 +1,156 @@
{
"folders": [
{
"name": "@vben/web-antd",
"path": "apps/web-antd"
},
{
"name": "@vben/commitlint-config",
"path": "internal/lint-configs/commitlint-config"
},
{
"name": "@vben/eslint-config",
"path": "internal/lint-configs/eslint-config"
},
{
"name": "@vben/oxfmt-config",
"path": "internal/lint-configs/oxfmt-config"
},
{
"name": "@vben/oxlint-config",
"path": "internal/lint-configs/oxlint-config"
},
{
"name": "@vben/stylelint-config",
"path": "internal/lint-configs/stylelint-config"
},
{
"name": "@vben/node-utils",
"path": "internal/node-utils"
},
{
"name": "@vben/tailwind-config",
"path": "internal/tailwind-config"
},
{
"name": "@vben/tsconfig",
"path": "internal/tsconfig"
},
{
"name": "@vben/vite-config",
"path": "internal/vite-config"
},
{
"name": "@vben-core/design",
"path": "packages/@core/base/design"
},
{
"name": "@vben-core/icons",
"path": "packages/@core/base/icons"
},
{
"name": "@vben-core/shared",
"path": "packages/@core/base/shared"
},
{
"name": "@vben-core/typings",
"path": "packages/@core/base/typings"
},
{
"name": "@vben-core/composables",
"path": "packages/@core/composables"
},
{
"name": "@vben-core/preferences",
"path": "packages/@core/preferences"
},
{
"name": "@vben-core/form-ui",
"path": "packages/@core/ui-kit/form-ui"
},
{
"name": "@vben-core/layout-ui",
"path": "packages/@core/ui-kit/layout-ui"
},
{
"name": "@vben-core/menu-ui",
"path": "packages/@core/ui-kit/menu-ui"
},
{
"name": "@vben-core/popup-ui",
"path": "packages/@core/ui-kit/popup-ui"
},
{
"name": "@vben-core/shadcn-ui",
"path": "packages/@core/ui-kit/shadcn-ui"
},
{
"name": "@vben-core/tabs-ui",
"path": "packages/@core/ui-kit/tabs-ui"
},
{
"name": "@vben/constants",
"path": "packages/constants"
},
{
"name": "@vben/access",
"path": "packages/effects/access"
},
{
"name": "@vben/common-ui",
"path": "packages/effects/common-ui"
},
{
"name": "@vben/hooks",
"path": "packages/effects/hooks"
},
{
"name": "@vben/layouts",
"path": "packages/effects/layouts"
},
{
"name": "@vben/plugins",
"path": "packages/effects/plugins"
},
{
"name": "@vben/request",
"path": "packages/effects/request"
},
{
"name": "@vben/icons",
"path": "packages/icons"
},
{
"name": "@vben/locales",
"path": "packages/locales"
},
{
"name": "@vben/preferences",
"path": "packages/preferences"
},
{
"name": "@vben/stores",
"path": "packages/stores"
},
{
"name": "@vben/styles",
"path": "packages/styles"
},
{
"name": "@vben/types",
"path": "packages/types"
},
{
"name": "@vben/utils",
"path": "packages/utils"
},
{
"name": "@vben/turbo-run",
"path": "scripts/turbo-run"
},
{
"name": "@vben/vsh",
"path": "scripts/vsh"
}
]
}