功能模块:添加监控模块

- 添加在线用户监控(src/views/monitor/online/)
  - 在线用户列表
  - 用户强制下线
  - 在线状态实时监控
- 添加服务器监控(src/views/monitor/server/)
  - CPU使用率监控
  - 内存使用情况
  - 磁盘空间监控
  - 网络流量统计
- 添加Redis监控(src/views/monitor/redis/)
  - Redis连接信息
  - 缓存命中率统计
  - 键值空间分析
- 添加缓存监控(src/views/monitor/cache/)
  - 缓存列表管理
  - 缓存清理功能
- 添加定时任务管理(src/views/monitor/job/)
  - 任务列表管理
  - 任务执行记录
  - 任务启停控制
- 添加自定义业务组件(src/components/custom/)
  - 监控仪表盘组件
  - 统计图表组件
This commit is contained in:
Leo 2025-10-08 02:30:54 +08:00
parent b64d303570
commit 387e5f75d1
10 changed files with 4233 additions and 0 deletions

View File

@ -0,0 +1,29 @@
<template>
<MdEditor
v-model="model" :theme="appStore.colorMode" :toolbars-exclude="toolbarsExclude"
/>
</template>
<script setup lang="ts">
import type { ToolbarNames } from 'md-editor-v3'
import { useAppStore } from '@/store'
import { MdEditor } from 'md-editor-v3'
// https://imzbf.github.io/md-editor-v3/zh-CN/docs
import 'md-editor-v3/lib/style.css'
const model = defineModel<string>()
const appStore = useAppStore()
const toolbarsExclude: ToolbarNames[] = [
'mermaid',
'katex',
'github',
'htmlPreview',
'catalog',
]
</script>
<style scoped></style>

View File

@ -0,0 +1,107 @@
<template>
<div ref="editorRef" />
</template>
<script setup lang="ts">
import Quill from 'quill'
import { useTemplateRef } from 'vue'
import 'quill/dist/quill.snow.css'
defineOptions({
name: 'RichTextEditor',
})
const { disabled } = defineProps<Props>()
interface Props {
disabled?: boolean
}
const model = defineModel<string>()
let editorInst = null
const editorModel = ref<string>()
onMounted(() => {
initEditor()
})
const editorRef = useTemplateRef<HTMLElement>('editorRef')
function initEditor() {
const options = {
modules: {
toolbar: [
{ header: [1, 2, 3, 4, 5, 6, false] }, //
'bold', //
'italic', //
'strike', // 线
{ size: ['small', false, 'large', 'huge'] }, //
{ font: [] }, //
{ color: [] }, //
{ background: [] }, //
'link', //
'image', //
'blockquote', //
'link', //
'image', //
'video', //
{ list: 'bullet' }, //
{ list: 'ordered' }, //
{ script: 'sub' }, //
{ script: 'super' }, //
{ align: [] }, //
'formula', //
'clean', // remove formatting button
],
},
placeholder: 'Insert text here ...',
theme: 'snow',
}
const quill = new Quill(editorRef.value!, options)
quill.on('text-change', (_delta, _oldDelta, _source) => {
editorModel.value = quill.getSemanticHTML()
})
if (disabled)
quill.enable(false)
editorInst = quill
if (model.value)
setContents(model.value)
}
function setContents(html: string) {
editorInst!.setContents(editorInst!.clipboard.convert({ html }))
}
watch(
() => model.value,
(newValue, _oldValue) => {
if (newValue && newValue !== editorModel.value) {
setContents(newValue)
}
else if (!newValue) {
setContents('')
}
},
)
watch(editorModel, (newValue, oldValue) => {
if (newValue && newValue !== oldValue)
model.value = newValue
else if (!newValue)
editorInst!.setContents([])
})
watch(
() => disabled,
(newValue, _oldValue) => {
editorInst!.enable(!newValue)
},
)
onBeforeUnmount(() => editorInst = null)
</script>

954
src/views/monitor/cache/index.vue vendored Normal file
View File

