feat: restore core chat and observability

This commit is contained in:
2026-06-22 02:24:10 +08:00
parent 524c027a4a
commit 705cfe47e8
43 changed files with 12301 additions and 11 deletions
+6
View File
@@ -310,10 +310,12 @@ backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_ui_design_node.py
!backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py
web/apps/web-ele/src/api/core/database-monitor.ts
!web/apps/web-ele/src/api/core/database-monitor.ts
web/apps/web-ele/src/api/core/demo.ts
web/apps/web-ele/src/api/core/link-preview.ts
web/apps/web-ele/src/api/core/redis-manager.ts
web/apps/web-ele/src/api/core/redis-monitor.ts
!web/apps/web-ele/src/api/core/redis-monitor.ts
web/apps/web-ele/src/api/core/region.ts
web/apps/web-ele/src/api/core/scheduler.ts
web/apps/web-ele/src/api/core/server-monitor.ts
@@ -324,10 +326,14 @@ web/apps/web-ele/src/views/_core/data-source/
web/apps/web-ele/src/views/_core/database-connection/
web/apps/web-ele/src/views/_core/database-manager/
web/apps/web-ele/src/views/_core/database-monitor/
!web/apps/web-ele/src/views/_core/database-monitor/
!web/apps/web-ele/src/views/_core/database-monitor/**
web/apps/web-ele/src/views/_core/demo/
web/apps/web-ele/src/views/_core/mobile-signature/
web/apps/web-ele/src/views/_core/redis-manager/
web/apps/web-ele/src/views/_core/redis-monitor/
!web/apps/web-ele/src/views/_core/redis-monitor/
!web/apps/web-ele/src/views/_core/redis-monitor/**
web/apps/web-ele/src/views/_core/region-manager/
web/apps/web-ele/src/views/_core/scheduler/
web/apps/web-ele/src/views/_core/server-monitor/
@@ -8,7 +8,7 @@ from typing import AsyncGenerator, Dict, List, Optional
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.models import AIApp, Conversation, Message, LLMModel
from ai_platform.models import AIApp, Conversation, Message, LLMModel, LLMProvider
from utils.context import get_current_user_id_from_context
from .llm_service import LLMService
@@ -352,15 +352,33 @@ class ChatService:
async def _get_effective_model(self, conversation: Conversation, app: AIApp) -> Optional[LLMModel]:
"""获取有效的模型"""
model_id = conversation.model_override_id or app.model_id
if not model_id:
return None
if model_id:
result = await self._db.execute(
select(LLMModel)
.join(LLMProvider, LLMProvider.id == LLMModel.provider_id)
.where(
LLMModel.id == model_id,
LLMModel.is_active == True,
LLMModel.is_deleted == False,
LLMProvider.is_active == True,
LLMProvider.is_deleted == False,
)
)
model = result.scalar_one_or_none()
if model:
return model
result = await self._db.execute(
select(LLMModel).where(
LLMModel.id == model_id,
select(LLMModel)
.join(LLMProvider, LLMProvider.id == LLMModel.provider_id)
.where(
LLMModel.is_active == True,
LLMModel.is_deleted == False
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()
+151 -1
View File
@@ -17,7 +17,7 @@ from core.menu.schema import MenuCreate, MenuUpdate
menu_cache = CacheManager(prefix="menu:")
# 缓存key
AI_AGENT_ADMIN_MENU_CACHE_VERSION = "v19"
AI_AGENT_ADMIN_MENU_CACHE_VERSION = "v20"
MENU_TREE_CACHE_KEY = f"tree:ai_agent_admin:{AI_AGENT_ADMIN_MENU_CACHE_VERSION}"
USER_ROUTE_CACHE_PREFIX = f"user_route:ai_agent_admin:{AI_AGENT_ADMIN_MENU_CACHE_VERSION}:"
@@ -32,7 +32,12 @@ AI_AGENT_ADMIN_MENU_NAMES = {
"AIWorkflowRuns",
"CodexAgentChat",
"KnowledgeBase",
"StartChat",
"SystemManagement",
"SystemMonitoring",
"ServerMonitor",
"RedisMonitor",
"DatabaseMonitor",
"SystemPermission",
"UserManagement",
"SystemDept",
@@ -63,7 +68,12 @@ AI_AGENT_ADMIN_VISIBLE_MENU_NAMES = {
"AIWorkflowRuns",
"CodexAgentChat",
"KnowledgeBase",
"StartChat",
"SystemManagement",
"SystemMonitoring",
"ServerMonitor",
"RedisMonitor",
"DatabaseMonitor",
"SystemPermission",
"UserManagement",
"SystemDept",
@@ -115,7 +125,43 @@ AI_AGENT_ADMIN_MESSAGE_CHILD_MENU_NAMES = {
"MessageList",
}
AI_AGENT_ADMIN_MONITOR_CHILD_MENU_NAMES = {
"DatabaseMonitor",
"RedisMonitor",
"ServerMonitor",
}
AI_AGENT_ADMIN_MENU_OVERRIDES = {
"StartChat": {
"component": "/_core/chat/index",
"hideInMenu": False,
"noBasicLayout": True,
"path": "/chat",
"title": "menu-title.startChat",
},
"SystemMonitoring": {
"hideInMenu": False,
"path": "/monitor",
"title": "menu-title.systemMonitoring",
},
"ServerMonitor": {
"component": "/_core/server-monitor/index",
"hideInMenu": False,
"path": "/monitor/server",
"title": "menu-title.serverMonitoring",
},
"RedisMonitor": {
"component": "/_core/redis-monitor/index",
"hideInMenu": False,
"path": "/monitor/redis",
"title": "menu-title.redisMonitoring",
},
"DatabaseMonitor": {
"component": "/_core/database-monitor/index",
"hideInMenu": False,
"path": "/monitor/database",
"title": "menu-title.databaseMonitoring",
},
"SystemConfigManager": {
"component": "/_core/system-config/index",
"path": "/system-config",
@@ -145,10 +191,107 @@ AI_AGENT_ADMIN_MENU_OVERRIDES = {
},
}
AI_AGENT_ADMIN_SYNTHETIC_MENUS = [
{
"id": "aiadm-chat",
"name": "StartChat",
"title": "menu-title.startChat",
"path": "/chat",
"type": "menu",
"component": "/_core/chat/index",
"icon": "lucide:align-center-vertical",
"order": 20,
"noBasicLayout": True,
},
{
"id": "aiadm-monitor",
"name": "SystemMonitoring",
"title": "menu-title.systemMonitoring",
"path": "/monitor",
"type": "catalog",
"component": None,
"icon": "lucide:activity",
"order": 80,
},
{
"id": "aiadm-server",
"name": "ServerMonitor",
"title": "menu-title.serverMonitoring",
"path": "/monitor/server",
"type": "menu",
"component": "/_core/server-monitor/index",
"icon": "carbon:bare-metal-server",
"order": 10,
},
{
"id": "aiadm-redis",
"name": "RedisMonitor",
"title": "menu-title.redisMonitoring",
"path": "/monitor/redis",
"type": "menu",
"component": "/_core/redis-monitor/index",
"icon": "logos:redis",
"order": 20,
},
{
"id": "aiadm-dbmon",
"name": "DatabaseMonitor",
"title": "menu-title.databaseMonitoring",
"path": "/monitor/database",
"type": "menu",
"component": "/_core/database-monitor/index",
"icon": "carbon:db2-database",
"order": 30,
},
]
def _make_ai_agent_admin_menu(spec: Dict[str, Any]) -> Menu:
menu = Menu()
menu.id = spec["id"]
menu.application_id = None
menu.is_system = False
menu.parent_id = None
menu.name = spec["name"]
menu.title = spec["title"]
menu.authCode = None
menu.path = spec["path"]
menu.type = spec["type"]
menu.component = spec.get("component")
menu.redirect = None
menu.activePath = None
menu.query = None
menu.noBasicLayout = bool(spec.get("noBasicLayout", False))
menu.icon = spec.get("icon")
menu.activeIcon = None
menu.order = spec["order"]
menu.hideInMenu = False
menu.hideChildrenInMenu = False
menu.hideInBreadcrumb = False
menu.hideInTab = False
menu.affixTab = False
menu.affixTabOrder = None
menu.keepAlive = False
menu.maxNumOfOpenTab = None
menu.fullPathKey = True
menu.link = None
menu.iframeSrc = None
menu.openInNewWindow = False
menu.badge = None
menu.badgeType = None
menu.badgeVariants = None
return menu
def _filter_ai_agent_admin_menus(menus: List[Menu]) -> List[Menu]:
"""Keep only the lightweight admin and AI modules for this product."""
filtered = [menu for menu in menus if menu.name in AI_AGENT_ADMIN_MENU_NAMES]
existing_names = {menu.name for menu in filtered}
filtered.extend(
_make_ai_agent_admin_menu(spec)
for spec in AI_AGENT_ADMIN_SYNTHETIC_MENUS
if spec["name"] not in existing_names
)
for menu in filtered:
_normalize_ai_agent_admin_menu(menu)
_normalize_ai_agent_admin_parentage(filtered)
@@ -184,6 +327,10 @@ def _normalize_ai_agent_admin_parentage(menus: List[Menu]) -> None:
(menu for menu in menus if menu.name == "Message"),
None,
)
monitor_menu = next(
(menu for menu in menus if menu.name == "SystemMonitoring"),
None,
)
for menu in menus:
if system_menu and menu.name in AI_AGENT_ADMIN_SYSTEM_CHILD_MENU_NAMES:
menu.parent_id = system_menu.id
@@ -194,6 +341,9 @@ def _normalize_ai_agent_admin_parentage(menus: List[Menu]) -> None:
if message_menu and menu.name in AI_AGENT_ADMIN_MESSAGE_CHILD_MENU_NAMES:
menu.parent_id = message_menu.id
continue
if monitor_menu and menu.name in AI_AGENT_ADMIN_MONITOR_CHILD_MENU_NAMES:
menu.parent_id = monitor_menu.id
continue
if not menu.parent_id or menu.parent_id in retained_ids:
continue
menu.parent_id = None
+10
View File
@@ -7,8 +7,11 @@ from core.application.api import router as application_router
from core.api_token.api import router as api_token_router
from core.auth.api import router as auth_router
from core.chat.api import router as chat_router
from core.database_monitor.api import router as database_monitor_router
from core.dept.api import router as dept_router
from core.device.api import router as device_router
from core.dict.api import router as dict_router
from core.dict_item.api import router as dict_item_router
from core.file_manager.router import router as file_manager_router
from core.login_log.api import router as login_log_router
from core.menu.api import router as menu_router
@@ -18,10 +21,12 @@ from core.oauth.api import router as oauth_router
from core.page_manager.api import router as page_manager_router
from core.permission.api import router as permission_router
from core.post.api import router as post_router
from core.redis_monitor.api import router as redis_monitor_router
from core.resource_scope.field_permission.api import router as field_permission_router
from core.resource_scope.scope_permission.api import router as resource_scope_router
from core.role.api import router as role_router
from core.server_monitor.api import router as server_monitor_router
from core.system_config.api import router as system_config_router
from core.ui_config.api import router as ui_config_router
from core.user.api import router as user_router
@@ -31,11 +36,15 @@ router = APIRouter()
router.include_router(application_router)
router.include_router(auth_router)
router.include_router(chat_router)
router.include_router(database_monitor_router)
router.include_router(dept_router)
router.include_router(device_router)
router.include_router(dict_router)
router.include_router(dict_item_router)
router.include_router(menu_router)
router.include_router(permission_router)
router.include_router(post_router)
router.include_router(redis_monitor_router)
router.include_router(resource_scope_router)
router.include_router(field_permission_router)
router.include_router(role_router)
@@ -47,5 +56,6 @@ router.include_router(announcement_router)
router.include_router(oauth_router)
router.include_router(page_manager_router)
router.include_router(server_monitor_router)
router.include_router(system_config_router)
router.include_router(ui_config_router)
router.include_router(api_token_router)
+1
View File
@@ -6,6 +6,7 @@ public-hoist-pattern[]=prettier-plugin-tailwindcss
public-hoist-pattern[]=stylelint
public-hoist-pattern[]=*postcss*
public-hoist-pattern[]=@commitlint/*
public-hoist-pattern[]=@element-plus/icons-vue
public-hoist-pattern[]=czg
public-hoist-pattern[]=prosemirror-*
public-hoist-pattern[]=@univerjs/*
@@ -901,7 +901,10 @@ export interface WorkflowStreamEvent {
accumulated_content?: string; // 累积内容
// 并行执行字段
branches?: string[]; // 并行分支 ID 列表
branch_id?: string;
branch_label?: string;
branch_results?: Record<string, any>; // 各分支执行结果
results?: any;
// 循环迭代字段
iteration?: number; // 当前迭代次数(从 0 开始)
total?: number; // 总迭代次数(for_each 模式)
@@ -1154,20 +1157,37 @@ export interface ReasoningStep {
| 'action'
| 'annotation_reply'
| 'knowledge_retrieval'
| 'loop_complete'
| 'loop_iteration_complete'
| 'loop_iteration_error'
| 'loop_iteration_start'
| 'node_complete'
| 'node_start'
| 'observation'
| 'parallel_complete'
| 'parallel_start'
| 'thought';
content: string;
tool?: string;
params?: Record<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;
// 对话流模式:节点信息
node_id?: string;
node_type?: string;
output?: any;
/** 步骤状态:running 执行中,completed 已完成 */
status?: 'completed' | 'running';
status?: 'completed' | 'failed' | 'running';
}
/** 工具调用记录 */
@@ -1237,6 +1257,8 @@ export interface AgentChatEvent {
| 'node_event'
| 'node_start'
| 'observation'
| 'parallel_complete'
| 'parallel_start'
| 'start'
| 'thought'
| 'waiting_input';
@@ -1254,7 +1276,12 @@ export interface AgentChatEvent {
node_id?: string;
node_type?: string;
node_label?: string;
branch_id?: string;
branch_label?: string;
communication?: Record<string, any>;
output?: any;
results?: any;
branch_results?: Record<string, any>;
outputs?: {
output?: 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": "字典管理",
"fileManagement": "文件管理",
"serverMonitoring": "服务器监控",
"redisMonitoring": "Redis监控",
"databaseMonitoring": "数据库监控",
"uiConfig": "界面配置",
"loginLog": "登录日志",
"orgChart": "组织架构",
+4
View File
@@ -177,6 +177,8 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
'../views/_core/announcement/list.vue',
'../views/_core/application/index.vue',
'../views/_core/authentication/login.vue',
'../views/_core/chat/**/*.vue',
'../views/_core/database-monitor/**/*.vue',
'../views/_core/dept/index.vue',
'../views/_core/dict/index.vue',
'../views/_core/fallback/**/*.vue',
@@ -190,6 +192,8 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
'../views/_core/permission/index.vue',
'../views/_core/post/index.vue',
'../views/_core/role/index.vue',
'../views/_core/redis-monitor/**/*.vue',
'../views/_core/server-monitor/index.vue',
'../views/_core/system-config/index.vue',
'../views/_core/ui-config/index.vue',
'../views/_core/user/index.vue',
@@ -17,6 +17,10 @@ export const LIGHT_MENU_NAMES = new Set([
'KnowledgeBase',
'LoginLog',
'OrgNode',
'DatabaseMonitor',
'RedisMonitor',
'ServerMonitor',
'StartChat',
'SystemDept',
'SystemDict',
'SystemFileManager',
@@ -25,6 +29,7 @@ export const LIGHT_MENU_NAMES = new Set([
'SystemOrgChart',
'SystemConfig',
'SystemConfigManager',
'SystemMonitoring',
'SystemMenu',
'SystemPermission',
'SystemPost',
@@ -44,10 +49,12 @@ const LIGHT_ROUTE_PATH_PREFIXES = [
'/ai-platform/workflow-runs',
'/agent-chat',
'/application',
'/chat',
'/message/list',
'/message/announcement',
'/message/announcement-list',
'/page-render/main_home',
'/monitor',
'/core/ui-config',
'/system-config',
'/system/dept',
@@ -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.001msms(毫秒) = 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 || '',
tool: step.tool,
params: step.params,
branch_id: step.branch_id,
branch_label: step.branch_label,
agent_code: step.agent_code,
agent_name: step.agent_name,
model: step.model,
model_id: step.model_id,
subflow_name: step.subflow_name,
from_subflow: step.from_subflow,
collaboration_role: step.collaboration_role,
collaboration_mode: step.collaboration_mode,
communication: step.communication,
node_id: step.node_id,
node_type: step.node_type,
output: step.output,
status: step.status,
timestamp: step.timestamp,
})),
interaction: msg.interaction,
voice: msg.voice as any,
@@ -370,6 +386,47 @@ function handleStreamEvent(
assistantMessage.reasoning_steps = [...streamingSteps.value];
break;
}
case 'parallel_complete': {
const existingIndex = streamingSteps.value.findIndex((s) =>
event.branch_id
? s.branch_id === event.branch_id && s.type === 'parallel_start'
: s.node_id === event.node_id && s.type === 'parallel_start',
);
const content =
event.content ||
event.branch_label ||
event.node_label ||
'并行分支执行完成';
const output =
event.results || event.output || event.outputs || event.branch_results;
if (existingIndex === -1) {
streamingSteps.value.push({
type: 'parallel_complete',
content,
node_id: event.node_id,
node_type: event.node_type,
branch_id: event.branch_id,
branch_label: event.branch_label,
output,
status: 'completed',
timestamp: new Date().toISOString(),
});
} else {
const existingStep = streamingSteps.value[existingIndex]!;
streamingSteps.value[existingIndex] = {
...existingStep,
type: 'parallel_complete',
content,
output,
status: 'completed',
timestamp: existingStep.timestamp || new Date().toISOString(),
};
}
assistantMessage.reasoning_steps = [...streamingSteps.value];
break;
}
case 'node_event': {
// 节点事件(如消息节点发送消息)
const nodeEvent = event.event;
@@ -425,6 +482,32 @@ function handleStreamEvent(
break;
}
case 'parallel_start': {
const existingIndex = streamingSteps.value.findIndex((s) =>
event.branch_id
? s.branch_id === event.branch_id && s.type === 'parallel_start'
: s.node_id === event.node_id && s.type === 'parallel_start',
);
if (existingIndex === -1) {
streamingSteps.value.push({
type: 'parallel_start',
content:
event.content ||
event.branch_label ||
event.node_label ||
'并行分支执行中',
node_id: event.node_id,
node_type: event.node_type,
branch_id: event.branch_id,
branch_label: event.branch_label,
status: 'running',
timestamp: new Date().toISOString(),
});
}
assistantMessage.reasoning_steps = [...streamingSteps.value];
break;
}
case 'observation': {
currentStep.value = 'observation';
streamingSteps.value.push({
@@ -498,6 +498,59 @@ const handleStreamEvent = (event: WorkflowStreamEvent, msgId: string) => {
break;
}
case 'parallel_complete': {
const existingIndex = currentSteps.value.findIndex((s) =>
event.branch_id
? s.branch_id === event.branch_id && s.type === 'parallel_start'
: s.node_id === event.node_id && s.type === 'parallel_start',
);
const content =
event.content ||
event.branch_label ||
event.node_label ||
'并行分支执行完成';
const output =
event.results || event.output || event.outputs || event.branch_results;
if (existingIndex === -1) {
currentSteps.value.push({
type: 'parallel_complete',
content,
node_id: event.node_id,
node_type: event.node_type,
branch_id: event.branch_id,
branch_label: event.branch_label,
output,
status: 'completed',
timestamp: new Date().toISOString(),
});
} else {
currentSteps.value[existingIndex] = {
...currentSteps.value[existingIndex],
type: 'parallel_complete',
content,
output,
status: 'completed',
};
}
updateAssistantMessage(msgId, {
reasoning_steps: [...currentSteps.value],
});
emit('node-complete', {
node_id: event.node_id,
node_type: event.node_type || 'parallel',
status: event.status || 'success',
elapsed_time: event.elapsed_time,
tokens_used: event.tokens_used,
outputs: output,
branch_id: event.branch_id,
branch_label: event.branch_label,
branch_results: event.branch_results,
});
break;
}
case 'node_event': {
// 节点事件(如消息节点发送消息)
const nodeEvent = event.event;
@@ -525,6 +578,43 @@ const handleStreamEvent = (event: WorkflowStreamEvent, msgId: string) => {
break;
}
case 'parallel_start': {
const existingIndex = currentSteps.value.findIndex((s) =>
event.branch_id
? s.branch_id === event.branch_id && s.type === 'parallel_start'
: s.node_id === event.node_id && s.type === 'parallel_start',
);
if (existingIndex === -1) {
currentSteps.value.push({
type: 'parallel_start',
content:
event.content ||
event.branch_label ||
event.node_label ||
'并行分支执行中',
node_id: event.node_id,
node_type: event.node_type,
branch_id: event.branch_id,
branch_label: event.branch_label,
status: 'running',
timestamp: new Date().toISOString(),
});
}
updateAssistantMessage(msgId, {
reasoning_steps: [...currentSteps.value],
});
emit('node-start', {
node_id: event.node_id,
node_type: event.node_type || 'parallel',
node_label: event.node_label,
branch_id: event.branch_id,
branch_label: event.branch_label,
branches: event.branches,
});
break;
}
case 'node_start': {
// 检查是否已存在相同 node_id 的步骤(避免重复)
const existingIndex = currentSteps.value.findIndex(