feat: restore core chat and observability
This commit is contained in:
@@ -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/form_ui_design_node.py
|
||||||
!backend-fastapi/ai_platform/nodes/builtin/system_summary_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/database-monitor.ts
|
||||||
web/apps/web-ele/src/api/core/demo.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/link-preview.ts
|
||||||
web/apps/web-ele/src/api/core/redis-manager.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/redis-monitor.ts
|
||||||
web/apps/web-ele/src/api/core/region.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/scheduler.ts
|
||||||
web/apps/web-ele/src/api/core/server-monitor.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-connection/
|
||||||
web/apps/web-ele/src/views/_core/database-manager/
|
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/database-monitor/**
|
||||||
web/apps/web-ele/src/views/_core/demo/
|
web/apps/web-ele/src/views/_core/demo/
|
||||||
web/apps/web-ele/src/views/_core/mobile-signature/
|
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-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/redis-monitor/**
|
||||||
web/apps/web-ele/src/views/_core/region-manager/
|
web/apps/web-ele/src/views/_core/region-manager/
|
||||||
web/apps/web-ele/src/views/_core/scheduler/
|
web/apps/web-ele/src/views/_core/scheduler/
|
||||||
web/apps/web-ele/src/views/_core/server-monitor/
|
web/apps/web-ele/src/views/_core/server-monitor/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import AsyncGenerator, Dict, List, Optional
|
|||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 utils.context import get_current_user_id_from_context
|
||||||
from .llm_service import LLMService
|
from .llm_service import LLMService
|
||||||
|
|
||||||
@@ -352,16 +352,34 @@ class ChatService:
|
|||||||
async def _get_effective_model(self, conversation: Conversation, app: AIApp) -> Optional[LLMModel]:
|
async def _get_effective_model(self, conversation: Conversation, app: AIApp) -> Optional[LLMModel]:
|
||||||
"""获取有效的模型"""
|
"""获取有效的模型"""
|
||||||
model_id = conversation.model_override_id or app.model_id
|
model_id = conversation.model_override_id or app.model_id
|
||||||
if not model_id:
|
if model_id:
|
||||||
return None
|
|
||||||
|
|
||||||
result = await self._db.execute(
|
result = await self._db.execute(
|
||||||
select(LLMModel).where(
|
select(LLMModel)
|
||||||
|
.join(LLMProvider, LLMProvider.id == LLMModel.provider_id)
|
||||||
|
.where(
|
||||||
LLMModel.id == model_id,
|
LLMModel.id == model_id,
|
||||||
LLMModel.is_active == True,
|
LLMModel.is_active == True,
|
||||||
LLMModel.is_deleted == False
|
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()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def _build_messages(
|
async def _build_messages(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from core.menu.schema import MenuCreate, MenuUpdate
|
|||||||
menu_cache = CacheManager(prefix="menu:")
|
menu_cache = CacheManager(prefix="menu:")
|
||||||
|
|
||||||
# 缓存key
|
# 缓存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}"
|
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}:"
|
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",
|
"AIWorkflowRuns",
|
||||||
"CodexAgentChat",
|
"CodexAgentChat",
|
||||||
"KnowledgeBase",
|
"KnowledgeBase",
|
||||||
|
"StartChat",
|
||||||
"SystemManagement",
|
"SystemManagement",
|
||||||
|
"SystemMonitoring",
|
||||||
|
"ServerMonitor",
|
||||||
|
"RedisMonitor",
|
||||||
|
"DatabaseMonitor",
|
||||||
"SystemPermission",
|
"SystemPermission",
|
||||||
"UserManagement",
|
"UserManagement",
|
||||||
"SystemDept",
|
"SystemDept",
|
||||||
@@ -63,7 +68,12 @@ AI_AGENT_ADMIN_VISIBLE_MENU_NAMES = {
|
|||||||
"AIWorkflowRuns",
|
"AIWorkflowRuns",
|
||||||
"CodexAgentChat",
|
"CodexAgentChat",
|
||||||
"KnowledgeBase",
|
"KnowledgeBase",
|
||||||
|
"StartChat",
|
||||||
"SystemManagement",
|
"SystemManagement",
|
||||||
|
"SystemMonitoring",
|
||||||
|
"ServerMonitor",
|
||||||
|
"RedisMonitor",
|
||||||
|
"DatabaseMonitor",
|
||||||
"SystemPermission",
|
"SystemPermission",
|
||||||
"UserManagement",
|
"UserManagement",
|
||||||
"SystemDept",
|
"SystemDept",
|
||||||
@@ -115,7 +125,43 @@ AI_AGENT_ADMIN_MESSAGE_CHILD_MENU_NAMES = {
|
|||||||
"MessageList",
|
"MessageList",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AI_AGENT_ADMIN_MONITOR_CHILD_MENU_NAMES = {
|
||||||
|
"DatabaseMonitor",
|
||||||
|
"RedisMonitor",
|
||||||
|
"ServerMonitor",
|
||||||
|
}
|
||||||
|
|
||||||
AI_AGENT_ADMIN_MENU_OVERRIDES = {
|
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": {
|
"SystemConfigManager": {
|
||||||
"component": "/_core/system-config/index",
|
"component": "/_core/system-config/index",
|
||||||
"path": "/system-config",
|
"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]:
|
def _filter_ai_agent_admin_menus(menus: List[Menu]) -> List[Menu]:
|
||||||
"""Keep only the lightweight admin and AI modules for this product."""
|
"""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]
|
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:
|
for menu in filtered:
|
||||||
_normalize_ai_agent_admin_menu(menu)
|
_normalize_ai_agent_admin_menu(menu)
|
||||||
_normalize_ai_agent_admin_parentage(filtered)
|
_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"),
|
(menu for menu in menus if menu.name == "Message"),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
monitor_menu = next(
|
||||||
|
(menu for menu in menus if menu.name == "SystemMonitoring"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
for menu in menus:
|
for menu in menus:
|
||||||
if system_menu and menu.name in AI_AGENT_ADMIN_SYSTEM_CHILD_MENU_NAMES:
|
if system_menu and menu.name in AI_AGENT_ADMIN_SYSTEM_CHILD_MENU_NAMES:
|
||||||
menu.parent_id = system_menu.id
|
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:
|
if message_menu and menu.name in AI_AGENT_ADMIN_MESSAGE_CHILD_MENU_NAMES:
|
||||||
menu.parent_id = message_menu.id
|
menu.parent_id = message_menu.id
|
||||||
continue
|
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:
|
if not menu.parent_id or menu.parent_id in retained_ids:
|
||||||
continue
|
continue
|
||||||
menu.parent_id = None
|
menu.parent_id = None
|
||||||
|
|||||||
@@ -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.api_token.api import router as api_token_router
|
||||||
from core.auth.api import router as auth_router
|
from core.auth.api import router as auth_router
|
||||||
from core.chat.api import router as chat_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.dept.api import router as dept_router
|
||||||
from core.device.api import router as device_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.file_manager.router import router as file_manager_router
|
||||||
from core.login_log.api import router as login_log_router
|
from core.login_log.api import router as login_log_router
|
||||||
from core.menu.api import router as menu_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.page_manager.api import router as page_manager_router
|
||||||
from core.permission.api import router as permission_router
|
from core.permission.api import router as permission_router
|
||||||
from core.post.api import router as post_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.field_permission.api import router as field_permission_router
|
||||||
from core.resource_scope.scope_permission.api import router as resource_scope_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.role.api import router as role_router
|
||||||
from core.server_monitor.api import router as server_monitor_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.ui_config.api import router as ui_config_router
|
||||||
from core.user.api import router as user_router
|
from core.user.api import router as user_router
|
||||||
|
|
||||||
@@ -31,11 +36,15 @@ router = APIRouter()
|
|||||||
router.include_router(application_router)
|
router.include_router(application_router)
|
||||||
router.include_router(auth_router)
|
router.include_router(auth_router)
|
||||||
router.include_router(chat_router)
|
router.include_router(chat_router)
|
||||||
|
router.include_router(database_monitor_router)
|
||||||
router.include_router(dept_router)
|
router.include_router(dept_router)
|
||||||
router.include_router(device_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(menu_router)
|
||||||
router.include_router(permission_router)
|
router.include_router(permission_router)
|
||||||
router.include_router(post_router)
|
router.include_router(post_router)
|
||||||
|
router.include_router(redis_monitor_router)
|
||||||
router.include_router(resource_scope_router)
|
router.include_router(resource_scope_router)
|
||||||
router.include_router(field_permission_router)
|
router.include_router(field_permission_router)
|
||||||
router.include_router(role_router)
|
router.include_router(role_router)
|
||||||
@@ -47,5 +56,6 @@ router.include_router(announcement_router)
|
|||||||
router.include_router(oauth_router)
|
router.include_router(oauth_router)
|
||||||
router.include_router(page_manager_router)
|
router.include_router(page_manager_router)
|
||||||
router.include_router(server_monitor_router)
|
router.include_router(server_monitor_router)
|
||||||
|
router.include_router(system_config_router)
|
||||||
router.include_router(ui_config_router)
|
router.include_router(ui_config_router)
|
||||||
router.include_router(api_token_router)
|
router.include_router(api_token_router)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public-hoist-pattern[]=prettier-plugin-tailwindcss
|
|||||||
public-hoist-pattern[]=stylelint
|
public-hoist-pattern[]=stylelint
|
||||||
public-hoist-pattern[]=*postcss*
|
public-hoist-pattern[]=*postcss*
|
||||||
public-hoist-pattern[]=@commitlint/*
|
public-hoist-pattern[]=@commitlint/*
|
||||||
|
public-hoist-pattern[]=@element-plus/icons-vue
|
||||||
public-hoist-pattern[]=czg
|
public-hoist-pattern[]=czg
|
||||||
public-hoist-pattern[]=prosemirror-*
|
public-hoist-pattern[]=prosemirror-*
|
||||||
public-hoist-pattern[]=@univerjs/*
|
public-hoist-pattern[]=@univerjs/*
|
||||||
|
|||||||
@@ -901,7 +901,10 @@ export interface WorkflowStreamEvent {
|
|||||||
accumulated_content?: string; // 累积内容
|
accumulated_content?: string; // 累积内容
|
||||||
// 并行执行字段
|
// 并行执行字段
|
||||||
branches?: string[]; // 并行分支 ID 列表
|
branches?: string[]; // 并行分支 ID 列表
|
||||||
|
branch_id?: string;
|
||||||
|
branch_label?: string;
|
||||||
branch_results?: Record<string, any>; // 各分支执行结果
|
branch_results?: Record<string, any>; // 各分支执行结果
|
||||||
|
results?: any;
|
||||||
// 循环迭代字段
|
// 循环迭代字段
|
||||||
iteration?: number; // 当前迭代次数(从 0 开始)
|
iteration?: number; // 当前迭代次数(从 0 开始)
|
||||||
total?: number; // 总迭代次数(for_each 模式)
|
total?: number; // 总迭代次数(for_each 模式)
|
||||||
@@ -1154,20 +1157,37 @@ export interface ReasoningStep {
|
|||||||
| 'action'
|
| 'action'
|
||||||
| 'annotation_reply'
|
| 'annotation_reply'
|
||||||
| 'knowledge_retrieval'
|
| 'knowledge_retrieval'
|
||||||
|
| 'loop_complete'
|
||||||
|
| 'loop_iteration_complete'
|
||||||
|
| 'loop_iteration_error'
|
||||||
|
| 'loop_iteration_start'
|
||||||
| 'node_complete'
|
| 'node_complete'
|
||||||
| 'node_start'
|
| 'node_start'
|
||||||
| 'observation'
|
| 'observation'
|
||||||
|
| 'parallel_complete'
|
||||||
|
| 'parallel_start'
|
||||||
| 'thought';
|
| 'thought';
|
||||||
content: string;
|
content: string;
|
||||||
tool?: string;
|
tool?: string;
|
||||||
params?: Record<string, any>;
|
params?: Record<string, any>;
|
||||||
|
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<string, any>;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
// 对话流模式:节点信息
|
// 对话流模式:节点信息
|
||||||
node_id?: string;
|
node_id?: string;
|
||||||
node_type?: string;
|
node_type?: string;
|
||||||
output?: any;
|
output?: any;
|
||||||
/** 步骤状态:running 执行中,completed 已完成 */
|
/** 步骤状态:running 执行中,completed 已完成 */
|
||||||
status?: 'completed' | 'running';
|
status?: 'completed' | 'failed' | 'running';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工具调用记录 */
|
/** 工具调用记录 */
|
||||||
@@ -1237,6 +1257,8 @@ export interface AgentChatEvent {
|
|||||||
| 'node_event'
|
| 'node_event'
|
||||||
| 'node_start'
|
| 'node_start'
|
||||||
| 'observation'
|
| 'observation'
|
||||||
|
| 'parallel_complete'
|
||||||
|
| 'parallel_start'
|
||||||
| 'start'
|
| 'start'
|
||||||
| 'thought'
|
| 'thought'
|
||||||
| 'waiting_input';
|
| 'waiting_input';
|
||||||
@@ -1254,7 +1276,12 @@ export interface AgentChatEvent {
|
|||||||
node_id?: string;
|
node_id?: string;
|
||||||
node_type?: string;
|
node_type?: string;
|
||||||
node_label?: string;
|
node_label?: string;
|
||||||
|
branch_id?: string;
|
||||||
|
branch_label?: string;
|
||||||
|
communication?: Record<string, any>;
|
||||||
output?: any;
|
output?: any;
|
||||||
|
results?: any;
|
||||||
|
branch_results?: Record<string, any>;
|
||||||
outputs?: {
|
outputs?: {
|
||||||
output?: any;
|
output?: any;
|
||||||
output_variables?: Record<string, any>;
|
output_variables?: Record<string, any>;
|
||||||
|
|||||||
@@ -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<DatabaseConfig[]>(
|
||||||
|
'/api/core/database_monitor/configs',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据库概览信息
|
||||||
|
*/
|
||||||
|
export async function getDatabaseMonitorOverviewApi(dbName: string) {
|
||||||
|
return requestClient.get<DatabaseMonitorOverview>(
|
||||||
|
`/api/core/database_monitor/${dbName}/overview`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据库实时统计信息
|
||||||
|
*/
|
||||||
|
export async function getDatabaseRealtimeStatsApi(dbName: string) {
|
||||||
|
return requestClient.get<DatabaseRealtimeStats>(
|
||||||
|
`/api/core/database_monitor/${dbName}/realtime`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试数据库连接
|
||||||
|
*/
|
||||||
|
export async function testDatabaseConnectionApi(dbName: string) {
|
||||||
|
return requestClient.post<DatabaseConnectionTest>(
|
||||||
|
`/api/core/database_monitor/${dbName}/test`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<RedisConfig>('/api/core/redis_monitor/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Redis监控概览
|
||||||
|
*/
|
||||||
|
export async function getRedisMonitorOverviewApi() {
|
||||||
|
return requestClient.get<RedisMonitorOverview>(
|
||||||
|
'/api/core/redis_monitor/overview',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Redis实时统计
|
||||||
|
*/
|
||||||
|
export async function getRedisRealtimeStatsApi() {
|
||||||
|
return requestClient.get<RedisRealtimeStats>(
|
||||||
|
'/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');
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { ZqDialog } from '#/components/zq-dialog';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
import OrgChartPanel from '#/views/_core/org-chart/modules/OrgChartPanel.vue';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'UserProfileDialog',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
userId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ZqDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="$t('user-avatar.profile.organization')"
|
||||||
|
width="80%"
|
||||||
|
:show-footer="false"
|
||||||
|
content-height="70vh"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div class="org-chart-wrapper">
|
||||||
|
<OrgChartPanel :user-id="userId" :show-mode-toggle="true" />
|
||||||
|
</div>
|
||||||
|
</ZqDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.org-chart-wrapper {
|
||||||
|
height: 65vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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": "查看详情"
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
"dictionaryManagement": "字典管理",
|
"dictionaryManagement": "字典管理",
|
||||||
"fileManagement": "文件管理",
|
"fileManagement": "文件管理",
|
||||||
"serverMonitoring": "服务器监控",
|
"serverMonitoring": "服务器监控",
|
||||||
|
"redisMonitoring": "Redis监控",
|
||||||
|
"databaseMonitoring": "数据库监控",
|
||||||
"uiConfig": "界面配置",
|
"uiConfig": "界面配置",
|
||||||
"loginLog": "登录日志",
|
"loginLog": "登录日志",
|
||||||
"orgChart": "组织架构",
|
"orgChart": "组织架构",
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
|||||||
'../views/_core/announcement/list.vue',
|
'../views/_core/announcement/list.vue',
|
||||||
'../views/_core/application/index.vue',
|
'../views/_core/application/index.vue',
|
||||||
'../views/_core/authentication/login.vue',
|
'../views/_core/authentication/login.vue',
|
||||||
|
'../views/_core/chat/**/*.vue',
|
||||||
|
'../views/_core/database-monitor/**/*.vue',
|
||||||
'../views/_core/dept/index.vue',
|
'../views/_core/dept/index.vue',
|
||||||
'../views/_core/dict/index.vue',
|
'../views/_core/dict/index.vue',
|
||||||
'../views/_core/fallback/**/*.vue',
|
'../views/_core/fallback/**/*.vue',
|
||||||
@@ -190,6 +192,8 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
|||||||
'../views/_core/permission/index.vue',
|
'../views/_core/permission/index.vue',
|
||||||
'../views/_core/post/index.vue',
|
'../views/_core/post/index.vue',
|
||||||
'../views/_core/role/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/system-config/index.vue',
|
||||||
'../views/_core/ui-config/index.vue',
|
'../views/_core/ui-config/index.vue',
|
||||||
'../views/_core/user/index.vue',
|
'../views/_core/user/index.vue',
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export const LIGHT_MENU_NAMES = new Set([
|
|||||||
'KnowledgeBase',
|
'KnowledgeBase',
|
||||||
'LoginLog',
|
'LoginLog',
|
||||||
'OrgNode',
|
'OrgNode',
|
||||||
|
'DatabaseMonitor',
|
||||||
|
'RedisMonitor',
|
||||||
|
'ServerMonitor',
|
||||||
|
'StartChat',
|
||||||
'SystemDept',
|
'SystemDept',
|
||||||
'SystemDict',
|
'SystemDict',
|
||||||
'SystemFileManager',
|
'SystemFileManager',
|
||||||
@@ -25,6 +29,7 @@ export const LIGHT_MENU_NAMES = new Set([
|
|||||||
'SystemOrgChart',
|
'SystemOrgChart',
|
||||||
'SystemConfig',
|
'SystemConfig',
|
||||||
'SystemConfigManager',
|
'SystemConfigManager',
|
||||||
|
'SystemMonitoring',
|
||||||
'SystemMenu',
|
'SystemMenu',
|
||||||
'SystemPermission',
|
'SystemPermission',
|
||||||
'SystemPost',
|
'SystemPost',
|
||||||
@@ -44,10 +49,12 @@ const LIGHT_ROUTE_PATH_PREFIXES = [
|
|||||||
'/ai-platform/workflow-runs',
|
'/ai-platform/workflow-runs',
|
||||||
'/agent-chat',
|
'/agent-chat',
|
||||||
'/application',
|
'/application',
|
||||||
|
'/chat',
|
||||||
'/message/list',
|
'/message/list',
|
||||||
'/message/announcement',
|
'/message/announcement',
|
||||||
'/message/announcement-list',
|
'/message/announcement-list',
|
||||||
'/page-render/main_home',
|
'/page-render/main_home',
|
||||||
|
'/monitor',
|
||||||
'/core/ui-config',
|
'/core/ui-config',
|
||||||
'/system-config',
|
'/system-config',
|
||||||
'/system/dept',
|
'/system/dept',
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElButton, ElForm, ElFormItem, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { ZqDialog } from '#/components/zq-dialog';
|
||||||
|
import { UserSelector } from '#/components/zq-form/user-selector';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
existingMemberIds?: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
confirm: [memberIds: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ default: false });
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const loading = ref(false);
|
||||||
|
const formData = ref({
|
||||||
|
member_ids: [] as string[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = computed<FormRules>(() => ({
|
||||||
|
member_ids: [
|
||||||
|
{ required: true, message: $t('chat.membersRequired'), trigger: 'change' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
formData.value = { member_ids: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (!formRef.value) return;
|
||||||
|
try {
|
||||||
|
await formRef.value.validate();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 过滤掉已有成员
|
||||||
|
const newIds = formData.value.member_ids.filter(
|
||||||
|
(id) => !props.existingMemberIds?.includes(id),
|
||||||
|
);
|
||||||
|
if (newIds.length === 0) {
|
||||||
|
ElMessage.warning($t('chat.allMembersExist'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
emit('confirm', newIds);
|
||||||
|
visible.value = false;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ZqDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="$t('chat.addMember')"
|
||||||
|
width="500px"
|
||||||
|
@open="handleOpen"
|
||||||
|
>
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="rules"
|
||||||
|
label-width="90px"
|
||||||
|
label-position="left"
|
||||||
|
>
|
||||||
|
<ElFormItem :label="$t('chat.selectMembers')" prop="member_ids">
|
||||||
|
<UserSelector
|
||||||
|
v-model="formData.member_ids"
|
||||||
|
multiple
|
||||||
|
:placeholder="$t('chat.selectMembersPlaceholder')"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="visible = false">{{ $t('common.cancel') }}</ElButton>
|
||||||
|
<ElButton type="primary" :loading="loading" @click="handleConfirm">
|
||||||
|
{{ $t('common.confirm') }}
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ZqDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,762 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChatMessage } from '#/api/core/chat';
|
||||||
|
|
||||||
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CornerUpLeft,
|
||||||
|
Loader2,
|
||||||
|
Mic,
|
||||||
|
Paperclip,
|
||||||
|
Send,
|
||||||
|
Smile,
|
||||||
|
Square,
|
||||||
|
X,
|
||||||
|
} from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElMessage, ElPopover, ElTooltip } from 'element-plus';
|
||||||
|
|
||||||
|
import { uploadFile } from '#/api/core/file';
|
||||||
|
import { getFileTypeIcon } from '#/assets/file-icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatVoiceDuration,
|
||||||
|
useVoiceRecorder,
|
||||||
|
} from '../composables/useVoiceRecorder';
|
||||||
|
import EmojiPicker from './EmojiPicker.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
disabled?: boolean;
|
||||||
|
replyTo?: ChatMessage | null;
|
||||||
|
sending?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
cancelReply: [];
|
||||||
|
send: [
|
||||||
|
content: string,
|
||||||
|
msgType: string,
|
||||||
|
fileId?: string,
|
||||||
|
fileName?: string,
|
||||||
|
localUrl?: string,
|
||||||
|
extra?: Record<string, any>,
|
||||||
|
];
|
||||||
|
typing: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const inputText = ref('');
|
||||||
|
const uploading = ref(false);
|
||||||
|
const inputRef = ref<HTMLTextAreaElement>();
|
||||||
|
const emojiVisible = ref(false);
|
||||||
|
|
||||||
|
// ---- 语音录音 ----
|
||||||
|
const {
|
||||||
|
isRecording,
|
||||||
|
duration: recordingDuration,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
cancelRecording,
|
||||||
|
} = useVoiceRecorder();
|
||||||
|
const voiceUploading = ref(false);
|
||||||
|
|
||||||
|
async function handleStartRecording() {
|
||||||
|
const ok = await startRecording();
|
||||||
|
if (!ok) {
|
||||||
|
ElMessage.warning($t('chat.micPermissionDenied'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStopRecording() {
|
||||||
|
const result = await stopRecording();
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
if (result.duration < 1) {
|
||||||
|
ElMessage.warning($t('chat.voiceTooShort'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
voiceUploading.value = true;
|
||||||
|
try {
|
||||||
|
const file = new File([result.blob], `voice_${Date.now()}.webm`, {
|
||||||
|
type: result.blob.type,
|
||||||
|
});
|
||||||
|
const res = await uploadFile(file, { source: 'chat' });
|
||||||
|
if (res?.id) {
|
||||||
|
emit('send', '', 'voice', res.id, file.name, undefined, {
|
||||||
|
duration: result.duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\u8BED\u97F3\u4E0A\u4F20\u5931\u8D25:', error);
|
||||||
|
} finally {
|
||||||
|
voiceUploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelRecording() {
|
||||||
|
cancelRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEmojiSelect(emoji: string) {
|
||||||
|
const el = inputRef.value;
|
||||||
|
if (el) {
|
||||||
|
const start = el.selectionStart ?? inputText.value.length;
|
||||||
|
const end = el.selectionEnd ?? start;
|
||||||
|
inputText.value =
|
||||||
|
inputText.value.slice(0, start) + emoji + inputText.value.slice(end);
|
||||||
|
nextTick(() => {
|
||||||
|
const pos = start + emoji.length;
|
||||||
|
el.setSelectionRange(pos, pos);
|
||||||
|
el.focus();
|
||||||
|
autoResize();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
inputText.value += emoji;
|
||||||
|
}
|
||||||
|
emojiVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 暂存附件 ----
|
||||||
|
interface PendingFile {
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileType: 'file' | 'image';
|
||||||
|
localUrl?: string;
|
||||||
|
ext: string;
|
||||||
|
}
|
||||||
|
const pendingFiles = ref<PendingFile[]>([]);
|
||||||
|
|
||||||
|
const IMAGE_EXTS = new Set([
|
||||||
|
'bmp',
|
||||||
|
'gif',
|
||||||
|
'ico',
|
||||||
|
'jpeg',
|
||||||
|
'jpg',
|
||||||
|
'png',
|
||||||
|
'svg',
|
||||||
|
'tiff',
|
||||||
|
'webp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isImageExt(ext: string): boolean {
|
||||||
|
return IMAGE_EXTS.has(ext.toLowerCase().replace('.', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExt(name: string): string {
|
||||||
|
const idx = name.lastIndexOf('.');
|
||||||
|
return idx === -1 ? '' : name.slice(idx + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePendingFile(index: number) {
|
||||||
|
const pf = pendingFiles.value[index];
|
||||||
|
if (pf?.localUrl) URL.revokeObjectURL(pf.localUrl);
|
||||||
|
pendingFiles.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSend = computed(() => {
|
||||||
|
return (
|
||||||
|
(inputText.value.trim() || pendingFiles.value.length > 0) &&
|
||||||
|
!props.sending &&
|
||||||
|
!uploading.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function autoResize() {
|
||||||
|
const el = inputRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
const maxH = 200; // ~6 rows
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, maxH)}px`;
|
||||||
|
el.style.overflowY = el.scrollHeight > maxH ? 'auto' : 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.replyTo,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
nextTick(() => {
|
||||||
|
inputRef.value?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(autoResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
let typingTimer: null | ReturnType<typeof setTimeout> = null;
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
autoResize();
|
||||||
|
if (!typingTimer) {
|
||||||
|
emit('typing');
|
||||||
|
typingTimer = setTimeout(() => {
|
||||||
|
typingTimer = null;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSend() {
|
||||||
|
if (!canSend.value) return;
|
||||||
|
|
||||||
|
// 发送所有暂存附件
|
||||||
|
if (pendingFiles.value.length > 0) {
|
||||||
|
for (const pf of pendingFiles.value) {
|
||||||
|
emit(
|
||||||
|
'send',
|
||||||
|
pf.fileName,
|
||||||
|
pf.fileType,
|
||||||
|
pf.fileId,
|
||||||
|
pf.fileName,
|
||||||
|
pf.localUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pendingFiles.value = []; // 不 revoke localUrl,交给消息列表使用
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送文本
|
||||||
|
const text = inputText.value.trim();
|
||||||
|
if (text) {
|
||||||
|
emit('send', text, 'text');
|
||||||
|
inputText.value = '';
|
||||||
|
nextTick(autoResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.replyTo) {
|
||||||
|
emit('cancelReply');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: Event) {
|
||||||
|
const ke = e as KeyboardEvent;
|
||||||
|
if (ke.key === 'Enter' && !ke.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePaste(e: ClipboardEvent) {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
const files: File[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
// 截图粘贴时文件名通常为 image.png,加上时间戳区分
|
||||||
|
const name =
|
||||||
|
file.name === 'image.png'
|
||||||
|
? `screenshot_${Date.now()}.png`
|
||||||
|
: file.name;
|
||||||
|
files.push(new File([file], name, { type: file.type }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
await processFiles(files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadFile() {
|
||||||
|
await handleFileUpload('file');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processFiles(files: File[]) {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const ext = getExt(file.name);
|
||||||
|
const actualType: 'file' | 'image' = isImageExt(ext) ? 'image' : 'file';
|
||||||
|
const localUrl = isImageExt(ext) ? URL.createObjectURL(file) : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(file, { source: 'chat' });
|
||||||
|
if (res?.id) {
|
||||||
|
pendingFiles.value.push({
|
||||||
|
fileId: res.id,
|
||||||
|
fileName: file.name,
|
||||||
|
fileType: actualType,
|
||||||
|
localUrl,
|
||||||
|
ext,
|
||||||
|
});
|
||||||
|
} else if (localUrl) {
|
||||||
|
URL.revokeObjectURL(localUrl);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上传失败:', file.name, error);
|
||||||
|
if (localUrl) URL.revokeObjectURL(localUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileUpload(type: 'file' | 'image') {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.multiple = true;
|
||||||
|
if (type === 'image') {
|
||||||
|
input.accept = 'image/*';
|
||||||
|
}
|
||||||
|
input.addEventListener('change', async () => {
|
||||||
|
const files = input.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
await processFiles([...files]);
|
||||||
|
});
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 拖拽上传 ----
|
||||||
|
const isDragOver = ref(false);
|
||||||
|
let dragCounter = 0;
|
||||||
|
|
||||||
|
function handleDragEnter(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter++;
|
||||||
|
if (e.dataTransfer?.types.includes('Files')) {
|
||||||
|
isDragOver.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter--;
|
||||||
|
if (dragCounter <= 0) {
|
||||||
|
dragCounter = 0;
|
||||||
|
isDragOver.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter = 0;
|
||||||
|
isDragOver.value = false;
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
await processFiles([...files]);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative mb-4"
|
||||||
|
@dragenter="handleDragEnter"
|
||||||
|
@dragover="handleDragOver"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="handleDrop"
|
||||||
|
>
|
||||||
|
<!-- 拖拽上传遮罩 -->
|
||||||
|
<div v-if="isDragOver" class="drag-overlay mx-3">
|
||||||
|
<div class="drag-overlay-content">
|
||||||
|
<Paperclip class="h-6 w-6" />
|
||||||
|
<span class="text-sm font-medium">{{ $t('chat.dropToUpload') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 回复预览条 -->
|
||||||
|
<div
|
||||||
|
v-if="replyTo"
|
||||||
|
class="flex items-center gap-2 bg-[var(--el-fill-color-light)] px-4 py-2"
|
||||||
|
>
|
||||||
|
<CornerUpLeft
|
||||||
|
class="h-3.5 w-3.5 shrink-0 text-[var(--el-color-primary)]"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-xs font-medium text-[var(--el-color-primary)]">
|
||||||
|
{{ $t('chat.replyingTo', { name: replyTo.sender_name }) }}
|
||||||
|
</div>
|
||||||
|
<div class="truncate text-xs text-[var(--el-text-color-secondary)]">
|
||||||
|
{{ replyTo.content || `[${replyTo.msg_type}]` }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<X
|
||||||
|
class="h-4 w-4 shrink-0 cursor-pointer text-[var(--el-text-color-placeholder)] transition-colors hover:text-[var(--el-text-color-primary)]"
|
||||||
|
@click="emit('cancelReply')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 附件预览列表 -->
|
||||||
|
<div v-if="pendingFiles.length > 0" class="pending-files-area">
|
||||||
|
<div
|
||||||
|
v-for="(pf, idx) in pendingFiles"
|
||||||
|
:key="pf.fileId"
|
||||||
|
class="pending-file-item"
|
||||||
|
>
|
||||||
|
<div class="pending-file-preview">
|
||||||
|
<img
|
||||||
|
v-if="pf.localUrl"
|
||||||
|
:src="pf.localUrl"
|
||||||
|
class="pending-file-thumb"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="getFileTypeIcon(pf.ext)"
|
||||||
|
class="pending-file-icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
class="truncate text-xs font-medium text-[var(--el-text-color-primary)]"
|
||||||
|
>
|
||||||
|
{{ pf.fileName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<X
|
||||||
|
class="h-3.5 w-3.5 shrink-0 cursor-pointer text-[var(--el-text-color-placeholder)] transition-colors hover:text-[var(--el-text-color-primary)]"
|
||||||
|
@click="removePendingFile(idx)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-input-container">
|
||||||
|
<!-- 录音中 UI -->
|
||||||
|
<div v-if="isRecording" class="voice-recording-bar">
|
||||||
|
<div class="voice-recording-indicator">
|
||||||
|
<span class="voice-recording-dot"></span>
|
||||||
|
<span class="text-xs font-medium text-[var(--el-color-danger)]">
|
||||||
|
{{ formatVoiceDuration(recordingDuration) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button class="voice-cancel-btn" @click="handleCancelRecording">
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button class="voice-stop-btn" @click="handleStopRecording">
|
||||||
|
<Square class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 语音上传中 -->
|
||||||
|
<div v-else-if="voiceUploading" class="voice-recording-bar">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-xs text-[var(--el-text-color-placeholder)]"
|
||||||
|
>
|
||||||
|
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||||
|
{{ $t('chat.voiceUploading') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 正常输入 UI -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- 上传中提示 -->
|
||||||
|
<div
|
||||||
|
v-if="uploading"
|
||||||
|
class="flex items-center gap-2 px-3 pt-2 text-xs text-[var(--el-text-color-placeholder)]"
|
||||||
|
>
|
||||||
|
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||||
|
{{ $t('chat.uploading') || '上传中...' }}
|
||||||
|
</div>
|
||||||
|
<!-- 输入框 -->
|
||||||
|
<textarea
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="inputText"
|
||||||
|
class="chat-textarea"
|
||||||
|
:placeholder="$t('chat.inputPlaceholder')"
|
||||||
|
:disabled="disabled"
|
||||||
|
rows="2"
|
||||||
|
@input="handleInput"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@paste="handlePaste"
|
||||||
|
></textarea>
|
||||||
|
<!-- 底部操作栏 -->
|
||||||
|
<div class="chat-input-actions">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<ElTooltip :content="$t('chat.sendFile')" placement="top">
|
||||||
|
<Paperclip
|
||||||
|
class="chat-action-icon"
|
||||||
|
:class="{ 'pointer-events-none opacity-50': uploading }"
|
||||||
|
@click="handleUploadFile"
|
||||||
|
/>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElPopover
|
||||||
|
v-model:visible="emojiVisible"
|
||||||
|
placement="top-start"
|
||||||
|
:width="336"
|
||||||
|
trigger="click"
|
||||||
|
:show-arrow="false"
|
||||||
|
:offset="8"
|
||||||
|
popper-class="emoji-popover"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<span
|
||||||
|
class="chat-action-icon inline-flex"
|
||||||
|
:title="$t('chat.emoji')"
|
||||||
|
>
|
||||||
|
<Smile class="h-[18px] w-[18px]" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<EmojiPicker @select="handleEmojiSelect" />
|
||||||
|
</ElPopover>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<ElTooltip :content="$t('chat.voiceMessage')" placement="top">
|
||||||
|
<button class="chat-send-btn" @click="handleStartRecording">
|
||||||
|
<Mic class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</ElTooltip>
|
||||||
|
<button
|
||||||
|
class="chat-send-btn"
|
||||||
|
:class="{ 'chat-send-btn--active': canSend }"
|
||||||
|
:disabled="!canSend"
|
||||||
|
@click="handleSend"
|
||||||
|
>
|
||||||
|
<Send class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-input-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 8px 12px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-container:focus-within {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
box-shadow: 0 0 0 2px var(--el-color-primary-light-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
padding: 10px 12px 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
resize: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-textarea::placeholder {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-textarea:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 8px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn--active {
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn--active:hover {
|
||||||
|
background: var(--el-color-primary-light-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn--active:active {
|
||||||
|
background: var(--el-color-primary-dark-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-files-area {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 120px;
|
||||||
|
margin: 0 12px;
|
||||||
|
padding: 6px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 200px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-file-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-file-thumb {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-file-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-action-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-action-icon:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-action-icon:focus,
|
||||||
|
.chat-action-icon:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 语音录音 ---- */
|
||||||
|
.voice-recording-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-recording-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-recording-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--el-color-danger);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: voice-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes voice-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-cancel-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-cancel-btn:hover {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
background: var(--el-fill-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-stop-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--el-color-danger);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-stop-btn:hover {
|
||||||
|
background: var(--el-color-danger-light-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 拖拽上传遮罩 ---- */
|
||||||
|
.drag-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
border: 2px dashed var(--el-color-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-overlay-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.emoji-popover.el-popover.el-popper {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { X } from '@vben/icons';
|
||||||
|
|
||||||
|
import UserAvatar from '#/components/user-avatar/index.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
content: string;
|
||||||
|
senderAvatar?: string;
|
||||||
|
senderId?: string;
|
||||||
|
senderName: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [];
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chat-toast" @click="emit('click')">
|
||||||
|
<UserAvatar
|
||||||
|
:user-id="senderId"
|
||||||
|
:name="senderName"
|
||||||
|
:avatar="senderAvatar"
|
||||||
|
:size="40"
|
||||||
|
:font-size="16"
|
||||||
|
:show-popover="false"
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="chat-toast-body">
|
||||||
|
<div class="chat-toast-sender">{{ senderName }}</div>
|
||||||
|
<div class="chat-toast-content">{{ content }}</div>
|
||||||
|
</div>
|
||||||
|
<button class="chat-toast-close" @click.stop="emit('close')">
|
||||||
|
<X class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat-toast {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
width: 320px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
background: var(--el-bg-color-overlay);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgb(0 0 0 / 8%),
|
||||||
|
0 1px 3px rgb(0 0 0 / 12%);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
animation: chat-toast-enter 0.3s ease;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-toast:hover {
|
||||||
|
transform: translateX(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-toast-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-toast-sender {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-toast-content {
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-toast-close {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
background: transparent;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-toast:hover .chat-toast-close {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-toast-close:hover {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes chat-toast-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat-toast-exit .chat-toast {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition:
|
||||||
|
opacity 0.3s ease,
|
||||||
|
transform 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { User } from '#/api/core/user';
|
||||||
|
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { MessageSquare, Network } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElSkeleton, ElSkeletonItem, ElTooltip } from 'element-plus';
|
||||||
|
|
||||||
|
import { getUserDetailApi } from '#/api/core/user';
|
||||||
|
import DeptTag from '#/components/dept-tag/index.vue';
|
||||||
|
import PostTag from '#/components/post-tag/index.vue';
|
||||||
|
import RoleTag from '#/components/role-tag/index.vue';
|
||||||
|
import UserAvatar from '#/components/user-avatar/index.vue';
|
||||||
|
import UserProfileDialog from '#/components/user-avatar/UserProfileDialog.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
onlineUsers?: Set<string>;
|
||||||
|
userId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
startChat: [user: User];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const userDetail = ref<User>();
|
||||||
|
const profileDialogVisible = ref(false);
|
||||||
|
|
||||||
|
async function loadUserDetail() {
|
||||||
|
if (!props.userId) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
userDetail.value = await getUserDetailApi(props.userId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载用户详情失败:', error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadUserDetail();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.userId,
|
||||||
|
(newId, oldId) => {
|
||||||
|
if (newId && newId !== oldId) {
|
||||||
|
userDetail.value = undefined;
|
||||||
|
loadUserDetail();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<!-- 加载骨架屏 -->
|
||||||
|
<div v-if="loading" class="flex flex-1 items-center justify-center">
|
||||||
|
<ElSkeleton animated :loading="true">
|
||||||
|
<template #template>
|
||||||
|
<div class="flex flex-col items-center px-6">
|
||||||
|
<ElSkeletonItem
|
||||||
|
variant="circle"
|
||||||
|
style="width: 80px; height: 80px"
|
||||||
|
/>
|
||||||
|
<ElSkeletonItem
|
||||||
|
variant="text"
|
||||||
|
style="width: 120px; margin-top: 16px"
|
||||||
|
/>
|
||||||
|
<ElSkeletonItem
|
||||||
|
variant="text"
|
||||||
|
style="width: 80px; margin-top: 6px"
|
||||||
|
/>
|
||||||
|
<div class="mt-4 flex flex-col items-center gap-2">
|
||||||
|
<ElSkeletonItem variant="text" style="width: 160px" />
|
||||||
|
<ElSkeletonItem variant="text" style="width: 140px" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex gap-6">
|
||||||
|
<ElSkeletonItem
|
||||||
|
variant="circle"
|
||||||
|
style="width: 44px; height: 44px"
|
||||||
|
/>
|
||||||
|
<ElSkeletonItem
|
||||||
|
variant="circle"
|
||||||
|
style="width: 44px; height: 44px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElSkeleton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="userDetail">
|
||||||
|
<div class="flex flex-1 items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center px-6">
|
||||||
|
<!-- 头像 -->
|
||||||
|
<UserAvatar
|
||||||
|
:user-id="userDetail.id"
|
||||||
|
:name="userDetail.name || userDetail.username"
|
||||||
|
:avatar="userDetail.avatar"
|
||||||
|
:size="80"
|
||||||
|
:font-size="32"
|
||||||
|
:shadow="true"
|
||||||
|
:show-popover="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 姓名 & 用户名 -->
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<span
|
||||||
|
class="text-lg font-semibold text-[var(--el-text-color-primary)]"
|
||||||
|
>
|
||||||
|
{{ userDetail.name || userDetail.username }}
|
||||||
|
</span>
|
||||||
|
<!-- <span
|
||||||
|
class="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] leading-none"
|
||||||
|
:class="props.onlineUsers?.has(userDetail.id)
|
||||||
|
? 'bg-[var(--el-color-success-light-9)] text-[var(--el-color-success)]'
|
||||||
|
: 'bg-[var(--el-fill-color)] text-[var(--el-text-color-placeholder)]'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="props.onlineUsers?.has(userDetail.id)
|
||||||
|
? 'bg-[var(--el-color-success)]'
|
||||||
|
: 'bg-[var(--el-text-color-placeholder)]'"
|
||||||
|
/>
|
||||||
|
{{ props.onlineUsers?.has(userDetail.id) ? $t('chat.online') : $t('chat.offline') }}
|
||||||
|
</span> -->
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 text-xs text-[var(--el-text-color-secondary)]">
|
||||||
|
@{{ userDetail.username }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="userDetail.bio"
|
||||||
|
class="mt-2 max-w-[280px] text-xs leading-relaxed text-[var(--el-text-color-placeholder)]"
|
||||||
|
>
|
||||||
|
{{ userDetail.bio }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详细信息 -->
|
||||||
|
<div class="contact-details">
|
||||||
|
<div v-if="userDetail.email" class="detail-row">
|
||||||
|
<span class="detail-label">邮箱</span>
|
||||||
|
<span class="detail-value">{{ userDetail.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="userDetail.mobile" class="detail-row">
|
||||||
|
<span class="detail-label">手机</span>
|
||||||
|
<span class="detail-value">{{ userDetail.mobile }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="userDetail.city" class="detail-row">
|
||||||
|
<span class="detail-label">城市</span>
|
||||||
|
<span class="detail-value">{{ userDetail.city }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="userDetail.dept_id" class="detail-row">
|
||||||
|
<span class="detail-label">部门</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
<DeptTag :dept-id="userDetail.dept_id" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="userDetail.post_id" class="detail-row">
|
||||||
|
<span class="detail-label">岗位</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
<PostTag :post-id="userDetail.post_id" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="userDetail.role_ids && userDetail.role_ids.length > 0"
|
||||||
|
class="detail-row"
|
||||||
|
>
|
||||||
|
<span class="detail-label">角色</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
<RoleTag :role-ids="userDetail.role_ids" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="userDetail.manager_id" class="detail-row">
|
||||||
|
<span class="detail-label">经理</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
<UserAvatar
|
||||||
|
:user-id="userDetail.manager_id"
|
||||||
|
:size="20"
|
||||||
|
:font-size="10"
|
||||||
|
:shadow="false"
|
||||||
|
show-info
|
||||||
|
hide-username
|
||||||
|
info-position="right"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮组 -->
|
||||||
|
<div class="mt-6 flex items-center justify-center gap-6">
|
||||||
|
<ElTooltip :content="$t('chat.startChat')" placement="bottom">
|
||||||
|
<button class="action-btn" @click="emit('startChat', userDetail)">
|
||||||
|
<MessageSquare class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip :content="$t('chat.contactOrg')" placement="bottom">
|
||||||
|
<button class="action-btn" @click="profileDialogVisible = true">
|
||||||
|
<Network class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</ElTooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 组织架构弹窗 -->
|
||||||
|
<UserProfileDialog
|
||||||
|
v-if="userId"
|
||||||
|
v-model="profileDialogVisible"
|
||||||
|
:user-id="userId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--primary {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--primary:hover {
|
||||||
|
background: var(--el-color-primary-light-3);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 48px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
text-align: right;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { User } from '#/api/core/user';
|
||||||
|
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Building2, Loader2, MessageSquare, Search } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElEmpty, ElInput, ElScrollbar, ElSkeletonItem } from 'element-plus';
|
||||||
|
|
||||||
|
import { getUserListApi } from '#/api/core/user';
|
||||||
|
import UserAvatar from '#/components/user-avatar/index.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
onlineUsers?: Set<string>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
startChat: [user: User];
|
||||||
|
viewOrg: [user: User];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const users = ref<User[]>([]);
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const hasMore = ref(true);
|
||||||
|
const scrollbarRef = ref<InstanceType<typeof ElScrollbar>>();
|
||||||
|
|
||||||
|
const filteredUsers = computed(() => {
|
||||||
|
const kw = searchKeyword.value.trim().toLowerCase();
|
||||||
|
if (!kw) return users.value;
|
||||||
|
return users.value.filter((u) => {
|
||||||
|
const name = (u.name || u.username || '').toLowerCase();
|
||||||
|
const dept = (u.dept_name || '').toLowerCase();
|
||||||
|
return name.includes(kw) || dept.includes(kw);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按部门分组
|
||||||
|
const groupedUsers = computed(() => {
|
||||||
|
const groups: Record<string, User[]> = {};
|
||||||
|
for (const u of filteredUsers.value) {
|
||||||
|
const dept = u.dept_name || $t('chat.noContacts');
|
||||||
|
if (!groups[dept]) groups[dept] = [];
|
||||||
|
groups[dept].push(u);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadUsers(page = 1) {
|
||||||
|
if (page === 1) {
|
||||||
|
loading.value = true;
|
||||||
|
} else {
|
||||||
|
loadingMore.value = true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await getUserListApi({
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
user_status: 1,
|
||||||
|
});
|
||||||
|
const items = res?.items || [];
|
||||||
|
users.value = page === 1 ? items : [...users.value, ...items];
|
||||||
|
currentPage.value = page;
|
||||||
|
hasMore.value = items.length >= PAGE_SIZE;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载联系人失败:', error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
const wrap = scrollbarRef.value?.$el?.querySelector('.el-scrollbar__wrap');
|
||||||
|
if (!wrap) return;
|
||||||
|
if (
|
||||||
|
wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < 50 &&
|
||||||
|
hasMore.value &&
|
||||||
|
!loadingMore.value &&
|
||||||
|
!loading.value &&
|
||||||
|
!searchKeyword.value.trim()
|
||||||
|
) {
|
||||||
|
loadUsers(currentPage.value + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索时重置
|
||||||
|
watch(searchKeyword, () => {
|
||||||
|
// 搜索仅在已加载数据中过滤,不重新请求
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadUsers(1);
|
||||||
|
document.addEventListener('click', onDocumentClick);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', onDocumentClick);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 右键菜单 ----
|
||||||
|
const contextMenu = ref<{
|
||||||
|
user: null | User;
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}>({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleContextMenu(e: MouseEvent, user: User) {
|
||||||
|
e.preventDefault();
|
||||||
|
contextMenu.value = {
|
||||||
|
visible: true,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeContextMenu() {
|
||||||
|
contextMenu.value.visible = false;
|
||||||
|
contextMenu.value.user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentClick() {
|
||||||
|
if (contextMenu.value.visible) {
|
||||||
|
closeContextMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<!-- 搜索 -->
|
||||||
|
<div class="shrink-0 px-3 pb-2 pt-3">
|
||||||
|
<ElInput
|
||||||
|
v-model="searchKeyword"
|
||||||
|
:placeholder="$t('chat.searchContacts')"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Search class="h-3.5 w-3.5 text-[var(--el-text-color-placeholder)]" />
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<ElScrollbar ref="scrollbarRef" class="flex-1" @scroll="handleScroll">
|
||||||
|
<!-- 骨架屏 -->
|
||||||
|
<template v-if="loading">
|
||||||
|
<div v-for="i in 8" :key="i" class="flex items-center gap-3 px-3 py-2">
|
||||||
|
<ElSkeletonItem variant="circle" style="width: 36px; height: 36px" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<ElSkeletonItem variant="text" style="width: 50%" />
|
||||||
|
<ElSkeletonItem
|
||||||
|
variant="text"
|
||||||
|
style="width: 30%; margin-top: 4px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<ElEmpty
|
||||||
|
v-else-if="filteredUsers.length === 0"
|
||||||
|
:description="$t('chat.noContacts')"
|
||||||
|
:image-size="80"
|
||||||
|
class="mt-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 按部门分组的联系人列表 -->
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="(groupUsers, deptName) in groupedUsers"
|
||||||
|
:key="deptName"
|
||||||
|
class="mb-1"
|
||||||
|
>
|
||||||
|
<!-- 部门标题 -->
|
||||||
|
<div class="sticky top-0 z-10 bg-[var(--el-bg-color)] px-3 py-1.5">
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium text-[var(--el-text-color-secondary)]"
|
||||||
|
>
|
||||||
|
{{ deptName }} ({{ groupUsers.length }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户列表 -->
|
||||||
|
<div
|
||||||
|
v-for="user in groupUsers"
|
||||||
|
:key="user.id"
|
||||||
|
class="flex cursor-pointer items-center gap-3 px-3 py-2 transition-colors hover:bg-[var(--el-fill-color-light)]"
|
||||||
|
@click="emit('startChat', user)"
|
||||||
|
@contextmenu="handleContextMenu($event, user)"
|
||||||
|
>
|
||||||
|
<!-- 头像 -->
|
||||||
|
<div class="relative shrink-0">
|
||||||
|
<UserAvatar
|
||||||
|
:user-id="user.id"
|
||||||
|
:name="user.name || user.username"
|
||||||
|
:avatar="user.avatar"
|
||||||
|
:size="36"
|
||||||
|
:font-size="14"
|
||||||
|
:shadow="false"
|
||||||
|
:show-popover="false"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="absolute bottom-0 right-0 h-2 w-2 rounded-full border-[1.5px] border-[var(--el-bg-color)]"
|
||||||
|
:class="
|
||||||
|
props.onlineUsers?.has(user.id)
|
||||||
|
? 'bg-[var(--el-color-success)]'
|
||||||
|
: 'bg-[var(--el-text-color-placeholder)]'
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 信息 -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate text-sm text-[var(--el-text-color-primary)]">
|
||||||
|
{{ user.name || user.username }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="user.dept_name"
|
||||||
|
class="mt-0.5 truncate text-xs text-[var(--el-text-color-placeholder)]"
|
||||||
|
>
|
||||||
|
{{ user.dept_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载更多提示 -->
|
||||||
|
<div
|
||||||
|
v-if="loadingMore"
|
||||||
|
class="flex items-center justify-center gap-1 py-3 text-xs text-[var(--el-text-color-placeholder)]"
|
||||||
|
>
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
{{ $t('chat.loading') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!hasMore && users.length > 0"
|
||||||
|
class="py-3 text-center text-xs text-[var(--el-text-color-placeholder)]"
|
||||||
|
>
|
||||||
|
-- {{ $t('chat.noContacts') }} --
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElScrollbar>
|
||||||
|
|
||||||
|
<!-- 右键菜单 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="ctx-menu">
|
||||||
|
<div
|
||||||
|
v-if="contextMenu.visible && contextMenu.user"
|
||||||
|
class="contact-context-menu"
|
||||||
|
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
|
||||||
|
@contextmenu.prevent
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="contact-context-menu-item"
|
||||||
|
@click="
|
||||||
|
emit('startChat', contextMenu.user!);
|
||||||
|
closeContextMenu();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<MessageSquare class="h-4 w-4" />
|
||||||
|
<span>{{ $t('chat.startChat') }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="contact-context-menu-item"
|
||||||
|
@click="
|
||||||
|
emit('viewOrg', contextMenu.user!);
|
||||||
|
closeContextMenu();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Building2 class="h-4 w-4" />
|
||||||
|
<span>{{ $t('chat.orgStructure') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.contact-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: var(--el-bg-color-overlay);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--el-box-shadow-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-context-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-context-menu-item:hover {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-menu-enter-active {
|
||||||
|
transition: all 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-menu-leave-active {
|
||||||
|
transition: all 0.1s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-menu-enter-from,
|
||||||
|
.ctx-menu-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Conversation, ConversationMember } from '#/api/core/chat';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { BellOff, Pin, PinOff, Trash2, UserPlus, Users } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
import { useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElDivider,
|
||||||
|
ElMessageBox,
|
||||||
|
ElScrollbar,
|
||||||
|
ElTag,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import UserAvatar from '#/components/user-avatar/index.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
conversation: Conversation | null;
|
||||||
|
loading?: boolean;
|
||||||
|
members: ConversationMember[];
|
||||||
|
onlineUsers?: Set<string>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
addMember: [];
|
||||||
|
dissolve: [];
|
||||||
|
removeMember: [userId: string];
|
||||||
|
toggleMute: [value: boolean];
|
||||||
|
togglePin: [value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const currentUserId = userStore.userInfo?.userId || '';
|
||||||
|
|
||||||
|
const isOwner = computed(() => {
|
||||||
|
return props.conversation?.owner_id === currentUserId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isGroup = computed(() => {
|
||||||
|
return props.conversation?.type === 'group';
|
||||||
|
});
|
||||||
|
|
||||||
|
function getRoleLabel(role: string): string {
|
||||||
|
if (role === 'owner') return $t('chat.owner');
|
||||||
|
if (role === 'admin') return $t('chat.admin');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveMember(userId: string) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm($t('chat.removeMemberConfirm'), {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: $t('common.confirm'),
|
||||||
|
cancelButtonText: $t('common.cancel'),
|
||||||
|
});
|
||||||
|
emit('removeMember', userId);
|
||||||
|
} catch {
|
||||||
|
// cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDissolve() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm($t('chat.dissolveGroupConfirm'), {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: $t('common.confirm'),
|
||||||
|
cancelButtonText: $t('common.cancel'),
|
||||||
|
});
|
||||||
|
emit('dissolve');
|
||||||
|
} catch {
|
||||||
|
// cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="conversation" class="flex h-full flex-col">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 border-b border-[var(--el-border-color-lighter)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<h3 class="text-sm font-medium text-[var(--el-text-color-primary)]">
|
||||||
|
{{ $t('chat.conversationInfo') }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElScrollbar class="flex-1">
|
||||||
|
<div class="p-4">
|
||||||
|
<!-- 会话设置 -->
|
||||||
|
<div class="mb-4 flex flex-col gap-2">
|
||||||
|
<ElButton
|
||||||
|
text
|
||||||
|
class="!justify-start"
|
||||||
|
@click="emit('togglePin', !conversation.is_pinned)"
|
||||||
|
>
|
||||||
|
<Pin v-if="!conversation.is_pinned" class="mr-2 h-4 w-4" />
|
||||||
|
<PinOff v-else class="mr-2 h-4 w-4" />
|
||||||
|
{{ conversation.is_pinned ? $t('chat.unpin') : $t('chat.pin') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
text
|
||||||
|
class="!justify-start"
|
||||||
|
@click="emit('toggleMute', !conversation.is_muted)"
|
||||||
|
>
|
||||||
|
<BellOff class="mr-2 h-4 w-4" />
|
||||||
|
{{ conversation.is_muted ? $t('chat.unmute') : $t('chat.mute') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElDivider />
|
||||||
|
|
||||||
|
<!-- 成员列表 -->
|
||||||
|
<div v-if="isGroup" class="mb-4">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium text-[var(--el-text-color-primary)]"
|
||||||
|
>
|
||||||
|
<Users class="mr-1 inline h-4 w-4" />
|
||||||
|
{{ $t('chat.members') }} ({{ members.length }})
|
||||||
|
</span>
|
||||||
|
<ElButton
|
||||||
|
v-if="isOwner"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
@click="emit('addMember')"
|
||||||
|
>
|
||||||
|
<UserPlus class="h-4 w-4" />
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div
|
||||||
|
v-for="m in members"
|
||||||
|
:key="m.id"
|
||||||
|
class="group flex items-center gap-2 rounded px-2 py-1.5 hover:bg-[var(--el-fill-color-light)]"
|
||||||
|
>
|
||||||
|
<div class="relative shrink-0">
|
||||||
|
<UserAvatar
|
||||||
|
:user-id="m.user_id"
|
||||||
|
:name="m.user_name"
|
||||||
|
:avatar="m.user_avatar"
|
||||||
|
:size="28"
|
||||||
|
:font-size="12"
|
||||||
|
:shadow="false"
|
||||||
|
:show-popover="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="absolute bottom-0 right-0 h-2 w-2 rounded-full border-[1.5px] border-[var(--el-bg-color)]"
|
||||||
|
:class="
|
||||||
|
props.onlineUsers?.has(m.user_id)
|
||||||
|
? 'bg-[var(--el-color-success)]'
|
||||||
|
: 'bg-[var(--el-text-color-placeholder)]'
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="flex-1 truncate text-sm text-[var(--el-text-color-primary)]"
|
||||||
|
>
|
||||||
|
{{ m.user_name || m.user_id }}
|
||||||
|
</span>
|
||||||
|
<ElTag v-if="getRoleLabel(m.role)" size="small" type="warning">
|
||||||
|
{{ getRoleLabel(m.role) }}
|
||||||
|
</ElTag>
|
||||||
|
<Trash2
|
||||||
|
v-if="isOwner && m.user_id !== currentUserId"
|
||||||
|
class="h-3.5 w-3.5 shrink-0 cursor-pointer text-[var(--el-text-color-placeholder)] opacity-0 transition-opacity hover:text-[var(--el-color-danger)] group-hover:opacity-100"
|
||||||
|
@click="handleRemoveMember(m.user_id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 解散群聊 -->
|
||||||
|
<div v-if="isGroup && isOwner">
|
||||||
|
<ElDivider />
|
||||||
|
<ElButton
|
||||||
|
type="danger"
|
||||||
|
text
|
||||||
|
class="w-full !justify-start"
|
||||||
|
@click="handleDissolve"
|
||||||
|
>
|
||||||
|
<Trash2 class="mr-2 h-4 w-4" />
|
||||||
|
{{ $t('chat.dissolveGroup') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Conversation } from '#/api/core/chat';
|
||||||
|
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { BellOff, Eye, Pin, PinOff, Search, Trash2, Users } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import {
|
||||||
|
ElBadge,
|
||||||
|
ElEmpty,
|
||||||
|
ElInput,
|
||||||
|
ElScrollbar,
|
||||||
|
ElSkeletonItem,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import UserAvatar from '#/components/user-avatar/index.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
conversations: Conversation[];
|
||||||
|
currentId?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
onlineUsers?: Set<string>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
delete: [conv: Conversation];
|
||||||
|
markUnread: [conv: Conversation];
|
||||||
|
select: [conv: Conversation];
|
||||||
|
toggleMute: [conv: Conversation, value: boolean];
|
||||||
|
togglePin: [conv: Conversation, value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
|
||||||
|
const filteredConversations = computed(() => {
|
||||||
|
let list = [...props.conversations];
|
||||||
|
const kw = searchKeyword.value.trim().toLowerCase();
|
||||||
|
if (kw) {
|
||||||
|
list = list.filter((c) => getDisplayName(c).toLowerCase().includes(kw));
|
||||||
|
}
|
||||||
|
list.sort((a, b) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getDisplayName(conv: Conversation): string {
|
||||||
|
if (conv.type === 'private') {
|
||||||
|
return conv.peer_user_name || $t('chat.private');
|
||||||
|
}
|
||||||
|
return conv.name || $t('chat.group');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvatar(conv: Conversation): string | undefined {
|
||||||
|
if (conv.type === 'private') {
|
||||||
|
return conv.peer_user_avatar || undefined;
|
||||||
|
}
|
||||||
|
return conv.avatar || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time?: string): string {
|
||||||
|
if (!time) return '';
|
||||||
|
const d = dayjs(time);
|
||||||
|
const now = dayjs();
|
||||||
|
if (d.isSame(now, 'day')) return d.format('HH:mm');
|
||||||
|
if (d.isSame(now.subtract(1, 'day'), 'day')) return $t('chat.yesterday');
|
||||||
|
if (d.isSame(now, 'year')) return d.format('MM/DD');
|
||||||
|
return d.format('YYYY/MM/DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 右键菜单 ----
|
||||||
|
const contextMenu = ref<{
|
||||||
|
conversation: Conversation | null;
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}>({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
conversation: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleContextMenu(e: MouseEvent, conv: Conversation) {
|
||||||
|
e.preventDefault();
|
||||||
|
contextMenu.value = {
|
||||||
|
visible: true,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
conversation: conv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeContextMenu() {
|
||||||
|
contextMenu.value.visible = false;
|
||||||
|
contextMenu.value.conversation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentClick() {
|
||||||
|
if (contextMenu.value.visible) {
|
||||||
|
closeContextMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', onDocumentClick);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', onDocumentClick);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<!-- 搜索 -->
|
||||||
|
<div class="shrink-0 px-3 pb-2 pt-3">
|
||||||
|
<ElInput
|
||||||
|
v-model="searchKeyword"
|
||||||
|
:placeholder="$t('chat.search')"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Search class="h-3.5 w-3.5 text-[var(--el-text-color-placeholder)]" />
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<ElScrollbar class="flex-1">
|
||||||
|
<!-- 骨架屏 -->
|
||||||
|
<template v-if="loading">
|
||||||
|
<div
|
||||||
|
v-for="i in 6"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<ElSkeletonItem variant="circle" style="width: 40px; height: 40px" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<ElSkeletonItem variant="text" style="width: 60%" />
|
||||||
|
<ElSkeletonItem
|
||||||
|
variant="text"
|
||||||
|
style="width: 80%; margin-top: 6px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<ElEmpty
|
||||||
|
v-else-if="filteredConversations.length === 0"
|
||||||
|
:description="$t('chat.noConversations')"
|
||||||
|
:image-size="80"
|
||||||
|
class="mt-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 会话列表 -->
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="conv in filteredConversations"
|
||||||
|
:key="conv.id"
|
||||||
|
class="group flex cursor-pointer items-center gap-3 px-3 py-2.5 transition-colors hover:bg-[var(--el-fill-color-light)]"
|
||||||
|
:class="{
|
||||||
|
'bg-[var(--el-color-primary-light-9)]': currentId === conv.id,
|
||||||
|
}"
|
||||||
|
@click="emit('select', conv)"
|
||||||
|
@contextmenu="handleContextMenu($event, conv)"
|
||||||
|
>
|
||||||
|
<!-- 头像 -->
|
||||||
|
<div
|
||||||
|
v-if="conv.type === 'private' && conv.peer_user_id"
|
||||||
|
class="relative shrink-0"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
:user-id="conv.peer_user_id"
|
||||||
|
:name="conv.peer_user_name"
|
||||||
|
:avatar="conv.peer_user_avatar"
|
||||||
|
:size="40"
|
||||||
|
:font-size="16"
|
||||||
|
:shadow="false"
|
||||||
|
:show-popover="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border-2 border-[var(--el-bg-color)]"
|
||||||
|
:class="
|
||||||
|
props.onlineUsers?.has(conv.peer_user_id)
|
||||||
|
? 'bg-[var(--el-color-success)]'
|
||||||
|
: 'bg-[var(--el-text-color-placeholder)]'
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex shrink-0 items-center justify-center rounded-full bg-[var(--el-color-primary-light-7)] text-white"
|
||||||
|
:style="{ width: '40px', height: '40px' }"
|
||||||
|
>
|
||||||
|
<Users class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 信息 -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
class="truncate text-sm font-medium text-[var(--el-text-color-primary)]"
|
||||||
|
>
|
||||||
|
{{ getDisplayName(conv) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="ml-2 shrink-0 text-xs text-[var(--el-text-color-placeholder)]"
|
||||||
|
>
|
||||||
|
{{ formatTime(conv.last_message_time) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 flex items-center justify-between gap-1">
|
||||||
|
<div class="flex min-w-0 items-center gap-1">
|
||||||
|
<Pin
|
||||||
|
v-if="conv.is_pinned"
|
||||||
|
class="h-3 w-3 shrink-0 text-[var(--el-color-primary)]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="truncate text-xs text-[var(--el-text-color-secondary)]"
|
||||||
|
>
|
||||||
|
{{ conv.last_message_preview || '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<BellOff
|
||||||
|
v-if="conv.is_muted"
|
||||||
|
class="h-3 w-3 text-[var(--el-text-color-placeholder)]"
|
||||||
|
/>
|
||||||
|
<ElBadge
|
||||||
|
v-if="conv.unread_count > 0"
|
||||||
|
:value="conv.unread_count"
|
||||||
|
:max="99"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElScrollbar>
|
||||||
|
|
||||||
|
<!-- 右键菜单 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="ctx-menu">
|
||||||
|
<div
|
||||||
|
v-if="contextMenu.visible && contextMenu.conversation"
|
||||||
|
class="conv-context-menu"
|
||||||
|
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
|
||||||
|
@contextmenu.prevent
|
||||||
|
>
|
||||||
|
<!-- 置顶/取消置顶 -->
|
||||||
|
<div
|
||||||
|
class="conv-context-menu-item"
|
||||||
|
@click="
|
||||||
|
emit(
|
||||||
|
'togglePin',
|
||||||
|
contextMenu.conversation!,
|
||||||
|
!contextMenu.conversation!.is_pinned,
|
||||||
|
);
|
||||||
|
closeContextMenu();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PinOff
|
||||||
|
v-if="contextMenu.conversation!.is_pinned"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<Pin v-else class="h-4 w-4" />
|
||||||
|
<span>{{
|
||||||
|
contextMenu.conversation!.is_pinned
|
||||||
|
? $t('chat.unpin')
|
||||||
|
: $t('chat.pin')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 标记未读 -->
|
||||||
|
<div
|
||||||
|
class="conv-context-menu-item"
|
||||||
|
@click="
|
||||||
|
emit('markUnread', contextMenu.conversation!);
|
||||||
|
closeContextMenu();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
<span>{{ $t('chat.markUnread') }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 免打扰/取消免打扰 -->
|
||||||
|
<div
|
||||||
|
class="conv-context-menu-item"
|
||||||
|
@click="
|
||||||
|
emit(
|
||||||
|
'toggleMute',
|
||||||
|
contextMenu.conversation!,
|
||||||
|
!contextMenu.conversation!.is_muted,
|
||||||
|
);
|
||||||
|
closeContextMenu();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<BellOff class="h-4 w-4" />
|
||||||
|
<span>{{
|
||||||
|
contextMenu.conversation!.is_muted
|
||||||
|
? $t('chat.unmute')
|
||||||
|
: $t('chat.mute')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 删除记录 -->
|
||||||
|
<div
|
||||||
|
class="conv-context-menu-item conv-context-menu-item--danger"
|
||||||
|
@click="
|
||||||
|
emit('delete', contextMenu.conversation!);
|
||||||
|
closeContextMenu();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
<span>{{ $t('chat.deleteConversation') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.conv-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: var(--el-bg-color-overlay);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--el-box-shadow-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-context-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-context-menu-item:hover {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-context-menu-item--danger:hover {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
background: var(--el-color-danger-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-menu-enter-active {
|
||||||
|
transition: all 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-menu-leave-active {
|
||||||
|
transition: all 0.1s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-menu-enter-from,
|
||||||
|
.ctx-menu-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElButton, ElForm, ElFormItem, ElInput } from 'element-plus';
|
||||||
|
|
||||||
|
import { ZqDialog } from '#/components/zq-dialog';
|
||||||
|
import { UserSelector } from '#/components/zq-form/user-selector';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
confirm: [name: string, memberIds: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ default: false });
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const loading = ref(false);
|
||||||
|
const formData = ref({
|
||||||
|
name: '',
|
||||||
|
member_ids: [] as string[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = computed<FormRules>(() => ({
|
||||||
|
name: [
|
||||||
|
{ required: true, message: $t('chat.groupNameRequired'), trigger: 'blur' },
|
||||||
|
],
|
||||||
|
member_ids: [
|
||||||
|
{ required: true, message: $t('chat.membersRequired'), trigger: 'change' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
formData.value = { name: '', member_ids: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (!formRef.value) return;
|
||||||
|
try {
|
||||||
|
await formRef.value.validate();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
emit('confirm', formData.value.name, formData.value.member_ids);
|
||||||
|
visible.value = false;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ZqDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="$t('chat.newGroup')"
|
||||||
|
width="500px"
|
||||||
|
@open="handleOpen"
|
||||||
|
>
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="rules"
|
||||||
|
label-width="90px"
|
||||||
|
label-position="left"
|
||||||
|
>
|
||||||
|
<ElFormItem :label="$t('chat.groupName')" prop="name">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.name"
|
||||||
|
:placeholder="$t('chat.groupNamePlaceholder')"
|
||||||
|
maxlength="100"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('chat.selectMembers')" prop="member_ids">
|
||||||
|
<UserSelector
|
||||||
|
v-model="formData.member_ids"
|
||||||
|
multiple
|
||||||
|
:placeholder="$t('chat.selectMembersPlaceholder')"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="visible = false">{{ $t('common.cancel') }}</ElButton>
|
||||||
|
<ElButton type="primary" :loading="loading" @click="handleConfirm">
|
||||||
|
{{ $t('common.confirm') }}
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ZqDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { ElScrollbar } from 'element-plus';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [emoji: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const activeCategory = ref(0);
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{
|
||||||
|
key: 'smileys',
|
||||||
|
icon: '😀',
|
||||||
|
emojis: [
|
||||||
|
'😀',
|
||||||
|
'😃',
|
||||||
|
'😄',
|
||||||
|
'😁',
|
||||||
|
'😆',
|
||||||
|
'😅',
|
||||||
|
'🤣',
|
||||||
|
'😂',
|
||||||
|
'🙂',
|
||||||
|
'😉',
|
||||||
|
'😊',
|
||||||
|
'😇',
|
||||||
|
'🥰',
|
||||||
|
'😍',
|
||||||
|
'🤩',
|
||||||
|
'😘',
|
||||||
|
'😗',
|
||||||
|
'😚',
|
||||||
|
'😙',
|
||||||
|
'🥲',
|
||||||
|
'😋',
|
||||||
|
'😛',
|
||||||
|
'😜',
|
||||||
|
'🤪',
|
||||||
|
'😝',
|
||||||
|
'🤑',
|
||||||
|
'🤗',
|
||||||
|
'🤭',
|
||||||
|
'🤫',
|
||||||
|
'🤔',
|
||||||
|
'🫡',
|
||||||
|
'🤐',
|
||||||
|
'🤨',
|
||||||
|
'😐',
|
||||||
|
'😑',
|
||||||
|
'😶',
|
||||||
|
'🫥',
|
||||||
|
'😏',
|
||||||
|
'😒',
|
||||||
|
'🙄',
|
||||||
|
'😬',
|
||||||
|
'🤥',
|
||||||
|
'😌',
|
||||||
|
'😔',
|
||||||
|
'😪',
|
||||||
|
'🤤',
|
||||||
|
'😴',
|
||||||
|
'😷',
|
||||||
|
'🤒',
|
||||||
|
'🤕',
|
||||||
|
'🤢',
|
||||||
|
'🤮',
|
||||||
|
'🥵',
|
||||||
|
'🥶',
|
||||||
|
'🥴',
|
||||||
|
'😵',
|
||||||
|
'🤯',
|
||||||
|
'🤠',
|
||||||
|
'🥳',
|
||||||
|
'🥸',
|
||||||
|
'😎',
|
||||||
|
'🤓',
|
||||||
|
'🧐',
|
||||||
|
'😕',
|
||||||
|
'🫤',
|
||||||
|
'😟',
|
||||||
|
'🙁',
|
||||||
|
'😮',
|
||||||
|
'😯',
|
||||||
|
'😲',
|
||||||
|
'😳',
|
||||||
|
'🥺',
|
||||||
|
'🥹',
|
||||||
|
'😦',
|
||||||
|
'😧',
|
||||||
|
'😨',
|
||||||
|
'😰',
|
||||||
|
'😥',
|
||||||
|
'😢',
|
||||||
|
'😭',
|
||||||
|
'😱',
|
||||||
|
'😖',
|
||||||
|
'😣',
|
||||||
|
'😞',
|
||||||
|
'😓',
|
||||||
|
'😩',
|
||||||
|
'😫',
|
||||||
|
'🥱',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gestures',
|
||||||
|
icon: '👋',
|
||||||
|
emojis: [
|
||||||
|
'👋',
|
||||||
|
'🤚',
|
||||||
|
'🖐️',
|
||||||
|
'✋',
|
||||||
|
'🖖',
|
||||||
|
'🫱',
|
||||||
|
'🫲',
|
||||||
|
'🫳',
|
||||||
|
'🫴',
|
||||||
|
'👌',
|
||||||
|
'🤌',
|
||||||
|
'🤏',
|
||||||
|
'✌️',
|
||||||
|
'🤞',
|
||||||
|
'🫰',
|
||||||
|
'🤟',
|
||||||
|
'🤘',
|
||||||
|
'🤙',
|
||||||
|
'👈',
|
||||||
|
'👉',
|
||||||
|
'👆',
|
||||||
|
'🖕',
|
||||||
|
'👇',
|
||||||
|
'☝️',
|
||||||
|
'🫵',
|
||||||
|
'👍',
|
||||||
|
'👎',
|
||||||
|
'✊',
|
||||||
|
'👊',
|
||||||
|
'🤛',
|
||||||
|
'🤜',
|
||||||
|
'👏',
|
||||||
|
'🙌',
|
||||||
|
'🫶',
|
||||||
|
'👐',
|
||||||
|
'🤲',
|
||||||
|
'🤝',
|
||||||
|
'🙏',
|
||||||
|
'💪',
|
||||||
|
'🦾',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hearts',
|
||||||
|
icon: '❤️',
|
||||||
|
emojis: [
|
||||||
|
'❤️',
|
||||||
|
'🧡',
|
||||||
|
'💛',
|
||||||
|
'💚',
|
||||||
|
'💙',
|
||||||
|
'💜',
|
||||||
|
'🖤',
|
||||||
|
'🤍',
|
||||||
|
'🤎',
|
||||||
|
'💔',
|
||||||
|
'❤️🔥',
|
||||||
|
'❤️🩹',
|
||||||
|
'❣️',
|
||||||
|
'💕',
|
||||||
|
'💞',
|
||||||
|
'💓',
|
||||||
|
'💗',
|
||||||
|
'💖',
|
||||||
|
'💘',
|
||||||
|
'💝',
|
||||||
|
'💟',
|
||||||
|
'♥️',
|
||||||
|
'💋',
|
||||||
|
'💯',
|
||||||
|
'💢',
|
||||||
|
'💥',
|
||||||
|
'💫',
|
||||||
|
'💦',
|
||||||
|
'💨',
|
||||||
|
'🕳️',
|
||||||
|
'💣',
|
||||||
|
'💬',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'objects',
|
||||||
|
icon: '🎉',
|
||||||
|
emojis: [
|
||||||
|
'🎉',
|
||||||
|
'🎊',
|
||||||
|
'🎈',
|
||||||
|
'🎁',
|
||||||
|
'🎀',
|
||||||
|
'🏆',
|
||||||
|
'🥇',
|
||||||
|
'🥈',
|
||||||
|
'🥉',
|
||||||
|
'⚽',
|
||||||
|
'🏀',
|
||||||
|
'🎯',
|
||||||
|
'🎮',
|
||||||
|
'🎲',
|
||||||
|
'🧩',
|
||||||
|
'🎵',
|
||||||
|
'🎶',
|
||||||
|
'🔔',
|
||||||
|
'📢',
|
||||||
|
'💡',
|
||||||
|
'🔥',
|
||||||
|
'⭐',
|
||||||
|
'🌟',
|
||||||
|
'✨',
|
||||||
|
'⚡',
|
||||||
|
'☀️',
|
||||||
|
'🌈',
|
||||||
|
'☁️',
|
||||||
|
'❄️',
|
||||||
|
'🌸',
|
||||||
|
'🍀',
|
||||||
|
'🌺',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'food',
|
||||||
|
icon: '🍕',
|
||||||
|
emojis: [
|
||||||
|
'🍕',
|
||||||
|
'🍔',
|
||||||
|
'🍟',
|
||||||
|
'🌭',
|
||||||
|
'🍿',
|
||||||
|
'🧁',
|
||||||
|
'🍰',
|
||||||
|
'🎂',
|
||||||
|
'🍩',
|
||||||
|
'🍪',
|
||||||
|
'🍫',
|
||||||
|
'🍬',
|
||||||
|
'🍭',
|
||||||
|
'☕',
|
||||||
|
'🍵',
|
||||||
|
'🧋',
|
||||||
|
'🍺',
|
||||||
|
'🍻',
|
||||||
|
'🥂',
|
||||||
|
'🍷',
|
||||||
|
'🍸',
|
||||||
|
'🍹',
|
||||||
|
'🧃',
|
||||||
|
'🍎',
|
||||||
|
'🍊',
|
||||||
|
'🍋',
|
||||||
|
'🍌',
|
||||||
|
'🍉',
|
||||||
|
'🍇',
|
||||||
|
'🍓',
|
||||||
|
'🫐',
|
||||||
|
'🍑',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleSelect(emoji: string) {
|
||||||
|
emit('select', emoji);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="emoji-picker">
|
||||||
|
<!-- 分类标签 -->
|
||||||
|
<div class="emoji-tabs">
|
||||||
|
<button
|
||||||
|
v-for="(cat, idx) in categories"
|
||||||
|
:key="cat.key"
|
||||||
|
class="emoji-tab"
|
||||||
|
:class="{ 'emoji-tab--active': activeCategory === idx }"
|
||||||
|
@click="activeCategory = idx"
|
||||||
|
>
|
||||||
|
{{ cat.icon }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- 表情网格 -->
|
||||||
|
<ElScrollbar height="200px">
|
||||||
|
<div class="emoji-grid">
|
||||||
|
<button
|
||||||
|
v-for="emoji in categories[activeCategory]!.emojis"
|
||||||
|
:key="emoji"
|
||||||
|
class="emoji-item"
|
||||||
|
:title="emoji"
|
||||||
|
@click="handleSelect(emoji)"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.emoji-picker {
|
||||||
|
width: 320px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-tab:hover {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-tab--active {
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-item:hover {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<IDBDatabase> = null;
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
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<T>(
|
||||||
|
storeName: string,
|
||||||
|
mode: IDBTransactionMode,
|
||||||
|
fn: (store: IDBObjectStore) => IDBRequest<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
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<Conversation[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void>((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<void>((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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>((resolve, reject) => {
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCachedMessages(
|
||||||
|
conversationId: string,
|
||||||
|
msgs: ChatMessage[],
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<null | string> {
|
||||||
|
try {
|
||||||
|
const result = await withStore<any>(STORE_META, 'readonly', (store) =>
|
||||||
|
store.get(`sync_${userId}`),
|
||||||
|
);
|
||||||
|
return result?.value || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLastSyncTime(userId: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>((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<void> {
|
||||||
|
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<string>();
|
||||||
|
await new Promise<void>((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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void>((resolve, reject) => {
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ChatStorage] Failed to clear cache:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Conversation[]>([]);
|
||||||
|
const currentConversation = ref<Conversation | null>(null);
|
||||||
|
const messages = ref<ChatMessage[]>([]);
|
||||||
|
const members = ref<ConversationMember[]>([]);
|
||||||
|
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<Map<string, string>>(new Map());
|
||||||
|
// 待跳转的会话ID(从其他页面点击通知后跳转到聊天页时使用)
|
||||||
|
const pendingConversationId = ref<null | string>(null);
|
||||||
|
|
||||||
|
const unreadChatMessages = ref<ChatMessage[]>([]);
|
||||||
|
const onlineUsers = ref<Set<string>>(new Set());
|
||||||
|
|
||||||
|
let wsManager: null | WebSocketManager = null;
|
||||||
|
const typingTimers: Map<string, ReturnType<typeof setTimeout>> = 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<string, any>,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<typeof setTimeout>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
// 静默降级
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof setInterval> = 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<boolean> {
|
||||||
|
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<null | { blob: Blob; duration: number }> {
|
||||||
|
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')}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChatMessage, Conversation } from '#/api/core/chat';
|
||||||
|
import type { User } from '#/api/core/user';
|
||||||
|
|
||||||
|
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CirclePlus,
|
||||||
|
Contact,
|
||||||
|
MessageSquare,
|
||||||
|
PanelRight,
|
||||||
|
Users,
|
||||||
|
} from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
import { useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElDropdown,
|
||||||
|
ElDropdownItem,
|
||||||
|
ElDropdownMenu,
|
||||||
|
ElEmpty,
|
||||||
|
ElMessage,
|
||||||
|
ElMessageBox,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addMembersApi,
|
||||||
|
deleteConversationApi,
|
||||||
|
removeMemberApi,
|
||||||
|
} from '#/api/core/chat';
|
||||||
|
import { UserAvatar } from '#/components/user-avatar';
|
||||||
|
|
||||||
|
import AddMemberDialog from './components/AddMemberDialog.vue';
|
||||||
|
import ChatInput from './components/ChatInput.vue';
|
||||||
|
import ContactDetail from './components/ContactDetail.vue';
|
||||||
|
import ContactList from './components/ContactList.vue';
|
||||||
|
import ConversationInfo from './components/ConversationInfo.vue';
|
||||||
|
import ConversationList from './components/ConversationList.vue';
|
||||||
|
import CreateGroupDialog from './components/CreateGroupDialog.vue';
|
||||||
|
import MessageList from './components/MessageList.vue';
|
||||||
|
import { useChat } from './composables/useChat';
|
||||||
|
|
||||||
|
const {
|
||||||
|
conversations,
|
||||||
|
currentConversation,
|
||||||
|
messages,
|
||||||
|
members,
|
||||||
|
hasMoreMessages,
|
||||||
|
loadingConversations,
|
||||||
|
loadingMessages,
|
||||||
|
loadingMembers,
|
||||||
|
sending,
|
||||||
|
typingUsers,
|
||||||
|
pendingConversationId,
|
||||||
|
connectChat,
|
||||||
|
loadConversations,
|
||||||
|
selectConversation,
|
||||||
|
loadMessages,
|
||||||
|
loadMembers,
|
||||||
|
sendMessage,
|
||||||
|
sendTyping,
|
||||||
|
recallMessage,
|
||||||
|
createPrivateChat,
|
||||||
|
createGroupChat,
|
||||||
|
togglePin,
|
||||||
|
toggleMute,
|
||||||
|
onlineUsers,
|
||||||
|
loadOnlineUsers,
|
||||||
|
} = useChat();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
// 左侧菜单 Tab: chats / contacts
|
||||||
|
const activeTab = ref<'chats' | 'contacts'>('chats');
|
||||||
|
const showInfo = ref(false);
|
||||||
|
const selectedContactId = ref<string>();
|
||||||
|
const showCreateGroup = ref(false);
|
||||||
|
const showAddMember = ref(false);
|
||||||
|
const messageListRef = ref<InstanceType<typeof MessageList>>();
|
||||||
|
const replyTo = ref<ChatMessage | null>(null);
|
||||||
|
|
||||||
|
const typingText = computed(() => {
|
||||||
|
if (typingUsers.value.size === 0) return '';
|
||||||
|
const names = [...typingUsers.value.values()];
|
||||||
|
if (names.length === 1) return `${names[0]} ${$t('chat.typing')}`;
|
||||||
|
return `${names.length} ${$t('chat.typing')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const conversationTitle = computed(() => {
|
||||||
|
const conv = currentConversation.value;
|
||||||
|
if (!conv) return '';
|
||||||
|
if (conv.type === 'private') return conv.peer_user_name || $t('chat.private');
|
||||||
|
return conv.name || $t('chat.group');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 未读消息总数
|
||||||
|
const totalUnread = computed(() => {
|
||||||
|
return conversations.value.reduce((sum, c) => sum + (c.unread_count || 0), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// noBasicLayout 模式下 basic.vue 不会挂载,需要在此建立 Chat WebSocket
|
||||||
|
connectChat();
|
||||||
|
// 加载在线用户状态
|
||||||
|
loadOnlineUsers();
|
||||||
|
// 会话列表已在 BasicLayout 全局加载,但进入聊天页时刷新一下
|
||||||
|
await loadConversations();
|
||||||
|
|
||||||
|
// 优先从 URL query 参数获取会话ID
|
||||||
|
const queryConversationId = route.query.conversationId as string | undefined;
|
||||||
|
const targetConversationId =
|
||||||
|
queryConversationId || pendingConversationId.value;
|
||||||
|
|
||||||
|
// 如果有待跳转的会话(从通知点击进入),自动选中
|
||||||
|
if (targetConversationId) {
|
||||||
|
const conv = conversations.value.find((c) => c.id === targetConversationId);
|
||||||
|
if (conv) {
|
||||||
|
await handleSelectConversation(conv);
|
||||||
|
}
|
||||||
|
pendingConversationId.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSend(
|
||||||
|
content: string,
|
||||||
|
msgType: string,
|
||||||
|
fileId?: string,
|
||||||
|
fileName?: string,
|
||||||
|
localUrl?: string,
|
||||||
|
extra?: Record<string, any>,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await sendMessage(
|
||||||
|
content,
|
||||||
|
msgType,
|
||||||
|
fileId,
|
||||||
|
replyTo.value?.id,
|
||||||
|
fileName,
|
||||||
|
localUrl,
|
||||||
|
extra,
|
||||||
|
);
|
||||||
|
replyTo.value = null;
|
||||||
|
} catch {
|
||||||
|
ElMessage.error($t('chat.recallFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRecall(messageId: string) {
|
||||||
|
try {
|
||||||
|
await recallMessage(messageId);
|
||||||
|
ElMessage.success($t('chat.recallSuccess'));
|
||||||
|
} catch {
|
||||||
|
ElMessage.error($t('chat.recallFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReply(msg: ChatMessage) {
|
||||||
|
replyTo.value = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectConversation(conv: Conversation) {
|
||||||
|
await selectConversation(conv);
|
||||||
|
// 双重确保滚到底部:nextTick 处理 DOM 更新,setTimeout 处理虚拟列表延迟渲染
|
||||||
|
nextTick(() => {
|
||||||
|
messageListRef.value?.scrollToBottom(false);
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
messageListRef.value?.scrollToBottom(false);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectContact(user: User) {
|
||||||
|
selectedContactId.value = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStartChat(user: User) {
|
||||||
|
activeTab.value = 'chats';
|
||||||
|
selectedContactId.value = undefined;
|
||||||
|
try {
|
||||||
|
await createPrivateChat(user.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发起聊天失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateGroup(name: string, memberIds: string[]) {
|
||||||
|
try {
|
||||||
|
await createGroupChat(name, memberIds);
|
||||||
|
ElMessage.success($t('chat.createGroupSuccess'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建群聊失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTogglePin(value: boolean) {
|
||||||
|
if (!currentConversation.value) return;
|
||||||
|
await togglePin(currentConversation.value.id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleMute(value: boolean) {
|
||||||
|
if (!currentConversation.value) return;
|
||||||
|
await toggleMute(currentConversation.value.id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddMember() {
|
||||||
|
showAddMember.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddMemberConfirm(memberIds: string[]) {
|
||||||
|
if (!currentConversation.value) return;
|
||||||
|
try {
|
||||||
|
await addMembersApi(currentConversation.value.id, memberIds);
|
||||||
|
await loadMembers();
|
||||||
|
ElMessage.success($t('chat.addMemberSuccess'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加成员失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveMember(userId: string) {
|
||||||
|
if (!currentConversation.value) return;
|
||||||
|
try {
|
||||||
|
await removeMemberApi(currentConversation.value.id, userId);
|
||||||
|
await loadMembers();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('移除成员失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDissolve() {
|
||||||
|
if (!currentConversation.value) return;
|
||||||
|
try {
|
||||||
|
await deleteConversationApi(currentConversation.value.id);
|
||||||
|
ElMessage.success($t('chat.dissolveSuccess'));
|
||||||
|
currentConversation.value = null;
|
||||||
|
await loadConversations();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解散群聊失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 会话列表右键菜单处理 ----
|
||||||
|
async function handleConvTogglePin(conv: Conversation, value: boolean) {
|
||||||
|
await togglePin(conv.id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConvToggleMute(conv: Conversation, value: boolean) {
|
||||||
|
await toggleMute(conv.id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConvMarkUnread(conv: Conversation) {
|
||||||
|
const target = conversations.value.find((c) => c.id === conv.id);
|
||||||
|
if (target && target.unread_count === 0) {
|
||||||
|
target.unread_count = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConvDelete(conv: Conversation) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm($t('chat.deleteConversationConfirm'), {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: $t('common.confirm'),
|
||||||
|
cancelButtonText: $t('common.cancel'),
|
||||||
|
});
|
||||||
|
if (conv.type === 'group') {
|
||||||
|
await deleteConversationApi(conv.id);
|
||||||
|
}
|
||||||
|
// 单聊:前端移除(后端无删除单聊 API)
|
||||||
|
conversations.value = conversations.value.filter((c) => c.id !== conv.id);
|
||||||
|
if (currentConversation.value?.id === conv.id) {
|
||||||
|
currentConversation.value = null;
|
||||||
|
}
|
||||||
|
ElMessage.success($t('chat.deleteSuccess'));
|
||||||
|
} catch {
|
||||||
|
// cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleViewOrg(user: User) {
|
||||||
|
router.push({ path: '/dept', query: { userId: user.id } });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-background-deep h-full py-4 pr-4">
|
||||||
|
<div class="chat-container flex h-full overflow-hidden">
|
||||||
|
<!-- 第1栏:左侧窄菜单栏 -->
|
||||||
|
<div
|
||||||
|
class="flex w-[68px] shrink-0 flex-col items-center bg-[var(--el-bg-color-page)] py-4"
|
||||||
|
>
|
||||||
|
<!-- 当前用户头像 -->
|
||||||
|
<UserAvatar
|
||||||
|
:user-id="userStore.userInfo?.userId"
|
||||||
|
:name="userStore.userInfo?.realName || userStore.userInfo?.username"
|
||||||
|
:avatar="userStore.userInfo?.avatar"
|
||||||
|
:size="40"
|
||||||
|
:font-size="14"
|
||||||
|
:shadow="false"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 菜单图标 -->
|
||||||
|
<!-- <ElTooltip :content="$t('chat.recentChats')" placement="right"> -->
|
||||||
|
<div
|
||||||
|
class="chat-nav-item"
|
||||||
|
:class="{ 'chat-nav-item--active': activeTab === 'chats' }"
|
||||||
|
@click="activeTab = 'chats'"
|
||||||
|
>
|
||||||
|
<MessageSquare class="h-6 w-6" />
|
||||||
|
<!-- 未读徽标 -->
|
||||||
|
<span
|
||||||
|
v-if="totalUnread > 0"
|
||||||
|
class="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--el-color-danger)] px-1 text-[10px] font-medium text-white"
|
||||||
|
>
|
||||||
|
{{ totalUnread > 99 ? '99+' : totalUnread }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- </ElTooltip> -->
|
||||||
|
|
||||||
|
<!-- <ElTooltip :content="$t('chat.contacts')" placement="right"> -->
|
||||||
|
<div
|
||||||
|
class="chat-nav-item"
|
||||||
|
:class="{ 'chat-nav-item--active': activeTab === 'contacts' }"
|
||||||
|
@click="activeTab = 'contacts'"
|
||||||
|
>
|
||||||
|
<Contact class="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<!-- </ElTooltip> -->
|
||||||
|
|
||||||
|
<!-- 底部操作 -->
|
||||||
|
<div class="mt-auto flex flex-col items-center gap-2">
|
||||||
|
<ElDropdown trigger="click" placement="right-start">
|
||||||
|
<div class="chat-nav-item">
|
||||||
|
<CirclePlus class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<template #dropdown>
|
||||||
|
<ElDropdownMenu>
|
||||||
|
<ElDropdownItem @click="showCreateGroup = true">
|
||||||
|
<Users class="mr-2 h-4 w-4" />
|
||||||
|
{{ $t('chat.newGroup') }}
|
||||||
|
</ElDropdownItem>
|
||||||
|
</ElDropdownMenu>
|
||||||
|
</template>
|
||||||
|
</ElDropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第2栏:会话列表 / 联系人列表 -->
|
||||||
|
<div
|
||||||
|
class="bg-background mr-3 flex w-[260px] shrink-0 flex-col rounded-[8px] pt-1"
|
||||||
|
>
|
||||||
|
<!-- 标题栏 -->
|
||||||
|
<!-- <div class="flex shrink-0 items-center justify-between border-b border-[var(--el-border-color-lighter)] px-3 py-2.5">
|
||||||
|
<span class="text-sm font-medium text-[var(--el-text-color-primary)]">
|
||||||
|
{{ activeTab === 'chats' ? $t('chat.recentChats') : $t('chat.contacts') }}
|
||||||
|
</span>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<!-- 会话列表 -->
|
||||||
|
<ConversationList
|
||||||
|
v-show="activeTab === 'chats'"
|
||||||
|
:conversations="conversations"
|
||||||
|
:current-id="currentConversation?.id"
|
||||||
|
:loading="loadingConversations"
|
||||||
|
:online-users="onlineUsers"
|
||||||
|
@select="handleSelectConversation"
|
||||||
|
@toggle-pin="handleConvTogglePin"
|
||||||
|
@toggle-mute="handleConvToggleMute"
|
||||||
|
@mark-unread="handleConvMarkUnread"
|
||||||
|
@delete="handleConvDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 联系人列表 -->
|
||||||
|
<ContactList
|
||||||
|
v-show="activeTab === 'contacts'"
|
||||||
|
:online-users="onlineUsers"
|
||||||
|
@start-chat="handleSelectContact"
|
||||||
|
@view-org="handleViewOrg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第3栏:聊天区域 / 联系人详情 -->
|
||||||
|
<div class="bg-background flex min-w-0 flex-1 flex-col rounded-[8px]">
|
||||||
|
<!-- 联系人 Tab:显示联系人详情 -->
|
||||||
|
<template v-if="activeTab === 'contacts'">
|
||||||
|
<template v-if="selectedContactId">
|
||||||
|
<ContactDetail
|
||||||
|
:user-id="selectedContactId"
|
||||||
|
:online-users="onlineUsers"
|
||||||
|
@start-chat="handleStartChat"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div v-else class="flex h-full items-center justify-center">
|
||||||
|
<ElEmpty
|
||||||
|
:description="$t('chat.selectContactHint')"
|
||||||
|
:image-size="120"
|
||||||
|
>
|
||||||
|
<template #image>
|
||||||
|
<Contact
|
||||||
|
class="h-16 w-16 text-[var(--el-text-color-placeholder)]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ElEmpty>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 聊天 Tab:显示聊天窗口 -->
|
||||||
|
<template v-else>
|
||||||
|
<template v-if="currentConversation">
|
||||||
|
<!-- 聊天头部 -->
|
||||||
|
<div
|
||||||
|
class="flex min-h-[45px] shrink-0 items-center justify-between border-b border-[var(--el-border-color-lighter)] px-4 py-2.5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium text-[var(--el-text-color-primary)]"
|
||||||
|
>
|
||||||
|
{{ conversationTitle }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="
|
||||||
|
currentConversation.type === 'private' &&
|
||||||
|
currentConversation.peer_user_id
|
||||||
|
"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] leading-none"
|
||||||
|
:class="
|
||||||
|
onlineUsers.has(currentConversation.peer_user_id)
|
||||||
|
? 'bg-[var(--el-color-success-light-9)] text-[var(--el-color-success)]'
|
||||||
|
: 'bg-[var(--el-fill-color)] text-[var(--el-text-color-placeholder)]'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="
|
||||||
|
onlineUsers.has(currentConversation.peer_user_id)
|
||||||
|
? 'bg-[var(--el-color-success)]'
|
||||||
|
: 'bg-[var(--el-text-color-placeholder)]'
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
{{
|
||||||
|
onlineUsers.has(currentConversation.peer_user_id)
|
||||||
|
? $t('chat.online')
|
||||||
|
: $t('chat.offline')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="currentConversation.type === 'group'"
|
||||||
|
class="text-xs text-[var(--el-text-color-placeholder)]"
|
||||||
|
>
|
||||||
|
({{ currentConversation.member_count }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- <ElDropdown trigger="click">
|
||||||
|
<MoreVertical class="h-4 w-4 cursor-pointer text-[var(--el-text-color-secondary)] transition-colors hover:text-[var(--el-color-primary)]" />
|
||||||
|
<template #dropdown>
|
||||||
|
<ElDropdownMenu>
|
||||||
|
<ElDropdownItem @click="showInfo = !showInfo">
|
||||||
|
{{ $t('chat.conversationInfo') }}
|
||||||
|
</ElDropdownItem>
|
||||||
|
</ElDropdownMenu>
|
||||||
|
</template>
|
||||||
|
</ElDropdown> -->
|
||||||
|
<PanelRight
|
||||||
|
class="h-4 w-4 cursor-pointer text-[var(--el-text-color-secondary)] transition-colors hover:text-[var(--el-color-primary)]"
|
||||||
|
:class="{ 'text-[var(--el-color-primary)]': showInfo }"
|
||||||
|
@click="showInfo = !showInfo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息列表 -->
|
||||||
|
<MessageList
|
||||||
|
:key="currentConversation?.id || 'empty'"
|
||||||
|
ref="messageListRef"
|
||||||
|
:messages="messages"
|
||||||
|
:has-more="hasMoreMessages"
|
||||||
|
:loading="loadingMessages"
|
||||||
|
:typing-text="typingText"
|
||||||
|
@load-more="loadMessages(true)"
|
||||||
|
@recall="handleRecall"
|
||||||
|
@reply="handleReply"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 输入框 -->
|
||||||
|
<ChatInput
|
||||||
|
:sending="sending"
|
||||||
|
:reply-to="replyTo"
|
||||||
|
@send="handleSend"
|
||||||
|
@typing="sendTyping"
|
||||||
|
@cancel-reply="replyTo = null"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 未选择会话 -->
|
||||||
|
<div v-else class="flex h-full items-center justify-center">
|
||||||
|
<ElEmpty :description="$t('chat.selectHint')" :image-size="120">
|
||||||
|
<template #image>
|
||||||
|
<MessageSquare
|
||||||
|
class="h-16 w-16 text-[var(--el-text-color-placeholder)]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ElEmpty>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第4栏:会话信息面板(仅聊天 Tab 时显示) -->
|
||||||
|
<transition name="slide-right">
|
||||||
|
<div
|
||||||
|
v-if="showInfo && currentConversation && activeTab === 'chats'"
|
||||||
|
class="bg-background ml-3 w-[260px] shrink-0 rounded-[8px]"
|
||||||
|
>
|
||||||
|
<ConversationInfo
|
||||||
|
:conversation="currentConversation"
|
||||||
|
:members="members"
|
||||||
|
:online-users="onlineUsers"
|
||||||
|
:loading="loadingMembers"
|
||||||
|
@toggle-pin="handleTogglePin"
|
||||||
|
@toggle-mute="handleToggleMute"
|
||||||
|
@add-member="handleAddMember"
|
||||||
|
@remove-member="handleRemoveMember"
|
||||||
|
@dissolve="handleDissolve"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 建群弹窗 -->
|
||||||
|
<CreateGroupDialog v-model="showCreateGroup" @confirm="handleCreateGroup" />
|
||||||
|
|
||||||
|
<!-- 添加成员弹窗 -->
|
||||||
|
<AddMemberDialog
|
||||||
|
v-model="showAddMember"
|
||||||
|
:existing-member-ids="members.map((m) => m.user_id)"
|
||||||
|
@confirm="handleAddMemberConfirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-container {
|
||||||
|
border-radius: 8px;
|
||||||
|
/* border: 1px solid var(--el-border-color-lighter); */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧导航项 */
|
||||||
|
.chat-nav-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-nav-item:hover {
|
||||||
|
background-color: var(--el-fill-color);
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-nav-item--active {
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-nav-item--active:hover {
|
||||||
|
background-color: var(--el-color-primary-light-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧面板滑入动画 */
|
||||||
|
.slide-right-enter-active,
|
||||||
|
.slide-right-leave-active {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-right-enter-from,
|
||||||
|
.slide-right-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
DatabaseConfig,
|
||||||
|
DatabaseMonitorOverview,
|
||||||
|
DatabaseRealtimeStats,
|
||||||
|
} from '#/api/core/database-monitor';
|
||||||
|
import type { CardListItem, CardListOptions } from '#/components/card-list';
|
||||||
|
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { onBeforeRouteLeave } from 'vue-router';
|
||||||
|
|
||||||
|
import { Page } from '@vben/common-ui';
|
||||||
|
import { BarChart, LayoutDashboard, ListTree, Network } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElMessage,
|
||||||
|
ElOption,
|
||||||
|
ElScrollbar,
|
||||||
|
ElSelect,
|
||||||
|
ElTag,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDatabaseMonitorConfigsApi,
|
||||||
|
getDatabaseMonitorOverviewApi,
|
||||||
|
getDatabaseRealtimeStatsApi,
|
||||||
|
} from '#/api/core/database-monitor';
|
||||||
|
import { CardList } from '#/components/card-list';
|
||||||
|
|
||||||
|
import ConnectionsPanel from './modules/connections-panel.vue';
|
||||||
|
import OverviewPanel from './modules/overview-panel.vue';
|
||||||
|
import PerformancePanel from './modules/performance-panel.vue';
|
||||||
|
import TablesPanel from './modules/tables-panel.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'DatabaseMonitor' });
|
||||||
|
|
||||||
|
// 菜单项类型
|
||||||
|
interface MonitorMenuItem extends CardListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key: 'connections' | 'overview' | 'performance' | 'tables';
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单项数据
|
||||||
|
const menuItems = ref<MonitorMenuItem[]>([
|
||||||
|
{ id: 'overview', name: $t('database-monitor.overview'), key: 'overview' },
|
||||||
|
{
|
||||||
|
id: 'connections',
|
||||||
|
name: $t('database-monitor.connectionInfo'),
|
||||||
|
key: 'connections',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'performance',
|
||||||
|
name: $t('database-monitor.performanceStats'),
|
||||||
|
key: 'performance',
|
||||||
|
},
|
||||||
|
{ id: 'tables', name: $t('database-monitor.tableStats'), key: 'tables' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 图标映射
|
||||||
|
const iconMap: Record<string, any> = {
|
||||||
|
overview: LayoutDashboard,
|
||||||
|
connections: Network,
|
||||||
|
performance: BarChart,
|
||||||
|
tables: ListTree,
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedMenuId = ref<string>('overview');
|
||||||
|
|
||||||
|
// CardList 配置
|
||||||
|
const cardListOptions: CardListOptions<MonitorMenuItem> = {
|
||||||
|
searchFields: [{ field: 'name' }],
|
||||||
|
displayMode: 'center',
|
||||||
|
titleField: 'name',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 数据库配置列表
|
||||||
|
const databaseConfigs = ref<DatabaseConfig[]>([]);
|
||||||
|
const selectedDatabase = ref<string>('');
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
// 监控数据
|
||||||
|
const monitorData = ref<DatabaseMonitorOverview | null>(null);
|
||||||
|
const realtimeData = ref<DatabaseRealtimeStats | null>(null);
|
||||||
|
|
||||||
|
// 自动刷新
|
||||||
|
const autoRefresh = ref(true);
|
||||||
|
const refreshInterval = ref<null | number>(null);
|
||||||
|
|
||||||
|
// 获取当前图标组件
|
||||||
|
const currentIcon = computed(() => {
|
||||||
|
return iconMap[selectedMenuId.value] || LayoutDashboard;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取当前标题
|
||||||
|
const currentTitle = computed(() => {
|
||||||
|
const item = menuItems.value.find((m) => m.id === selectedMenuId.value);
|
||||||
|
return item?.name || $t('database-monitor.overview');
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatConnectionLabel(config: DatabaseConfig): string {
|
||||||
|
const dbPart = config.database ? ` (${config.database})` : '';
|
||||||
|
return `${config.name}${dbPart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取数据库配置列表
|
||||||
|
async function loadDatabaseConfigs() {
|
||||||
|
try {
|
||||||
|
const configs = await getDatabaseMonitorConfigsApi();
|
||||||
|
databaseConfigs.value = configs;
|
||||||
|
|
||||||
|
// 默认选择第一个连接(db_name = 连接 code)
|
||||||
|
if (configs.length > 0 && !selectedDatabase.value) {
|
||||||
|
selectedDatabase.value = configs[0].db_name;
|
||||||
|
await loadMonitorData(false, true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load database configs:', error);
|
||||||
|
ElMessage.error($t('database-monitor.loadConfigFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载监控数据
|
||||||
|
async function loadMonitorData(showMessage = false, showLoading = false) {
|
||||||
|
if (!selectedDatabase.value) return;
|
||||||
|
|
||||||
|
// 获取当前数据库名称用于显示
|
||||||
|
const currentDbConfig = databaseConfigs.value.find(
|
||||||
|
(config) => config.db_name === selectedDatabase.value,
|
||||||
|
);
|
||||||
|
const dbDisplayName = currentDbConfig?.name || selectedDatabase.value;
|
||||||
|
|
||||||
|
// 显示加载提示
|
||||||
|
if (showMessage) {
|
||||||
|
ElMessage.warning($t('database-monitor.loading', { name: dbDisplayName }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLoading) {
|
||||||
|
loading.value = true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [overview, realtime] = await Promise.all([
|
||||||
|
getDatabaseMonitorOverviewApi(selectedDatabase.value),
|
||||||
|
getDatabaseRealtimeStatsApi(selectedDatabase.value),
|
||||||
|
]);
|
||||||
|
|
||||||
|
monitorData.value = overview;
|
||||||
|
realtimeData.value = realtime;
|
||||||
|
|
||||||
|
// 显示成功提示
|
||||||
|
if (showMessage) {
|
||||||
|
ElMessage.success(
|
||||||
|
$t('database-monitor.loadSuccess', { name: dbDisplayName }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load monitor data:', error);
|
||||||
|
ElMessage.error($t('database-monitor.loadFailed', { name: dbDisplayName }));
|
||||||
|
} finally {
|
||||||
|
if (showLoading) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换数据库
|
||||||
|
function handleDatabaseChange(dbName: string) {
|
||||||
|
selectedDatabase.value = dbName;
|
||||||
|
loadMonitorData(true, true); // 切换数据库时显示加载提示和loading
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单选择
|
||||||
|
function handleMenuSelect(menuId: string | undefined) {
|
||||||
|
if (menuId) {
|
||||||
|
selectedMenuId.value = menuId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动刷新
|
||||||
|
function handleRefresh() {
|
||||||
|
loadMonitorData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换自动刷新
|
||||||
|
function toggleAutoRefresh() {
|
||||||
|
autoRefresh.value = !autoRefresh.value;
|
||||||
|
if (autoRefresh.value) {
|
||||||
|
startAutoRefresh();
|
||||||
|
} else {
|
||||||
|
stopAutoRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始自动刷新
|
||||||
|
function startAutoRefresh() {
|
||||||
|
if (refreshInterval.value) return;
|
||||||
|
|
||||||
|
refreshInterval.value = window.setInterval(() => {
|
||||||
|
if (selectedDatabase.value) {
|
||||||
|
loadMonitorData();
|
||||||
|
}
|
||||||
|
}, 3000); // 每3秒刷新一次
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止自动刷新
|
||||||
|
function stopAutoRefresh() {
|
||||||
|
if (refreshInterval.value) {
|
||||||
|
clearInterval(refreshInterval.value);
|
||||||
|
refreshInterval.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadDatabaseConfigs();
|
||||||
|
// loadDatabaseConfigs 会自动加载第一个数据库的数据(第一次加载显示loading)
|
||||||
|
if (autoRefresh.value) {
|
||||||
|
startAutoRefresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopAutoRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 路由离开时停止轮询
|
||||||
|
onBeforeRouteLeave(() => {
|
||||||
|
stopAutoRefresh();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<!-- 主内容区域 -->
|
||||||
|
<div class="flex h-full">
|
||||||
|
<!-- 左侧菜单 -->
|
||||||
|
<div class="w-1/6 flex-shrink-0">
|
||||||
|
<CardList
|
||||||
|
:items="menuItems"
|
||||||
|
:selected-id="selectedMenuId"
|
||||||
|
:options="cardListOptions"
|
||||||
|
:loading="false"
|
||||||
|
class="database-monitor-menu"
|
||||||
|
@select="handleMenuSelect"
|
||||||
|
>
|
||||||
|
<template #item="{ item }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="iconMap[item.id]" :size="16" />
|
||||||
|
<span class="text-sm font-medium">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</CardList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧内容 -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<!-- 概览信息 -->
|
||||||
|
<ElCard
|
||||||
|
v-if="selectedMenuId === 'overview'"
|
||||||
|
class="flex h-full flex-col"
|
||||||
|
style="border: none"
|
||||||
|
shadow="never"
|
||||||
|
:body-style="{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="currentIcon" :size="20" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{ currentTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- 数据库选择器 -->
|
||||||
|
<ElSelect
|
||||||
|
v-model="selectedDatabase"
|
||||||
|
:placeholder="$t('database-monitor.selectConnection')"
|
||||||
|
style="width: 320px"
|
||||||
|
@change="handleDatabaseChange"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="config in databaseConfigs"
|
||||||
|
:key="config.db_name"
|
||||||
|
:label="formatConnectionLabel(config)"
|
||||||
|
:value="config.db_name"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex flex-col">
|
||||||
|
<span class="truncate">{{ config.name }}</span>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
{{ config.host }}:{{ config.port }} ·
|
||||||
|
{{ config.database || '-' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<ElTag
|
||||||
|
v-if="config.is_system"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.systemConnection') }}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag size="small" type="info">
|
||||||
|
{{ config.db_type }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElOption>
|
||||||
|
</ElSelect>
|
||||||
|
|
||||||
|
<!-- 状态标签 -->
|
||||||
|
<ElTag :type="autoRefresh ? 'success' : 'info'">
|
||||||
|
{{
|
||||||
|
autoRefresh
|
||||||
|
? $t('database-monitor.autoRefreshing')
|
||||||
|
: $t('database-monitor.paused')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag
|
||||||
|
v-if="monitorData"
|
||||||
|
:type="
|
||||||
|
monitorData.status === 'connected' ? 'success' : 'danger'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
monitorData.status === 'connected'
|
||||||
|
? $t('database-monitor.connected')
|
||||||
|
: $t('database-monitor.disconnected')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElScrollbar v-loading="loading" class="monitor-scrollbar">
|
||||||
|
<div class="p-4">
|
||||||
|
<OverviewPanel
|
||||||
|
:monitor-data="monitorData"
|
||||||
|
:realtime-data="realtimeData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 连接信息 -->
|
||||||
|
<ElCard
|
||||||
|
v-else-if="selectedMenuId === 'connections'"
|
||||||
|
class="flex h-full flex-col"
|
||||||
|
style="border: none"
|
||||||
|
shadow="never"
|
||||||
|
:body-style="{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="currentIcon" :size="20" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{ currentTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- 数据库选择器 -->
|
||||||
|
<ElSelect
|
||||||
|
v-model="selectedDatabase"
|
||||||
|
:placeholder="$t('database-monitor.selectConnection')"
|
||||||
|
style="width: 320px"
|
||||||
|
@change="handleDatabaseChange"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="config in databaseConfigs"
|
||||||
|
:key="config.db_name"
|
||||||
|
:label="formatConnectionLabel(config)"
|
||||||
|
:value="config.db_name"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex flex-col">
|
||||||
|
<span class="truncate">{{ config.name }}</span>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
{{ config.host }}:{{ config.port }} ·
|
||||||
|
{{ config.database || '-' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<ElTag
|
||||||
|
v-if="config.is_system"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.systemConnection') }}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag size="small" type="info">
|
||||||
|
{{ config.db_type }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElOption>
|
||||||
|
</ElSelect>
|
||||||
|
|
||||||
|
<!-- 状态标签 -->
|
||||||
|
<ElTag :type="autoRefresh ? 'success' : 'info'">
|
||||||
|
{{
|
||||||
|
autoRefresh
|
||||||
|
? $t('database-monitor.autoRefreshing')
|
||||||
|
: $t('database-monitor.paused')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag
|
||||||
|
v-if="monitorData"
|
||||||
|
:type="
|
||||||
|
monitorData.status === 'connected' ? 'success' : 'danger'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
monitorData.status === 'connected'
|
||||||
|
? $t('database-monitor.connected')
|
||||||
|
: $t('database-monitor.disconnected')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElScrollbar v-loading="loading" class="monitor-scrollbar">
|
||||||
|
<div class="p-4">
|
||||||
|
<ConnectionsPanel
|
||||||
|
:monitor-data="monitorData"
|
||||||
|
:realtime-data="realtimeData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 性能统计 -->
|
||||||
|
<ElCard
|
||||||
|
v-else-if="selectedMenuId === 'performance'"
|
||||||
|
class="flex h-full flex-col"
|
||||||
|
style="border: none"
|
||||||
|
shadow="never"
|
||||||
|
:body-style="{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="currentIcon" :size="20" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{ currentTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- 数据库选择器 -->
|
||||||
|
<ElSelect
|
||||||
|
v-model="selectedDatabase"
|
||||||
|
:placeholder="$t('database-monitor.selectConnection')"
|
||||||
|
style="width: 320px"
|
||||||
|
@change="handleDatabaseChange"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="config in databaseConfigs"
|
||||||
|
:key="config.db_name"
|
||||||
|
:label="formatConnectionLabel(config)"
|
||||||
|
:value="config.db_name"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex flex-col">
|
||||||
|
<span class="truncate">{{ config.name }}</span>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
{{ config.host }}:{{ config.port }} ·
|
||||||
|
{{ config.database || '-' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<ElTag
|
||||||
|
v-if="config.is_system"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.systemConnection') }}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag size="small" type="info">
|
||||||
|
{{ config.db_type }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElOption>
|
||||||
|
</ElSelect>
|
||||||
|
|
||||||
|
<!-- 状态标签 -->
|
||||||
|
<ElTag :type="autoRefresh ? 'success' : 'info'">
|
||||||
|
{{
|
||||||
|
autoRefresh
|
||||||
|
? $t('database-monitor.autoRefreshing')
|
||||||
|
: $t('database-monitor.paused')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag
|
||||||
|
v-if="monitorData"
|
||||||
|
:type="
|
||||||
|
monitorData.status === 'connected' ? 'success' : 'danger'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
monitorData.status === 'connected'
|
||||||
|
? $t('database-monitor.connected')
|
||||||
|
: $t('database-monitor.disconnected')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElScrollbar v-loading="loading" class="monitor-scrollbar">
|
||||||
|
<div class="p-4">
|
||||||
|
<PerformancePanel
|
||||||
|
:monitor-data="monitorData"
|
||||||
|
:realtime-data="realtimeData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 表统计 -->
|
||||||
|
<ElCard
|
||||||
|
v-else-if="selectedMenuId === 'tables'"
|
||||||
|
class="flex h-full flex-col"
|
||||||
|
style="border: none"
|
||||||
|
shadow="never"
|
||||||
|
:body-style="{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="currentIcon" :size="20" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{ currentTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- 数据库选择器 -->
|
||||||
|
<ElSelect
|
||||||
|
v-model="selectedDatabase"
|
||||||
|
:placeholder="$t('database-monitor.selectConnection')"
|
||||||
|
style="width: 320px"
|
||||||
|
@change="handleDatabaseChange"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="config in databaseConfigs"
|
||||||
|
:key="config.db_name"
|
||||||
|
:label="formatConnectionLabel(config)"
|
||||||
|
:value="config.db_name"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex flex-col">
|
||||||
|
<span class="truncate">{{ config.name }}</span>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
{{ config.host }}:{{ config.port }} ·
|
||||||
|
{{ config.database || '-' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<ElTag
|
||||||
|
v-if="config.is_system"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.systemConnection') }}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag size="small" type="info">
|
||||||
|
{{ config.db_type }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElOption>
|
||||||
|
</ElSelect>
|
||||||
|
|
||||||
|
<!-- 状态标签 -->
|
||||||
|
<ElTag :type="autoRefresh ? 'success' : 'info'">
|
||||||
|
{{
|
||||||
|
autoRefresh
|
||||||
|
? $t('database-monitor.autoRefreshing')
|
||||||
|
: $t('database-monitor.paused')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag
|
||||||
|
v-if="monitorData"
|
||||||
|
:type="
|
||||||
|
monitorData.status === 'connected' ? 'success' : 'danger'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
monitorData.status === 'connected'
|
||||||
|
? $t('database-monitor.connected')
|
||||||
|
: $t('database-monitor.disconnected')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElScrollbar v-loading="loading" class="monitor-scrollbar">
|
||||||
|
<div class="p-4">
|
||||||
|
<TablesPanel
|
||||||
|
:monitor-data="monitorData"
|
||||||
|
:realtime-data="realtimeData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 其他菜单内容占位 -->
|
||||||
|
<ElCard v-else class="h-full" shadow="never">
|
||||||
|
<div class="flex h-full items-center justify-center text-gray-400">
|
||||||
|
<div class="text-center">
|
||||||
|
<component :is="currentIcon" :size="48" class="mx-auto mb-4" />
|
||||||
|
<p class="text-lg">{{ currentTitle }}</p>
|
||||||
|
<p class="mt-2 text-sm">
|
||||||
|
{{ $t('database-monitor.featureDeveloping') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.monitor-scrollbar {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
DatabaseMonitorOverview,
|
||||||
|
DatabaseRealtimeStats,
|
||||||
|
} from '#/api/core/database-monitor';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { Activity, Network, Users } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElDescriptions,
|
||||||
|
ElDescriptionsItem,
|
||||||
|
ElProgress,
|
||||||
|
ElTag,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ConnectionsPanel' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
monitorData: DatabaseMonitorOverview | null;
|
||||||
|
realtimeData: DatabaseRealtimeStats | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 连接信息
|
||||||
|
const connectionInfo = computed(() => props.monitorData?.connection_info);
|
||||||
|
|
||||||
|
// 实时连接数据
|
||||||
|
const realtimeConnections = computed(() => ({
|
||||||
|
used: props.realtimeData?.connections_used || 0,
|
||||||
|
active: props.realtimeData?.active_connections || 0,
|
||||||
|
usagePercent: props.realtimeData?.connection_usage_percent || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 获取百分比颜色
|
||||||
|
function getPercentColor(percent: number): string {
|
||||||
|
if (percent >= 90) return '#f56c6c';
|
||||||
|
if (percent >= 70) return '#e6a23c';
|
||||||
|
return '#67c23a';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取连接状态
|
||||||
|
function getConnectionStatus(percent: number): {
|
||||||
|
text: string;
|
||||||
|
type: 'danger' | 'info' | 'success' | 'warning';
|
||||||
|
} {
|
||||||
|
if (percent >= 90)
|
||||||
|
return { text: $t('database-monitor.congested'), type: 'danger' };
|
||||||
|
if (percent >= 70)
|
||||||
|
return { text: $t('database-monitor.busy'), type: 'warning' };
|
||||||
|
if (percent >= 50)
|
||||||
|
return { text: $t('database-monitor.normal'), type: 'info' };
|
||||||
|
return { text: $t('database-monitor.idle'), type: 'success' };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 连接统计卡片 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<!-- 总连接数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.totalConnections') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ connectionInfo?.total_connections || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-blue-100 p-3 dark:bg-blue-900/30">
|
||||||
|
<Network :size="32" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 最大连接数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.maxConnections') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ connectionInfo?.max_connections || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-purple-100 p-3 dark:bg-purple-900/30">
|
||||||
|
<Network :size="32" class="text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 活动连接 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.activeConnections') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold text-green-600">
|
||||||
|
{{ realtimeConnections.active }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500">
|
||||||
|
{{ $t('database-monitor.realtimeActiveConnections') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-green-100 p-3 dark:bg-green-900/30">
|
||||||
|
<Activity :size="32" class="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 空闲连接 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.idleConnections') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold text-gray-600">
|
||||||
|
{{ connectionInfo?.idle_connections || 0 }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500">
|
||||||
|
{{ $t('database-monitor.currentIdleConnections') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-100 p-3 dark:bg-gray-800">
|
||||||
|
<Users :size="32" class="text-gray-600 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 连接使用情况 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<!-- 连接池状态 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Network :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.connectionPoolStatus')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 连接使用率 -->
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.connectionUsageRate')
|
||||||
|
}}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElTag
|
||||||
|
:type="
|
||||||
|
getConnectionStatus(realtimeConnections.usagePercent).type
|
||||||
|
"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
getConnectionStatus(realtimeConnections.usagePercent).text
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
<span class="font-mono text-sm font-semibold">
|
||||||
|
{{ realtimeConnections.usagePercent.toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElProgress
|
||||||
|
:percentage="Number(realtimeConnections.usagePercent.toFixed(1))"
|
||||||
|
:color="getPercentColor(realtimeConnections.usagePercent)"
|
||||||
|
:stroke-width="12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 连接分布 -->
|
||||||
|
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||||
|
<div
|
||||||
|
class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.connectionDistribution') }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-3 w-3 rounded-full bg-green-500"></div>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.activeConnections')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-mono font-semibold">{{
|
||||||
|
realtimeConnections.active
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-3 w-3 rounded-full bg-gray-400"></div>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.idleConnections')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-mono font-semibold">{{
|
||||||
|
connectionInfo?.idle_connections || 0
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-3 w-3 rounded-full bg-blue-500"></div>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.totalConnections')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-mono font-semibold">{{
|
||||||
|
connectionInfo?.total_connections || 0
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 连接池容量 -->
|
||||||
|
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||||
|
<div
|
||||||
|
class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.connectionPoolCapacity') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.usedMaxConnections') }}
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-lg font-semibold">
|
||||||
|
{{ realtimeConnections.used }} /
|
||||||
|
{{ connectionInfo?.max_connections || 0 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<ElProgress
|
||||||
|
:percentage="
|
||||||
|
Number(
|
||||||
|
(
|
||||||
|
(realtimeConnections.used /
|
||||||
|
(connectionInfo?.max_connections || 1)) *
|
||||||
|
100
|
||||||
|
).toFixed(1),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:color="
|
||||||
|
getPercentColor(
|
||||||
|
(realtimeConnections.used /
|
||||||
|
(connectionInfo?.max_connections || 1)) *
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:show-text="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 连接详细信息 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.connectionDetailInfo')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.totalConnections')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
connectionInfo?.total_connections || 0
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.maxConnections')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
connectionInfo?.max_connections || 0
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.activeConnections')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono font-semibold text-green-600">
|
||||||
|
{{ realtimeConnections.active }}
|
||||||
|
</span>
|
||||||
|
<ElTag type="success" size="small">
|
||||||
|
{{ $t('database-monitor.yes') }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.idleConnections')">
|
||||||
|
<span class="font-mono text-gray-600">
|
||||||
|
{{ connectionInfo?.idle_connections || 0 }}
|
||||||
|
</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.usedConnections')">
|
||||||
|
<span class="font-mono">{{ realtimeConnections.used }}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.connectionUsageRate')"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElProgress
|
||||||
|
:percentage="
|
||||||
|
Number(realtimeConnections.usagePercent.toFixed(1))
|
||||||
|
"
|
||||||
|
:color="getPercentColor(realtimeConnections.usagePercent)"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ realtimeConnections.usagePercent.toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.availableConnections')"
|
||||||
|
>
|
||||||
|
<span class="font-mono">
|
||||||
|
{{
|
||||||
|
(connectionInfo?.max_connections || 0) -
|
||||||
|
realtimeConnections.used
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 连接说明 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.connectionExplanation')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.totalConnections') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.totalConnectionsDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.maxConnections') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.maxConnectionsDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.activeConnections') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.activeConnectionsDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.idleConnections') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.idleConnectionsDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.connectionUsageRate') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.usageRateDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.connectionStatus') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.connectionStatusDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
DatabaseMonitorOverview,
|
||||||
|
DatabaseRealtimeStats,
|
||||||
|
} from '#/api/core/database-monitor';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { Activity, HardDrive, Network, Server } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElDescriptions,
|
||||||
|
ElDescriptionsItem,
|
||||||
|
ElProgress,
|
||||||
|
ElTag,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
defineOptions({ name: 'OverviewPanel' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
monitorData: DatabaseMonitorOverview | null;
|
||||||
|
realtimeData: DatabaseRealtimeStats | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 基本信息
|
||||||
|
const basicInfo = computed(() => props.monitorData?.basic_info);
|
||||||
|
|
||||||
|
// 连接信息
|
||||||
|
const connectionInfo = computed(() => props.monitorData?.connection_info);
|
||||||
|
|
||||||
|
// 数据库大小
|
||||||
|
const databaseSize = computed(() => props.monitorData?.database_size);
|
||||||
|
|
||||||
|
// 性能统计
|
||||||
|
const performanceStats = computed(() => props.monitorData?.performance_stats);
|
||||||
|
|
||||||
|
// 连接使用率
|
||||||
|
const connectionUsagePercent = computed(() => {
|
||||||
|
return props.realtimeData?.connection_usage_percent || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 缓存命中率
|
||||||
|
const cacheHitRatio = computed(() => {
|
||||||
|
return props.realtimeData?.cache_hit_ratio || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化字节大小
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化大数字
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)}B`;
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (num >= 1000) return `${(num / 1000).toFixed(2)}K`;
|
||||||
|
return num.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取百分比颜色
|
||||||
|
function getPercentColor(percent: number): string {
|
||||||
|
if (percent >= 90) return '#f56c6c';
|
||||||
|
if (percent >= 70) return '#e6a23c';
|
||||||
|
return '#67c23a';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取数据库类型标签颜色
|
||||||
|
function getDbTypeColor(
|
||||||
|
dbType: string,
|
||||||
|
): 'danger' | 'info' | 'success' | 'warning' {
|
||||||
|
const type = dbType.toUpperCase();
|
||||||
|
if (type === 'POSTGRESQL') return 'success';
|
||||||
|
if (type === 'MYSQL') return 'info';
|
||||||
|
if (type === 'SQLSERVER') return 'warning';
|
||||||
|
if (type === 'ORACLE') return 'danger';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 关键指标卡片 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<!-- 连接使用率 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||||
|
<Network :size="20" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>{{ $t('database-monitor.connectionUsageRate') }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ connectionUsagePercent.toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
<ElProgress
|
||||||
|
:percentage="Number(connectionUsagePercent.toFixed(1))"
|
||||||
|
:color="getPercentColor(connectionUsagePercent)"
|
||||||
|
:stroke-width="8"
|
||||||
|
/>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 数据库大小 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
||||||
|
<HardDrive
|
||||||
|
:size="20"
|
||||||
|
class="text-purple-600 dark:text-purple-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.databaseSize')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ databaseSize?.database_size_gb.toFixed(2) || 0 }} GB
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ databaseSize?.database_size_mb.toFixed(2) || 0 }} MB
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 缓存命中率 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
|
||||||
|
<Activity :size="20" class="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.cacheHitRatio')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ cacheHitRatio.toFixed(2) }}%
|
||||||
|
</div>
|
||||||
|
<ElProgress
|
||||||
|
:percentage="Number(cacheHitRatio.toFixed(1))"
|
||||||
|
:color="getPercentColor(100 - cacheHitRatio)"
|
||||||
|
:stroke-width="8"
|
||||||
|
/>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 活动连接数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-orange-100 p-2 dark:bg-orange-900/30">
|
||||||
|
<Network :size="20" class="text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.activeConnections')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ realtimeData?.active_connections || 0 }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.currentActiveConnections') }}
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详细信息 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Server :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.basicInfo')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.databaseType')">
|
||||||
|
<ElTag :type="getDbTypeColor(basicInfo?.db_type || '')">
|
||||||
|
{{ basicInfo?.db_type || '-' }}
|
||||||
|
</ElTag>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.hostAddress')">
|
||||||
|
{{ basicInfo?.host || '-' }}:{{ basicInfo?.port || '-' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.databaseName')">
|
||||||
|
{{ basicInfo?.database || '-' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.version')">
|
||||||
|
{{ basicInfo?.version || '-' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.uptime')">
|
||||||
|
{{ basicInfo?.uptime || '-' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.timezone')">
|
||||||
|
{{ basicInfo?.timezone || '-' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.charset')">
|
||||||
|
{{ basicInfo?.charset || '-' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 连接信息 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Network :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.connectionInfo')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.totalConnections')">
|
||||||
|
{{ connectionInfo?.total_connections || 0 }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.maxConnections')">
|
||||||
|
{{ connectionInfo?.max_connections || 0 }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.activeConnections')">
|
||||||
|
<span class="font-semibold text-green-600">
|
||||||
|
{{ connectionInfo?.active_connections || 0 }}
|
||||||
|
</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.idleConnections')">
|
||||||
|
<span class="text-gray-600">
|
||||||
|
{{ connectionInfo?.idle_connections || 0 }}
|
||||||
|
</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.connectionUsageRate')"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElProgress
|
||||||
|
:percentage="
|
||||||
|
Number(
|
||||||
|
connectionInfo?.connection_usage_percent?.toFixed(1) || 0,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:color="
|
||||||
|
getPercentColor(connectionInfo?.connection_usage_percent || 0)
|
||||||
|
"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ connectionInfo?.connection_usage_percent?.toFixed(1) || 0 }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 数据库大小 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<HardDrive :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.storageInfo')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.databaseSizeGb')">
|
||||||
|
<span class="font-mono font-semibold">
|
||||||
|
{{ databaseSize?.database_size_gb.toFixed(2) || 0 }} GB
|
||||||
|
</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.databaseSizeMb')">
|
||||||
|
<span class="font-mono">
|
||||||
|
{{ databaseSize?.database_size_mb.toFixed(2) || 0 }} MB
|
||||||
|
</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.databaseSizeBytes')">
|
||||||
|
<span class="font-mono text-sm">
|
||||||
|
{{ formatBytes(databaseSize?.database_size_bytes || 0) }}
|
||||||
|
</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 性能统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.performanceStats')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.cacheHitRatio')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElProgress
|
||||||
|
:percentage="
|
||||||
|
Number(performanceStats?.cache_hit_ratio?.toFixed(1) || 0)
|
||||||
|
"
|
||||||
|
:color="
|
||||||
|
getPercentColor(
|
||||||
|
100 - (performanceStats?.cache_hit_ratio || 0),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ performanceStats?.cache_hit_ratio?.toFixed(2) || 0 }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
|
||||||
|
<!-- PostgreSQL 特有指标 -->
|
||||||
|
<template v-if="basicInfo?.db_type === 'POSTGRESQL'">
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.transactionsCommit')"
|
||||||
|
>
|
||||||
|
{{ formatNumber(performanceStats?.transactions_commit || 0) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.transactionsRollback')"
|
||||||
|
>
|
||||||
|
{{ formatNumber(performanceStats?.transactions_rollback || 0) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.tuplesReturned')">
|
||||||
|
{{ formatNumber(performanceStats?.tuples_returned || 0) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- MySQL 特有指标 -->
|
||||||
|
<template v-if="basicInfo?.db_type === 'MYSQL'">
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.totalQueries')">
|
||||||
|
{{ formatNumber(performanceStats?.total_queries || 0) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.slowQueries')">
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
(performanceStats?.slow_queries || 0) > 0
|
||||||
|
? 'text-red-600'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ formatNumber(performanceStats?.slow_queries || 0) }}
|
||||||
|
</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.bytesReceived')">
|
||||||
|
{{ formatBytes(performanceStats?.bytes_received || 0) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- SQL Server 特有指标 -->
|
||||||
|
<template v-if="basicInfo?.db_type === 'SQLSERVER'">
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.batchRequestsPerSec')"
|
||||||
|
>
|
||||||
|
{{ performanceStats?.batch_requests_per_sec || 0 }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.pageLifeExpectancy')"
|
||||||
|
>
|
||||||
|
{{ performanceStats?.page_life_expectancy || 0 }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.bufferCacheHitRatio')"
|
||||||
|
>
|
||||||
|
{{ performanceStats?.buffer_cache_hit_ratio?.toFixed(2) || 0 }}%
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Oracle 特有指标 -->
|
||||||
|
<template v-if="basicInfo?.db_type === 'ORACLE'">
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.transactionsCommit')"
|
||||||
|
>
|
||||||
|
{{ formatNumber(performanceStats?.transactions_commit || 0) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.transactionsRollback')"
|
||||||
|
>
|
||||||
|
{{ formatNumber(performanceStats?.transactions_rollback || 0) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</template>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
DatabaseMonitorOverview,
|
||||||
|
DatabaseRealtimeStats,
|
||||||
|
} from '#/api/core/database-monitor';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { Activity, BarChart, TrendingUp, Zap } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElDescriptions,
|
||||||
|
ElDescriptionsItem,
|
||||||
|
ElProgress,
|
||||||
|
ElTag,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
defineOptions({ name: 'PerformancePanel' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
monitorData: DatabaseMonitorOverview | null;
|
||||||
|
realtimeData: DatabaseRealtimeStats | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 基本信息
|
||||||
|
const basicInfo = computed(() => props.monitorData?.basic_info);
|
||||||
|
|
||||||
|
// 性能统计
|
||||||
|
const performanceStats = computed(() => props.monitorData?.performance_stats);
|
||||||
|
|
||||||
|
// 缓存命中率
|
||||||
|
const cacheHitRatio = computed(() => {
|
||||||
|
return props.realtimeData?.cache_hit_ratio || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化大数字
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)}B`;
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (num >= 1000) return `${(num / 1000).toFixed(2)}K`;
|
||||||
|
return num.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化字节
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取百分比颜色
|
||||||
|
function getPercentColor(percent: number): string {
|
||||||
|
if (percent >= 90) return '#67c23a';
|
||||||
|
if (percent >= 70) return '#e6a23c';
|
||||||
|
return '#f56c6c';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取数据库类型
|
||||||
|
const dbType = computed(() => basicInfo.value?.db_type || '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 核心性能指标 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<!-- 缓存命中率 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
|
||||||
|
<Zap :size="20" class="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>{{ $t('database-monitor.cacheHitRatio') }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ cacheHitRatio.toFixed(2) }}%
|
||||||
|
</div>
|
||||||
|
<ElProgress
|
||||||
|
:percentage="Number(cacheHitRatio.toFixed(1))"
|
||||||
|
:color="getPercentColor(cacheHitRatio)"
|
||||||
|
:stroke-width="8"
|
||||||
|
/>
|
||||||
|
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.cacheHitRatio') }}
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- PostgreSQL: 事务提交 -->
|
||||||
|
<ElCard v-if="dbType === 'POSTGRESQL'" shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||||
|
<TrendingUp :size="20" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.transactionsCommit')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ formatNumber(performanceStats?.transactions_commit || 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.totalCommittedTransactions') }}
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- PostgreSQL: 事务回滚 -->
|
||||||
|
<ElCard v-if="dbType === 'POSTGRESQL'" shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-orange-100 p-2 dark:bg-orange-900/30">
|
||||||
|
<Activity :size="20" class="text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.transactionsRollback')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ formatNumber(performanceStats?.transactions_rollback || 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.totalRollbackTransactions') }}
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- MySQL: 总查询数 -->
|
||||||
|
<ElCard v-if="dbType === 'MYSQL'" shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||||
|
<BarChart :size="20" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.totalQueries')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ formatNumber(performanceStats?.total_queries || 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.totalQueriesCount') }}
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- MySQL: 慢查询 -->
|
||||||
|
<ElCard v-if="dbType === 'MYSQL'" shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-red-100 p-2 dark:bg-red-900/30">
|
||||||
|
<Activity :size="20" class="text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.slowQueries')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mb-2 text-3xl font-bold"
|
||||||
|
:class="
|
||||||
|
(performanceStats?.slow_queries || 0) > 0 ? 'text-red-600' : ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ formatNumber(performanceStats?.slow_queries || 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.queriesNeedOptimization') }}
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- SQL Server: 批处理请求 -->
|
||||||
|
<ElCard v-if="dbType === 'SQLSERVER'" shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
||||||
|
<Zap :size="20" class="text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.batchRequestsPerSec')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ performanceStats?.batch_requests_per_sec || 0 }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.batchRequestsPerSecDesc') }}
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PostgreSQL 性能详情 -->
|
||||||
|
<div
|
||||||
|
v-if="dbType === 'POSTGRESQL'"
|
||||||
|
class="grid grid-cols-1 gap-4 lg:grid-cols-2"
|
||||||
|
>
|
||||||
|
<!-- 事务统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<TrendingUp :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.transactionStats')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.transactionsCommit')"
|
||||||
|
>
|
||||||
|
<span class="font-mono">{{
|
||||||
|
formatNumber(performanceStats?.transactions_commit || 0)
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.transactionsRollback')"
|
||||||
|
>
|
||||||
|
<span class="font-mono">{{
|
||||||
|
formatNumber(performanceStats?.transactions_rollback || 0)
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.commitRate')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElProgress
|
||||||
|
:percentage="
|
||||||
|
Number(
|
||||||
|
(
|
||||||
|
((performanceStats?.transactions_commit || 0) /
|
||||||
|
((performanceStats?.transactions_commit || 0) +
|
||||||
|
(performanceStats?.transactions_rollback || 1))) *
|
||||||
|
100
|
||||||
|
).toFixed(1),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:color="
|
||||||
|
getPercentColor(
|
||||||
|
((performanceStats?.transactions_commit || 0) /
|
||||||
|
((performanceStats?.transactions_commit || 0) +
|
||||||
|
(performanceStats?.transactions_rollback || 1))) *
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{
|
||||||
|
(
|
||||||
|
((performanceStats?.transactions_commit || 0) /
|
||||||
|
((performanceStats?.transactions_commit || 0) +
|
||||||
|
(performanceStats?.transactions_rollback || 1))) *
|
||||||
|
100
|
||||||
|
).toFixed(1)
|
||||||
|
}}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.cacheHitRatio')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElProgress
|
||||||
|
:percentage="
|
||||||
|
Number(performanceStats?.cache_hit_ratio?.toFixed(1) || 0)
|
||||||
|
"
|
||||||
|
:color="getPercentColor(performanceStats?.cache_hit_ratio || 0)"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ performanceStats?.cache_hit_ratio?.toFixed(2) || 0 }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 元组操作统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<BarChart :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.tupleOperations')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.tuplesReturned')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
formatNumber(performanceStats?.tuples_returned || 0)
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.tuplesFetched')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
formatNumber(performanceStats?.tuples_fetched || 0)
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.tuplesInserted')">
|
||||||
|
<span class="font-mono text-green-600">{{
|
||||||
|
formatNumber(performanceStats?.tuples_inserted || 0)
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.tuplesUpdated')">
|
||||||
|
<span class="font-mono text-blue-600">{{
|
||||||
|
formatNumber(performanceStats?.tuples_updated || 0)
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.tuplesDeleted')">
|
||||||
|
<span class="font-mono text-red-600">{{
|
||||||
|
formatNumber(performanceStats?.tuples_deleted || 0)
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MySQL 性能详情 -->
|
||||||
|
<div
|
||||||
|
v-if="dbType === 'MYSQL'"
|
||||||
|
class="grid grid-cols-1 gap-4 lg:grid-cols-2"
|
||||||
|
>
|
||||||
|
<!-- 查询统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<BarChart :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.queryStats')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.totalQueries')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
formatNumber(performanceStats?.total_queries || 0)
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.totalConnections')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
formatNumber(performanceStats?.total_connections || 0)
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.slowQueries')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="font-mono"
|
||||||
|
:class="
|
||||||
|
(performanceStats?.slow_queries || 0) > 0
|
||||||
|
? 'text-red-600'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ formatNumber(performanceStats?.slow_queries || 0) }}
|
||||||
|
</span>
|
||||||
|
<ElTag
|
||||||
|
v-if="(performanceStats?.slow_queries || 0) > 0"
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.needOptimization') }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.cacheHitRatio')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElProgress
|
||||||
|
:percentage="
|
||||||
|
Number(performanceStats?.cache_hit_ratio?.toFixed(1) || 0)
|
||||||
|
"
|
||||||
|
:color="getPercentColor(performanceStats?.cache_hit_ratio || 0)"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ performanceStats?.cache_hit_ratio?.toFixed(2) || 0 }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 网络流量 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.networkTraffic')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.bytesReceived')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
formatBytes(performanceStats?.bytes_received || 0)
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.bytesSent')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
formatBytes(performanceStats?.bytes_sent || 0)
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.totalTraffic')">
|
||||||
|
<span class="font-mono font-semibold">
|
||||||
|
{{
|
||||||
|
formatBytes(
|
||||||
|
(performanceStats?.bytes_received || 0) +
|
||||||
|
(performanceStats?.bytes_sent || 0),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SQL Server 性能详情 -->
|
||||||
|
<div
|
||||||
|
v-if="dbType === 'SQLSERVER'"
|
||||||
|
class="grid grid-cols-1 gap-4 lg:grid-cols-2"
|
||||||
|
>
|
||||||
|
<!-- 批处理统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Zap :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.batchStats')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.batchRequestsPerSec')"
|
||||||
|
>
|
||||||
|
<span class="font-mono">{{
|
||||||
|
performanceStats?.batch_requests_per_sec || 0
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.pageLifeExpectancy')"
|
||||||
|
>
|
||||||
|
<span class="font-mono">{{
|
||||||
|
performanceStats?.page_life_expectancy || 0
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('database-monitor.bufferCacheHitRatio')"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElProgress
|
||||||
|
:percentage="
|
||||||
|
Number(
|
||||||
|
performanceStats?.buffer_cache_hit_ratio?.toFixed(1) || 0,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:color="
|
||||||
|
getPercentColor(performanceStats?.buffer_cache_hit_ratio || 0)
|
||||||
|
"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ performanceStats?.buffer_cache_hit_ratio?.toFixed(2) || 0 }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 缓存统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.cacheStats')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem :label="$t('database-monitor.cacheHitRatio')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElProgress
|
||||||
|
:percentage="
|
||||||
|
Number(performanceStats?.cache_hit_ratio?.toFixed(1) || 0)
|
||||||
|
"
|
||||||
|
:color="getPercentColor(performanceStats?.cache_hit_ratio || 0)"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ performanceStats?.cache_hit_ratio?.toFixed(2) || 0 }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 性能说明 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.performanceMetricExplanation')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.cacheHitRatio') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.cacheHitRatioDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PostgreSQL -->
|
||||||
|
<template v-if="dbType === 'POSTGRESQL'">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.transactionsCommit') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.transactionsCommitDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.transactionsRollback') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.transactionsRollbackDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.tupleOperations') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.tupleOperationsDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- MySQL -->
|
||||||
|
<template v-if="dbType === 'MYSQL'">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.totalQueries') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.totalQueriesDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.slowQueries') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.slowQueriesDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.networkTraffic') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.networkTrafficDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- SQL Server -->
|
||||||
|
<template v-if="dbType === 'SQLSERVER'">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.batchRequests') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.batchRequestsDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.pageLifeExpectancy') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.pageLifeExpectancyDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.bufferCacheHitRatio') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.bufferCacheHitRatioDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
DatabaseMonitorOverview,
|
||||||
|
DatabaseRealtimeStats,
|
||||||
|
DatabaseTableStats,
|
||||||
|
} from '#/api/core/database-monitor';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { BarChart, Database, ListTree } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElCard, ElEmpty, ElInput, ElTag } from 'element-plus';
|
||||||
|
|
||||||
|
defineOptions({ name: 'TablesPanel' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
monitorData: DatabaseMonitorOverview | null;
|
||||||
|
realtimeData: DatabaseRealtimeStats | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 基本信息
|
||||||
|
const basicInfo = computed(() => props.monitorData?.basic_info);
|
||||||
|
|
||||||
|
// 表统计数据
|
||||||
|
const tableStats = computed(() => props.monitorData?.table_stats || []);
|
||||||
|
|
||||||
|
// 搜索关键词
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
|
||||||
|
// 过滤后的表数据
|
||||||
|
const filteredTables = computed(() => {
|
||||||
|
if (!searchKeyword.value) return tableStats.value;
|
||||||
|
|
||||||
|
const keyword = searchKeyword.value.toLowerCase();
|
||||||
|
return tableStats.value.filter((table) => {
|
||||||
|
const tableName = getTableName(table).toLowerCase();
|
||||||
|
const schemaName = getSchemaName(table).toLowerCase();
|
||||||
|
return tableName.includes(keyword) || schemaName.includes(keyword);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取表名
|
||||||
|
function getTableName(table: DatabaseTableStats): string {
|
||||||
|
return table.tablename || table.table_name || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模式名
|
||||||
|
function getSchemaName(table: DatabaseTableStats): string {
|
||||||
|
return table.schemaname || (dbType.value === 'ORACLE' ? '-' : 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化字节大小
|
||||||
|
function formatBytes(bytes: number | undefined): string {
|
||||||
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化大数字
|
||||||
|
function formatNumber(num: number | undefined): string {
|
||||||
|
if (!num) return '0';
|
||||||
|
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)}B`;
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (num >= 1000) return `${(num / 1000).toFixed(2)}K`;
|
||||||
|
return num.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取数据库类型
|
||||||
|
const dbType = computed(() => basicInfo.value?.db_type || '');
|
||||||
|
|
||||||
|
// 按大小排序的表
|
||||||
|
const tablesBySize = computed(() => {
|
||||||
|
const tables = [...filteredTables.value];
|
||||||
|
return tables
|
||||||
|
.sort((a, b) => {
|
||||||
|
const sizeA = getTableSize(a);
|
||||||
|
const sizeB = getTableSize(b);
|
||||||
|
return sizeB - sizeA;
|
||||||
|
})
|
||||||
|
.slice(0, 10); // 只显示前10个
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取表大小
|
||||||
|
function getTableSize(table: DatabaseTableStats): number {
|
||||||
|
// PostgreSQL: 使用 total_size_bytes
|
||||||
|
if (table.total_size_bytes) return table.total_size_bytes;
|
||||||
|
// Oracle: 使用 size_bytes
|
||||||
|
if (table.size_bytes) return table.size_bytes;
|
||||||
|
// SQL Server: 使用 total_size_kb
|
||||||
|
if (table.total_size_kb) return table.total_size_kb * 1024;
|
||||||
|
// MySQL: 使用 data_length + index_length
|
||||||
|
if (table.data_length) return table.data_length + (table.index_length || 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取表行数
|
||||||
|
function getTableRows(table: DatabaseTableStats): number {
|
||||||
|
return table.table_rows || table.live_tuples || 0;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 统计概览 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<!-- 总表数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.totalTables') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ tableStats.length }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-blue-100 p-3 dark:bg-blue-900/30">
|
||||||
|
<ListTree :size="32" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 总行数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.totalRows') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{
|
||||||
|
formatNumber(
|
||||||
|
tableStats.reduce((sum, t) => sum + getTableRows(t), 0),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-green-100 p-3 dark:bg-green-900/30">
|
||||||
|
<BarChart :size="32" class="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 总大小 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.totalSize') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{
|
||||||
|
formatBytes(
|
||||||
|
tableStats.reduce((sum, t) => sum + getTableSize(t), 0),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-purple-100 p-3 dark:bg-purple-900/30">
|
||||||
|
<Database :size="32" class="text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 搜索框 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex h-full flex-col justify-center">
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.searchTable') }}
|
||||||
|
</div>
|
||||||
|
<ElInput
|
||||||
|
v-model="searchKeyword"
|
||||||
|
:placeholder="$t('database-monitor.searchTablePlaceholder')"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top 10 最大的表 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Database :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.top10LargestTables')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="tablesBySize.length === 0" class="py-12">
|
||||||
|
<ElEmpty :description="$t('database-monitor.noTableData')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.rank') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
v-if="dbType === 'POSTGRESQL'"
|
||||||
|
class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.schema') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.tableName') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.rows') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.size') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
v-if="dbType === 'MYSQL'"
|
||||||
|
class="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.dataSize') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
v-if="dbType === 'MYSQL'"
|
||||||
|
class="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('database-monitor.indexSize') }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<tr
|
||||||
|
v-for="(table, index) in tablesBySize"
|
||||||
|
:key="index"
|
||||||
|
class="hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<ElTag
|
||||||
|
:type="
|
||||||
|
index === 0
|
||||||
|
? 'danger'
|
||||||
|
: index === 1
|
||||||
|
? 'warning'
|
||||||
|
: index === 2
|
||||||
|
? 'success'
|
||||||
|
: 'info'
|
||||||
|
"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
#{{ index + 1 }}
|
||||||
|
</ElTag>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-if="dbType === 'POSTGRESQL'"
|
||||||
|
class="px-4 py-3 text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ getSchemaName(table) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 font-mono font-medium">
|
||||||
|
{{ getTableName(table) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right font-mono">
|
||||||
|
{{ formatNumber(getTableRows(table)) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right font-mono font-semibold">
|
||||||
|
{{ formatBytes(getTableSize(table)) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-if="dbType === 'MYSQL'"
|
||||||
|
class="px-4 py-3 text-right font-mono text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
{{ formatBytes(table.data_length) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-if="dbType === 'MYSQL'"
|
||||||
|
class="px-4 py-3 text-right font-mono text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
{{ formatBytes(table.index_length) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 所有表列表 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ListTree :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.allTablesList')
|
||||||
|
}}</span>
|
||||||
|
<ElTag type="info" size="small">
|
||||||
|
{{
|
||||||
|
$t('database-monitor.tableCount', {
|
||||||
|
count: filteredTables.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="filteredTables.length === 0" class="py-12">
|
||||||
|
<ElEmpty
|
||||||
|
:description="
|
||||||
|
searchKeyword
|
||||||
|
? $t('database-monitor.noMatchingTables')
|
||||||
|
: $t('database-monitor.noTableData')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div
|
||||||
|
v-for="(table, index) in filteredTables"
|
||||||
|
:key="index"
|
||||||
|
class="hover:border-primary rounded-lg border border-gray-200 p-4 transition-all hover:shadow-md dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<!-- 表头 -->
|
||||||
|
<div class="mb-3 flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div
|
||||||
|
v-if="dbType === 'POSTGRESQL'"
|
||||||
|
class="mb-1 text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
{{ getSchemaName(table) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="font-mono font-semibold text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{{ getTableName(table) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Database :size="20" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 统计信息 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.rows')
|
||||||
|
}}</span>
|
||||||
|
<span class="text-600 font-mono">{{
|
||||||
|
formatNumber(getTableRows(table))
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.size')
|
||||||
|
}}</span>
|
||||||
|
<span class="text-600 font-mono">{{
|
||||||
|
formatBytes(getTableSize(table))
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PostgreSQL 特有 -->
|
||||||
|
<template v-if="dbType === 'POSTGRESQL'">
|
||||||
|
<div
|
||||||
|
v-if="table.inserts !== undefined"
|
||||||
|
class="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.inserts')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-mono text-green-600">{{
|
||||||
|
formatNumber(table.inserts)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="table.updates !== undefined"
|
||||||
|
class="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.updates')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-mono text-blue-600">{{
|
||||||
|
formatNumber(table.updates)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="table.deletes !== undefined"
|
||||||
|
class="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.deletes')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-mono text-red-600">{{
|
||||||
|
formatNumber(table.deletes)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="table.dead_tuples !== undefined && table.dead_tuples > 0"
|
||||||
|
class="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.deadTuples')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-mono text-orange-600">{{
|
||||||
|
formatNumber(table.dead_tuples)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- MySQL 特有 -->
|
||||||
|
<template v-if="dbType === 'MYSQL'">
|
||||||
|
<div
|
||||||
|
v-if="table.data_length !== undefined"
|
||||||
|
class="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.dataSize')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-mono text-sm">{{
|
||||||
|
formatBytes(table.data_length)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="table.index_length !== undefined"
|
||||||
|
class="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.indexSize')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-mono text-sm">{{
|
||||||
|
formatBytes(table.index_length)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="table.auto_increment !== undefined"
|
||||||
|
class="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.autoIncrement')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-mono text-sm">{{
|
||||||
|
formatNumber(table.auto_increment)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- SQL Server 特有 -->
|
||||||
|
<template v-if="dbType === 'SQLSERVER'">
|
||||||
|
<div
|
||||||
|
v-if="table.used_size_kb !== undefined"
|
||||||
|
class="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.usedSize')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-mono text-sm">{{
|
||||||
|
formatBytes(table.used_size_kb * 1024)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="table.data_size_kb !== undefined"
|
||||||
|
class="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('database-monitor.dataSize')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-mono text-sm">{{
|
||||||
|
formatBytes(table.data_size_kb * 1024)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 表统计说明 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<BarChart :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('database-monitor.tableStatsExplanation')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.tableSize') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.tableSizeDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.rows') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.tableRowsDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PostgreSQL 说明 -->
|
||||||
|
<template v-if="dbType === 'POSTGRESQL'">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.deadTuples') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.deadTuplesDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.insertUpdateDelete') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.tableOpsDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- MySQL 说明 -->
|
||||||
|
<template v-if="dbType === 'MYSQL'">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.dataSize') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.tableDataSizeDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.indexSize') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.tableIndexSizeDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.autoIncrement') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.autoIncrementDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- SQL Server 说明 -->
|
||||||
|
<template v-if="dbType === 'SQLSERVER'">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.usedSize') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.tableUsedSizeDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('database-monitor.dataSize') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('database-monitor.tableDataSizeDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
RedisMonitorOverview,
|
||||||
|
RedisRealtimeStats,
|
||||||
|
} from '#/api/core/redis-monitor';
|
||||||
|
import type { CardListItem, CardListOptions } from '#/components/card-list';
|
||||||
|
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { onBeforeRouteLeave } from 'vue-router';
|
||||||
|
|
||||||
|
import { Page } from '@vben/common-ui';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Database,
|
||||||
|
Key,
|
||||||
|
LayoutDashboard,
|
||||||
|
Timer,
|
||||||
|
Users,
|
||||||
|
} from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElCard, ElMessage, ElScrollbar, ElTag } from 'element-plus';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getRedisMonitorOverviewApi,
|
||||||
|
getRedisRealtimeStatsApi,
|
||||||
|
} from '#/api/core/redis-monitor';
|
||||||
|
import { CardList } from '#/components/card-list';
|
||||||
|
|
||||||
|
import ClientsPanel from './modules/clients-panel.vue';
|
||||||
|
import KeyspacePanel from './modules/keyspace-panel.vue';
|
||||||
|
import MemoryPanel from './modules/memory-panel.vue';
|
||||||
|
import OverviewPanel from './modules/overview-panel.vue';
|
||||||
|
import SlowlogPanel from './modules/slowlog-panel.vue';
|
||||||
|
import StatsPanel from './modules/stats-panel.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'RedisMonitor' });
|
||||||
|
|
||||||
|
// 菜单项类型
|
||||||
|
interface MonitorMenuItem extends CardListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key: 'clients' | 'keyspace' | 'memory' | 'overview' | 'slowlog' | 'stats';
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单项数据
|
||||||
|
const menuItems = ref<MonitorMenuItem[]>([
|
||||||
|
{ id: 'overview', name: $t('redis-monitor.overview'), key: 'overview' },
|
||||||
|
{ id: 'memory', name: $t('redis-monitor.memory'), key: 'memory' },
|
||||||
|
{ id: 'clients', name: $t('redis-monitor.clients'), key: 'clients' },
|
||||||
|
{ id: 'keyspace', name: $t('redis-monitor.keyspace'), key: 'keyspace' },
|
||||||
|
{ id: 'stats', name: $t('redis-monitor.stats'), key: 'stats' },
|
||||||
|
{ id: 'slowlog', name: $t('redis-monitor.slowlog'), key: 'slowlog' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 图标映射
|
||||||
|
const iconMap: Record<string, any> = {
|
||||||
|
overview: LayoutDashboard,
|
||||||
|
memory: Database,
|
||||||
|
clients: Users,
|
||||||
|
keyspace: Key,
|
||||||
|
stats: BarChart,
|
||||||
|
slowlog: Timer,
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedMenuId = ref<string>('overview');
|
||||||
|
|
||||||
|
// CardList 配置
|
||||||
|
const cardListOptions: CardListOptions<MonitorMenuItem> = {
|
||||||
|
searchFields: [{ field: 'name' }],
|
||||||
|
displayMode: 'center',
|
||||||
|
titleField: 'name',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当前标题和图标
|
||||||
|
const currentTitle = computed(() => {
|
||||||
|
const item = menuItems.value.find((m) => m.id === selectedMenuId.value);
|
||||||
|
return item?.name || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentIcon = computed(() => {
|
||||||
|
return iconMap[selectedMenuId.value];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 菜单选择处理
|
||||||
|
function handleMenuSelect(id: string | undefined) {
|
||||||
|
if (id) {
|
||||||
|
selectedMenuId.value = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监控数据
|
||||||
|
const monitorData = ref<null | RedisMonitorOverview>(null);
|
||||||
|
const realtimeData = ref<null | RedisRealtimeStats>(null);
|
||||||
|
|
||||||
|
// 自动刷新
|
||||||
|
const autoRefresh = ref(true);
|
||||||
|
const refreshInterval = ref<null | number>(null);
|
||||||
|
const REFRESH_INTERVAL = 3000; // 5秒
|
||||||
|
|
||||||
|
// 加载监控数据
|
||||||
|
async function loadMonitorData() {
|
||||||
|
try {
|
||||||
|
const response = await getRedisMonitorOverviewApi();
|
||||||
|
monitorData.value = response;
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error($t('redis-monitor.loadFailed'));
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载实时统计
|
||||||
|
async function loadRealtimeStats() {
|
||||||
|
try {
|
||||||
|
const response = await getRedisRealtimeStatsApi();
|
||||||
|
realtimeData.value = response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error($t('redis-monitor.loadRealtimeStatsFailed'), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动刷新
|
||||||
|
async function handleRefresh() {
|
||||||
|
await loadMonitorData();
|
||||||
|
await loadRealtimeStats();
|
||||||
|
ElMessage.success($t('redis-monitor.refreshSuccess'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换自动刷新
|
||||||
|
function toggleAutoRefresh() {
|
||||||
|
autoRefresh.value = !autoRefresh.value;
|
||||||
|
if (autoRefresh.value) {
|
||||||
|
startAutoRefresh();
|
||||||
|
} else {
|
||||||
|
stopAutoRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始自动刷新
|
||||||
|
function startAutoRefresh() {
|
||||||
|
if (refreshInterval.value) {
|
||||||
|
clearInterval(refreshInterval.value);
|
||||||
|
}
|
||||||
|
refreshInterval.value = window.setInterval(async () => {
|
||||||
|
await loadRealtimeStats();
|
||||||
|
}, REFRESH_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止自动刷新
|
||||||
|
function stopAutoRefresh() {
|
||||||
|
if (refreshInterval.value) {
|
||||||
|
clearInterval(refreshInterval.value);
|
||||||
|
refreshInterval.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadMonitorData();
|
||||||
|
startAutoRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopAutoRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 路由离开时停止轮询
|
||||||
|
onBeforeRouteLeave(() => {
|
||||||
|
stopAutoRefresh();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<!-- 主内容区域 -->
|
||||||
|
<div class="flex h-full">
|
||||||
|
<!-- 左侧菜单 -->
|
||||||
|
<div class="w-1/6 flex-shrink-0">
|
||||||
|
<CardList
|
||||||
|
:items="menuItems"
|
||||||
|
:selected-id="selectedMenuId"
|
||||||
|
:options="cardListOptions"
|
||||||
|
:loading="false"
|
||||||
|
class="redis-monitor-menu"
|
||||||
|
@select="handleMenuSelect"
|
||||||
|
>
|
||||||
|
<template #item="{ item }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="iconMap[item.id]" :size="16" />
|
||||||
|
<span class="text-sm font-medium">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</CardList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧内容 -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<!-- 概览信息 -->
|
||||||
|
<ElCard
|
||||||
|
v-if="selectedMenuId === 'overview'"
|
||||||
|
class="flex h-full flex-col"
|
||||||
|
style="border: none"
|
||||||
|
shadow="never"
|
||||||
|
:body-style="{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="currentIcon" :size="20" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{ currentTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElTag :type="autoRefresh ? 'success' : 'info'">
|
||||||
|
{{
|
||||||
|
autoRefresh
|
||||||
|
? $t('redis-monitor.autoRefreshing')
|
||||||
|
: $t('redis-monitor.paused')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElScrollbar class="monitor-scrollbar">
|
||||||
|
<div class="p-4">
|
||||||
|
<OverviewPanel
|
||||||
|
:monitor-data="monitorData"
|
||||||
|
:realtime-data="realtimeData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 内存信息 -->
|
||||||
|
<ElCard
|
||||||
|
v-else-if="selectedMenuId === 'memory'"
|
||||||
|
class="flex h-full flex-col"
|
||||||
|
shadow="never"
|
||||||
|
style="border: none"
|
||||||
|
:body-style="{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="currentIcon" :size="20" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{ currentTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElTag :type="autoRefresh ? 'success' : 'info'">
|
||||||
|
{{
|
||||||
|
autoRefresh
|
||||||
|
? $t('redis-monitor.autoRefreshing')
|
||||||
|
: $t('redis-monitor.paused')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElScrollbar class="monitor-scrollbar">
|
||||||
|
<div class="p-4">
|
||||||
|
<MemoryPanel
|
||||||
|
:monitor-data="monitorData"
|
||||||
|
:realtime-data="realtimeData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 客户端信息 -->
|
||||||
|
<ElCard
|
||||||
|
v-else-if="selectedMenuId === 'clients'"
|
||||||
|
class="flex h-full flex-col"
|
||||||
|
style="border: none"
|
||||||
|
shadow="never"
|
||||||
|
:body-style="{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="currentIcon" :size="20" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{ currentTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElTag :type="autoRefresh ? 'success' : 'info'">
|
||||||
|
{{
|
||||||
|
autoRefresh
|
||||||
|
? $t('redis-monitor.autoRefreshing')
|
||||||
|
: $t('redis-monitor.paused')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElScrollbar class="monitor-scrollbar">
|
||||||
|
<div class="p-4">
|
||||||
|
<ClientsPanel
|
||||||
|
:monitor-data="monitorData"
|
||||||
|
:realtime-data="realtimeData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 键空间信息 -->
|
||||||
|
<ElCard
|
||||||
|
v-else-if="selectedMenuId === 'keyspace'"
|
||||||
|
class="flex h-full flex-col"
|
||||||
|
style="border: none"
|
||||||
|
shadow="never"
|
||||||
|
:body-style="{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="currentIcon" :size="20" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{ currentTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElTag :type="autoRefresh ? 'success' : 'info'">
|
||||||
|
{{
|
||||||
|
autoRefresh
|
||||||
|
? $t('redis-monitor.autoRefreshing')
|
||||||
|
: $t('redis-monitor.paused')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElScrollbar class="monitor-scrollbar">
|
||||||
|
<div class="p-4">
|
||||||
|
<KeyspacePanel
|
||||||
|
:monitor-data="monitorData"
|
||||||
|
:realtime-data="realtimeData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 统计信息 -->
|
||||||
|
<ElCard
|
||||||
|
v-else-if="selectedMenuId === 'stats'"
|
||||||
|
class="flex h-full flex-col"
|
||||||
|
style="border: none"
|
||||||
|
shadow="never"
|
||||||
|
:body-style="{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="currentIcon" :size="20" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{ currentTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElTag :type="autoRefresh ? 'success' : 'info'">
|
||||||
|
{{
|
||||||
|
autoRefresh
|
||||||
|
? $t('redis-monitor.autoRefreshing')
|
||||||
|
: $t('redis-monitor.paused')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElScrollbar class="monitor-scrollbar">
|
||||||
|
<div class="p-4">
|
||||||
|
<StatsPanel
|
||||||
|
:monitor-data="monitorData"
|
||||||
|
:realtime-data="realtimeData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 慢日志 -->
|
||||||
|
<ElCard
|
||||||
|
v-else-if="selectedMenuId === 'slowlog'"
|
||||||
|
class="flex h-full flex-col"
|
||||||
|
style="border: none"
|
||||||
|
shadow="never"
|
||||||
|
:body-style="{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="currentIcon" :size="20" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{ currentTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElTag :type="autoRefresh ? 'success' : 'info'">
|
||||||
|
{{
|
||||||
|
autoRefresh
|
||||||
|
? $t('redis-monitor.autoRefreshing')
|
||||||
|
: $t('redis-monitor.paused')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElScrollbar class="monitor-scrollbar">
|
||||||
|
<div class="p-4">
|
||||||
|
<SlowlogPanel
|
||||||
|
:monitor-data="monitorData"
|
||||||
|
:realtime-data="realtimeData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 其他菜单内容占位 -->
|
||||||
|
<ElCard v-else class="h-full">
|
||||||
|
<div class="flex h-full items-center justify-center text-gray-400">
|
||||||
|
<div class="text-center">
|
||||||
|
<component :is="currentIcon" :size="48" class="mx-auto mb-4" />
|
||||||
|
<p class="text-lg">{{ currentTitle }}</p>
|
||||||
|
<p class="mt-2 text-sm">
|
||||||
|
{{ $t('redis-monitor.featureDeveloping') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.monitor-scrollbar {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
RedisMonitorOverview,
|
||||||
|
RedisRealtimeStats,
|
||||||
|
} from '#/api/core/redis-monitor';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { Activity, Network, Users } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElCard, ElEmpty, ElTag } from 'element-plus';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ClientsPanel' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
monitorData: null | RedisMonitorOverview;
|
||||||
|
realtimeData: null | RedisRealtimeStats;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 客户端列表
|
||||||
|
const clients = computed(() => props.monitorData?.clients || []);
|
||||||
|
|
||||||
|
// 格式化时间(秒转为可读格式)
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
if (seconds < 60) return `${seconds}${$t('redis-monitor.seconds')}`;
|
||||||
|
if (seconds < 3600)
|
||||||
|
return `${Math.floor(seconds / 60)}${$t('redis-monitor.minutes')}`;
|
||||||
|
if (seconds < 86_400)
|
||||||
|
return `${Math.floor(seconds / 3600)}${$t('redis-monitor.hours')}`;
|
||||||
|
return `${Math.floor(seconds / 86_400)}${$t('redis-monitor.days')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化字节大小
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取客户端状态颜色
|
||||||
|
function getClientStatusColor(
|
||||||
|
flags: string,
|
||||||
|
): 'danger' | 'info' | 'success' | 'warning' {
|
||||||
|
if (flags.includes('M')) return 'danger'; // Master
|
||||||
|
if (flags.includes('S')) return 'warning'; // Slave
|
||||||
|
if (flags.includes('b')) return 'info'; // Blocked
|
||||||
|
return 'success'; // Normal
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取客户端状态文本
|
||||||
|
function getClientStatusText(flags: string): string {
|
||||||
|
if (flags.includes('M')) return $t('redis-monitor.master');
|
||||||
|
if (flags.includes('S')) return $t('redis-monitor.slave');
|
||||||
|
if (flags.includes('b')) return $t('redis-monitor.blocked');
|
||||||
|
if (flags.includes('N')) return $t('redis-monitor.normal');
|
||||||
|
return $t('redis-monitor.active');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 客户端统计卡片 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<!-- 连接客户端数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.connectedClients') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ clients.length }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-blue-100 p-3 dark:bg-blue-900/30">
|
||||||
|
<Users :size="32" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 阻塞客户端数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.blockedClients') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ monitorData?.info?.blocked_clients || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-orange-100 p-3 dark:bg-orange-900/30">
|
||||||
|
<Activity :size="32" class="text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 总连接数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.totalConnections') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ monitorData?.stats?.total_connections_received || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-green-100 p-3 dark:bg-green-900/30">
|
||||||
|
<Network :size="32" class="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 客户端列表 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Users :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.clientList')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{
|
||||||
|
$t('redis-monitor.totalClientsCount', { count: clients.length })
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="clients.length === 0">
|
||||||
|
<ElEmpty :description="$t('redis-monitor.noClientConnections')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('redis-monitor.clientId') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('redis-monitor.address') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('redis-monitor.name') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('redis-monitor.database') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('redis-monitor.status') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('redis-monitor.age') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('redis-monitor.idle') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('redis-monitor.outputBuffer') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('redis-monitor.lastCommand') }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<tr
|
||||||
|
v-for="client in clients"
|
||||||
|
:key="client.id"
|
||||||
|
class="hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs">
|
||||||
|
{{ client.id }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Network :size="14" class="text-gray-400" />
|
||||||
|
<span class="font-mono text-xs">{{ client.addr }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span v-if="client.name" class="font-medium">{{
|
||||||
|
client.name
|
||||||
|
}}</span>
|
||||||
|
<span v-else class="text-gray-400">-</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<ElTag size="small" type="info"> DB{{ client.db }} </ElTag>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<ElTag size="small" :type="getClientStatusColor(client.flags)">
|
||||||
|
{{ getClientStatusText(client.flags) }}
|
||||||
|
</ElTag>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||||
|
{{ formatTime(client.age) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||||
|
{{ formatTime(client.idle) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="text-xs">
|
||||||
|
<div>
|
||||||
|
{{ $t('redis-monitor.used') }}:
|
||||||
|
{{ formatBytes(client.omem) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-500">
|
||||||
|
{{ $t('redis-monitor.queue') }}:
|
||||||
|
{{ client.obl + client.oll }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
v-if="client.cmd"
|
||||||
|
class="rounded bg-gray-100 px-2 py-1 font-mono text-xs dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
{{ client.cmd }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-gray-400">-</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 客户端详细信息说明 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.fieldDescription')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.clientId') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.clientIdDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.address') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.addressDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.name') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.nameDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.age') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.ageDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.idle') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.idleDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.outputBuffer') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.outputBufferDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
RedisMonitorOverview,
|
||||||
|
RedisRealtimeStats,
|
||||||
|
} from '#/api/core/redis-monitor';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { Activity, Database, Key } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElCard, ElEmpty, ElProgress, ElTag } from 'element-plus';
|
||||||
|
|
||||||
|
defineOptions({ name: 'KeyspacePanel' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
monitorData: null | RedisMonitorOverview;
|
||||||
|
realtimeData: null | RedisRealtimeStats;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 键空间列表
|
||||||
|
const keyspaces = computed(() => props.monitorData?.keyspace || []);
|
||||||
|
|
||||||
|
// 总键数
|
||||||
|
const totalKeys = computed(() => {
|
||||||
|
return keyspaces.value.reduce((sum, db) => sum + db.keys, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 总过期键数
|
||||||
|
const totalExpires = computed(() => {
|
||||||
|
return keyspaces.value.reduce((sum, db) => sum + db.expires, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 平均TTL
|
||||||
|
const avgTTL = computed(() => {
|
||||||
|
if (keyspaces.value.length === 0) return 0;
|
||||||
|
const totalTTL = keyspaces.value.reduce((sum, db) => sum + db.avg_ttl, 0);
|
||||||
|
return Math.round(totalTTL / keyspaces.value.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化时间(毫秒转为可读格式)
|
||||||
|
function formatTTL(ms: number): string {
|
||||||
|
if (ms === 0) return $t('redis-monitor.permanent');
|
||||||
|
if (ms < 1000) return `${ms}${$t('redis-monitor.milliseconds')}`;
|
||||||
|
if (ms < 60_000)
|
||||||
|
return `${Math.floor(ms / 1000)}${$t('redis-monitor.seconds')}`;
|
||||||
|
if (ms < 3_600_000)
|
||||||
|
return `${Math.floor(ms / 60_000)}${$t('redis-monitor.minutes')}`;
|
||||||
|
if (ms < 86_400_000)
|
||||||
|
return `${Math.floor(ms / 3_600_000)}${$t('redis-monitor.hours')}`;
|
||||||
|
return `${Math.floor(ms / 86_400_000)}${$t('redis-monitor.days')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取过期率颜色
|
||||||
|
function getExpireRateColor(
|
||||||
|
expires: number,
|
||||||
|
total: number,
|
||||||
|
): 'danger' | 'info' | 'success' | 'warning' {
|
||||||
|
if (total === 0) return 'info';
|
||||||
|
const rate = (expires / total) * 100;
|
||||||
|
if (rate >= 80) return 'success';
|
||||||
|
if (rate >= 50) return 'warning';
|
||||||
|
if (rate >= 20) return 'info';
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取数据库使用率
|
||||||
|
function getDbUsagePercent(keys: number): number {
|
||||||
|
// 假设每个数据库最大容量为1000万个键
|
||||||
|
const maxKeys = 10_000_000;
|
||||||
|
return Math.min((keys / maxKeys) * 100, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取使用率颜色
|
||||||
|
function getUsageColor(percent: number): 'danger' | 'success' | 'warning' {
|
||||||
|
if (percent >= 80) return 'danger';
|
||||||
|
if (percent >= 50) return 'warning';
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 键空间统计卡片 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<!-- 总键数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.totalKeys') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ totalKeys.toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-blue-100 p-3 dark:bg-blue-900/30">
|
||||||
|
<Key :size="32" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 过期键数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.expiresKeys') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ totalExpires.toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500">
|
||||||
|
{{ $t('redis-monitor.proportion') }}:
|
||||||
|
{{
|
||||||
|
totalKeys > 0
|
||||||
|
? ((totalExpires / totalKeys) * 100).toFixed(1)
|
||||||
|
: 0
|
||||||
|
}}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-orange-100 p-3 dark:bg-orange-900/30">
|
||||||
|
<Activity :size="32" class="text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 平均TTL -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.avgTTL') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ formatTTL(avgTTL) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-green-100 p-3 dark:bg-green-900/30">
|
||||||
|
<Database :size="32" class="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据库列表 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Database :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.databaseList')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{
|
||||||
|
$t('redis-monitor.totalDatabasesCount', {
|
||||||
|
count: keyspaces.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="keyspaces.length === 0">
|
||||||
|
<ElEmpty :description="$t('redis-monitor.noDatabaseInfo')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="db in keyspaces"
|
||||||
|
:key="db.db_id"
|
||||||
|
class="rounded-lg border border-gray-200 p-4 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<!-- 数据库头部 -->
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/30"
|
||||||
|
>
|
||||||
|
<Database :size="24" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold">DB{{ db.db_id }}</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.database') }} {{ db.db_id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElTag :type="db.keys > 0 ? 'success' : 'info'" size="large">
|
||||||
|
{{
|
||||||
|
db.keys > 0
|
||||||
|
? $t('redis-monitor.active')
|
||||||
|
: $t('redis-monitor.paused')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据库统计 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<!-- 键数量 -->
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.keyCount')
|
||||||
|
}}</span>
|
||||||
|
<Key :size="16" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold">
|
||||||
|
{{ db.keys.toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<ElProgress
|
||||||
|
:percentage="getDbUsagePercent(db.keys)"
|
||||||
|
:color="getUsageColor(getDbUsagePercent(db.keys))"
|
||||||
|
:show-text="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 过期键 -->
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.expires')
|
||||||
|
}}</span>
|
||||||
|
<Activity :size="16" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold">
|
||||||
|
{{ db.expires.toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<ElTag
|
||||||
|
:type="getExpireRateColor(db.expires, db.keys)"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
db.keys > 0 ? ((db.expires / db.keys) * 100).toFixed(1) : 0
|
||||||
|
}}%
|
||||||
|
</ElTag>
|
||||||
|
<span class="text-xs text-gray-500">{{
|
||||||
|
$t('redis-monitor.expireRate')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 平均TTL -->
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.avgTTL')
|
||||||
|
}}</span>
|
||||||
|
<Database :size="16" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold">
|
||||||
|
{{ formatTTL(db.avg_ttl) }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xs text-gray-500">
|
||||||
|
{{
|
||||||
|
db.avg_ttl > 0
|
||||||
|
? `${db.avg_ttl.toLocaleString()}${$t('redis-monitor.milliseconds')}`
|
||||||
|
: $t('redis-monitor.noExpireTime')
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 键空间说明 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.fieldDescription')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.keyCount') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.keyCountDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.expires') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.expiresDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.avgTTL') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.avgTTLDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.expireRate') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.expireRateDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.dbId') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.dbIdDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.usageRate') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.usageRateDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
RedisMonitorOverview,
|
||||||
|
RedisRealtimeStats,
|
||||||
|
} from '#/api/core/redis-monitor';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Activity, Database, HardDrive, Settings } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElDescriptions,
|
||||||
|
ElDescriptionsItem,
|
||||||
|
ElProgress,
|
||||||
|
ElTag,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
defineOptions({ name: 'MemoryPanel' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
monitorData: null | RedisMonitorOverview;
|
||||||
|
realtimeData: null | RedisRealtimeStats;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 历史数据点(最多保存60个点,约3分钟数据)
|
||||||
|
interface MemoryDataPoint {
|
||||||
|
timestamp: string;
|
||||||
|
usedMemory: number;
|
||||||
|
memoryPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyData = ref<MemoryDataPoint[]>([]);
|
||||||
|
const MAX_HISTORY_POINTS = 60;
|
||||||
|
|
||||||
|
// 监听实时数据变化,添加到历史记录
|
||||||
|
watch(
|
||||||
|
() => props.realtimeData,
|
||||||
|
(newData) => {
|
||||||
|
if (newData) {
|
||||||
|
const now = new Date();
|
||||||
|
const timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
historyData.value.push({
|
||||||
|
timestamp: timeStr,
|
||||||
|
usedMemory: newData.used_memory,
|
||||||
|
memoryPercent: newData.memory_usage_percent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保持最多60个数据点
|
||||||
|
if (historyData.value.length > MAX_HISTORY_POINTS) {
|
||||||
|
historyData.value.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算图表数据
|
||||||
|
const chartData = computed(() => {
|
||||||
|
if (historyData.value.length === 0) {
|
||||||
|
return {
|
||||||
|
labels: [],
|
||||||
|
values: [],
|
||||||
|
hasData: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: historyData.value.map((d) => d.timestamp),
|
||||||
|
values: historyData.value.map((d) => d.memoryPercent),
|
||||||
|
hasData: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化字节大小
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取百分比颜色
|
||||||
|
function getPercentColor(
|
||||||
|
percent: number,
|
||||||
|
): 'danger' | 'info' | 'primary' | 'success' | 'warning' {
|
||||||
|
if (percent >= 90) return 'danger';
|
||||||
|
if (percent >= 70) return 'warning';
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取碎片率状态
|
||||||
|
function getFragmentationStatus(ratio: number): {
|
||||||
|
text: string;
|
||||||
|
type: 'danger' | 'info' | 'success' | 'warning';
|
||||||
|
} {
|
||||||
|
if (ratio < 1) {
|
||||||
|
return { type: 'danger', text: $t('redis-monitor.insufficientMemory') };
|
||||||
|
}
|
||||||
|
if (ratio > 1.5) {
|
||||||
|
return { type: 'warning', text: $t('redis-monitor.moreFragmentation') };
|
||||||
|
}
|
||||||
|
return { type: 'success', text: $t('redis-monitor.normal') };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 内存使用概览卡片 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<!-- 内存使用率 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||||
|
<Database :size="20" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.memoryUsage')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ realtimeData?.memory_usage_percent?.toFixed(1) || '0.0' }}%
|
||||||
|
</div>
|
||||||
|
<ElProgress
|
||||||
|
:percentage="
|
||||||
|
Number(realtimeData?.memory_usage_percent?.toFixed(1) || 0)
|
||||||
|
"
|
||||||
|
:color="getPercentColor(realtimeData?.memory_usage_percent || 0)"
|
||||||
|
:stroke-width="8"
|
||||||
|
/>
|
||||||
|
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ monitorData?.memory?.used_memory_human || '-' }} /
|
||||||
|
{{ monitorData?.memory?.total_system_memory_human || '-' }}
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 内存峰值 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-orange-100 p-2 dark:bg-orange-900/30">
|
||||||
|
<Activity :size="20" class="text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.memoryPeak')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ monitorData?.memory?.used_memory_peak_human || '-' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.memoryPeakDesc') }}
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 内存碎片率 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
||||||
|
<HardDrive
|
||||||
|
:size="20"
|
||||||
|
class="text-purple-600 dark:text-purple-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.fragmentationRatio')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{
|
||||||
|
monitorData?.memory?.mem_fragmentation_ratio?.toFixed(2) || '0.00'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<ElTag
|
||||||
|
:type="
|
||||||
|
getFragmentationStatus(
|
||||||
|
monitorData?.memory?.mem_fragmentation_ratio || 0,
|
||||||
|
).type
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
getFragmentationStatus(
|
||||||
|
monitorData?.memory?.mem_fragmentation_ratio || 0,
|
||||||
|
).text
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内存使用详情 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Database :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.memoryUsageDetail')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<ElDescriptions :column="1" border size="default">
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.usedMemory')">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>{{ monitorData?.memory?.used_memory_human || '-' }}</span>
|
||||||
|
<ElTag type="primary" size="small">
|
||||||
|
{{ formatBytes(monitorData?.memory?.used_memory || 0) }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.rssMemory')">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>{{
|
||||||
|
formatBytes(monitorData?.memory?.used_memory_rss || 0)
|
||||||
|
}}</span>
|
||||||
|
<ElTag type="info" size="small">
|
||||||
|
{{ $t('redis-monitor.physicalMemory') }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.memoryPeak')">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>{{
|
||||||
|
monitorData?.memory?.used_memory_peak_human || '-'
|
||||||
|
}}</span>
|
||||||
|
<ElTag type="warning" size="small">
|
||||||
|
{{ formatBytes(monitorData?.memory?.used_memory_peak || 0) }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.totalSystemMemory')">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>{{
|
||||||
|
monitorData?.memory?.total_system_memory_human || '-'
|
||||||
|
}}</span>
|
||||||
|
<ElTag type="success" size="small">
|
||||||
|
{{ $t('redis-monitor.system') }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ElDescriptions :column="1" border size="default">
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.datasetMemory')">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>{{
|
||||||
|
formatBytes(monitorData?.memory?.used_memory_dataset || 0)
|
||||||
|
}}</span>
|
||||||
|
<ElTag type="primary" size="small">
|
||||||
|
{{ monitorData?.memory?.used_memory_dataset_perc || '-' }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.allocatorAllocated')">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>{{
|
||||||
|
formatBytes(monitorData?.memory?.allocator_allocated || 0)
|
||||||
|
}}</span>
|
||||||
|
<ElTag type="info" size="small">Allocator</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.allocatorActive')">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>{{
|
||||||
|
formatBytes(monitorData?.memory?.allocator_active || 0)
|
||||||
|
}}</span>
|
||||||
|
<ElTag type="info" size="small">Active</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.fragmentationRatio')">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>{{
|
||||||
|
monitorData?.memory?.mem_fragmentation_ratio?.toFixed(2) ||
|
||||||
|
'0.00'
|
||||||
|
}}</span>
|
||||||
|
<ElTag
|
||||||
|
:type="
|
||||||
|
getFragmentationStatus(
|
||||||
|
monitorData?.memory?.mem_fragmentation_ratio || 0,
|
||||||
|
).type
|
||||||
|
"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
getFragmentationStatus(
|
||||||
|
monitorData?.memory?.mem_fragmentation_ratio || 0,
|
||||||
|
).text
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 内存策略配置 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Settings :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.memoryPolicyConfig')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElDescriptions :column="2" border size="default">
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.maxMemoryLimit')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{{
|
||||||
|
monitorData?.memory?.maxmemory_human ||
|
||||||
|
$t('redis-monitor.noLimit')
|
||||||
|
}}</span>
|
||||||
|
<ElTag
|
||||||
|
v-if="
|
||||||
|
monitorData?.memory?.maxmemory &&
|
||||||
|
monitorData.memory.maxmemory > 0
|
||||||
|
"
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ $t('redis-monitor.set') }}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag v-else type="info" size="small">
|
||||||
|
{{ $t('redis-monitor.notLimited') }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.evictionPolicy')">
|
||||||
|
<ElTag type="primary">
|
||||||
|
{{ monitorData?.memory?.maxmemory_policy || '-' }}
|
||||||
|
</ElTag>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||||
|
<div
|
||||||
|
class="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ $t('redis-monitor.evictionPolicyDesc') }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">noeviction:</span>
|
||||||
|
{{ $t('redis-monitor.noevictionDesc') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">allkeys-lru:</span>
|
||||||
|
{{ $t('redis-monitor.allkeysLruDesc') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">volatile-lru:</span>
|
||||||
|
{{ $t('redis-monitor.volatileLruDesc') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">allkeys-random:</span>
|
||||||
|
{{ $t('redis-monitor.allkeysRandomDesc') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">volatile-random:</span>
|
||||||
|
{{ $t('redis-monitor.volatileRandomDesc') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">volatile-ttl:</span>
|
||||||
|
{{ $t('redis-monitor.volatileTtlDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 内存使用趋势图表 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.memoryUsageTrend')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{
|
||||||
|
$t('redis-monitor.recentDataPoints', {
|
||||||
|
count: historyData.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!chartData.hasData"
|
||||||
|
class="flex h-64 items-center justify-center text-gray-400"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<Activity :size="48" class="mx-auto mb-4" />
|
||||||
|
<p class="text-lg">{{ $t('redis-monitor.waitingForData') }}</p>
|
||||||
|
<p class="mt-2 text-sm">
|
||||||
|
{{ $t('redis-monitor.collectingMemoryData') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="relative h-64 p-4">
|
||||||
|
<!-- Y轴标签 -->
|
||||||
|
<div
|
||||||
|
class="absolute left-0 top-0 flex h-full flex-col justify-between py-4 text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
<span>100%</span>
|
||||||
|
<span>75%</span>
|
||||||
|
<span>50%</span>
|
||||||
|
<span>25%</span>
|
||||||
|
<span>0%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图表区域 -->
|
||||||
|
<div class="ml-12 h-full">
|
||||||
|
<svg
|
||||||
|
class="h-full w-full"
|
||||||
|
viewBox="0 0 800 200"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<!-- 网格线 -->
|
||||||
|
<line
|
||||||
|
v-for="i in 5"
|
||||||
|
:key="`grid-${i}`"
|
||||||
|
:x1="0"
|
||||||
|
:y1="(i - 1) * 50"
|
||||||
|
:x2="800"
|
||||||
|
:y2="(i - 1) * 50"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="0.5"
|
||||||
|
class="text-gray-200 dark:text-gray-700"
|
||||||
|
stroke-dasharray="5,5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 折线图 -->
|
||||||
|
<polyline
|
||||||
|
:points="
|
||||||
|
chartData.values
|
||||||
|
.map((val, idx) => {
|
||||||
|
const x = (idx / (chartData.values.length - 1 || 1)) * 800;
|
||||||
|
const y = 200 - (val / 100) * 200;
|
||||||
|
return `${x},${y}`;
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="text-blue-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 填充区域 -->
|
||||||
|
<polygon
|
||||||
|
:points="
|
||||||
|
[
|
||||||
|
'0,200',
|
||||||
|
...chartData.values.map((val, idx) => {
|
||||||
|
const x = (idx / (chartData.values.length - 1 || 1)) * 800;
|
||||||
|
const y = 200 - (val / 100) * 200;
|
||||||
|
return `${x},${y}`;
|
||||||
|
}),
|
||||||
|
'800,200',
|
||||||
|
].join(' ')
|
||||||
|
"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-blue-500 opacity-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 数据点 -->
|
||||||
|
<circle
|
||||||
|
v-for="(val, idx) in chartData.values"
|
||||||
|
:key="`point-${idx}`"
|
||||||
|
:cx="(idx / (chartData.values.length - 1 || 1)) * 800"
|
||||||
|
:cy="200 - (val / 100) * 200"
|
||||||
|
r="3"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-blue-600"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- X轴时间标签 -->
|
||||||
|
<div class="ml-12 mt-2 flex justify-between text-xs text-gray-500">
|
||||||
|
<span>{{ chartData.labels[0] || '-' }}</span>
|
||||||
|
<span>{{
|
||||||
|
chartData.labels[Math.floor(chartData.labels.length / 2)] || '-'
|
||||||
|
}}</span>
|
||||||
|
<span>{{
|
||||||
|
chartData.labels[chartData.labels.length - 1] || '-'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 当前值显示 -->
|
||||||
|
<div class="mt-4 flex items-center justify-center gap-4 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-3 w-3 rounded-full bg-blue-500"></div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400"
|
||||||
|
>{{ $t('redis-monitor.currentUsage') }}:</span
|
||||||
|
>
|
||||||
|
<span class="font-semibold"
|
||||||
|
>{{
|
||||||
|
chartData.values[chartData.values.length - 1]?.toFixed(1) ||
|
||||||
|
'0.0'
|
||||||
|
}}%</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400"
|
||||||
|
>{{ $t('redis-monitor.average') }}:</span
|
||||||
|
>
|
||||||
|
<span class="font-semibold"
|
||||||
|
>{{
|
||||||
|
(
|
||||||
|
chartData.values.reduce((a, b) => a + b, 0) /
|
||||||
|
chartData.values.length
|
||||||
|
).toFixed(1)
|
||||||
|
}}%</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400"
|
||||||
|
>{{ $t('redis-monitor.highest') }}:</span
|
||||||
|
>
|
||||||
|
<span class="font-semibold text-orange-600"
|
||||||
|
>{{ Math.max(...chartData.values).toFixed(1) }}%</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
RedisMonitorOverview,
|
||||||
|
RedisRealtimeStats,
|
||||||
|
} from '#/api/core/redis-monitor';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Cpu,
|
||||||
|
Database,
|
||||||
|
HardDrive,
|
||||||
|
Network,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
} from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElDescriptions,
|
||||||
|
ElDescriptionsItem,
|
||||||
|
ElProgress,
|
||||||
|
ElTag,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
defineOptions({ name: 'OverviewPanel' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
monitorData: null | RedisMonitorOverview;
|
||||||
|
realtimeData: null | RedisRealtimeStats;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 客户端数量(使用实际客户端列表长度)
|
||||||
|
const clientsCount = computed(() => props.monitorData?.clients?.length || 0);
|
||||||
|
|
||||||
|
// 格式化运行时间
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86_400);
|
||||||
|
const hours = Math.floor((seconds % 86_400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${days}${$t('redis-monitor.days')} ${hours}${$t('redis-monitor.hours')} ${minutes}${$t('redis-monitor.minutes')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化字节大小
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取百分比颜色
|
||||||
|
function getPercentColor(
|
||||||
|
percent: number,
|
||||||
|
): 'danger' | 'info' | 'primary' | 'success' | 'warning' {
|
||||||
|
if (percent >= 90) return 'danger';
|
||||||
|
if (percent >= 70) return 'warning';
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 关键指标卡片 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<!-- 内存使用率 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||||
|
<Database :size="20" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.memoryUsage')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ realtimeData?.memory_usage_percent?.toFixed(1) || '0.0' }}%
|
||||||
|
</div>
|
||||||
|
<ElProgress
|
||||||
|
:percentage="
|
||||||
|
Number(realtimeData?.memory_usage_percent?.toFixed(1) || 0)
|
||||||
|
"
|
||||||
|
:color="getPercentColor(realtimeData?.memory_usage_percent || 0)"
|
||||||
|
:stroke-width="8"
|
||||||
|
/>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 连接客户端数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
|
||||||
|
<Users :size="20" class="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.connectedClients')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ clientsCount }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.currentConnections') }}
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 每秒操作数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
||||||
|
<Activity :size="20" class="text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.opsPerSec')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ realtimeData?.ops_per_sec?.toLocaleString() || 0 }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">OPS</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 命中率 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="rounded-lg bg-orange-100 p-2 dark:bg-orange-900/30">
|
||||||
|
<Activity :size="20" class="text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.hitRate')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-3xl font-bold">
|
||||||
|
{{ realtimeData?.hit_rate?.toFixed(2) || '0.00' }}%
|
||||||
|
</div>
|
||||||
|
<ElProgress
|
||||||
|
:percentage="Number(realtimeData?.hit_rate?.toFixed(1) || 0)"
|
||||||
|
:color="getPercentColor(realtimeData?.hit_rate || 0)"
|
||||||
|
:stroke-width="8"
|
||||||
|
/>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Redis基础信息 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Settings :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.redisBasicInfo')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElDescriptions :column="3" border size="default">
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.redisVersion')">
|
||||||
|
<ElTag type="primary">
|
||||||
|
{{ monitorData?.info?.redis_version || '-' }}
|
||||||
|
</ElTag>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.redisMode')">
|
||||||
|
<ElTag type="success">
|
||||||
|
{{ monitorData?.info?.redis_mode || '-' }}
|
||||||
|
</ElTag>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.role')">
|
||||||
|
<ElTag
|
||||||
|
:type="monitorData?.info?.role === 'master' ? 'danger' : 'info'"
|
||||||
|
>
|
||||||
|
{{ monitorData?.info?.role || '-' }}
|
||||||
|
</ElTag>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.os')">
|
||||||
|
{{ monitorData?.info?.os || '-' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.architecture')">
|
||||||
|
{{ monitorData?.info?.arch_bits || 0 }} {{ $t('redis-monitor.bits') }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.tcpPort')">
|
||||||
|
{{ monitorData?.info?.tcp_port || 0 }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.uptime')">
|
||||||
|
{{ formatUptime(monitorData?.info?.uptime_in_seconds || 0) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.uptimeInDays')">
|
||||||
|
{{ monitorData?.info?.uptime_in_days || 0 }}
|
||||||
|
{{ $t('redis-monitor.days') }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.connectionStatus')">
|
||||||
|
<ElTag
|
||||||
|
:type="monitorData?.status === 'connected' ? 'success' : 'danger'"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
monitorData?.status === 'connected'
|
||||||
|
? $t('redis-monitor.connected')
|
||||||
|
: $t('redis-monitor.disconnected')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 内存信息概览 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Database :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.memoryInfo')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<ElDescriptions :column="1" border size="default">
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.usedMemory')">
|
||||||
|
{{ monitorData?.memory?.used_memory_human || '-' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.rssMemory')">
|
||||||
|
{{ formatBytes(monitorData?.memory?.used_memory_rss || 0) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.memoryPeak')">
|
||||||
|
{{ monitorData?.memory?.used_memory_peak_human || '-' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.totalSystemMemory')">
|
||||||
|
{{ monitorData?.memory?.total_system_memory_human || '-' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ElDescriptions :column="1" border size="default">
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.datasetMemory')">
|
||||||
|
{{ formatBytes(monitorData?.memory?.used_memory_dataset || 0) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.maxMemoryLimit')">
|
||||||
|
{{
|
||||||
|
monitorData?.memory?.maxmemory_human ||
|
||||||
|
$t('redis-monitor.noLimit')
|
||||||
|
}}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.memoryPolicy')">
|
||||||
|
<ElTag type="info">
|
||||||
|
{{ monitorData?.memory?.maxmemory_policy || '-' }}
|
||||||
|
</ElTag>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.fragmentationRatio')">
|
||||||
|
<ElTag
|
||||||
|
:type="
|
||||||
|
(monitorData?.memory?.mem_fragmentation_ratio || 0) > 1.5
|
||||||
|
? 'warning'
|
||||||
|
: 'success'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
monitorData?.memory?.mem_fragmentation_ratio?.toFixed(2) ||
|
||||||
|
'0.00'
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 统计信息 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<!-- 连接统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.connectionStats')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.totalConnections')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
monitorData?.stats?.total_connections_received?.toLocaleString() ||
|
||||||
|
0
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.connectedClients')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-semibold">{{ clientsCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.blockedClients')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
monitorData?.info?.blocked_clients || 0
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.rejectedConnections')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-semibold text-red-600">{{
|
||||||
|
monitorData?.stats?.rejected_connections?.toLocaleString() || 0
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 命令统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Cpu :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.commandStats')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.totalCommands')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
monitorData?.stats?.total_commands_processed?.toLocaleString() ||
|
||||||
|
0
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.opsPerSec')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-semibold text-green-600">{{
|
||||||
|
monitorData?.stats?.instantaneous_ops_per_sec?.toLocaleString() ||
|
||||||
|
0
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.keyspaceHits')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-semibold text-green-600">{{
|
||||||
|
monitorData?.stats?.keyspace_hits?.toLocaleString() || 0
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.keyspaceMisses')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-semibold text-red-600">{{
|
||||||
|
monitorData?.stats?.keyspace_misses?.toLocaleString() || 0
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 键空间信息 -->
|
||||||
|
<ElCard
|
||||||
|
v-if="monitorData?.keyspace && monitorData.keyspace.length > 0"
|
||||||
|
shadow="hover"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<HardDrive :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.keyspaceInfo')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<ElCard
|
||||||
|
v-for="db in monitorData.keyspace"
|
||||||
|
:key="db.db_id"
|
||||||
|
shadow="hover"
|
||||||
|
class="border border-gray-200 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="font-semibold">DB{{ db.db_id }}</span>
|
||||||
|
<ElTag type="primary" size="small">
|
||||||
|
{{ db.keys }} {{ $t('redis-monitor.keys') }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400"
|
||||||
|
>{{ $t('redis-monitor.expires') }}:</span
|
||||||
|
>
|
||||||
|
<span>{{ db.expires }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400"
|
||||||
|
>{{ $t('redis-monitor.avgTTL') }}:</span
|
||||||
|
>
|
||||||
|
<span>{{ db.avg_ttl }}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 网络IO统计 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<!-- 输入统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Network :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.networkInput')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.totalNetInput')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
formatBytes(monitorData?.stats?.total_net_input_bytes || 0)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.inputKbps')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-semibold text-blue-600">{{
|
||||||
|
monitorData?.stats?.instantaneous_input_kbps?.toFixed(2) ||
|
||||||
|
'0.00'
|
||||||
|
}}
|
||||||
|
KB/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 输出统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Network :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.networkOutput')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.totalNetOutput')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
formatBytes(monitorData?.stats?.total_net_output_bytes || 0)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.outputKbps')
|
||||||
|
}}</span>
|
||||||
|
<span class="font-semibold text-green-600">{{
|
||||||
|
monitorData?.stats?.instantaneous_output_kbps?.toFixed(2) ||
|
||||||
|
'0.00'
|
||||||
|
}}
|
||||||
|
KB/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
RedisMonitorOverview,
|
||||||
|
RedisRealtimeStats,
|
||||||
|
} from '#/api/core/redis-monitor';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { Activity, Clock, Database, Network, Timer } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElCard, ElEmpty, ElTag } from 'element-plus';
|
||||||
|
|
||||||
|
defineOptions({ name: 'SlowlogPanel' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
monitorData: null | RedisMonitorOverview;
|
||||||
|
realtimeData: null | RedisRealtimeStats;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 慢日志列表
|
||||||
|
const slowLogs = computed(() => props.monitorData?.slow_log || []);
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
|
const totalSlowLogs = computed(() => slowLogs.value.length);
|
||||||
|
const avgDuration = computed(() => {
|
||||||
|
if (slowLogs.value.length === 0) return 0;
|
||||||
|
const total = slowLogs.value.reduce((sum, log) => sum + log.duration, 0);
|
||||||
|
return total / slowLogs.value.length;
|
||||||
|
});
|
||||||
|
const maxDuration = computed(() => {
|
||||||
|
if (slowLogs.value.length === 0) return 0;
|
||||||
|
return Math.max(...slowLogs.value.map((log) => log.duration));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化时间戳
|
||||||
|
function formatTimestamp(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化持续时间(微秒)
|
||||||
|
function formatDuration(microseconds: number): string {
|
||||||
|
if (microseconds < 1000) return `${microseconds}μs`;
|
||||||
|
if (microseconds < 1_000_000) return `${(microseconds / 1000).toFixed(2)}ms`;
|
||||||
|
return `${(microseconds / 1_000_000).toFixed(2)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取持续时间颜色
|
||||||
|
function getDurationColor(
|
||||||
|
microseconds: number,
|
||||||
|
): 'danger' | 'info' | 'success' | 'warning' {
|
||||||
|
if (microseconds >= 1_000_000) return 'danger'; // >= 1s
|
||||||
|
if (microseconds >= 100_000) return 'warning'; // >= 100ms
|
||||||
|
if (microseconds >= 10_000) return 'info'; // >= 10ms
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取持续时间进度条百分比
|
||||||
|
function getDurationPercent(duration: number): number {
|
||||||
|
if (maxDuration.value === 0) return 0;
|
||||||
|
return (duration / maxDuration.value) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 截取命令显示
|
||||||
|
function truncateCommand(command: string, maxLength: number = 100): string {
|
||||||
|
if (command.length <= maxLength) return command;
|
||||||
|
return `${command.slice(0, Math.max(0, maxLength))}...`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 慢日志统计卡片 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<!-- 慢日志总数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.totalSlowLogs') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ totalSlowLogs }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-blue-100 p-3 dark:bg-blue-900/30">
|
||||||
|
<Timer :size="32" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 平均耗时 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.avgDuration') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ formatDuration(avgDuration) }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500">
|
||||||
|
{{ avgDuration.toFixed(0) }} μs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-orange-100 p-3 dark:bg-orange-900/30">
|
||||||
|
<Clock :size="32" class="text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 最大耗时 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.maxDuration') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold text-red-600">
|
||||||
|
{{ formatDuration(maxDuration) }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500">
|
||||||
|
{{ maxDuration.toFixed(0) }} μs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-red-100 p-3 dark:bg-red-900/30">
|
||||||
|
<Activity :size="32" class="text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 慢日志列表 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Timer :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.slowLogList')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{
|
||||||
|
$t('redis-monitor.recentLogsCount', { count: slowLogs.length })
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="slowLogs.length === 0">
|
||||||
|
<ElEmpty :description="$t('redis-monitor.noSlowLogs')">
|
||||||
|
<template #image>
|
||||||
|
<Database :size="64" class="text-gray-300" />
|
||||||
|
</template>
|
||||||
|
</ElEmpty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(log, index) in slowLogs"
|
||||||
|
:key="log.id"
|
||||||
|
class="hover:border-primary rounded-lg border border-gray-200 p-4 transition-all hover:shadow-md dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<!-- 日志头部 -->
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-mono text-sm font-semibold text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
#{{ index + 1 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>ID:</span
|
||||||
|
>
|
||||||
|
<span class="font-mono text-sm font-semibold">{{
|
||||||
|
log.id
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<Clock :size="12" />
|
||||||
|
<span>{{ formatTimestamp(log.timestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElTag :type="getDurationColor(log.duration)" size="large">
|
||||||
|
{{ formatDuration(log.duration) }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 命令内容 -->
|
||||||
|
<div class="mb-3 rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.command') }}:
|
||||||
|
</div>
|
||||||
|
<div class="break-all font-mono text-sm">
|
||||||
|
{{ truncateCommand(log.command) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 客户端信息和耗时进度 -->
|
||||||
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<!-- 客户端信息 -->
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<Network :size="14" class="text-gray-400" />
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.clientInfo')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">IP:</span>
|
||||||
|
<span class="font-mono">{{ log.client_ip || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400"
|
||||||
|
>{{ $t('redis-monitor.name') }}:</span
|
||||||
|
>
|
||||||
|
<span class="font-mono">{{
|
||||||
|
log.client_name || $t('redis-monitor.unnamed')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 耗时进度 -->
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<Activity :size="14" class="text-gray-400" />
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||||
|
$t('redis-monitor.durationProportion')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400"
|
||||||
|
>{{ $t('redis-monitor.relativeToMax') }}:</span
|
||||||
|
>
|
||||||
|
<span class="font-semibold"
|
||||||
|
>{{ getDurationPercent(log.duration).toFixed(1) }}%</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full transition-all"
|
||||||
|
:class="{
|
||||||
|
'bg-red-500': getDurationColor(log.duration) === 'danger',
|
||||||
|
'bg-yellow-500':
|
||||||
|
getDurationColor(log.duration) === 'warning',
|
||||||
|
'bg-blue-500': getDurationColor(log.duration) === 'info',
|
||||||
|
'bg-green-500':
|
||||||
|
getDurationColor(log.duration) === 'success',
|
||||||
|
}"
|
||||||
|
:style="{ width: `${getDurationPercent(log.duration)}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 慢日志说明 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.slowLogDesc')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.slowLogThreshold') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.slowLogThresholdDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.executionTime') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.executionTimeDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.clientInfo') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.clientInfoDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.performanceOptimization') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.performanceOptimizationDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.timeUnit') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{
|
||||||
|
$t('redis-monitor.timeUnitDesc') ||
|
||||||
|
'μs(微秒) = 0.001ms,ms(毫秒) = 0.001s'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.colorIndicator') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{
|
||||||
|
$t('redis-monitor.colorIndicatorDesc') ||
|
||||||
|
'红色(≥1s) > 黄色(≥100ms) > 蓝色(≥10ms) > 绿色'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
RedisMonitorOverview,
|
||||||
|
RedisRealtimeStats,
|
||||||
|
} from '#/api/core/redis-monitor';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { Activity, BarChart, Network, TrendingUp, Zap } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElCard,
|
||||||
|
ElDescriptions,
|
||||||
|
ElDescriptionsItem,
|
||||||
|
ElProgress,
|
||||||
|
ElTag,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
defineOptions({ name: 'StatsPanel' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
monitorData: null | RedisMonitorOverview;
|
||||||
|
realtimeData: null | RedisRealtimeStats;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
|
const stats = computed(() => props.monitorData?.stats);
|
||||||
|
|
||||||
|
// 命中率
|
||||||
|
const hitRate = computed(() => {
|
||||||
|
if (!stats.value) return 0;
|
||||||
|
const hits = stats.value.keyspace_hits || 0;
|
||||||
|
const misses = stats.value.keyspace_misses || 0;
|
||||||
|
const total = hits + misses;
|
||||||
|
return total > 0 ? (hits / total) * 100 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化字节大小
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化大数字
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)}B`;
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (num >= 1000) return `${(num / 1000).toFixed(2)}K`;
|
||||||
|
return num.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取命中率颜色
|
||||||
|
function getHitRateColor(rate: number): 'danger' | 'success' | 'warning' {
|
||||||
|
if (rate >= 90) return 'success';
|
||||||
|
if (rate >= 70) return 'warning';
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取性能状态
|
||||||
|
function getPerformanceStatus(opsPerSec: number): {
|
||||||
|
text: string;
|
||||||
|
type: 'danger' | 'info' | 'success' | 'warning';
|
||||||
|
} {
|
||||||
|
if (opsPerSec >= 10_000)
|
||||||
|
return { text: $t('redis-monitor.excellent'), type: 'success' };
|
||||||
|
if (opsPerSec >= 5000)
|
||||||
|
return { text: $t('redis-monitor.good'), type: 'success' };
|
||||||
|
if (opsPerSec >= 1000)
|
||||||
|
return { text: $t('redis-monitor.normal'), type: 'info' };
|
||||||
|
if (opsPerSec >= 100)
|
||||||
|
return { text: $t('redis-monitor.lower'), type: 'warning' };
|
||||||
|
return { text: $t('redis-monitor.veryLow'), type: 'danger' };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 关键性能指标 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<!-- 每秒操作数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.opsPerSec') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ formatNumber(stats?.instantaneous_ops_per_sec || 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<ElTag
|
||||||
|
:type="
|
||||||
|
getPerformanceStatus(stats?.instantaneous_ops_per_sec || 0)
|
||||||
|
.type
|
||||||
|
"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
getPerformanceStatus(stats?.instantaneous_ops_per_sec || 0)
|
||||||
|
.text
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-blue-100 p-3 dark:bg-blue-900/30">
|
||||||
|
<Zap :size="32" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 命中率 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.cacheHitRate') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">{{ hitRate.toFixed(2) }}%</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<ElProgress
|
||||||
|
:percentage="hitRate"
|
||||||
|
:color="getHitRateColor(hitRate)"
|
||||||
|
:show-text="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-green-100 p-3 dark:bg-green-900/30">
|
||||||
|
<TrendingUp :size="32" class="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 总命令数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.totalCommands') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ formatNumber(stats?.total_commands_processed || 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500">
|
||||||
|
{{ (stats?.total_commands_processed || 0).toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-purple-100 p-3 dark:bg-purple-900/30">
|
||||||
|
<Activity :size="32" class="text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 总连接数 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.totalConnections') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{{ formatNumber(stats?.total_connections_received || 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500">
|
||||||
|
{{ (stats?.total_connections_received || 0).toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-orange-100 p-3 dark:bg-orange-900/30">
|
||||||
|
<Network :size="32" class="text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详细统计信息 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<!-- 命令统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.commandStats')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.totalCommands')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
(stats?.total_commands_processed || 0).toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.opsPerSec')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono font-semibold">{{
|
||||||
|
stats?.instantaneous_ops_per_sec || 0
|
||||||
|
}}</span>
|
||||||
|
<ElTag
|
||||||
|
:type="
|
||||||
|
getPerformanceStatus(stats?.instantaneous_ops_per_sec || 0)
|
||||||
|
.type
|
||||||
|
"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
getPerformanceStatus(stats?.instantaneous_ops_per_sec || 0)
|
||||||
|
.text
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.keyspaceHits')">
|
||||||
|
<span class="font-mono text-green-600">{{
|
||||||
|
(stats?.keyspace_hits || 0).toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.keyspaceMisses')">
|
||||||
|
<span class="font-mono text-red-600">{{
|
||||||
|
(stats?.keyspace_misses || 0).toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.hitRate')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElProgress
|
||||||
|
:percentage="hitRate"
|
||||||
|
:color="getHitRateColor(hitRate)"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="font-semibold">{{ hitRate.toFixed(2) }}%</span>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 连接统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Network :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.connectionStats')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.totalConnections')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
(stats?.total_connections_received || 0).toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.rejectedConnections')">
|
||||||
|
<span
|
||||||
|
class="font-mono"
|
||||||
|
:class="
|
||||||
|
(stats?.rejected_connections || 0) > 0 ? 'text-red-600' : ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ (stats?.rejected_connections || 0).toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.totalInputTraffic')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
formatBytes(stats?.total_net_input_bytes || 0)
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.totalOutputTraffic')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
formatBytes(stats?.total_net_output_bytes || 0)
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('redis-monitor.instantaneousInputRate')"
|
||||||
|
>
|
||||||
|
<span class="font-mono"
|
||||||
|
>{{
|
||||||
|
(stats?.instantaneous_input_kbps || 0).toFixed(2)
|
||||||
|
}}
|
||||||
|
KB/s</span
|
||||||
|
>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem
|
||||||
|
:label="$t('redis-monitor.instantaneousOutputRate')"
|
||||||
|
>
|
||||||
|
<span class="font-mono"
|
||||||
|
>{{
|
||||||
|
(stats?.instantaneous_output_kbps || 0).toFixed(2)
|
||||||
|
}}
|
||||||
|
KB/s</span
|
||||||
|
>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 键操作统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<BarChart :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.keyOperationStats')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.expiresKeys')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
(stats?.expired_keys || 0).toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.evictedKeys')">
|
||||||
|
<span
|
||||||
|
class="font-mono"
|
||||||
|
:class="(stats?.evicted_keys || 0) > 0 ? 'text-orange-600' : ''"
|
||||||
|
>
|
||||||
|
{{ (stats?.evicted_keys || 0).toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.keyspaceHits')">
|
||||||
|
<span class="font-mono text-green-600">{{
|
||||||
|
(stats?.keyspace_hits || 0).toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.keyspaceMisses')">
|
||||||
|
<span class="font-mono text-red-600">{{
|
||||||
|
(stats?.keyspace_misses || 0).toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 同步统计 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.syncStats')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElDescriptions :column="1" border size="small">
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.syncFull')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
(stats?.sync_full || 0).toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.syncPartialOk')">
|
||||||
|
<span class="font-mono text-green-600">{{
|
||||||
|
(stats?.sync_partial_ok || 0).toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.syncPartialErr')">
|
||||||
|
<span
|
||||||
|
class="font-mono"
|
||||||
|
:class="(stats?.sync_partial_err || 0) > 0 ? 'text-red-600' : ''"
|
||||||
|
>
|
||||||
|
{{ (stats?.sync_partial_err || 0).toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.pubsubChannels')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
(stats?.pubsub_channels || 0).toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.pubsubPatterns')">
|
||||||
|
<span class="font-mono">{{
|
||||||
|
(stats?.pubsub_patterns || 0).toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('redis-monitor.latestForkUsec')">
|
||||||
|
<span class="font-mono"
|
||||||
|
>{{ (stats?.latest_fork_usec || 0).toLocaleString() }} μs</span
|
||||||
|
>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 统计说明 -->
|
||||||
|
<ElCard shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Activity :size="18" class="text-primary" />
|
||||||
|
<span class="font-semibold">{{
|
||||||
|
$t('redis-monitor.indicatorDesc')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.opsPerSec') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.opsPerSecDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.hitRate') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.hitRateDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.evictedKeys') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.evictedKeysDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.rejectedConnections') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.rejectedConnectionsDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.syncFull') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.syncFullDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||||
|
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $t('redis-monitor.latestForkUsec') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('redis-monitor.latestForkUsecDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -88,6 +88,22 @@ const formattedMessages = computed<ChatMessage[]>(() => {
|
|||||||
content: step.content || '',
|
content: step.content || '',
|
||||||
tool: step.tool,
|
tool: step.tool,
|
||||||
params: step.params,
|
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,
|
interaction: msg.interaction,
|
||||||
voice: msg.voice as any,
|
voice: msg.voice as any,
|
||||||
@@ -370,6 +386,47 @@ function handleStreamEvent(
|
|||||||
assistantMessage.reasoning_steps = [...streamingSteps.value];
|
assistantMessage.reasoning_steps = [...streamingSteps.value];
|
||||||
break;
|
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': {
|
case 'node_event': {
|
||||||
// 节点事件(如消息节点发送消息)
|
// 节点事件(如消息节点发送消息)
|
||||||
const nodeEvent = event.event;
|
const nodeEvent = event.event;
|
||||||
@@ -425,6 +482,32 @@ function handleStreamEvent(
|
|||||||
break;
|
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': {
|
case 'observation': {
|
||||||
currentStep.value = 'observation';
|
currentStep.value = 'observation';
|
||||||
streamingSteps.value.push({
|
streamingSteps.value.push({
|
||||||
|
|||||||
@@ -498,6 +498,59 @@ const handleStreamEvent = (event: WorkflowStreamEvent, msgId: string) => {
|
|||||||
break;
|
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': {
|
case 'node_event': {
|
||||||
// 节点事件(如消息节点发送消息)
|
// 节点事件(如消息节点发送消息)
|
||||||
const nodeEvent = event.event;
|
const nodeEvent = event.event;
|
||||||
@@ -525,6 +578,43 @@ const handleStreamEvent = (event: WorkflowStreamEvent, msgId: string) => {
|
|||||||
break;
|
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': {
|
case 'node_start': {
|
||||||
// 检查是否已存在相同 node_id 的步骤(避免重复)
|
// 检查是否已存在相同 node_id 的步骤(避免重复)
|
||||||
const existingIndex = currentSteps.value.findIndex(
|
const existingIndex = currentSteps.value.findIndex(
|
||||||
|
|||||||
Reference in New Issue
Block a user