@ -0,0 +1,954 @@
<template>
<div class="cache-monitor-container">
<!-- 三栏布局 -->
<div class="cache-layout">
<!-- 左侧缓存名称列表 -->
<n-card title="缓存名称" class="cache-names-panel">
<template #header-extra>
<n-space size="small">
<n-button text size="small" :loading="loading" @click="refreshCacheNames">
<template #icon>
<n-icon><IconParkOutlineRefresh /></n-icon>
</template>
</n-button>
<n-popconfirm
trigger="click"
:show-icon="false"
placement="bottom"
@positive-click="clearAllCache"
>
<template #trigger>
<n-button text size="small" type="error" :loading="clearingAll">
<template #icon>
<n-icon><IconParkOutlineDelete /></n-icon>
</template>
</n-button>
</template>
<div class="confirm-content">
<p class="confirm-title">
确定要清空所有缓存吗
</p>
<p class="confirm-desc">
此操作将删除Redis中的所有数据且不可恢复
</p>
</div>
</n-popconfirm>
</n-space>
</template>
<div class="cache-names-list">
<div
v-for="(cache, index) in cacheNames"
:key="index"
class="cache-name-item"
:class="{ active: selectedCacheName === cache.cacheName }"
@click="selectCacheName(cache.cacheName)"
>
<div class="cache-name-content">
<div class="cache-name">
{{ cache.cacheName }}
</div>
<div class="cache-remark">
{{ cache.remark }}
</div>
</div>
<div class="cache-actions">
<n-popconfirm
trigger="click"
:show-icon="false"
placement="right"
@positive-click="deleteCacheByName(cache.cacheName)"
@click.stop
>
<template #trigger>
<n-button type="error" size="small" text>
<template #icon>
<n-icon><IconParkOutlineDelete /></n-icon>
</template>
</n-button>
</template>
确定删除此缓存分类的所有数据吗
</n-popconfirm>
</div>
</div>
</div>
</n-card>
<!-- 中间缓存键名列表 -->
<n-card :title="`缓存键名 (${selectedCacheName || '请选择缓存类型'})`" class="cache-keys-panel">
<template #header-extra>
<n-space size="small">
<n-button
text
size="small"
:loading="loadingKeys"
:disabled="!selectedCacheName"
@click="refreshCacheKeys"
>
<template #icon>
<n-icon><IconParkOutlineRefresh /></n-icon>
</template>
</n-button>
</n-space>
</template>
<div v-if="!selectedCacheName" class="empty-state">
<n-empty description="请先选择左侧的缓存类型">
<template #icon>
<n-icon><IconParkOutlineKey /></n-icon>
</template>
</n-empty>
</div>
<div v-else-if="loadingKeys" class="loading-state">
<n-spin size="medium">
<template #description>
加载缓存键名中...
</template>
</n-spin>
</div>
<div v-else class="cache-keys-list">
<div v-if="cacheKeys.length === 0" class="empty-state">
<n-empty description="该缓存类型下暂无数据">
<template #icon>
<n-icon><IconParkOutlineFileQuestion /></n-icon>
</template>
</n-empty>
</div>
<div
v-for="(key, index) in cacheKeys"
:key="index"
class="cache-key-item"
:class="{ active: selectedCacheKey === key }"
@click="selectCacheKey(key)"
>
<div class="cache-key-content">
<div class="cache-key">
{{ key }}
</div>
</div>
<div class="cache-key-actions">
<n-popconfirm
trigger="click"
:show-icon="false"
placement="right"
@positive-click="deleteCacheByKey(key)"
@click.stop
>
<template #trigger>
<n-button type="error" size="small" text>
<template #icon>
<n-icon><IconParkOutlineDelete /></n-icon>
</template>
</n-button>
</template>
确定删除此缓存键吗
</n-popconfirm>
</div>
</div>
</div>
</n-card>
<!-- 右侧缓存内容详情 -->
<n-card :title="`缓存内容 (${selectedCacheKey || '请选择缓存键'})`" class="cache-content-panel">
<template #header-extra>
<n-space size="small">
<n-button
text
size="small"
:loading="loadingContent"
:disabled="!selectedCacheKey"
@click="refreshCacheContent"
>
<template #icon>
<n-icon><IconParkOutlineRefresh /></n-icon>
</template>
</n-button>
</n-space>
</template>
<div v-if="!selectedCacheKey" class="empty-state">
<n-empty description="请先选择左侧的缓存键">
<template #icon>
<n-icon><IconParkOutlineFileText /></n-icon>
</template>
</n-empty>
</div>
<div v-else-if="loadingContent" class="loading-state">
<n-spin size="medium">
<template #description>
加载缓存内容中...
</template>
</n-spin>
</div>
<div v-else class="cache-content">
<!-- 优化的元信息展示 -->
<div class="content-meta-card">
<div class="meta-item">
<div class="meta-label">
缓存键名
</div>
<div class="meta-value key-name">
{{ cacheContent?.cacheKey || 'N/A' }}
</div>
</div>
<div class="meta-item">
<div class="meta-label">
过期时间
</div>
<div class="meta-value">
<n-tag v-if="cacheContent?.expireTime && cacheContent.expireTime !== '不过期'" type="warning" size="small" :bordered="false">
<template #icon>
<n-icon><IconParkOutlineTime /></n-icon>
</template>
{{ cacheContent.expireTime }}
</n-tag>
<n-tag v-else type="success" size="small" :bordered="false">
<template #icon>
<n-icon><IconParkOutlineCheck /></n-icon>
</template>
永不过期
</n-tag>
</div>
</div>
</div>
<!-- 优化的缓存内容展示 -->
<div class="content-section">
<div class="section-header">
<h4 class="section-title">
缓存内容
</h4>
<n-space size="small">
<n-button size="tiny" text @click="formatJson">
<template #icon>
<n-icon><IconParkOutlineCode /></n-icon>
</template>
格式化
</n-button>
<n-button size="tiny" text @click="copyContent">
<template #icon>
<n-icon><IconParkOutlineCopy /></n-icon>
</template>
复制
</n-button>
</n-space>
</div>
<div class="content-display">
<div v-if="isJsonContent" class="json-viewer">
<pre class="json-content">{{ formattedContent }}</pre>
</div>
<div v-else class="text-content">
<n-input
:value="cacheContent?.cacheValue || ''"
type="textarea"
readonly
:rows="12"
placeholder="缓存内容为空"
class="content-textarea"
/>
</div>
</div>
</div>
</div>
</n-card>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import {
deleteCacheAll,
deleteCacheKey,
deleteCacheName,
getCacheKeys,
getCacheValue,
getRedisCache,
} from '@/service/api/monitor/cache'
import type { SysCacheVo } from '@/service/api/monitor/cache'
import IconParkOutlineRefresh from '~icons/icon-park-outline/refresh'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import IconParkOutlineKey from '~icons/icon-park-outline/key'
import IconParkOutlineFileQuestion from '~icons/icon-park-outline/file-question'
import IconParkOutlineFileText from '~icons/icon-park-outline/file-text'
import IconParkOutlineTime from '~icons/icon-park-outline/time'
import IconParkOutlineCheck from '~icons/icon-park-outline/check'
import IconParkOutlineCode from '~icons/icon-park-outline/code'
import IconParkOutlineCopy from '~icons/icon-park-outline/copy'
//
const loading = ref(false)
const loadingKeys = ref(false)
const loadingContent = ref(false)
const clearingAll = ref(false)
const cacheNames = ref<SysCacheVo[]>([])
const cacheKeys = ref<string[]>([])
const cacheContent = ref<SysCacheVo>()
const selectedCacheName = ref<string>('')
const selectedCacheKey = ref<string>('')
//
const isJsonFormatted = ref(false)
//
const isJsonContent = computed(() => {
const content = cacheContent.value?.cacheValue
if (!content)
return false
try {
JSON.parse(content)
return true
}
catch {
return false
}
})
const formattedContent = computed(() => {
const content = cacheContent.value?.cacheValue
if (!content || !isJsonContent.value)
return content
try {
return JSON.stringify(JSON.parse(content), null, 2)
}
catch {
return content
}
})
// JSON
function formatJson() {
isJsonFormatted.value = !isJsonFormatted.value
}
//
async function copyContent() {
const content = cacheContent.value?.cacheValue
if (!content) {
coiMsgWarning('没有可复制的内容')
return
}
try {
await navigator.clipboard.writeText(content)
coiMsgSuccess('内容已复制到剪贴板')
}
catch {
coiMsgError('复制失败,请手动复制')
}
}
//
async function fetchCacheNames() {
try {
loading.value = true
const { data, isSuccess } = await getRedisCache()
if (isSuccess && data) {
cacheNames.value = data
}
else {
coiMsgError('获取缓存名称列表失败')
}
}
catch (error) {
console.error('获取缓存名称列表异常:', error)
coiMsgError('获取缓存名称列表异常')
}
finally {
loading.value = false
}
}
//
async function fetchCacheKeys(cacheName: string) {
try {
loadingKeys.value = true
console.warn('正在获取缓存键列表,缓存名称:', cacheName)
const { data, isSuccess, message } = await getCacheKeys(cacheName)
if (isSuccess && data) {
cacheKeys.value = data
console.warn('获取缓存键列表成功,数据量:', data.length)
if (data.length === 0) {
coiMsgWarning(`缓存类型"${cacheName}"下暂无数据`)
}
}
else {
cacheKeys.value = []
console.warn('获取缓存键列表失败:', message || '未知错误')
coiMsgWarning(message || `缓存类型"${cacheName}"下暂无数据`)
}
}
catch (error) {
console.error('获取缓存键列表异常:', error)
cacheKeys.value = []
coiMsgError('获取缓存键列表异常,请检查网络连接或权限配置')
}
finally {
loadingKeys.value = false
}
}
//
async function fetchCacheContent(cacheName: string, cacheKey: string) {
try {
loadingContent.value = true
const { data, isSuccess } = await getCacheValue({
cacheName,
cacheKey,
})
if (isSuccess && data) {
cacheContent.value = data
}
else {
cacheContent.value = undefined
coiMsgError('获取缓存内容失败')
}
}
catch (error) {
console.error('获取缓存内容异常:', error)
cacheContent.value = undefined
coiMsgError('获取缓存内容异常')
}
finally {
loadingContent.value = false
}
}
//
async function selectCacheName(cacheName: string) {
selectedCacheName.value = cacheName
selectedCacheKey.value = ''
cacheContent.value = undefined
await fetchCacheKeys(cacheName)
}
//
async function selectCacheKey(cacheKey: string) {
selectedCacheKey.value = cacheKey
if (selectedCacheName.value) {
await fetchCacheContent(selectedCacheName.value, cacheKey)
}
}
//
async function deleteCacheByName(cacheName: string) {
try {
const { isSuccess } = await deleteCacheName(cacheName)
if (isSuccess) {
coiMsgSuccess('删除缓存成功')
//
if (selectedCacheName.value === cacheName) {
selectedCacheName.value = ''
selectedCacheKey.value = ''
cacheKeys.value = []
cacheContent.value = undefined
}
//
await fetchCacheNames()
}
else {
coiMsgError('删除缓存失败')
}
}
catch (error) {
console.error('删除缓存异常:', error)
coiMsgError('删除缓存异常')
}
}
//
async function deleteCacheByKey(cacheKey: string) {
try {
const { isSuccess } = await deleteCacheKey({
cacheName: selectedCacheName.value,
cacheKey,
})
if (isSuccess) {
coiMsgSuccess('删除缓存键成功')
//
if (selectedCacheKey.value === cacheKey) {
selectedCacheKey.value = ''
cacheContent.value = undefined
}
//
if (selectedCacheName.value) {
await fetchCacheKeys(selectedCacheName.value)
}
}
else {
coiMsgError('删除缓存键失败')
}
}
catch (error) {
console.error('删除缓存键异常:', error)
coiMsgError('删除缓存键异常')
}
}
//
async function clearAllCache() {
try {
clearingAll.value = true
const { isSuccess } = await deleteCacheAll()
if (isSuccess) {
coiMsgSuccess('清空所有缓存成功')
//
selectedCacheName.value = ''
selectedCacheKey.value = ''
cacheKeys.value = []
cacheContent.value = undefined
//
await fetchCacheNames()
}
else {
coiMsgError('清空所有缓存失败')
}
}
catch (error) {
console.error('清空所有缓存异常:', error)
coiMsgError('清空所有缓存异常')
}
finally {
clearingAll.value = false
}
}
//
async function refreshCacheNames() {
await fetchCacheNames()
coiMsgSuccess('刷新缓存成功')
}
//
async function refreshCacheKeys() {
if (selectedCacheName.value) {
await fetchCacheKeys(selectedCacheName.value)
coiMsgSuccess('刷新缓存键成功')
}
else {
coiMsgWarning('请先选择缓存类型')
}
}
//
async function refreshCacheContent() {
if (selectedCacheName.value && selectedCacheKey.value) {
await fetchCacheContent(selectedCacheName.value, selectedCacheKey.value)
coiMsgSuccess('刷新缓存内容成功')
}
else {
coiMsgWarning('请先选择缓存类型和缓存键')
}
}
//
onMounted(() => {
fetchCacheNames()
})
</script>
<style scoped>
.cache-monitor-container {
padding: 0;
margin: 0;
background-color: var(--body-color);
min-height: 100vh;
}
.confirm-content {
max-width: 200px;
}
.confirm-title {
font-weight: 600;
color: var(--text-color-1);
margin: 0 0 8px 0;
}
.confirm-desc {
font-size: 13px;
color: var(--text-color-3);
margin: 0;
}
.cache-layout {
display: grid;
grid-template-columns: 300px 360px 1fr;
gap: 20px;
height: calc(100vh - 140px);
padding: 20px 20px 20px 0;
margin: 0;
}
.cache-names-panel,
.cache-keys-panel,
.cache-content-panel {
height: 100%;
overflow: hidden;
}
.cache-names-list,
.cache-keys-list {
height: calc(100% - 20px);
overflow-y: auto;
}
.cache-name-item,
.cache-key-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-radius: 12px;
border: 1px solid var(--divider-color);
margin-bottom: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: var(--card-color);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.08),
0 1px 2px rgba(0, 0, 0, 0.04);
position: relative;
}
.cache-name-item:hover,
.cache-key-item:hover {
background: var(--hover-color);
border-color: var(--primary-color);
box-shadow:
0 4px 12px rgba(var(--primary-color-rgb), 0.12),
0 2px 6px rgba(var(--primary-color-rgb), 0.08);
transform: translateY(-2px);
}
.cache-name-item.active,
.cache-key-item.active {
background: var(--primary-color-suppl);
border-color: var(--primary-color);
box-shadow:
0 6px 20px rgba(var(--primary-color-rgb), 0.2),
0 3px 10px rgba(var(--primary-color-rgb), 0.12);
transform: translateY(-2px);
}
.cache-name-item.active::before,
.cache-key-item.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 60%;
background: var(--primary-color);
border-radius: 0 4px 4px 0;
}
.cache-name-content,
.cache-key-content {
flex: 1;
min-width: 0;
}
.cache-name {
font-size: 14px;
font-weight: 600;
color: var(--text-color-1);
margin-bottom: 6px;
word-break: break-all;
line-height: 1.3;
}
.cache-remark {
font-size: 12px;
color: var(--text-color-2);
font-weight: 500;
opacity: 0.8;
}
.cache-key {
font-size: 13px;
font-weight: 600;
color: var(--text-color-1);
word-break: break-all;
font-family: 'JetBrains Mono', 'Courier New', monospace;
line-height: 1.5;
padding: 2px 0;
}
.cache-actions,
.cache-key-actions {
flex-shrink: 0;
margin-left: 8px;
}
.empty-state,
.loading-state {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.cache-content {
height: calc(100% - 20px);
display: flex;
flex-direction: column;
}
.content-meta {
flex-shrink: 0;
margin-bottom: 16px;
}
/* 新的内容展示样式 */
.content-meta-card {
background: linear-gradient(135deg, var(--card-color) 0%, rgba(var(--primary-color-rgb), 0.03) 100%);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.meta-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.meta-label {
font-size: 12px;
font-weight: 600;
color: var(--text-color-2);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.meta-value {
font-size: 14px;
font-weight: 500;
color: var(--text-color-1);
word-break: break-all;
}
.key-name {
font-family: 'JetBrains Mono', 'Courier New', monospace;
background: rgba(var(--primary-color-rgb), 0.1);
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
border: 1px solid rgba(var(--primary-color-rgb), 0.2);
}
.content-section {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--divider-color);
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--text-color-1);
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.content-display {
flex: 1;
overflow: hidden;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.json-viewer {
height: 100%;
overflow: auto;
background: var(--code-block-color, #1e1e1e);
border-radius: 8px;
}
.json-content {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
color: #d4d4d4;
margin: 0;
padding: 20px;
white-space: pre-wrap;
word-break: break-all;
background: linear-gradient(135deg, #1e1e1e 0%, #2d2d30 100%);
border-radius: 8px;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
}
.text-content {
height: 100%;
}
.content-textarea {
height: 100%;
}
.content-textarea :deep(.n-input__textarea-el) {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
background: var(--input-color);
border: none;
resize: none;
}
:deep(.n-card) {
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(var(--border-color-rgb, 239, 239, 245), 0.6);
background: var(--card-color);
backdrop-filter: blur(10px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
:deep(.n-card:hover) {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
:deep(.n-card .n-card__header) {
padding: 20px 24px;
border-bottom: 1px solid var(--divider-color);
background: linear-gradient(135deg, var(--card-color) 0%, rgba(var(--primary-color-rgb), 0.02) 100%);
border-radius: 16px 16px 0 0;
}
:deep(.n-card .n-card__content) {
padding: 24px;
height: calc(100% - 70px);
}
:deep(.n-card .n-card-header__main) {
font-weight: 600;
font-size: 16px;
color: var(--text-color-1);
}
/* 优化标签样式 */
:deep(.n-tag) {
border-radius: 6px;
font-weight: 500;
padding: 4px 8px;
}
/* 优化空状态样式 */
.empty-state,
.loading-state {
height: 300px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-color-3);
}
/* 响应式设计优化 */
@media (max-width: 1400px) {
.cache-layout {
grid-template-columns: 280px 320px 1fr;
gap: 16px;
padding: 16px 16px 16px 0;
}
}
@media (max-width: 1200px) {
.cache-layout {
grid-template-columns: 1fr;
gap: 16px;
height: auto;
padding: 16px 16px 16px 0;
}
.cache-names-panel,
.cache-keys-panel,
.cache-content-panel {
height: 400px;
}
.content-meta-card {
grid-template-columns: 1fr;
gap: 16px;
}
}
@media (max-width: 768px) {
.cache-layout {
gap: 12px;
padding: 12px 12px 12px 0;
}
:deep(.n-card .n-card__header) {
padding: 16px 20px;
}
:deep(.n-card .n-card__content) {
padding: 20px;
}
.content-meta-card {
padding: 16px;
}
}
/* 清理重复样式,已在上面定义 */
/* 滚动条样式 */
.cache-names-list::-webkit-scrollbar,
.cache-keys-list::-webkit-scrollbar {
width: 6px;
}
.cache-names-list::-webkit-scrollbar-track,
.cache-keys-list::-webkit-scrollbar-track {
background: var(--scrollbar-track-color);
border-radius: 3px;
}
.cache-names-list::-webkit-scrollbar-thumb,
.cache-keys-list::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-color);
border-radius: 3px;
}
.cache-names-list::-webkit-scrollbar-thumb:hover,
.cache-keys-list::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover-color);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,688 @@
<template>
<div class="online-user-management p-1 bg-gray-50 h-screen flex flex-col">
<!-- 搜索表单和在线用户表格 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-100 flex-1 flex flex-col overflow-hidden">
<!-- 搜索表单 -->
<div class="px-4 py-2 border-b border-gray-100">
<n-form
ref="searchFormRef"
:model="searchForm"
label-placement="left"
label-width="auto"
class="search-form"
>
<n-grid :cols="4" :x-gap="8" :y-gap="4">
<n-grid-item>
<n-form-item label="登录账号" path="loginName">
<n-input
v-model:value="searchForm.loginName"
placeholder="请输入登录账号"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="用户姓名" path="userName">
<n-input
v-model:value="searchForm.userName"
placeholder="请输入用户姓名"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="登录IP" path="loginIp">
<n-input
v-model:value="searchForm.loginIp"
placeholder="请输入登录IP"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="" path="">
<NSpace>
<NButton type="primary" @click="handleSearch">
<template #icon>
<NIcon><icon-park-outline:search /></NIcon>
</template>
搜索
</NButton>
<NButton @click="handleReset">
<template #icon>
<NIcon><IconParkOutlineRefresh /></NIcon>
</template>
重置
</NButton>
</NSpace>
</n-form-item>
</n-grid-item>
</n-grid>
</n-form>
</div>
<!-- 统计信息和操作栏 -->
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-100">
<div class="flex items-center gap-4">
<!-- 在线用户统计 -->
<div class="flex items-center gap-2">
<NIcon size="16" color="#18a058">
<icon-park-outline:people />
</NIcon>
<span class="text-sm font-medium text-gray-700">在线用户:</span>
<NTag type="primary" size="small">
{{ onlineCount }}
</NTag>
</div>
<!-- 自动刷新开关 -->
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">自动刷新:</span>
<NSwitch
v-model:value="autoRefreshEnabled"
size="small"
@update:value="handleAutoRefreshToggle"
/>
<span v-if="autoRefreshEnabled" class="text-xs text-gray-500">
({{ refreshCountdown }}s)
</span>
</div>
<!-- 最后更新时间 -->
<div class="text-xs text-gray-500">
最后更新: {{ lastUpdateTime }}
</div>
</div>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span> {{ pagination.itemCount }} </span>
<NButton text @click="getOnlineUserList">
<template #icon>
<NIcon><IconParkOutlineRefresh /></NIcon>
</template>
</NButton>
</div>
</div>
<!-- 表格内容 -->
<div class="table-wrapper flex-1 overflow-auto pt-4">
<!-- 数据表格 -->
<n-data-table
v-if="tableData.length > 0 || loading"
:columns="columns"
:data="tableData"
:loading="loading"
:row-key="(row: OnlineUserVo) => row.userId"
:bordered="false"
:single-line="false"
:scroll-x="1650"
size="medium"
class="custom-table"
@update:checked-row-keys="handleRowSelectionChange"
/>
<!-- 空状态 -->
<CoiEmpty
v-else
:type="getEmptyType()"
:title="getEmptyTitle()"
:description="getEmptyDescription()"
:show-action="true"
:action-text="getEmptyActionText()"
size="medium"
@action="handleEmptyAction"
>
<template #action>
<NButton
type="primary"
size="medium"
@click="handleEmptyAction"
>
<template #icon>
<NIcon>
<IconParkOutlineRefresh v-if="hasSearchConditions()" />
<icon-park-outline:refresh v-else />
</NIcon>
</template>
{{ getEmptyActionText() }}
</NButton>
</template>
</CoiEmpty>
</div>
<!-- 分页器 -->
<div v-if="tableData.length > 0" class="border-t border-gray-100">
<CoiPagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:show-size-picker="true"
:page-sizes="[10, 20, 50, 100]"
:show-quick-jumper="true"
:show-current-info="true"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { h, onBeforeUnmount, onMounted, ref } from 'vue'
import type { DataTableColumns, FormInst } from 'naive-ui'
import { NButton, NIcon, NPopconfirm, NSpace, NSwitch, NTag } from 'naive-ui'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import IconParkOutlineRefresh from '~icons/icon-park-outline/refresh'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import {
getOnlineUserCount,
getOnlineUserListPage,
logoutUser,
} from '@/service/api/monitor/online'
import type { OnlineUserSearchForm, OnlineUserVo } from '@/service/api/monitor/online'
import { coiMsgError, coiMsgSuccess } from '@/utils/coi'
import { PERMISSIONS } from '@/constants/permissions'
import { usePermission } from '@/hooks'
//
const { hasButton } = usePermission()
//
const loading = ref(false)
const tableData = ref<OnlineUserVo[]>([])
const searchFormRef = ref<FormInst | null>(null)
const onlineCount = ref(0)
const lastUpdateTime = ref('')
//
const autoRefreshEnabled = ref(false)
const refreshInterval = ref<NodeJS.Timeout | null>(null)
const refreshCountdown = ref(30)
const countdownInterval = ref<NodeJS.Timeout | null>(null)
//
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
})
//
const searchForm = ref<OnlineUserSearchForm>({})
//
const selectedRows = ref<OnlineUserVo[]>([])
//
const columns: DataTableColumns<OnlineUserVo> = [
{
type: 'selection',
width: 50,
},
{
title: '序号',
key: 'index',
width: 70,
align: 'center',
render: (_, index) => {
return (pagination.value.page - 1) * pagination.value.pageSize + index + 1
},
},
{
title: '头像',
key: 'avatar',
width: 80,
align: 'center',
render: (row) => {
return h('div', { class: 'flex justify-center' }, [
h('div', {
class: 'w-10 h-10 rounded-full overflow-hidden border-2 border-gray-200',
}, [
row.avatar
? h('img', {
src: row.avatar,
alt: `${row.userName}的头像`,
class: 'w-full h-full object-cover',
onError: (e) => {
//
(e.target as HTMLElement).style.display = 'none'
const parent = (e.target as HTMLElement).parentElement
if (parent) {
parent.className = 'w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-bold shadow-md'
parent.textContent = row.loginName.charAt(0).toUpperCase()
}
},
})
: h('div', {
class: 'w-full h-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-bold',
}, row.loginName.charAt(0).toUpperCase()),
]),
])
},
},
{
title: '登录账号',
key: 'loginName',
width: 120,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-600 font-medium' }, row.loginName)
},
},
{
title: '用户姓名',
key: 'userName',
width: 120,
align: 'center',
render: (row) => {
return h('div', { class: 'text-gray-600' }, row.userName)
},
},
{
title: '登录IP',
key: 'loginIp',
width: 130,
align: 'center',
render: (row) => {
return h('span', {
class: 'text-blue-600 font-mono text-sm bg-blue-50 px-2 py-1 rounded',
}, row.loginIp || '-')
},
},
{
title: '登录地址',
key: 'loginAddress',
width: 150,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-600' }, row.loginAddress || '-')
},
},
{
title: '浏览器',
key: 'browser',
width: 120,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-600 text-sm' }, row.browser || '-')
},
},
{
title: '操作系统',
key: 'os',
width: 120,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-600 text-sm' }, row.os || '-')
},
},
{
title: '登录设备',
key: 'deviceName',
width: 130,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-600 text-sm' }, row.deviceName || '-')
},
},
{
title: '登录时间',
key: 'loginTime',
width: 160,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-500 text-sm' }, row.loginTime || '-')
},
},
{
title: '操作',
key: 'actions',
width: 150,
align: 'center',
fixed: 'right',
render: (row) => {
const buttons = []
//
if (hasButton(PERMISSIONS.ONLINE.LOGOUT)) {
buttons.push(h(NPopconfirm, {
onPositiveClick: () => handleLogout(row),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => `确定要强制注销用户「${row.userName}」吗?`,
trigger: () => h(NButton, {
type: 'error',
secondary: true,
size: 'small',
class: 'action-btn-secondary action-btn-danger',
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlineDelete),
}),
default: () => '强制注销',
}),
}))
}
return h('div', { class: 'flex items-center justify-center gap-2' }, buttons)
},
},
]
// 线
async function getOnlineUserList() {
loading.value = true
try {
//
const filteredParams = Object.entries(searchForm.value).reduce((acc, [key, value]) => {
if (value !== null && value !== undefined && value !== '') {
acc[key] = value
}
return acc
}, {} as Record<string, any>)
const params = {
pageNo: pagination.value.page,
pageSize: pagination.value.pageSize,
...filteredParams,
}
const { isSuccess, data } = await getOnlineUserListPage(params)
if (isSuccess && data) {
tableData.value = data.records || []
pagination.value.itemCount = data.total || 0
//
lastUpdateTime.value = new Date().toLocaleString()
}
}
catch (error) {
console.error('获取在线用户列表失败:', error)
coiMsgError('获取在线用户列表失败')
}
finally {
loading.value = false
}
}
// 线
async function getOnlineUserCountData() {
try {
const { isSuccess, data } = await getOnlineUserCount()
if (isSuccess && data) {
onlineCount.value = data.onlineCount
}
}
catch (error) {
console.error('获取在线用户统计失败:', error)
}
}
//
async function handleLogout(row: OnlineUserVo) {
try {
const { isSuccess } = await logoutUser(row.userId)
if (isSuccess) {
coiMsgSuccess(`用户「${row.userName}」已被强制注销`)
await Promise.all([getOnlineUserList(), getOnlineUserCountData()])
}
else {
coiMsgError('强制注销失败')
}
}
catch (error) {
console.error('强制注销失败:', error)
coiMsgError('强制注销失败')
}
}
//
async function handleSearch() {
pagination.value.page = 1
await getOnlineUserList()
}
//
async function handleReset() {
searchForm.value = {}
pagination.value.page = 1
await getOnlineUserList()
}
//
async function handlePageChange(page: number) {
pagination.value.page = page
await getOnlineUserList()
}
async function handlePageSizeChange(pageSize: number) {
pagination.value.pageSize = pageSize
pagination.value.page = 1
await getOnlineUserList()
}
//
function handleRowSelectionChange(rowKeys: (string | number)[]) {
selectedRows.value = tableData.value.filter(row => rowKeys.includes(row.userId))
}
//
function handleAutoRefreshToggle(enabled: boolean) {
if (enabled) {
startAutoRefresh()
}
else {
stopAutoRefresh()
}
}
function startAutoRefresh() {
stopAutoRefresh() //
refreshCountdown.value = 30
//
countdownInterval.value = setInterval(() => {
refreshCountdown.value--
if (refreshCountdown.value <= 0) {
refreshCountdown.value = 30
}
}, 1000)
//
refreshInterval.value = setInterval(async () => {
await Promise.all([getOnlineUserList(), getOnlineUserCountData()])
}, 30000)
}
function stopAutoRefresh() {
if (refreshInterval.value) {
clearInterval(refreshInterval.value)
refreshInterval.value = null
}
if (countdownInterval.value) {
clearInterval(countdownInterval.value)
countdownInterval.value = null
}
}
//
function getEmptyType() {
return hasSearchConditions() ? 'search' : 'empty'
}
function getEmptyTitle() {
return hasSearchConditions() ? '未找到相关数据' : '暂无在线用户'
}
function getEmptyDescription() {
return hasSearchConditions() ? '请尝试调整搜索条件' : '当前没有用户在线'
}
function getEmptyActionText() {
return hasSearchConditions() ? '重新搜索' : '刷新数据'
}
function hasSearchConditions() {
const { loginName, userName, loginIp } = searchForm.value
return !!(loginName || userName || loginIp)
}
async function handleEmptyAction() {
if (hasSearchConditions()) {
await handleReset()
}
else {
await getOnlineUserList()
}
}
//
onMounted(async () => {
await Promise.all([getOnlineUserList(), getOnlineUserCountData()])
})
onBeforeUnmount(() => {
stopAutoRefresh()
})
</script>
<style scoped>
/* 搜索表单样式 */
.search-form :deep(.n-form-item) {
margin-bottom: 8px !important;
}
.search-form :deep(.n-form-item .n-form-item-feedback-wrapper) {
min-height: 0 !important;
padding-top: 2px !important;
}
.search-form :deep(.n-form-item .n-form-item-label) {
padding-bottom: 2px !important;
}
.search-form :deep(.n-input),
.search-form :deep(.n-select) {
font-size: 14px !important;
}
.search-form :deep(.n-input .n-input__input-el) {
padding: 2px 1px !important;
min-height: 32px !important;
}
/* 表格样式 */
.custom-table :deep(.n-data-table-td) {
padding: 8px 12px;
vertical-align: middle;
}
.custom-table :deep(.n-data-table-th) {
background-color: #fafafa;
font-weight: 500;
padding: 12px;
border-bottom: 1px solid #e8e8e8;
}
.custom-table :deep(.n-data-table-tr:hover .n-data-table-td) {
background-color: #f8f9fa;
}
.custom-table :deep(.n-data-table-tbody .n-data-table-tr) {
border-bottom: 1px solid #f0f0f0;
}
.custom-table :deep(.n-button--small-type) {
height: 28px;
padding: 0 8px;
font-size: 12px;
}
.custom-table :deep(.n-tag--small-type) {
height: 22px;
font-size: 11px;
padding: 0 6px;
}
.table-wrapper {
border-radius: 8px;
}
.table-wrapper::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.table-wrapper::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.table-wrapper::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.table-wrapper::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.table-wrapper::-webkit-scrollbar-corner {
background: #f1f1f1;
}
.custom-table :deep(.n-data-table-base-table) {
border-radius: 8px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 按钮样式 */
.action-btn-secondary {
border: 1px solid var(--border-color);
}
.action-btn-danger {
color: var(--error-color);
border-color: var(--error-color);
}
.action-btn-danger:hover {
background-color: var(--error-color);
color: white;
}
</style>

View File

@ -0,0 +1,332 @@
<template>
<div class="pie-chart-container">
<div v-if="!data || data.length === 0" class="empty-state">
<n-empty description="暂无命令统计数据">
<template #icon>
<n-icon><IconParkOutlineChartPie /></n-icon>
</template>
</n-empty>
</div>
<div v-else class="chart-content">
<!-- SVG饼图 -->
<svg class="pie-chart" :width="chartSize" :height="chartSize" :viewBox="`0 0 ${chartSize} ${chartSize}`">
<!-- 饼图扇形 -->
<g :transform="`translate(${chartSize / 2}, ${chartSize / 2})`">
<path
v-for="(segment, index) in pieSegments"
:key="index"
:d="segment.path"
:fill="segment.color"
stroke="var(--card-color)"
:stroke-width="2"
class="pie-segment"
@mouseenter="hoveredIndex = index"
@mouseleave="hoveredIndex = -1"
/>
</g>
<!-- 中心文本 -->
<g :transform="`translate(${chartSize / 2}, ${chartSize / 2})`">
<text
class="center-text-title"
text-anchor="middle"
:y="-8"
fill="var(--text-color-1)"
>
命令统计
</text>
<text
class="center-text-value"
text-anchor="middle"
:y="12"
fill="var(--text-color-2)"
>
{{ totalCommands.toLocaleString() }}
</text>
</g>
</svg>
<!-- 图例 -->
<div class="chart-legend">
<div
v-for="(item, index) in topCommands"
:key="index"
class="legend-item"
:class="{ active: hoveredIndex === index }"
@mouseenter="hoveredIndex = index"
@mouseleave="hoveredIndex = -1"
>
<div class="legend-color" :style="{ backgroundColor: colors[index] }" />
<div class="legend-content">
<div class="legend-name">
{{ item.name }}
</div>
<div class="legend-value">
{{ Number(item.value).toLocaleString() }} ({{ item.percentage }}%)
</div>
</div>
</div>
<div v-if="otherCommands > 0" class="legend-item">
<div class="legend-color" :style="{ backgroundColor: colors[topCommands.length] }" />
<div class="legend-content">
<div class="legend-name">
其他
</div>
<div class="legend-value">
{{ otherCommands.toLocaleString() }} ({{ otherPercentage }}%)
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { RedisCommandStatVo } from '@/service/api/monitor/redis'
// Props
interface Props {
data: RedisCommandStatVo[]
}
const props = defineProps<Props>()
//
const hoveredIndex = ref(-1)
const chartSize = 240
const radius = 80
//
const colors = [
'#18a058',
'#2080f0',
'#f0a020',
'#d03050',
'#722ed1',
'#eb2f96',
'#fa8c16',
'#52c41a',
'#13c2c2',
'#1890ff',
]
//
const totalCommands = computed(() => {
return props.data.reduce((sum, item) => sum + Number(item.value), 0)
})
// 8""
const topCommands = computed(() => {
const sorted = [...props.data]
.sort((a, b) => Number(b.value) - Number(a.value))
.slice(0, 8)
return sorted.map(item => ({
...item,
percentage: totalCommands.value > 0
? ((Number(item.value) / totalCommands.value) * 100).toFixed(1)
: '0.0',
}))
})
const otherCommands = computed(() => {
if (props.data.length <= 8)
return 0
const topTotal = topCommands.value.reduce((sum, item) => sum + Number(item.value), 0)
return totalCommands.value - topTotal
})
const otherPercentage = computed(() => {
if (otherCommands.value === 0 || totalCommands.value === 0)
return '0.0'
return ((otherCommands.value / totalCommands.value) * 100).toFixed(1)
})
//
const pieSegments = computed(() => {
const segments = []
let currentAngle = -Math.PI / 2 // 12
//
topCommands.value.forEach((item, index) => {
const percentage = Number(item.value) / totalCommands.value
const angle = percentage * 2 * Math.PI
if (angle > 0) {
const path = createArcPath(currentAngle, currentAngle + angle, radius)
segments.push({
path,
color: colors[index],
percentage: item.percentage,
})
currentAngle += angle
}
})
// ""
if (otherCommands.value > 0) {
const percentage = otherCommands.value / totalCommands.value
const angle = percentage * 2 * Math.PI
if (angle > 0) {
const path = createArcPath(currentAngle, currentAngle + angle, radius)
segments.push({
path,
color: colors[topCommands.value.length],
percentage: otherPercentage.value,
})
}
}
return segments
})
//
function createArcPath(startAngle: number, endAngle: number, radius: number): string {
const x1 = Math.cos(startAngle) * radius
const y1 = Math.sin(startAngle) * radius
const x2 = Math.cos(endAngle) * radius
const y2 = Math.sin(endAngle) * radius
const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0
return [
'M',
0,
0,
'L',
x1,
y1,
'A',
radius,
radius,
0,
largeArcFlag,
1,
x2,
y2,
'Z',
].join(' ')
}
</script>
<style scoped>
.pie-chart-container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.empty-state {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.chart-content {
display: flex;
align-items: center;
gap: 32px;
width: 100%;
max-width: 600px;
justify-content: center;
}
.pie-chart {
flex-shrink: 0;
}
.pie-segment {
cursor: pointer;
transition: all 0.3s ease;
}
.pie-segment:hover {
filter: brightness(1.1);
transform: scale(1.02);
}
.center-text-title {
font-size: 14px;
font-weight: 500;
}
.center-text-value {
font-size: 18px;
font-weight: 600;
}
.chart-legend {
flex: 1;
max-height: 280px;
overflow-y: auto;
padding-right: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.2s ease;
cursor: pointer;
}
.legend-item:hover,
.legend-item.active {
background-color: var(--hover-color);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-content {
flex: 1;
min-width: 0;
}
.legend-name {
font-size: 14px;
font-weight: 500;
color: var(--text-color-1);
margin-bottom: 2px;
text-transform: uppercase;
}
.legend-value {
font-size: 12px;
color: var(--text-color-3);
}
/* 滚动条样式 */
.chart-legend::-webkit-scrollbar {
width: 4px;
}
.chart-legend::-webkit-scrollbar-track {
background: var(--scrollbar-track-color);
border-radius: 2px;
}
.chart-legend::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-color);
border-radius: 2px;
}
.chart-legend::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover-color);
}
</style>

