diff --git a/.gitignore b/.gitignore index 0c0ed8d..854e995 100644 --- a/.gitignore +++ b/.gitignore @@ -310,10 +310,12 @@ backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py !backend-fastapi/ai_platform/nodes/builtin/form_ui_design_node.py !backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py web/apps/web-ele/src/api/core/database-monitor.ts +!web/apps/web-ele/src/api/core/database-monitor.ts web/apps/web-ele/src/api/core/demo.ts web/apps/web-ele/src/api/core/link-preview.ts web/apps/web-ele/src/api/core/redis-manager.ts web/apps/web-ele/src/api/core/redis-monitor.ts +!web/apps/web-ele/src/api/core/redis-monitor.ts web/apps/web-ele/src/api/core/region.ts web/apps/web-ele/src/api/core/scheduler.ts web/apps/web-ele/src/api/core/server-monitor.ts @@ -324,10 +326,14 @@ web/apps/web-ele/src/views/_core/data-source/ web/apps/web-ele/src/views/_core/database-connection/ web/apps/web-ele/src/views/_core/database-manager/ web/apps/web-ele/src/views/_core/database-monitor/ +!web/apps/web-ele/src/views/_core/database-monitor/ +!web/apps/web-ele/src/views/_core/database-monitor/** web/apps/web-ele/src/views/_core/demo/ web/apps/web-ele/src/views/_core/mobile-signature/ web/apps/web-ele/src/views/_core/redis-manager/ web/apps/web-ele/src/views/_core/redis-monitor/ +!web/apps/web-ele/src/views/_core/redis-monitor/ +!web/apps/web-ele/src/views/_core/redis-monitor/** web/apps/web-ele/src/views/_core/region-manager/ web/apps/web-ele/src/views/_core/scheduler/ web/apps/web-ele/src/views/_core/server-monitor/ diff --git a/backend-fastapi/ai_platform/services/chat_service.py b/backend-fastapi/ai_platform/services/chat_service.py index 5353615..ea1254c 100644 --- a/backend-fastapi/ai_platform/services/chat_service.py +++ b/backend-fastapi/ai_platform/services/chat_service.py @@ -8,7 +8,7 @@ from typing import AsyncGenerator, Dict, List, Optional from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession -from ai_platform.models import AIApp, Conversation, Message, LLMModel +from ai_platform.models import AIApp, Conversation, Message, LLMModel, LLMProvider from utils.context import get_current_user_id_from_context from .llm_service import LLMService @@ -352,15 +352,33 @@ class ChatService: async def _get_effective_model(self, conversation: Conversation, app: AIApp) -> Optional[LLMModel]: """获取有效的模型""" model_id = conversation.model_override_id or app.model_id - if not model_id: - return None - - result = await self._db.execute( - select(LLMModel).where( - LLMModel.id == model_id, - LLMModel.is_active == True, - LLMModel.is_deleted == False + if model_id: + result = await self._db.execute( + select(LLMModel) + .join(LLMProvider, LLMProvider.id == LLMModel.provider_id) + .where( + LLMModel.id == model_id, + LLMModel.is_active == True, + LLMModel.is_deleted == False, + LLMProvider.is_active == True, + LLMProvider.is_deleted == False, + ) ) + model = result.scalar_one_or_none() + if model: + return model + + result = await self._db.execute( + select(LLMModel) + .join(LLMProvider, LLMProvider.id == LLMModel.provider_id) + .where( + LLMModel.is_active == True, + LLMModel.is_deleted == False, + LLMModel.model_type == "chat", + LLMProvider.is_active == True, + LLMProvider.is_deleted == False, + ) + .order_by(LLMModel.sort.desc(), LLMModel.sys_create_datetime.desc()) ) return result.scalar_one_or_none() diff --git a/backend-fastapi/core/menu/service.py b/backend-fastapi/core/menu/service.py index 8127b54..aa176af 100644 --- a/backend-fastapi/core/menu/service.py +++ b/backend-fastapi/core/menu/service.py @@ -17,7 +17,7 @@ from core.menu.schema import MenuCreate, MenuUpdate menu_cache = CacheManager(prefix="menu:") # 缓存key -AI_AGENT_ADMIN_MENU_CACHE_VERSION = "v19" +AI_AGENT_ADMIN_MENU_CACHE_VERSION = "v20" MENU_TREE_CACHE_KEY = f"tree:ai_agent_admin:{AI_AGENT_ADMIN_MENU_CACHE_VERSION}" USER_ROUTE_CACHE_PREFIX = f"user_route:ai_agent_admin:{AI_AGENT_ADMIN_MENU_CACHE_VERSION}:" @@ -32,7 +32,12 @@ AI_AGENT_ADMIN_MENU_NAMES = { "AIWorkflowRuns", "CodexAgentChat", "KnowledgeBase", + "StartChat", "SystemManagement", + "SystemMonitoring", + "ServerMonitor", + "RedisMonitor", + "DatabaseMonitor", "SystemPermission", "UserManagement", "SystemDept", @@ -63,7 +68,12 @@ AI_AGENT_ADMIN_VISIBLE_MENU_NAMES = { "AIWorkflowRuns", "CodexAgentChat", "KnowledgeBase", + "StartChat", "SystemManagement", + "SystemMonitoring", + "ServerMonitor", + "RedisMonitor", + "DatabaseMonitor", "SystemPermission", "UserManagement", "SystemDept", @@ -115,7 +125,43 @@ AI_AGENT_ADMIN_MESSAGE_CHILD_MENU_NAMES = { "MessageList", } +AI_AGENT_ADMIN_MONITOR_CHILD_MENU_NAMES = { + "DatabaseMonitor", + "RedisMonitor", + "ServerMonitor", +} + AI_AGENT_ADMIN_MENU_OVERRIDES = { + "StartChat": { + "component": "/_core/chat/index", + "hideInMenu": False, + "noBasicLayout": True, + "path": "/chat", + "title": "menu-title.startChat", + }, + "SystemMonitoring": { + "hideInMenu": False, + "path": "/monitor", + "title": "menu-title.systemMonitoring", + }, + "ServerMonitor": { + "component": "/_core/server-monitor/index", + "hideInMenu": False, + "path": "/monitor/server", + "title": "menu-title.serverMonitoring", + }, + "RedisMonitor": { + "component": "/_core/redis-monitor/index", + "hideInMenu": False, + "path": "/monitor/redis", + "title": "menu-title.redisMonitoring", + }, + "DatabaseMonitor": { + "component": "/_core/database-monitor/index", + "hideInMenu": False, + "path": "/monitor/database", + "title": "menu-title.databaseMonitoring", + }, "SystemConfigManager": { "component": "/_core/system-config/index", "path": "/system-config", @@ -145,10 +191,107 @@ AI_AGENT_ADMIN_MENU_OVERRIDES = { }, } +AI_AGENT_ADMIN_SYNTHETIC_MENUS = [ + { + "id": "aiadm-chat", + "name": "StartChat", + "title": "menu-title.startChat", + "path": "/chat", + "type": "menu", + "component": "/_core/chat/index", + "icon": "lucide:align-center-vertical", + "order": 20, + "noBasicLayout": True, + }, + { + "id": "aiadm-monitor", + "name": "SystemMonitoring", + "title": "menu-title.systemMonitoring", + "path": "/monitor", + "type": "catalog", + "component": None, + "icon": "lucide:activity", + "order": 80, + }, + { + "id": "aiadm-server", + "name": "ServerMonitor", + "title": "menu-title.serverMonitoring", + "path": "/monitor/server", + "type": "menu", + "component": "/_core/server-monitor/index", + "icon": "carbon:bare-metal-server", + "order": 10, + }, + { + "id": "aiadm-redis", + "name": "RedisMonitor", + "title": "menu-title.redisMonitoring", + "path": "/monitor/redis", + "type": "menu", + "component": "/_core/redis-monitor/index", + "icon": "logos:redis", + "order": 20, + }, + { + "id": "aiadm-dbmon", + "name": "DatabaseMonitor", + "title": "menu-title.databaseMonitoring", + "path": "/monitor/database", + "type": "menu", + "component": "/_core/database-monitor/index", + "icon": "carbon:db2-database", + "order": 30, + }, +] + + +def _make_ai_agent_admin_menu(spec: Dict[str, Any]) -> Menu: + menu = Menu() + menu.id = spec["id"] + menu.application_id = None + menu.is_system = False + menu.parent_id = None + menu.name = spec["name"] + menu.title = spec["title"] + menu.authCode = None + menu.path = spec["path"] + menu.type = spec["type"] + menu.component = spec.get("component") + menu.redirect = None + menu.activePath = None + menu.query = None + menu.noBasicLayout = bool(spec.get("noBasicLayout", False)) + menu.icon = spec.get("icon") + menu.activeIcon = None + menu.order = spec["order"] + menu.hideInMenu = False + menu.hideChildrenInMenu = False + menu.hideInBreadcrumb = False + menu.hideInTab = False + menu.affixTab = False + menu.affixTabOrder = None + menu.keepAlive = False + menu.maxNumOfOpenTab = None + menu.fullPathKey = True + menu.link = None + menu.iframeSrc = None + menu.openInNewWindow = False + menu.badge = None + menu.badgeType = None + menu.badgeVariants = None + return menu + def _filter_ai_agent_admin_menus(menus: List[Menu]) -> List[Menu]: """Keep only the lightweight admin and AI modules for this product.""" filtered = [menu for menu in menus if menu.name in AI_AGENT_ADMIN_MENU_NAMES] + existing_names = {menu.name for menu in filtered} + filtered.extend( + _make_ai_agent_admin_menu(spec) + for spec in AI_AGENT_ADMIN_SYNTHETIC_MENUS + if spec["name"] not in existing_names + ) for menu in filtered: _normalize_ai_agent_admin_menu(menu) _normalize_ai_agent_admin_parentage(filtered) @@ -184,6 +327,10 @@ def _normalize_ai_agent_admin_parentage(menus: List[Menu]) -> None: (menu for menu in menus if menu.name == "Message"), None, ) + monitor_menu = next( + (menu for menu in menus if menu.name == "SystemMonitoring"), + None, + ) for menu in menus: if system_menu and menu.name in AI_AGENT_ADMIN_SYSTEM_CHILD_MENU_NAMES: menu.parent_id = system_menu.id @@ -194,6 +341,9 @@ def _normalize_ai_agent_admin_parentage(menus: List[Menu]) -> None: if message_menu and menu.name in AI_AGENT_ADMIN_MESSAGE_CHILD_MENU_NAMES: menu.parent_id = message_menu.id continue + if monitor_menu and menu.name in AI_AGENT_ADMIN_MONITOR_CHILD_MENU_NAMES: + menu.parent_id = monitor_menu.id + continue if not menu.parent_id or menu.parent_id in retained_ids: continue menu.parent_id = None diff --git a/backend-fastapi/core/router.py b/backend-fastapi/core/router.py index d4430e9..68d9c2d 100644 --- a/backend-fastapi/core/router.py +++ b/backend-fastapi/core/router.py @@ -7,8 +7,11 @@ from core.application.api import router as application_router from core.api_token.api import router as api_token_router from core.auth.api import router as auth_router from core.chat.api import router as chat_router +from core.database_monitor.api import router as database_monitor_router from core.dept.api import router as dept_router from core.device.api import router as device_router +from core.dict.api import router as dict_router +from core.dict_item.api import router as dict_item_router from core.file_manager.router import router as file_manager_router from core.login_log.api import router as login_log_router from core.menu.api import router as menu_router @@ -18,10 +21,12 @@ from core.oauth.api import router as oauth_router from core.page_manager.api import router as page_manager_router from core.permission.api import router as permission_router from core.post.api import router as post_router +from core.redis_monitor.api import router as redis_monitor_router from core.resource_scope.field_permission.api import router as field_permission_router from core.resource_scope.scope_permission.api import router as resource_scope_router from core.role.api import router as role_router from core.server_monitor.api import router as server_monitor_router +from core.system_config.api import router as system_config_router from core.ui_config.api import router as ui_config_router from core.user.api import router as user_router @@ -31,11 +36,15 @@ router = APIRouter() router.include_router(application_router) router.include_router(auth_router) router.include_router(chat_router) +router.include_router(database_monitor_router) router.include_router(dept_router) router.include_router(device_router) +router.include_router(dict_router) +router.include_router(dict_item_router) router.include_router(menu_router) router.include_router(permission_router) router.include_router(post_router) +router.include_router(redis_monitor_router) router.include_router(resource_scope_router) router.include_router(field_permission_router) router.include_router(role_router) @@ -47,5 +56,6 @@ router.include_router(announcement_router) router.include_router(oauth_router) router.include_router(page_manager_router) router.include_router(server_monitor_router) +router.include_router(system_config_router) router.include_router(ui_config_router) router.include_router(api_token_router) diff --git a/web/.npmrc b/web/.npmrc index c509bf6..e7bade6 100644 --- a/web/.npmrc +++ b/web/.npmrc @@ -6,6 +6,7 @@ public-hoist-pattern[]=prettier-plugin-tailwindcss public-hoist-pattern[]=stylelint public-hoist-pattern[]=*postcss* public-hoist-pattern[]=@commitlint/* +public-hoist-pattern[]=@element-plus/icons-vue public-hoist-pattern[]=czg public-hoist-pattern[]=prosemirror-* public-hoist-pattern[]=@univerjs/* diff --git a/web/apps/web-ele/src/api/ai-platform/ai-platform.ts b/web/apps/web-ele/src/api/ai-platform/ai-platform.ts index 806e206..85cf632 100644 --- a/web/apps/web-ele/src/api/ai-platform/ai-platform.ts +++ b/web/apps/web-ele/src/api/ai-platform/ai-platform.ts @@ -901,7 +901,10 @@ export interface WorkflowStreamEvent { accumulated_content?: string; // 累积内容 // 并行执行字段 branches?: string[]; // 并行分支 ID 列表 + branch_id?: string; + branch_label?: string; branch_results?: Record; // 各分支执行结果 + results?: any; // 循环迭代字段 iteration?: number; // 当前迭代次数(从 0 开始) total?: number; // 总迭代次数(for_each 模式) @@ -1154,20 +1157,37 @@ export interface ReasoningStep { | 'action' | 'annotation_reply' | 'knowledge_retrieval' + | 'loop_complete' + | 'loop_iteration_complete' + | 'loop_iteration_error' + | 'loop_iteration_start' | 'node_complete' | 'node_start' | 'observation' + | 'parallel_complete' + | 'parallel_start' | 'thought'; content: string; tool?: string; params?: Record; + branch_id?: string; + branch_label?: string; + agent_code?: string; + agent_name?: string; + model?: string; + model_id?: string; + subflow_name?: string; + from_subflow?: boolean; + collaboration_role?: string; + collaboration_mode?: string; + communication?: Record; timestamp: string; // 对话流模式:节点信息 node_id?: string; node_type?: string; output?: any; /** 步骤状态:running 执行中,completed 已完成 */ - status?: 'completed' | 'running'; + status?: 'completed' | 'failed' | 'running'; } /** 工具调用记录 */ @@ -1237,6 +1257,8 @@ export interface AgentChatEvent { | 'node_event' | 'node_start' | 'observation' + | 'parallel_complete' + | 'parallel_start' | 'start' | 'thought' | 'waiting_input'; @@ -1254,7 +1276,12 @@ export interface AgentChatEvent { node_id?: string; node_type?: string; node_label?: string; + branch_id?: string; + branch_label?: string; + communication?: Record; output?: any; + results?: any; + branch_results?: Record; outputs?: { output?: any; output_variables?: Record; diff --git a/web/apps/web-ele/src/api/core/database-monitor.ts b/web/apps/web-ele/src/api/core/database-monitor.ts new file mode 100644 index 0000000..5e6d4bc --- /dev/null +++ b/web/apps/web-ele/src/api/core/database-monitor.ts @@ -0,0 +1,170 @@ +import { requestClient } from '#/api/request'; + +// 数据库基本信息 +export interface DatabaseBasicInfo { + db_type: string; + host: string; + port: number; + database: string; + version: string; + uptime: string; + timezone: string; + charset: string; +} + +// 数据库连接信息 +export interface DatabaseConnectionInfo { + total_connections: number; + max_connections: number; + active_connections: number; + idle_connections: number; + connection_usage_percent: number; +} + +// 数据库大小信息 +export interface DatabaseSize { + database_size_bytes: number; + database_size_mb: number; + database_size_gb: number; +} + +// 数据库性能统计 +export interface DatabasePerformanceStats { + // PostgreSQL + total_backends?: number; + transactions_commit?: number; + transactions_rollback?: number; + tuples_returned?: number; + tuples_fetched?: number; + tuples_inserted?: number; + tuples_updated?: number; + tuples_deleted?: number; + + // MySQL + total_queries?: number; + total_connections?: number; + slow_queries?: number; + bytes_received?: number; + bytes_sent?: number; + + // SQL Server + batch_requests_per_sec?: number; + page_life_expectancy?: number; + buffer_cache_hit_ratio?: number; + + // 通用 + cache_hit_ratio: number; +} + +// 数据库表统计 +export interface DatabaseTableStats { + // PostgreSQL + schemaname?: string; + tablename?: string; + inserts?: number; + updates?: number; + deletes?: number; + live_tuples?: number; + dead_tuples?: number; + size?: string; + size_bytes?: number; + total_size?: string; + total_size_bytes?: number; + + // MySQL + table_name?: string; + table_rows?: number; + data_length?: number; + index_length?: number; + auto_increment?: number; + + // SQL Server + total_size_kb?: number; + used_size_kb?: number; + data_size_kb?: number; + + // Oracle + size_bytes?: number; +} + +// 数据库概览 +export interface DatabaseMonitorOverview { + connection_id: string; + connection_name: string; + status: string; + basic_info: DatabaseBasicInfo; + connection_info: DatabaseConnectionInfo; + database_size: DatabaseSize; + performance_stats: DatabasePerformanceStats; + table_stats: DatabaseTableStats[]; + timestamp: string; +} + +// 数据库实时统计 +export interface DatabaseRealtimeStats { + connection_id: string; + connections_used: number; + connection_usage_percent: number; + database_size_mb: number; + cache_hit_ratio: number; + active_connections: number; + timestamp: string; +} + +// 数据库连接测试 +export interface DatabaseConnectionTest { + success: boolean; + message: string; + response_time?: number; + version?: string; + db_type: string; +} + +// 数据库配置(监控目标 = 数据库连接 code) +export interface DatabaseConfig { + name: string; + db_name: string; + db_type: string; + host: string; + port: number; + database: string; + user: string; + has_password: boolean; + is_system?: boolean; +} + +/** + * 获取数据库监控配置列表 + */ +export async function getDatabaseMonitorConfigsApi() { + return requestClient.get( + '/api/core/database_monitor/configs', + ); +} + +/** + * 获取数据库概览信息 + */ +export async function getDatabaseMonitorOverviewApi(dbName: string) { + return requestClient.get( + `/api/core/database_monitor/${dbName}/overview`, + ); +} + +/** + * 获取数据库实时统计信息 + */ +export async function getDatabaseRealtimeStatsApi(dbName: string) { + return requestClient.get( + `/api/core/database_monitor/${dbName}/realtime`, + ); +} + +/** + * 测试数据库连接 + */ +export async function testDatabaseConnectionApi(dbName: string) { + return requestClient.post( + `/api/core/database_monitor/${dbName}/test`, + ); +} diff --git a/web/apps/web-ele/src/api/core/redis-monitor.ts b/web/apps/web-ele/src/api/core/redis-monitor.ts new file mode 100644 index 0000000..b042fb8 --- /dev/null +++ b/web/apps/web-ele/src/api/core/redis-monitor.ts @@ -0,0 +1,170 @@ +import { requestClient } from '#/api/request'; + +// Redis配置信息 +export interface RedisConfig { + host: string; + port: number; + database: number; + has_password: boolean; + redis_url: string; +} + +// Redis基础信息 +export interface RedisInfo { + redis_version: string; + redis_mode: string; + role: string; + os: string; + arch_bits: number; + uptime_in_seconds: number; + uptime_in_days: number; + tcp_port: number; + connected_clients: number; + blocked_clients: number; +} + +// Redis内存信息 +export interface RedisMemory { + used_memory: number; + used_memory_human: string; + used_memory_rss: number; + used_memory_peak: number; + used_memory_peak_human: string; + total_system_memory: number; + total_system_memory_human: string; + used_memory_dataset: number; + used_memory_dataset_perc: string; + allocator_allocated: number; + allocator_active: number; + maxmemory: number; + maxmemory_human: string; + maxmemory_policy: string; + mem_fragmentation_ratio: number; +} + +// Redis统计信息 +export interface RedisStats { + total_connections_received: number; + total_commands_processed: number; + instantaneous_ops_per_sec: number; + total_net_input_bytes: number; + total_net_output_bytes: number; + instantaneous_input_kbps: number; + instantaneous_output_kbps: number; + rejected_connections: number; + sync_full: number; + sync_partial_ok: number; + sync_partial_err: number; + expired_keys: number; + evicted_keys: number; + keyspace_hits: number; + keyspace_misses: number; + pubsub_channels: number; + pubsub_patterns: number; + latest_fork_usec: number; + migrate_cached_sockets: number; +} + +// Redis键空间信息 +export interface RedisKeyspace { + db_id: number; + keys: number; + expires: number; + avg_ttl: number; +} + +// Redis客户端信息 +export interface RedisClient { + id: string; + addr: string; + fd: number; + name: string; + age: number; + idle: number; + flags: string; + db: number; + sub: number; + psub: number; + multi: number; + qbuf: number; + qbuf_free: number; + obl: number; + oll: number; + omem: number; + events: string; + cmd: string; +} + +// Redis慢日志 +export interface RedisSlowLog { + id: number; + timestamp: number; + duration: number; + command: string; + client_ip: string; + client_name: string; +} + +// Redis监控概览 +export interface RedisMonitorOverview { + connection_id: string; + connection_name: string; + status: string; + info: RedisInfo; + memory: RedisMemory; + stats: RedisStats; + keyspace: RedisKeyspace[]; + clients: RedisClient[]; + slow_log: RedisSlowLog[]; + timestamp: string; +} + +// Redis实时统计 +export interface RedisRealtimeStats { + connection_id: string; + used_memory: number; + memory_usage_percent: number; + connected_clients: number; + ops_per_sec: number; + hit_rate: number; + keyspace_hits: number; + keyspace_misses: number; + timestamp: string; +} + +/** + * 获取Redis配置信息 + */ +export async function getRedisConfigApi() { + return requestClient.get('/api/core/redis_monitor/config'); +} + +/** + * 获取Redis监控概览 + */ +export async function getRedisMonitorOverviewApi() { + return requestClient.get( + '/api/core/redis_monitor/overview', + ); +} + +/** + * 获取Redis实时统计 + */ +export async function getRedisRealtimeStatsApi() { + return requestClient.get( + '/api/core/redis_monitor/realtime', + ); +} + +/** + * 测试Redis连接 + */ +export async function testRedisConnectionApi() { + return requestClient.post<{ + message: string; + redis_version?: string; + response_time?: number; + success: boolean; + }>('/api/core/redis_monitor/test'); +} diff --git a/web/apps/web-ele/src/components/user-avatar/UserProfileDialog.vue b/web/apps/web-ele/src/components/user-avatar/UserProfileDialog.vue new file mode 100644 index 0000000..23ca920 --- /dev/null +++ b/web/apps/web-ele/src/components/user-avatar/UserProfileDialog.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/web/apps/web-ele/src/locales/langs/zh-CN/chat.json b/web/apps/web-ele/src/locales/langs/zh-CN/chat.json new file mode 100644 index 0000000..ad2db01 --- /dev/null +++ b/web/apps/web-ele/src/locales/langs/zh-CN/chat.json @@ -0,0 +1,91 @@ +{ + "title": "聊天", + "search": "搜索联系人或群聊", + "noConversations": "暂无会话", + "selectHint": "选择一个会话开始聊天", + "newChat": "发起聊天", + "newGroup": "创建群聊", + "private": "单聊", + "group": "群聊", + "members": "成员", + "memberCount": "{count} 人", + "owner": "群主", + "admin": "管理员", + "member": "成员", + "groupName": "群聊名称", + "groupNamePlaceholder": "请输入群聊名称", + "selectMembers": "选择成员", + "selectMembersPlaceholder": "请选择群成员", + "createGroupSuccess": "群聊创建成功", + "inputPlaceholder": "输入消息...", + "send": "发送", + "sendImage": "发送图片", + "sendFile": "发送文件", + "recall": "撤回", + "recallSuccess": "消息已撤回", + "recallFailed": "撤回失败", + "recallTimeout": "超过2分钟无法撤回", + "messageRecalled": "消息已撤回", + "typing": "正在输入...", + "yesterday": "昨天", + "pin": "置顶", + "unpin": "取消置顶", + "mute": "免打扰", + "unmute": "取消免打扰", + "conversationInfo": "会话信息", + "addMember": "添加成员", + "addMemberSuccess": "成员添加成功", + "allMembersExist": "所选成员已在群聊中", + "removeMember": "移除成员", + "removeMemberConfirm": "确定移除该成员吗?", + "dissolveGroup": "解散群聊", + "dissolveGroupConfirm": "确定解散该群聊吗?解散后不可恢复。", + "dissolveSuccess": "群聊已解散", + "leaveGroup": "退出群聊", + "noMessages": "暂无消息", + "loadMore": "加载更多", + "loading": "加载中...", + "image": "图片", + "file": "文件", + "replyTo": "回复", + "groupNameRequired": "请输入群聊名称", + "membersRequired": "请至少选择一个成员", + "recentChats": "最近聊天", + "contacts": "联系人", + "searchContacts": "搜索联系人", + "noContacts": "暂无联系人", + "startChat": "发起聊天", + "online": "在线", + "offline": "离线", + "sending": "发送中...", + "contactDetail": "联系人详情", + "contactDept": "部门", + "contactPost": "岗位", + "contactManager": "直属上级", + "contactEmail": "邮箱", + "contactMobile": "手机", + "contactCity": "城市", + "contactType": "用户类型", + "contactOrg": "组织架构", + "contactOrgInfo": "组织信息", + "contactInfo": "联系方式", + "selectContactHint": "选择一个联系人查看详情", + "copy": "复制", + "copySuccess": "已复制到剪贴板", + "replyingTo": "回复 {name}", + "markUnread": "标记未读", + "deleteConversation": "删除记录", + "deleteConversationConfirm": "确定删除该会话记录吗?", + "deleteSuccess": "已删除", + "orgStructure": "组织架构", + "emoji": "表情", + "voiceMessage": "语音消息", + "voiceTooShort": "录音时间太短", + "voiceUploading": "语音发送中...", + "micPermissionDenied": "无法访问麦克风,请检查浏览器权限", + "voice": "语音", + "dropToUpload": "松开发送文件", + "newMessage": "新消息", + "systemNotification": "系统通知", + "viewDetail": "查看详情" +} diff --git a/web/apps/web-ele/src/locales/langs/zh-CN/menu-title.json b/web/apps/web-ele/src/locales/langs/zh-CN/menu-title.json index 6bebd86..4a0ec55 100644 --- a/web/apps/web-ele/src/locales/langs/zh-CN/menu-title.json +++ b/web/apps/web-ele/src/locales/langs/zh-CN/menu-title.json @@ -21,6 +21,8 @@ "dictionaryManagement": "字典管理", "fileManagement": "文件管理", "serverMonitoring": "服务器监控", + "redisMonitoring": "Redis监控", + "databaseMonitoring": "数据库监控", "uiConfig": "界面配置", "loginLog": "登录日志", "orgChart": "组织架构", diff --git a/web/apps/web-ele/src/router/access.ts b/web/apps/web-ele/src/router/access.ts index ee956e2..05792ec 100644 --- a/web/apps/web-ele/src/router/access.ts +++ b/web/apps/web-ele/src/router/access.ts @@ -177,6 +177,8 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) { '../views/_core/announcement/list.vue', '../views/_core/application/index.vue', '../views/_core/authentication/login.vue', + '../views/_core/chat/**/*.vue', + '../views/_core/database-monitor/**/*.vue', '../views/_core/dept/index.vue', '../views/_core/dict/index.vue', '../views/_core/fallback/**/*.vue', @@ -190,6 +192,8 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) { '../views/_core/permission/index.vue', '../views/_core/post/index.vue', '../views/_core/role/index.vue', + '../views/_core/redis-monitor/**/*.vue', + '../views/_core/server-monitor/index.vue', '../views/_core/system-config/index.vue', '../views/_core/ui-config/index.vue', '../views/_core/user/index.vue', diff --git a/web/apps/web-ele/src/router/light-menu.ts b/web/apps/web-ele/src/router/light-menu.ts index 7ec295c..58ba2e8 100644 --- a/web/apps/web-ele/src/router/light-menu.ts +++ b/web/apps/web-ele/src/router/light-menu.ts @@ -17,6 +17,10 @@ export const LIGHT_MENU_NAMES = new Set([ 'KnowledgeBase', 'LoginLog', 'OrgNode', + 'DatabaseMonitor', + 'RedisMonitor', + 'ServerMonitor', + 'StartChat', 'SystemDept', 'SystemDict', 'SystemFileManager', @@ -25,6 +29,7 @@ export const LIGHT_MENU_NAMES = new Set([ 'SystemOrgChart', 'SystemConfig', 'SystemConfigManager', + 'SystemMonitoring', 'SystemMenu', 'SystemPermission', 'SystemPost', @@ -44,10 +49,12 @@ const LIGHT_ROUTE_PATH_PREFIXES = [ '/ai-platform/workflow-runs', '/agent-chat', '/application', + '/chat', '/message/list', '/message/announcement', '/message/announcement-list', '/page-render/main_home', + '/monitor', '/core/ui-config', '/system-config', '/system/dept', diff --git a/web/apps/web-ele/src/views/_core/chat/components/AddMemberDialog.vue b/web/apps/web-ele/src/views/_core/chat/components/AddMemberDialog.vue new file mode 100644 index 0000000..ab6caa6 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/components/AddMemberDialog.vue @@ -0,0 +1,93 @@ + + + diff --git a/web/apps/web-ele/src/views/_core/chat/components/ChatInput.vue b/web/apps/web-ele/src/views/_core/chat/components/ChatInput.vue new file mode 100644 index 0000000..374fd38 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/components/ChatInput.vue @@ -0,0 +1,762 @@ + + + + + + + diff --git a/web/apps/web-ele/src/views/_core/chat/components/ChatToast.vue b/web/apps/web-ele/src/views/_core/chat/components/ChatToast.vue new file mode 100644 index 0000000..9f80607 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/components/ChatToast.vue @@ -0,0 +1,133 @@ + + + + + + + diff --git a/web/apps/web-ele/src/views/_core/chat/components/ContactDetail.vue b/web/apps/web-ele/src/views/_core/chat/components/ContactDetail.vue new file mode 100644 index 0000000..d461bf8 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/components/ContactDetail.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/web/apps/web-ele/src/views/_core/chat/components/ContactList.vue b/web/apps/web-ele/src/views/_core/chat/components/ContactList.vue new file mode 100644 index 0000000..83566c9 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/components/ContactList.vue @@ -0,0 +1,333 @@ + + + + + diff --git a/web/apps/web-ele/src/views/_core/chat/components/ConversationInfo.vue b/web/apps/web-ele/src/views/_core/chat/components/ConversationInfo.vue new file mode 100644 index 0000000..1c55317 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/components/ConversationInfo.vue @@ -0,0 +1,192 @@ + + + diff --git a/web/apps/web-ele/src/views/_core/chat/components/ConversationList.vue b/web/apps/web-ele/src/views/_core/chat/components/ConversationList.vue new file mode 100644 index 0000000..a62da47 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/components/ConversationList.vue @@ -0,0 +1,373 @@ + + + + + diff --git a/web/apps/web-ele/src/views/_core/chat/components/CreateGroupDialog.vue b/web/apps/web-ele/src/views/_core/chat/components/CreateGroupDialog.vue new file mode 100644 index 0000000..ab2a10d --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/components/CreateGroupDialog.vue @@ -0,0 +1,93 @@ + + + diff --git a/web/apps/web-ele/src/views/_core/chat/components/EmojiPicker.vue b/web/apps/web-ele/src/views/_core/chat/components/EmojiPicker.vue new file mode 100644 index 0000000..83fddc1 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/components/EmojiPicker.vue @@ -0,0 +1,370 @@ + + + + + diff --git a/web/apps/web-ele/src/views/_core/chat/components/MessageList.vue b/web/apps/web-ele/src/views/_core/chat/components/MessageList.vue new file mode 100644 index 0000000..fbd80f5 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/components/MessageList.vue @@ -0,0 +1,1276 @@ + + + + + diff --git a/web/apps/web-ele/src/views/_core/chat/composables/chatStorage.ts b/web/apps/web-ele/src/views/_core/chat/composables/chatStorage.ts new file mode 100644 index 0000000..7c83545 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/composables/chatStorage.ts @@ -0,0 +1,442 @@ +import type { ChatMessage, Conversation } from '#/api/core/chat'; + +const DB_NAME = 'zq_chat_cache'; +const DB_VERSION = 1; +const STORE_CONVERSATIONS = 'conversations'; +const STORE_MESSAGES = 'messages'; +const STORE_META = 'meta'; + +// 每个会话最多缓存的消息数量 +const MAX_MESSAGES_PER_CONVERSATION = 200; + +let dbPromise: null | Promise = null; + +function openDB(): Promise { + if (dbPromise) return dbPromise; + + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // 会话表: 按 userId 分区存储 + if (!db.objectStoreNames.contains(STORE_CONVERSATIONS)) { + const convStore = db.createObjectStore(STORE_CONVERSATIONS, { + keyPath: ['userId', 'id'], + }); + convStore.createIndex('by_user', 'userId', { unique: false }); + } + + // 消息表: 按 conversationId 索引 + if (!db.objectStoreNames.contains(STORE_MESSAGES)) { + const msgStore = db.createObjectStore(STORE_MESSAGES, { + keyPath: ['conversationId', 'id'], + }); + msgStore.createIndex('by_conversation', 'conversationId', { + unique: false, + }); + msgStore.createIndex( + 'by_conv_time', + ['conversationId', 'sys_create_datetime'], + { + unique: false, + }, + ); + } + + // 元数据表: 存储同步时间戳等 + if (!db.objectStoreNames.contains(STORE_META)) { + db.createObjectStore(STORE_META, { keyPath: 'key' }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => { + dbPromise = null; + reject(request.error); + }; + }); + + return dbPromise; +} + +// ============ 通用事务辅助 ============ + +function withStore( + storeName: string, + mode: IDBTransactionMode, + fn: (store: IDBObjectStore) => IDBRequest, +): Promise { + return openDB().then( + (db) => + new Promise((resolve, reject) => { + const tx = db.transaction(storeName, mode); + const store = tx.objectStore(storeName); + const req = fn(store); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }), + ); +} + +// ============ 会话操作 ============ + +export async function getCachedConversations( + userId: string, +): Promise { + try { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_CONVERSATIONS, 'readonly'); + const store = tx.objectStore(STORE_CONVERSATIONS); + const index = store.index('by_user'); + const req = index.getAll(userId); + req.onsuccess = () => { + const items = (req.result || []).map((item: any) => { + const { userId: _uid, ...conv } = item; + return conv as Conversation; + }); + // 按 last_message_time 降序,置顶优先 + items.sort((a: Conversation, b: Conversation) => { + if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1; + const ta = a.last_message_time || a.sys_create_datetime || ''; + const tb = b.last_message_time || b.sys_create_datetime || ''; + return tb.localeCompare(ta); + }); + resolve(items); + }; + req.onerror = () => reject(req.error); + }); + } catch { + return []; + } +} + +export async function setCachedConversations( + userId: string, + conversations: Conversation[], +): Promise { + try { + const db = await openDB(); + const tx = db.transaction(STORE_CONVERSATIONS, 'readwrite'); + const store = tx.objectStore(STORE_CONVERSATIONS); + + // 先清除该用户的旧数据 + const index = store.index('by_user'); + const cursorReq = index.openCursor(userId); + await new Promise((resolve, reject) => { + cursorReq.onsuccess = () => { + const cursor = cursorReq.result; + if (cursor) { + cursor.delete(); + cursor.continue(); + } else { + resolve(); + } + }; + cursorReq.onerror = () => reject(cursorReq.error); + }); + + // 写入新数据 + for (const conv of conversations) { + store.put({ ...conv, userId }); + } + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch (error) { + console.warn('[ChatStorage] Failed to cache conversations:', error); + } +} + +export async function updateCachedConversation( + userId: string, + conversation: Conversation, +): Promise { + try { + await withStore(STORE_CONVERSATIONS, 'readwrite', (store) => + store.put({ ...conversation, userId }), + ); + } catch (error) { + console.warn('[ChatStorage] Failed to update conversation:', error); + } +} + +// ============ 消息操作 ============ + +export async function getCachedMessages( + conversationId: string, + limit = 30, + beforeId?: string, +): Promise<{ has_more: boolean; items: ChatMessage[] }> { + try { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_MESSAGES, 'readonly'); + const store = tx.objectStore(STORE_MESSAGES); + const index = store.index('by_conversation'); + const req = index.getAll(conversationId); + req.onsuccess = () => { + let items: ChatMessage[] = (req.result || []).map((item: any) => { + const { conversationId: _cid, ...msg } = item; + return msg as ChatMessage; + }); + + // 按时间排序 + items.sort((a, b) => { + const ta = a.sys_create_datetime || ''; + const tb = b.sys_create_datetime || ''; + return ta.localeCompare(tb); + }); + + // 如果有 beforeId,截取之前的消息 + if (beforeId) { + const idx = items.findIndex((m) => m.id === beforeId); + if (idx > 0) { + items = items.slice(0, idx); + } else if (idx === 0) { + items = []; + } + } + + const hasMore = items.length > limit; + if (hasMore) { + items = items.slice(items.length - limit); + } + + resolve({ items, has_more: hasMore }); + }; + req.onerror = () => reject(req.error); + }); + } catch { + return { items: [], has_more: false }; + } +} + +async function _doSetCachedMessages( + conversationId: string, + msgs: ChatMessage[], +): Promise { + const db = await openDB(); + const tx = db.transaction(STORE_MESSAGES, 'readwrite'); + const store = tx.objectStore(STORE_MESSAGES); + + for (const msg of msgs) { + if (msg._sending || msg._tempId) continue; + store.put({ + ...msg, + conversationId, + _sending: undefined, + _tempId: undefined, + _localUrl: undefined, + }); + } + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +export async function setCachedMessages( + conversationId: string, + msgs: ChatMessage[], +): Promise { + try { + await _doSetCachedMessages(conversationId, msgs); + // 主动裁剪,保持每个会话消息数在限制内 + await trimOldMessages(conversationId); + } catch (error) { + if (await handleQuotaError(error)) { + try { + await _doSetCachedMessages(conversationId, msgs); + } catch { + console.warn('[ChatStorage] Retry failed after pruning:', error); + } + } else { + console.warn('[ChatStorage] Failed to cache messages:', error); + } + } +} + +export async function addCachedMessage(msg: ChatMessage): Promise { + if (msg._sending || msg._tempId) return; + const data = { + ...msg, + conversationId: msg.conversation_id, + _sending: undefined, + _tempId: undefined, + _localUrl: undefined, + }; + try { + await withStore(STORE_MESSAGES, 'readwrite', (store) => store.put(data)); + } catch (error) { + if (await handleQuotaError(error)) { + try { + await withStore(STORE_MESSAGES, 'readwrite', (store) => + store.put(data), + ); + } catch { + console.warn('[ChatStorage] Retry failed after pruning:', error); + } + } else { + console.warn('[ChatStorage] Failed to add message:', error); + } + } +} + +export async function updateCachedMessage(msg: ChatMessage): Promise { + const data = { + ...msg, + conversationId: msg.conversation_id, + _sending: undefined, + _tempId: undefined, + _localUrl: undefined, + }; + try { + await withStore(STORE_MESSAGES, 'readwrite', (store) => store.put(data)); + } catch (error) { + console.warn('[ChatStorage] Failed to update message:', error); + } +} + +// ============ 元数据操作 ============ + +export async function getLastSyncTime(userId: string): Promise { + try { + const result = await withStore(STORE_META, 'readonly', (store) => + store.get(`sync_${userId}`), + ); + return result?.value || null; + } catch { + return null; + } +} + +export async function setLastSyncTime(userId: string): Promise { + try { + await withStore(STORE_META, 'readwrite', (store) => + store.put({ key: `sync_${userId}`, value: new Date().toISOString() }), + ); + } catch (error) { + console.warn('[ChatStorage] Failed to set sync time:', error); + } +} + +// ============ 容量管理 ============ + +/** + * 裁剪指定会话的旧消息,只保留最近 MAX_MESSAGES_PER_CONVERSATION 条 + */ +async function trimOldMessages(conversationId: string): Promise { + try { + const db = await openDB(); + const tx = db.transaction(STORE_MESSAGES, 'readwrite'); + const store = tx.objectStore(STORE_MESSAGES); + const index = store.index('by_conversation'); + + const allMsgs: any[] = await new Promise((resolve, reject) => { + const req = index.getAll(conversationId); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); + + if (allMsgs.length <= MAX_MESSAGES_PER_CONVERSATION) return; + + // 按时间排序,删除最旧的 + allMsgs.sort((a, b) => { + const ta = a.sys_create_datetime || ''; + const tb = b.sys_create_datetime || ''; + return ta.localeCompare(tb); + }); + + const toDelete = allMsgs.slice( + 0, + allMsgs.length - MAX_MESSAGES_PER_CONVERSATION, + ); + for (const msg of toDelete) { + store.delete([msg.conversationId, msg.id]); + } + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch (error) { + console.warn('[ChatStorage] Failed to trim messages:', error); + } +} + +/** + * 全局清理:删除所有会话中超出限制的旧消息 + */ +export async function pruneAllOldMessages(): Promise { + try { + const db = await openDB(); + const tx = db.transaction(STORE_MESSAGES, 'readonly'); + const store = tx.objectStore(STORE_MESSAGES); + const index = store.index('by_conversation'); + + // 收集所有 conversationId + const convIds = new Set(); + await new Promise((resolve, reject) => { + const req = index.openKeyCursor(); + req.onsuccess = () => { + const cursor = req.result; + if (cursor) { + convIds.add(cursor.key as string); + cursor.continue(); + } else { + resolve(); + } + }; + req.onerror = () => reject(req.error); + }); + + for (const convId of convIds) { + await trimOldMessages(convId); + } + } catch (error) { + console.warn('[ChatStorage] Failed to prune messages:', error); + } +} + +/** + * 处理存储配额超限:清理旧消息后重试 + * 返回 true 表示已处理,调用方可重试 + */ +async function handleQuotaError(error: unknown): Promise { + if ( + error instanceof DOMException && + (error.name === 'QuotaExceededError' || error.code === 22) + ) { + console.warn('[ChatStorage] Storage quota exceeded, pruning old data...'); + await pruneAllOldMessages(); + return true; + } + return false; +} + +// ============ 清理 ============ + +export async function clearChatCache(): Promise { + try { + const db = await openDB(); + const tx = db.transaction( + [STORE_CONVERSATIONS, STORE_MESSAGES, STORE_META], + 'readwrite', + ); + tx.objectStore(STORE_CONVERSATIONS).clear(); + tx.objectStore(STORE_MESSAGES).clear(); + tx.objectStore(STORE_META).clear(); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch (error) { + console.warn('[ChatStorage] Failed to clear cache:', error); + } +} diff --git a/web/apps/web-ele/src/views/_core/chat/composables/useChat.ts b/web/apps/web-ele/src/views/_core/chat/composables/useChat.ts new file mode 100644 index 0000000..b700e35 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/composables/useChat.ts @@ -0,0 +1,623 @@ +import type { + ChatMessage, + Conversation, + ConversationMember, +} from '#/api/core/chat'; +import type { WebSocketManager } from '#/api/core/websocket'; + +import { computed, nextTick, ref } from 'vue'; + +import { $t } from '@vben/locales'; +import { useAccessStore, useUserStore } from '@vben/stores'; + +import { + createGroupConversationApi, + createPrivateConversationApi, + getConversationsApi, + getMembersApi, + getMessagesApi, + getOnlineUsersApi, + getUnreadChatMessagesApi, + markConversationReadApi, + recallMessageApi, + sendChatMessageApi, + toggleMuteApi, + togglePinApi, +} from '#/api/core/chat'; +import { createChatWebSocket } from '#/api/core/websocket'; + +import { + addCachedMessage, + clearChatCache, + getCachedConversations, + getCachedMessages, + setCachedConversations, + setCachedMessages, + setLastSyncTime, + updateCachedConversation, + updateCachedMessage, +} from './chatStorage'; +import { showBrowserNotification, showChatToast } from './useChatNotification'; +import { playMessageSound } from './useChatSound'; + +// ============ 状态 ============ +const conversations = ref([]); +const currentConversation = ref(null); +const messages = ref([]); +const members = ref([]); +const hasMoreMessages = ref(false); +const loadingConversations = ref(false); +const loadingMessages = ref(false); +const loadingMembers = ref(false); +const sending = ref(false); +const isLoadingMore = ref(false); +const typingUsers = ref>(new Map()); +// 待跳转的会话ID(从其他页面点击通知后跳转到聊天页时使用) +const pendingConversationId = ref(null); + +const unreadChatMessages = ref([]); +const onlineUsers = ref>(new Set()); + +let wsManager: null | WebSocketManager = null; +const typingTimers: Map> = new Map(); +let tempIdCounter = 0; + +// ============ 计算属性 ============ +const totalUnread = computed(() => + conversations.value.reduce((sum, c) => sum + (c.unread_count || 0), 0), +); + +// ============ WebSocket ============ +function connectChat() { + if (wsManager?.isConnected) return; + + const accessStore = useAccessStore(); + if (!accessStore.accessToken) return; + + wsManager = createChatWebSocket({ + onOpen: () => { + console.log('[Chat WS] Connected'); + }, + onMessage: (message) => { + handleWsMessage(message); + }, + onClose: () => { + console.log('[Chat WS] Disconnected'); + }, + onError: () => { + console.error('[Chat WS] Error'); + }, + }); + + wsManager.connect().catch((error) => { + console.error('[Chat WS] Connect failed:', error); + }); +} + +function disconnectChat() { + wsManager?.close(); + wsManager = null; +} + +function handleWsMessage(data: any) { + const type = data.type; + const payload = data.data; + + switch (type) { + case 'chat.message': { + handleIncomingMessage(payload); + break; + } + case 'chat.presence': { + handlePresence(payload); + break; + } + case 'chat.read_receipt': { + handleReadReceipt(payload); + break; + } + case 'chat.recalled': { + handleRecalledMessage(payload); + break; + } + case 'chat.typing': { + handleTypingNotification(payload); + break; + } + } +} + +function handleIncomingMessage(msg: ChatMessage) { + // 如果是当前会话的消息,添加到消息列表 + if ( + currentConversation.value && + msg.conversation_id === currentConversation.value.id + ) { + // 查找是否有对应的临时消息(乐观更新),用服务器确认的消息替换 + const userStore = useUserStore(); + const currentUserId = userStore.userInfo?.userId || ''; + const tempIndex = + msg.sender_id === currentUserId + ? messages.value.findIndex( + (m) => + m._sending && + m._tempId && + m.msg_type === msg.msg_type && + (m.msg_type === 'text' + ? m.content === msg.content + : m.file_id === msg.file_id), + ) + : -1; + if (tempIndex >= 0) { + // 释放本地 blob URL + const tempMsg = messages.value[tempIndex]; + if (tempMsg?._localUrl) { + URL.revokeObjectURL(tempMsg._localUrl); + } + messages.value.splice(tempIndex, 1, msg); + } else { + const exists = messages.value.some((m) => m.id === msg.id); + if (!exists) { + messages.value.push(msg); + } + } + } + + // 缓存新消息到本地 + addCachedMessage(msg); + + // 非自己发的消息:提示音 + 通知 + 更新未读消息列表 + const userId = getCurrentUserId(); + if (msg.sender_id !== userId) { + // 实时添加到未读消息列表(通知中心用) + if ( + !currentConversation.value || + msg.conversation_id !== currentConversation.value.id + ) { + unreadChatMessages.value.unshift(msg); + } + + playMessageSound(); + // 不是当前会话的消息才显示 Toast + if ( + !currentConversation.value || + msg.conversation_id !== currentConversation.value.id + ) { + const conv = conversations.value.find( + (c) => c.id === msg.conversation_id, + ); + showChatToast(msg, () => { + // 设置待跳转会话,导航到聊天页后自动选中 + pendingConversationId.value = msg.conversation_id; + // 使用 window.location 检查是否已在聊天页 + if ( + window.location.hash?.includes('/chat') || + window.location.pathname?.includes('/chat') + ) { + // 已在聊天页,直接选中会话 + if (conv) selectConversation(conv); + pendingConversationId.value = null; + } else { + // 不在聊天页,通过动态 import router 导航 + import('#/router').then(({ router }) => { + router.push('/chat'); + }); + } + }); + showBrowserNotification(msg); + } + } + + // 更新会话列表 + const convIndex = conversations.value.findIndex( + (c) => c.id === msg.conversation_id, + ); + if (convIndex !== -1) { + const conv = { ...conversations.value[convIndex]! }; + conv.last_message_preview = + msg.content || `[${$t(`chat.${msg.msg_type}`) || msg.msg_type}]`; + conv.last_message_time = msg.sys_create_datetime || ''; + // 如果不是当前会话,增加未读数 + if ( + !currentConversation.value || + msg.conversation_id !== currentConversation.value.id + ) { + conv.unread_count = (conv.unread_count || 0) + 1; + } + conversations.value.splice(convIndex, 1); + conversations.value.unshift(conv); + // 更新会话缓存 + const userId = getCurrentUserId(); + if (userId) { + updateCachedConversation(userId, conv); + } + } +} + +function handleTypingNotification(payload: { + conversation_id: string; + user_id: string; + user_name: string; +}) { + if ( + currentConversation.value && + payload.conversation_id === currentConversation.value.id + ) { + typingUsers.value.set(payload.user_id, payload.user_name); + + // 清除之前的定时器 + const existingTimer = typingTimers.get(payload.user_id); + if (existingTimer) clearTimeout(existingTimer); + + // 3秒后自动清除 + typingTimers.set( + payload.user_id, + setTimeout(() => { + typingUsers.value.delete(payload.user_id); + typingTimers.delete(payload.user_id); + }, 3000), + ); + } +} + +function handleRecalledMessage(payload: { + conversation_id: string; + message_id: string; +}) { + if ( + currentConversation.value && + payload.conversation_id === currentConversation.value.id + ) { + const msg = messages.value.find((m) => m.id === payload.message_id); + if (msg) { + msg.is_recalled = true; + msg.content = null as any; + // 更新缓存中的撤回状态 + updateCachedMessage(msg); + } + } +} + +function handleReadReceipt(_payload: { + conversation_id: string; + message_id: string; + user_id: string; +}) { + // 可用于显示已读状态 +} + +function handlePresence(payload: { status: string; user_id: string }) { + if (payload.status === 'online') { + onlineUsers.value.add(payload.user_id); + } else { + onlineUsers.value.delete(payload.user_id); + } + // 触发响应式更新 + onlineUsers.value = new Set(onlineUsers.value); +} + +// ============ 辅助函数 ============ +function getCurrentUserId(): string { + const userStore = useUserStore(); + return userStore.userInfo?.userId || ''; +} + +// ============ API 操作 ============ +async function loadConversations() { + loadingConversations.value = true; + const userId = getCurrentUserId(); + + // 1. 先从本地缓存加载,立即渲染 + if (userId) { + try { + const cached = await getCachedConversations(userId); + if (cached.length > 0 && conversations.value.length === 0) { + conversations.value = cached; + } + } catch (error) { + console.warn('[Chat] Load cache failed:', error); + } + } + + // 2. 从服务端同步最新数据 + try { + const res = await getConversationsApi(); + conversations.value = res.items || []; + // 写入缓存 + if (userId) { + setCachedConversations(userId, conversations.value); + setLastSyncTime(userId); + } + } catch (error) { + console.error('加载会话列表失败:', error); + } finally { + loadingConversations.value = false; + } +} + +async function selectConversation(conv: Conversation) { + currentConversation.value = conv; + messages.value = []; + hasMoreMessages.value = false; + typingUsers.value.clear(); + + // 等待 key 变化触发 MessageList 重建后再加载数据 + await nextTick(); + + // 确保切换期间用户没有再次切换到其他会话 + if (currentConversation.value?.id !== conv.id) return; + + // 先从缓存加载消息(此时新 MessageList 已创建,不会闪现旧消息) + try { + const cached = await getCachedMessages(conv.id, 30); + if (currentConversation.value?.id !== conv.id) return; + if (cached.items.length > 0 && messages.value.length === 0) { + // 只有当 messages 仍为空时才使用缓存(避免覆盖已加载的服务端数据) + messages.value = cached.items; + hasMoreMessages.value = cached.has_more; + } + } catch (error) { + console.warn('[Chat] Load cached messages failed:', error); + } + + // 从服务端加载最新数据 + if (currentConversation.value?.id !== conv.id) return; + await Promise.all([loadMessages(), loadMembers()]); + + // 标记已读 + if (conv.unread_count > 0 && messages.value.length > 0) { + const lastMsg = messages.value[messages.value.length - 1]; + if (lastMsg) { + await markConversationReadApi(conv.id, lastMsg.id); + const c = conversations.value.find((item) => item.id === conv.id); + if (c) c.unread_count = 0; + // 从未读消息列表中移除该会话的消息 + unreadChatMessages.value = unreadChatMessages.value.filter( + (m) => m.conversation_id !== conv.id, + ); + } + } +} + +async function loadMessages(loadMore = false) { + if (!currentConversation.value) return; + const convId = currentConversation.value.id; + loadingMessages.value = true; + if (loadMore) isLoadingMore.value = true; + try { + const beforeId = + loadMore && messages.value.length > 0 ? messages.value[0]?.id : undefined; + const res = await getMessagesApi(convId, { + beforeId, + limit: 30, + }); + // 请求返回后检查会话是否已切换 + if (currentConversation.value?.id !== convId) return; + + const items = res.items || []; + hasMoreMessages.value = res.has_more || false; + + if (loadMore) { + // 使用 splice 原地插入,避免替换数组引用触发整体替换的 watch + messages.value.splice(0, 0, ...items); + } else { + messages.value = items; + } + // 缓存到本地 + if (items.length > 0) { + setCachedMessages(convId, items); + } + } catch (error) { + console.error('加载消息失败:', error); + } finally { + loadingMessages.value = false; + if (loadMore) isLoadingMore.value = false; + } +} + +async function loadMembers() { + if (!currentConversation.value) return; + loadingMembers.value = true; + try { + const res = await getMembersApi(currentConversation.value.id); + members.value = Array.isArray(res) ? res : []; + } catch (error) { + console.error('加载成员失败:', error); + } finally { + loadingMembers.value = false; + } +} + +async function sendMessage( + content: string, + msgType = 'text', + fileId?: string, + replyToId?: string, + fileName?: string, + localUrl?: string, + extra?: Record, +) { + if (!currentConversation.value) return; + sending.value = true; + + // 乐观更新:立即插入临时消息 + const userStore = useUserStore(); + const tempId = `_temp_${++tempIdCounter}_${Date.now()}`; + const tempMsg: ChatMessage = { + id: tempId, + conversation_id: currentConversation.value.id, + sender_id: userStore.userInfo?.userId || '', + msg_type: msgType, + content: msgType === 'text' ? content : undefined, + file_id: fileId, + file_name: fileName, + reply_to_id: replyToId, + is_recalled: false, + sys_create_datetime: new Date().toISOString(), + sender_name: + userStore.userInfo?.realName || userStore.userInfo?.username || '', + sender_avatar: userStore.userInfo?.avatar || '', + extra, + _sending: true, + _tempId: tempId, + _localUrl: localUrl, + }; + messages.value.push(tempMsg); + + try { + // 优先走 WebSocket + if (wsManager?.isConnected) { + wsManager.send({ + type: 'chat.send', + data: { + conversation_id: currentConversation.value.id, + msg_type: msgType, + content: msgType === 'text' ? content : undefined, + file_id: fileId, + reply_to_id: replyToId, + extra, + }, + }); + } else { + // 降级走 REST + const result = await sendChatMessageApi(currentConversation.value.id, { + msg_type: msgType, + content: msgType === 'text' ? content : undefined, + file_id: fileId, + reply_to_id: replyToId, + extra, + }); + // REST 返回后替换临时消息 + const idx = messages.value.findIndex((m) => m._tempId === tempId); + if (idx !== -1 && result) { + messages.value.splice(idx, 1, result as ChatMessage); + // 缓存已确认的消息 + addCachedMessage(result as ChatMessage); + } else if (idx !== -1) { + messages.value[idx]!._sending = false; + } + } + } catch (error) { + // 发送失败,移除临时消息 + const idx = messages.value.findIndex((m) => m._tempId === tempId); + if (idx !== -1) { + messages.value.splice(idx, 1); + } + console.error('发送消息失败:', error); + throw error; + } finally { + sending.value = false; + } +} + +function sendTyping() { + if (!currentConversation.value || !wsManager?.isConnected) return; + wsManager.send({ + type: 'chat.typing', + data: { conversation_id: currentConversation.value.id }, + }); +} + +async function recallMessage(messageId: string) { + await recallMessageApi(messageId); + // 立即更新本地消息状态(撤回者自己不会收到 chat.recalled WebSocket 事件) + const msg = messages.value.find((m) => m.id === messageId); + if (msg) { + msg.is_recalled = true; + msg.content = null as any; + // 同步更新缓存 + updateCachedMessage(msg); + } +} + +async function createPrivateChat(userId: string) { + const conv = await createPrivateConversationApi(userId); + // 添加到列表前面 + const exists = conversations.value.findIndex((c) => c.id === conv.id); + if (exists !== -1) { + conversations.value.splice(exists, 1); + } + conversations.value.unshift(conv); + await selectConversation(conv); + return conv; +} + +async function createGroupChat(name: string, memberIds: string[]) { + const conv = await createGroupConversationApi({ + name, + member_ids: memberIds, + }); + conversations.value.unshift(conv); + await selectConversation(conv); + return conv; +} + +async function loadUnreadChatMessages() { + try { + const res = await getUnreadChatMessagesApi(50); + unreadChatMessages.value = res.items || []; + } catch (error) { + console.error('[Chat] Failed to load unread messages:', error); + } +} + +async function loadOnlineUsers() { + try { + const res = await getOnlineUsersApi(); + onlineUsers.value = new Set(res.user_ids || []); + } catch (error) { + console.error('[Chat] Failed to load online users:', error); + } +} + +async function togglePin(conversationId: string, value: boolean) { + await togglePinApi(conversationId, value); + const conv = conversations.value.find((c) => c.id === conversationId); + if (conv) conv.is_pinned = value; +} + +async function toggleMute(conversationId: string, value: boolean) { + await toggleMuteApi(conversationId, value); + const conv = conversations.value.find((c) => c.id === conversationId); + if (conv) conv.is_muted = value; +} + +// ============ 导出 ============ +export function useChat() { + return { + // 状态 + conversations, + currentConversation, + messages, + members, + hasMoreMessages, + loadingConversations, + loadingMessages, + loadingMembers, + sending, + isLoadingMore, + typingUsers, + totalUnread, + pendingConversationId, + unreadChatMessages, + onlineUsers, + // WebSocket + connectChat, + disconnectChat, + // 操作 + loadConversations, + loadUnreadChatMessages, + loadOnlineUsers, + selectConversation, + loadMessages, + loadMembers, + sendMessage, + sendTyping, + recallMessage, + createPrivateChat, + createGroupChat, + togglePin, + toggleMute, + clearChatCache, + }; +} diff --git a/web/apps/web-ele/src/views/_core/chat/composables/useChatNotification.ts b/web/apps/web-ele/src/views/_core/chat/composables/useChatNotification.ts new file mode 100644 index 0000000..d2dd92a --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/composables/useChatNotification.ts @@ -0,0 +1,118 @@ +/** + * 聊天消息桌面通知 + 应用内 Toast 通知 + */ +import type { ChatMessage } from '#/api/core/chat'; + +import { h, render } from 'vue'; + +import { $t } from '@vben/locales'; + +import ChatToast from '../components/ChatToast.vue'; + +// 通知队列 +const MAX_TOASTS = 3; +const activeToasts: Array<{ + el: HTMLDivElement; + timer: ReturnType; +}> = []; + +function getMessagePreview(msg: ChatMessage): string { + if (msg.msg_type === 'text') return msg.content || ''; + if (msg.msg_type === 'image') return `[${$t('chat.image')}]`; + if (msg.msg_type === 'file') + return `[${$t('chat.file')}] ${msg.file_name || ''}`; + if (msg.msg_type === 'voice') return `[${$t('chat.voice')}]`; + return `[${$t(`chat.${msg.msg_type}`) || msg.msg_type}]`; +} + +function removeToast(container: HTMLDivElement) { + container.classList.add('chat-toast-exit'); + setTimeout(() => { + render(null, container); + container.remove(); + const idx = activeToasts.findIndex((t) => t.el === container); + if (idx !== -1) activeToasts.splice(idx, 1); + repositionToasts(); + }, 300); +} + +function repositionToasts() { + activeToasts.forEach((toast, index) => { + toast.el.style.top = `${16 + index * 88}px`; + }); +} + +/** + * 显示应用内 Toast 通知 + */ +export function showChatToast(msg: ChatMessage, onClick?: () => void) { + // 超出最大数量,移除最早的 + while (activeToasts.length >= MAX_TOASTS) { + const oldest = activeToasts.shift(); + if (oldest) { + clearTimeout(oldest.timer); + removeToast(oldest.el); + } + } + + const container = document.createElement('div'); + document.body.append(container); + + const preview = getMessagePreview(msg); + + const vnode = h(ChatToast, { + senderName: msg.sender_name || '', + senderId: msg.sender_id || '', + senderAvatar: msg.sender_avatar || '', + content: preview, + onClose: () => removeToast(container), + onClick: () => { + removeToast(container); + onClick?.(); + }, + }); + + render(vnode, container); + + const topOffset = 16 + activeToasts.length * 88; + container.style.position = 'fixed'; + container.style.top = `${topOffset}px`; + container.style.right = '16px'; + container.style.zIndex = '9999'; + container.style.transition = 'top 0.3s ease, opacity 0.3s ease'; + + const timer = setTimeout(() => { + removeToast(container); + }, 5000); + + activeToasts.push({ el: container, timer }); +} + +/** + * 尝试发送浏览器原生通知(页面不在前台时) + */ +export function showBrowserNotification(msg: ChatMessage) { + if (!('Notification' in window)) return; + if (document.visibilityState === 'visible') return; + + if (Notification.permission === 'granted') { + const preview = getMessagePreview(msg); + const notification = new Notification( + msg.sender_name || $t('chat.newMessage'), + { + body: preview, + tag: `chat-${msg.id || Date.now()}`, + ...({ renotify: true } as any), + }, + ); + notification.addEventListener('click', () => { + window.focus(); + import('#/router').then(({ router }) => { + router.push('/chat'); + }); + notification.close(); + }); + } else if (Notification.permission !== 'denied') { + Notification.requestPermission(); + } +} diff --git a/web/apps/web-ele/src/views/_core/chat/composables/useChatSound.ts b/web/apps/web-ele/src/views/_core/chat/composables/useChatSound.ts new file mode 100644 index 0000000..b13939e --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/composables/useChatSound.ts @@ -0,0 +1,33 @@ +/** + * 聊天消息提示音 + */ +import notificationSound from '#/assets/sounds/message-notification.mp3'; + +let audio: HTMLAudioElement | null = null; +let lastPlayTime = 0; + +// 最小播放间隔(毫秒),避免短时间内大量消息导致音效叠加 +const MIN_INTERVAL = 500; + +/** + * 播放消息提示音 + */ +export function playMessageSound() { + const now = Date.now(); + if (now - lastPlayTime < MIN_INTERVAL) return; + lastPlayTime = now; + + try { + if (!audio) { + audio = new Audio(notificationSound); + audio.volume = 0.5; + } + // 如果正在播放,重置到开头 + audio.currentTime = 0; + audio.play().catch(() => { + // 浏览器可能阻止自动播放,静默忽略 + }); + } catch { + // 静默降级 + } +} diff --git a/web/apps/web-ele/src/views/_core/chat/composables/useVoiceRecorder.ts b/web/apps/web-ele/src/views/_core/chat/composables/useVoiceRecorder.ts new file mode 100644 index 0000000..cd37c64 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/composables/useVoiceRecorder.ts @@ -0,0 +1,130 @@ +import { onBeforeUnmount, ref } from 'vue'; + +export function useVoiceRecorder() { + const isRecording = ref(false); + const duration = ref(0); + const isPaused = ref(false); + + let mediaRecorder: MediaRecorder | null = null; + let audioChunks: Blob[] = []; + let stream: MediaStream | null = null; + let timer: null | ReturnType = null; + let startTime = 0; + + function startTimer() { + startTime = Date.now(); + duration.value = 0; + timer = setInterval(() => { + duration.value = Math.floor((Date.now() - startTime) / 1000); + }, 200); + } + + function stopTimer() { + if (timer) { + clearInterval(timer); + timer = null; + } + } + + async function startRecording(): Promise { + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch { + return false; + } + + audioChunks = []; + + // 优先使用 webm/opus,兼容性好 + const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') + ? 'audio/webm;codecs=opus' + : (MediaRecorder.isTypeSupported('audio/webm') + ? 'audio/webm' + : ''); + + mediaRecorder = mimeType + ? new MediaRecorder(stream, { mimeType }) + : new MediaRecorder(stream); + + mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) { + audioChunks.push(e.data); + } + }; + + mediaRecorder.start(100); // 每 100ms 收集一次数据 + isRecording.value = true; + isPaused.value = false; + startTimer(); + return true; + } + + function stopRecording(): Promise { + return new Promise((resolve) => { + if (!mediaRecorder || mediaRecorder.state === 'inactive') { + cleanup(); + resolve(null); + return; + } + + const finalDuration = Math.max( + 1, + Math.round((Date.now() - startTime) / 1000), + ); + + mediaRecorder.onstop = () => { + const blob = new Blob(audioChunks, { + type: mediaRecorder?.mimeType || 'audio/webm', + }); + cleanup(); + resolve({ blob, duration: finalDuration }); + }; + + mediaRecorder.stop(); + }); + } + + function cancelRecording() { + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + mediaRecorder.onstop = null; + mediaRecorder.stop(); + } + cleanup(); + } + + function cleanup() { + stopTimer(); + isRecording.value = false; + isPaused.value = false; + duration.value = 0; + audioChunks = []; + if (stream) { + stream.getTracks().forEach((t) => t.stop()); + stream = null; + } + mediaRecorder = null; + } + + onBeforeUnmount(() => { + if (isRecording.value) { + cancelRecording(); + } + }); + + return { + isRecording, + duration, + startRecording, + stopRecording, + cancelRecording, + }; +} + +/** + * 格式化录音时长为 mm:ss + */ +export function formatVoiceDuration(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} diff --git a/web/apps/web-ele/src/views/_core/chat/index.vue b/web/apps/web-ele/src/views/_core/chat/index.vue new file mode 100644 index 0000000..1141d06 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/chat/index.vue @@ -0,0 +1,591 @@ + + + + + diff --git a/web/apps/web-ele/src/views/_core/database-monitor/index.vue b/web/apps/web-ele/src/views/_core/database-monitor/index.vue new file mode 100644 index 0000000..f7cc088 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/database-monitor/index.vue @@ -0,0 +1,654 @@ + + + + + diff --git a/web/apps/web-ele/src/views/_core/database-monitor/modules/connections-panel.vue b/web/apps/web-ele/src/views/_core/database-monitor/modules/connections-panel.vue new file mode 100644 index 0000000..e59c837 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/database-monitor/modules/connections-panel.vue @@ -0,0 +1,398 @@ + + + diff --git a/web/apps/web-ele/src/views/_core/database-monitor/modules/overview-panel.vue b/web/apps/web-ele/src/views/_core/database-monitor/modules/overview-panel.vue new file mode 100644 index 0000000..d66186b --- /dev/null +++ b/web/apps/web-ele/src/views/_core/database-monitor/modules/overview-panel.vue @@ -0,0 +1,395 @@ + + + diff --git a/web/apps/web-ele/src/views/_core/database-monitor/modules/performance-panel.vue b/web/apps/web-ele/src/views/_core/database-monitor/modules/performance-panel.vue new file mode 100644 index 0000000..f9a7336 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/database-monitor/modules/performance-panel.vue @@ -0,0 +1,601 @@ + + + diff --git a/web/apps/web-ele/src/views/_core/database-monitor/modules/tables-panel.vue b/web/apps/web-ele/src/views/_core/database-monitor/modules/tables-panel.vue new file mode 100644 index 0000000..8b8648a --- /dev/null +++ b/web/apps/web-ele/src/views/_core/database-monitor/modules/tables-panel.vue @@ -0,0 +1,583 @@ + + + diff --git a/web/apps/web-ele/src/views/_core/redis-monitor/index.vue b/web/apps/web-ele/src/views/_core/redis-monitor/index.vue new file mode 100644 index 0000000..ac40a9a --- /dev/null +++ b/web/apps/web-ele/src/views/_core/redis-monitor/index.vue @@ -0,0 +1,480 @@ + + + + + diff --git a/web/apps/web-ele/src/views/_core/redis-monitor/modules/clients-panel.vue b/web/apps/web-ele/src/views/_core/redis-monitor/modules/clients-panel.vue new file mode 100644 index 0000000..42af30c --- /dev/null +++ b/web/apps/web-ele/src/views/_core/redis-monitor/modules/clients-panel.vue @@ -0,0 +1,316 @@ + + + diff --git a/web/apps/web-ele/src/views/_core/redis-monitor/modules/keyspace-panel.vue b/web/apps/web-ele/src/views/_core/redis-monitor/modules/keyspace-panel.vue new file mode 100644 index 0000000..79cdbac --- /dev/null +++ b/web/apps/web-ele/src/views/_core/redis-monitor/modules/keyspace-panel.vue @@ -0,0 +1,334 @@ + + + diff --git a/web/apps/web-ele/src/views/_core/redis-monitor/modules/memory-panel.vue b/web/apps/web-ele/src/views/_core/redis-monitor/modules/memory-panel.vue new file mode 100644 index 0000000..7459676 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/redis-monitor/modules/memory-panel.vue @@ -0,0 +1,540 @@ + + + diff --git a/web/apps/web-ele/src/views/_core/redis-monitor/modules/overview-panel.vue b/web/apps/web-ele/src/views/_core/redis-monitor/modules/overview-panel.vue new file mode 100644 index 0000000..82688f7 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/redis-monitor/modules/overview-panel.vue @@ -0,0 +1,480 @@ + + + diff --git a/web/apps/web-ele/src/views/_core/redis-monitor/modules/slowlog-panel.vue b/web/apps/web-ele/src/views/_core/redis-monitor/modules/slowlog-panel.vue new file mode 100644 index 0000000..f280e43 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/redis-monitor/modules/slowlog-panel.vue @@ -0,0 +1,344 @@ + + + diff --git a/web/apps/web-ele/src/views/_core/redis-monitor/modules/stats-panel.vue b/web/apps/web-ele/src/views/_core/redis-monitor/modules/stats-panel.vue new file mode 100644 index 0000000..aaf3137 --- /dev/null +++ b/web/apps/web-ele/src/views/_core/redis-monitor/modules/stats-panel.vue @@ -0,0 +1,444 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/agent/components/AgentChatPanel.vue b/web/apps/web-ele/src/views/ai-platform/agent/components/AgentChatPanel.vue index 0d2a060..53c595b 100644 --- a/web/apps/web-ele/src/views/ai-platform/agent/components/AgentChatPanel.vue +++ b/web/apps/web-ele/src/views/ai-platform/agent/components/AgentChatPanel.vue @@ -88,6 +88,22 @@ const formattedMessages = computed(() => { content: step.content || '', tool: step.tool, params: step.params, + branch_id: step.branch_id, + branch_label: step.branch_label, + agent_code: step.agent_code, + agent_name: step.agent_name, + model: step.model, + model_id: step.model_id, + subflow_name: step.subflow_name, + from_subflow: step.from_subflow, + collaboration_role: step.collaboration_role, + collaboration_mode: step.collaboration_mode, + communication: step.communication, + node_id: step.node_id, + node_type: step.node_type, + output: step.output, + status: step.status, + timestamp: step.timestamp, })), interaction: msg.interaction, voice: msg.voice as any, @@ -370,6 +386,47 @@ function handleStreamEvent( assistantMessage.reasoning_steps = [...streamingSteps.value]; break; } + case 'parallel_complete': { + const existingIndex = streamingSteps.value.findIndex((s) => + event.branch_id + ? s.branch_id === event.branch_id && s.type === 'parallel_start' + : s.node_id === event.node_id && s.type === 'parallel_start', + ); + const content = + event.content || + event.branch_label || + event.node_label || + '并行分支执行完成'; + const output = + event.results || event.output || event.outputs || event.branch_results; + + if (existingIndex === -1) { + streamingSteps.value.push({ + type: 'parallel_complete', + content, + node_id: event.node_id, + node_type: event.node_type, + branch_id: event.branch_id, + branch_label: event.branch_label, + output, + status: 'completed', + timestamp: new Date().toISOString(), + }); + } else { + const existingStep = streamingSteps.value[existingIndex]!; + streamingSteps.value[existingIndex] = { + ...existingStep, + type: 'parallel_complete', + content, + output, + status: 'completed', + timestamp: existingStep.timestamp || new Date().toISOString(), + }; + } + assistantMessage.reasoning_steps = [...streamingSteps.value]; + break; + } + case 'node_event': { // 节点事件(如消息节点发送消息) const nodeEvent = event.event; @@ -425,6 +482,32 @@ function handleStreamEvent( break; } + case 'parallel_start': { + const existingIndex = streamingSteps.value.findIndex((s) => + event.branch_id + ? s.branch_id === event.branch_id && s.type === 'parallel_start' + : s.node_id === event.node_id && s.type === 'parallel_start', + ); + if (existingIndex === -1) { + streamingSteps.value.push({ + type: 'parallel_start', + content: + event.content || + event.branch_label || + event.node_label || + '并行分支执行中', + node_id: event.node_id, + node_type: event.node_type, + branch_id: event.branch_id, + branch_label: event.branch_label, + status: 'running', + timestamp: new Date().toISOString(), + }); + } + assistantMessage.reasoning_steps = [...streamingSteps.value]; + break; + } + case 'observation': { currentStep.value = 'observation'; streamingSteps.value.push({ diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/components/ChatPanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/components/ChatPanel.vue index 8fddb14..c42c9d7 100644 --- a/web/apps/web-ele/src/views/ai-platform/workflow/editor/components/ChatPanel.vue +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/components/ChatPanel.vue @@ -498,6 +498,59 @@ const handleStreamEvent = (event: WorkflowStreamEvent, msgId: string) => { break; } + case 'parallel_complete': { + const existingIndex = currentSteps.value.findIndex((s) => + event.branch_id + ? s.branch_id === event.branch_id && s.type === 'parallel_start' + : s.node_id === event.node_id && s.type === 'parallel_start', + ); + const content = + event.content || + event.branch_label || + event.node_label || + '并行分支执行完成'; + const output = + event.results || event.output || event.outputs || event.branch_results; + + if (existingIndex === -1) { + currentSteps.value.push({ + type: 'parallel_complete', + content, + node_id: event.node_id, + node_type: event.node_type, + branch_id: event.branch_id, + branch_label: event.branch_label, + output, + status: 'completed', + timestamp: new Date().toISOString(), + }); + } else { + currentSteps.value[existingIndex] = { + ...currentSteps.value[existingIndex], + type: 'parallel_complete', + content, + output, + status: 'completed', + }; + } + updateAssistantMessage(msgId, { + reasoning_steps: [...currentSteps.value], + }); + + emit('node-complete', { + node_id: event.node_id, + node_type: event.node_type || 'parallel', + status: event.status || 'success', + elapsed_time: event.elapsed_time, + tokens_used: event.tokens_used, + outputs: output, + branch_id: event.branch_id, + branch_label: event.branch_label, + branch_results: event.branch_results, + }); + break; + } + case 'node_event': { // 节点事件(如消息节点发送消息) const nodeEvent = event.event; @@ -525,6 +578,43 @@ const handleStreamEvent = (event: WorkflowStreamEvent, msgId: string) => { break; } + case 'parallel_start': { + const existingIndex = currentSteps.value.findIndex((s) => + event.branch_id + ? s.branch_id === event.branch_id && s.type === 'parallel_start' + : s.node_id === event.node_id && s.type === 'parallel_start', + ); + if (existingIndex === -1) { + currentSteps.value.push({ + type: 'parallel_start', + content: + event.content || + event.branch_label || + event.node_label || + '并行分支执行中', + node_id: event.node_id, + node_type: event.node_type, + branch_id: event.branch_id, + branch_label: event.branch_label, + status: 'running', + timestamp: new Date().toISOString(), + }); + } + updateAssistantMessage(msgId, { + reasoning_steps: [...currentSteps.value], + }); + + emit('node-start', { + node_id: event.node_id, + node_type: event.node_type || 'parallel', + node_label: event.node_label, + branch_id: event.branch_id, + branch_label: event.branch_label, + branches: event.branches, + }); + break; + } + case 'node_start': { // 检查是否已存在相同 node_id 的步骤(避免重复) const existingIndex = currentSteps.value.findIndex(