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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 渠道配置
+
+
+
+
+
+
+
+
+
+
+ {{ item.eventName || '-' }}
+ handleToggle(item, val)" />
+
+
+ {{ item.eventType }}
+ {{ item.note }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.eventName || '-' }}
+ handleToggle(item, val)" />
+
+
+ {{ item.eventType }}
+ {{ item.note }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.eventName || '-' }}
+ {{ item.channel }}
+ handleToggle(item, val)" />
+
+
+ {{ item.eventType }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 模板管理
+
+
+
+
+
+
+
+
+
+
+ {{ row.name }}
+
+
+
+
+ {{ row.tag }}
+
+
+
+
+
+
+
+
+ {{ row.type === 'email' ? '邮件' : '短信' }}
+
+
+
+
+
+
+ {{ row.content || '-' }}
+
+
+
+
+
+ {{ arg }}
+
+ -
+
+
+
+ {{ row.note || '-' }}
+
+
+ {{ formatTime(row.UpdatedAt) }}
+
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 邮件
+
+
+
+
+
+ 短信
+
+
+
+
+
+
+ 点击参数按钮可将其插入到模板内容光标处
+
+
+
+
+
+
+ 取消
+ 确定
+
+
+
+
+
+
+
+
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) }}
+
+
+
+
+
+ 磁盘挂载
+
+
+
+ {{ disk.path }}
+ {{ formatBytesAuto(disk.used) }} / {{ formatBytesAuto(disk.total) }}
+ {{ disk.percent.toFixed(1) }}%
+
+
+
+
+
+
+
+
+
+
+
+
+ 硬件与系统
+
+
+
+
+
+
+
+
运行{{ 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 || '-' }}
+
+
+
+
+
+ 存储设备
+
+
+
+
+ /dev/{{ dev.name }}
+ {{ dev.model || '-' }}
+
+
+ {{ (dev.type || 'HDD').toUpperCase() }}
+ {{ formatBytesAuto(dev.size) }}
+
+
+
+
+
+
+ 网卡
+
+
+
+
+
{{ nic.name }}
+
{{ nic.is_up ? 'UP' : 'DOWN' }}
+
{{ nic.speed_mbps >= 1000 ? (nic.speed_mbps/1000)+'G' : nic.speed_mbps+'M' }}
+
+
+
+
+
+ MAC
+ {{ nic._mac }}
+
+
+
+
+
+
+
+
硬盘 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; }
+