feat: restore core chat and observability
This commit is contained in:
@@ -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": "组织架构",
|
||||
|
||||
@@ -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.001ms,ms(毫秒) = 0.001s'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ $t('redis-monitor.colorIndicator') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{
|
||||
$t('redis-monitor.colorIndicatorDesc') ||
|
||||
'红色(≥1s) > 黄色(≥100ms) > 蓝色(≥10ms) > 绿色'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,444 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
RedisMonitorOverview,
|
||||
RedisRealtimeStats,
|
||||
} from '#/api/core/redis-monitor';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Activity, BarChart, Network, TrendingUp, Zap } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElCard,
|
||||
ElDescriptions,
|
||||
ElDescriptionsItem,
|
||||
ElProgress,
|
||||
ElTag,
|
||||
} from 'element-plus';
|
||||
|
||||
defineOptions({ name: 'StatsPanel' });
|
||||
|
||||
const props = defineProps<{
|
||||
monitorData: null | RedisMonitorOverview;
|
||||
realtimeData: null | RedisRealtimeStats;
|
||||
}>();
|
||||
|
||||
// 统计信息
|
||||
const stats = computed(() => props.monitorData?.stats);
|
||||
|
||||
// 命中率
|
||||
const hitRate = computed(() => {
|
||||
if (!stats.value) return 0;
|
||||
const hits = stats.value.keyspace_hits || 0;
|
||||
const misses = stats.value.keyspace_misses || 0;
|
||||
const total = hits + misses;
|
||||
return total > 0 ? (hits / total) * 100 : 0;
|
||||
});
|
||||
|
||||
// 格式化字节大小
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
// 格式化大数字
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)}B`;
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(2)}M`;
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(2)}K`;
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
// 获取命中率颜色
|
||||
function getHitRateColor(rate: number): 'danger' | 'success' | 'warning' {
|
||||
if (rate >= 90) return 'success';
|
||||
if (rate >= 70) return 'warning';
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
// 获取性能状态
|
||||
function getPerformanceStatus(opsPerSec: number): {
|
||||
text: string;
|
||||
type: 'danger' | 'info' | 'success' | 'warning';
|
||||
} {
|
||||
if (opsPerSec >= 10_000)
|
||||
return { text: $t('redis-monitor.excellent'), type: 'success' };
|
||||
if (opsPerSec >= 5000)
|
||||
return { text: $t('redis-monitor.good'), type: 'success' };
|
||||
if (opsPerSec >= 1000)
|
||||
return { text: $t('redis-monitor.normal'), type: 'info' };
|
||||
if (opsPerSec >= 100)
|
||||
return { text: $t('redis-monitor.lower'), type: 'warning' };
|
||||
return { text: $t('redis-monitor.veryLow'), type: 'danger' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 关键性能指标 -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- 每秒操作数 -->
|
||||
<ElCard shadow="hover">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('redis-monitor.opsPerSec') }}
|
||||
</div>
|
||||
<div class="text-3xl font-bold">
|
||||
{{ formatNumber(stats?.instantaneous_ops_per_sec || 0) }}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<ElTag
|
||||
:type="
|
||||
getPerformanceStatus(stats?.instantaneous_ops_per_sec || 0)
|
||||
.type
|
||||
"
|
||||
size="small"
|
||||
>
|
||||
{{
|
||||
getPerformanceStatus(stats?.instantaneous_ops_per_sec || 0)
|
||||
.text
|
||||
}}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-blue-100 p-3 dark:bg-blue-900/30">
|
||||
<Zap :size="32" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- 命中率 -->
|
||||
<ElCard shadow="hover">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('redis-monitor.cacheHitRate') }}
|
||||
</div>
|
||||
<div class="text-3xl font-bold">{{ hitRate.toFixed(2) }}%</div>
|
||||
<div class="mt-2">
|
||||
<ElProgress
|
||||
:percentage="hitRate"
|
||||
:color="getHitRateColor(hitRate)"
|
||||
:show-text="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-green-100 p-3 dark:bg-green-900/30">
|
||||
<TrendingUp :size="32" class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- 总命令数 -->
|
||||
<ElCard shadow="hover">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('redis-monitor.totalCommands') }}
|
||||
</div>
|
||||
<div class="text-3xl font-bold">
|
||||
{{ formatNumber(stats?.total_commands_processed || 0) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{{ (stats?.total_commands_processed || 0).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-purple-100 p-3 dark:bg-purple-900/30">
|
||||
<Activity :size="32" class="text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- 总连接数 -->
|
||||
<ElCard shadow="hover">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('redis-monitor.totalConnections') }}
|
||||
</div>
|
||||
<div class="text-3xl font-bold">
|
||||
{{ formatNumber(stats?.total_connections_received || 0) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{{ (stats?.total_connections_received || 0).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-orange-100 p-3 dark:bg-orange-900/30">
|
||||
<Network :size="32" class="text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 详细统计信息 -->
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<!-- 命令统计 -->
|
||||
<ElCard shadow="hover">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Activity :size="18" class="text-primary" />
|
||||
<span class="font-semibold">{{
|
||||
$t('redis-monitor.commandStats')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<ElDescriptions :column="1" border size="small">
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.totalCommands')">
|
||||
<span class="font-mono">{{
|
||||
(stats?.total_commands_processed || 0).toLocaleString()
|
||||
}}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.opsPerSec')">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono font-semibold">{{
|
||||
stats?.instantaneous_ops_per_sec || 0
|
||||
}}</span>
|
||||
<ElTag
|
||||
:type="
|
||||
getPerformanceStatus(stats?.instantaneous_ops_per_sec || 0)
|
||||
.type
|
||||
"
|
||||
size="small"
|
||||
>
|
||||
{{
|
||||
getPerformanceStatus(stats?.instantaneous_ops_per_sec || 0)
|
||||
.text
|
||||
}}
|
||||
</ElTag>
|
||||
</div>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.keyspaceHits')">
|
||||
<span class="font-mono text-green-600">{{
|
||||
(stats?.keyspace_hits || 0).toLocaleString()
|
||||
}}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.keyspaceMisses')">
|
||||
<span class="font-mono text-red-600">{{
|
||||
(stats?.keyspace_misses || 0).toLocaleString()
|
||||
}}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.hitRate')">
|
||||
<div class="flex items-center gap-2">
|
||||
<ElProgress
|
||||
:percentage="hitRate"
|
||||
:color="getHitRateColor(hitRate)"
|
||||
class="flex-1"
|
||||
/>
|
||||
<span class="font-semibold">{{ hitRate.toFixed(2) }}%</span>
|
||||
</div>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
|
||||
<!-- 连接统计 -->
|
||||
<ElCard shadow="hover">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Network :size="18" class="text-primary" />
|
||||
<span class="font-semibold">{{
|
||||
$t('redis-monitor.connectionStats')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<ElDescriptions :column="1" border size="small">
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.totalConnections')">
|
||||
<span class="font-mono">{{
|
||||
(stats?.total_connections_received || 0).toLocaleString()
|
||||
}}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.rejectedConnections')">
|
||||
<span
|
||||
class="font-mono"
|
||||
:class="
|
||||
(stats?.rejected_connections || 0) > 0 ? 'text-red-600' : ''
|
||||
"
|
||||
>
|
||||
{{ (stats?.rejected_connections || 0).toLocaleString() }}
|
||||
</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.totalInputTraffic')">
|
||||
<span class="font-mono">{{
|
||||
formatBytes(stats?.total_net_input_bytes || 0)
|
||||
}}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.totalOutputTraffic')">
|
||||
<span class="font-mono">{{
|
||||
formatBytes(stats?.total_net_output_bytes || 0)
|
||||
}}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
:label="$t('redis-monitor.instantaneousInputRate')"
|
||||
>
|
||||
<span class="font-mono"
|
||||
>{{
|
||||
(stats?.instantaneous_input_kbps || 0).toFixed(2)
|
||||
}}
|
||||
KB/s</span
|
||||
>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
:label="$t('redis-monitor.instantaneousOutputRate')"
|
||||
>
|
||||
<span class="font-mono"
|
||||
>{{
|
||||
(stats?.instantaneous_output_kbps || 0).toFixed(2)
|
||||
}}
|
||||
KB/s</span
|
||||
>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
|
||||
<!-- 键操作统计 -->
|
||||
<ElCard shadow="hover">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<BarChart :size="18" class="text-primary" />
|
||||
<span class="font-semibold">{{
|
||||
$t('redis-monitor.keyOperationStats')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<ElDescriptions :column="1" border size="small">
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.expiresKeys')">
|
||||
<span class="font-mono">{{
|
||||
(stats?.expired_keys || 0).toLocaleString()
|
||||
}}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.evictedKeys')">
|
||||
<span
|
||||
class="font-mono"
|
||||
:class="(stats?.evicted_keys || 0) > 0 ? 'text-orange-600' : ''"
|
||||
>
|
||||
{{ (stats?.evicted_keys || 0).toLocaleString() }}
|
||||
</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.keyspaceHits')">
|
||||
<span class="font-mono text-green-600">{{
|
||||
(stats?.keyspace_hits || 0).toLocaleString()
|
||||
}}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.keyspaceMisses')">
|
||||
<span class="font-mono text-red-600">{{
|
||||
(stats?.keyspace_misses || 0).toLocaleString()
|
||||
}}</span>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
|
||||
<!-- 同步统计 -->
|
||||
<ElCard shadow="hover">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Activity :size="18" class="text-primary" />
|
||||
<span class="font-semibold">{{
|
||||
$t('redis-monitor.syncStats')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<ElDescriptions :column="1" border size="small">
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.syncFull')">
|
||||
<span class="font-mono">{{
|
||||
(stats?.sync_full || 0).toLocaleString()
|
||||
}}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.syncPartialOk')">
|
||||
<span class="font-mono text-green-600">{{
|
||||
(stats?.sync_partial_ok || 0).toLocaleString()
|
||||
}}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.syncPartialErr')">
|
||||
<span
|
||||
class="font-mono"
|
||||
:class="(stats?.sync_partial_err || 0) > 0 ? 'text-red-600' : ''"
|
||||
>
|
||||
{{ (stats?.sync_partial_err || 0).toLocaleString() }}
|
||||
</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.pubsubChannels')">
|
||||
<span class="font-mono">{{
|
||||
(stats?.pubsub_channels || 0).toLocaleString()
|
||||
}}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.pubsubPatterns')">
|
||||
<span class="font-mono">{{
|
||||
(stats?.pubsub_patterns || 0).toLocaleString()
|
||||
}}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('redis-monitor.latestForkUsec')">
|
||||
<span class="font-mono"
|
||||
>{{ (stats?.latest_fork_usec || 0).toLocaleString() }} μs</span
|
||||
>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 统计说明 -->
|
||||
<ElCard shadow="hover">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Activity :size="18" class="text-primary" />
|
||||
<span class="font-semibold">{{
|
||||
$t('redis-monitor.indicatorDesc')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ $t('redis-monitor.opsPerSec') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('redis-monitor.opsPerSecDesc') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ $t('redis-monitor.hitRate') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('redis-monitor.hitRateDesc') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ $t('redis-monitor.evictedKeys') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('redis-monitor.evictedKeysDesc') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ $t('redis-monitor.rejectedConnections') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('redis-monitor.rejectedConnectionsDesc') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ $t('redis-monitor.syncFull') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('redis-monitor.syncFullDesc') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||
<div class="mb-1 font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ $t('redis-monitor.latestForkUsec') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $t('redis-monitor.latestForkUsecDesc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -88,6 +88,22 @@ const formattedMessages = computed<ChatMessage[]>(() => {
|
||||
content: step.content || '',
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user