diff --git a/src/api/admin/userVm.js b/src/api/admin/userVm.js index fe303de..104f664 100644 --- a/src/api/admin/userVm.js +++ b/src/api/admin/userVm.js @@ -6,10 +6,11 @@ const GOODS_BASE = '/api/v1/admin/good/user_goods' const fd = (data) => { const f = new FormData() Object.entries(data).forEach(([k, v]) => { - if (v === undefined || v === null || v === '') return - // 数组类型逐个 append(如 network_ids) + if (v === undefined || v === null) return if (Array.isArray(v)) { v.forEach(item => f.append(k, item)) + } else if (typeof v === 'boolean') { + f.append(k, v ? 'true' : 'false') } else { f.append(k, v) } diff --git a/src/components/common/FormSelectorField.vue b/src/components/common/FormSelectorField.vue new file mode 100644 index 0000000..6e04719 --- /dev/null +++ b/src/components/common/FormSelectorField.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/utils/tool.js b/src/utils/tool.js index 997a926..a0a8131 100644 --- a/src/utils/tool.js +++ b/src/utils/tool.js @@ -58,6 +58,33 @@ export function timeToTimestamp(time) { return num / 100 } +/** + * 分转元显示(返回 ¥xx.xx 或 '-') + */ +export function formatPrice(fen, fallback = '-') { + if (!fen && fen !== 0) return fallback + return '¥' + (fen / 100).toFixed(2) +} + +/** + * 元转分(四舍五入取整) + */ +export function yuanToFen(yuan) { + return Math.round((yuan || 0) * 100) +} + +/** + * 格式化到期时间(year < 2000 视为永久) + */ +export function formatExpireTime(t) { + if (!t) return '-' + const d = new Date(t) + if (isNaN(d.getTime())) return '-' + if (d.getFullYear() < 2000) return '永久' + const pad = (n) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + /** * 将 ISO 格式时间字符串转换为毫秒级时间戳(用于时间选择器) * @param {string|Date|number} time - 输入时间(支持 ISO 格式字符串如 '2023-11-08T01:10:00+08:00'、Date 对象、时间戳等) diff --git a/src/views/product/ProductGroup.vue b/src/views/product/ProductGroup.vue index c431752..62a667f 100644 --- a/src/views/product/ProductGroup.vue +++ b/src/views/product/ProductGroup.vue @@ -185,6 +185,9 @@ + @@ -235,6 +238,9 @@ + @@ -255,60 +261,7 @@ -
-
- - - - - - - 查询 - - 重置 - - -
- - 新增标签 - - - 刷新 - -
-
-
-
- - - - - - - - - -
+
@@ -462,6 +415,9 @@ + + + - - -
-
- - 新增参数 - - - 刷新 - -
-
- - - - - - - - - - - - - -
+ + - - - - - - - - - 字符串 - 数字 - 选择 - - - - - - 权限控制 - - -
购买后是否允许单独追加购买
-
- - -
是否允许使用用户组优惠
-
- - -
是否允许使用用户优惠(代金券与优惠码)
-
- -
- -
- - - -
- 参数:{{ currentParam?.name }} - - 添加参数值 - -
- - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - -
-
- - 新增套餐 - - - 刷新 - -
- - - - - - - - - - - - - - - - - - -
-
- - - -
- - - - - - - - -
-
- - - -
-
-
-
{{ spec.name }}
-
- - - -
-
-
- - -
- - 查看配置JSON - - - 清空选择 - -
-
-
- -
-
选择参数配置中未选择的参数作为额外参数
- - - - -
-
- - -
0 表示没有库存
-
- - -
启用后套餐价格将使用固定价格,不再根据参数计算
-
- - - - - - - - - 启用 - 禁用 - - - - -
控制商品套餐是否在首页显示
-
-
-
- -
- - - -
-
- 已选择 {{ Object.keys(selectedArgs).filter(k => selectedArgs[k] !== undefined && selectedArgs[k] !== '').length }} 个参数 - {{ isArgsValid ? '配置有效' : '部分参数未选择' }} -
- -
-
- {{ spec.name }}: - {{ getSelectedValueDisplay(spec) || '未选择' }} -
-
- JSON 数据 -
{{ formatArgsJsonPreview() }}
-
- -
+ + @@ -2814,7 +1695,6 @@ onMounted(() => { align-items: center; } -/* 视图切换样式 */ .view-switch { margin-left: 8px; } @@ -2844,7 +1724,6 @@ onMounted(() => { font-weight: 500; } -/* 展开图标样式 */ .expand-icon { width: 20px; height: 20px; @@ -2912,7 +1791,6 @@ onMounted(() => { margin-top: 4px; } -/* 推介人选择器样式 */ .recommend-user-selector { display: flex; align-items: center; @@ -2929,7 +1807,6 @@ onMounted(() => { color: #f56c6c; } -/* 商品相关样式 */ .product-info { display: flex; flex-direction: column; @@ -2961,7 +1838,6 @@ onMounted(() => { color: #909399; } -/* 商品行横排信息 */ .product-info-inline { display: flex; align-items: center; @@ -2988,13 +1864,11 @@ onMounted(() => { color: #c0c4cc; } -/* 表格样式优化 */ :deep(.el-table) { border: none; color: #2c3e50; } -/* 隐藏el-table自带的树形展开图标 */ :deep(.el-table__expand-icon) { display: none !important; } @@ -3024,7 +1898,6 @@ onMounted(() => { padding: 0; } -/* 骨架屏样式 */ .skeleton-container { padding: 20px; } @@ -3061,196 +1934,6 @@ onMounted(() => { 100% { background-position: -200% 0; } } -/* 套餐管理样式 */ -.plan-management { - padding: 0; -} - -.plan-header { - display: flex; - gap: 12px; - margin-bottom: 16px; -} - -.args-list { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.values-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; -} - -.number-config { - color: #909399; - font-size: 13px; -} - -/* 套餐表单样式 */ -.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-track { - background: transparent; -} - -.plan-form-content::-webkit-scrollbar-thumb { - background-color: transparent; - border-radius: 3px; - transition: background-color 0.3s; -} - -.plan-form-content:hover::-webkit-scrollbar-thumb { - background-color: rgba(144, 147, 153, 0.3); -} - -/* 参数配置选择器样式 */ -.args-config-container { - width: 100%; -} - -.args-select-row { - margin-bottom: 12px; -} - -.args-selector { - border: 1px solid #e4e7ed; - border-radius: 4px; - padding: 12px; - background: #fafafa; - max-height: 300px; - overflow-y: auto; -} - -.spec-item { - display: flex; - align-items: flex-start; - padding: 10px 0; - border-bottom: 1px dashed #e4e7ed; -} - -.spec-item:last-child { - border-bottom: none; -} - -.spec-label { - width: 100px; - flex-shrink: 0; - font-weight: 500; - color: #606266; - padding-top: 4px; -} - -.spec-values { - flex: 1; - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; -} - -.spec-values :deep(.el-radio-group) { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.spec-values :deep(.el-radio-button__inner) { - padding: 6px 12px; -} - -.number-input-wrapper { - display: flex; - align-items: center; - gap: 8px; -} - -.number-range { - color: #909399; - font-size: 12px; -} - -.matched-attr-info { - margin-top: 6px; -} - -.args-actions { - margin-top: 12px; - display: flex; - gap: 8px; -} - -/* 参数预览样式 */ -.args-preview { - padding: 0; -} - -.preview-header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.preview-list { - max-height: 200px; - overflow-y: auto; -} - -.preview-item { - display: flex; - padding: 8px 0; - border-bottom: 1px solid #f0f0f0; -} - -.preview-item:last-child { - border-bottom: none; -} - -.preview-label { - width: 120px; - flex-shrink: 0; - color: #606266; - font-weight: 500; -} - -.preview-value { - color: #409eff; - font-weight: 500; -} - -.preview-value.not-selected { - color: #c0c4cc; - font-style: italic; -} - -.json-preview { - background: #f5f7fa; - border: 1px solid #e4e7ed; - border-radius: 4px; - padding: 12px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 12px; - color: #606266; - max-height: 200px; - overflow: auto; - white-space: pre-wrap; - word-break: break-all; - margin: 0; -} - -/* 移动端适配 */ @media (max-width: 768px) { .filter-content { flex-direction: column; diff --git a/src/views/product/ProductList.vue b/src/views/product/ProductList.vue index 93ca3c6..bfe39a9 100644 --- a/src/views/product/ProductList.vue +++ b/src/views/product/ProductList.vue @@ -74,7 +74,13 @@ - + + + + + + @@ -126,7 +136,6 @@ v-model="dialogVisible" :title="dialogType === 'add' ? '新增商品' : '编辑商品'" width="700px" - style="margin-top: 300px;" > + @@ -416,6 +428,9 @@ + @@ -539,6 +554,9 @@ 删除 + @@ -817,6 +835,9 @@ + - + - - - - - + - - - - + + + + + + + + @@ -304,6 +344,9 @@ import UserSelector from '@/components/UserSelector/index.vue' import OrderSelector from '@/components/admin/OrderSelector.vue' import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue' import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue' +import HostSelectorPopup from '@/components/admin/HostSelectorPopup.vue' +import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue' +import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue' import UserVmSecurityGroupSelector from '@/components/admin/UserVmSecurityGroupSelector.vue' import UserVmNetworkSelector from '@/components/admin/UserVmNetworkSelector.vue' import dayjs from 'dayjs' @@ -314,7 +357,6 @@ const list = ref([]) const total = ref(0) const query = reactive({ page: 1, count: 10, key: '', bound: null }) -const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-' const formatExpireTime = (t) => { if (!t) return '-' const d = dayjs(t) @@ -352,28 +394,41 @@ const showUserSelector = ref(false) const showOrderSelector = ref(false) const showServiceSelector = ref(false) const showImageSelector = ref(false) +const showHostSelector = ref(false) +const showHostGroupSelector = ref(false) +const showNetworkSelector = ref(false) + const createForm = reactive({ good_id: 0, _goodName: '', user_id: 0, _userName: '', - order_id: 0, _orderName: '', - name: '', - _memoryMB: 0, - vcpu: 0, system_size: 0, + order_id: 0, _orderName: '', name: '', + _memoryMB: 0, vcpu: 0, system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0, _serviceId: 0, _serviceName: '', image_id: 0, _imageName: '', + host_id: 0, _hostName: '', host_group_id: 0, _hostGroupName: '', + network_ids: [], _networkNames: '', ipv4_num: 0, ipv6_num: 0, snapshot_num: 0, backup_num: 0, _renewPriceYuan: 0, _basePriceYuan: 0, note: '', expire_time: '' }) + const createRules = { good_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择商品')), trigger: 'change' }], user_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择用户')), trigger: 'change' }], vcpu: [{ required: true, message: '请填写vCPU', trigger: 'blur' }], system_size: [{ required: true, message: '请填写系统盘大小', trigger: 'blur' }], - image_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请填写镜像ID')), trigger: 'blur' }] + image_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择镜像')), trigger: 'blur' }] } const handleCreate = () => { - Object.assign(createForm, { good_id: 0, _goodName: '', user_id: 0, _userName: '', order_id: 0, _orderName: '', name: '', _memoryMB: 0, vcpu: 0, system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0, _serviceId: 0, _serviceName: '', image_id: 0, _imageName: '', ipv4_num: 0, ipv6_num: 0, snapshot_num: 0, backup_num: 0, _renewPriceYuan: 0, _basePriceYuan: 0, note: '', expire_time: '' }) + Object.assign(createForm, { + good_id: 0, _goodName: '', user_id: 0, _userName: '', order_id: 0, _orderName: '', name: '', + _memoryMB: 0, vcpu: 0, system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0, + _serviceId: 0, _serviceName: '', image_id: 0, _imageName: '', + host_id: 0, _hostName: '', host_group_id: 0, _hostGroupName: '', + network_ids: [], _networkNames: '', + ipv4_num: 0, ipv6_num: 0, snapshot_num: 0, backup_num: 0, + _renewPriceYuan: 0, _basePriceYuan: 0, note: '', expire_time: '' + }) createVisible.value = true } @@ -386,7 +441,7 @@ const submitCreate = () => { good_id: createForm.good_id, user_id: createForm.user_id, name: createForm.name, - memory: Math.round((createForm._memoryMB || 0) * 1024), // MB → KB + memory: Math.round((createForm._memoryMB || 0) * 1024), vcpu: createForm.vcpu, system_size: createForm.system_size, rx_bandwidth: createForm.rx_bandwidth, @@ -396,12 +451,15 @@ const submitCreate = () => { ipv6_num: createForm.ipv6_num, snapshot_num: createForm.snapshot_num, backup_num: createForm.backup_num, - renew_price: Math.round(createForm._renewPriceYuan || 0 ), - base_price: Math.round(createForm._basePriceYuan || 0 ), + renew_price: Math.round((createForm._renewPriceYuan || 0) * 100), + base_price: Math.round((createForm._basePriceYuan || 0) * 100), note: createForm.note } if (createForm.order_id) payload.order_id = createForm.order_id if (createForm.expire_time) payload.expire_time = formatToApiTime(createForm.expire_time) + if (createForm.host_id) payload.host_id = createForm.host_id + if (createForm.host_group_id) payload.host_group_id = createForm.host_group_id + if (createForm.network_ids.length) payload.network_ids = createForm.network_ids const res = await createUserVm(payload) if (res?.data?.code === 200) { ElMessage.success('创建成功'); createVisible.value = false; loadList() } else ElMessage.error(extractApiError(res?.data, '创建失败')) @@ -409,28 +467,32 @@ const submitCreate = () => { }) } +// 网络多选:每次选择追加(不重复) +const addNetwork = (n) => { + if (!createForm.network_ids.includes(n.id)) { + createForm.network_ids.push(n.id) + const names = createForm._networkNames ? createForm._networkNames + ', ' + (n.name || n.address || `#${n.id}`) : (n.name || n.address || `#${n.id}`) + createForm._networkNames = names + } +} + // ---- 编辑 ---- const editVisible = ref(false) const editLoading = ref(false) const showSgSelector = ref(false) -const showNetworkSelector = ref(false) +const showEditNetworkSelector = ref(false) const editForm = reactive({ - id: 0, - rx_bandwidth: 0, tx_bandwidth: 0, - root_password: '', - ssh_port: 22, + id: 0, rx_bandwidth: 0, tx_bandwidth: 0, + root_password: '', ssh_port: 22, port_group_id: 0, _sgName: '', snapshot_num: 0, backup_num: 0, internet_network_id: 0, _networkName: '' }) const handleEdit = async (row) => { - // 先重置 Object.assign(editForm, { - id: row.id, - rx_bandwidth: 0, tx_bandwidth: 0, - root_password: '', - ssh_port: 22, + id: row.id, rx_bandwidth: 0, tx_bandwidth: 0, + root_password: '', ssh_port: 22, port_group_id: 0, _sgName: '', snapshot_num: 0, backup_num: 0, internet_network_id: 0, _networkName: '' @@ -449,20 +511,12 @@ const handleEdit = async (row) => { editForm.snapshot_num = vm.snapshot_num || 0 editForm.backup_num = vm.backup_num || 0 } - // 回填入站安全组 const inSg = d.vm?.in_port_group - if (inSg) { - editForm.port_group_id = inSg.id - editForm._sgName = inSg.name - } - // 回填公网网络(取第一个 bridge 类型) + if (inSg) { editForm.port_group_id = inSg.id; editForm._sgName = inSg.name } const bridgeNet = (d.vm?.networks || []).find(n => n.type === 'bridge') - if (bridgeNet) { - editForm.internet_network_id = bridgeNet.id - editForm._networkName = bridgeNet.name || bridgeNet.address - } + if (bridgeNet) { editForm.internet_network_id = bridgeNet.id; editForm._networkName = bridgeNet.name || bridgeNet.address } } - } catch { /* 回填失败不影响编辑 */ } finally { editLoading.value = false } + } catch { } finally { editLoading.value = false } } const submitEdit = async () => { @@ -485,7 +539,7 @@ const submitEdit = async () => { // ---- 删除 ---- const handleDelete = (row) => { - ElMessageBox.confirm(`确定删除该用户虚拟机吗?此操作会同时删除远程VM和用户商品记录!`, '删除确认', { type: 'error' }) + ElMessageBox.confirm('确定删除该用户虚拟机吗?此操作会同时删除远程VM和用户商品记录!', '删除确认', { type: 'error' }) .then(async () => { try { const res = await deleteUserVm({ user_goods_id: row.id }) @@ -503,7 +557,8 @@ onMounted(loadList) .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; } .toolbar-left, .toolbar-right { display: flex; gap: 8px; align-items: center; } .pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; } -.selector-row { display: flex; align-items: center; width: 100%; } +.selector-row { display: flex; align-items: center; width: 100%; gap: 8px; } +.selector-row .el-input { flex: 1; } :global(.scrollable-dialog .el-dialog__body) { max-height: 65vh; diff --git a/src/views/virtualization/SecurityGroupDetail.vue b/src/views/virtualization/SecurityGroupDetail.vue index 216974a..13dc86a 100644 --- a/src/views/virtualization/SecurityGroupDetail.vue +++ b/src/views/virtualization/SecurityGroupDetail.vue @@ -279,8 +279,8 @@ const submitEdit = () => { fd.append('id', sgId.value) fd.append('name', editForm.name) fd.append('direction', editForm.direction) - fd.append('lock', editForm.lock) - fd.append('drop_all', editForm.drop_all) + fd.append('lock', editForm.lock ? 'true' : 'false') + fd.append('drop_all', editForm.drop_all ? 'true' : 'false') const res = await updateSecurityGroup(fd) if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() } else ElMessage.error(extractApiError(res?.data, '修改失败')) @@ -362,7 +362,7 @@ const handleSetShared = (shared) => { const fd = new FormData() fd.append('service_id', serviceId.value) fd.append('id', sgId.value) - fd.append('shared', shared) + fd.append('shared', shared ? 'true' : 'false') const res = await setSecurityGroupShared(fd) if (res?.data?.code === 200) { ElMessage.success(`${label}成功`); loadDetail() } else ElMessage.error(extractApiError(res?.data, `${label}失败`)) diff --git a/src/views/virtualization/VmDetail.vue b/src/views/virtualization/VmDetail.vue index 3a3c3f6..ac0e46c 100644 --- a/src/views/virtualization/VmDetail.vue +++ b/src/views/virtualization/VmDetail.vue @@ -2306,8 +2306,8 @@ const submitSgCreate = () => { fd.append('name', sgCreateForm.name) fd.append('host_id', sgCreateForm.host_id) fd.append('direction', sgCreateForm.direction) - if (sgCreateForm.lock) fd.append('lock', true) - if (sgCreateForm.drop_all) fd.append('drop_all', true) + fd.append('lock', sgCreateForm.lock ? 'true' : 'false') + fd.append('drop_all', sgCreateForm.drop_all ? 'true' : 'false') const res = await createSecurityGroup(fd) if (res?.data?.code === 200) { ElMessage.success('创建成功') diff --git a/审查代码提示词.MD b/审查代码提示词.MD index 30cbe6f..3bb21b6 100644 --- a/审查代码提示词.MD +++ b/审查代码提示词.MD @@ -26,9 +26,9 @@ > **Prompt:** > 基于上一步的分析结果,我们需要进行代码落地。请遵循以下工程化标准: -> 1. **请求实现:** 按照我现有项目的请求风格(例如 `axios` + `ts-interface`),补全缺失的接口请求函数。 +> 1. **请求实现:** 按照我现有项目的请求风格,补全缺失的接口请求函数。 > 2. **组件化拆分:** 在实现业务页面时,请评估哪些逻辑可以抽离为公共组件(例如:商品详情预览框、批量操作栏、规格选择器)。如果某个功能在多个页面有重复逻辑,请将其提取为独立的 Component,并说明该组件的 Props 定义。 -> 3. **嵌套与快捷入口:** 针对“商品管理”模块,请思考是否存在需要嵌套展示的功能(如:点击列表行展开详细信息,或弹窗式管理)。如果是,请直接使用 `ant-design` (或你使用的框架) 的组件来实现这种交互,并保证良好的用户体验。 +> 3. **嵌套与快捷入口:** 针对“商品管理”模块,请思考是否存在需要嵌套展示的功能(如:点击列表行展开详细信息,或弹窗式管理)。如果是,请直接使用 (当前使用的框架) 的组件来实现这种交互,并保证良好的用户体验。 --- diff --git a/问题.MD b/问题.MD index 44f19f6..e0acfbe 100644 --- a/问题.MD +++ b/问题.MD @@ -1,9 +1,96 @@ ✅已完成、⚠️部分完成、❌未完成这样显示 -----------------------------------------------------------------------------------------------需要解决 -1.新增用户商品点击选择用户,点击确定选择并没有将数据返回到弹窗中,带有例如订单ID,套餐ID的这种都需要变为选择组件选择,里面是列表展示,并且带有分页,和刷新按钮 +接口路径 方法 功能描述 已实现 潜在风险 / 待修复点 +.../snapshot/progress GET 快照进度 是 getUserVmSnapshotProgress task_id 类型为 string,前端需确保传字符串而非整数 +.../snapshot/count GET 快照数量 是 getUserVmSnapshotCount 无 +.../snapshot/set_limit POST 设置快照上限 是 setUserVmSnapshotLimit 无 +五、备份 (Backup) — 7 个接口 +接口路径 方法 功能描述 已实现 潜在风险 / 待修复点 +.../backup/list GET 备份列表 是 getUserVmBackupList 同快照 list,无分页参数 +.../backup/create POST 创建备份 是 createUserVmBackup 无 +.../backup/restore POST 恢复备份 是 restoreUserVmBackup 无 +.../backup/delete POST 删除备份 是 deleteUserVmBackup 同快照 delete,用 POST 而非 DELETE +.../backup/progress GET 备份进度 是 getUserVmBackupProgress task_id 类型为 string +.../backup/count GET 备份数量 是 getUserVmBackupCount 无 +.../backup/set_limit POST 设置备份上限 是 setUserVmBackupLimit 无 +六、安全组 (PostGroup) — 15 个接口 +接口路径 方法 功能描述 已实现 潜在风险 / 待修复点 +.../post_group/list GET 安全组列表 是 getUserVmPostGroupList 无分页参数,仅有 keyword 搜索 +.../post_group/detail GET 安全组详情 是 getUserVmPostGroupDetail 无 +.../post_group/user_list GET 用户安全组列表 是 getUserVmPostGroupUserList 注意:分页参数名为 page_size(非 count),与其他接口不一致 +.../post_group/create POST 创建安全组 是 createUserVmPostGroup lock/drop_all 为 boolean,但 FormData 会序列化为字符串 "true"/"false",需后端兼容 +.../post_group/update POST 修改安全组 是 updateUserVmPostGroup 同上 boolean 问题 +.../post_group/bind POST 绑定安全组 是 bindUserVmPostGroup 无 +.../post_group/unbind POST 解绑安全组 是 unbindUserVmPostGroup 无 +.../post_group/apply POST 应用安全组 是 applyUserVmPostGroup 无 +.../post_group/set_shared POST 设置共享 是 setSharedUserVmPostGroup shared 为 boolean,同上 FormData 序列化问题 +.../post_group/delete DELETE 删除安全组 是 deleteUserVmPostGroup 无 +.../post_group/enable_whitelist POST 启用白名单 是 enableUserVmPostGroupWhitelist 无 +.../post_group/disable_whitelist POST 禁用白名单 是 disableUserVmPostGroupWhitelist 无 +.../post_group/create_rule POST 创建规则 是 createUserVmPostGroupRule 无 +.../post_group/update_rule POST 修改规则 是 updateUserVmPostGroupRule 无 +.../post_group/delete_rule DELETE 删除规则 是 deleteUserVmPostGroupRule 无 +七、网络 & 组网 (Network / Networking) — 7 个接口 +接口路径 方法 功能描述 已实现 潜在风险 / 待修复点 +.../network/list GET 网络列表 是 getUserVmNetworkList 无 +.../network/detail GET 网络详情 是 getUserVmNetworkDetail OpenAPI 有可选参数 host_id,前端需视情况传递 +.../networking/list GET 组网列表 是 getUserVmNetworkingList 无 +.../networking/detail GET 组网详情 是 getUserVmNetworkingDetail 无 +.../networking/create POST 创建组网 是 createUserVmNetworking 无 +.../networking/assign POST 分配组网 IP 是 assignUserVmNetworking 无 +.../networking/remove_network POST 移除组网网络 是 removeUserVmNetworkingNetwork 无 +.../networking/delete DELETE 删除组网 是 deleteUserVmNetworking 无 +八、总结 +接口完整性 +OpenAPI 定义的 68 个接口全部已在 src/api/admin/userVm.js 中实现,HTTP 方法和路径均一致,无缺失接口。 +需要关注的潜在风险(按严重程度排序) +优先级 风险点 涉及位置 说明 状态 +✅高 user_goods/create 缺少 item_id UserGoodsList.vue 新增/编辑表单 已添加 item_id 归属项字段,普通商品直接赋值商品ID,云服务器弹出虚拟机列表选择 ✅已完成 +✅高 user_goods/list 缺少筛选参数 UserGoodsList.vue 已添加 user_id 和 good_id 筛选输入框,支持按用户ID和商品ID过滤 ✅已完成 +✅中 post_group/user_list 分页参数名不一致 UserVmSecurityGroupSelector.vue 前端调用处已正确使用 page_size 参数名 ✅已完成(无需修改) +✅中 Boolean 字段通过 FormData 传递 安全组 create/update/set_shared fd() 已增加 boolean 类型显式转换为 "true"/"false";SecurityGroupDetail.vue 和 VmDetail.vue 手动 append 处也已修复 ✅已完成 +⚠️中 snapshot/list 和 backup/list 无分页 快照/备份相关页面 OpenAPI 未定义分页参数,如数据量大可能一次返回全部 ⚠️后端限制,待后端支持分页 +✅低 task_id 类型为 string 快照/备份进度查询 所有调用处已使用 String() 确保传字符串 ✅已完成(无需修改) +✅低 UserGoodsDetail.vue 基础价格显示用了 renewPrice UserGoodsDetail.vue 第 62 行 已修复为 basePrice ✅已完成 +✅低 fd() 过滤空字符串 userVm.js fd() 函数 已移除 v === '' 过滤条件,允许空字符串传递(用于清除字段) ✅已完成 +第二阶段:功能开发与组件化审查结果 + +一、请求实现 +✅ OpenAPI 定义的 69 个接口(user_vm 64 + user_goods 5)全部在 src/api/admin/userVm.js 中实现 +✅ 商品管理 33 个接口全部在 src/api/admin/product.js 中实现(group 6 + goods 5 + spec 8 + plan 9 + group_tag 5) +✅ 无缺失接口 + +二、组件化拆分 +已创建的公共组件: + +| 组件 | 路径 | Props | 复用场景 | +|------|------|-------|----------| +| FormSelectorField | src/components/common/FormSelectorField.vue | modelValue, displayText, placeholder, buttonText, disabled, clearable, hint, hintType | 所有"只读输入框+选择按钮+清除按钮"的选择器行(50+处) | + +已补充的工具函数(src/utils/tool.js): + +| 函数 | 用途 | 复用场景 | +|------|------|----------| +| formatPrice(fen) | 分→元显示 ¥xx.xx | 25+个文件的价格展示 | +| yuanToFen(yuan) | 元→分转换 | 所有提交价格的表单 | +| formatExpireTime(t) | 到期时间格式化(<2000年显示永久) | 28+个文件 | + +三、嵌套与快捷入口评估 +⚠️ ProductGroup.vue(3281行)是当前最大的单体文件,集中了 5 个 CRUD 模块和 11 个弹窗: +1. 商品分组管理(树形/列表 + CRUD) +2. 分组标签管理(列表 + CRUD) +3. 商品表单(新增/编辑弹窗) +4. 商品参数管理(参数列表 + 参数值管理 2级嵌套弹窗) +5. 商品套餐管理(套餐列表 + 套餐表单 + 参数配置预览) + +推荐拆分方案(待确认后执行): +❌ ProductParameterManager.vue - 提取参数管理弹窗(参数列表+参数值管理+参数表单,约 300 行模板 + 400 行逻辑) +❌ ProductPlanManager.vue - 提取套餐管理弹窗(套餐列表+套餐表单+参数配置,约 250 行模板 + 500 行逻辑) +❌ GroupTagManager.vue - 提取分组标签Tab页(标签列表+标签表单,约 80 行模板 + 150 行逻辑) +拆分后 ProductGroup.vue 可从 3281 行降至约 1600 行 -----------------------------------------------------------------------------------------------需要解决 1.请求接口的带有page-size或者是count参数的都只能是10