16 Commits

Author SHA1 Message Date
shiran 928d14aada refactor(admin): 重构系统配置管理页面为分组 Tab 布局
Build and Deploy Vue3 / build (push) Successful in 1m33s
Build and Deploy Vue3 / deploy (push) Successful in 32s
优化配置组切换与搜索操作栏,提升配置项浏览与管理效率。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:24:29 +08:00
shiran 1b44186e44 fix(admin): 修正设置主IP确认文案,移除CloudInit重建描述
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 34s
VmDetail 与 UserVmDetail 设置主IP提示与实际行为一致,仅提示将重启虚拟机。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 16:05:07 +08:00
shiran 765f925482 feat(admin): 主机组映射展示全部主控并展开加载主机组
Build and Deploy Vue3 / build (push) Successful in 1m23s
Build and Deploy Vue3 / deploy (push) Successful in 30s
主机组映射页改为卡片列表展示所有主控服务,展开后按需请求主机组;套餐管理增加必填参数未配置提醒。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 18:31:56 +08:00
shiran 9974a82ac8 chore(ci): 构建产物改为dist.tar.gz部署(上传单包+远程解压),测试部署前ssh mkdir,生产ssh-keyscan前置到Set up SSH
Build and Deploy Vue3 / build (push) Successful in 1m33s
Build and Deploy Vue3 / deploy (push) Successful in 29s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 16:18:44 +08:00
shiran 61d777af8e chore(ci): 部署目标合并为单服务器变量WEB_SERVICE_SERVER_IP,部署路径改为/home/www/web-online/admin.007yjs.com/
Build and Deploy Vue3 / build (push) Successful in 1m37s
Build and Deploy Vue3 / deploy (push) Successful in 1m21s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 15:35:41 +08:00
shiran a443e4f147 feat(admin): KSM内存去重管理+监控图表增强+额度统计UI重构+流量管理合并 -- 缘由: 后端新增KSM状态/配置接口,监控数据改为绝对值,额度统计需可视化 -- 预期: HostDetail支持KSM查看/启停/调参,内存图表改为绝对值+磁盘IOPS图+流量趋势图,额度统计改为环形进度卡片,流量策略与统计合并为流量管理tab,订单代金券改为非必填,VmManage显示累计流量
Build and Deploy Vue3 / build (push) Failing after 48s
Build and Deploy Vue3 / deploy (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 16:41:00 +08:00
shiran a5f8a9ef13 feat(admin+user): 虚拟机断网/恢复网络+每小时流量图表+宿主机额度统计 -- 缘由: 后端新增disconnect/connect_network,traffic_hourly,quota_stats接口,VM新增network_disabled字段 -- 预期: VmDetail/UserVmDetail/用户详情支持断网恢复操作并显示断网状态,VmDetail新增流量统计tab,HostDetail新增额度统计tab
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 16:29:18 +08:00
shiran 564e6cc017 feat(admin/vm-network): 网络列表增加is_primary主IP标识+设为主IP+重置MAC地址 -- 缘由: 后端新增network/set_primary和vm/reset_mac接口 -- 预期: VmDetail与UserVmDetail网络列表显示主IP标签,非主IP行有设为主IP按钮,更多菜单增加重置MAC地址
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 10:59:45 +08:00
shiran 98678859cb fix(admin/host): 硬盘IO限制带宽字段改为动态单位选择(B/s~GB/s), 带宽与IOPS分组渲染修复标签溢出 -- 缘由: 带宽字段直接展示bytes原始值且标签过长导致换行错乱 -- 预期: 带宽字段参照内存/磁盘方式通过下拉选择单位(默认MB/s), IOPS独立分组, 标签简化为读取/写入/突发读取/突发写入
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 13:20:32 +08:00
shiran dc63020943 feat(admin/host): 宿主机表单与详情增加硬盘IO限制8字段(可折叠动态展示) -- 缘由: 后端新增 read/write_bytes_sec, read/write_iops_sec 及突发对应字段 -- 预期: HostManage/HostDetail/HostTreeManage 的新增/编辑/令牌表单含可折叠IO参数区, 详情页可展开查看IO限制值
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 13:00:26 +08:00
shiran 59c5d16082 feat(admin/product): 商品表单与列表增加 sold_out 售罄字段 -- 缘由: 后端新增 sold_out 布尔字段, 管理员可手动设置商品售罄状态 -- 预期: 商品创建/编辑表单含售罄开关, 列表显示售罄/在售标签
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 16:44:00 +08:00
shiran ea571563e0 fix(admin): 安全组选择器新增成功未关弹框 + 概览vCPU/带宽合并布局
缘由:
1) UserVmSecurityGroupSelector 中新增安全组成功后,若后端返回码非严格 200
   则 showCreate 不会置 false、列表不会刷新、无成功反馈。
2) /user-goods/vm-detail 概览第一行 vCPU、内存、下行带宽、上行带宽 各占一格,
   需求要求 vCPU+内存 合并为一格、上行+下行带宽 合并为一格并加编辑按钮。

修改:
- UserVmSecurityGroupSelector.vue submitCreate:响应码判断放宽为 200/201/2xx;
  catch 块提取 e.response.data.message 作更精确的错误信息。
- UserVmDetail.vue config-row 第一行:vCPU/内存 合并为「vCPU / 内存」单格;
  带宽合并为「带宽 ↓ / ↑」单格并内嵌修改按钮(handleMoreCmd('updateTraffic'));
  原第二行重复的用户名/远程端口 cell 移至第一行,外网IP/内网IP 独占第二行。

预期:
- 安全组选择器中新增安全组后弹框关闭、列表刷新、显示成功消息。
- 概览第一行信息密度提升,带宽格可一键触发修改带宽弹框。

测试:admin_dashboard_pc 本地 HMR 通过,无编译/lint 报错。
安全组新增接口联调需在有后端环境下验证实际 response code。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 16:27:52 +08:00
shiran e38ea4cc32 feat(admin/vm): 流量概览展示新字段 + 修改入口改挂 traffic_policy 接口
缘由:上次(802eaa3)我把概览"流量上限"的"修改"按钮挂到旧 update_traffic 接口
(兼容字段 traffic_max),与 docs/2026.05.08.12.37-add.json 中真正的流量策略接口
(traffic_policy/update + add_fixed + add_temporary)路径错位;同时 vm 详情返回
已新增 traffic_max_mb / temporary_traffic_mb / temporary_cycle_start / traffic_used /
traffic_exhausted_rx_mbps / traffic_exhausted_tx_mbps 等字段,概览未体现。

修改:
- UserVmDetail.vue & VmDetail.vue 概览将"流量上限"单值 cell 改为分段展示:
  主行 已用/总量;副行 基础 + 临时(含周期起始日期);按钮组「修改」「加临时」。
- 主行/副行字段来源 add.json 新字段,旧 traffic_max 仅作 fallback。
- 「修改」按钮改挂 openTrafficPolicyDialog / openVmTrafficPolicyDialog
  (对应 user_vm/traffic_policy/update 与 host_service/point/vm/traffic_policy/update);
  「加临时」直达 openAddTrafficDialog('temporary') / openVmAddTrafficDialog('temporary')。
- openTrafficPolicyDialog / openVmTrafficPolicyDialog 增加 vm / detail 字段 fallback,
  并在 trafficPolicy 未加载时异步触发 loadTrafficPolicy,避免懒加载导致初值全 0。
- 新增 formatTrafficMb helper(VmDetail.vue)处理 MB 自适应单位、对 0 友好输出。
- 新增 .traffic-cell 系列样式。

预期:
- 详情概览能直接看到 总/已用/基础/临时/周期 五个关键信息。
- 概览"修改"走 add.json 中的新流量策略接口,与"流量策略" tab 行为一致。
- 旧 dropdown 中"修改带宽"路径保留(不删除),用于纯带宽场景。

未测试:admin_dashboard_pc 本地 HMR 已更新,无编译/控制台报错。新流量策略接口与
真实 vm.value 字段填充尚需联调验证(特别是 traffic_used 单位假设为 MB,若实际为
字节需调整 formatTraffic / detailTrafficUsedMb 的换算)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 11:10:17 +08:00
shiran 0dcce0822d fix(admin/user-vm): 流量上限修改按钮回调名修正为 handleMoreCmd
缘由:上一次提交(802eaa3)在 UserVmDetail.vue 流量上限单元格内追加的"修改"按钮误用了不存在的 handleCommand,点击时报 _ctx.handleCommand is not a function。该文件中触发 updateTraffic 的实际函数名为 handleMoreCmd(行 1329),dropdown 的 @command 也是绑到该函数。

修改:仅将 132 行附近的 @click="handleCommand('updateTraffic')" 改为 @click="handleMoreCmd('updateTraffic')"。

预期:流量上限单元格的"修改"按钮可正常触发 trafficVisible 弹窗,与 dropdown "修改带宽"行为一致。

测试:admin_dashboard_pc 本地 dev 已 HMR 更新,未见编译/控制台报错。VmDetail.vue 同名样式与按钮独立、不受影响。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 17:12:44 +08:00
shiran 802eaa396b feat(admin/user-vm): 流量上限展示加修改入口、网络tab加删除网络操作
缘由:
1) 虚拟机详情页(UserVmDetail.vue / VmDetail.vue)中"流量上限"原仅展示无修改入口,对应 docs/2026.05.08.12.37-update.json 中 update_traffic 接口已支持 traffic_max + traffic_exhausted_rx/tx_mbps 修改,但用户需从"更多 dropdown"绕一道才能到达。
2) /user-goods/vm-detail 网络管理 tab 缺少"删除网络"操作。

修改:
- UserVmDetail.vue:流量上限单元格内追加"修改"小按钮,复用既有 updateTraffic 弹窗(已覆盖 update_traffic 全部新字段,不动接口逻辑);网络表格新增"操作"列+删除按钮,调用 host_service/point/network/delete;row 上若缺 service_id/host_id 用 getUserVmNetworkDetail 反查兜底,仍取不到则提示并阻止;二次确认弹窗明示该操作会影响所有绑定该网络的虚拟机。
- VmDetail.vue:流量上限单元格内追加"修改"小按钮,复用 handleUpdateTraffic(host_service/point/vm/update_traffic)。

预期:
- 详情页用户在"流量上限"位置可一键进入修改弹窗,无需走 dropdown。
- vm-detail 网络tab 表格每行可触发"删除网络"流程,含强提示与兜底取值。
- 不引入新依赖;trafficVisible 弹窗保持向 docs 字段对齐;UI 微调仅限新增样式 .cfg-edit-btn 与一列操作列。

未测试:未在 admin_dashboard_pc 本地 dev 验证(终端仅运行 user_dashboard_pc),需联调 update_traffic 与 point/network/delete 实际返回。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 17:09:23 +08:00
shiran 3d783cd224 fix(order): 新增/编辑订单提交时 price/renew_price 由"分"换算为"元"
缘由:
后端 /api/v1/admin/order/create 与 /api/v1/admin/order/update 入参单位由"分"改为
"元"(用户 2026-05-12 确认两端都已生效)。前端列表与表单内部仍按"分"持有,提交时未
做换算,导致表单里的 9900(分)被后端当作 9900 元入库 = 990000 分,下次列表读取后
表现为"价格额外 *100"。

改动:
- src/views/order/OrderList.vue:submitForm 构造 submitData 时把 price 与 renew_price
  统一 /100,create 与 update 路径共享。输入框旁的"分"单位文案暂不改(用户明确仅
  要求 /100),UI 一致性问题(列表显示元、弹窗输入分)作为另一个 issue 留待后续。

预期:
- 编辑订单不改价直接保存:原 9900 分 → 提交 99 元 → 入库 9900 分(一致,修复前会
  入成 990000 分)。
- 编辑订单改价:用户在"分"单位输入框填 12000 → 提交 120 元 → 入库 12000 分。
- 新增订单:同上链路一致。

