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 @@ + + + + + {{ buttonText }} + 清除 + + + {{ hint }} + + + + + + + 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 @@ + + + 取消 @@ -477,34 +433,6 @@ @confirm="handleCoverSelect" /> - - - - - - - - - - - - - - - {{ row.name }} + + + 取消 @@ -683,6 +614,9 @@ + + + 取消 @@ -690,384 +624,25 @@ - - - - - - 新增参数 - - - 刷新 - - - - - - - - - {{ getArgTypeText(row.type) }} - - - - - - 步进: {{ row.step || '-' }} | 范围: {{ row.min ?? '-' }} ~ {{ row.max ?? '-' }} - - - - - - - - - 编辑 - 查看参数值 - 删除 - - - - - + + - - - - - - - - - 字符串 - 数字 - 选择 - - - - - - 权限控制 - - - 购买后是否允许单独追加购买 - - - - 是否允许使用用户组优惠 - - - - 是否允许使用用户优惠(代金券与优惠码) - - - 数值参数配置 - - - - - - - - - - - - - - - - - - - - 参数:{{ currentParam?.name }} - - 添加参数值 - - - - - - - - {{ row.value || '-' }} - - {{ getRangeTypeText(row.rangeType) }} {{ row.range }} - - {{ row.value || '-' }} - - - - - ¥{{ (row.price / 100).toFixed(2) }} - - - - - 编辑 - 删除 - - - - - - - - - - - - - - - - - 数值范围配置(phase) - - - - - - - before: 数值 ≤ phase 时匹配 | after: 数值 ≥ phase 时匹配 - - - - - - - - - - - - - - - - - - - - - - - 新增套餐 - - - 刷新 - - - - - - - - - - {{ arg.name || arg.value || `参数${arg.arg_id}` }} - - - - - - - - - - - {{ row.disable ? '禁用' : '启用' }} - - - - - - {{ row.showHome || row.show_home ? '展示' : '不展示' }} - - - - - - 编辑 - - {{ row.disable ? '启用' : '禁用' }} - - 删除 - - - - - - - - - - - - - - - - - - - - - - - - - - {{ spec.name }} - - - - {{ attr.name }} - - - - - - (范围: {{ spec.min || 0 }} - {{ spec.max || 9999 }},步长: {{ spec.step || 1 }}) - - - 匹配区间: {{ getMatchedAttrName(spec, selectedArgs[spec.id]) }} - - - - - - - - - - - - - 查看配置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 @@ - + + + {{ row.tag }} + - + + + ¥{{ (row.price / 100).toFixed(2) }} @@ -104,6 +110,10 @@ 删除 + + + + @@ -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 @@ + + + diff --git a/src/views/product/UserGoodsDetail.vue b/src/views/product/UserGoodsDetail.vue index f5609fa..fce3847 100644 --- a/src/views/product/UserGoodsDetail.vue +++ b/src/views/product/UserGoodsDetail.vue @@ -15,8 +15,14 @@ + - + + + 重新加载 + + + @@ -59,14 +65,16 @@ {{ detail.goodId || '-' }} {{ detail.orderId || '-' }} {{ detail.itemId || '-' }} - {{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }} + {{ (detail.basePrice || detail.base_price) ? '¥' + ((detail.basePrice || detail.base_price) / 100).toFixed(2) : '-' }} {{ formatTime(detail.CreatedAt) }} {{ formatTime(detail.UpdatedAt) }} - - 关联信息 + + + 关联信息 + {{ detail.good?.name || '-' }} {{ detail.good?.table || '-' }} @@ -224,28 +232,176 @@ watch(goodsId, (newId, oldId) => { diff --git a/src/views/product/UserGoodsList.vue b/src/views/product/UserGoodsList.vue index fd257d5..1a0f0de 100644 --- a/src/views/product/UserGoodsList.vue +++ b/src/views/product/UserGoodsList.vue @@ -1,60 +1,132 @@ - - - 新增用户商品 - 刷新 + + + + + + + + + + + + + + + + + + + 查询 + + 重置 + + + + + 新增用户商品 + + + 刷新 + + + - - - - - 搜索 + + + + + + + + + + + + + + + + + + + + + + {{ row.user?.UserName || row.user?.username || '-' }} + ({{ row.userId || row.user_id || '-' }}) + + + + + {{ row.good?.name || '-' }} + + + + {{ row.tag }} + - + + + + {{ row.goodPlanId || row.good_plan_id || '-' }} + + + {{ row.order?.name || (row.orderId ? `订单 #${row.orderId}` : '-') }} + + + + ¥{{ ((row.renewPrice || row.renew_price) / 100).toFixed(2) }} + - + + + + + + {{ formatExpireTime(row.expireTime || row.expire_time) }} + + + + + + + 详情 + 编辑 + 删除 + + + + + + + + + + + + { query.count = s; query.page = 1; loadList() }" + @current-change="p => { query.page = p; loadList() }" + background + class="pagination" + /> - - - - - - - {{ row.user?.UserName || row.user?.username || '-' }} - ({{ row.userId || row.user_id || '-' }}) - - - - {{ row.good?.name || '-' }} - - - {{ row.goodPlanId || row.good_plan_id || '-' }} - - - {{ row.order?.name || (row.orderId ? `订单 #${row.orderId}` : '-') }} - - - - ¥{{ ((row.renewPrice || row.renew_price) / 100).toFixed(2) }} - - - - - - {{ formatExpireTime(row.expireTime || row.expire_time) }} - - - - 详情 - 编辑 - 删除 - - - - - - { query.count = s; query.page = 1; loadList() }" - @current-change="p => { query.page = p; loadList() }" /> - + @@ -100,7 +172,7 @@ 配置 清除 - 请先选择商品 + 请先选择商品 @@ -112,9 +184,9 @@ 清除 - 请先选择商品 - 云服务器商品,点击选择用户虚拟机作为归属项 - 普通商品,点击将商品ID赋值为归属项 + 请先选择商品 + 云服务器商品,点击选择用户虚拟机作为归属项 + 普通商品,点击将商品ID赋值为归属项 @@ -132,14 +204,28 @@ - 取消 - 确定 + - + + + + + + {{ editForm._goodTag === '云服务器' ? '选择虚拟机' : '使用商品ID' }} + + 清除 + + 云服务器商品,点击选择用户虚拟机作为归属项 + 普通商品,点击将商品ID赋值为归属项 + @@ -155,8 +241,10 @@ - 取消 - 保存 + @@ -165,9 +253,9 @@ { createForm.order_id = o.id; createForm._orderName = o.name }" /> - + - + vmListSelected = r" :height="350" style="width:100%"> + @current-change="r => vmListSelected = r" :height="350" style="width:100%" + :header-cell-style="{ background: '#f8f9fa', color: '#2c3e50', fontWeight: 600 }"> {{ row.user?.UserName || row.user?.username || '-' }} @@ -193,26 +282,31 @@ {{ formatExpireTime(row.expireTime || row.expire_time) }} + + + { vmListQuery.count = s; vmListQuery.page = 1; loadVmListForItem() }" @current-change="p => { vmListQuery.page = p; loadVmListForItem() }" /> - 取消 - 确定选择 + - 加载参数中... - 该商品暂无参数配置 + + - - + + {{ spec.name }} 必填 @@ -224,21 +318,23 @@ - 范围: {{ spec.min || 0 }} ~ {{ spec.max || 9999 }},步长: {{ spec.step || 1 }} + 范围: {{ spec.min || 0 }} ~ {{ spec.max || 9999 }},步长: {{ spec.step || 1 }} - - 生成的参数 JSON: + + 生成的参数 JSON: - 取消 - 确定 + @@ -263,11 +359,10 @@ const router = useRouter() const loading = ref(false) const list = ref([]) const total = ref(0) -const query = reactive({ page: 1, count: 10, key: '' }) +const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '' }) const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-' -// 过期时间为 0001-01-01 时视为无到期时间 const formatExpireTime = (t) => { if (!t) return '-' const d = dayjs(t) @@ -280,11 +375,12 @@ const loadList = async () => { try { const params = { page: query.page, count: query.count } if (query.key) params.key = query.key + if (query.user_id) params.user_id = parseInt(query.user_id) || undefined + if (query.good_id) params.good_id = parseInt(query.good_id) || undefined const res = await getUserGoodsList(params) if (res?.data?.code === 200 && res?.data?.data) { const d = res.data.data list.value = d.data || (Array.isArray(d) ? d : []) - console.log("用户商品列表:",list.value) total.value = d.all_count ?? d.total ?? list.value.length } else { list.value = []; total.value = 0 } } catch { list.value = []; total.value = 0 } finally { loading.value = false } @@ -321,7 +417,6 @@ const loadArgsSpec = async (goodId) => { const res = await getProductParameterList({ good_id: goodId }) if (res?.data?.code === 200) { argsSpecList.value = res.data.data || [] - // 初始化 number 类型的默认值 for (const spec of argsSpecList.value) { if (spec.type === 'number' && argsValues[spec.id] === undefined) { argsValues[spec.id] = spec.min || 0 @@ -332,93 +427,99 @@ const loadArgsSpec = async (goodId) => { } const buildArgsJson = () => { - const result = [] + const argsArray = [] for (const spec of argsSpecList.value) { const val = argsValues[spec.id] - if (val === undefined || val === null || val === '') continue + if (val === undefined || val === '') continue if (spec.type === 'select') { const attr = spec.attrs?.find(a => a.id === val) - if (attr) result.push({ arg_id: spec.id, name: spec.name, attr_id: attr.id, value: attr.value, number: 0 }) + if (attr) argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: attr.id, value: attr.value || '' }) } else if (spec.type === 'number') { - result.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: '', number: val }) + argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: 0, number: Number(val) }) } else { - result.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(val), number: 0 }) + argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(val) }) } } - createForm.order_args = result.length > 0 ? JSON.stringify(result) : '' -} - -// 选择套餐后自动填入参数 -const handlePlanSelectedForCreate = async (plan) => { - createForm.good_plan_id = plan.id - createForm._planName = plan.name - // 解析套餐的 args 字段自动填入参数值 - if (plan.args) { - try { - const planArgs = typeof plan.args === 'string' ? JSON.parse(plan.args) : plan.args - if (Array.isArray(planArgs)) { - for (const arg of planArgs) { - if (arg.arg_id) { - if (arg.attr_id) argsValues[arg.arg_id] = arg.attr_id - else if (arg.number !== undefined) argsValues[arg.arg_id] = arg.number - else if (arg.value) argsValues[arg.arg_id] = arg.value - } - } - buildArgsJson() - } - } catch { /* */ } - } -} - -// 选择商品后加载参数 -// watch 在 createForm 声明后定义,见下方 -const handleDetail = (row) => { - router.push({ path: '/user-goods/vm-detail', query: { id: row.id } }) + createForm.order_args = argsArray.length > 0 ? JSON.stringify(argsArray) : '' } // ---- 新增 ---- const createVisible = ref(false) -const submitLoading = ref(false) const createFormRef = ref(null) +const submitLoading = ref(false) const showProductSelector = ref(false) const showUserSelector = ref(false) const showOrderSelector = ref(false) const showPlanSelector = ref(false) + const createForm = reactive({ - good_id: 0, _goodName: '', _goodTag: '', user_id: 0, _userName: '', - order_id: 0, _orderName: '', good_plan_id: 0, _planName: '', + good_id: 0, _goodName: '', _goodTag: '', + user_id: 0, _userName: '', + order_id: 0, _orderName: '', + good_plan_id: 0, _planName: '', + order_args: '', item_id: 0, _itemName: '', - _renewYuan: 0, _baseYuan: 0, note: '', expire_time: '', - order_args: '' + _renewYuan: 0, _baseYuan: 0, + expire_time: '', note: '' }) + 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' }] } -// 商品选择确认处理 +const handleCreate = () => { + Object.assign(createForm, { + good_id: 0, _goodName: '', _goodTag: '', + user_id: 0, _userName: '', + order_id: 0, _orderName: '', + good_plan_id: 0, _planName: '', + order_args: '', + item_id: 0, _itemName: '', + _renewYuan: 0, _baseYuan: 0, + expire_time: '', note: '' + }) + clearArgsConfig() + createVisible.value = true +} + const handleProductSelected = (p) => { createForm.good_id = p.id - createForm._goodName = p.name + createForm._goodName = p.name || '' createForm._goodTag = p.tag || '' createForm.item_id = 0 createForm._itemName = '' + clearArgsConfig() } -// ---- 归属项选择(item_id) ---- +const handlePlanSelectedForCreate = async (plan) => { + createForm.good_plan_id = plan.id + createForm._planName = plan.name || `套餐 #${plan.id}` + if (plan.args) { + createForm.order_args = typeof plan.args === 'string' ? plan.args : JSON.stringify(plan.args) + } + if (plan.id && createForm.good_id) { + try { + const res = await getProductPlanDetail({ good_id: String(createForm.good_id), plan_id: String(plan.id) }) + if (res?.data?.code === 200 && res.data.data?.args) { + createForm.order_args = typeof res.data.data.args === 'string' ? res.data.data.args : JSON.stringify(res.data.data.args) + } + } catch { /* keep what we have */ } + } +} + +// ---- item_id 选择 ---- +const vmItemTarget = ref('create') const showVmListDialog = ref(false) const vmListForItem = ref([]) const vmListLoading = ref(false) -const vmListTotal = ref(0) const vmListSelected = ref(null) +const vmListTotal = ref(0) const vmListQuery = reactive({ page: 1, count: 10, key: '' }) const handleItemSelect = () => { - if (!createForm.good_id) return if (createForm._goodTag === '云服务器') { - vmListSelected.value = null - vmListQuery.page = 1 - vmListQuery.key = '' + vmItemTarget.value = 'create' showVmListDialog.value = true loadVmListForItem() } else { @@ -438,73 +539,94 @@ const loadVmListForItem = async () => { const d = res.data.data vmListForItem.value = d.data || (Array.isArray(d) ? d : []) vmListTotal.value = d.all_count ?? d.total ?? vmListForItem.value.length - } else { vmListForItem.value = []; vmListTotal.value = 0 } - } catch { vmListForItem.value = []; vmListTotal.value = 0 } finally { vmListLoading.value = false } + } + } catch { vmListForItem.value = [] } finally { vmListLoading.value = false } } const confirmVmForItem = () => { if (!vmListSelected.value) return const vm = vmListSelected.value - createForm.item_id = vm.id - createForm._itemName = `虚拟机 #${vm.id}${vm.good?.name ? ` (${vm.good.name})` : ''}` + if (vmItemTarget.value === 'edit') { + editForm.item_id = vm.id + editForm._itemName = `虚拟机 #${vm.id}` + } else { + createForm.item_id = vm.id + createForm._itemName = `虚拟机 #${vm.id}` + } showVmListDialog.value = false + vmListSelected.value = null + ElMessage.success('已选择虚拟机') } -// 选择商品后加载参数 -watch(() => createForm.good_id, (id) => { - if (id) loadArgsSpec(id) - else clearArgsConfig() -}) - -const handleCreate = () => { - Object.assign(createForm, { good_id: 0, _goodName: '', _goodTag: '', user_id: 0, _userName: '', order_id: 0, _orderName: '', good_plan_id: 0, _planName: '', item_id: 0, _itemName: '', _renewYuan: 0, _baseYuan: 0, note: '', expire_time: '', order_args: '' }) - clearArgsConfig() - createVisible.value = true -} - -const submitCreate = () => { - createFormRef.value?.validate(async (valid) => { - if (!valid) return - submitLoading.value = true - try { - const payload = { - good_id: createForm.good_id, user_id: createForm.user_id, - order_id: createForm.order_id, good_plan_id: createForm.good_plan_id, - item_id: createForm.item_id || 0, - note: createForm.note, - renew_price: Math.round((createForm._renewYuan || 0) * 100), - base_price: Math.round((createForm._baseYuan || 0) * 100) - } - if (createForm.order_args) payload.order_args = createForm.order_args - if (createForm.expire_time) payload.expire_time = formatToApiTime(createForm.expire_time) - const res = await createUserGoods(payload) - if (res?.data?.code === 200) { ElMessage.success('新增成功'); createVisible.value = false; loadList() } - else ElMessage.error(extractApiError(res?.data, '新增失败')) - } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '新增失败')) } finally { submitLoading.value = false } - }) +const submitCreate = async () => { + try { await createFormRef.value?.validate() } catch { return } + submitLoading.value = true + try { + const payload = { + good_id: createForm.good_id, + user_id: createForm.user_id, + item_id: createForm.item_id || 0, + renew_price: Math.round((createForm._renewYuan || 0) * 100), + base_price: Math.round((createForm._baseYuan || 0) * 100), + note: createForm.note || '' + } + if (createForm.order_id) payload.order_id = createForm.order_id + if (createForm.good_plan_id) payload.good_plan_id = createForm.good_plan_id + if (createForm.order_args) payload.order_args = createForm.order_args + if (createForm.expire_time) payload.expire_time = formatToApiTime(createForm.expire_time) + const res = await createUserGoods(payload) + if (res?.data?.code === 200) { ElMessage.success('创建成功'); createVisible.value = false; loadList() } + else ElMessage.error(extractApiError(res?.data, '创建失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { submitLoading.value = false } } // ---- 编辑 ---- const editVisible = ref(false) -const editForm = reactive({ id: 0, note: '', _renewYuan: 0, _baseYuan: 0, expire_time: '' }) +const editForm = reactive({ + id: 0, note: '', _renewYuan: 0, _baseYuan: 0, expire_time: '', + item_id: 0, _itemName: '', _goodTag: '', _goodId: 0 +}) const handleEdit = (row) => { + const goodTag = row.tag || row.good?.tag || '' + const goodId = row.good?.id || row.goodId || 0 + const itemId = row.itemId || row.item_id || 0 + Object.assign(editForm, { id: row.id, note: row.note || '', _renewYuan: ((row.renewPrice || row.renew_price || 0) / 100), _baseYuan: ((row.basePrice || row.base_price || 0) / 100), - expire_time: row.expireTime || row.expire_time || '' + expire_time: row.expireTime || row.expire_time || '', + item_id: itemId, + _goodTag: goodTag, + _goodId: goodId, + _itemName: itemId + ? (goodTag === '云服务器' ? `虚拟机 #${itemId}` : `商品 #${itemId}`) + : '' }) editVisible.value = true } +const handleEditItemSelect = () => { + if (editForm._goodTag === '云服务器') { + vmItemTarget.value = 'edit' + showVmListDialog.value = true + loadVmListForItem() + } else { + editForm.item_id = editForm._goodId + editForm._itemName = `商品 #${editForm._goodId}` + ElMessage.success('已将商品ID赋值为归属项') + } +} + const submitEdit = async () => { submitLoading.value = true try { const payload = { id: editForm.id, note: editForm.note, + item_id: editForm.item_id || 0, renew_price: Math.round((editForm._renewYuan || 0) * 100), base_price: Math.round((editForm._baseYuan || 0) * 100) } @@ -515,7 +637,9 @@ const submitEdit = async () => { } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '保存失败')) } finally { submitLoading.value = false } } -// ---- 删除 ---- +// ---- 详情 / 删除 ---- +const handleDetail = (row) => { router.push({ name: 'UserGoodsDetail', params: { id: row.id } }) } + const handleDelete = (row) => { ElMessageBox.confirm(`确定删除该用户商品吗?`, '删除确认', { type: 'warning' }) .then(async () => { @@ -531,11 +655,200 @@ onMounted(loadList) diff --git a/src/views/product/components/GroupTagManager.vue b/src/views/product/components/GroupTagManager.vue new file mode 100644 index 0000000..a79afaa --- /dev/null +++ b/src/views/product/components/GroupTagManager.vue @@ -0,0 +1,204 @@ + + + + + + + + + + + 查询 + + 重置 + + + + + 新增标签 + + + 刷新 + + + + + + + + + + + + + 编辑 + 删除 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/views/product/components/ProductParameterManager.vue b/src/views/product/components/ProductParameterManager.vue new file mode 100644 index 0000000..7195d75 --- /dev/null +++ b/src/views/product/components/ProductParameterManager.vue @@ -0,0 +1,402 @@ + + + + + + + 新增参数 + + + 刷新 + + + + + + + + + {{ getArgTypeText(row.type) }} + + + + + + 步进: {{ row.step || '-' }} | 范围: {{ row.min ?? '-' }} ~ {{ row.max ?? '-' }} + + - + + + + + + 编辑 + 查看参数值 + 删除 + + + + + + + + + + + + + + + + + + 字符串 + 数字 + 选择 + + + + + + 权限控制 + + + 购买后是否允许单独追加购买 + + + + 是否允许使用用户组优惠 + + + + 是否允许使用用户优惠(代金券与优惠码) + + + 数值参数配置 + + + + + + + + + + + + + + + + + + + + 参数:{{ currentParam?.name }} + + 添加参数值 + + + + + + + + {{ row.value || '-' }} + + {{ getRangeTypeText(row.rangeType) }} {{ row.range }} + + {{ row.value || '-' }} + + + + + ¥{{ (row.price / 100).toFixed(2) }} + + + + + 编辑 + 删除 + + + + + + + + + + + + + + + + + + + + 数值范围配置(phase) + + + + + + + before: 数值 ≤ phase 时匹配 | after: 数值 ≥ phase 时匹配 + + + + + + + + + + + + + + + + + + + + + diff --git a/src/views/product/components/ProductPlanManager.vue b/src/views/product/components/ProductPlanManager.vue new file mode 100644 index 0000000..232a81a --- /dev/null +++ b/src/views/product/components/ProductPlanManager.vue @@ -0,0 +1,512 @@ + + + + + + + 新增套餐 + + + 刷新 + + + + + + + + + + {{ arg.name || arg.value || `参数${arg.arg_id}` }} + + + - + + + + + + + {{ row.disable ? '禁用' : '启用' }} + + + + + + {{ row.showHome || row.show_home ? '展示' : '不展示' }} + + + + + + 编辑 + + {{ row.disable ? '启用' : '禁用' }} + + 删除 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ spec.name }} + + + + {{ attr.name }} + + + + + + (范围: {{ spec.min || 0 }} - {{ spec.max || 9999 }},步长: {{ spec.step || 1 }}) + + + 匹配区间: {{ getMatchedAttrName(spec, selectedArgs[spec.id]) }} + + + + + + + + + + + + + 查看配置JSON + + + 清空选择 + + + + + + + 选择参数配置中未选择的参数作为额外参数 + + + + + + + + + 0 表示没有库存 + + + + 启用后套餐价格将使用固定价格,不再根据参数计算 + + + + + + + + + + 启用 + 禁用 + + + + + 控制商品套餐是否在首页显示 + + + + + + + + + + + + + 已选择 {{ Object.keys(selectedArgs).filter(k => selectedArgs[k] !== undefined && selectedArgs[k] !== '').length }} 个参数 + {{ isArgsValid ? '配置有效' : '部分参数未选择' }} + + + + + {{ spec.name }}: + {{ getSelectedValueDisplay(spec) || '未选择' }} + + + JSON 数据 + {{ formatArgsJsonPreview() }} + + + 关闭 + + + + + + + diff --git a/src/views/user-vm/UserVmList.vue b/src/views/user-vm/UserVmList.vue index 5ca5bce..5ce027e 100644 --- a/src/views/user-vm/UserVmList.vue +++ b/src/views/user-vm/UserVmList.vue @@ -78,33 +78,36 @@ - - - + + + + - - 选择 + + 选择 - - 选择 + + 选择 + - + + - + @@ -113,95 +116,136 @@ - + + - - - - - 选择 - 清除 - - - - 选择 - 清除 - + + + + 选择 + 清除 - + + - + - + + + + 选择 + 清除 + + + + + + + + - - - + + + + - + - - + + + - - + + + - - - - 选择 - 清除 + + 选择 + 清除 + + + + + + + + 选择 + 清除 + + + + + + + + + + 选择 + 清除 + + + + + + + + 选择 + 清除 + + 请先选择宿主机 + + + + @@ -212,7 +256,7 @@ - + @@ -251,17 +295,15 @@ - + 选择 清除 - - 选择 + + 选择 清除 @@ -272,22 +314,20 @@ - + { createForm.good_id = p.id; createForm._goodName = p.name }" /> - { createForm.user_id = u.user_id; createForm._userName = u.user_name }" /> - { createForm.order_id = o.id; createForm._orderName = o.name }" /> - - { createForm._serviceId = s.id; createForm._serviceName = s.name; createForm.image_id = 0; createForm._imageName = '' }" /> - + { createForm._serviceId = s.id; createForm._serviceName = s.name; createForm.image_id = 0; createForm._imageName = ''; createForm.host_id = 0; createForm._hostName = ''; createForm.host_group_id = 0; createForm._hostGroupName = ''; createForm.network_ids = []; createForm._networkNames = '' }" /> { createForm.image_id = img.id; createForm._imageName = img.name }" /> - - { editForm.port_group_id = sg.id; editForm._sgName = sg.name }" /> - - { editForm.internet_network_id = net.id; editForm._networkName = net.name }" /> + + { createForm.host_id = h.id; createForm._hostName = h.name || h.ip; createForm.network_ids = []; createForm._networkNames = '' }" /> + + { createForm.host_group_id = g.id; createForm._hostGroupName = g.name }" /> + + addNetwork(n)" /> + { editForm.port_group_id = sg.id; editForm._sgName = sg.name }" /> + { editForm.internet_network_id = net.id; editForm._networkName = net.name }" /> @@ -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
{{ formatArgsJsonPreview() }}