diff --git a/src/api/admin/noticeChannel.js b/src/api/admin/noticeChannel.js new file mode 100644 index 0000000..c5b62b5 --- /dev/null +++ b/src/api/admin/noticeChannel.js @@ -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 }) +} diff --git a/src/components/admin/AvatarSelector.vue b/src/components/admin/AvatarSelector.vue index 38e7592..71d1be7 100644 --- a/src/components/admin/AvatarSelector.vue +++ b/src/components/admin/AvatarSelector.vue @@ -13,9 +13,15 @@

文件列表

- - 上传新文件 - +
+ + + + + + 上传新文件 + +
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; diff --git a/src/components/admin/ImageSelector.vue b/src/components/admin/ImageSelector.vue index 193c5e2..10585f6 100644 --- a/src/components/admin/ImageSelector.vue +++ b/src/components/admin/ImageSelector.vue @@ -33,6 +33,10 @@ @input="handleSearch" style="width: 300px;" /> + + + +
@@ -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 { diff --git a/src/config/menus.js b/src/config/menus.js index 1c50552..01f110e 100644 --- a/src/config/menus.js +++ b/src/config/menus.js @@ -194,6 +194,10 @@ export const menus = [ path: '/system/setting-manage', title: '配置管理' }, + { + path: '/system/notice-channel', + title: '通知管理' + }, { path: '/system/menu', title: '菜单管理', diff --git a/src/router/index.js b/src/router/index.js index 7dd6a94..09bebdb 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -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', diff --git a/src/views/system/NoticeChannel.vue b/src/views/system/NoticeChannel.vue new file mode 100644 index 0000000..e2e56a3 --- /dev/null +++ b/src/views/system/NoticeChannel.vue @@ -0,0 +1,500 @@ + + + + + diff --git a/src/views/virtualization/HostDetail.vue b/src/views/virtualization/HostDetail.vue index 9ad4e20..67fe599 100644 --- a/src/views/virtualization/HostDetail.vue +++ b/src/views/virtualization/HostDetail.vue @@ -54,6 +54,18 @@ 带宽 ↓{{ detail.rx_bandwidth || 0 }} / ↑{{ detail.tx_bandwidth || 0 }} Mbps
+
+ 负载 + + {{ loadAvg['1min']?.toFixed(2) }} / + {{ loadAvg['5min']?.toFixed(2) }} / + {{ loadAvg['15min']?.toFixed(2) }} + +
+
+ 实时网速 + ↓{{ formatSpeedAuto(hostMetrics.internet_speed.rx_bytes) }} / ↑{{ formatSpeedAuto(hostMetrics.internet_speed.tx_bytes) }} +
创建时间 {{ formatTimestamp(detail.created_at) }} @@ -131,6 +143,187 @@
+ +
+
+

实时监控

+ 刷新 +
+
+ +
+
+ +
+
+ CPU + {{ hostMetrics.cpu.cpu_usage_percent?.toFixed(1) }}% + + {{ hostMetrics.cpu.cpu_count }} 逻辑核心 +
+
+ +
+
+ +
+
+ 内存 + {{ hostMetrics.memory.percent?.toFixed(1) }}% + + {{ formatBytesAuto(hostMetrics.memory.used) }} / {{ formatBytesAuto(hostMetrics.memory.total) }} +
+
+ +
+
+ +
+
+ 系统负载 +
+ + 1m{{ loadAvg['1min']?.toFixed(2) }} + + + 5m{{ loadAvg['5min']?.toFixed(2) }} + + + 15m{{ loadAvg['15min']?.toFixed(2) }} + +
+ 核心数 {{ hostMetrics?.cpu?.cpu_count ?? '-' }},满载阈值 {{ hostMetrics?.cpu?.cpu_count ?? '-' }}.00 +
+
+ +
+
+ +
+
+ 网络带宽 +
+ {{ formatSpeedAuto(hostMetrics.internet_speed.rx_bytes) }} + {{ formatSpeedAuto(hostMetrics.internet_speed.tx_bytes) }} +
+ 累计 ↓{{ formatBytesAuto(hostMetrics.network.rx_bytes) }} ↑{{ formatBytesAuto(hostMetrics.network.tx_bytes) }} +
+
+
+ + + +
+ + +
+

+ + 硬件与系统 + +

+
+ +
+
+ +
运行{{ formatUptime(hardwareInfo.system_info.uptime_seconds) }}
+
+
+ +
主机{{ hardwareInfo.system_info.hostname }}
+
+
+ +
系统{{ hardwareInfo.system_info.distro || hardwareInfo.system_info.os }}
+
+
+ +
内核{{ hardwareInfo.system_info.os }}
+
+
+ +
+
+
+
+ 处理器 + {{ hardwareInfo.cpu_model }} + {{ hardwareInfo.cpu_physical_cores }}C / {{ hardwareInfo.cpu_logical_cores }}T · {{ formatCpuFreq(hardwareInfo.cpu_freq) }} +
+
+
+
+
+ 内存 + {{ formatBytesAuto(hardwareInfo.memory_total) }} + Swap {{ hardwareInfo.swap_total ? formatBytesAuto(hardwareInfo.swap_total) : '无' }} · {{ hardwareInfo.system_info?.arch || '-' }} +
+
+
+ + + + +
+
+

硬盘 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; } +