测试:未脚本化。建议人工核对一次"编辑→不改→保存→列表金额无变化"作为最低验收。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 16:29:34 +08:00
16 changed files with 3179 additions and 2721 deletions
+10 -7
View File
@@ -28,12 +28,15 @@ jobs:
run: | run: |
pnpm build pnpm build
- name: Compress artifacts
run: |
tar -czf dist.tar.gz -C ./dist .
- name: Save artifact - name: Save artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: vue3-build name: vue3-build
path: | path: dist.tar.gz
./dist
deploy: deploy:
needs: build needs: build
@@ -49,11 +52,11 @@ jobs:
mkdir -p ~/.ssh mkdir -p ~/.ssh
echo "${{ secrets.PUBLICT_PRIVATE_KEY }}" > ~/.ssh/id_rsa echo "${{ secrets.PUBLICT_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ vars.WEB_SERVICE_SERVER_IP }} >> ~/.ssh/known_hosts
- name: Deploy to server - name: Deploy to server
run: | run: |
ssh-keyscan -H ${{ vars.WEB_SERVICE_SERVER_IP_1 }} >> ~/.ssh/known_hosts DEPLOY_DIR="/home/www/web-online/admin.007yjs.com/"
scp -o StrictHostKeyChecking=no -r ./* ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP_1 }}:/home/www/admin.007yjs.com/ ssh ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP }} "mkdir -p $DEPLOY_DIR"
ssh-keyscan -H ${{ vars.WEB_SERVICE_SERVER_IP_2 }} >> ~/.ssh/known_hosts scp -o StrictHostKeyChecking=no dist.tar.gz ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP }}:$DEPLOY_DIR
scp -o StrictHostKeyChecking=no -r ./* ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP_2 }}:/home/www/admin.007yjs.com/ ssh ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP }} "cd $DEPLOY_DIR && tar -xzf dist.tar.gz && rm -f dist.tar.gz"
+9 -4
View File
@@ -24,12 +24,15 @@ jobs:
run: | run: |
pnpm build pnpm build
- name: Compress artifacts
run: |
tar -czf dist.tar.gz -C ./dist .
- name: Save artifact - name: Save artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: vue3-build name: vue3-build
path: | path: dist.tar.gz
./dist
deploy: deploy:
needs: build needs: build
@@ -49,5 +52,7 @@ jobs:
- name: Deploy to server - name: Deploy to server
run: | run: |
scp -o StrictHostKeyChecking=no -r ./* ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_TEST_SERVER_IP }}:/www/wwwroot/apiserver_admin.s1f.ren/ DEPLOY_DIR="/www/wwwroot/apiserver_admin.s1f.ren/"
ssh ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_TEST_SERVER_IP }} "mkdir -p $DEPLOY_DIR"
scp -o StrictHostKeyChecking=no dist.tar.gz ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_TEST_SERVER_IP }}:$DEPLOY_DIR
ssh ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_TEST_SERVER_IP }} "cd $DEPLOY_DIR && tar -xzf dist.tar.gz && rm -f dist.tar.gz"
+50
View File
@@ -284,6 +284,56 @@ export const deleteNetwork = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/network/delete', { params }) return http2.delete('/api/v1/admin/server/host_service/point/network/delete', { params })
} }
/** 设置主IP */
export const setNetworkPrimary = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/network/set_primary', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 重置虚拟机MAC地址 */
export const resetVmMac = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/reset_mac', data, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
/** 断开虚拟机外部网络 */
export const disconnectVmNetwork = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/disconnect_network', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 恢复虚拟机外部网络 */
export const connectVmNetwork = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/connect_network', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 查询虚拟机每小时流量 */
export const getVmTrafficHourly = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/traffic_hourly', { params })
}
/** 获取宿主机额度统计 */
export const getHostQuotaStats = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host/quota_stats', { params })
}
/** 获取宿主机 KSM 状态 */
export const getHostKsmStatus = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host/ksm/status', { params })
}
/** 配置宿主机 KSM */
export const configureHostKsm = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/host/ksm/configure', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** /**
* ================================ * ================================
* 主控服务接口 - 数据卷管理 * 主控服务接口 - 数据卷管理
+5
View File
@@ -88,6 +88,10 @@ export const deleteUserVmPostGroupRule = (params) => http2.delete(`${BASE}/post_
// ========== 网络 ========== // ========== 网络 ==========
export const getUserVmNetworkList = (params) => http2.get(`${BASE}/network/list`, { params }) export const getUserVmNetworkList = (params) => http2.get(`${BASE}/network/list`, { params })
export const getUserVmNetworkDetail = (params) => http2.get(`${BASE}/network/detail`, { params }) export const getUserVmNetworkDetail = (params) => http2.get(`${BASE}/network/detail`, { params })
export const setUserVmNetworkPrimary = (data) => http2.post(`${BASE}/network/set_primary`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const resetUserVmMac = (params) => http2.post(`${BASE}/reset_mac`, null, { params })
export const disconnectUserVmNetwork = (data) => http2.post(`${BASE}/disconnect_network`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const connectUserVmNetwork = (data) => http2.post(`${BASE}/connect_network`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
// ========== 组网 ========== // ========== 组网 ==========
export const getUserVmNetworkingList = (params) => http2.get(`${BASE}/networking/list`, { params }) export const getUserVmNetworkingList = (params) => http2.get(`${BASE}/networking/list`, { params })
@@ -105,6 +109,7 @@ export const updateUserGoods = (data) => http2.post(`${GOODS_BASE}/update`, fd(d
export const deleteUserGoods = (params) => http2.delete(`${GOODS_BASE}/delete`, { params }) export const deleteUserGoods = (params) => http2.delete(`${GOODS_BASE}/delete`, { params })
export const getUserVmMetricsHistory = (params) => http2.get(`${BASE}/metrics_history`, { params }) export const getUserVmMetricsHistory = (params) => http2.get(`${BASE}/metrics_history`, { params })
export const getUserVmTrafficHourly = (params) => http2.get(`${BASE}/traffic_hourly`, { params })
// ========== 流量策略 ========== // ========== 流量策略 ==========
// 测试未通过(接口新增,待联调) // 测试未通过(接口新增,待联调)
@@ -124,13 +124,19 @@ const submitCreate = async () => {
lock: createForm.lock, lock: createForm.lock,
drop_all: createForm.drop_all drop_all: createForm.drop_all
}) })
if (res?.data?.code === 200) { const code = res?.data?.code
if (code === 200 || code === 201 || (code >= 200 && code < 300)) {
ElMessage.success('创建成功') ElMessage.success('创建成功')
showCreate.value = false showCreate.value = false
Object.assign(createForm, { name: '', direction: 'in', lock: false, drop_all: false }) Object.assign(createForm, { name: '', direction: 'in', lock: false, drop_all: false })
loadList() await loadList()
} else ElMessage.error(res?.data?.message || '创建失败') } else {
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false } ElMessage.error(res?.data?.message || res?.data?.error || '创建失败')
}
} catch (e) {
const msg = e?.response?.data?.message || e?.response?.data?.error || e?.message || '创建失败'
ElMessage.error(msg)
} finally { createLoading.value = false }
} }
const handleClose = () => { visible.value = false } const handleClose = () => { visible.value = false }
+7 -4
View File
@@ -474,7 +474,7 @@ const orderRules = {
{ type: 'number', message: '用户ID必须是数字', trigger: 'blur' } { type: 'number', message: '用户ID必须是数字', trigger: 'blur' }
], ],
coupon_id: [ coupon_id: [
{ required: true, message: '请输入代金券ID', trigger: 'blur' }, { message: '请输入代金券ID', trigger: 'blur' },
{ type: 'number', message: '代金券ID必须是数字', trigger: 'blur' } { type: 'number', message: '代金券ID必须是数字', trigger: 'blur' }
], ],
pay_num: [ pay_num: [
@@ -756,15 +756,18 @@ const submitForm = () => {
expireTimeSeconds = timestamp || 0 expireTimeSeconds = timestamp || 0
} }
// 准备提交的数据 // 2026-05-12: /api/v1/admin/order/create 与 /update 入参 price / renew_price 单位由
// "分"改为"元"(后端接口变更,用户确认两端都已生效);列表与表单内部仍按"分"持有,
// 提交时统一除以 100 做"分→元"换算,避免再次入库时被当成元导致额外放大 100 倍。
// 输入框旁的"分"单位文案暂不改动(用户明确仅要求 /100),UI 一致性问题待后续单独处理。
const submitData = { const submitData = {
name: orderForm.name, name: orderForm.name,
table: orderForm.table, table: orderForm.table,
user_id: Number(orderForm.user_id), user_id: Number(orderForm.user_id),
commodity_id: Number(orderForm.commodity_id), commodity_id: Number(orderForm.commodity_id),
pay_num: Number(orderForm.pay_num), pay_num: Number(orderForm.pay_num),
price: Number(orderForm.price), price: Number(orderForm.price) / 100,
renew_price: Number(orderForm.renew_price), renew_price: Number(orderForm.renew_price) / 100,
expire_time: expireTimeSeconds, expire_time: expireTimeSeconds,
discount_code_id: Number(orderForm.discount_code_id), discount_code_id: Number(orderForm.discount_code_id),
coupon_id: Number(orderForm.coupon_id), coupon_id: Number(orderForm.coupon_id),
+15 -2
View File
@@ -106,6 +106,13 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="售罄" width="80">
<template #default="{ row }">
<el-tag :type="row.soldOut ? 'danger' : 'success'" size="small">
{{ row.soldOut ? '售罄' : '在售' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right"> <el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button> <el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
@@ -234,6 +241,10 @@
<el-form-item label="库存数量" prop="inventory"> <el-form-item label="库存数量" prop="inventory">
<el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" /> <el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="售罄状态" prop="sold_out">
<el-switch v-model="productForm.sold_out" active-text="已售罄" inactive-text="正常" active-color="#f56c6c" />
<div class="form-tip">开启后用户端无法选购该商品KVM 商品可由后台定时任务根据 IP 资源自动标记</div>
</el-form-item>
<el-form-item label="商品价格" prop="price"> <el-form-item label="商品价格" prop="price">
<div class="unit-input-row"> <div class="unit-input-row">
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="flex:1" /> <el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="flex:1" />
@@ -957,7 +968,8 @@ const productForm = reactive({
recommend: false, recommend: false,
recommend_rebate: 0, recommend_rebate: 0,
arg_type: 'all', // 商品参数类型 all/plan/customize arg_type: 'all', // 商品参数类型 all/plan/customize
attribution_id: '' // 归属项ID attribution_id: '', // 归属项ID
sold_out: false
}) })
const productRules = { const productRules = {
@@ -1261,7 +1273,8 @@ const handleEdit = (row) => {
expire_time: row.expireTime, expire_time: row.expireTime,
recommend: row.recommend, recommend: row.recommend,
recommend_rebate: row.recommendRebate, recommend_rebate: row.recommendRebate,
arg_type: row.argType || 'all' arg_type: row.argType || 'all',
sold_out: !!row.soldOut
}) })
coverPreviewUrl.value = row.cover || '' coverPreviewUrl.value = row.cover || ''
} }
@@ -162,6 +162,17 @@
<el-form-item label="说明" prop="note"> <el-form-item label="说明" prop="note">
<el-input v-model="planForm.note" type="textarea" :rows="2" placeholder="请输入套餐说明" /> <el-input v-model="planForm.note" type="textarea" :rows="2" placeholder="请输入套餐说明" />
</el-form-item> </el-form-item>
<div v-if="missingMustSpecs.length > 0" class="must-params-alert">
<div class="must-params-alert-header">
<el-icon><WarningFilled /></el-icon>
<span>以下必填参数尚未配置请将其加入参数配置额外参数</span>
</div>
<div class="must-params-alert-tags">
<el-tag v-for="spec in missingMustSpecs" :key="spec.id" type="danger" size="small" effect="plain">
<span class="must-star">*</span>{{ spec.name }}
</el-tag>
</div>
</div>
<el-form-item label="参数配置" prop="args"> <el-form-item label="参数配置" prop="args">
<div class="args-config-container"> <div class="args-config-container">
<div class="args-select-row"> <div class="args-select-row">
@@ -385,6 +396,10 @@ const selectedExtraArgIds = ref([])
const selectedArgSpecs = computed(() => planSpecList.value.filter(spec => selectedArgIds.value.includes(spec.id))) const selectedArgSpecs = computed(() => planSpecList.value.filter(spec => selectedArgIds.value.includes(spec.id)))
const extraSpecList = computed(() => planSpecList.value.filter(spec => !selectedArgIds.value.includes(spec.id))) const extraSpecList = computed(() => planSpecList.value.filter(spec => !selectedArgIds.value.includes(spec.id)))
const missingMustSpecs = computed(() => {
const allSelectedIds = [...selectedArgIds.value, ...selectedExtraArgIds.value]
return planSpecList.value.filter(s => s.must && !allSelectedIds.includes(s.id))
})
const getSpecDisplayMin = (spec) => { const getSpecDisplayMin = (spec) => {
if (!hasUnit(spec)) return spec.min ?? 0 if (!hasUnit(spec)) return spec.min ?? 0
@@ -1184,6 +1199,26 @@ watch(() => props.visible, (val) => {
.plan-card-actions .el-button + .el-button { .plan-card-actions .el-button + .el-button {
margin-left: 0; margin-left: 0;
} }
.must-params-alert {
margin: 0 0 16px;
padding: 10px 14px;
background: linear-gradient(135deg, #fff5f5 0%, #fff1f0 100%);
border: 1px solid #fcdcdc;
border-radius: 8px;
border-left: 3px solid #f56c6c;
}
.must-params-alert-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #f56c6c;
font-weight: 500;
margin-bottom: 8px;
}
.must-params-alert-header .el-icon { font-size: 16px; flex-shrink: 0; }
.must-params-alert-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.must-params-alert-tags .must-star { color: #f56c6c; font-weight: 700; margin-right: 2px; }
.plan-form-content { max-height: 60vh; overflow-y: auto; padding-right: 8px; margin-right: -8px; } .plan-form-content { max-height: 60vh; overflow-y: auto; padding-right: 8px; margin-right: -8px; }
.plan-form-content::-webkit-scrollbar { width: 6px; } .plan-form-content::-webkit-scrollbar { width: 6px; }
.plan-form-content::-webkit-scrollbar-track { background: transparent; } .plan-form-content::-webkit-scrollbar-track { background: transparent; }
File diff suppressed because it is too large Load Diff
+334 -21
View File
@@ -19,6 +19,7 @@
<div class="name-row"> <div class="name-row">
<h2 class="vm-name">{{ vm?.name || userGoods.good?.name || `用户虚拟机 #${userGoodsId}` }}</h2> <h2 class="vm-name">{{ vm?.name || userGoods.good?.name || `用户虚拟机 #${userGoodsId}` }}</h2>
<el-tag v-if="vm?.status" :type="vmStatusType(vm.status)" size="small" style="margin-left:8px">{{ vmStatusLabel(vm.status) }}</el-tag> <el-tag v-if="vm?.status" :type="vmStatusType(vm.status)" size="small" style="margin-left:8px">{{ vmStatusLabel(vm.status) }}</el-tag>
<el-tag v-if="vm?.network_disabled" size="small" type="danger" effect="dark" style="margin-left:4px">已断网</el-tag>
<el-tag v-if="vm?.rescue" size="small" type="danger" effect="dark" style="margin-left:4px">救援模式</el-tag> <el-tag v-if="vm?.rescue" size="small" type="danger" effect="dark" style="margin-left:4px">救援模式</el-tag>
<el-tag v-if="userGoods.tag" size="small" type="info" style="margin-left:4px">{{ userGoods.tag }}</el-tag> <el-tag v-if="userGoods.tag" size="small" type="info" style="margin-left:4px">{{ userGoods.tag }}</el-tag>
</div> </div>
@@ -46,6 +47,9 @@
<el-dropdown-item command="resume">恢复</el-dropdown-item> <el-dropdown-item command="resume">恢复</el-dropdown-item>
<el-dropdown-item command="rescue" :disabled="!!vm?.rescue">救援模式</el-dropdown-item> <el-dropdown-item command="rescue" :disabled="!!vm?.rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exitRescue" :disabled="!vm?.rescue">退出救援</el-dropdown-item> <el-dropdown-item command="exitRescue" :disabled="!vm?.rescue">退出救援</el-dropdown-item>
<el-dropdown-item command="resetMac">重置MAC地址</el-dropdown-item>
<el-dropdown-item command="disconnectNetwork" :disabled="vm?.network_disabled">断网</el-dropdown-item>
<el-dropdown-item command="connectNetwork" :disabled="!vm?.network_disabled">恢复网络</el-dropdown-item>
<el-dropdown-item divided command="rebuild">重装系统</el-dropdown-item> <el-dropdown-item divided command="rebuild">重装系统</el-dropdown-item>
<el-dropdown-item command="updateVm">编辑虚拟机</el-dropdown-item> <el-dropdown-item command="updateVm">编辑虚拟机</el-dropdown-item>
<el-dropdown-item command="refactorVm">重构虚拟机</el-dropdown-item> <el-dropdown-item command="refactorVm">重构虚拟机</el-dropdown-item>
@@ -63,14 +67,20 @@
<!-- VM 配置信息 --> <!-- VM 配置信息 -->
<div class="vm-config-grid" v-if="vm"> <div class="vm-config-grid" v-if="vm">
<div class="config-row"> <div class="config-row">
<div class="config-cell"><span class="config-label">vCPU</span><span class="config-value">{{ vm.vcpu || '-' }} </span></div> <div class="config-cell"><span class="config-label">vCPU / 内存</span><span class="config-value">{{ vm.vcpu || '-' }} / {{ formatMemory(vm.memory) }}</span></div>
<div class="config-cell"><span class="config-label">内存</span><span class="config-value">{{ formatMemory(vm.memory) }}</span></div> <div class="config-cell">
<div class="config-cell"><span class="config-label">下行带宽</span><span class="config-value">{{ vm.rx_bandwidth || 0 }} Mbps</span></div> <span class="config-label">带宽 / </span>
<div class="config-cell"><span class="config-label">上行带宽</span><span class="config-value">{{ vm.tx_bandwidth || 0 }} Mbps</span></div> <span class="config-value">
{{ vm.rx_bandwidth || 0 }} / {{ vm.tx_bandwidth || 0 }} Mbps
<el-button link type="primary" size="small" class="cfg-edit-btn" @click="handleMoreCmd('updateTraffic')">
<el-icon :size="14"><Edit /></el-icon>修改
</el-button>
</span>
</div> </div>
<div class="config-row">
<div class="config-cell"><span class="config-label">用户名</span><span class="config-value" style="font-weight:500">{{ isWindows ? 'Administrator' : 'root' }}</span></div> <div class="config-cell"><span class="config-label">用户名</span><span class="config-value" style="font-weight:500">{{ isWindows ? 'Administrator' : 'root' }}</span></div>
<div class="config-cell"><span class="config-label">远程端口</span><span class="config-value">{{ isWindows ? (vm.ssh_port && vm.ssh_port !== 22 ? vm.ssh_port : 3389) : (vm.ssh_port || 22) }}</span></div> <div class="config-cell"><span class="config-label">远程端口</span><span class="config-value">{{ isWindows ? (vm.ssh_port && vm.ssh_port !== 22 ? vm.ssh_port : 3389) : (vm.ssh_port || 22) }}</span></div>
</div>
<div class="config-row">
<div class="config-cell"> <div class="config-cell">
<span class="config-label">外网IP</span> <span class="config-label">外网IP</span>
<span class="config-value ip-value" v-if="vmPublicIpList.length"> <span class="config-value ip-value" v-if="vmPublicIpList.length">
@@ -129,7 +139,25 @@
</el-button> </el-button>
</span> </span>
</div> </div>
<div class="config-cell"><span class="config-label">流量上限</span><span class="config-value">{{ formatTraffic(vm.traffic_max) }}</span></div> <div class="config-cell">
<span class="config-label">流量</span>
<span class="config-value traffic-cell">
<span class="traffic-main">{{ formatTraffic(trafficUsedMb) }} / {{ formatTraffic(trafficTotalMb) }}</span>
<span class="traffic-sub">
基础 {{ formatTraffic(trafficBaseMb) }}
<template v-if="trafficTempMb > 0">
· 临时 {{ formatTraffic(trafficTempMb) }}
<span v-if="trafficCycleStartText" class="traffic-cycle">周期 {{ trafficCycleStartText }}</span>
</template>
</span>
<span class="traffic-actions">
<el-button link type="primary" size="small" class="cfg-edit-btn" @click="openTrafficPolicyDialog">
<el-icon :size="14"><Edit /></el-icon>修改
</el-button>
<el-button link type="warning" size="small" class="cfg-edit-btn" @click="openAddTrafficDialog('temporary')">加临时</el-button>
</span>
</span>
</div>
<div class="config-cell"><span class="config-label">续费价格</span><span class="config-value">¥{{ (userGoods.renewPrice / 100).toFixed(2) }}</span></div> <div class="config-cell"><span class="config-label">续费价格</span><span class="config-value">¥{{ (userGoods.renewPrice / 100).toFixed(2) }}</span></div>
<div class="config-cell"><span class="config-label">基础价格</span><span class="config-value">¥{{ (userGoods.basePrice / 100).toFixed(2) }}</span></div> <div class="config-cell"><span class="config-label">基础价格</span><span class="config-value">¥{{ (userGoods.basePrice / 100).toFixed(2) }}</span></div>
</div> </div>
@@ -305,7 +333,20 @@
<el-table-column prop="address" label="地址(CIDR)" min-width="150" /> <el-table-column prop="address" label="地址(CIDR)" min-width="150" />
<el-table-column prop="gateway" label="网关" min-width="120" /> <el-table-column prop="gateway" label="网关" min-width="120" />
<el-table-column prop="mac_address" label="MAC" min-width="150" show-overflow-tooltip /> <el-table-column prop="mac_address" label="MAC" min-width="150" show-overflow-tooltip />
<el-table-column label="主IP" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_primary" type="success" size="small" effect="dark"></el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="80"><template #default="{ row }"><el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.type === 'bridge' ? '网桥' : 'NAT' }}</el-tag></template></el-table-column> <el-table-column label="类型" width="80"><template #default="{ row }"><el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.type === 'bridge' ? '网桥' : 'NAT' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button v-if="!row.is_primary" link type="warning" size="small" @click="handleSetVmNetworkPrimary(row)">设为主IP</el-button>
<el-button link type="danger" size="small" :loading="deletingNetworkId === row.id" @click="handleDeleteVmNetwork(row)">
<el-icon :size="14"><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table> </el-table>
<el-empty v-if="!vmNetworks.length" :image-size="60" description="暂无网络" /> <el-empty v-if="!vmNetworks.length" :image-size="60" description="暂无网络" />
</el-tab-pane> </el-tab-pane>
@@ -398,6 +439,11 @@
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div> <div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div>
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div> <div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div>
</div> </div>
<div class="metric-summary-card">
<div class="metric-summary-label">累计流量</div>
<div class="metric-summary-value">{{ latestMetrics.traffic_used_mb != null ? latestMetrics.traffic_used_mb + ' MB' : '-' }}</div>
<div class="metric-summary-sub">&nbsp;</div>
</div>
</div> </div>
</template> </template>
<template v-if="metricsData"> <template v-if="metricsData">
@@ -410,7 +456,7 @@
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-card shadow="hover" class="metrics-card"> <el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 内存使用</span></template> <template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 内存使用</span></template>
<div ref="memChartRef" class="chart-container"></div> <div ref="memChartRef" class="chart-container"></div>
</el-card> </el-card>
</el-col> </el-col>
@@ -429,13 +475,27 @@
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘 IOPS</span></template>
<div ref="diskIopsChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 流量使用趋势</span></template>
<div ref="trafficUsedChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
</template> </template>
<el-empty v-else-if="!metricsLoading" description="暂无监控数据" :image-size="80" /> <el-empty v-else-if="!metricsLoading" description="暂无监控数据" :image-size="80" />
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- 流量策略 --> <!-- 流量管理(合并流量策略 + 流量统计) -->
<el-tab-pane v-if="isVmGoods" label="流量策略" name="trafficPolicy"> <el-tab-pane v-if="isVmGoods" label="流量管理" name="trafficManage">
<div class="section-block"> <div class="section-block">
<div class="section-header"> <div class="section-header">
<h3 class="section-title">流量策略</h3> <h3 class="section-title">流量策略</h3>
@@ -453,6 +513,30 @@
</el-descriptions> </el-descriptions>
<el-empty v-else-if="!trafficPolicyLoading" description="暂无流量策略数据" :image-size="60" /> <el-empty v-else-if="!trafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
</div> </div>
<div class="section-block" style="margin-top:16px">
<div class="section-header">
<h3 class="section-title">每小时流量</h3>
<div style="display: flex; align-items: center; gap: 8px">
<el-date-picker
v-model="trafficHourlyRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
size="small"
style="width: 360px"
:shortcuts="monitorShortcuts"
@change="loadTrafficHourly"
/>
<el-button size="small" :icon="Refresh" @click="loadTrafficHourly" :loading="trafficHourlyLoading">刷新</el-button>
</div>
</div>
<el-card v-if="trafficHourlyData.length" shadow="hover" class="metrics-card" style="margin-top:12px">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 每小时流量(MB</span></template>
<div ref="trafficHourlyChartRef" class="chart-container"></div>
</el-card>
<el-empty v-else-if="!trafficHourlyLoading" description="暂无流量统计数据" :image-size="60" />
</div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@@ -1072,7 +1156,7 @@
import { ref, reactive, computed, onMounted, onBeforeUnmount, onActivated, nextTick } from 'vue' import { ref, reactive, computed, onMounted, onBeforeUnmount, onActivated, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument } from '@element-plus/icons-vue' import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument, Edit, Delete } from '@element-plus/icons-vue'
import { import {
getUserVmDetail, getUserVmVnc, getUserVmHostImages, getUserVmDetail, getUserVmVnc, getUserVmHostImages,
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm, startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
@@ -1083,11 +1167,14 @@ import {
getUserVmPostGroupList, createUserVmPostGroup, updateUserVmPostGroup, bindUserVmPostGroup, unbindUserVmPostGroup, applyUserVmPostGroup, deleteUserVmPostGroup, enableUserVmPostGroupWhitelist, disableUserVmPostGroupWhitelist, getUserVmPostGroupList, createUserVmPostGroup, updateUserVmPostGroup, bindUserVmPostGroup, unbindUserVmPostGroup, applyUserVmPostGroup, deleteUserVmPostGroup, enableUserVmPostGroupWhitelist, disableUserVmPostGroupWhitelist,
createUserVmPostGroupRule, updateUserVmPostGroupRule, deleteUserVmPostGroupRule, createUserVmPostGroupRule, updateUserVmPostGroupRule, deleteUserVmPostGroupRule,
getUserVmPostGroupDetail, getUserVmPostGroupDetail,
getUserVmNetworkList, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, removeUserVmNetworkingNetwork, deleteUserVmNetworking, getUserVmNetworkList, getUserVmNetworkDetail, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, removeUserVmNetworkingNetwork, deleteUserVmNetworking,
getUserGoodsDetail, getUserGoodsDetail,
getUserVmMetricsHistory, getUserVmMetricsHistory, getUserVmTrafficHourly,
getUserVmTrafficPolicy, updateUserVmTrafficPolicy, addUserVmFixedTraffic, addUserVmTemporaryTraffic getUserVmTrafficPolicy, updateUserVmTrafficPolicy, addUserVmFixedTraffic, addUserVmTemporaryTraffic,
setUserVmNetworkPrimary, resetUserVmMac,
disconnectUserVmNetwork, connectUserVmNetwork
} from '@/api/admin/userVm' } from '@/api/admin/userVm'
import { deleteNetwork as deletePointNetwork } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import { vmStatusLabel as vmStatusLabelUtil, vmStatusType as vmStatusTypeUtil, volumeStatusLabel, volumeStatusType } from '@/utils/tool' import { vmStatusLabel as vmStatusLabelUtil, vmStatusType as vmStatusTypeUtil, volumeStatusLabel, volumeStatusType } from '@/utils/tool'
import UserSelector from '@/components/UserSelector/index.vue' import UserSelector from '@/components/UserSelector/index.vue'
@@ -1164,6 +1251,18 @@ const copyAllIps = async (ipList) => {
const isWindows = computed(() => vmImage.value?.os_type === 'windows') const isWindows = computed(() => vmImage.value?.os_type === 'windows')
// 流量字段优先使用 traffic_max_mbadd.json 新字段),fallback 旧字段 traffic_max
// traffic_used 单位假设为 MB(与 traffic_max_mb 同维度),如后端实为字节需调整
const trafficBaseMb = computed(() => Number(vm.value?.traffic_max_mb ?? vm.value?.traffic_max ?? 0) || 0)
const trafficTempMb = computed(() => Number(vm.value?.temporary_traffic_mb || 0))
const trafficTotalMb = computed(() => trafficBaseMb.value + trafficTempMb.value)
const trafficUsedMb = computed(() => Number(vm.value?.traffic_used || 0))
const trafficCycleStartText = computed(() => {
const t = vm.value?.temporary_cycle_start
const sec = typeof t === 'object' ? (t?.seconds ?? null) : t
return sec ? dayjs(Number(sec) * 1000).format('YYYY-MM-DD') : ''
})
const vmPublicIpList = computed(() => { const vmPublicIpList = computed(() => {
return vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.address ? n.address.split('/')[0] : n.name).filter(Boolean) return vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.address ? n.address.split('/')[0] : n.name).filter(Boolean)
}) })
@@ -1262,7 +1361,7 @@ const handleTabChange = (tab) => {
if (tab === 'security') loadSgLockInfo() if (tab === 'security') loadSgLockInfo()
if (tab === 'networking') loadNetworkings() if (tab === 'networking') loadNetworkings()
if (tab === 'monitor' && !metricsData.value) loadMetricsHistory() if (tab === 'monitor' && !metricsData.value) loadMetricsHistory()
if (tab === 'trafficPolicy') loadTrafficPolicy() if (tab === 'trafficManage') { loadTrafficPolicy(); loadTrafficHourly() }
} }
// 请求安全组详情补充 lock 字段(使用用户虚拟机安全组详情接口) // 请求安全组详情补充 lock 字段(使用用户虚拟机安全组详情接口)
@@ -1319,6 +1418,9 @@ const handleMoreCmd = (cmd) => {
if (cmd === 'refactorVm') openRefactorVm() if (cmd === 'refactorVm') openRefactorVm()
if (cmd === 'migrate') openMigrateVm() if (cmd === 'migrate') openMigrateVm()
if (cmd === 'editGoods') openEditGoods() if (cmd === 'editGoods') openEditGoods()
if (cmd === 'resetMac') handleResetVmMac()
if (cmd === 'disconnectNetwork') handleDisconnectVmNetwork()
if (cmd === 'connectNetwork') handleConnectVmNetwork()
if (cmd === 'delete') { if (cmd === 'delete') {
ElMessageBox.confirm('确定删除该用户虚拟机吗?', '删除确认', { type: 'error' }).then(async () => { ElMessageBox.confirm('确定删除该用户虚拟机吗?', '删除确认', { type: 'error' }).then(async () => {
try { try {
@@ -1644,6 +1746,101 @@ const loadNetworks = async () => {
} catch { /* */ } finally { networkLoading.value = false } } catch { /* */ } finally { networkLoading.value = false }
} }
// ---- 删除网络 ----
// 走 host_service/point/network/delete:删除底层物理网络(破坏性,会影响其他绑定该网络的 VM)
// row 字段可能不完整,service_id / host_id 通过 getUserVmNetworkDetail 兜底反查
const deletingNetworkId = ref(0)
const resolveNetworkServiceHost = async (row) => {
let serviceId = row.service_id ?? row.host_service_id ?? row.kvm_service_id ?? 0
let hostId = row.host_id ?? 0
if (!serviceId || !hostId) {
try {
const res = await getUserVmNetworkDetail({ user_goods_id: userGoodsId.value, id: row.id })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
const n = d.data || d.network || d
if (!serviceId) serviceId = n?.service_id ?? n?.host_service_id ?? n?.kvm_service_id ?? 0
if (!hostId) hostId = n?.host_id ?? 0
}
} catch { /* 兜底失败时返回原值,由调用方判断 */ }
}
return { serviceId, hostId }
}
const handleDeleteVmNetwork = (row) => {
ElMessageBox.confirm(
`将删除底层网络「${row.name}」(ID:${row.id}),该操作会影响所有绑定该网络的虚拟机,是否继续?`,
'删除网络',
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
deletingNetworkId.value = row.id
try {
const { serviceId, hostId } = await resolveNetworkServiceHost(row)
if (!serviceId) { ElMessage.error('无法获取该网络所属服务ID,删除失败'); return }
const params = { service_id: serviceId, network_id: row.id }
if (hostId) params.host_id = hostId
const res = await deletePointNetwork(params)
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '删除失败'))
} finally {
deletingNetworkId.value = 0
}
}).catch(() => {})
}
const handleSetVmNetworkPrimary = (row) => {
ElMessageBox.confirm(
`将「${row.address || row.name}」设为主IP?设置后将重启虚拟机。`,
'设置主IP', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const res = await setUserVmNetworkPrimary({ user_goods_id: userGoodsId.value, network_id: row.id })
if (res?.data?.code === 200) { ElMessage.success('设置主IP成功,虚拟机将重启'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '设置主IP失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '设置主IP失败')) }
}).catch(() => {})
}
const handleResetVmMac = () => {
ElMessageBox.confirm(
'重置MAC地址将重建 CloudInit 并重启虚拟机,确定继续?',
'重置MAC地址', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const res = await resetUserVmMac({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200) { ElMessage.success('MAC地址重置成功,虚拟机将重启'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '重置MAC失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重置MAC失败')) }
}).catch(() => {})
}
const handleDisconnectVmNetwork = () => {
ElMessageBox.confirm(
'断开虚拟机外部网络连接?断网后虚拟机将无法访问外部网络。',
'断网', { confirmButtonText: '确定断网', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const res = await disconnectUserVmNetwork({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200) { ElMessage.success('已断网'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '断网失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '断网失败')) }
}).catch(() => {})
}
const handleConnectVmNetwork = () => {
ElMessageBox.confirm(
'恢复虚拟机外部网络连接?',
'恢复网络', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' }
).then(async () => {
try {
const res = await connectUserVmNetwork({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200) { ElMessage.success('网络已恢复'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '恢复网络失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复网络失败')) }
}).catch(() => {})
}
// ---- 绑定网络 ---- // ---- 绑定网络 ----
const showBindNetworkSelector = ref(false) const showBindNetworkSelector = ref(false)
@@ -2019,12 +2216,15 @@ const loadTrafficPolicy = async () => {
} }
const openTrafficPolicyDialog = () => { const openTrafficPolicyDialog = () => {
// 从概览触发时 trafficPolicy 可能尚未加载(trafficPolicy tab 懒加载),
// 用 vm.value 上 add.json 新字段作 fallback;同时异步加载策略以便后续 submit 时数据准确
Object.assign(trafficPolicyForm, { Object.assign(trafficPolicyForm, {
traffic_max_mb: trafficPolicy.value?.traffic_max_mb || 0, traffic_max_mb: trafficPolicy.value?.traffic_max_mb ?? vm.value?.traffic_max_mb ?? vm.value?.traffic_max ?? 0,
exhausted_rx_mbps: trafficPolicy.value?.exhausted_rx_mbps || 0, exhausted_rx_mbps: trafficPolicy.value?.exhausted_rx_mbps ?? vm.value?.traffic_exhausted_rx_mbps ?? 0,
exhausted_tx_mbps: trafficPolicy.value?.exhausted_tx_mbps || 0 exhausted_tx_mbps: trafficPolicy.value?.exhausted_tx_mbps ?? vm.value?.traffic_exhausted_tx_mbps ?? 0
}) })
trafficPolicyVisible.value = true trafficPolicyVisible.value = true
if (!trafficPolicy.value) loadTrafficPolicy()
} }
const submitUpdateTrafficPolicy = async () => { const submitUpdateTrafficPolicy = async () => {
@@ -2058,6 +2258,55 @@ const submitAddTraffic = async () => {
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { trafficPolicyLoading.value = false } } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { trafficPolicyLoading.value = false }
} }
// ---- 每小时流量统计 ----
const trafficHourlyRange = ref(null)
const trafficHourlyData = ref([])
const trafficHourlyLoading = ref(false)
const trafficHourlyChartRef = ref(null)
let trafficHourlyChart = null
const loadTrafficHourly = async () => {
if (!userGoodsId.value) return
if (!trafficHourlyRange.value) {
const now = new Date()
trafficHourlyRange.value = [new Date(now.getTime() - 24 * 3600 * 1000), now]
}
trafficHourlyLoading.value = true
try {
const res = await getUserVmTrafficHourly({
user_goods_id: userGoodsId.value,
start: new Date(trafficHourlyRange.value[0]).toISOString(),
end_time: new Date(trafficHourlyRange.value[1]).toISOString()
})
const raw = res?.data?.data?.data
trafficHourlyData.value = typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : [])
nextTick(renderTrafficHourlyChart)
} catch { trafficHourlyData.value = [] } finally { trafficHourlyLoading.value = false }
}
const renderTrafficHourlyChart = () => {
if (!trafficHourlyChartRef.value || !trafficHourlyData.value.length) return
if (!trafficHourlyChart) trafficHourlyChart = echarts.init(trafficHourlyChartRef.value)
const data = trafficHourlyData.value
const times = data.map(d => {
const date = new Date(d.bucket)
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
})
const toMB = (b) => +(b / 1048576).toFixed(2)
trafficHourlyChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => params.map(p => `${p.marker}${p.seriesName}: ${p.value} MB`).join('<br/>') },
legend: { data: ['下行', '上行', '合计'], bottom: 0 },
grid: { top: 10, right: 16, bottom: 40, left: 50 },
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', name: 'MB', axisLabel: { fontSize: 10 } },
series: [
{ name: '下行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.rx_bytes)), itemStyle: { color: '#409EFF' } },
{ name: '上行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.tx_bytes)), itemStyle: { color: '#67C23A' } },
{ name: '合计', type: 'line', smooth: true, data: data.map(d => toMB(d.total_bytes)), itemStyle: { color: '#E6A23C' }, lineStyle: { width: 2 } }
]
}, true)
}
// ---- 转移 ---- // ---- 转移 ----
const transferVisible = ref(false) const transferVisible = ref(false)
const showTransferUserSelector = ref(false) const showTransferUserSelector = ref(false)
@@ -2110,11 +2359,15 @@ const submitEditGoods = async () => {
const cpuChartRef = ref(null) const cpuChartRef = ref(null)
const memChartRef = ref(null) const memChartRef = ref(null)
const diskChartRef = ref(null) const diskChartRef = ref(null)
const diskIopsChartRef = ref(null)
const netChartRef = ref(null) const netChartRef = ref(null)
const trafficUsedChartRef = ref(null)
let cpuChart = null let cpuChart = null
let memChart = null let memChart = null
let diskChart = null let diskChart = null
let diskIopsChart = null
let netChart = null let netChart = null
let trafficUsedChart = null
const metricsData = ref(null) const metricsData = ref(null)
const metricsLoading = ref(false) const metricsLoading = ref(false)
@@ -2241,9 +2494,28 @@ const renderMetricsCharts = () => {
}) })
const cpuData = metrics.map(m => m.cpu_usage ?? 0) const cpuData = metrics.map(m => m.cpu_usage ?? 0)
const memData = metrics.map(m => m.mem_total ? ((m.mem_used / m.mem_total) * 100) : 0) const memData = metrics.map(m => m.mem_used ?? 0)
const memTotal = Math.max(...metrics.map(m => m.mem_total ?? 0))
const memAxisFmt = memTotal >= 1048576
? (v) => (v / 1048576).toFixed(1) + ' GB'
: memTotal >= 1024
? (v) => (v / 1024).toFixed(0) + ' MB'
: (v) => v + ' KB'
const diskReadData = metrics.map(m => m.disk_read ?? 0) const diskReadData = metrics.map(m => m.disk_read ?? 0)
const diskWriteData = metrics.map(m => m.disk_write ?? 0) const diskWriteData = metrics.map(m => m.disk_write ?? 0)
const diskReadRate = []
const diskWriteRate = []
for (let i = 0; i < metrics.length; i++) {
if (i === 0) { diskReadRate.push(0); diskWriteRate.push(0); continue }
const dt = (new Date(metrics[i].bucket) - new Date(metrics[i - 1].bucket)) / 1000
if (dt > 0) {
diskReadRate.push(+Math.max(0, ((metrics[i].disk_read ?? 0) - (metrics[i - 1].disk_read ?? 0)) / dt / 1024).toFixed(2))
diskWriteRate.push(+Math.max(0, ((metrics[i].disk_write ?? 0) - (metrics[i - 1].disk_write ?? 0)) / dt / 1024).toFixed(2))
} else { diskReadRate.push(0); diskWriteRate.push(0) }
}
const netRxData = metrics.map(m => m.net_rx ?? 0) const netRxData = metrics.map(m => m.net_rx ?? 0)
const netTxData = metrics.map(m => m.net_tx ?? 0) const netTxData = metrics.map(m => m.net_tx ?? 0)
@@ -2264,9 +2536,9 @@ const renderMetricsCharts = () => {
if (memChartRef.value) { if (memChartRef.value) {
if (!memChart) memChart = echarts.init(memChartRef.value) if (!memChart) memChart = echarts.init(memChartRef.value)
memChart.setOption({ memChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` }, tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${formatMemKB(p[0].value)}` },
grid: baseGrid, xAxis: makeXAxis(), grid: { ...baseGrid, left: 60 }, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } }, yAxis: { type: 'value', min: 0, max: memTotal || undefined, axisLabel: { fontSize: 10, formatter: memAxisFmt } },
series: [makeSeries('内存', memData, '#67c23a')] series: [makeSeries('内存', memData, '#67c23a')]
}, true) }, true)
} }
@@ -2285,6 +2557,20 @@ const renderMetricsCharts = () => {
}, true) }, true)
} }
if (diskIopsChartRef.value) {
if (!diskIopsChart) diskIopsChart = echarts.init(diskIopsChartRef.value)
diskIopsChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => {
let s = params[0].axisValue
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatBytesRaw(p.value)}/s` })
return s
}},
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatBytesRaw(v) + '/s' } },
series: [makeSeries('读取', diskReadRate, '#409eff'), makeSeries('写入', diskWriteRate, '#e6a23c')]
}, true)
}
if (netChartRef.value) { if (netChartRef.value) {
if (!netChart) netChart = echarts.init(netChartRef.value) if (!netChart) netChart = echarts.init(netChartRef.value)
netChart.setOption({ netChart.setOption({
@@ -2298,13 +2584,32 @@ const renderMetricsCharts = () => {
series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')] series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')]
}, true) }, true)
} }
if (trafficUsedChartRef.value) {
if (!trafficUsedChart) trafficUsedChart = echarts.init(trafficUsedChartRef.value)
const trafficDelta = []
for (let i = 0; i < metrics.length; i++) {
if (i === 0) { trafficDelta.push(0); continue }
const delta = Math.max(0, (metrics[i].traffic_used_mb ?? 0) - (metrics[i - 1].traffic_used_mb ?? 0))
trafficDelta.push(+delta.toFixed(2))
}
trafficUsedChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 流量增量: ${p[0].value} MB` },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => v + ' MB' } },
series: [makeSeries('流量增量', trafficDelta, '#722ed1')]
}, true)
}
} }
const disposeCharts = () => { const disposeCharts = () => {
cpuChart?.dispose(); cpuChart = null cpuChart?.dispose(); cpuChart = null
memChart?.dispose(); memChart = null memChart?.dispose(); memChart = null
diskChart?.dispose(); diskChart = null diskChart?.dispose(); diskChart = null
diskIopsChart?.dispose(); diskIopsChart = null
netChart?.dispose(); netChart = null netChart?.dispose(); netChart = null
trafficUsedChart?.dispose(); trafficUsedChart = null
trafficHourlyChart?.dispose(); trafficHourlyChart = null
} }
onMounted(() => { loadDetail() }) onMounted(() => { loadDetail() })
@@ -2355,6 +2660,14 @@ onBeforeUnmount(() => { disposeCharts() })
.config-cell:last-child { border-right: none; } .config-cell:last-child { border-right: none; }
.config-label { font-size: 12px; color: #86909c; line-height: 1; } .config-label { font-size: 12px; color: #86909c; line-height: 1; }
.config-value { font-size: 13px; color: #1d2129; line-height: 1.4; word-break: break-all; } .config-value { font-size: 13px; color: #1d2129; line-height: 1.4; word-break: break-all; }
.cfg-edit-btn { margin-left: 8px; padding: 0 4px; height: 18px; vertical-align: middle; }
.cfg-edit-btn .el-icon { margin-right: 2px; vertical-align: -2px; }
.traffic-cell { display: flex; flex-direction: column; gap: 2px; }
.traffic-cell .traffic-main { font-size: 13px; color: #1d2129; }
.traffic-cell .traffic-sub { font-size: 12px; color: #86909c; line-height: 1.3; }
.traffic-cell .traffic-cycle { color: #c0c4cc; margin-left: 2px; }
.traffic-cell .traffic-actions { display: inline-flex; gap: 6px; margin-top: 2px; }
.traffic-cell .traffic-actions .cfg-edit-btn { margin-left: 0; }
.pwd-value { display: inline-flex; align-items: center; gap: 4px; } .pwd-value { display: inline-flex; align-items: center; gap: 4px; }
.pwd-text { font-family: 'Consolas', 'Monaco', monospace; font-size: 13px; background: #f5f7fa; padding: 2px 8px; border-radius: 3px; letter-spacing: .5px; user-select: all; } .pwd-text { font-family: 'Consolas', 'Monaco', monospace; font-size: 13px; background: #f5f7fa; padding: 2px 8px; border-radius: 3px; letter-spacing: .5px; user-select: all; }
.pwd-btn { padding: 0 !important; height: auto !important; min-height: auto !important; } .pwd-btn { padding: 0 !important; height: auto !important; min-height: auto !important; }
+438 -6
View File
@@ -131,6 +131,127 @@
</div> </div>
</div> </div>
</div> </div>
<div class="section-block">
<h3 class="section-title clickable" @click="showDetailDiskIo = !showDetailDiskIo">
硬盘 IO 限制
<el-icon class="section-arrow" :class="{ expanded: showDetailDiskIo }"><ArrowRight /></el-icon>
</h3>
<div v-show="showDetailDiskIo" class="config-grid">
<div class="config-row">
<div class="config-cell" v-for="f in diskIoBwFields" :key="f.key">
<span class="config-label">{{ f.label }}带宽</span>
<span class="config-value mono-text">{{ formatDiskIoVal(detail[f.key], true) }}</span>
</div>
</div>
<div class="config-row">
<div class="config-cell" v-for="f in diskIoIopsFields" :key="f.key">
<span class="config-label">{{ f.label }} IOPS</span>
<span class="config-value mono-text">{{ formatDiskIoVal(detail[f.key], false) }}</span>
</div>
</div>
</div>
</div>
<div class="section-block">
<div class="section-header">
<h3 class="section-title">KSM 内存去重</h3>
<div style="display:flex;gap:8px">
<el-button size="small" :icon="Refresh" @click="loadKsmStatus" :loading="ksmLoading">刷新</el-button>
</div>
</div>
<template v-if="ksmStats">
<el-descriptions :column="3" border size="small" style="margin-top:12px">
<el-descriptions-item label="内核支持">
<el-tag :type="ksmStats.available ? 'success' : 'danger'" size="small">{{ ksmStats.available ? '支持' : '不支持' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="启用状态">
<el-tag :type="ksmStats.enabled ? 'success' : 'info'" size="small">{{ ksmStats.enabled ? '已启用' : '未启用' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="跨 NUMA 合并">
<el-tag :type="ksmStats.merge_across_nodes ? 'warning' : 'info'" size="small">{{ ksmStats.merge_across_nodes ? '是' : '否' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="扫描页面数">{{ ksmStats.pages_to_scan ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="扫描间隔">{{ ksmStats.sleep_millisecs != null ? ksmStats.sleep_millisecs + ' ms' : '-' }}</el-descriptions-item>
<el-descriptions-item label="完整扫描次数">{{ ksmStats.full_scans ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="已合并唯一页面">{{ ksmStats.pages_shared ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="共享总页面">{{ ksmStats.pages_sharing ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="未合并页面">{{ ksmStats.pages_unshared ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="节省内存">
<span style="font-weight:600;color:#67c23a">{{ formatMemKB(ksmStats.memory_saved_kb) }}</span>
</el-descriptions-item>
</el-descriptions>
<div style="margin-top:12px;display:flex;gap:8px">
<el-button v-if="!ksmStats.enabled" type="success" size="small" :loading="ksmActionLoading" @click="handleKsmAction('enable')">启用 KSM</el-button>
<el-button v-if="ksmStats.enabled" type="danger" size="small" :loading="ksmActionLoading" @click="handleKsmAction('disable')">关闭 KSM</el-button>
<el-button v-if="ksmStats.available" type="primary" size="small" @click="openKsmTuneDialog">调参</el-button>
</div>
</template>
<el-empty v-else-if="!ksmLoading" description="点击刷新加载 KSM 状态" :image-size="60" />
</div>
</el-tab-pane>
<el-tab-pane label="额度统计" name="quotaStats">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">资源额度统计</h3>
<el-button size="small" :icon="Refresh" @click="loadQuotaStats" :loading="quotaStatsLoading">刷新</el-button>
</div>
<template v-if="quotaStats">
<div class="quota-grid">
<div class="quota-card quota-card-vm">
<div class="quota-card-icon"><el-icon :size="28" color="#409eff"><Monitor /></el-icon></div>
<div class="quota-card-body">
<div class="quota-card-label">虚拟机数量</div>
<div class="quota-card-number">{{ quotaStats.vm_count ?? 0 }}<span class="quota-card-unit"></span></div>
</div>
</div>
<div class="quota-card" v-for="item in quotaAllocationItems" :key="item.label">
<el-progress type="circle" :percentage="Math.min(100, item.ratio)" :width="80" :stroke-width="7" :color="quotaColor(item.ratio)">
<span class="quota-ring-pct" :style="{ color: quotaColor(item.ratio) }">{{ item.ratio }}%</span>
</el-progress>
<div class="quota-card-body">
<div class="quota-card-label">{{ item.label }}</div>
<div class="quota-card-alloc">{{ item.allocatedText }} <span class="quota-card-sep">/</span> {{ item.plannedText }}</div>
<div class="quota-card-sub">已分配 / 规划</div>
</div>
</div>
</div>
<div class="quota-actual-section">
<h4 class="quota-subtitle">实时使用</h4>
<div class="quota-actual-grid">
<div class="quota-actual-card">
<div class="quota-actual-header">
<span class="quota-actual-label">CPU 使用率</span>
<span class="quota-actual-value" :style="{ color: quotaColor(quotaStats.actual_cpu_percent ?? 0) }">{{ quotaStats.actual_cpu_percent != null ? quotaStats.actual_cpu_percent.toFixed(1) + '%' : '-' }}</span>
</div>
<el-progress :percentage="Math.min(100, quotaStats.actual_cpu_percent ?? 0)" :show-text="false" :stroke-width="10" :color="quotaColor(quotaStats.actual_cpu_percent ?? 0)" />
</div>
<div class="quota-actual-card">
<div class="quota-actual-header">
<span class="quota-actual-label">内存使用</span>
<span class="quota-actual-value" :style="{ color: quotaColor(actualMemPercent) }">{{ formatQuotaBytes(quotaStats.actual_memory_used) }} / {{ formatQuotaBytes(quotaStats.actual_memory_total) }}</span>
</div>
<el-progress :percentage="actualMemPercent" :show-text="false" :stroke-width="10" :color="quotaColor(actualMemPercent)" />
</div>
</div>
</div>
<template v-if="quotaStatsDisk.length">
<div class="quota-disk-section">
<h4 class="quota-subtitle">磁盘使用详情</h4>
<div class="quota-disk-list">
<div class="quota-disk-item" v-for="disk in quotaStatsDisk" :key="disk.path">
<div class="quota-disk-header">
<code class="quota-disk-path">{{ disk.path }}</code>
<span class="quota-disk-detail">{{ formatQuotaBytes(disk.used) }} / {{ formatQuotaBytes(disk.total) }}</span>
<span class="quota-disk-pct" :style="{ color: quotaColor(disk.percent ?? 0) }">{{ (disk.percent ?? 0).toFixed(1) }}%</span>
</div>
<el-progress :percentage="Math.min(100, disk.percent ?? 0)" :show-text="false" :stroke-width="8" :color="quotaColor(disk.percent ?? 0)" />
</div>
</div>
</div>
</template>
</template>
<el-empty v-else-if="!quotaStatsLoading" description="暂无额度统计数据" :image-size="60" />
</div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="监控" name="monitor"> <el-tab-pane label="监控" name="monitor">
@@ -408,6 +529,32 @@
<el-form-item label="上行带宽"><el-input-number v-model="formData.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span></el-form-item> <el-form-item label="上行带宽"><el-input-number v-model="formData.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span></el-form-item>
</div> </div>
</div> </div>
<div class="tk-section">
<div class="tk-section-title clickable" @click="showDiskIoSection = !showDiskIoSection">
硬盘 IO 限制
<el-icon class="section-arrow" :class="{ expanded: showDiskIoSection }"><ArrowRight /></el-icon>
<span class="section-hint">可选,不展开则使用默认值</span>
</div>
<div v-show="showDiskIoSection">
<div class="io-sub-title">
带宽限制
<el-select v-model="ioBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label">
<el-input-number :model-value="+(formData[f.key] / getIoBwFactor()).toFixed(2)" @update:model-value="v => formData[f.key] = Math.round((v || 0) * getIoBwFactor())" :min="0" controls-position="right" />
</el-form-item>
</div>
<div class="io-sub-title">IOPS 限制</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label">
<el-input-number v-model="formData[f.key]" :min="0" controls-position="right" />
</el-form-item>
</div>
</div>
</div>
<div class="tk-section"> <div class="tk-section">
<div class="tk-section-title">其他配置</div> <div class="tk-section-title">其他配置</div>
<el-form-item label="宿主机组"> <el-form-item label="宿主机组">
@@ -475,6 +622,32 @@
</el-form-item> </el-form-item>
</div> </div>
</div> </div>
<div class="tk-section">
<div class="tk-section-title clickable" @click="showTokenDiskIo = !showTokenDiskIo">
硬盘 IO 限制
<el-icon class="section-arrow" :class="{ expanded: showTokenDiskIo }"><ArrowRight /></el-icon>
<span class="section-hint">可选,不展开则使用默认值</span>
</div>
<div v-show="showTokenDiskIo">
<div class="io-sub-title">
带宽限制
<el-select v-model="tokenIoBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label" class="tk-res-item">
<el-input-number :model-value="+(tokenForm[f.key] / getTokenIoBwFactor()).toFixed(2)" @update:model-value="v => tokenForm[f.key] = Math.round((v || 0) * getTokenIoBwFactor())" :min="0" controls-position="right" />
</el-form-item>
</div>
<div class="io-sub-title">IOPS 限制</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label" class="tk-res-item">
<el-input-number v-model="tokenForm[f.key]" :min="0" controls-position="right" />
</el-form-item>
</div>
</div>
</div>
<div class="tk-section"> <div class="tk-section">
<div class="tk-section-title">令牌有效期</div> <div class="tk-section-title">令牌有效期</div>
<el-form-item label="有效期" prop="expire_hours"> <el-form-item label="有效期" prop="expire_hours">
@@ -529,6 +702,25 @@
<!-- 令牌用宿主机组选择器 --> <!-- 令牌用宿主机组选择器 -->
<HostGroupSelectorPopup v-model="showTokenGroupSelector" :service-id="serviceId" :current-id="tokenForm.host_group_id" @confirm="handleTokenGroupSelected" /> <HostGroupSelectorPopup v-model="showTokenGroupSelector" :service-id="serviceId" :current-id="tokenForm.host_group_id" @confirm="handleTokenGroupSelected" />
<!-- KSM 调参弹窗 -->
<el-dialog v-model="ksmTuneVisible" title="KSM 调参" width="480px" destroy-on-close>
<el-form label-width="140px" style="padding: 8px 0">
<el-form-item label="每次扫描页面数">
<el-input-number v-model="ksmTuneForm.pages_to_scan" :min="0" :step="100" controls-position="right" style="width:200px" />
</el-form-item>
<el-form-item label="扫描间隔 (ms)">
<el-input-number v-model="ksmTuneForm.sleep_millisecs" :min="0" :step="10" controls-position="right" style="width:200px" />
</el-form-item>
<el-form-item label=" NUMA 节点合并">
<el-switch v-model="ksmTuneForm.merge_across_nodes" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="ksmTuneVisible = false">取消</el-button>
<el-button type="primary" :loading="ksmActionLoading" @click="submitKsmTune">确定</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -536,12 +728,13 @@
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, watch, nextTick, provide } from 'vue' import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, watch, nextTick, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument } from '@element-plus/icons-vue' import { ArrowLeft, ArrowRight, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument } from '@element-plus/icons-vue'
import { import {
getRemoteHostDetail, updateRemoteHost, deleteRemoteHost, getRemoteHostDetail, updateRemoteHost, deleteRemoteHost,
getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking, getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking,
assignUserNetworking, removeUserNetworkingNetwork, assignUserNetworking, removeUserNetworkingNetwork,
createHostToken, getMetricsHistory createHostToken, getMetricsHistory, getHostQuotaStats,
getHostKsmStatus, configureHostKsm
} from '@/api/admin/kvmService' } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import { baseUrl } from '@/config/env' import { baseUrl } from '@/config/env'
@@ -590,6 +783,7 @@ watch(activeTab, (tab) => {
} }
} }
if (tab === 'networking') loadNetworkingList() if (tab === 'networking') loadNetworkingList()
if (tab === 'quotaStats' && !quotaStats.value) loadQuotaStats()
}) })
const loading = ref(false) const loading = ref(false)
@@ -630,9 +824,56 @@ const editDialogVisible = ref(false)
const showGroupSelector = ref(false) const showGroupSelector = ref(false)
const formRef = ref(null) const formRef = ref(null)
const diskIoDefaults = {
read_bytes_sec: 314572800, write_bytes_sec: 314572800,
read_iops_sec: 1000, write_iops_sec: 1000,
read_bytes_sec_max: 314572800, write_bytes_sec_max: 314572800,
read_iops_sec_max: 1000, write_iops_sec_max: 1000
}
const diskIoBwFields = [
{ key: 'read_bytes_sec', label: '读取' },
{ key: 'write_bytes_sec', label: '写入' },
{ key: 'read_bytes_sec_max', label: '突发读取' },
{ key: 'write_bytes_sec_max', label: '突发写入' }
]
const diskIoIopsFields = [
{ key: 'read_iops_sec', label: '读取' },
{ key: 'write_iops_sec', label: '写入' },
{ key: 'read_iops_sec_max', label: '突发读取' },
{ key: 'write_iops_sec_max', label: '突发写入' }
]
const diskIoFields = [
...diskIoBwFields.map(f => ({ ...f, isBandwidth: true })),
...diskIoIopsFields.map(f => ({ ...f, isBandwidth: false }))
]
const formatDiskIoVal = (val, isBandwidth) => {
if (!val && val !== 0) return '-'
val = Number(val)
if (!isBandwidth) return val.toLocaleString() + ' IOPS'
if (val >= 1073741824) return (val / 1073741824).toFixed(1) + ' GB/s'
if (val >= 1048576) return (val / 1048576).toFixed(0) + ' MB/s'
if (val >= 1024) return (val / 1024).toFixed(0) + ' KB/s'
return val + ' B/s'
}
const ioBwUnitOptions = [
{ label: 'B/s', factor: 1 },
{ label: 'KB/s', factor: 1024 },
{ label: 'MB/s', factor: 1048576 },
{ label: 'GB/s', factor: 1073741824 }
]
const ioBwUnit = ref('MB/s')
const tokenIoBwUnit = ref('MB/s')
const getIoBwFactor = () => ioBwUnitOptions.find(u => u.label === ioBwUnit.value)?.factor || 1048576
const getTokenIoBwFactor = () => ioBwUnitOptions.find(u => u.label === tokenIoBwUnit.value)?.factor || 1048576
const showDiskIoSection = ref(false)
const showTokenDiskIo = ref(false)
const showDetailDiskIo = ref(false)
const formData = reactive({ const formData = reactive({
name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '',
max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '' max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
...diskIoDefaults
}) })
const formRules = { const formRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }], name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
@@ -703,6 +944,150 @@ const loadDetail = async () => {
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false } } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
} }
// ---- 额度统计 ----
const quotaStats = ref(null)
const quotaStatsLoading = ref(false)
const quotaStatsDisk = computed(() => {
if (!quotaStats.value?.actual_disk_json) return []
try {
const parsed = JSON.parse(quotaStats.value.actual_disk_json)
if (Array.isArray(parsed)) return parsed
return Object.entries(parsed).map(([path, info]) => ({ path, ...info }))
} catch { return [] }
})
const loadQuotaStats = async () => {
if (!serviceId.value || !hostId.value) return
quotaStatsLoading.value = true
try {
const res = await getHostQuotaStats({ service_id: serviceId.value, host_id: hostId.value })
if (res?.data?.code === 200) quotaStats.value = res.data.data
else quotaStats.value = null
} catch { quotaStats.value = null } finally { quotaStatsLoading.value = false }
}
const formatQuotaKB = (kb) => {
if (!kb && kb !== 0) return '-'
const v = Number(kb)
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' TB'
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' GB'
if (v >= 1024) return (v / 1024).toFixed(1) + ' MB'
return v + ' KB'
}
const formatQuotaDisk = (gb) => {
if (!gb && gb !== 0) return '-'
const v = Number(gb)
if (v >= 1024) return (v / 1024).toFixed(1) + ' TB'
return v + ' GB'
}
const formatBandwidth = (mbps) => {
if (!mbps && mbps !== 0) return '-'
const v = Number(mbps)
if (v >= 1000) return (v / 1000).toFixed(1) + ' Gbps'
return v + ' Mbps'
}
const formatQuotaBytes = (bytes) => {
if (!bytes && bytes !== 0) return '-'
const n = Number(bytes)
if (n >= 1099511627776) return (n / 1099511627776).toFixed(2) + ' TB'
if (n >= 1073741824) return (n / 1073741824).toFixed(2) + ' GB'
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB'
if (n >= 1024) return (n / 1024).toFixed(0) + ' KB'
return n + ' B'
}
const quotaColor = (ratio) => {
if (ratio >= 90) return '#f56c6c'
if (ratio >= 70) return '#e6a23c'
return '#409eff'
}
const quotaAllocationItems = computed(() => {
const s = quotaStats.value
if (!s) return []
const ratio = (a, p) => p ? Math.round((a / p) * 100) : 0
return [
{ label: 'CPU', ratio: ratio(s.allocated_cpu, s.planned_cpu), allocatedText: (s.allocated_cpu ?? 0) + ' 核', plannedText: (s.planned_cpu ?? 0) + ' 核' },
{ label: '内存', ratio: ratio(s.allocated_memory, s.planned_memory), allocatedText: formatQuotaKB(s.allocated_memory), plannedText: formatQuotaKB(s.planned_memory) },
{ label: '磁盘', ratio: ratio(s.allocated_disk, s.planned_disk), allocatedText: formatQuotaDisk(s.allocated_disk), plannedText: formatQuotaDisk(s.planned_disk) },
{ label: '下行带宽', ratio: ratio(s.allocated_rx_bandwidth, s.planned_rx_bandwidth), allocatedText: formatBandwidth(s.allocated_rx_bandwidth), plannedText: formatBandwidth(s.planned_rx_bandwidth) },
{ label: '上行带宽', ratio: ratio(s.allocated_tx_bandwidth, s.planned_tx_bandwidth), allocatedText: formatBandwidth(s.allocated_tx_bandwidth), plannedText: formatBandwidth(s.planned_tx_bandwidth) }
]
})
const actualMemPercent = computed(() => {
const s = quotaStats.value
if (!s?.actual_memory_total) return 0
return Math.round((s.actual_memory_used / s.actual_memory_total) * 100)
})
// ---- KSM 内存去重 ----
const ksmStats = ref(null)
const ksmLoading = ref(false)
const ksmActionLoading = ref(false)
const ksmTuneVisible = ref(false)
const ksmTuneForm = reactive({ pages_to_scan: 300, sleep_millisecs: 100, merge_across_nodes: false })
const loadKsmStatus = async () => {
if (!serviceId.value || !hostId.value) return
ksmLoading.value = true
try {
const res = await getHostKsmStatus({ service_id: serviceId.value, host_id: hostId.value })
if (res?.data?.code === 200) ksmStats.value = res.data.data?.stats || res.data.data
else ElMessage.error(extractApiError(res?.data, '获取 KSM 状态失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取 KSM 状态失败')) } finally { ksmLoading.value = false }
}
const handleKsmAction = async (action) => {
const labels = { enable: '启用', disable: '关闭' }
try {
await ElMessageBox.confirm(`确定要${labels[action]} KSM 吗?`, 'KSM 操作', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
} catch { return }
ksmActionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('host_id', hostId.value)
fd.append('action', action)
const res = await configureHostKsm(fd)
if (res?.data?.code === 200) {
ElMessage.success(`KSM 已${labels[action]}`)
ksmStats.value = res.data.data?.stats || res.data.data
} else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { ksmActionLoading.value = false }
}
const openKsmTuneDialog = () => {
if (ksmStats.value) {
ksmTuneForm.pages_to_scan = ksmStats.value.pages_to_scan ?? 300
ksmTuneForm.sleep_millisecs = ksmStats.value.sleep_millisecs ?? 100
ksmTuneForm.merge_across_nodes = !!ksmStats.value.merge_across_nodes
}
ksmTuneVisible.value = true
}
const submitKsmTune = async () => {
ksmActionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('host_id', hostId.value)
fd.append('action', 'tune')
fd.append('pages_to_scan', ksmTuneForm.pages_to_scan)
fd.append('sleep_millisecs', ksmTuneForm.sleep_millisecs)
fd.append('merge_across_nodes', ksmTuneForm.merge_across_nodes)
const res = await configureHostKsm(fd)
if (res?.data?.code === 200) {
ElMessage.success('KSM 参数已更新')
ksmStats.value = res.data.data?.stats || res.data.data
ksmTuneVisible.value = false
} else ElMessage.error(extractApiError(res?.data, '调参失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调参失败')) } finally { ksmActionLoading.value = false }
}
const cpuChartRef = ref(null) const cpuChartRef = ref(null)
const memChartRef = ref(null) const memChartRef = ref(null)
const netChartRef = ref(null) const netChartRef = ref(null)
@@ -899,8 +1284,10 @@ const handleEdit = () => {
port: d.port || 22, user: d.user || '', password: d.password || '', private_key: d.private_key || '', port: d.port || 22, user: d.user || '', password: d.password || '', private_key: d.private_key || '',
max_cpu: d.max_cpu || 0, max_memory: d.max_memory || 0, max_disk: d.max_disk || 0, max_cpu: d.max_cpu || 0, max_memory: d.max_memory || 0, max_disk: d.max_disk || 0,
rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0, rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0,
host_group_id: d.host_group_id || 0, description: d.description || '' host_group_id: d.host_group_id || 0, description: d.description || '',
...Object.fromEntries(diskIoFields.map(f => [f.key, d[f.key] ?? diskIoDefaults[f.key]]))
}) })
showDiskIoSection.value = diskIoFields.some(f => d[f.key] && d[f.key] !== diskIoDefaults[f.key])
editDialogVisible.value = true editDialogVisible.value = true
} }
@@ -947,7 +1334,8 @@ const tokenForm = reactive({
name: '', host_group_id: 0, max_cpu: 4, name: '', host_group_id: 0, max_cpu: 4,
max_memory: 4194304, max_disk: 100, max_memory: 4194304, max_disk: 100,
rx_bandwidth: 100, tx_bandwidth: 100, rx_bandwidth: 100, tx_bandwidth: 100,
description: '', expire_hours: 24 description: '', expire_hours: 24,
...diskIoDefaults
}) })
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 }) const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
const tokenRules = { const tokenRules = {
@@ -979,10 +1367,13 @@ const openTokenDialog = () => {
max_disk: d?.max_disk || 100, max_disk: d?.max_disk || 100,
rx_bandwidth: d?.rx_bandwidth || 100, rx_bandwidth: d?.rx_bandwidth || 100,
tx_bandwidth: d?.tx_bandwidth || 100, tx_bandwidth: d?.tx_bandwidth || 100,
description: '', expire_hours: 24 description: '', expire_hours: 24,
...Object.fromEntries(diskIoFields.map(f => [f.key, d?.[f.key] ?? diskIoDefaults[f.key]]))
}) })
tokenMemUnit.value = 'GB' tokenMemUnit.value = 'GB'
tokenDiskUnit.value = 'GB' tokenDiskUnit.value = 'GB'
showTokenDiskIo.value = false
tokenIoBwUnit.value = 'MB/s'
tokenDialogVisible.value = true tokenDialogVisible.value = true
} }
@@ -1004,6 +1395,7 @@ const handleTokenSubmit = () => {
fd.append('max_disk', tokenForm.max_disk) fd.append('max_disk', tokenForm.max_disk)
fd.append('rx_bandwidth', tokenForm.rx_bandwidth) fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
fd.append('tx_bandwidth', tokenForm.tx_bandwidth) fd.append('tx_bandwidth', tokenForm.tx_bandwidth)
diskIoFields.forEach(f => { if (tokenForm[f.key] !== undefined) fd.append(f.key, tokenForm[f.key]) })
fd.append('description', tokenForm.description || '') fd.append('description', tokenForm.description || '')
fd.append('expire_hours', tokenForm.expire_hours) fd.append('expire_hours', tokenForm.expire_hours)
const res = await createHostToken(fd) const res = await createHostToken(fd)
@@ -1294,4 +1686,44 @@ onBeforeUnmount(() => { isPageActive = false; disposeCharts() })
.metric-summary-value { font-size: 22px; font-weight: 600; color: #1d2129; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .metric-summary-value { font-size: 22px; font-weight: 600; color: #1d2129; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.metric-summary-sub { font-size: 12px; color: #86909c; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .metric-summary-sub { font-size: 12px; color: #86909c; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.clickable { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
.clickable:hover { color: #409eff; }
.section-arrow { transition: transform 0.2s; font-size: 14px; }
.section-arrow.expanded { transform: rotate(90deg); }
.section-hint { font-size: 12px; color: #909399; font-weight: 400; }
.tk-section-title.clickable { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
.tk-section-title.clickable:hover { color: #409eff; }
.io-sub-title { font-size: 13px; font-weight: 500; color: #606266; margin: 12px 0 8px; display: flex; align-items: center; }
.quota-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 16px; margin-top: 12px; }
.quota-card { background: #f7f8fa; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px 16px; display: flex; flex-direction: column; align-items: center; gap: 12px; transition: box-shadow 0.2s; }
.quota-card:hover { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); }
.quota-card-vm { flex-direction: row; justify-content: center; gap: 16px; }
.quota-card-icon { display: flex; align-items: center; justify-content: center; width: 56px; height: 56px; background: #ecf5ff; border-radius: 12px; }
.quota-card-body { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; }
.quota-card-vm .quota-card-body { align-items: flex-start; }
.quota-card-label { font-size: 12px; color: #86909c; white-space: nowrap; }
.quota-card-number { font-size: 32px; font-weight: 700; color: #1d2129; line-height: 1.1; }
.quota-card-unit { font-size: 14px; font-weight: 400; color: #86909c; margin-left: 4px; }
.quota-ring-pct { font-size: 16px; font-weight: 700; line-height: 1; }
.quota-card-alloc { font-size: 13px; color: #1d2129; font-weight: 500; white-space: nowrap; text-align: center; }
.quota-card-sep { color: #c0c4cc; margin: 0 2px; }
.quota-card-sub { font-size: 11px; color: #a8abb2; }
.quota-actual-section { margin-top: 24px; }
.quota-subtitle { font-size: 14px; font-weight: 600; color: #1d2129; margin: 0 0 14px; padding-left: 8px; border-left: 3px solid #409eff; }
.quota-actual-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.quota-actual-card { background: #f7f8fa; border: 1px solid #e8e8e8; border-radius: 8px; padding: 16px 20px; }
.quota-actual-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.quota-actual-label { font-size: 13px; color: #606266; font-weight: 500; }
.quota-actual-value { font-size: 14px; font-weight: 600; }
.quota-disk-section { margin-top: 24px; }
.quota-disk-list { display: flex; flex-direction: column; gap: 12px; }
.quota-disk-item { background: #f7f8fa; border: 1px solid #e8e8e8; border-radius: 8px; padding: 12px 20px; }
.quota-disk-header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; flex-wrap: wrap; }
.quota-disk-path { font-size: 13px; font-family: 'Consolas', 'Monaco', monospace; color: #1d2129; background: #e8eaed; padding: 2px 8px; border-radius: 4px; }
.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; }
</style> </style>
+239 -112
View File
@@ -8,30 +8,24 @@
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<el-select v-model="selectedServiceId" placeholder="选择主控服务" filterable style="width: 240px" @change="handleServiceChange"> <el-button @click="loadAllServices" :loading="servicesLoading">
<el-option v-for="s in serviceOptions" :key="s.id" :label="`${s.name} (ID: ${s.id})`" :value="s.id" />
</el-select>
<el-button type="primary" @click="handleSync" :loading="syncLoading" :disabled="!serviceId">
<el-icon><RefreshRight /></el-icon>从远程同步
</el-button>
<el-button @click="handleRefresh">
<el-icon><Refresh /></el-icon>刷新 <el-icon><Refresh /></el-icon>刷新
</el-button> </el-button>
</div> </div>
</div> </div>
<div class="embedded-toolbar" v-if="embedded"> <div class="embedded-toolbar" v-if="embedded">
<el-button type="primary" @click="handleSync" :loading="syncLoading"><el-icon><RefreshRight /></el-icon>从远程同步</el-button> <el-button type="primary" @click="handleSync(embeddedServiceId)" :loading="syncLoadingMap[embeddedServiceId]"><el-icon><RefreshRight /></el-icon>从远程同步</el-button>
<el-button @click="loadHostGroups"><el-icon><Refresh /></el-icon>刷新</el-button> <el-button @click="loadHostGroupsForService(embeddedServiceId)"><el-icon><Refresh /></el-icon>刷新</el-button>
</div> </div>
<!-- 本地主机组列表树形折叠 --> <!-- embedded 模式直接展示单个主控的主机组 -->
<div class="main-panel"> <div v-if="embedded" class="main-panel">
<div class="panel-header"> <div class="panel-header">
<h4>本地主机组列表</h4> <h4>本地主机组列表</h4>
</div> </div>
<div class="panel-body" v-loading="loading"> <div class="panel-body" v-loading="hostGroupLoadingMap[embeddedServiceId]">
<el-table :data="treeGroupList" stripe style="width: 100%" row-key="_rowKey" <el-table :data="getTreeGroupList(embeddedServiceId)" stripe style="width: 100%" row-key="_rowKey"
:tree-props="{ children: '_children', hasChildren: '_hasChildren' }"> default-expand-all :tree-props="{ children: '_children', hasChildren: '_hasChildren' }">
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip /> <el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="id" label="ID" width="70" /> <el-table-column prop="id" label="ID" width="70" />
<el-table-column label="远程ID" width="80"> <el-table-column label="远程ID" width="80">
@@ -60,13 +54,82 @@
<el-button link type="primary" size="small" @click.stop="handleEditGroup(row)">编辑</el-button> <el-button link type="primary" size="small" @click.stop="handleEditGroup(row)">编辑</el-button>
<el-button link type="primary" size="small" @click.stop="handleBind(row)">绑定</el-button> <el-button link type="primary" size="small" @click.stop="handleBind(row)">绑定</el-button>
<el-button link type="success" size="small" @click.stop="handleGenerateGoods(row)">生成商品</el-button> <el-button link type="success" size="small" @click.stop="handleGenerateGoods(row)">生成商品</el-button>
<el-button link type="danger" size="small" @click.stop="handleDeleteGroup(row)">删除</el-button> <el-button link type="danger" size="small" @click.stop="handleDeleteGroup(row, embeddedServiceId)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div> </div>
</div> </div>
<!-- embedded 模式展示所有主控服务每个可折叠展开 -->
<div v-if="!embedded" v-loading="servicesLoading">
<el-empty v-if="!servicesLoading && serviceList.length === 0" description="暂无主控服务" :image-size="100" />
<div v-for="service in serviceList" :key="service.id" class="service-card">
<div class="service-card-header" @click="toggleService(service)">
<div class="service-card-title">
<el-icon class="expand-icon" :class="{ 'is-expanded': expandedServiceIds.has(service.id) }"><ArrowRight /></el-icon>
<span class="service-name">{{ service.name }}</span>
<el-tag size="small" type="info">ID: {{ service.id }}</el-tag>
<el-tag v-if="service.host" size="small">{{ service.host }}{{ service.port ? ':' + service.port : '' }}</el-tag>
</div>
<div class="service-card-actions" @click.stop>
<el-button type="primary" size="small" @click="handleSync(service.id)" :loading="syncLoadingMap[service.id]">
<el-icon><RefreshRight /></el-icon>同步
</el-button>
<el-button size="small" @click="loadHostGroupsForService(service.id)">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<el-collapse-transition>
<div v-show="expandedServiceIds.has(service.id)" class="service-card-body">
<div v-loading="hostGroupLoadingMap[service.id]" class="host-group-table-wrapper">
<el-table
v-if="hostGroupDataMap[service.id]?.length > 0"
:data="getTreeGroupList(service.id)"
stripe style="width: 100%" row-key="_rowKey"
default-expand-all
:tree-props="{ children: '_children', hasChildren: '_hasChildren' }"
>
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="id" label="ID" width="70" />
<el-table-column label="远程ID" width="80">
<template #default="{ row }">{{ row.remoteId || '-' }}</template>
</el-table-column>
<el-table-column label="父级远程ID" width="100">
<template #default="{ row }">{{ row.parentRemoteId || '-' }}</template>
</el-table-column>
<el-table-column label="绑定商品组" min-width="120">
<template #default="{ row }">
<el-tag v-if="row.goodGroupId" type="success" size="small">商品组#{{ row.goodGroupId }}</el-tag>
<span v-else class="text-muted">未绑定</span>
</template>
</el-table-column>
<el-table-column label="绑定商品" min-width="100">
<template #default="{ row }">
<el-tag v-if="row.goodId" type="warning" size="small">商品#{{ row.goodId }}</el-tag>
<span v-else class="text-muted">未绑定</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="handleEditGroup(row)">编辑</el-button>
<el-button link type="primary" size="small" @click.stop="handleBind(row)">绑定</el-button>
<el-button link type="success" size="small" @click.stop="handleGenerateGoods(row)">生成商品</el-button>
<el-button link type="danger" size="small" @click.stop="handleDeleteGroup(row, service.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else-if="!hostGroupLoadingMap[service.id]" description="暂无主机组数据,请先从远程同步" :image-size="60" />
</div>
</div>
</el-collapse-transition>
</div>
</div>
<!-- 编辑本地主机组弹窗 --> <!-- 编辑本地主机组弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑本地主机组" width="480px" destroy-on-close> <el-dialog v-model="editDialogVisible" title="编辑本地主机组" width="480px" destroy-on-close>
<el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="90px"> <el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="90px">
@@ -215,7 +278,7 @@
import { ref, reactive, computed, inject, onMounted, watch } from 'vue' import { ref, reactive, computed, inject, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, RefreshRight, Search, ArrowLeft } from '@element-plus/icons-vue' import { Plus, Refresh, RefreshRight, Search, ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
import { import {
getHostGroupList, getHostGroupList,
syncHostGroup, syncHostGroup,
@@ -237,15 +300,12 @@ const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null) const injectedServiceId = inject('serviceId', null)
const injectedServiceName = inject('serviceName', null) const injectedServiceName = inject('serviceName', null)
const selectedServiceId = ref(parseInt(route.query.service_id) || null) const embeddedServiceId = computed(() => injectedServiceId?.value || 0)
const serviceOptions = ref([])
const serviceId = computed(() => injectedServiceId?.value || selectedServiceId.value || 0) // ==================== 主控服务列表 ====================
const serviceName = computed(() => { const serviceList = ref([])
if (injectedServiceName?.value) return injectedServiceName.value const servicesLoading = ref(false)
const s = serviceOptions.value.find(x => x.id === selectedServiceId.value) const expandedServiceIds = reactive(new Set())
return s?.name || route.query.service_name || ''
})
const normalizeService = (s) => ({ const normalizeService = (s) => ({
id: s.Id ?? s.id, id: s.Id ?? s.id,
@@ -255,59 +315,29 @@ const normalizeService = (s) => ({
note: s.Note ?? s.note note: s.Note ?? s.note
}) })
const loadServiceOptions = async () => { const loadAllServices = async () => {
servicesLoading.value = true
try { try {
const res = await getKvmServiceList({ page: 1, count: 10, key: '' }) const res = await getKvmServiceList({ page: 1, count: 100, key: '' })
if (res?.data?.code === 200 && res?.data?.data) { if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data const inner = res.data.data
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : []) const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
serviceOptions.value = raw.map(normalizeService) serviceList.value = raw.map(normalizeService)
} serviceList.value.forEach(s => {
} catch { /* */ } expandedServiceIds.add(s.id)
} loadHostGroupsForService(s.id)
const handleServiceChange = () => {
hostGroupList.value = []
if (serviceId.value) {
loadHostGroups()
}
}
const handleRefresh = () => {
if (!serviceId.value) {
ElMessage.warning('请先选择主控服务')
return
}
loadHostGroups()
}
const loading = ref(false)
const syncLoading = ref(false)
const hostGroupList = ref([])
const selectedGroup = ref(null)
const treeGroupList = computed(() => {
const items = hostGroupList.value
if (!items.length) return []
const map = new Map()
items.forEach(item => {
map.set(item.remoteId, { ...item, _rowKey: `g-${item.id}`, _children: [], _hasChildren: false })
}) })
const roots = []
map.forEach(item => {
if (item.parentRemoteId && map.has(item.parentRemoteId)) {
const parent = map.get(item.parentRemoteId)
parent._children.push(item)
parent._hasChildren = true
} else {
roots.push(item)
} }
}) } catch { /* */ } finally {
return roots servicesLoading.value = false
}) }
}
// ==================== 每个主控下的主机组数据 ====================
const hostGroupDataMap = reactive({})
const hostGroupLoadingMap = reactive({})
const syncLoadingMap = reactive({})
// 规范化后端 PascalCase 字段为前端 camelCase
// 同时保留原始字段以便在需要时直接访问
const normalizeHostGroup = (item) => { const normalizeHostGroup = (item) => {
if (!item) return item if (!item) return item
return { return {
@@ -325,36 +355,65 @@ const normalizeHostGroup = (item) => {
} }
} }
// ========== 本地主机组列表 ========== const loadHostGroupsForService = async (svcId) => {
const loadHostGroups = async () => { if (!svcId) return
if (!serviceId.value) return hostGroupLoadingMap[svcId] = true
loading.value = true
try { try {
const res = await getHostGroupList({ service_id: serviceId.value }) const res = await getHostGroupList({ service_id: svcId })
const body = res?.data const body = res?.data
console.debug('[HostGroup] list response body:', JSON.stringify(body))
if (body?.code === 200 && body?.data) { if (body?.code === 200 && body?.data) {
// data 可能是直接数组,或 { all_count, data: [...] } 格式
const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || []) const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || [])
hostGroupList.value = items.map(normalizeHostGroup) hostGroupDataMap[svcId] = items.map(normalizeHostGroup)
console.debug('[HostGroup] normalized list:', hostGroupList.value)
} else { } else {
hostGroupList.value = [] hostGroupDataMap[svcId] = []
if (body?.message) { if (body?.message) ElMessage.warning(body.message)
ElMessage.warning(body.message)
}
} }
} catch (error) { } catch (error) {
console.error('获取本地主机组列表失败:', error) console.error('获取本地主机组列表失败:', error)
ElMessage.error('获取本地主机组列表失败') ElMessage.error('获取本地主机组列表失败')
hostGroupDataMap[svcId] = []
} finally { } finally {
loading.value = false hostGroupLoadingMap[svcId] = false
} }
} }
// ========== 同步 ========== const buildTree = (items) => {
const handleSync = async () => { if (!items || !items.length) return []
if (!serviceId.value) { const map = new Map()
items.forEach(item => {
map.set(item.remoteId, { ...item, _rowKey: `g-${item.id}`, _children: [], _hasChildren: false })
})
const roots = []
map.forEach(item => {
if (item.parentRemoteId && map.has(item.parentRemoteId)) {
const parent = map.get(item.parentRemoteId)
parent._children.push(item)
parent._hasChildren = true
} else {
roots.push(item)
}
})
return roots
}
const getTreeGroupList = (svcId) => {
return buildTree(hostGroupDataMap[svcId] || [])
}
const toggleService = (service) => {
if (expandedServiceIds.has(service.id)) {
expandedServiceIds.delete(service.id)
} else {
expandedServiceIds.add(service.id)
if (!hostGroupDataMap[service.id]) {
loadHostGroupsForService(service.id)
}
}
}
// ==================== 同步 ====================
const handleSync = async (svcId) => {
if (!svcId) {
ElMessage.warning('缺少主控服务ID') ElMessage.warning('缺少主控服务ID')
return return
} }
@@ -363,9 +422,9 @@ const handleSync = async () => {
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'info' type: 'info'
}).then(async () => { }).then(async () => {
syncLoading.value = true syncLoadingMap[svcId] = true
try { try {
const res = await syncHostGroup({ service_id: serviceId.value }) const res = await syncHostGroup({ service_id: svcId })
const body = res?.data const body = res?.data
if (body?.code === 200) { if (body?.code === 200) {
const synced = body.data const synced = body.data
@@ -374,19 +433,20 @@ const handleSync = async () => {
} else { } else {
ElMessage.warning(body?.message || '同步返回异常') ElMessage.warning(body?.message || '同步返回异常')
} }
loadHostGroups() loadHostGroupsForService(svcId)
} catch (error) { } catch (error) {
ElMessage.error(extractApiError(error?.response?.data, '同步失败')) ElMessage.error(extractApiError(error?.response?.data, '同步失败'))
} finally { } finally {
syncLoading.value = false syncLoadingMap[svcId] = false
} }
}).catch(() => {}) }).catch(() => {})
} }
// ========== 编辑 ========== // ==================== 编辑 ====================
const editDialogVisible = ref(false) const editDialogVisible = ref(false)
const editSubmitLoading = ref(false) const editSubmitLoading = ref(false)
const editFormRef = ref(null) const editFormRef = ref(null)
const editingServiceId = ref(null)
const editForm = reactive({ const editForm = reactive({
id: undefined, id: undefined,
@@ -399,6 +459,7 @@ const editFormRules = {
} }
const handleEditGroup = (row) => { const handleEditGroup = (row) => {
editingServiceId.value = row.serviceId
Object.assign(editForm, { Object.assign(editForm, {
id: Number(row.Id ?? row.id), id: Number(row.Id ?? row.id),
name: row.Name ?? row.name, name: row.Name ?? row.name,
@@ -421,7 +482,7 @@ const submitEdit = () => {
if (body?.code === 200) { if (body?.code === 200) {
ElMessage.success('修改成功') ElMessage.success('修改成功')
editDialogVisible.value = false editDialogVisible.value = false
loadHostGroups() if (editingServiceId.value) loadHostGroupsForService(editingServiceId.value)
} else { } else {
ElMessage.error(extractApiError(body, '修改失败')) ElMessage.error(extractApiError(body, '修改失败'))
} }
@@ -433,10 +494,11 @@ const submitEdit = () => {
}) })
} }
// ========== 绑定 ========== // ==================== 绑定 ====================
const bindDialogVisible = ref(false) const bindDialogVisible = ref(false)
const bindSubmitLoading = ref(false) const bindSubmitLoading = ref(false)
const bindFormRef = ref(null) const bindFormRef = ref(null)
const bindingServiceId = ref(null)
const bindForm = reactive({ const bindForm = reactive({
id: undefined, id: undefined,
@@ -447,23 +509,19 @@ const bindForm = reactive({
good_id: 0 good_id: 0
}) })
// 选择器弹窗控制
const showGroupSelector = ref(false) const showGroupSelector = ref(false)
const showProductSelector = ref(false) const showProductSelector = ref(false)
// 商品组选中回调
const handleGroupSelected = (group) => { const handleGroupSelected = (group) => {
bindForm.good_group_id = group.id bindForm.good_group_id = group.id
bindForm._groupName = group.name || '' bindForm._groupName = group.name || ''
} }
// 商品选中回调
const handleProductSelected = (product) => { const handleProductSelected = (product) => {
bindForm.good_id = product.id bindForm.good_id = product.id
bindForm._goodName = product.name || '' bindForm._goodName = product.name || ''
} }
// 清除绑定
const clearBindGroup = () => { const clearBindGroup = () => {
bindForm.good_group_id = 0 bindForm.good_group_id = 0
bindForm._groupName = '' bindForm._groupName = ''
@@ -475,6 +533,7 @@ const clearBindProduct = () => {
} }
const handleBind = (row) => { const handleBind = (row) => {
bindingServiceId.value = row.serviceId
Object.assign(bindForm, { Object.assign(bindForm, {
id: Number(row.Id ?? row.id), id: Number(row.Id ?? row.id),
_name: row.Name ?? row.name, _name: row.Name ?? row.name,
@@ -498,7 +557,7 @@ const submitBind = async () => {
if (body?.code === 200) { if (body?.code === 200) {
ElMessage.success('绑定成功') ElMessage.success('绑定成功')
bindDialogVisible.value = false bindDialogVisible.value = false
loadHostGroups() if (bindingServiceId.value) loadHostGroupsForService(bindingServiceId.value)
} else { } else {
ElMessage.error(extractApiError(body, '绑定失败')) ElMessage.error(extractApiError(body, '绑定失败'))
} }
@@ -509,10 +568,11 @@ const submitBind = async () => {
} }
} }
// ========== 生成商品 ========== // ==================== 生成商品 ====================
const generateDialogVisible = ref(false) const generateDialogVisible = ref(false)
const generateSubmitLoading = ref(false) const generateSubmitLoading = ref(false)
const generateFormRef = ref(null) const generateFormRef = ref(null)
const generatingServiceId = ref(null)
const generateForm = reactive({ const generateForm = reactive({
id: undefined, id: undefined,
@@ -527,10 +587,8 @@ const generateFormRules = {
id: [{ required: true, message: '主机组ID不能为空', trigger: 'blur' }] id: [{ required: true, message: '主机组ID不能为空', trigger: 'blur' }]
} }
// 父级商品组选择器
const showGenerateGroupSelector = ref(false) const showGenerateGroupSelector = ref(false)
// 标签选择器
const showGenerateTagSelector = ref(false) const showGenerateTagSelector = ref(false)
const tagOptions = ref([]) const tagOptions = ref([])
const tagLoading = ref(false) const tagLoading = ref(false)
@@ -567,10 +625,10 @@ const fetchTagOptions = async () => {
} catch { /* */ } finally { tagLoading.value = false } } catch { /* */ } finally { tagLoading.value = false }
} }
// 监听标签选择器打开时加载数据
watch(showGenerateTagSelector, (val) => { if (val) loadTagOptions() }) watch(showGenerateTagSelector, (val) => { if (val) loadTagOptions() })
const handleGenerateGoods = (row) => { const handleGenerateGoods = (row) => {
generatingServiceId.value = row.serviceId
Object.assign(generateForm, { Object.assign(generateForm, {
id: Number(row.Id ?? row.id), id: Number(row.Id ?? row.id),
parent_group_id: 0, parent_group_id: 0,
@@ -603,7 +661,7 @@ const submitGenerate = () => {
if (body?.code === 200) { if (body?.code === 200) {
ElMessage.success('商品生成成功') ElMessage.success('商品生成成功')
generateDialogVisible.value = false generateDialogVisible.value = false
loadHostGroups() if (generatingServiceId.value) loadHostGroupsForService(generatingServiceId.value)
} else { } else {
ElMessage.error(extractApiError(body, '商品生成失败')) ElMessage.error(extractApiError(body, '商品生成失败'))
} }
@@ -616,8 +674,8 @@ const submitGenerate = () => {
}) })
} }
// ========== 删除本地主机组 ========== // ==================== 删除本地主机组 ====================
const handleDeleteGroup = (row) => { const handleDeleteGroup = (row, svcId) => {
const rawId = Number(row.Id ?? row.id) const rawId = Number(row.Id ?? row.id)
if (!rawId) { if (!rawId) {
ElMessage.error('无法获取主机组ID') ElMessage.error('无法获取主机组ID')
@@ -633,7 +691,7 @@ const handleDeleteGroup = (row) => {
const body = res?.data const body = res?.data
if (body?.code === 200) { if (body?.code === 200) {
ElMessage.success('删除成功') ElMessage.success('删除成功')
loadHostGroups() if (svcId) loadHostGroupsForService(svcId)
} else { } else {
ElMessage.error(extractApiError(body, '删除失败')) ElMessage.error(extractApiError(body, '删除失败'))
} }
@@ -643,15 +701,16 @@ const handleDeleteGroup = (row) => {
}).catch(() => {}) }).catch(() => {})
} }
// ========== 返回 ========== // ==================== 返回 ====================
const goBack = () => { const goBack = () => {
router.push('/virtualization/kvm-service') router.push('/virtualization/kvm-service')
} }
onMounted(() => { onMounted(() => {
if (!embedded) loadServiceOptions() if (!embedded) {
if (serviceId.value) { loadAllServices()
loadHostGroups() } else if (embeddedServiceId.value) {
loadHostGroupsForService(embeddedServiceId.value)
} }
}) })
</script> </script>
@@ -723,8 +782,76 @@ onMounted(() => {
.panel-body { .panel-body {
padding: 16px; padding: 16px;
min-height: 300px; min-height: 200px;
} }
/* 主控服务卡片 */
.service-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
margin-bottom: 12px;
overflow: hidden;
border: 1px solid #ebeef5;
transition: border-color 0.2s;
}
.service-card:hover {
border-color: #c0c4cc;
}
.service-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
cursor: pointer;
user-select: none;
background: #fafbfc;
transition: background 0.2s;
}
.service-card-header:hover {
background: #f0f2f5;
}
.service-card-title {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.expand-icon {
font-size: 14px;
color: #909399;
transition: transform 0.3s;
flex-shrink: 0;
}
.expand-icon.is-expanded {
transform: rotate(90deg);
}
.service-name {
font-size: 15px;
font-weight: 600;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.service-card-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.service-card-body {
border-top: 1px solid #ebeef5;
}
.host-group-table-wrapper {
padding: 16px;
min-height: 100px;
}
</style> </style>
+111 -7
View File
@@ -159,6 +159,32 @@
</el-form-item> </el-form-item>
</div> </div>
</div> </div>
<div class="tk-section">
<div class="tk-section-title clickable" @click="showDiskIoSection = !showDiskIoSection">
硬盘 IO 限制
<el-icon class="section-arrow" :class="{ expanded: showDiskIoSection }"><ArrowRight /></el-icon>
<span class="section-hint">可选不展开则使用默认值</span>
</div>
<div v-show="showDiskIoSection">
<div class="io-sub-title">
带宽限制
<el-select v-model="ioBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label">
<el-input-number :model-value="+(formData[f.key] / getIoBwFactor()).toFixed(2)" @update:model-value="v => formData[f.key] = Math.round((v || 0) * getIoBwFactor())" :min="0" controls-position="right" />
</el-form-item>
</div>
<div class="io-sub-title">IOPS 限制</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label">
<el-input-number v-model="formData[f.key]" :min="0" controls-position="right" />
</el-form-item>
</div>
</div>
</div>
<div class="tk-section"> <div class="tk-section">
<div class="tk-section-title">其他配置</div> <div class="tk-section-title">其他配置</div>
<el-form-item label="宿主机组"> <el-form-item label="宿主机组">
@@ -271,6 +297,32 @@
</el-form-item> </el-form-item>
</div> </div>
</div> </div>
<div class="tk-section">
<div class="tk-section-title clickable" @click="showTokenDiskIo = !showTokenDiskIo">
硬盘 IO 限制
<el-icon class="section-arrow" :class="{ expanded: showTokenDiskIo }"><ArrowRight /></el-icon>
<span class="section-hint">可选不展开则使用默认值</span>
</div>
<div v-show="showTokenDiskIo">
<div class="io-sub-title">
带宽限制
<el-select v-model="tokenIoBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label" class="tk-res-item">
<el-input-number :model-value="+(tokenForm[f.key] / getTokenIoBwFactor()).toFixed(2)" @update:model-value="v => tokenForm[f.key] = Math.round((v || 0) * getTokenIoBwFactor())" :min="0" controls-position="right" />
</el-form-item>
</div>
<div class="io-sub-title">IOPS 限制</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label" class="tk-res-item">
<el-input-number v-model="tokenForm[f.key]" :min="0" controls-position="right" />
</el-form-item>
</div>
</div>
</div>
<div class="tk-section"> <div class="tk-section">
<div class="tk-section-title">令牌有效期</div> <div class="tk-section-title">令牌有效期</div>
<el-form-item label="有效期" prop="expire_hours"> <el-form-item label="有效期" prop="expire_hours">
@@ -378,7 +430,7 @@
import { ref, reactive, computed, inject, onMounted } from 'vue' import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowLeft, Monitor, Coin, Connection, Key, CopyDocument } from '@element-plus/icons-vue' import { Plus, Refresh, Search, ArrowLeft, ArrowRight, Monitor, Coin, Connection, Key, CopyDocument } from '@element-plus/icons-vue'
import { import {
getRemoteHostList, getRemoteHostDetail, getRemoteHostList, getRemoteHostDetail,
addRemoteHost, updateRemoteHost, deleteRemoteHost, addRemoteHost, updateRemoteHost, deleteRemoteHost,
@@ -418,12 +470,49 @@ const currentDetail = ref(null)
const metricsVisible = ref(false) const metricsVisible = ref(false)
const metricsData = ref(null) const metricsData = ref(null)
const diskIoDefaults = {
read_bytes_sec: 314572800, write_bytes_sec: 314572800,
read_iops_sec: 1000, write_iops_sec: 1000,
read_bytes_sec_max: 314572800, write_bytes_sec_max: 314572800,
read_iops_sec_max: 1000, write_iops_sec_max: 1000
}
const diskIoBwFields = [
{ key: 'read_bytes_sec', label: '读取' },
{ key: 'write_bytes_sec', label: '写入' },
{ key: 'read_bytes_sec_max', label: '突发读取' },
{ key: 'write_bytes_sec_max', label: '突发写入' }
]
const diskIoIopsFields = [
{ key: 'read_iops_sec', label: '读取' },
{ key: 'write_iops_sec', label: '写入' },
{ key: 'read_iops_sec_max', label: '突发读取' },
{ key: 'write_iops_sec_max', label: '突发写入' }
]
const diskIoFields = [
...diskIoBwFields.map(f => ({ ...f, isBandwidth: true })),
...diskIoIopsFields.map(f => ({ ...f, isBandwidth: false }))
]
const ioBwUnitOptions = [
{ label: 'B/s', factor: 1 },
{ label: 'KB/s', factor: 1024 },
{ label: 'MB/s', factor: 1048576 },
{ label: 'GB/s', factor: 1073741824 }
]
const ioBwUnit = ref('MB/s')
const tokenIoBwUnit = ref('MB/s')
const getIoBwFactor = () => ioBwUnitOptions.find(u => u.label === ioBwUnit.value)?.factor || 1048576
const getTokenIoBwFactor = () => ioBwUnitOptions.find(u => u.label === tokenIoBwUnit.value)?.factor || 1048576
const showDiskIoSection = ref(false)
const showTokenDiskIo = ref(false)
const formData = reactive({ const formData = reactive({
id: undefined, name: '', base_url: '', ip: '', token: '', id: undefined, name: '', base_url: '', ip: '', token: '',
port: 22, user: '', password: '', private_key: '', port: 22, user: '', password: '', private_key: '',
max_cpu: 0, max_memory: 0, max_disk: 0, max_cpu: 0, max_memory: 0, max_disk: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
_groupName: '' _groupName: '',
...diskIoDefaults
}) })
const formRules = { const formRules = {
@@ -522,8 +611,10 @@ const resetForm = () => {
port: 22, user: '', password: '', private_key: '', port: 22, user: '', password: '', private_key: '',
max_cpu: 0, max_memory: 0, max_disk: 0, max_cpu: 0, max_memory: 0, max_disk: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
_groupName: '' _groupName: '', ...diskIoDefaults
}) })
showDiskIoSection.value = false
ioBwUnit.value = 'MB/s'
} }
const handleHostGroupSelected = (group) => { const handleHostGroupSelected = (group) => {
@@ -585,9 +676,10 @@ const handleEdit = (row) => {
max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0, max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0,
rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0, rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0,
host_group_id: row.host_group_id || 0, description: row.description || '', host_group_id: row.host_group_id || 0, description: row.description || '',
_groupName: getGroupName(row.host_group_id) _groupName: getGroupName(row.host_group_id),
...Object.fromEntries(diskIoFields.map(f => [f.key, row[f.key] ?? diskIoDefaults[f.key]]))
}) })
// password showDiskIoSection.value = diskIoFields.some(f => row[f.key] && row[f.key] !== diskIoDefaults[f.key])
getRemoteHostDetail({ service_id: serviceId.value, id: row.id }).then(res => { getRemoteHostDetail({ service_id: serviceId.value, id: row.id }).then(res => {
const body = res?.data const body = res?.data
if (body?.code === 200 && body?.data) { if (body?.code === 200 && body?.data) {
@@ -595,6 +687,7 @@ const handleEdit = (row) => {
if (detail.password) formData.password = detail.password if (detail.password) formData.password = detail.password
if (detail.token) formData.token = detail.token if (detail.token) formData.token = detail.token
if (detail.private_key) formData.private_key = detail.private_key if (detail.private_key) formData.private_key = detail.private_key
diskIoFields.forEach(f => { if (detail[f.key] !== undefined) formData[f.key] = detail[f.key] })
} }
}).catch(() => {}) }).catch(() => {})
dialogVisible.value = true dialogVisible.value = true
@@ -719,7 +812,8 @@ const tokenForm = reactive({
max_memory: 4194304, max_disk: 100, max_memory: 4194304, max_disk: 100,
rx_bandwidth: 100, tx_bandwidth: 100, rx_bandwidth: 100, tx_bandwidth: 100,
description: '', expire_hours: 24, description: '', expire_hours: 24,
_groupName: '' _groupName: '',
...diskIoDefaults
}) })
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 }) const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
@@ -750,10 +844,13 @@ const openTokenDialog = () => {
name: '', host_group_id: 0, max_cpu: 4, name: '', host_group_id: 0, max_cpu: 4,
max_memory: 4194304, max_disk: 100, max_memory: 4194304, max_disk: 100,
rx_bandwidth: 100, tx_bandwidth: 100, rx_bandwidth: 100, tx_bandwidth: 100,
description: '', expire_hours: 24, _groupName: '' description: '', expire_hours: 24, _groupName: '',
...diskIoDefaults
}) })
tokenMemUnit.value = 'GB' tokenMemUnit.value = 'GB'
tokenDiskUnit.value = 'GB' tokenDiskUnit.value = 'GB'
showTokenDiskIo.value = false
tokenIoBwUnit.value = 'MB/s'
tokenDialogVisible.value = true tokenDialogVisible.value = true
} }
@@ -776,6 +873,7 @@ const handleTokenSubmit = () => {
fd.append('max_disk', tokenForm.max_disk) fd.append('max_disk', tokenForm.max_disk)
fd.append('rx_bandwidth', tokenForm.rx_bandwidth) fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
fd.append('tx_bandwidth', tokenForm.tx_bandwidth) fd.append('tx_bandwidth', tokenForm.tx_bandwidth)
diskIoFields.forEach(f => { if (tokenForm[f.key] !== undefined) fd.append(f.key, tokenForm[f.key]) })
fd.append('description', tokenForm.description || '') fd.append('description', tokenForm.description || '')
fd.append('expire_hours', tokenForm.expire_hours) fd.append('expire_hours', tokenForm.expire_hours)
@@ -832,4 +930,10 @@ onMounted(() => {
.metrics-card { margin-bottom: 12px; } .metrics-card { margin-bottom: 12px; }
.metrics-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; } .metrics-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
.metrics-title .el-icon { font-size: 16px; color: #409eff; } .metrics-title .el-icon { font-size: 16px; color: #409eff; }
.tk-section-title.clickable { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
.tk-section-title.clickable:hover { color: #409eff; }
.section-arrow { transition: transform 0.2s; font-size: 14px; }
.section-arrow.expanded { transform: rotate(90deg); }
.section-hint { font-size: 12px; color: #909399; font-weight: 400; }
.io-sub-title { font-size: 13px; font-weight: 500; color: #606266; margin: 12px 0 8px; display: flex; align-items: center; }
</style> </style>
+113 -6
View File
@@ -131,6 +131,32 @@
</el-form-item> </el-form-item>
</div> </div>
</div> </div>
<div class="tk-section">
<div class="tk-section-title clickable" @click="showTokenDiskIo = !showTokenDiskIo">
硬盘 IO 限制
<el-icon class="section-arrow" :class="{ expanded: showTokenDiskIo }"><ArrowRight /></el-icon>
<span class="section-hint">可选不展开则使用默认值</span>
</div>
<div v-show="showTokenDiskIo">
<div class="io-sub-title">
带宽限制
<el-select v-model="tokenIoBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label" class="tk-res-item">
<el-input-number :model-value="+(tokenForm[f.key] / getTokenIoBwFactor()).toFixed(2)" @update:model-value="v => tokenForm[f.key] = Math.round((v || 0) * getTokenIoBwFactor())" :min="0" controls-position="right" />
</el-form-item>
</div>
<div class="io-sub-title">IOPS 限制</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label" class="tk-res-item">
<el-input-number v-model="tokenForm[f.key]" :min="0" controls-position="right" />
</el-form-item>
</div>
</div>
</div>
<div class="tk-section"> <div class="tk-section">
<div class="tk-section-title">令牌有效期</div> <div class="tk-section-title">令牌有效期</div>
<el-form-item label="有效期" prop="expire_hours"> <el-form-item label="有效期" prop="expire_hours">
@@ -326,6 +352,32 @@
</el-form-item> </el-form-item>
</div> </div>
</div> </div>
<div class="tk-section">
<div class="tk-section-title clickable" @click="showHostDiskIo = !showHostDiskIo">
硬盘 IO 限制
<el-icon class="section-arrow" :class="{ expanded: showHostDiskIo }"><ArrowRight /></el-icon>
<span class="section-hint">可选不展开则使用默认值</span>
</div>
<div v-show="showHostDiskIo">
<div class="io-sub-title">
带宽限制
<el-select v-model="hostIoBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label">
<el-input-number :model-value="+(hostForm[f.key] / getHostIoBwFactor()).toFixed(2)" @update:model-value="v => hostForm[f.key] = Math.round((v || 0) * getHostIoBwFactor())" :min="0" controls-position="right" />
</el-form-item>
</div>
<div class="io-sub-title">IOPS 限制</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label">
<el-input-number v-model="hostForm[f.key]" :min="0" controls-position="right" />
</el-form-item>
</div>
</div>
</div>
<div class="tk-section"> <div class="tk-section">
<div class="tk-section-title">其他配置</div> <div class="tk-section-title">其他配置</div>
<el-form-item label="宿主机组"> <el-form-item label="宿主机组">
@@ -625,6 +677,42 @@ const handleOptimalHost = async (row) => {
} }
// ---- 宿 CRUD ---- // ---- 宿 CRUD ----
const diskIoDefaults = {
read_bytes_sec: 314572800, write_bytes_sec: 314572800,
read_iops_sec: 1000, write_iops_sec: 1000,
read_bytes_sec_max: 314572800, write_bytes_sec_max: 314572800,
read_iops_sec_max: 1000, write_iops_sec_max: 1000
}
const diskIoBwFields = [
{ key: 'read_bytes_sec', label: '读取' },
{ key: 'write_bytes_sec', label: '写入' },
{ key: 'read_bytes_sec_max', label: '突发读取' },
{ key: 'write_bytes_sec_max', label: '突发写入' }
]
const diskIoIopsFields = [
{ key: 'read_iops_sec', label: '读取' },
{ key: 'write_iops_sec', label: '写入' },
{ key: 'read_iops_sec_max', label: '突发读取' },
{ key: 'write_iops_sec_max', label: '突发写入' }
]
const diskIoFields = [
...diskIoBwFields.map(f => ({ ...f, isBandwidth: true })),
...diskIoIopsFields.map(f => ({ ...f, isBandwidth: false }))
]
const ioBwUnitOptions = [
{ label: 'B/s', factor: 1 },
{ label: 'KB/s', factor: 1024 },
{ label: 'MB/s', factor: 1048576 },
{ label: 'GB/s', factor: 1073741824 }
]
const hostIoBwUnit = ref('MB/s')
const tokenIoBwUnit = ref('MB/s')
const getHostIoBwFactor = () => ioBwUnitOptions.find(u => u.label === hostIoBwUnit.value)?.factor || 1048576
const getTokenIoBwFactor = () => ioBwUnitOptions.find(u => u.label === tokenIoBwUnit.value)?.factor || 1048576
const showHostDiskIo = ref(false)
const showTokenDiskIo = ref(false)
const hostDialogVisible = ref(false) const hostDialogVisible = ref(false)
const hostDialogType = ref('add') const hostDialogType = ref('add')
const hostFormRef = ref(null) const hostFormRef = ref(null)
@@ -632,7 +720,8 @@ const hostForm = reactive({
id: undefined, name: '', base_url: '', ip: '', token: '', id: undefined, name: '', base_url: '', ip: '', token: '',
port: 22, user: '', password: '', private_key: '', port: 22, user: '', password: '', private_key: '',
max_cpu: 0, max_memory: 0, max_disk: 0, max_cpu: 0, max_memory: 0, max_disk: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '' rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
...diskIoDefaults
}) })
const hostFormRules = { const hostFormRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }], name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
@@ -642,13 +731,17 @@ const hostFormRules = {
const handleAddHost = () => { const handleAddHost = () => {
hostDialogType.value = 'add' hostDialogType.value = 'add'
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '' }) Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', ...diskIoDefaults })
showHostDiskIo.value = false
hostIoBwUnit.value = 'MB/s'
hostDialogVisible.value = true hostDialogVisible.value = true
} }
const handleAddHostToGroup = (group) => { const handleAddHostToGroup = (group) => {
hostDialogType.value = 'add' hostDialogType.value = 'add'
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: group.id, description: '' }) Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: group.id, description: '', ...diskIoDefaults })
showHostDiskIo.value = false
hostIoBwUnit.value = 'MB/s'
hostDialogVisible.value = true hostDialogVisible.value = true
} }
@@ -659,14 +752,17 @@ const handleEditHost = (row) => {
port: row.port || 22, user: row.user || '', password: row.password || '', private_key: row.private_key || '', port: row.port || 22, user: row.user || '', password: row.password || '', private_key: row.private_key || '',
max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0, max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0,
rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0, rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0,
host_group_id: row.host_group_id || 0, description: row.description || '' host_group_id: row.host_group_id || 0, description: row.description || '',
...Object.fromEntries(diskIoFields.map(f => [f.key, row[f.key] ?? diskIoDefaults[f.key]]))
}) })
showHostDiskIo.value = diskIoFields.some(f => row[f.key] && row[f.key] !== diskIoDefaults[f.key])
getRemoteHostDetail({ service_id: serviceId.value, id: row.id }).then(res => { getRemoteHostDetail({ service_id: serviceId.value, id: row.id }).then(res => {
if (res?.data?.code === 200 && res?.data?.data) { if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data.host ?? res.data.data.data ?? res.data.data const d = res.data.data.host ?? res.data.data.data ?? res.data.data
if (d.password) hostForm.password = d.password if (d.password) hostForm.password = d.password
if (d.token) hostForm.token = d.token if (d.token) hostForm.token = d.token
if (d.private_key) hostForm.private_key = d.private_key if (d.private_key) hostForm.private_key = d.private_key
diskIoFields.forEach(f => { if (d[f.key] !== undefined) hostForm[f.key] = d[f.key] })
} }
}).catch(() => {}) }).catch(() => {})
hostDialogVisible.value = true hostDialogVisible.value = true
@@ -723,7 +819,8 @@ const tokenForm = reactive({
name: '', host_group_id: 0, max_cpu: 4, name: '', host_group_id: 0, max_cpu: 4,
max_memory: 4194304, max_disk: 100, max_memory: 4194304, max_disk: 100,
rx_bandwidth: 100, tx_bandwidth: 100, rx_bandwidth: 100, tx_bandwidth: 100,
description: '', expire_hours: 24 description: '', expire_hours: 24,
...diskIoDefaults
}) })
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 }) const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
const tokenRules = { const tokenRules = {
@@ -751,10 +848,13 @@ const openTokenDialog = () => {
name: '', host_group_id: 0, max_cpu: 4, name: '', host_group_id: 0, max_cpu: 4,
max_memory: 4194304, max_disk: 100, max_memory: 4194304, max_disk: 100,
rx_bandwidth: 100, tx_bandwidth: 100, rx_bandwidth: 100, tx_bandwidth: 100,
description: '', expire_hours: 24 description: '', expire_hours: 24,
...diskIoDefaults
}) })
tokenMemUnit.value = 'GB' tokenMemUnit.value = 'GB'
tokenDiskUnit.value = 'GB' tokenDiskUnit.value = 'GB'
showTokenDiskIo.value = false
tokenIoBwUnit.value = 'MB/s'
tokenDialogVisible.value = true tokenDialogVisible.value = true
} }
@@ -772,6 +872,7 @@ const handleTokenSubmit = () => {
fd.append('max_disk', tokenForm.max_disk) fd.append('max_disk', tokenForm.max_disk)
fd.append('rx_bandwidth', tokenForm.rx_bandwidth) fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
fd.append('tx_bandwidth', tokenForm.tx_bandwidth) fd.append('tx_bandwidth', tokenForm.tx_bandwidth)
diskIoFields.forEach(f => { if (tokenForm[f.key] !== undefined) fd.append(f.key, tokenForm[f.key]) })
fd.append('description', tokenForm.description || '') fd.append('description', tokenForm.description || '')
fd.append('expire_hours', tokenForm.expire_hours) fd.append('expire_hours', tokenForm.expire_hours)
const res = await createHostToken(fd) const res = await createHostToken(fd)
@@ -839,4 +940,10 @@ onMounted(() => { if (serviceId.value) loadTreeData() })
.host-addr { color: #409eff; font-size: 13px; } .host-addr { color: #409eff; font-size: 13px; }
.host-url { color: #909399; font-size: 12px; } .host-url { color: #909399; font-size: 12px; }
.tk-section-title.clickable { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
.tk-section-title.clickable:hover { color: #409eff; }
.section-arrow { transition: transform 0.2s; font-size: 14px; }
.section-arrow.expanded { transform: rotate(90deg); }
.section-hint { font-size: 12px; color: #909399; font-weight: 400; }
.io-sub-title { font-size: 13px; font-weight: 500; color: #606266; margin: 12px 0 8px; display: flex; align-items: center; }
</style> </style>
+298 -17
View File
@@ -30,6 +30,9 @@
<el-dropdown-item divided command="editVm" :disabled="isMigrating">修改虚拟机</el-dropdown-item> <el-dropdown-item divided command="editVm" :disabled="isMigrating">修改虚拟机</el-dropdown-item>
<el-dropdown-item command="refactorVm" :disabled="isMigrating">重构虚拟机</el-dropdown-item> <el-dropdown-item command="refactorVm" :disabled="isMigrating">重构虚拟机</el-dropdown-item>
<el-dropdown-item command="updateTraffic" :disabled="isMigrating">修改带宽</el-dropdown-item> <el-dropdown-item command="updateTraffic" :disabled="isMigrating">修改带宽</el-dropdown-item>
<el-dropdown-item command="resetMac" :disabled="isMigrating">重置MAC地址</el-dropdown-item>
<el-dropdown-item command="disconnectNetwork" :disabled="isMigrating || detail.network_disabled">断网</el-dropdown-item>
<el-dropdown-item command="connectNetwork" :disabled="isMigrating || !detail.network_disabled">恢复网络</el-dropdown-item>
<el-dropdown-item divided command="rebuild" :disabled="isMigrating">重装虚拟机</el-dropdown-item> <el-dropdown-item divided command="rebuild" :disabled="isMigrating">重装虚拟机</el-dropdown-item>
<el-dropdown-item command="rescue">救援模式</el-dropdown-item> <el-dropdown-item command="rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exitRescue">退出救援</el-dropdown-item> <el-dropdown-item command="exitRescue">退出救援</el-dropdown-item>
@@ -50,6 +53,7 @@
<span class="status-value"> <span class="status-value">
<span class="status-dot" :class="detail.status === 'running' ? 'dot-running' : 'dot-other'"></span> <span class="status-dot" :class="detail.status === 'running' ? 'dot-running' : 'dot-other'"></span>
{{ vmStatusLabel(detail.status) }} {{ vmStatusLabel(detail.status) }}
<el-tag v-if="detail.network_disabled" type="danger" size="small" effect="dark" style="margin-left:8px">已断网</el-tag>
<el-tag v-if="isMigrating" type="warning" size="small" effect="dark" style="margin-left:8px">迁移中</el-tag> <el-tag v-if="isMigrating" type="warning" size="small" effect="dark" style="margin-left:8px">迁移中</el-tag>
</span> </span>
</div> </div>
@@ -221,8 +225,23 @@
</div> </div>
<div class="config-row"> <div class="config-row">
<div class="config-cell"> <div class="config-cell">
<span class="config-label">流量上限</span> <span class="config-label">流量</span>
<span class="config-value">{{ formatTrafficMax(detail.traffic_max) }}</span> <span class="config-value traffic-cell">
<span class="traffic-main">{{ formatTrafficMb(detailTrafficUsedMb) }} / {{ formatTrafficMb(detailTrafficTotalMb) }}</span>
<span class="traffic-sub">
基础 {{ formatTrafficMb(detailTrafficBaseMb) }}
<template v-if="detailTrafficTempMb > 0">
· 临时 {{ formatTrafficMb(detailTrafficTempMb) }}
<span v-if="detailTrafficCycleStartText" class="traffic-cycle">周期 {{ detailTrafficCycleStartText }}</span>
</template>
</span>
<span class="traffic-actions">
<el-button link type="primary" size="small" class="cfg-edit-btn" @click="openVmTrafficPolicyDialog" :disabled="isMigrating">
<el-icon :size="14"><Edit /></el-icon>修改
</el-button>
<el-button link type="warning" size="small" class="cfg-edit-btn" @click="openVmAddTrafficDialog('temporary')" :disabled="isMigrating">加临时</el-button>
</span>
</span>
</div> </div>
<div class="config-cell"> <div class="config-cell">
<span class="config-label">快照配额</span> <span class="config-label">快照配额</span>
@@ -265,6 +284,11 @@
<el-table-column prop="gateway" label="网关" min-width="120" /> <el-table-column prop="gateway" label="网关" min-width="120" />
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip /> <el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
<el-table-column prop="mac_address" label="MAC地址" min-width="150" show-overflow-tooltip /> <el-table-column prop="mac_address" label="MAC地址" min-width="150" show-overflow-tooltip />
<el-table-column label="主IP" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_primary" type="success" size="small" effect="dark"></el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="80"> <el-table-column label="类型" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.type === 'bridge' ? '网桥' : 'NAT' }}</el-tag> <el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.type === 'bridge' ? '网桥' : 'NAT' }}</el-tag>
@@ -272,10 +296,11 @@
</el-table-column> </el-table-column>
<el-table-column prop="bridge_name" label="网桥" width="100" /> <el-table-column prop="bridge_name" label="网桥" width="100" />
<el-table-column prop="target_device" label="目标设备" width="100" show-overflow-tooltip /> <el-table-column prop="target_device" label="目标设备" width="100" show-overflow-tooltip />
<el-table-column label="操作" width="180" fixed="right"> <el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="handleNetDetail(row)">详情</el-button> <el-button link type="primary" size="small" @click="handleNetDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="handleNetEdit(row)">编辑</el-button> <el-button link type="primary" size="small" @click="handleNetEdit(row)">编辑</el-button>
<el-button v-if="!row.is_primary" link type="warning" size="small" @click="handleSetPrimary(row)">设为主IP</el-button>
<el-button link type="danger" size="small" @click="handleNetDelete(row)">删除</el-button> <el-button link type="danger" size="small" @click="handleNetDelete(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -606,6 +631,11 @@
<div class="metric-summary-value">{{ formatNetLabel(latestMetrics.net_rx) }}</div> <div class="metric-summary-value">{{ formatNetLabel(latestMetrics.net_rx) }}</div>
<div class="metric-summary-sub">{{ formatNetLabel(latestMetrics.net_tx) }}</div> <div class="metric-summary-sub">{{ formatNetLabel(latestMetrics.net_tx) }}</div>
</div> </div>
<div class="metric-summary-card">
<div class="metric-summary-label">累计流量</div>
<div class="metric-summary-value">{{ latestMetrics.traffic_used_mb != null ? latestMetrics.traffic_used_mb + ' MB' : '-' }}</div>
<div class="metric-summary-sub">&nbsp;</div>
</div>
</div> </div>
</template> </template>
<template v-if="historicalMetricsData"> <template v-if="historicalMetricsData">
@@ -618,7 +648,7 @@
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-card shadow="hover" class="metrics-card"> <el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 内存使用</span></template> <template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 内存使用</span></template>
<div ref="memChartRef" class="chart-container"></div> <div ref="memChartRef" class="chart-container"></div>
</el-card> </el-card>
</el-col> </el-col>
@@ -637,13 +667,27 @@
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘 IOPS</span></template>
<div ref="diskIopsChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 流量使用趋势</span></template>
<div ref="trafficUsedChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
</template> </template>
<el-empty v-else-if="!historicalMetricsLoading" description="加载监控数据中..." :image-size="80" /> <el-empty v-else-if="!historicalMetricsLoading" description="加载监控数据中..." :image-size="80" />
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- 流量策略 --> <!-- 流量管理合并流量策略 + 流量统计 -->
<el-tab-pane label="流量策略" name="vmTrafficPolicy"> <el-tab-pane label="流量管理" name="trafficManage">
<div class="section-block"> <div class="section-block">
<div class="section-header"> <div class="section-header">
<h3 class="section-title">流量策略</h3> <h3 class="section-title">流量策略</h3>
@@ -661,6 +705,30 @@
</el-descriptions> </el-descriptions>
<el-empty v-else-if="!vmTrafficPolicyLoading" description="暂无流量策略数据" :image-size="60" /> <el-empty v-else-if="!vmTrafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
</div> </div>
<div class="section-block">
<div class="section-header">
<h3 class="section-title">每小时流量</h3>
<div style="display: flex; align-items: center; gap: 8px">
<el-date-picker
v-model="trafficHourlyRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
size="small"
style="width: 360px"
:shortcuts="monitorShortcuts"
@change="loadTrafficHourly"
/>
<el-button size="small" :icon="Refresh" @click="loadTrafficHourly" :loading="trafficHourlyLoading">刷新</el-button>
</div>
</div>
<el-card v-if="trafficHourlyData.length" shadow="hover" class="metrics-card" style="margin-top:12px">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 每小时流量MB</span></template>
<div ref="trafficHourlyChartRef" class="chart-container"></div>
</el-card>
<el-empty v-else-if="!trafficHourlyLoading" description="暂无流量统计数据" :image-size="60" />
</div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@@ -1564,7 +1632,7 @@
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, nextTick, watch } from 'vue' import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, ArrowDown, Plus, Search, WarningFilled, Loading } from '@element-plus/icons-vue' import { ArrowLeft, Refresh, ArrowDown, Plus, Search, WarningFilled, Loading, Edit } from '@element-plus/icons-vue'
import { import {
getVmDetail, getVmStatus, getVmDetail, getVmStatus,
startVm, stopVm, rebootVm, suspendVm, resumeVm, startVm, stopVm, rebootVm, suspendVm, resumeVm,
@@ -1583,7 +1651,9 @@ import {
migrateVm, getRemoteHostGroupList, getRemoteHostDetail, migrateVm, getRemoteHostGroupList, getRemoteHostDetail,
dataMigrateVm, getDataMigrateProgress, abortDataMigrate, dataMigrateVm, getDataMigrateProgress, abortDataMigrate,
getKvmServiceList, getMetricsHistory, getNetworkList, getKvmServiceList, getMetricsHistory, getNetworkList,
getVmTrafficPolicy, updateVmTrafficPolicy, addVmFixedTraffic, addVmTemporaryTraffic getVmTrafficPolicy, updateVmTrafficPolicy, addVmFixedTraffic, addVmTemporaryTraffic,
setNetworkPrimary, resetVmMac,
disconnectVmNetwork, connectVmNetwork, getVmTrafficHourly
} from '@/api/admin/kvmService' } from '@/api/admin/kvmService'
import { getUserInfo } from '@/api/admin/user' import { getUserInfo } from '@/api/admin/user'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
@@ -1644,6 +1714,33 @@ const formatTrafficMax = (val) => {
return `${gb.toFixed(2)} GB` return `${gb.toFixed(2)} GB`
} }
// MB formatTrafficMax 0 /
const formatTrafficMb = (mb) => {
const n = Number(mb)
if (!Number.isFinite(n) || n <= 0) return '0 MB'
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} TB`
if (n >= 1024) return `${(n / 1024).toFixed(2)} GB`
return `${n} MB`
}
// 使 add.json traffic_max_mb / temporary_traffic_mbfallback traffic_max
// traffic_used MB traffic_max_mb
const detailTrafficBaseMb = computed(() => Number(detail.value?.traffic_max_mb ?? detail.value?.traffic_max ?? 0) || 0)
const detailTrafficTempMb = computed(() => Number(detail.value?.temporary_traffic_mb || 0))
const detailTrafficTotalMb = computed(() => detailTrafficBaseMb.value + detailTrafficTempMb.value)
const detailTrafficUsedMb = computed(() => Number(detail.value?.traffic_used || 0))
const detailTrafficCycleStartText = computed(() => {
const t = detail.value?.temporary_cycle_start
const sec = typeof t === 'object' ? (t?.seconds ?? null) : t
if (!sec) return ''
const d = new Date(Number(sec) * 1000)
if (isNaN(d.getTime())) return ''
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
})
const copyAllIps = (ipList) => { const copyAllIps = (ipList) => {
if (!ipList?.length) return if (!ipList?.length) return
const text = ipList.join('\n') const text = ipList.join('\n')
@@ -1694,7 +1791,8 @@ const handleMoreCommand = (cmd) => {
const actionMap = { const actionMap = {
editVm: handleEditVm, refactorVm: handleRefactorVm, updateTraffic: handleUpdateTraffic, editVm: handleEditVm, refactorVm: handleRefactorVm, updateTraffic: handleUpdateTraffic,
rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue, rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue,
migrateVm: handleMigrateVm migrateVm: handleMigrateVm, resetMac: handleResetMac,
disconnectNetwork: handleDisconnectNetwork, connectNetwork: handleConnectNetwork
} }
if (actionMap[cmd]) actionMap[cmd]() if (actionMap[cmd]) actionMap[cmd]()
if (cmd === 'dataMigrateVm') handleDataMigrateVm() if (cmd === 'dataMigrateVm') handleDataMigrateVm()
@@ -1800,10 +1898,14 @@ const cpuChartRef = ref(null)
const memChartRef = ref(null) const memChartRef = ref(null)
const netChartRef = ref(null) const netChartRef = ref(null)
const diskChartRef = ref(null) const diskChartRef = ref(null)
const diskIopsChartRef = ref(null)
const trafficUsedChartRef = ref(null)
let cpuChart = null let cpuChart = null
let memChart = null let memChart = null
let netChart = null let netChart = null
let diskChart = null let diskChart = null
let diskIopsChart = null
let trafficUsedChart = null
let isPageActive = false let isPageActive = false
const historicalMetricsData = ref(null) const historicalMetricsData = ref(null)
@@ -1931,9 +2033,28 @@ const renderHistoricalCharts = () => {
}) })
const cpuData = metrics.map(m => m.cpu_usage ?? 0) const cpuData = metrics.map(m => m.cpu_usage ?? 0)
const memData = metrics.map(m => m.mem_total ? ((m.mem_used / m.mem_total) * 100) : 0) const memData = metrics.map(m => m.mem_used ?? 0)
const memTotal = Math.max(...metrics.map(m => m.mem_total ?? 0))
const memAxisFmt = memTotal >= 1048576
? (v) => (v / 1048576).toFixed(1) + ' GB'
: memTotal >= 1024
? (v) => (v / 1024).toFixed(0) + ' MB'
: (v) => v + ' KB'
const diskReadData = metrics.map(m => m.disk_read ?? 0) const diskReadData = metrics.map(m => m.disk_read ?? 0)
const diskWriteData = metrics.map(m => m.disk_write ?? 0) const diskWriteData = metrics.map(m => m.disk_write ?? 0)
const diskReadRate = []
const diskWriteRate = []
for (let i = 0; i < metrics.length; i++) {
if (i === 0) { diskReadRate.push(0); diskWriteRate.push(0); continue }
const dt = (new Date(metrics[i].bucket) - new Date(metrics[i - 1].bucket)) / 1000
if (dt > 0) {
diskReadRate.push(+Math.max(0, ((metrics[i].disk_read ?? 0) - (metrics[i - 1].disk_read ?? 0)) / dt / 1024).toFixed(2))
diskWriteRate.push(+Math.max(0, ((metrics[i].disk_write ?? 0) - (metrics[i - 1].disk_write ?? 0)) / dt / 1024).toFixed(2))
} else { diskReadRate.push(0); diskWriteRate.push(0) }
}
const netRxData = metrics.map(m => m.net_rx ?? 0) const netRxData = metrics.map(m => m.net_rx ?? 0)
const netTxData = metrics.map(m => m.net_tx ?? 0) const netTxData = metrics.map(m => m.net_tx ?? 0)
@@ -1954,9 +2075,9 @@ const renderHistoricalCharts = () => {
if (memChartRef.value) { if (memChartRef.value) {
if (!memChart) memChart = echarts.init(memChartRef.value) if (!memChart) memChart = echarts.init(memChartRef.value)
memChart.setOption({ memChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` }, tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${formatMemKB(p[0].value)}` },
grid: baseGrid, xAxis: makeXAxis(), grid: { ...baseGrid, left: 60 }, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } }, yAxis: { type: 'value', min: 0, max: memTotal || undefined, axisLabel: { fontSize: 10, formatter: memAxisFmt } },
series: [makeSeries('内存', memData, '#67c23a')] series: [makeSeries('内存', memData, '#67c23a')]
}, true) }, true)
} }
@@ -1975,6 +2096,20 @@ const renderHistoricalCharts = () => {
}, true) }, true)
} }
if (diskIopsChartRef.value) {
if (!diskIopsChart) diskIopsChart = echarts.init(diskIopsChartRef.value)
diskIopsChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => {
let s = params[0].axisValue
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatBytesRaw(p.value)}/s` })
return s
}},
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatBytesRaw(v) + '/s' } },
series: [makeSeries('读取', diskReadRate, '#409eff'), makeSeries('写入', diskWriteRate, '#e6a23c')]
}, true)
}
if (netChartRef.value) { if (netChartRef.value) {
if (!netChart) netChart = echarts.init(netChartRef.value) if (!netChart) netChart = echarts.init(netChartRef.value)
netChart.setOption({ netChart.setOption({
@@ -1988,6 +2123,22 @@ const renderHistoricalCharts = () => {
series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')] series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')]
}, true) }, true)
} }
if (trafficUsedChartRef.value) {
if (!trafficUsedChart) trafficUsedChart = echarts.init(trafficUsedChartRef.value)
const trafficDelta = []
for (let i = 0; i < metrics.length; i++) {
if (i === 0) { trafficDelta.push(0); continue }
const delta = Math.max(0, (metrics[i].traffic_used_mb ?? 0) - (metrics[i - 1].traffic_used_mb ?? 0))
trafficDelta.push(+delta.toFixed(2))
}
trafficUsedChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 流量增量: ${p[0].value} MB` },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => v + ' MB' } },
series: [makeSeries('流量增量', trafficDelta, '#722ed1')]
}, true)
}
} }
const disposeCharts = () => { const disposeCharts = () => {
@@ -1995,6 +2146,9 @@ const disposeCharts = () => {
memChart?.dispose(); memChart = null memChart?.dispose(); memChart = null
netChart?.dispose(); netChart = null netChart?.dispose(); netChart = null
diskChart?.dispose(); diskChart = null diskChart?.dispose(); diskChart = null
diskIopsChart?.dispose(); diskIopsChart = null
trafficUsedChart?.dispose(); trafficUsedChart = null
trafficHourlyChart?.dispose(); trafficHourlyChart = null
} }
const powerDialogVisible = ref(false) const powerDialogVisible = ref(false)
@@ -2287,12 +2441,15 @@ const loadVmTrafficPolicy = async () => {
} }
const openVmTrafficPolicyDialog = () => { const openVmTrafficPolicyDialog = () => {
// vmTrafficPolicy tab
// detail.value add.json fallback
Object.assign(vmTrafficPolicyForm, { Object.assign(vmTrafficPolicyForm, {
traffic_max_mb: vmTrafficPolicy.value?.traffic_max_mb || 0, traffic_max_mb: vmTrafficPolicy.value?.traffic_max_mb ?? detail.value?.traffic_max_mb ?? detail.value?.traffic_max ?? 0,
exhausted_rx_mbps: vmTrafficPolicy.value?.exhausted_rx_mbps || 0, exhausted_rx_mbps: vmTrafficPolicy.value?.exhausted_rx_mbps ?? detail.value?.traffic_exhausted_rx_mbps ?? 0,
exhausted_tx_mbps: vmTrafficPolicy.value?.exhausted_tx_mbps || 0 exhausted_tx_mbps: vmTrafficPolicy.value?.exhausted_tx_mbps ?? detail.value?.traffic_exhausted_tx_mbps ?? 0
}) })
vmTrafficPolicyVisible.value = true vmTrafficPolicyVisible.value = true
if (!vmTrafficPolicy.value) loadVmTrafficPolicy()
} }
const submitUpdateVmTrafficPolicy = async () => { const submitUpdateVmTrafficPolicy = async () => {
@@ -2780,6 +2937,122 @@ const handleNetDelete = (row) => {
}).catch(() => {}) }).catch(() => {})
} }
const handleSetPrimary = (row) => {
ElMessageBox.confirm(
`将「${row.address || row.name}」设为主IP?设置后将重启虚拟机。`,
'设置主IP', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('network_id', row.id)
fd.append('host_id', row.host_id || detail.value?.host_id)
const res = await setNetworkPrimary(fd)
if (res?.data?.code === 200) { ElMessage.success('设置主IP成功,虚拟机将重启'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '设置主IP失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '设置主IP失败')) }
}).catch(() => {})
}
const handleResetMac = () => {
ElMessageBox.confirm(
'重置MAC地址将重建 CloudInit 并重启虚拟机,确定继续?',
'重置MAC地址', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', detail.value?.id)
const res = await resetVmMac(fd)
if (res?.data?.code === 200) { ElMessage.success('MAC地址重置成功,虚拟机将重启'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '重置MAC失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重置MAC失败')) }
}).catch(() => {})
}
const handleDisconnectNetwork = () => {
ElMessageBox.confirm(
'断开虚拟机外部网络连接?断网后虚拟机将无法访问外部网络。',
'断网', { confirmButtonText: '确定断网', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', detail.value?.id)
const res = await disconnectVmNetwork(fd)
if (res?.data?.code === 200) { ElMessage.success('已断网'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '断网失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '断网失败')) }
}).catch(() => {})
}
const handleConnectNetwork = () => {
ElMessageBox.confirm(
'恢复虚拟机外部网络连接?',
'恢复网络', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' }
).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', detail.value?.id)
const res = await connectVmNetwork(fd)
if (res?.data?.code === 200) { ElMessage.success('网络已恢复'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '恢复网络失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复网络失败')) }
}).catch(() => {})
}
// ---- ----
const trafficHourlyRange = ref(null)
const trafficHourlyData = ref([])
const trafficHourlyLoading = ref(false)
const trafficHourlyChartRef = ref(null)
let trafficHourlyChart = null
const loadTrafficHourly = async () => {
if (!serviceId.value || !detail.value?.host_id || !detail.value?.name) return
if (!trafficHourlyRange.value) {
const now = new Date()
trafficHourlyRange.value = [new Date(now.getTime() - 24 * 3600 * 1000), now]
}
trafficHourlyLoading.value = true
try {
const res = await getVmTrafficHourly({
service_id: serviceId.value,
host_id: detail.value.host_id,
vm_name: detail.value.name,
start: new Date(trafficHourlyRange.value[0]).toISOString(),
end_time: new Date(trafficHourlyRange.value[1]).toISOString()
})
const raw = res?.data?.data?.data
trafficHourlyData.value = typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : [])
nextTick(renderTrafficHourlyChart)
} catch { trafficHourlyData.value = [] } finally { trafficHourlyLoading.value = false }
}
const renderTrafficHourlyChart = () => {
if (!trafficHourlyChartRef.value || !trafficHourlyData.value.length) return
if (!trafficHourlyChart) trafficHourlyChart = echarts.init(trafficHourlyChartRef.value)
const data = trafficHourlyData.value
const times = data.map(d => {
const date = new Date(d.bucket)
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
})
const toMB = (b) => +(b / 1048576).toFixed(2)
trafficHourlyChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => params.map(p => `${p.marker}${p.seriesName}: ${p.value} MB`).join('<br/>') },
legend: { data: ['下行', '上行', '合计'], bottom: 0 },
grid: { top: 10, right: 16, bottom: 40, left: 50 },
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', name: 'MB', axisLabel: { fontSize: 10 } },
series: [
{ name: '下行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.rx_bytes)), itemStyle: { color: '#409EFF' } },
{ name: '上行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.tx_bytes)), itemStyle: { color: '#67C23A' } },
{ name: '合计', type: 'line', smooth: true, data: data.map(d => toMB(d.total_bytes)), itemStyle: { color: '#E6A23C' }, lineStyle: { width: 2 } }
]
}, true)
}
// ---- /////// ---- // ---- /////// ----
const showVolSelector = ref(false) const showVolSelector = ref(false)
@@ -3619,7 +3892,7 @@ const triggerTabLoad = (tab) => {
if (tab === 'backup') { loadBackups(); loadBackupQuota() } if (tab === 'backup') { loadBackups(); loadBackupQuota() }
if (tab === 'userNetworking') loadVmNetworkingList() if (tab === 'userNetworking') loadVmNetworkingList()
if (tab === 'security') loadSgLockInfo() if (tab === 'security') loadSgLockInfo()
if (tab === 'vmTrafficPolicy') loadVmTrafficPolicy() if (tab === 'trafficManage') { loadVmTrafficPolicy(); loadTrafficHourly() }
} }
// lock // lock
@@ -3692,6 +3965,14 @@ onMounted(() => { isPageActive = true; initPage() })
.config-cell:last-child { border-right: none; } .config-cell:last-child { border-right: none; }
.config-label { font-size: 12px; color: #86909c; line-height: 1; } .config-label { font-size: 12px; color: #86909c; line-height: 1; }
.config-value { font-size: 14px; color: #1d2129; line-height: 1.4; word-break: break-all; } .config-value { font-size: 14px; color: #1d2129; line-height: 1.4; word-break: break-all; }
.cfg-edit-btn { margin-left: 8px; padding: 0 4px; height: 18px; vertical-align: middle; }
.cfg-edit-btn .el-icon { margin-right: 2px; vertical-align: -2px; }
.traffic-cell { display: flex; flex-direction: column; gap: 2px; }
.traffic-cell .traffic-main { font-size: 14px; color: #1d2129; }
.traffic-cell .traffic-sub { font-size: 12px; color: #86909c; line-height: 1.3; }
.traffic-cell .traffic-cycle { color: #c0c4cc; margin-left: 2px; }
.traffic-cell .traffic-actions { display: inline-flex; gap: 6px; margin-top: 2px; }
.traffic-cell .traffic-actions .cfg-edit-btn { margin-left: 0; }
.spec-value { font-size: 13px; color: #4e5969; } .spec-value { font-size: 13px; color: #4e5969; }
.ip-value { color: #165dff; font-weight: 500; } .ip-value { color: #165dff; font-weight: 500; }
.password-cell { display: flex; align-items: center; gap: 8px; } .password-cell { display: flex; align-items: center; gap: 8px; }
+1
View File
@@ -391,6 +391,7 @@
<el-descriptions-item label="磁盘写入">{{ formatBytesRaw(vmMetricsData.disk_write) }}</el-descriptions-item> <el-descriptions-item label="磁盘写入">{{ formatBytesRaw(vmMetricsData.disk_write) }}</el-descriptions-item>
<el-descriptions-item label="网络接收">{{ formatNetSpeed(vmMetricsData.net_rx) }}</el-descriptions-item> <el-descriptions-item label="网络接收">{{ formatNetSpeed(vmMetricsData.net_rx) }}</el-descriptions-item>
<el-descriptions-item label="网络发送">{{ formatNetSpeed(vmMetricsData.net_tx) }}</el-descriptions-item> <el-descriptions-item label="网络发送">{{ formatNetSpeed(vmMetricsData.net_tx) }}</el-descriptions-item>
<el-descriptions-item label="累计流量">{{ vmMetricsData.traffic_used_mb != null ? vmMetricsData.traffic_used_mb + ' MB' : '-' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</template> </template>
</div> </div>