feat(system): 通知管理与文件选择器来源筛选
- 新增通知管理(渠道卡片化、模板 CRUD、参数按钮插入) - ImageSelector/AvatarSelector 增加上传来源 is_admin 筛选 - 宿主机详情页实时指标与硬件/网卡 IPv6 展示优化 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { http2 } from '@/utils/request.js'
|
||||
|
||||
// ========== 通知渠道配置 ==========
|
||||
|
||||
/** 获取全部通知渠道配置列表(无分页) */
|
||||
export const getNoticeChannelList = () => {
|
||||
return http2.get('/api/v1/admin/notice_message/channel/list')
|
||||
}
|
||||
|
||||
/** 修改通知渠道配置 */
|
||||
export const updateNoticeChannel = (data) => {
|
||||
return http2.post('/api/v1/admin/notice_message/channel/update', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 通知模板管理 ==========
|
||||
|
||||
/** 获取全部通知模板列表(无分页) */
|
||||
export const getNoticeTemplateList = () => {
|
||||
return http2.get('/api/v1/admin/notice_message/template/list')
|
||||
}
|
||||
|
||||
/** 添加通知模板 */
|
||||
export const addNoticeTemplate = (data) => {
|
||||
return http2.post('/api/v1/admin/notice_message/template/add', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 修改通知模板 */
|
||||
export const updateNoticeTemplate = (data) => {
|
||||
return http2.post('/api/v1/admin/notice_message/template/update', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除通知模板 */
|
||||
export const deleteNoticeTemplate = (params) => {
|
||||
return http2.delete('/api/v1/admin/notice_message/template/delete', { params })
|
||||
}
|
||||
@@ -13,9 +13,15 @@
|
||||
<div class="file-list-container">
|
||||
<div class="file-list-header">
|
||||
<h4>文件列表</h4>
|
||||
<el-button type="primary" @click="switchToUpload" :icon="Upload">
|
||||
上传新文件
|
||||
</el-button>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="sourceFilter" placeholder="上传来源" clearable size="default" style="width: 120px" @change="handleSourceChange">
|
||||
<el-option label="管理员" :value="true" />
|
||||
<el-option label="用户" :value="false" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="switchToUpload" :icon="Upload">
|
||||
上传新文件
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-grid" v-loading="loading">
|
||||
<div
|
||||
@@ -137,6 +143,7 @@ import { closeAllMessage } from '../../utils/message'
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const sourceFilter = ref(undefined)
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
@@ -144,6 +151,7 @@ import { closeAllMessage } from '../../utils/message'
|
||||
if (newVal) {
|
||||
selectedId.value = props.currentCoverId
|
||||
currentPage.value = 1
|
||||
sourceFilter.value = undefined
|
||||
fetchFileList()
|
||||
}
|
||||
})
|
||||
@@ -161,10 +169,11 @@ import { closeAllMessage } from '../../utils/message'
|
||||
fileList.value = [] // 清空列表
|
||||
|
||||
try {
|
||||
const res = await getFileList({
|
||||
page: currentPage.value,
|
||||
count: pageSize.value
|
||||
})
|
||||
const params = { page: currentPage.value, count: pageSize.value }
|
||||
if (sourceFilter.value !== undefined && sourceFilter.value !== null && sourceFilter.value !== '') {
|
||||
params.is_admin = sourceFilter.value
|
||||
}
|
||||
const res = await getFileList(params)
|
||||
|
||||
console.log("获取文件列表:", res)
|
||||
|
||||
@@ -219,6 +228,12 @@ import { closeAllMessage } from '../../utils/message'
|
||||
fetchFileList()
|
||||
}
|
||||
|
||||
// 来源筛选变化
|
||||
const handleSourceChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchFileList()
|
||||
}
|
||||
|
||||
// 切换到上传标签页
|
||||
const switchToUpload = () => {
|
||||
activeTab.value = 'upload'
|
||||
@@ -312,6 +327,7 @@ import { closeAllMessage } from '../../utils/message'
|
||||
fileList.value = []
|
||||
currentPage.value = 1
|
||||
total.value = 0
|
||||
sourceFilter.value = undefined
|
||||
}
|
||||
|
||||
// 确认选择
|
||||
@@ -347,6 +363,12 @@ import { closeAllMessage } from '../../utils/message'
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
@input="handleSearch"
|
||||
style="width: 300px;"
|
||||
/>
|
||||
<el-select v-model="sourceFilter" placeholder="上传来源" clearable style="width: 130px; margin-left: 12px;" @change="handleSourceChange">
|
||||
<el-option label="管理员" :value="true" />
|
||||
<el-option label="用户" :value="false" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="file-grid" v-loading="loading">
|
||||
@@ -178,6 +182,7 @@ const currentPage = ref(1)
|
||||
const pageSize = ref(12)
|
||||
const total = ref(0)
|
||||
const searchKeyword = ref('')
|
||||
const sourceFilter = ref(undefined)
|
||||
const pendingFiles = ref([]) // 待上传文件列表
|
||||
const uploading = ref(false) // 批量上传中
|
||||
let fetchVersion = 0 // 防止 fetchFileList 竞态条件
|
||||
@@ -190,6 +195,7 @@ watch(() => props.modelValue, (newVal) => {
|
||||
selectedIds.value = new Set()
|
||||
currentPage.value = 1
|
||||
searchKeyword.value = ''
|
||||
sourceFilter.value = undefined
|
||||
fetchFileList()
|
||||
}
|
||||
})
|
||||
@@ -224,10 +230,11 @@ const fetchFileList = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const res = await getFileList({
|
||||
page: currentPage.value,
|
||||
count: pageSize.value
|
||||
})
|
||||
const params = { page: currentPage.value, count: pageSize.value }
|
||||
if (sourceFilter.value !== undefined && sourceFilter.value !== null && sourceFilter.value !== '') {
|
||||
params.is_admin = sourceFilter.value
|
||||
}
|
||||
const res = await getFileList(params)
|
||||
|
||||
// 如果有更新的请求发起,丢弃当前结果
|
||||
if (currentFetchVersion !== fetchVersion) return
|
||||
@@ -285,10 +292,15 @@ const handleTabClick = (tab) => {
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
// 搜索时重置到第一页
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
// 来源筛选变化
|
||||
const handleSourceChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchFileList()
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const handleSizeChange = (size) => {
|
||||
pageSize.value = size
|
||||
@@ -436,6 +448,7 @@ const handleClose = () => {
|
||||
currentPage.value = 1
|
||||
total.value = 0
|
||||
searchKeyword.value = ''
|
||||
sourceFilter.value = undefined
|
||||
// 清理待上传文件的预览URL
|
||||
pendingFiles.value.forEach(f => {
|
||||
if (f.previewUrl) URL.revokeObjectURL(f.previewUrl)
|
||||
@@ -495,6 +508,8 @@ const handleConfirm = () => {
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-grid {
|
||||
|
||||
@@ -194,6 +194,10 @@ export const menus = [
|
||||
path: '/system/setting-manage',
|
||||
title: '配置管理'
|
||||
},
|
||||
{
|
||||
path: '/system/notice-channel',
|
||||
title: '通知管理'
|
||||
},
|
||||
{
|
||||
path: '/system/menu',
|
||||
title: '菜单管理',
|
||||
|
||||
@@ -424,6 +424,12 @@ const routes = [
|
||||
component: () => import('../views/system/SettingManage.vue'),
|
||||
meta: { title: '配置管理' }
|
||||
},
|
||||
{
|
||||
path: 'notice-channel',
|
||||
name: 'NoticeChannel',
|
||||
component: () => import('../views/system/NoticeChannel.vue'),
|
||||
meta: { title: '通知管理' }
|
||||
},
|
||||
{
|
||||
path: 'menu-manage',
|
||||
name: 'MenuManage',
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
<template>
|
||||
<div class="notice-page">
|
||||
<!-- 页头 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 22c1.1 0 2-.9 2-2h-4a2 2 0 0 0 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" fill="#409eff"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="page-title">通知管理</h2>
|
||||
<p class="page-desc">管理通知渠道开关与消息模板,控制各业务事件的通知行为</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页 -->
|
||||
<el-tabs v-model="activeTab" type="border-card" class="main-tabs">
|
||||
<!-- ==================== 渠道配置 ==================== -->
|
||||
<el-tab-pane name="channel">
|
||||
<template #label>
|
||||
<span class="tab-label">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z" fill="currentColor"/><path d="M8 14h8v2H8zm0-4h8v2H8z" fill="currentColor"/></svg>
|
||||
渠道配置
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="channel-section" v-loading="channelLoading">
|
||||
<!-- 短信渠道 -->
|
||||
<div class="channel-group">
|
||||
<div class="group-header sms-header">
|
||||
<div class="group-icon sms-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="currentColor"/><path d="M7 9h10v2H7z" fill="currentColor"/><path d="M7 6h7v2H7z" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="group-title-area">
|
||||
<h3>短信通知</h3>
|
||||
<span class="group-count">{{ smsChannels.length }} 个事件</span>
|
||||
</div>
|
||||
<div class="group-summary">
|
||||
<el-tag type="success" size="small" effect="dark" round>{{ smsChannels.filter(c => c.enabled).length }} 已启用</el-tag>
|
||||
<el-tag type="info" size="small" effect="plain" round>{{ smsChannels.filter(c => !c.enabled).length }} 已关闭</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channel-cards">
|
||||
<div v-for="item in smsChannels" :key="item.id" class="channel-card" :class="{ 'enabled': item.enabled, 'disabled': !item.enabled }">
|
||||
<div class="card-top">
|
||||
<span class="card-event-name">{{ item.eventName || '-' }}</span>
|
||||
<el-switch v-model="item.enabled" :loading="item._switching" size="small" @change="(val) => handleToggle(item, val)" />
|
||||
</div>
|
||||
<div class="card-bottom">
|
||||
<code class="card-event-type">{{ item.eventType }}</code>
|
||||
<span v-if="item.note" class="card-note" :title="item.note">{{ item.note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="smsChannels.length === 0" description="暂无短信通知配置" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮件渠道 -->
|
||||
<div class="channel-group">
|
||||
<div class="group-header email-header">
|
||||
<div class="group-icon email-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="group-title-area">
|
||||
<h3>邮件通知</h3>
|
||||
<span class="group-count">{{ emailChannels.length }} 个事件</span>
|
||||
</div>
|
||||
<div class="group-summary">
|
||||
<el-tag type="success" size="small" effect="dark" round>{{ emailChannels.filter(c => c.enabled).length }} 已启用</el-tag>
|
||||
<el-tag type="info" size="small" effect="plain" round>{{ emailChannels.filter(c => !c.enabled).length }} 已关闭</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channel-cards">
|
||||
<div v-for="item in emailChannels" :key="item.id" class="channel-card" :class="{ 'enabled': item.enabled, 'disabled': !item.enabled }">
|
||||
<div class="card-top">
|
||||
<span class="card-event-name">{{ item.eventName || '-' }}</span>
|
||||
<el-switch v-model="item.enabled" :loading="item._switching" size="small" @change="(val) => handleToggle(item, val)" />
|
||||
</div>
|
||||
<div class="card-bottom">
|
||||
<code class="card-event-type">{{ item.eventType }}</code>
|
||||
<span v-if="item.note" class="card-note" :title="item.note">{{ item.note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="emailChannels.length === 0" description="暂无邮件通知配置" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他渠道 -->
|
||||
<div class="channel-group" v-if="otherChannels.length > 0">
|
||||
<div class="group-header other-header">
|
||||
<div class="group-icon other-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="group-title-area">
|
||||
<h3>其他渠道</h3>
|
||||
<span class="group-count">{{ otherChannels.length }} 个事件</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channel-cards">
|
||||
<div v-for="item in otherChannels" :key="item.id" class="channel-card" :class="{ 'enabled': item.enabled, 'disabled': !item.enabled }">
|
||||
<div class="card-top">
|
||||
<span class="card-event-name">{{ item.eventName || '-' }}</span>
|
||||
<el-tag size="small" type="info">{{ item.channel }}</el-tag>
|
||||
<el-switch v-model="item.enabled" :loading="item._switching" size="small" @change="(val) => handleToggle(item, val)" />
|
||||
</div>
|
||||
<div class="card-bottom">
|
||||
<code class="card-event-type">{{ item.eventType }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ==================== 模板管理 ==================== -->
|
||||
<el-tab-pane name="template">
|
||||
<template #label>
|
||||
<span class="tab-label">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z" fill="currentColor"/><path d="M8 12h8v2H8zm0 4h5v2H8z" fill="currentColor"/></svg>
|
||||
模板管理
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="template-section">
|
||||
<div class="tpl-toolbar">
|
||||
<div class="tpl-toolbar-left">
|
||||
<el-radio-group v-model="tplTypeFilter" size="default" @change="filterTemplates">
|
||||
<el-radio-button value="">全部</el-radio-button>
|
||||
<el-radio-button value="email">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" style="vertical-align:-2px;margin-right:3px"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="currentColor"/></svg>
|
||||
邮件模板
|
||||
</el-radio-button>
|
||||
<el-radio-button value="phone">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" style="vertical-align:-2px;margin-right:3px"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="currentColor"/></svg>
|
||||
短信模板
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openTplDialog()">新增模板</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredTemplates" v-loading="tplLoading" stripe border style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="65" align="center" />
|
||||
<el-table-column prop="name" label="模板名称" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="tpl-name">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tag" label="模板标识" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<code class="tpl-tag">{{ row.tag }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'email' ? 'warning' : 'success'" effect="dark" size="small" round>
|
||||
<span style="display:inline-flex;align-items:center;gap:3px">
|
||||
<svg v-if="row.type === 'email'" viewBox="0 0 24 24" width="12" height="12"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="currentColor"/></svg>
|
||||
<svg v-else viewBox="0 0 24 24" width="12" height="12"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="currentColor"/></svg>
|
||||
{{ row.type === 'email' ? '邮件' : '短信' }}
|
||||
</span>
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="content" label="模板内容" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="tpl-content-preview" :title="row.content">{{ row.content || '-' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="args" label="参数" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.args" class="args-tags">
|
||||
<el-tag v-for="arg in parseArgs(row.args)" :key="arg" size="small" type="info" effect="plain" class="arg-tag">{{ arg }}</el-tag>
|
||||
</div>
|
||||
<span v-else class="no-args">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="note" label="说明" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }"><span class="note-text">{{ row.note || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ formatTime(row.UpdatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openTplDialog(row)">编辑</el-button>
|
||||
<el-button size="small" link type="danger" @click="handleDeleteTpl(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- ==================== 模板编辑弹窗 ==================== -->
|
||||
<el-dialog v-model="tplDialogVisible" :title="tplForm.id ? '编辑模板' : '新增模板'" width="640px" append-to-body destroy-on-close>
|
||||
<el-form :model="tplForm" :rules="tplRules" ref="tplFormRef" label-width="90px" label-position="right">
|
||||
<el-form-item label="模板名称" prop="name">
|
||||
<el-input v-model="tplForm.name" placeholder="例:用户注册通知" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板标识" prop="tag">
|
||||
<el-input v-model="tplForm.tag" placeholder="例:user_register_notify" maxlength="100" :disabled="!!tplForm.id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板类型" prop="type">
|
||||
<el-radio-group v-model="tplForm.type">
|
||||
<el-radio value="email">
|
||||
<span class="radio-with-icon">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="#e6a23c"/></svg>
|
||||
邮件
|
||||
</span>
|
||||
</el-radio>
|
||||
<el-radio value="phone">
|
||||
<span class="radio-with-icon">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="#67c23a"/></svg>
|
||||
短信
|
||||
</span>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="可用参数" v-if="tplFormArgs.length > 0">
|
||||
<div class="args-btn-group">
|
||||
<el-button v-for="arg in tplFormArgs" :key="arg" size="small" @click="insertArg(arg)" class="arg-insert-btn">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" style="margin-right:3px"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="currentColor"/></svg>
|
||||
{{ arg }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="form-hint">点击参数按钮可将其插入到模板内容光标处</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板内容" prop="content">
|
||||
<el-input ref="contentInputRef" v-model="tplForm.content" type="textarea" :rows="6" placeholder="模板内容,点击上方参数按钮插入变量" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="tplDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="tplSubmitting" @click="handleSubmitTpl">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getNoticeChannelList, updateNoticeChannel,
|
||||
getNoticeTemplateList, addNoticeTemplate, updateNoticeTemplate, deleteNoticeTemplate
|
||||
} from '@/api/admin/noticeChannel'
|
||||
|
||||
// ==================== 渠道配置 ====================
|
||||
const activeTab = ref('channel')
|
||||
const channelLoading = ref(false)
|
||||
const channelList = ref([])
|
||||
|
||||
const smsChannels = computed(() => channelList.value.filter(c => c.channel === 'sms'))
|
||||
const emailChannels = computed(() => channelList.value.filter(c => c.channel === 'email'))
|
||||
const otherChannels = computed(() => channelList.value.filter(c => c.channel !== 'sms' && c.channel !== 'email'))
|
||||
|
||||
const loadChannels = async () => {
|
||||
channelLoading.value = true
|
||||
try {
|
||||
const res = await getNoticeChannelList()
|
||||
const body = res?.data
|
||||
if (body?.code === 200) {
|
||||
const list = body.data?.data || body.data || []
|
||||
channelList.value = (Array.isArray(list) ? list : []).map(item => ({ ...item, _switching: false }))
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载渠道配置失败')
|
||||
} finally {
|
||||
channelLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (row, val) => {
|
||||
row._switching = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('id', row.id)
|
||||
fd.append('enabled', val)
|
||||
const res = await updateNoticeChannel(fd)
|
||||
if (res?.data?.code === 200) {
|
||||
const chName = row.channel === 'sms' ? '短信' : row.channel === 'email' ? '邮件' : row.channel
|
||||
ElMessage.success(`已${val ? '启用' : '关闭'} ${row.eventName} - ${chName}`)
|
||||
} else {
|
||||
row.enabled = !val
|
||||
ElMessage.error(res?.data?.message || '操作失败')
|
||||
}
|
||||
} catch {
|
||||
row.enabled = !val
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
row._switching = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 模板管理 ====================
|
||||
const tplLoading = ref(false)
|
||||
const templateList = ref([])
|
||||
const tplTypeFilter = ref('')
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
if (!tplTypeFilter.value) return templateList.value
|
||||
return templateList.value.filter(t => t.type === tplTypeFilter.value)
|
||||
})
|
||||
|
||||
const filterTemplates = () => {}
|
||||
|
||||
const loadTemplates = async () => {
|
||||
tplLoading.value = true
|
||||
try {
|
||||
const res = await getNoticeTemplateList()
|
||||
const body = res?.data
|
||||
if (body?.code === 200) {
|
||||
const list = body.data?.data || body.data || []
|
||||
templateList.value = Array.isArray(list) ? list : []
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载模板列表失败')
|
||||
} finally {
|
||||
tplLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 模板弹窗
|
||||
const tplDialogVisible = ref(false)
|
||||
const tplSubmitting = ref(false)
|
||||
const tplFormRef = ref(null)
|
||||
const tplForm = ref({ id: 0, name: '', tag: '', content: '', type: 'email', args: '' })
|
||||
const tplRules = {
|
||||
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
|
||||
tag: [{ required: true, message: '请输入模板标识', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
|
||||
content: [{ required: true, message: '请输入模板内容', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const contentInputRef = ref(null)
|
||||
|
||||
const tplFormArgs = computed(() => parseArgs(tplForm.value.args))
|
||||
|
||||
const insertArg = (arg) => {
|
||||
const snippet = `{{index . "${arg}"}}`
|
||||
const textarea = contentInputRef.value?.textarea
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const before = tplForm.value.content.slice(0, start)
|
||||
const after = tplForm.value.content.slice(end)
|
||||
tplForm.value.content = before + snippet + after
|
||||
nextTick(() => {
|
||||
const pos = start + snippet.length
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(pos, pos)
|
||||
})
|
||||
} else {
|
||||
tplForm.value.content += snippet
|
||||
}
|
||||
}
|
||||
|
||||
const openTplDialog = (row) => {
|
||||
if (row) {
|
||||
tplForm.value = { id: row.id, name: row.name, tag: row.tag, content: row.content, type: row.type, args: row.args || '' }
|
||||
} else {
|
||||
tplForm.value = { id: 0, name: '', tag: '', content: '', type: 'email', args: '' }
|
||||
}
|
||||
tplDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmitTpl = async () => {
|
||||
const formEl = tplFormRef.value
|
||||
if (!formEl) return
|
||||
await formEl.validate()
|
||||
|
||||
tplSubmitting.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
if (tplForm.value.id) fd.append('id', tplForm.value.id)
|
||||
fd.append('name', tplForm.value.name)
|
||||
fd.append('tag', tplForm.value.tag)
|
||||
fd.append('content', tplForm.value.content)
|
||||
fd.append('type', tplForm.value.type)
|
||||
if (tplForm.value.args) fd.append('args', tplForm.value.args)
|
||||
|
||||
const apiFn = tplForm.value.id ? updateNoticeTemplate : addNoticeTemplate
|
||||
const res = await apiFn(fd)
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success(tplForm.value.id ? '模板已更新' : '模板已添加')
|
||||
tplDialogVisible.value = false
|
||||
loadTemplates()
|
||||
} else {
|
||||
ElMessage.error(res?.data?.message || '操作失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
tplSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTpl = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除模板「${row.name}」吗?`, '确认删除', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
|
||||
const res = await deleteNoticeTemplate({ id: row.id })
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('模板已删除')
|
||||
loadTemplates()
|
||||
} else {
|
||||
ElMessage.error(res?.data?.message || '删除失败')
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
const d = new Date(t)
|
||||
if (isNaN(d.getTime())) return t
|
||||
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
const parseArgs = (args) => {
|
||||
if (!args) return []
|
||||
return args.split('/').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
onMounted(() => {
|
||||
loadChannels()
|
||||
loadTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notice-page { padding: 20px 24px; }
|
||||
|
||||
/* ===== 页头 ===== */
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
||||
.header-left { display: flex; align-items: center; gap: 14px; }
|
||||
.header-icon { width: 48px; height: 48px; border-radius: 12px; background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.page-title { margin: 0 0 2px; font-size: 20px; font-weight: 700; color: #1d2129; }
|
||||
.page-desc { margin: 0; font-size: 13px; color: #86909c; }
|
||||
|
||||
/* ===== 标签页 ===== */
|
||||
.main-tabs { border-radius: 8px; }
|
||||
.tab-label { display: inline-flex; align-items: center; gap: 6px; }
|
||||
|
||||
/* ===== 渠道配置 ===== */
|
||||
.channel-section { padding: 4px 0; }
|
||||
.channel-group { margin-bottom: 24px; }
|
||||
.channel-group:last-child { margin-bottom: 0; }
|
||||
|
||||
.group-header { display: flex; align-items: center; gap: 12px; padding: 14px 18px; border-radius: 10px; margin-bottom: 14px; }
|
||||
.sms-header { background: linear-gradient(135deg, #f0f9eb 0%, #e1f3d8 100%); }
|
||||
.email-header { background: linear-gradient(135deg, #fdf6ec 0%, #faecd8 100%); }
|
||||
.other-header { background: linear-gradient(135deg, #f4f4f5 0%, #e9e9eb 100%); }
|
||||
|
||||
.group-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.sms-icon { background: #67c23a; color: #fff; }
|
||||
.email-icon { background: #e6a23c; color: #fff; }
|
||||
.other-icon { background: #909399; color: #fff; }
|
||||
|
||||
.group-title-area { flex: 1; }
|
||||
.group-title-area h3 { margin: 0; font-size: 15px; font-weight: 600; color: #1d2129; }
|
||||
.group-count { font-size: 12px; color: #86909c; }
|
||||
.group-summary { display: flex; gap: 6px; }
|
||||
|
||||
.channel-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
||||
|
||||
.channel-card { border: 1px solid #e4e7ed; border-radius: 8px; padding: 14px 16px; transition: all .25s; background: #fff; }
|
||||
.channel-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.08); transform: translateY(-1px); }
|
||||
.channel-card.enabled { border-left: 3px solid #67c23a; }
|
||||
.channel-card.disabled { border-left: 3px solid #dcdfe6; opacity: .7; }
|
||||
|
||||
.card-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.card-event-name { font-size: 14px; font-weight: 500; color: #1d2129; }
|
||||
.card-bottom { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.card-event-type { font-size: 11px; font-family: 'Consolas','Monaco',monospace; color: #909399; background: #f5f7fa; padding: 1px 6px; border-radius: 3px; }
|
||||
.card-note { font-size: 12px; color: #a8abb2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; }
|
||||
|
||||
/* ===== 模板管理 ===== */
|
||||
.template-section { padding: 4px 0; }
|
||||
.tpl-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 10px; }
|
||||
.tpl-toolbar-left { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.tpl-name { font-weight: 500; color: #1d2129; }
|
||||
.tpl-tag { font-size: 12px; font-family: 'Consolas','Monaco',monospace; color: #606266; background: #f0f2f5; padding: 2px 8px; border-radius: 4px; }
|
||||
.tpl-content-preview { font-size: 12px; color: #606266; max-height: 42px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.6; }
|
||||
.note-text { color: #86909c; font-size: 13px; }
|
||||
.no-args { color: #c0c4cc; }
|
||||
|
||||
.args-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.args-btn-group { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 4px; }
|
||||
.arg-insert-btn { font-family: 'Consolas','Monaco',monospace; font-size: 12px; }
|
||||
.arg-tag { font-family: 'Consolas','Monaco',monospace; font-size: 11px; }
|
||||
.form-hint { margin-top: 6px; font-size: 12px; color: #a8abb2; line-height: 1.4; }
|
||||
.form-hint code { background: #f5f7fa; padding: 1px 5px; border-radius: 3px; font-size: 11px; color: #606266; }
|
||||
|
||||
/* ===== 弹窗 ===== */
|
||||
.radio-with-icon { display: inline-flex; align-items: center; gap: 4px; }
|
||||
</style>
|
||||
@@ -54,6 +54,18 @@
|
||||
<span class="status-label">带宽</span>
|
||||
<span class="status-value">↓{{ detail.rx_bandwidth || 0 }} / ↑{{ detail.tx_bandwidth || 0 }} Mbps</span>
|
||||
</div>
|
||||
<div class="status-item" v-if="loadAvg">
|
||||
<span class="status-label">负载</span>
|
||||
<span class="status-value">
|
||||
<span :style="{ color: loadColor(loadAvg['1min'], hostMetrics?.cpu?.cpu_count) }">{{ loadAvg['1min']?.toFixed(2) }}</span> /
|
||||
<span :style="{ color: loadColor(loadAvg['5min'], hostMetrics?.cpu?.cpu_count) }">{{ loadAvg['5min']?.toFixed(2) }}</span> /
|
||||
<span :style="{ color: loadColor(loadAvg['15min'], hostMetrics?.cpu?.cpu_count) }">{{ loadAvg['15min']?.toFixed(2) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-item" v-if="hostMetrics?.internet_speed">
|
||||
<span class="status-label">实时网速</span>
|
||||
<span class="status-value">↓{{ formatSpeedAuto(hostMetrics.internet_speed.rx_bytes) }} / ↑{{ formatSpeedAuto(hostMetrics.internet_speed.tx_bytes) }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">创建时间</span>
|
||||
<span class="status-value">{{ formatTimestamp(detail.created_at) }}</span>
|
||||
@@ -131,6 +143,187 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 实时监控概览 -->
|
||||
<div class="section-block" v-loading="hostMetricsLoading">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title"><svg class="sec-icon" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#67c23a"/></svg> 实时监控</h3>
|
||||
<el-button size="small" :icon="Refresh" @click="loadHostMetrics" :loading="hostMetricsLoading">刷新</el-button>
|
||||
</div>
|
||||
<div class="rt-grid" v-if="hostMetrics">
|
||||
<!-- CPU -->
|
||||
<div class="rt-card" v-if="hostMetrics.cpu">
|
||||
<div class="rt-card-icon cpu-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M15 3H9v2H7v2H5v2H3v6h2v2h2v2h2v2h6v-2h2v-2h2v-2h2V9h-2V7h-2V5h-2V3zm0 2v2h2v2h2v6h-2v2h-2v2H9v-2H7v-2H5V9h2V7h2V5h6z" fill="currentColor"/><rect x="10" y="10" width="4" height="4" rx="0.5" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="rt-card-body">
|
||||
<span class="rt-label">CPU</span>
|
||||
<span class="rt-value" :style="{ color: quotaColor(hostMetrics.cpu.cpu_usage_percent ?? 0) }">{{ hostMetrics.cpu.cpu_usage_percent?.toFixed(1) }}<small>%</small></span>
|
||||
<el-progress :percentage="Math.min(100, hostMetrics.cpu.cpu_usage_percent ?? 0)" :show-text="false" :stroke-width="6" :color="quotaColor(hostMetrics.cpu.cpu_usage_percent ?? 0)" />
|
||||
<span class="rt-sub">{{ hostMetrics.cpu.cpu_count }} 逻辑核心</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 内存 -->
|
||||
<div class="rt-card" v-if="hostMetrics.memory">
|
||||
<div class="rt-card-icon mem-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 5h16a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V6a1 1 0 011-1zm1 2v10h14V7H5zm2 2h2v6H7V9zm4 0h2v6h-2V9zm4 2h2v4h-2v-4z" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="rt-card-body">
|
||||
<span class="rt-label">内存</span>
|
||||
<span class="rt-value" :style="{ color: quotaColor(hostMetrics.memory.percent ?? 0) }">{{ hostMetrics.memory.percent?.toFixed(1) }}<small>%</small></span>
|
||||
<el-progress :percentage="Math.min(100, hostMetrics.memory.percent ?? 0)" :show-text="false" :stroke-width="6" :color="quotaColor(hostMetrics.memory.percent ?? 0)" />
|
||||
<span class="rt-sub">{{ formatBytesAuto(hostMetrics.memory.used) }} / {{ formatBytesAuto(hostMetrics.memory.total) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 负载 -->
|
||||
<div class="rt-card" v-if="loadAvg">
|
||||
<div class="rt-card-icon load-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M3 13h2v8H3v-8zm4-4h2v12H7V9zm4-4h2v16h-2V5zm4 6h2v10h-2V11zm4-2h2v12h-2V9z" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="rt-card-body">
|
||||
<span class="rt-label">系统负载</span>
|
||||
<div class="load-pills">
|
||||
<span class="load-pill" :style="{ '--lc': loadColor(loadAvg['1min'], hostMetrics?.cpu?.cpu_count) }">
|
||||
<em>1m</em>{{ loadAvg['1min']?.toFixed(2) }}
|
||||
</span>
|
||||
<span class="load-pill" :style="{ '--lc': loadColor(loadAvg['5min'], hostMetrics?.cpu?.cpu_count) }">
|
||||
<em>5m</em>{{ loadAvg['5min']?.toFixed(2) }}
|
||||
</span>
|
||||
<span class="load-pill" :style="{ '--lc': loadColor(loadAvg['15min'], hostMetrics?.cpu?.cpu_count) }">
|
||||
<em>15m</em>{{ loadAvg['15min']?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="rt-sub">核心数 {{ hostMetrics?.cpu?.cpu_count ?? '-' }},满载阈值 {{ hostMetrics?.cpu?.cpu_count ?? '-' }}.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 网络 -->
|
||||
<div class="rt-card" v-if="hostMetrics.internet_speed">
|
||||
<div class="rt-card-icon net-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="rt-card-body">
|
||||
<span class="rt-label">网络带宽</span>
|
||||
<div class="net-bw-row">
|
||||
<span class="net-bw-item rx"><svg viewBox="0 0 12 12" width="12" height="12"><path d="M6 2v8M3 7l3 3 3-3" stroke="#67c23a" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>{{ formatSpeedAuto(hostMetrics.internet_speed.rx_bytes) }}</span>
|
||||
<span class="net-bw-item tx"><svg viewBox="0 0 12 12" width="12" height="12"><path d="M6 10V2M3 5l3-3 3 3" stroke="#409eff" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>{{ formatSpeedAuto(hostMetrics.internet_speed.tx_bytes) }}</span>
|
||||
</div>
|
||||
<span class="rt-sub" v-if="hostMetrics.network">累计 ↓{{ formatBytesAuto(hostMetrics.network.rx_bytes) }} ↑{{ formatBytesAuto(hostMetrics.network.tx_bytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 磁盘使用 -->
|
||||
<template v-if="metricsDisks.length">
|
||||
<h4 class="rt-subtitle"><svg viewBox="0 0 24 24" width="16" height="16" style="vertical-align:-2px"><path d="M4 4h16a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2V6a2 2 0 012-2zm0 2v12h16V6H4zm2 8h2v2H6v-2zm4 0h8v2h-8v-2z" fill="#909399"/></svg> 磁盘挂载</h4>
|
||||
<div class="disk-list">
|
||||
<div class="disk-item" v-for="disk in metricsDisks" :key="disk.path">
|
||||
<div class="disk-head">
|
||||
<code class="disk-path">{{ disk.path }}</code>
|
||||
<span class="disk-detail">{{ formatBytesAuto(disk.used) }} / {{ formatBytesAuto(disk.total) }}</span>
|
||||
<span class="disk-pct" :style="{ color: quotaColor(disk.percent) }">{{ disk.percent.toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress :percentage="Math.min(100, disk.percent)" :show-text="false" :stroke-width="6" :color="quotaColor(disk.percent)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-if="!hostMetrics && !hostMetricsLoading" description="暂无实时监控数据" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 硬件与系统信息 -->
|
||||
<div class="section-block" v-if="hardwareInfo">
|
||||
<h3 class="section-title clickable" @click="showHardwareInfo = !showHardwareInfo">
|
||||
<svg class="sec-icon" viewBox="0 0 24 24"><path d="M20 8h-3V6c0-1.1-.9-2-2-2H9c-1.1 0-2 .9-2 2v2H4c-1.1 0-2 .9-2 2v10h20V10c0-1.1-.9-2-2-2zM9 6h6v2H9V6zm11 12H4v-6h16v6zm0-8H4v-0c0 0 0 0 0 0h16v0z" fill="#606266"/></svg>
|
||||
硬件与系统
|
||||
<el-icon class="section-arrow" :class="{ expanded: showHardwareInfo }"><ArrowRight /></el-icon>
|
||||
</h3>
|
||||
<div v-show="showHardwareInfo">
|
||||
<!-- 系统概览 -->
|
||||
<div class="hw-overview" v-if="hardwareInfo.system_info">
|
||||
<div class="hw-ov-item">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-.22-13h-.06c-.4 0-.72.32-.72.72v4.72c0 .35.18.68.49.86l4.15 2.49c.34.2.78.1.98-.24.21-.34.1-.79-.25-.99l-3.87-2.3V7.72c0-.4-.32-.72-.72-.72z" fill="#409eff"/></svg>
|
||||
<div><em>运行</em>{{ formatUptime(hardwareInfo.system_info.uptime_seconds) }}</div>
|
||||
</div>
|
||||
<div class="hw-ov-item">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M20 18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H1v2h22v-2h-3zM4 6h16v10H4V6z" fill="#67c23a"/></svg>
|
||||
<div><em>主机</em>{{ hardwareInfo.system_info.hostname }}</div>
|
||||
</div>
|
||||
<div class="hw-ov-item">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z" fill="#e6a23c"/></svg>
|
||||
<div><em>系统</em>{{ hardwareInfo.system_info.distro || hardwareInfo.system_info.os }}</div>
|
||||
</div>
|
||||
<div class="hw-ov-item">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" fill="#909399"/></svg>
|
||||
<div><em>内核</em><span class="mono-text" style="font-size:12px">{{ hardwareInfo.system_info.os }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- CPU / 内存 -->
|
||||
<div class="hw-spec-grid">
|
||||
<div class="hw-spec-card">
|
||||
<div class="hw-spec-icon"><svg viewBox="0 0 24 24" width="20" height="20"><path d="M15 3H9v2H7v2H5v2H3v6h2v2h2v2h2v2h6v-2h2v-2h2v-2h2V9h-2V7h-2V5h-2V3zm0 2v2h2v2h2v6h-2v2h-2v2H9v-2H7v-2H5V9h2V7h2V5h6z" fill="#409eff"/><rect x="10" y="10" width="4" height="4" rx="0.5" fill="#409eff"/></svg></div>
|
||||
<div class="hw-spec-body">
|
||||
<span class="hw-spec-label">处理器</span>
|
||||
<span class="hw-spec-main">{{ hardwareInfo.cpu_model }}</span>
|
||||
<span class="hw-spec-detail">{{ hardwareInfo.cpu_physical_cores }}C / {{ hardwareInfo.cpu_logical_cores }}T · {{ formatCpuFreq(hardwareInfo.cpu_freq) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hw-spec-card">
|
||||
<div class="hw-spec-icon"><svg viewBox="0 0 24 24" width="20" height="20"><path d="M4 5h16a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V6a1 1 0 011-1zm1 2v10h14V7H5zm2 2h2v6H7V9zm4 0h2v6h-2V9zm4 2h2v4h-2v-4z" fill="#67c23a"/></svg></div>
|
||||
<div class="hw-spec-body">
|
||||
<span class="hw-spec-label">内存</span>
|
||||
<span class="hw-spec-main">{{ formatBytesAuto(hardwareInfo.memory_total) }}</span>
|
||||
<span class="hw-spec-detail">Swap {{ hardwareInfo.swap_total ? formatBytesAuto(hardwareInfo.swap_total) : '无' }} · {{ hardwareInfo.system_info?.arch || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 磁盘设备 -->
|
||||
<template v-if="hardwareInfo.disk_devices && hardwareInfo.disk_devices.length">
|
||||
<h4 class="hw-subtitle"><svg viewBox="0 0 24 24" width="14" height="14" style="vertical-align:-1px"><path d="M4 4h16a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2V6a2 2 0 012-2zm0 2v12h16V6H4zm2 8h2v2H6v-2zm4 0h8v2h-8v-2z" fill="#606266"/></svg> 存储设备</h4>
|
||||
<div class="hw-disk-cards">
|
||||
<div class="hw-disk-card" v-for="dev in hardwareInfo.disk_devices" :key="dev.name">
|
||||
<div class="hw-disk-icon">
|
||||
<svg v-if="dev.type === 'ssd'" viewBox="0 0 24 24" width="28" height="28"><rect x="3" y="6" width="18" height="12" rx="2" stroke="#67c23a" stroke-width="1.5" fill="none"/><path d="M8 10l2 2-2 2M12 14h4" stroke="#67c23a" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<svg v-else viewBox="0 0 24 24" width="28" height="28"><circle cx="12" cy="12" r="9" stroke="#909399" stroke-width="1.5" fill="none"/><circle cx="12" cy="12" r="3" stroke="#909399" stroke-width="1.5" fill="none"/><line x1="12" y1="3" x2="12" y2="6" stroke="#909399" stroke-width="1.5"/></svg>
|
||||
</div>
|
||||
<div class="hw-disk-info">
|
||||
<span class="hw-disk-name">/dev/{{ dev.name }}</span>
|
||||
<span class="hw-disk-model">{{ dev.model || '-' }}</span>
|
||||
</div>
|
||||
<div class="hw-disk-meta">
|
||||
<el-tag :type="dev.type === 'ssd' ? 'success' : 'info'" size="small" effect="plain">{{ (dev.type || 'HDD').toUpperCase() }}</el-tag>
|
||||
<span class="hw-disk-size">{{ formatBytesAuto(dev.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 网卡 -->
|
||||
<template v-if="filteredNics.length">
|
||||
<h4 class="hw-subtitle"><svg viewBox="0 0 24 24" width="14" height="14" style="vertical-align:-1px"><path d="M20 2H4a2 2 0 00-2 2v16a2 2 0 002 2h16a2 2 0 002-2V4a2 2 0 00-2-2zM8 18H6v-4h2v4zm4 0h-2V8h2v10zm4 0h-2v-6h2v6z" fill="#606266"/></svg> 网卡</h4>
|
||||
<div class="nic-cards">
|
||||
<div class="nic-card" v-for="nic in filteredNics" :key="nic.name" :class="{ 'nic-down': !nic.is_up }">
|
||||
<div class="nic-head">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" :fill="nic.is_up ? '#67c23a' : '#c0c4cc'"/></svg>
|
||||
<span class="nic-name">{{ nic.name }}</span>
|
||||
<el-tag :type="nic.is_up ? 'success' : 'info'" size="small" effect="plain" class="nic-tag">{{ nic.is_up ? 'UP' : 'DOWN' }}</el-tag>
|
||||
<span class="nic-speed" v-if="nic.speed_mbps">{{ nic.speed_mbps >= 1000 ? (nic.speed_mbps/1000)+'G' : nic.speed_mbps+'M' }}</span>
|
||||
</div>
|
||||
<div class="nic-body">
|
||||
<div class="nic-row" v-if="nic._ipv4List.length">
|
||||
<span class="nic-k">IPv4</span>
|
||||
<div class="nic-addr-list"><span class="nic-addr" v-for="(ip, i) in nic._ipv4List" :key="i">{{ ip }}</span></div>
|
||||
</div>
|
||||
<div class="nic-row" v-if="nic._ipv6List.length">
|
||||
<span class="nic-k">IPv6</span>
|
||||
<div class="nic-addr-list"><span class="nic-addr v6" v-for="(ip, i) in nic._ipv6List" :key="i">{{ ip }}</span></div>
|
||||
</div>
|
||||
<div class="nic-row" v-if="nic._mac">
|
||||
<span class="nic-k">MAC</span>
|
||||
<code class="nic-mac">{{ nic._mac }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-block">
|
||||
<h3 class="section-title clickable" @click="showDetailDiskIo = !showDetailDiskIo">
|
||||
硬盘 IO 限制
|
||||
@@ -746,7 +939,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, ArrowRight, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostDetail, updateRemoteHost, deleteRemoteHost,
|
||||
getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost,
|
||||
getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking,
|
||||
assignUserNetworking, removeUserNetworkingNetwork,
|
||||
createHostToken, getMetricsHistory, getHostQuotaStats,
|
||||
@@ -887,6 +1080,7 @@ const getTokenIoBwFactor = () => ioBwUnitOptions.find(u => u.label === tokenIoBw
|
||||
const showDiskIoSection = ref(false)
|
||||
const showTokenDiskIo = ref(false)
|
||||
const showDetailDiskIo = ref(false)
|
||||
const showHardwareInfo = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '',
|
||||
@@ -962,6 +1156,111 @@ const loadDetail = async () => {
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
|
||||
}
|
||||
|
||||
// ---- 实时监控指标 ----
|
||||
const hostMetrics = ref(null)
|
||||
const hostMetricsLoading = ref(false)
|
||||
const hardwareInfo = ref(null)
|
||||
const loadAvg = ref(null)
|
||||
|
||||
const loadHostMetrics = async () => {
|
||||
if (!hostId.value) return
|
||||
hostMetricsLoading.value = true
|
||||
try {
|
||||
const res = await getRemoteHostMetrics({ service_id: serviceId.value, host_id: hostId.value })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const raw = body.data.data ?? body.data
|
||||
hostMetrics.value = raw
|
||||
if (raw.hardware_json) {
|
||||
try { hardwareInfo.value = JSON.parse(raw.hardware_json) } catch { hardwareInfo.value = null }
|
||||
}
|
||||
if (raw.load_avg_json) {
|
||||
try { loadAvg.value = JSON.parse(raw.load_avg_json) } catch { loadAvg.value = null }
|
||||
}
|
||||
if (raw.ksm && !ksmStats.value) {
|
||||
ksmStats.value = raw.ksm
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载实时指标失败:', e)
|
||||
} finally {
|
||||
hostMetricsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const metricsDisks = computed(() => {
|
||||
if (!hostMetrics.value?.disk) return []
|
||||
return Object.entries(hostMetrics.value.disk).map(([path, info]) => {
|
||||
const pct = info.percent ?? (info.total ? ((info.used / info.total) * 100) : 0)
|
||||
return { path, ...info, percent: pct }
|
||||
})
|
||||
})
|
||||
|
||||
const formatBytesAuto = (val) => {
|
||||
if (!val && val !== 0) return '-'
|
||||
val = Number(val)
|
||||
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
|
||||
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
|
||||
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
|
||||
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
|
||||
return val + ' B'
|
||||
}
|
||||
|
||||
const formatSpeedAuto = (val) => {
|
||||
if (!val && val !== 0) return '0 B/s'
|
||||
val = Number(val)
|
||||
if (val >= 1073741824) return (val / 1073741824).toFixed(1) + ' GB/s'
|
||||
if (val >= 1048576) return (val / 1048576).toFixed(1) + ' MB/s'
|
||||
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB/s'
|
||||
return val + ' B/s'
|
||||
}
|
||||
|
||||
const formatCpuFreq = (freq) => {
|
||||
if (!freq) return '-'
|
||||
if (typeof freq === 'object') {
|
||||
const cur = freq.current ? (freq.current >= 1000 ? (freq.current / 1000).toFixed(2) + ' GHz' : freq.current.toFixed(0) + ' MHz') : ''
|
||||
const max = freq.max ? (freq.max >= 1000 ? (freq.max / 1000).toFixed(1) + ' GHz' : freq.max + ' MHz') : ''
|
||||
if (cur && max) return `${cur}(最高 ${max})`
|
||||
return cur || max || '-'
|
||||
}
|
||||
const v = Number(freq)
|
||||
return v >= 1000 ? (v / 1000).toFixed(2) + ' GHz' : v + ' MHz'
|
||||
}
|
||||
|
||||
const formatUptime = (seconds) => {
|
||||
if (!seconds) return '-'
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const parts = []
|
||||
if (d > 0) parts.push(`${d} 天`)
|
||||
if (h > 0) parts.push(`${h} 小时`)
|
||||
if (m > 0 && d === 0) parts.push(`${m} 分钟`)
|
||||
return parts.join(' ') || '< 1 分钟'
|
||||
}
|
||||
|
||||
const loadColor = (val, cores) => {
|
||||
if (!cores) return '#909399'
|
||||
const ratio = val / cores
|
||||
if (ratio >= 1) return '#f56c6c'
|
||||
if (ratio >= 0.7) return '#e6a23c'
|
||||
return '#67c23a'
|
||||
}
|
||||
|
||||
const filteredNics = computed(() => {
|
||||
if (!hardwareInfo.value?.nic_info) return []
|
||||
const skipPrefixes = ['vnet', 'veth', 'ovs-', 'br-int', 'tap']
|
||||
return hardwareInfo.value.nic_info
|
||||
.filter(nic => !skipPrefixes.some(p => nic.name.startsWith(p)))
|
||||
.map(nic => {
|
||||
const addrs = nic.addresses || []
|
||||
const ipv4List = addrs.filter(a => a.family?.includes('AF_INET') && !a.family?.includes('AF_INET6')).map(a => a.address)
|
||||
const ipv6List = addrs.filter(a => a.family?.includes('AF_INET6')).map(a => a.address)
|
||||
const macEntry = addrs.find(a => a.family?.includes('AF_PACKET'))
|
||||
return { ...nic, _ipv4List: ipv4List, _ipv6List: ipv6List, _mac: macEntry?.address || '' }
|
||||
})
|
||||
})
|
||||
|
||||
// ---- 额度统计 ----
|
||||
const quotaStats = ref(null)
|
||||
const quotaStatsLoading = ref(false)
|
||||
@@ -1637,6 +1936,7 @@ const initPage = () => {
|
||||
historicalMetricsData.value = null
|
||||
disposeCharts()
|
||||
loadDetail()
|
||||
loadHostMetrics()
|
||||
if (activeTab.value === 'monitor') loadHistoricalMetrics()
|
||||
}
|
||||
|
||||
@@ -1745,4 +2045,80 @@ onBeforeUnmount(() => { isPageActive = false; disposeCharts() })
|
||||
.quota-disk-detail { font-size: 12px; color: #86909c; flex: 1; text-align: right; }
|
||||
.quota-disk-pct { font-size: 13px; font-weight: 600; min-width: 48px; text-align: right; }
|
||||
|
||||
/* 实时监控 */
|
||||
.sec-icon { width: 18px; height: 18px; vertical-align: -3px; margin-right: 4px; }
|
||||
.rt-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
|
||||
.rt-card { display: flex; gap: 14px; background: #fff; border: 1px solid #ebeef5; border-radius: 10px; padding: 18px 20px; transition: box-shadow .2s, border-color .2s; }
|
||||
.rt-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.06); border-color: #d9ecff; }
|
||||
.rt-card-icon { flex-shrink: 0; width: 44px; height: 44px; border-radius: 10px; display: flex; align-items: center; justify-content: center; color: #fff; }
|
||||
.cpu-icon { background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%); }
|
||||
.mem-icon { background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%); }
|
||||
.load-icon { background: linear-gradient(135deg, #e6a23c 0%, #f0c78a 100%); }
|
||||
.net-icon { background: linear-gradient(135deg, #909399 0%, #b1b3b8 100%); }
|
||||
.rt-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.rt-label { font-size: 12px; color: #86909c; font-weight: 500; }
|
||||
.rt-value { font-size: 26px; font-weight: 700; line-height: 1.1; }
|
||||
.rt-value small { font-size: 14px; font-weight: 500; margin-left: 1px; }
|
||||
.rt-sub { font-size: 11px; color: #a8abb2; margin-top: 2px; }
|
||||
|
||||
.load-pills { display: flex; gap: 8px; margin: 4px 0 2px; }
|
||||
.load-pill { display: inline-flex; align-items: center; gap: 4px; background: #f7f8fa; border: 1px solid #ebeef5; border-radius: 6px; padding: 4px 10px; font-size: 15px; font-weight: 700; color: var(--lc, #1d2129); }
|
||||
.load-pill em { font-style: normal; font-size: 10px; font-weight: 500; color: #a8abb2; text-transform: uppercase; }
|
||||
|
||||
.net-bw-row { display: flex; gap: 16px; margin: 6px 0 2px; }
|
||||
.net-bw-item { display: inline-flex; align-items: center; gap: 5px; font-size: 16px; font-weight: 700; color: #1d2129; }
|
||||
|
||||
.rt-subtitle { font-size: 13px; font-weight: 600; color: #606266; margin: 20px 0 10px; display: flex; align-items: center; gap: 6px; }
|
||||
.disk-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
|
||||
.disk-item { background: #fff; border: 1px solid #ebeef5; border-radius: 8px; padding: 12px 16px; transition: box-shadow .2s; }
|
||||
.disk-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,.04); }
|
||||
.disk-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.disk-path { font-size: 13px; font-family: 'Consolas','Monaco',monospace; color: #1d2129; background: #f0f2f5; padding: 2px 8px; border-radius: 4px; }
|
||||
.disk-detail { font-size: 12px; color: #86909c; flex: 1; text-align: right; }
|
||||
.disk-pct { font-size: 13px; font-weight: 700; min-width: 44px; text-align: right; }
|
||||
|
||||
/* 硬件信息 */
|
||||
.hw-overview { display: flex; flex-wrap: wrap; gap: 0; background: #f7f8fa; border-radius: 8px; border: 1px solid #ebeef5; margin-bottom: 16px; overflow: hidden; }
|
||||
.hw-ov-item { flex: 1; min-width: 180px; display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-right: 1px solid #ebeef5; }
|
||||
.hw-ov-item:last-child { border-right: none; }
|
||||
.hw-ov-item div { display: flex; flex-direction: column; min-width: 0; }
|
||||
.hw-ov-item em { font-style: normal; font-size: 11px; color: #a8abb2; line-height: 1; margin-bottom: 2px; }
|
||||
.hw-ov-item div { font-size: 13px; color: #1d2129; font-weight: 500; word-break: break-all; }
|
||||
|
||||
.hw-spec-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.hw-spec-card { display: flex; gap: 14px; align-items: flex-start; background: #fff; border: 1px solid #ebeef5; border-radius: 10px; padding: 16px 20px; }
|
||||
.hw-spec-icon { flex-shrink: 0; width: 40px; height: 40px; background: #f0f7ff; border-radius: 8px; display: flex; align-items: center; justify-content: center; }
|
||||
.hw-spec-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.hw-spec-label { font-size: 11px; color: #a8abb2; }
|
||||
.hw-spec-main { font-size: 14px; font-weight: 600; color: #1d2129; word-break: break-word; }
|
||||
.hw-spec-detail { font-size: 12px; color: #86909c; }
|
||||
|
||||
.hw-subtitle { font-size: 13px; font-weight: 600; color: #606266; margin: 16px 0 10px; display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
.hw-disk-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 10px; }
|
||||
.hw-disk-card { display: flex; align-items: center; gap: 14px; background: #fff; border: 1px solid #ebeef5; border-radius: 10px; padding: 14px 18px; transition: box-shadow .2s; }
|
||||
.hw-disk-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,.05); }
|
||||
.hw-disk-icon { flex-shrink: 0; width: 44px; height: 44px; background: #f0f7ff; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
|
||||
.hw-disk-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.hw-disk-name { font-size: 14px; font-weight: 600; color: #1d2129; font-family: 'Consolas','Monaco',monospace; }
|
||||
.hw-disk-model { font-size: 12px; color: #86909c; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.hw-disk-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; }
|
||||
.hw-disk-size { font-size: 15px; font-weight: 700; color: #1d2129; }
|
||||
|
||||
.nic-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 10px; }
|
||||
.nic-card { background: #fff; border: 1px solid #ebeef5; border-radius: 10px; padding: 14px 18px; transition: box-shadow .2s; }
|
||||
.nic-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,.05); }
|
||||
.nic-card.nic-down { opacity: .55; }
|
||||
.nic-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.nic-name { font-size: 14px; font-weight: 600; color: #1d2129; }
|
||||
.nic-tag { margin-left: auto; }
|
||||
.nic-speed { font-size: 12px; color: #86909c; font-weight: 500; }
|
||||
.nic-body { display: flex; flex-direction: column; gap: 4px; }
|
||||
.nic-row { display: flex; align-items: center; gap: 8px; }
|
||||
.nic-k { font-size: 11px; color: #a8abb2; min-width: 32px; font-weight: 500; }
|
||||
.nic-addr-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.nic-addr { display: inline-block; background: #f0f7ff; border: 1px solid #d9ecff; border-radius: 4px; padding: 1px 8px; font-size: 12px; font-family: 'Consolas','Monaco',monospace; color: #409eff; }
|
||||
.nic-addr.v6 { background: #fdf6ec; border-color: #faecd8; color: #e6a23c; font-size: 11px; }
|
||||
.nic-mac { font-size: 12px; color: #606266; }
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user