View File

@ -0,0 +1,272 @@
<template>
<div class="memory-chart-container">
<div class="chart-content">
<!-- 圆环图表 -->
<div class="ring-chart-wrapper">
<svg class="ring-chart" :width="chartSize" :height="chartSize" :viewBox="`0 0 ${chartSize} ${chartSize}`">
<!-- 背景圆环 -->
<circle
:cx="centerX"
:cy="centerY"
:r="radius"
fill="none"
stroke="var(--border-color)"
:stroke-width="strokeWidth"
/>
<!-- 进度圆环 -->
<circle
:cx="centerX"
:cy="centerY"
:r="radius"
fill="none"
:stroke="progressColor"
:stroke-width="strokeWidth"
:stroke-dasharray="circumference"
:stroke-dashoffset="strokeDashoffset"
stroke-linecap="round"
class="progress-ring"
:transform="`rotate(-90 ${centerX} ${centerY})`"
/>
<!-- 中心内容 -->
<g :transform="`translate(${centerX}, ${centerY})`">
<text
class="usage-percentage"
text-anchor="middle"
:y="-8"
fill="var(--text-color-1)"
>
{{ formattedUsage }}
</text>
<text
class="usage-label"
text-anchor="middle"
:y="12"
fill="var(--text-color-3)"
>
内存使用率
</text>
</g>
</svg>
</div>
<!-- 内存统计信息 -->
<div class="memory-stats">
<div class="stat-item">
<div class="stat-label">
已用内存
</div>
<div class="stat-value" :style="{ color: progressColor }">
{{ formatBytes(usedMemory) }}
</div>
</div>
<div class="stat-item">
<div class="stat-label">
总内存
</div>
<div class="stat-value">
{{ formatBytes(maxMemory) }}
</div>
</div>
<div class="stat-item">
<div class="stat-label">
可用内存
</div>
<div class="stat-value">
{{ formatBytes(availableMemory) }}
</div>
</div>
<div class="stat-item">
<div class="stat-label">
状态
</div>
<div class="stat-value" :style="{ color: statusColor }">
{{ memoryStatus }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
// Props
interface Props {
usedMemory: number
maxMemory: number
}
const props = defineProps<Props>()
//
const chartSize = 280
const centerX = chartSize / 2
const centerY = chartSize / 2
const radius = 90
const strokeWidth = 16
//
const memoryUsage = computed(() => {
if (props.maxMemory === 0 || props.usedMemory === 0)
return 0
const usage = (props.usedMemory / props.maxMemory) * 100
return Math.min(Math.max(usage, 0), 100) // 0-100
})
const formattedUsage = computed(() => {
return `${memoryUsage.value.toFixed(1)}%`
})
const availableMemory = computed(() => {
return Math.max(props.maxMemory - props.usedMemory, 0)
})
//
const circumference = computed(() => 2 * Math.PI * radius)
const strokeDashoffset = computed(() => {
const progress = memoryUsage.value / 100
return circumference.value * (1 - progress)
})
const progressColor = computed(() => {
const usage = memoryUsage.value
if (usage < 50)
return '#18a058' // 绿
if (usage < 80)
return '#f0a020' //
return '#d03050' //
})
const statusColor = computed(() => {
return progressColor.value
})
const memoryStatus = computed(() => {
const usage = memoryUsage.value
if (usage === 0)
return '未知'
if (usage < 50)
return '良好'
if (usage < 80)
return '警告'
return '危险'
})
//
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 `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
}
</script>
<style scoped>
.memory-chart-container {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.chart-content {
display: flex;
align-items: center;
gap: 32px;
width: 100%;
max-width: 600px;
}
.ring-chart-wrapper {
flex-shrink: 0;
}
.ring-chart {
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.1));
}
.progress-ring {
transition: stroke-dashoffset 1.2s cubic-bezier(0.4, 0, 0.2, 1),
stroke 0.3s ease;
}
.progress-ring:hover {
filter: brightness(1.1);
}
.usage-percentage {
font-size: 32px;
font-weight: 700;
letter-spacing: -0.5px;
transition: all 0.3s ease;
}
.usage-label {
font-size: 14px;
font-weight: 500;
letter-spacing: 0.5px;
transition: all 0.3s ease;
}
.memory-stats {
flex: 1;
display: grid;
grid-template-columns: 1fr;
gap: 16px;
min-width: 0;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
background: rgba(var(--primary-color-rgb), 0.05);
border: 1px solid var(--border-color);
border-radius: 8px;
transition: all 0.2s ease;
}
.stat-item:hover {
background: rgba(var(--primary-color-rgb), 0.08);
border-color: var(--primary-color);
}
.stat-label {
font-size: 12px;
font-weight: 500;
color: var(--text-color-3);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 16px;
font-weight: 700;
color: var(--text-color-1);
line-height: 1.2;
}
/* 响应式设计 */
@media (max-width: 768px) {
.chart-content {
flex-direction: column;
gap: 24px;
}
.memory-stats {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
width: 100%;
}
}
</style>

View File

@ -0,0 +1,316 @@
<template>
<div class="redis-monitor-container">
<!-- 页面头部 -->
<div class="page-header mb-6">
<div class="header-left">
<h2 class="page-title">
Redis监控
</h2>
<p class="page-subtitle">
实时监控Redis服务器状态和性能指标
</p>
</div>
<div class="header-right">
<n-button type="primary" :loading="loading" @click="refreshData">
<template #icon>
<n-icon><IconParkOutlineRefresh /></n-icon>
</template>
刷新数据
</n-button>
</div>
</div>
<!-- Redis基本信息 -->
<n-card title="基本信息" class="mb-6">
<template #header-extra>
<n-icon><IconParkOutlineServer /></n-icon>
</template>
<n-grid :x-gap="16" :y-gap="16">
<n-gi :span="8">
<n-descriptions :column="1" label-placement="left" :label-style="{ width: '100px' }">
<n-descriptions-item label="Redis版本">
{{ getRedisInfo('redis_version') || 'N/A' }}
</n-descriptions-item>
<n-descriptions-item label="运行模式">
{{ getRedisMode() }}
</n-descriptions-item>
<n-descriptions-item label="端口">
{{ getRedisInfo('tcp_port') || 'N/A' }}
</n-descriptions-item>
<n-descriptions-item label="客户端连接">
{{ getRedisInfo('connected_clients') || '0' }}
</n-descriptions-item>
</n-descriptions>
</n-gi>
<n-gi :span="8">
<n-descriptions :column="1" label-placement="left" :label-style="{ width: '120px' }">
<n-descriptions-item label="运行时间(天)">
{{ formatUptime(getRedisInfo('uptime_in_days')) }}
</n-descriptions-item>
<n-descriptions-item label="使用内存">
{{ formatMemory(getRedisInfo('used_memory_human')) }}
</n-descriptions-item>
<n-descriptions-item label="最大内存">
{{ getRedisInfo('maxmemory') === '0' ? '无限制' : formatBytes(getRedisInfo('maxmemory')) }}
</n-descriptions-item>
<n-descriptions-item label="使用CPU">
{{ getRedisInfo('used_cpu_user_children') || '0' }}%
</n-descriptions-item>
<n-descriptions-item label="Key数量">
{{ redisData?.dbSize || 0 }}
</n-descriptions-item>
</n-descriptions>
</n-gi>
<n-gi :span="8">
<n-descriptions :column="1" label-placement="left" :label-style="{ width: '120px' }">
<n-descriptions-item label="网络输入/输出">
{{ formatNetwork() }}
</n-descriptions-item>
<n-descriptions-item label="AOF是否开启">
{{ getRedisInfo('aof_enabled') === '1' ? '是' : '否' }}
</n-descriptions-item>
<n-descriptions-item label="RDB是否成功">
{{ getRedisInfo('rdb_last_save_time') ? '是' : '否' }}
</n-descriptions-item>
<n-descriptions-item label="Key命中率">
{{ getKeyHitRate() }}
</n-descriptions-item>
</n-descriptions>
</n-gi>
</n-grid>
</n-card>
<!-- 图表区域 -->
<n-grid :x-gap="16" :y-gap="16">
<!-- 命令统计饼图 -->
<n-gi :span="12">
<n-card title="命令统计" class="chart-card">
<template #header-extra>
<n-icon><IconParkOutlineChartPie /></n-icon>
</template>
<div class="chart-container">
<CommandStatsPieChart :data="redisData?.commandStats || []" />
</div>
</n-card>
</n-gi>
<!-- 内存消耗仪表盘 -->
<n-gi :span="12">
<n-card title="内存消耗" class="chart-card">
<template #header-extra>
<n-icon><IconParkOutlineData /></n-icon>
</template>
<div class="chart-container">
<MemoryGaugeChart :used-memory="getUsedMemory()" :max-memory="getMaxMemory()" />
</div>
</n-card>
</n-gi>
</n-grid>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { coiMsgError, coiMsgSuccess } from '@/utils/coi'
import { getRedisInformation } from '@/service/api/monitor/redis'
import type { RedisInfoVo } from '@/service/api/monitor/redis'
import CommandStatsPieChart from './components/CommandStatsPieChart.vue'
import MemoryGaugeChart from './components/MemoryGaugeChart.vue'
//
const loading = ref(false)
const redisData = ref<RedisInfoVo>()
// Redis
function getRedisInfo(key: string): string {
return redisData.value?.info?.[key] || ''
}
// Redis
function getRedisMode(): string {
const mode = getRedisInfo('redis_mode')
if (mode === 'standalone')
return '单机'
if (mode === 'cluster')
return '集群'
if (mode === 'sentinel')
return '哨兵'
return mode || '未知'
}
//
function formatUptime(days: string): string {
const numDays = Number(days) || 0
return `${numDays}`
}
//
function formatMemory(memory: string): string {
return memory || '0B'
}
//
function formatNetwork(): string {
const inputKb = getRedisInfo('total_net_input_bytes')
const outputKb = getRedisInfo('total_net_output_bytes')
return `${formatBytes(inputKb)}/${formatBytes(outputKb)}`
}
//
function formatBytes(bytes: string): string {
const num = Number(bytes) || 0
if (num === 0)
return '0B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(num) / Math.log(k))
return `${Number.parseFloat((num / k ** i).toFixed(1))}${sizes[i]}`
}
// Key
function getKeyHitRate(): string {
const hits = Number(getRedisInfo('keyspace_hits')) || 0
const misses = Number(getRedisInfo('keyspace_misses')) || 0
const total = hits + misses
if (total === 0)
return '0.00%'
const rate = (hits / total) * 100
return `${rate.toFixed(2)}%`
}
//
function getUsedMemory(): number {
const used = getRedisInfo('used_memory')
return Number(used) || 0
}
//
function getMaxMemory(): number {
const max = getRedisInfo('maxmemory')
const maxMemory = Number(max) || 0
// Redismaxmemory0
if (maxMemory === 0) {
const systemMemory = getRedisInfo('total_system_memory')
if (systemMemory) {
return Number(systemMemory)
}
// 使
// 2-4
const used = getUsedMemory()
if (used > 0) {
return Math.max(used * 3, 1024 * 1024 * 1024) // 1GB
}
// 2GB
return 2 * 1024 * 1024 * 1024
}
return maxMemory
}
// Redis
async function fetchRedisData() {
try {
loading.value = true
const { data, isSuccess } = await getRedisInformation()
if (isSuccess && data) {
redisData.value = data
}
else {
coiMsgError('获取Redis监控数据失败')
}
}
catch (error) {
console.error('获取Redis监控数据异常:', error)
coiMsgError('获取Redis监控数据异常')
}
finally {
loading.value = false
}
}
//
async function refreshData() {
await fetchRedisData()
coiMsgSuccess('数据刷新成功')
}
//
onMounted(() => {
fetchRedisData()
})
</script>
<style scoped>
.redis-monitor-container {
padding: 20px;
background-color: var(--body-color);
min-height: 100vh;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header-left {
flex: 1;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: var(--text-color-1);
margin: 0 0 4px 0;
}
.page-subtitle {
font-size: 14px;
color: var(--text-color-3);
margin: 0;
}
.header-right {
display: flex;
gap: 12px;
}
.chart-card {
height: 500px;
}
.chart-container {
height: 420px;
display: flex;
align-items: center;
justify-content: center;
}
:deep(.n-card) {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:deep(.n-card .n-card__header) {
padding: 16px 20px;
border-bottom: 1px solid var(--divider-color);
}
:deep(.n-card .n-card__content) {
padding: 20px;
}
:deep(.n-descriptions .n-descriptions-item-label) {
font-weight: 500;
color: var(--text-color-2);
}
</style>

View File

@ -0,0 +1,182 @@
<template>
<n-card class="stat-card" :style="cardStyle" hoverable>
<!-- 主要内容区域 -->
<n-space justify="space-between" align="center" class="mb-2">
<!-- 左侧数据区域 -->
<div class="stat-content">
<div class="stat-title">
{{ title }}
</div>
<div class="stat-value">
{{ value }}
</div>
<div class="stat-subtitle">
{{ subtitle }}
</div>
</div>
<!-- 右侧图标区域 -->
<div class="stat-icon" :style="iconStyle">
<n-icon :size="28">
<IconParkOutlineServer v-if="icon === 'server'" />
<IconParkOutlineComputer v-else-if="icon === 'system'" />
<IconParkOutlineNetworkTree v-else-if="icon === 'network'" />
<IconParkOutlineChip v-else-if="icon === 'chip'" />
<IconParkOutlineCpu v-else-if="icon === 'cpu'" />
<IconParkOutlineData v-else-if="icon === 'memory'" />
<IconParkOutlineCode v-else-if="icon === 'java'" />
<IconParkOutlineServer v-else />
</n-icon>
</div>
</n-space>
<!-- 底部信息区域 -->
<div v-if="trend || extraInfo" class="stat-footer">
<n-space justify="space-between" align="center">
<div class="stat-extra">
{{ extraInfo }}
</div>
<div v-if="trend" class="stat-trend" :class="trendClass">
<n-icon :size="12" class="trend-icon">
<IconParkOutlineUp v-if="trendUp" />
<IconParkOutlineDown v-else />
</n-icon>
<span class="trend-text">{{ trend }}</span>
</div>
</n-space>
</div>
</n-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
// Props
interface StatCardProps {
title: string
value: string | number
subtitle: string
icon?: string
color?: string
trend?: string
trendUp?: boolean
extraInfo?: string
}
// Props
const props = withDefaults(defineProps<StatCardProps>(), {
icon: 'server',
color: '#18a058',
trendUp: true,
})
//
const cardStyle = computed(() => ({
background:
'linear-gradient(135deg, var(--card-color) 0%, rgba(255, 255, 255, 0.6) 100%)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.05)',
transition: 'all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
padding: '18px',
position: 'relative',
overflow: 'hidden',
minHeight: '140px',
}))
//
const iconStyle = computed(() => ({
color: props.color,
background: `${props.color}12`,
borderRadius: '10px',
padding: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}))
//
const trendClass = computed(() => ({
'trend-up': props.trendUp,
'trend-down': !props.trendUp,
}))
</script>
<style scoped>
.stat-card {
height: 100%;
transition: transform 0.2s ease-in-out;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 13px;
font-weight: 500;
color: var(--text-color-3);
margin-bottom: 8px;
line-height: 1.4;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-color-1);
margin-bottom: 6px;
line-height: 1.2;
word-break: break-all;
}
.stat-subtitle {
font-size: 12px;
color: var(--text-color-3);
line-height: 1.3;
}
.stat-icon {
flex-shrink: 0;
transition: all 0.3s ease;
}
.stat-footer {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid var(--divider-color);
}
.stat-extra {
font-size: 12px;
color: var(--text-color-3);
}
.stat-trend {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
}
.trend-up {
color: var(--success-color);
}
.trend-down {
color: var(--error-color);
}
.trend-icon {
display: flex;
align-items: center;
}
.trend-text {
line-height: 1;
}
</style>

View File

@ -0,0 +1,327 @@
<template>
<div class="server-monitor-container">
<!-- 页面头部 -->
<div class="page-header mb-6">
<div class="header-left">
<h2 class="page-title">
服务监控
</h2>
<p class="page-subtitle">
实时监控服务器运行状态和系统资源使用情况
</p>
</div>
<div class="header-right">
<n-button type="primary" :loading="loading" @click="refreshData">
<template #icon>
<n-icon><IconParkOutlineRefresh /></n-icon>
</template>
刷新数据
</n-button>
</div>
</div>
<!-- 系统信息统计卡片 -->
<n-grid :x-gap="16" :y-gap="16" class="mb-6">
<n-gi :span="6">
<StatCard
title="服务器名称"
:value="serverData?.sys?.computerName || 'N/A'"
subtitle="主机名"
icon="server"
color="#18a058"
/>
</n-gi>
<n-gi :span="6">
<StatCard
title="操作系统"
:value="serverData?.sys?.osName || 'N/A'"
subtitle="系统版本"
icon="system"
color="#2080f0"
/>
</n-gi>
<n-gi :span="6">
<StatCard
title="服务器IP"
:value="serverData?.sys?.computerIp || 'N/A'"
subtitle="主机地址"
icon="network"
color="#f0a020"
/>
</n-gi>
<n-gi :span="6">
<StatCard
title="系统架构"
:value="serverData?.sys?.osArch || 'N/A'"
subtitle="处理器架构"
icon="chip"
color="#d03050"
/>
</n-gi>
</n-grid>
<!-- CPU和内存信息 -->
<n-grid :x-gap="16" :y-gap="16" class="mb-6">
<n-gi :span="8">
<StatCard
title="CPU使用率"
:value="formatPercentage(serverData?.cpu?.cpuUsage)"
subtitle="处理器负载"
icon="cpu"
color="#18a058"
:trend="formatPercentage(serverData?.cpu?.cpuUsage)"
:trend-up="(serverData?.cpu?.cpuUsage || 0) < 80"
/>
</n-gi>
<n-gi :span="8">
<StatCard
title="内存使用率"
:value="formatPercentage(serverData?.mem?.usage)"
subtitle="内存负载"
icon="memory"
color="#2080f0"
:trend="formatPercentage(serverData?.mem?.usage)"
:trend-up="(serverData?.mem?.usage || 0) < 80"
/>
</n-gi>
<n-gi :span="8">
<StatCard
title="JVM使用率"
:value="formatPercentage(serverData?.jvm?.usage)"
subtitle="Java虚拟机"
icon="java"
color="#f0a020"
:trend="formatPercentage(serverData?.jvm?.usage)"
:trend-up="(serverData?.jvm?.usage || 0) < 80"
/>
</n-gi>
</n-grid>
<!-- Java环境信息 -->
<n-card title="Java环境信息" class="mb-6">
<template #header-extra>
<n-icon><IconParkOutlineCode /></n-icon>
</template>
<n-descriptions :column="3" label-placement="left" :label-style="{ width: '120px' }">
<n-descriptions-item label="Java版本">
{{ serverData?.jvm?.version || 'N/A' }}
</n-descriptions-item>
<n-descriptions-item label="启动时间">
{{ serverData?.jvm?.startTime || 'N/A' }}
</n-descriptions-item>
<n-descriptions-item label="运行时间">
{{ serverData?.jvm?.runTime || 'N/A' }}
</n-descriptions-item>
<n-descriptions-item label="安装路径">
{{ serverData?.jvm?.home || 'N/A' }}
</n-descriptions-item>
<n-descriptions-item label="项目路径">
{{ serverData?.sys?.userDir || 'N/A' }}
</n-descriptions-item>
<n-descriptions-item label="运行参数">
<n-tag type="info" size="small">
-Djava.version={{ serverData?.jvm?.version || 'N/A' }}
</n-tag>
</n-descriptions-item>
</n-descriptions>
<!-- JVM内存详情 -->
<n-divider>JVM内存详情</n-divider>
<n-space>
<n-statistic label="总内存" :value="serverData?.jvm?.totalStr || '0 MB'" />
<n-statistic label="已用内存" :value="serverData?.jvm?.usedStr || '0 MB'" />
<n-statistic label="剩余内存" :value="serverData?.jvm?.freeStr || '0 MB'" />
<n-statistic label="最大内存" :value="serverData?.jvm?.maxStr || '0 MB'" />
</n-space>
</n-card>
<!-- 磁盘状态 -->
<n-card title="磁盘状态">
<template #header-extra>
<n-icon><IconParkOutlineHdd /></n-icon>
</template>
<n-data-table
:columns="diskColumns"
:data="serverData?.sysFiles || []"
:loading="loading"
size="small"
:bordered="false"
/>
</n-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { coiMsgError, coiMsgSuccess } from '@/utils/coi'
import { getServerInformation } from '@/service/api/monitor/server'
import type { ServerVo, SysFileVo } from '@/service/api/monitor/server'
import StatCard from './components/StatCard.vue'
//
const loading = ref(false)
const serverData = ref<ServerVo>()
//
const diskColumns: DataTableColumns<SysFileVo> = [
{
title: '盘符路径',
key: 'dirName',
width: 120,
ellipsis: {
tooltip: true,
},
},
{
title: '文件系统',
key: 'sysTypeName',
width: 100,
},
{
title: '盘符类型',
key: 'typeName',
width: 200,
ellipsis: {
tooltip: true,
},
},
{
title: '总大小',
key: 'total',
width: 100,
align: 'right',
},
{
title: '可用大小',
key: 'free',
width: 100,
align: 'right',
},
{
title: '已用大小',
key: 'used',
width: 100,
align: 'right',
},
{
title: '已用百分比',
key: 'usage',
width: 120,
align: 'center',
render: (row) => {
const usage = row.usage || 0
const color = usage > 80 ? '#d03050' : usage > 60 ? '#f0a020' : '#18a058'
return h('div', { style: { color } }, `${usage.toFixed(1)}%`)
},
},
]
//
function formatPercentage(value?: number): string {
if (value === undefined || value === null)
return '0.0%'
return `${value.toFixed(1)}%`
}
//
async function fetchServerData() {
try {
loading.value = true
const { data, isSuccess } = await getServerInformation()
if (isSuccess && data) {
serverData.value = data
}
else {
coiMsgError('获取服务器监控数据失败')
}
}
catch (error) {
console.error('获取服务器监控数据异常:', error)
coiMsgError('获取服务器监控数据异常')
}
finally {
loading.value = false
}
}
//
async function refreshData() {
await fetchServerData()
coiMsgSuccess('数据刷新成功')
}
//
onMounted(() => {
fetchServerData()
})
</script>
<style scoped>
.server-monitor-container {
padding: 20px;
background-color: var(--body-color);
min-height: 100vh;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header-left {
flex: 1;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: var(--text-color-1);
margin: 0 0 4px 0;
}
.page-subtitle {
font-size: 14px;
color: var(--text-color-3);
margin: 0;
}
.header-right {
display: flex;
gap: 12px;
}
:deep(.n-card) {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:deep(.n-card .n-card__header) {
padding: 16px 20px;
border-bottom: 1px solid var(--divider-color);
}
:deep(.n-card .n-card__content) {
padding: 20px;
}
:deep(.n-descriptions .n-descriptions-item-label) {
font-weight: 500;
color: var(--text-color-2);
}
:deep(.n-divider) {
margin: 16px 0;
}
:deep(.n-statistic) {
text-align: center;
}
:deep(.n-data-table) {
border-radius: 8px;
}
</style>