fix: 提交修改
This commit is contained in:
@@ -168,7 +168,7 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<template v-if="row.isGroup">
|
||||
@@ -437,7 +437,7 @@
|
||||
<el-dialog
|
||||
v-model="showTagSelector"
|
||||
title="选择分组标签"
|
||||
width="600px"
|
||||
width="650px"
|
||||
append-to-body
|
||||
>
|
||||
<div class="tag-selector-header">
|
||||
@@ -467,6 +467,11 @@
|
||||
<el-tag type="primary">{{ row.name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link size="small" @click.stop="handleDeleteTagFromSelector(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无标签数据" :image-size="60" />
|
||||
</template>
|
||||
@@ -551,6 +556,7 @@
|
||||
v-model="coverSelectorVisible"
|
||||
:user-id="1"
|
||||
:current-cover-id="productForm.cover_id"
|
||||
title="选择封面"
|
||||
@confirm="handleProductCoverSelect"
|
||||
/>
|
||||
|
||||
@@ -560,14 +566,20 @@
|
||||
<el-form-item label="库存数量" prop="inventory">
|
||||
<el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品价格(元)" prop="price">
|
||||
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="width: 100%" />
|
||||
<el-form-item label="商品价格" prop="price">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="单个商品数量" prop="pay_num">
|
||||
<el-input-number v-model="productForm.pay_num" :min="1" placeholder="请输入单个商品数量" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期(天)" prop="expire_time">
|
||||
<el-input-number v-model="productForm.expire_time" :min="0" placeholder="请输入有效期" style="width: 100%" />
|
||||
<el-form-item label="有效期" prop="expire_time">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="productForm.expire_time" :min="0" placeholder="请输入有效期" style="flex:1" />
|
||||
<span class="unit-text">天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="推荐" prop="recommend">
|
||||
<el-switch v-model="productForm.recommend" active-text="启用" inactive-text="禁用" />
|
||||
@@ -651,6 +663,7 @@ import {
|
||||
hideProductGroup,
|
||||
startProductGroup,
|
||||
getProductGroupTagList,
|
||||
deleteProductGroupTag,
|
||||
getProductList,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
@@ -1117,6 +1130,25 @@ const clearTag = () => {
|
||||
groupForm.tag_id = undefined
|
||||
}
|
||||
|
||||
const handleDeleteTagFromSelector = (row) => {
|
||||
ElMessageBox.confirm(`确定删除标签「${row.name}」吗?删除后使用该标签的分组将失去关联。`, '删除确认', { type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const res = await deleteProductGroupTag({ id: row.id })
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('标签已删除')
|
||||
fetchTagOptionsForSelector()
|
||||
fetchAllTagOptions()
|
||||
} else {
|
||||
ElMessage.error(res?.data?.message || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '删除失败')
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
watch(showTagSelector, (val) => {
|
||||
if (val) {
|
||||
tagSelectorSearch.value = ''
|
||||
@@ -1468,7 +1500,7 @@ const clearProductGroup = () => {
|
||||
}
|
||||
|
||||
const handleProductCoverSelect = (file) => {
|
||||
productForm.cover_id = file.id
|
||||
productForm.cover_id = file.cover_id
|
||||
coverSelectorVisible.value = false
|
||||
}
|
||||
|
||||
@@ -1791,6 +1823,9 @@ onMounted(() => {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
|
||||
.recommend-user-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+378
-153
@@ -73,7 +73,11 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="商品名称" min-width="200" />
|
||||
<el-table-column label="商品名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
{{ row.name }}(ID:{{ row.id }})
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标签" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.tag" size="small" type="success">{{ row.tag }}</el-tag>
|
||||
@@ -221,6 +225,7 @@
|
||||
v-model="coverSelectorVisible"
|
||||
:user-id="1"
|
||||
:current-cover-id="productForm.cover_id"
|
||||
title="选择封面"
|
||||
@confirm="handleCoverSelect"
|
||||
/>
|
||||
<el-form-item label="库存控制" prop="inventory_control">
|
||||
@@ -229,14 +234,20 @@
|
||||
<el-form-item label="库存数量" prop="inventory">
|
||||
<el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品价格(元)" prop="price">
|
||||
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="width: 100%" />
|
||||
<el-form-item label="商品价格" prop="price">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="单个商品数量" prop="pay_num">
|
||||
<el-input-number v-model="productForm.pay_num" :min="1" placeholder="请输入单个商品数量" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期(天)" prop="expire_time">
|
||||
<el-input-number v-model="productForm.expire_time" :min="0" placeholder="请输入有效期" style="width: 100%" />
|
||||
<el-form-item label="有效期" prop="expire_time">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="productForm.expire_time" :min="0" placeholder="请输入有效期" style="flex:1" />
|
||||
<span class="unit-text">天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="推荐" prop="recommend">
|
||||
<el-switch v-model="productForm.recommend" active-text="启用" inactive-text="禁用" />
|
||||
@@ -252,6 +263,13 @@
|
||||
</el-select>
|
||||
<div class="form-tip">all: 所有参数 / plan: 套餐 / customize: 自定义</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="归属项 ID" prop="attribution_id">
|
||||
<el-select v-model="productForm.attribution_id" placeholder="请选择归属项" style="width: 100%" @change="handleAttributionChange">
|
||||
<el-option label="虚拟机" value="vm" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
<div class="form-tip">选择归属项类型,虚拟机会关联相关数据</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
@@ -320,61 +338,57 @@
|
||||
:title="paramFormType === 'add' ? '新增商品参数' : '编辑商品参数'"
|
||||
width="600px"
|
||||
append-to-body
|
||||
class="tk-dialog"
|
||||
>
|
||||
<el-form
|
||||
ref="paramFormRef"
|
||||
:model="paramForm"
|
||||
:rules="paramRules"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item label="参数名称" prop="arg_name">
|
||||
<el-input v-model="paramForm.arg_name" placeholder="请输入参数名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="参数类型" prop="arg_type">
|
||||
<el-radio-group v-model="paramForm.arg_type">
|
||||
<el-radio label="string">字符串</el-radio>
|
||||
<el-radio label="number">数字</el-radio>
|
||||
<el-radio label="select">选择</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否必选" prop="must">
|
||||
<el-switch
|
||||
v-model="paramForm.must"
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
active-text="必选"
|
||||
inactive-text="可选"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">权限控制</el-divider>
|
||||
<el-form-item label="允许单独购买">
|
||||
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户组优惠">
|
||||
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户优惠">
|
||||
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠(代金券与优惠码)</div>
|
||||
</el-form-item>
|
||||
<!-- number 类型参数的额外配置 -->
|
||||
<el-form ref="paramFormRef" :model="paramForm" :rules="paramRules" label-width="100px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="参数名称" prop="arg_name">
|
||||
<el-input v-model="paramForm.arg_name" placeholder="请输入参数名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="参数类型" prop="arg_type">
|
||||
<el-radio-group v-model="paramForm.arg_type">
|
||||
<el-radio label="string">字符串</el-radio>
|
||||
<el-radio label="number">数字</el-radio>
|
||||
<el-radio label="select">选择</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否必选" prop="must">
|
||||
<el-switch v-model="paramForm.must" :active-value="true" :inactive-value="false" active-text="必选" inactive-text="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">权限控制</div>
|
||||
<el-form-item label="允许单独购买">
|
||||
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户组优惠">
|
||||
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户优惠">
|
||||
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠(代金券与优惠码)</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template v-if="paramForm.arg_type === 'number'">
|
||||
<el-divider content-position="left">数值参数配置</el-divider>
|
||||
<el-form-item label="步进值" prop="arg_step">
|
||||
<el-input-number v-model="paramForm.arg_step" :min="1" placeholder="步进值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最小值" prop="arg_min">
|
||||
<el-input-number v-model="paramForm.arg_min" placeholder="最小值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大值" prop="arg_max">
|
||||
<el-input-number v-model="paramForm.arg_max" placeholder="最大值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">数值参数配置</div>
|
||||
<el-form-item label="步进值" prop="arg_step">
|
||||
<el-input-number v-model="paramForm.arg_step" :min="1" placeholder="步进值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最小值" prop="arg_min">
|
||||
<el-input-number v-model="paramForm.arg_min" placeholder="最小值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大值" prop="arg_max">
|
||||
<el-input-number v-model="paramForm.arg_max" placeholder="最大值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="paramFormDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitParamForm">确定</el-button>
|
||||
</div>
|
||||
@@ -440,45 +454,44 @@
|
||||
:title="paramValueFormType === 'add' ? '添加参数值' : '编辑参数值'"
|
||||
width="550px"
|
||||
append-to-body
|
||||
class="tk-dialog"
|
||||
>
|
||||
<el-form
|
||||
ref="paramValueFormRef"
|
||||
:model="paramValueForm"
|
||||
:rules="paramValueRules"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item label="值名称" prop="attr_name">
|
||||
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" />
|
||||
</el-form-item>
|
||||
<!-- select 类型显示参数值 -->
|
||||
<el-form-item v-if="currentParam?.type === 'select'" label="参数值" prop="attr_value">
|
||||
<el-input v-model="paramValueForm.attr_value" placeholder="请输入参数值" />
|
||||
</el-form-item>
|
||||
<!-- number 类型显示范围配置 -->
|
||||
<el-form ref="paramValueFormRef" :model="paramValueForm" :rules="paramValueRules" label-width="100px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">参数值信息</div>
|
||||
<el-form-item label="值名称" prop="attr_name">
|
||||
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="currentParam?.type === 'select'" label="参数值" prop="attr_value">
|
||||
<el-input v-model="paramValueForm.attr_value" placeholder="请输入参数值" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number v-model="paramValueForm.index" :min="0" placeholder="排序索引" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格" prop="attr_price">
|
||||
<el-input-number v-model="paramValueForm.attr_price" :min="0" placeholder="请输入价格" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template v-if="currentParam?.type === 'number'">
|
||||
<el-divider content-position="left">数值范围配置(phase)</el-divider>
|
||||
<el-form-item label="范围类型" prop="range_type">
|
||||
<el-select v-model="paramValueForm.range_type" placeholder="请选择范围类型" style="width: 100%">
|
||||
<el-option label="小于等于 (before)" value="before" />
|
||||
<el-option label="大于等于 (after)" value="after" />
|
||||
<el-option label="等于 (equal)" value="equal" />
|
||||
</el-select>
|
||||
<div class="form-tip">before: 数值 ≤ phase 时匹配 | after: 数值 ≥ phase 时匹配</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="阈值" prop="attr_range">
|
||||
<el-input-number v-model="paramValueForm.attr_range" :min="0" placeholder="范围阈值" style="width: 100%" />
|
||||
<div class="form-tip">例如:phase=100, rangeType=before 表示 0-100 范围</div>
|
||||
</el-form-item>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">数值范围配置</div>
|
||||
<el-form-item label="范围类型" prop="range_type">
|
||||
<el-select v-model="paramValueForm.range_type" placeholder="请选择范围类型" style="width: 100%">
|
||||
<el-option label="小于 (before)" value="before" />
|
||||
<el-option label="大于 (after)" value="after" />
|
||||
<el-option label="等于 (equal)" value="equal" />
|
||||
</el-select>
|
||||
<div class="form-tip">before: 数值 < phase 时匹配 | after: 数值 > phase 时匹配</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="阈值" prop="attr_range">
|
||||
<el-input-number v-model="paramValueForm.attr_range" :min="0" placeholder="范围阈值" style="width: 100%" />
|
||||
<div class="form-tip">例如:phase=100, rangeType=before 表示 0-100 范围</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number v-model="paramValueForm.index" :min="0" placeholder="排序索引" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格" prop="attr_price">
|
||||
<el-input-number v-model="paramValueForm.attr_price" :min="0" placeholder="请输入价格" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="paramValueFormDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitParamValueForm">确定</el-button>
|
||||
</div>
|
||||
@@ -541,6 +554,13 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="允许升级" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.canUpdate || row.can_update ? 'success' : 'info'" size="small">
|
||||
{{ row.canUpdate || row.can_update ? '允许' : '不允许' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEditPlan(row)">编辑</el-button>
|
||||
@@ -631,16 +651,26 @@
|
||||
<template v-else-if="spec.type === 'number'">
|
||||
<div class="number-input-wrapper">
|
||||
<el-input-number
|
||||
v-model="selectedArgs[spec.id]"
|
||||
:min="spec.min || 0"
|
||||
:max="spec.max || 9999"
|
||||
:step="spec.step || 1"
|
||||
v-model="displayValues[spec.id]"
|
||||
:min="getSpecDisplayMin(spec)"
|
||||
:max="getSpecDisplayMax(spec)"
|
||||
:step="getSpecDisplayStep(spec)"
|
||||
:step-strictly="true"
|
||||
size="small"
|
||||
@change="updateArgsJson"
|
||||
@change="onNumberDisplayChange(spec)"
|
||||
/>
|
||||
<el-select
|
||||
v-if="hasUnit(spec)"
|
||||
:model-value="displayUnits[spec.id]"
|
||||
size="small"
|
||||
style="width: 90px"
|
||||
@change="(newUnit) => onPlanUnitChange(spec, newUnit)"
|
||||
>
|
||||
<el-option v-for="u in getParamUnits(spec)" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
<span class="number-range">
|
||||
(范围: {{ spec.min || 0 }} - {{ spec.max || 9999 }},步长: {{ spec.step || 1 }})
|
||||
({{ spec.min ?? 0 }} - {{ spec.max ?? 0 }}
|
||||
<template v-if="hasUnit(spec)"> {{ getBaseUnit(getArgKey(spec)) }}</template>,步长: {{ spec.step ?? 1 }})
|
||||
</span>
|
||||
</div>
|
||||
<!-- 显示匹配的价格区间 -->
|
||||
@@ -666,15 +696,18 @@
|
||||
<el-empty v-else-if="planSpecList.length > 0" description="请先选择需要配置的参数" :image-size="60" />
|
||||
<el-empty v-else description="暂无参数配置,请先为商品添加参数" :image-size="60" />
|
||||
|
||||
<!-- 查看JSON按钮 -->
|
||||
<div class="args-actions" v-if="selectedArgSpecs.length > 0">
|
||||
<el-button type="info" plain size="small" @click="showArgsPreview = true">
|
||||
<el-icon><View /></el-icon>
|
||||
查看配置JSON
|
||||
<el-icon><View /></el-icon>查看配置JSON
|
||||
</el-button>
|
||||
<el-button type="primary" plain size="small" @click="handleCopyArgsJson">
|
||||
<el-icon><CopyDocument /></el-icon>复制JSON
|
||||
</el-button>
|
||||
<el-button type="success" plain size="small" @click="handlePasteArgsJson">
|
||||
<el-icon><DocumentAdd /></el-icon>粘贴JSON
|
||||
</el-button>
|
||||
<el-button type="warning" plain size="small" @click="clearArgsSelection">
|
||||
<el-icon><Delete /></el-icon>
|
||||
清空选择
|
||||
<el-icon><Delete /></el-icon>清空选择
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -728,8 +761,11 @@
|
||||
/>
|
||||
<div class="form-tip">启用后套餐价格将使用固定价格,不再根据参数计算</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="固定价格(元)" prop="fixed_price" v-if="planForm.enable_fixed_price === true">
|
||||
<el-input-number v-model="planForm.fixed_price" :min="0" :precision="2" :step="0.01" style="width: 100%" placeholder="请输入固定价格(元)" />
|
||||
<el-form-item label="固定价格" prop="fixed_price" v-if="planForm.enable_fixed_price === true">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="planForm.fixed_price" :min="0" :precision="2" :step="0.01" style="flex:1" placeholder="请输入固定价格(元)" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number v-model="planForm.index" :min="0" style="width: 100%" />
|
||||
@@ -748,6 +784,10 @@
|
||||
/>
|
||||
<div class="form-tip">控制商品套餐是否在首页显示</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="允许升级" prop="can_update">
|
||||
<el-switch v-model="planForm.can_update" active-text="允许" inactive-text="不允许" />
|
||||
<div class="form-tip">控制用户是否可以升级到此套餐</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -789,6 +829,15 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 手动粘贴JSON对话框 -->
|
||||
<el-dialog v-model="showPasteDialog" title="粘贴JSON" width="500px" append-to-body>
|
||||
<el-input v-model="pasteJsonText" type="textarea" :rows="8" placeholder="请将JSON粘贴到此处" />
|
||||
<template #footer>
|
||||
<el-button @click="showPasteDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="doPasteFromText">确定导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 商品分组选择器对话框 -->
|
||||
<el-dialog
|
||||
v-model="showGroupSelector"
|
||||
@@ -851,9 +900,14 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Delete, Search, Refresh, Picture, ArrowRight, Loading, View } from '@element-plus/icons-vue'
|
||||
import { Plus, Delete, Search, Refresh, Picture, ArrowRight, Loading, View, CopyDocument, DocumentAdd } from '@element-plus/icons-vue'
|
||||
import AvatarSelector from '@/components/admin/AvatarSelector.vue'
|
||||
import { getProductList, createProduct, updateProduct, deleteProduct, getProductGroupList,
|
||||
import {
|
||||
getProductList,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
getProductGroupList,
|
||||
getProductTagList,
|
||||
getProductParameterList,
|
||||
getProductParameterDetail,
|
||||
@@ -873,6 +927,11 @@ import { getProductList, createProduct, updateProduct, deleteProduct, getProduct
|
||||
disablePlanFixedPrice,
|
||||
enablePlanFixedPrice
|
||||
} from '@/api/admin/product'
|
||||
import { getUserVmList } from '@/api/admin/userVm'
|
||||
import {
|
||||
hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit,
|
||||
toBaseUnit, fromBaseUnit, formatValueWithUnit
|
||||
} from '@/utils/dynamicUnit'
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
@@ -897,7 +956,8 @@ const productForm = reactive({
|
||||
expire_time: 0,
|
||||
recommend: false,
|
||||
recommend_rebate: 0,
|
||||
arg_type: 'all' // 商品参数类型 all/plan/customize
|
||||
arg_type: 'all', // 商品参数类型 all/plan/customize
|
||||
attribution_id: '' // 归属项ID
|
||||
})
|
||||
|
||||
const productRules = {
|
||||
@@ -1170,6 +1230,7 @@ const handleAdd = () => {
|
||||
good_group_id: undefined,
|
||||
inventory_control: false,
|
||||
inventory: 0,
|
||||
attribution_id: '',
|
||||
price: 0,
|
||||
pay_num: 1,
|
||||
expire_time: 0,
|
||||
@@ -1295,7 +1356,8 @@ const submitForm = () => {
|
||||
pay_num: productForm.pay_num || 1,
|
||||
expire_time: productForm.expire_time || 0,
|
||||
recommend_rebate: productForm.recommend_rebate || 0,
|
||||
arg_type: productForm.arg_type || 'all' // 商品参数类型
|
||||
arg_type: productForm.arg_type || 'all', // 商品参数类型
|
||||
attribution_id: productForm.attribution_id || '' // 归属项ID
|
||||
}
|
||||
|
||||
console.log('提交的数据:', submitData) // 调试日志
|
||||
@@ -1318,6 +1380,27 @@ const submitForm = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 处理归属项变化
|
||||
const handleAttributionChange = async (value) => {
|
||||
if (value === 'vm' && productForm.id) {
|
||||
try {
|
||||
// 当选择虚拟机时,调用用户虚拟机列表API
|
||||
const res = await getUserVmList({
|
||||
page: 1,
|
||||
count: 10,
|
||||
good_id: productForm.id // 传递当前商品ID
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
console.log('虚拟机列表:', res.data.data)
|
||||
ElMessage.success('已获取虚拟机关联数据')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取虚拟机列表失败:', error)
|
||||
ElMessage.error('获取虚拟机关联数据失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 打开封面选择器
|
||||
const openCoverSelector = () => {
|
||||
coverSelectorVisible.value = true
|
||||
@@ -1436,7 +1519,7 @@ const getArgTypeTag = (type) => {
|
||||
|
||||
// 范围类型显示
|
||||
const getRangeTypeText = (type) => {
|
||||
const typeMap = { 'after': '大于 >', 'before': '小于 <', 'equal': '等于 =' }
|
||||
const typeMap = { 'after': '大于 >', 'before': '小于 <', 'equal': '等于 =' }
|
||||
return typeMap[type] || type || '-'
|
||||
}
|
||||
|
||||
@@ -1566,19 +1649,17 @@ const fetchParamValuesList = async () => {
|
||||
const handleAddParamValue = () => {
|
||||
paramValueFormType.value = 'add'
|
||||
paramValueFormDialogVisible.value = true
|
||||
// 先重置表单数据
|
||||
Object.assign(paramValueForm, {
|
||||
attr_id: undefined,
|
||||
attr_name: '',
|
||||
attr_value: '',
|
||||
attr_price: 0,
|
||||
index: 0,
|
||||
attr_range: 0,
|
||||
range_type: 'equal'
|
||||
})
|
||||
// 等待 DOM 更新后再重置表单验证状态
|
||||
nextTick(() => {
|
||||
paramValueFormRef.value?.resetFields()
|
||||
Object.assign(paramValueForm, {
|
||||
attr_id: undefined,
|
||||
attr_name: '',
|
||||
attr_value: '',
|
||||
attr_price: 0,
|
||||
index: 0,
|
||||
attr_range: 0,
|
||||
range_type: 'equal'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1682,7 +1763,8 @@ const planForm = reactive({
|
||||
enable_fixed_price: false,
|
||||
index: 0,
|
||||
disable: false,
|
||||
show_home: false
|
||||
show_home: false,
|
||||
can_update: false
|
||||
})
|
||||
|
||||
const planFormRules = {
|
||||
@@ -1692,8 +1774,12 @@ const planFormRules = {
|
||||
// 套餐参数选择相关
|
||||
const planSpecList = ref([]) // 当前商品的参数列表
|
||||
const selectedArgIds = ref([]) // 选中的参数ID列表
|
||||
const selectedArgs = reactive({}) // 选中的参数值 { arg_id: value_id 或 value }
|
||||
const showArgsPreview = ref(false) // 显示参数预览对话框
|
||||
const selectedArgs = reactive({}) // 选中的参数值(基础单位)
|
||||
const displayValues = reactive({}) // 显示值(当前选中单位)
|
||||
const displayUnits = reactive({}) // 当前选中的显示单位
|
||||
const showArgsPreview = ref(false)
|
||||
const showPasteDialog = ref(false)
|
||||
const pasteJsonText = ref('')
|
||||
|
||||
// 额外参数相关
|
||||
const selectedExtraArgIds = ref([]) // 选中的额外参数ID列表
|
||||
@@ -1724,13 +1810,19 @@ const fetchPlanSpecList = async () => {
|
||||
|
||||
// 参数选择变化
|
||||
const onSelectedArgsChange = () => {
|
||||
// 清除未选中参数的值
|
||||
for (const key in selectedArgs) {
|
||||
if (!selectedArgIds.value.includes(Number(key))) {
|
||||
delete selectedArgs[key]
|
||||
delete displayValues[key]
|
||||
delete displayUnits[key]
|
||||
}
|
||||
}
|
||||
for (const specId of selectedArgIds.value) {
|
||||
const spec = planSpecList.value.find(s => s.id === specId)
|
||||
if (spec && spec.type === 'number' && hasUnit(spec) && !displayUnits[specId]) {
|
||||
displayUnits[specId] = getParamDefaultUnit(spec)
|
||||
}
|
||||
}
|
||||
// 同时从额外参数中移除已选的参数
|
||||
selectedExtraArgIds.value = selectedExtraArgIds.value.filter(
|
||||
id => !selectedArgIds.value.includes(id)
|
||||
)
|
||||
@@ -1738,6 +1830,49 @@ const onSelectedArgsChange = () => {
|
||||
updateExtraArgIds()
|
||||
}
|
||||
|
||||
const getSpecDisplayMin = (spec) => {
|
||||
if (!hasUnit(spec)) return spec.min ?? 0
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
return unit ? fromBaseUnit(spec.min ?? 0, unit, argKey) : (spec.min ?? 0)
|
||||
}
|
||||
const getSpecDisplayMax = (spec) => {
|
||||
if (!hasUnit(spec)) return spec.max ?? 0
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
return unit ? fromBaseUnit(spec.max ?? 0, unit, argKey) : (spec.max ?? 0)
|
||||
}
|
||||
const getSpecDisplayStep = (spec) => {
|
||||
if (!hasUnit(spec)) return spec.step ?? 1
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
if (!unit) return spec.step ?? 1
|
||||
const converted = fromBaseUnit(spec.step ?? 1, unit, argKey)
|
||||
return converted > 0 ? converted : 1
|
||||
}
|
||||
|
||||
const onNumberDisplayChange = (spec) => {
|
||||
if (hasUnit(spec)) {
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
selectedArgs[spec.id] = Math.round(toBaseUnit(displayValues[spec.id] || 0, unit, argKey))
|
||||
} else {
|
||||
selectedArgs[spec.id] = displayValues[spec.id]
|
||||
}
|
||||
updateArgsJson()
|
||||
}
|
||||
|
||||
const onPlanUnitChange = (spec, newUnit) => {
|
||||
const argKey = getArgKey(spec)
|
||||
const oldUnit = displayUnits[spec.id]
|
||||
const oldDisplay = displayValues[spec.id] || 0
|
||||
const baseValue = oldUnit ? toBaseUnit(oldDisplay, oldUnit, argKey) : oldDisplay
|
||||
displayUnits[spec.id] = newUnit
|
||||
displayValues[spec.id] = fromBaseUnit(baseValue, newUnit, argKey)
|
||||
selectedArgs[spec.id] = Math.round(baseValue)
|
||||
updateArgsJson()
|
||||
}
|
||||
|
||||
// 额外参数选择变化
|
||||
const onSelectedExtraArgsChange = () => {
|
||||
updateExtraArgIds()
|
||||
@@ -1775,34 +1910,25 @@ const updateArgsJson = () => {
|
||||
if (selectedValue === undefined || selectedValue === '') continue
|
||||
|
||||
if (spec.type === 'select') {
|
||||
// select 类型:找到选中的值对象
|
||||
const attrObj = spec.attrs?.find(a => a.id === selectedValue)
|
||||
if (attrObj) {
|
||||
argsArray.push({
|
||||
arg_id: spec.id,
|
||||
name: spec.name,
|
||||
attr_id: attrObj.id,
|
||||
value: attrObj.value || ''
|
||||
arg_id: spec.id, name: spec.name, attr_id: attrObj.id,
|
||||
value: attrObj.value || '', key: getArgKey(spec) || undefined
|
||||
})
|
||||
}
|
||||
} else if (spec.type === 'number') {
|
||||
// number 类型:根据数值找到对应的价格区间ID
|
||||
const numValue = Number(selectedValue)
|
||||
const matchedAttr = findMatchingNumberAttr(spec, numValue)
|
||||
|
||||
argsArray.push({
|
||||
arg_id: spec.id,
|
||||
name: spec.name,
|
||||
arg_id: spec.id, name: spec.name,
|
||||
attr_id: matchedAttr ? matchedAttr.id : 0,
|
||||
number: numValue
|
||||
number: numValue, key: getArgKey(spec) || undefined
|
||||
})
|
||||
} else {
|
||||
// string 类型
|
||||
argsArray.push({
|
||||
arg_id: spec.id,
|
||||
name: spec.name,
|
||||
attr_id: 0,
|
||||
value: String(selectedValue)
|
||||
arg_id: spec.id, name: spec.name, attr_id: 0,
|
||||
value: String(selectedValue), key: getArgKey(spec) || undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1821,12 +1947,9 @@ const findMatchingNumberAttr = (spec, numValue) => {
|
||||
const phase = attr.phase || 0
|
||||
const rangeType = attr.rangeType || 'before'
|
||||
|
||||
// rangeType: before 表示小于等于 phase
|
||||
// rangeType: after 表示大于等于 phase
|
||||
// rangeType: equal 表示等于 phase
|
||||
if (rangeType === 'before' && numValue <= phase) {
|
||||
if (rangeType === 'before' && numValue < phase) {
|
||||
return attr
|
||||
} else if (rangeType === 'after' && numValue >= phase) {
|
||||
} else if (rangeType === 'after' && numValue > phase) {
|
||||
return attr
|
||||
} else if (rangeType === 'equal' && numValue === phase) {
|
||||
return attr
|
||||
@@ -1858,24 +1981,108 @@ const generateArgId = (argId, value) => {
|
||||
// 清空参数选择
|
||||
const clearArgsSelection = () => {
|
||||
selectedArgIds.value = []
|
||||
for (const key in selectedArgs) {
|
||||
delete selectedArgs[key]
|
||||
}
|
||||
for (const key in selectedArgs) delete selectedArgs[key]
|
||||
for (const key in displayValues) delete displayValues[key]
|
||||
for (const key in displayUnits) delete displayUnits[key]
|
||||
selectedExtraArgIds.value = []
|
||||
planForm.args = ''
|
||||
planForm.extra_arg_ids = ''
|
||||
planForm.extra_arg_ids_array = []
|
||||
}
|
||||
|
||||
// 获取选中值的显示文本
|
||||
const handleCopyArgsJson = async () => {
|
||||
if (!planForm.args) { ElMessage.warning('暂无参数配置可复制'); return }
|
||||
try {
|
||||
await navigator.clipboard.writeText(planForm.args)
|
||||
ElMessage.success('已复制参数配置JSON到剪贴板')
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = planForm.args
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
ElMessage.success('已复制参数配置JSON到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasteArgsJson = async () => {
|
||||
let clipText = ''
|
||||
try {
|
||||
clipText = await navigator.clipboard.readText()
|
||||
} catch {
|
||||
pasteJsonText.value = ''
|
||||
showPasteDialog.value = true
|
||||
return
|
||||
}
|
||||
if (!clipText || !clipText.trim()) { ElMessage.warning('剪贴板为空'); return }
|
||||
applyPastedJson(clipText)
|
||||
}
|
||||
|
||||
const doPasteFromText = () => {
|
||||
const text = pasteJsonText.value
|
||||
if (!text || !text.trim()) { ElMessage.warning('请输入JSON内容'); return }
|
||||
showPasteDialog.value = false
|
||||
applyPastedJson(text)
|
||||
}
|
||||
|
||||
const applyPastedJson = (jsonText) => {
|
||||
let pastedArgs
|
||||
try {
|
||||
pastedArgs = JSON.parse(jsonText)
|
||||
if (!Array.isArray(pastedArgs)) { ElMessage.error('JSON格式错误,需要数组格式'); return }
|
||||
} catch {
|
||||
ElMessage.error('JSON解析失败,请检查格式')
|
||||
return
|
||||
}
|
||||
clearArgsSelection()
|
||||
const matchedIds = []
|
||||
for (const arg of pastedArgs) {
|
||||
let spec = null
|
||||
if (arg.key) spec = planSpecList.value.find(s => getArgKey(s) === arg.key)
|
||||
if (!spec && arg.name) spec = planSpecList.value.find(s => s.name === arg.name)
|
||||
if (!spec && arg.arg_id) spec = planSpecList.value.find(s => s.id === arg.arg_id)
|
||||
if (!spec) continue
|
||||
matchedIds.push(spec.id)
|
||||
if (spec.type === 'select') {
|
||||
if (arg.attr_id) {
|
||||
const attrObj = spec.attrs?.find(a => a.id === arg.attr_id)
|
||||
if (attrObj) selectedArgs[spec.id] = attrObj.id
|
||||
else if (arg.value) { const byVal = spec.attrs?.find(a => a.value === arg.value || a.name === arg.value); if (byVal) selectedArgs[spec.id] = byVal.id }
|
||||
} else if (arg.value) { const byVal = spec.attrs?.find(a => a.value === arg.value || a.name === arg.value); if (byVal) selectedArgs[spec.id] = byVal.id }
|
||||
} else if (spec.type === 'number') {
|
||||
const numVal = Number(arg.number !== undefined ? arg.number : arg.value)
|
||||
selectedArgs[spec.id] = numVal
|
||||
if (hasUnit(spec)) {
|
||||
const unit = getParamDefaultUnit(spec)
|
||||
displayUnits[spec.id] = unit
|
||||
displayValues[spec.id] = fromBaseUnit(numVal, unit, getArgKey(spec))
|
||||
} else {
|
||||
displayValues[spec.id] = numVal
|
||||
}
|
||||
} else {
|
||||
selectedArgs[spec.id] = arg.value || ''
|
||||
}
|
||||
}
|
||||
selectedArgIds.value = matchedIds
|
||||
updateArgsJson()
|
||||
updateExtraArgIds()
|
||||
ElMessage.success(`已从JSON导入 ${matchedIds.length} 个参数配置`)
|
||||
}
|
||||
|
||||
const getSelectedValueDisplay = (spec) => {
|
||||
const selectedValue = selectedArgs[spec.id]
|
||||
if (selectedValue === undefined || selectedValue === '') return null
|
||||
|
||||
if (spec.type === 'select') {
|
||||
const attrObj = spec.attrs?.find(a => a.id === selectedValue)
|
||||
return attrObj ? attrObj.name : null
|
||||
}
|
||||
if (hasUnit(spec)) {
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id] || getParamDefaultUnit(spec)
|
||||
const displayVal = fromBaseUnit(Number(selectedValue), unit, argKey)
|
||||
return formatValueWithUnit(displayVal, unit)
|
||||
}
|
||||
return String(selectedValue)
|
||||
}
|
||||
|
||||
@@ -1928,8 +2135,15 @@ const initSelectedArgsFromJson = (argsJson, extraArgIds = []) => {
|
||||
}
|
||||
}
|
||||
} else if (spec.type === 'number') {
|
||||
// number 类型:优先使用 number 字段,兼容 value 字段
|
||||
selectedArgs[spec.id] = Number(arg.number !== undefined ? arg.number : arg.value)
|
||||
const numVal = Number(arg.number !== undefined ? arg.number : arg.value)
|
||||
selectedArgs[spec.id] = numVal
|
||||
if (hasUnit(spec)) {
|
||||
const unit = getParamDefaultUnit(spec)
|
||||
displayUnits[spec.id] = unit
|
||||
displayValues[spec.id] = fromBaseUnit(numVal, unit, getArgKey(spec))
|
||||
} else {
|
||||
displayValues[spec.id] = numVal
|
||||
}
|
||||
} else {
|
||||
selectedArgs[spec.id] = arg.value
|
||||
}
|
||||
@@ -2033,6 +2247,11 @@ const handleAddPlan = async () => {
|
||||
|
||||
// 默认选择所有参数
|
||||
selectedArgIds.value = planSpecList.value.map(spec => spec.id)
|
||||
for (const spec of planSpecList.value) {
|
||||
if (spec.type === 'number' && hasUnit(spec)) {
|
||||
displayUnits[spec.id] = getParamDefaultUnit(spec)
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(planForm, {
|
||||
plan_id: undefined,
|
||||
@@ -2046,7 +2265,8 @@ const handleAddPlan = async () => {
|
||||
enable_fixed_price: false,
|
||||
index: 0,
|
||||
disable: false,
|
||||
show_home: false
|
||||
show_home: false,
|
||||
can_update: false
|
||||
})
|
||||
|
||||
planFormDialogVisible.value = true
|
||||
@@ -2102,7 +2322,8 @@ const handleEditPlan = async (row) => {
|
||||
enable_fixed_price: !!(data.enableFixedPrice || data.enable_fixed_price), // 转为布尔值
|
||||
index: data.index || 0,
|
||||
disable: data.disable || false,
|
||||
show_home: !!(data.showHome || data.show_home) // 转为布尔值
|
||||
show_home: !!(data.showHome || data.show_home),
|
||||
can_update: !!(data.canUpdate || data.can_update)
|
||||
})
|
||||
|
||||
// 从已有的args初始化选择状态(包括额外参数)
|
||||
@@ -2202,7 +2423,8 @@ const submitPlanForm = () => {
|
||||
inventory: Number(planForm.inventory) || 0,
|
||||
fixed_price: Math.round(Number(planForm.fixed_price) * 100) || 0, // 元转分
|
||||
index: Number(planForm.index) || 0,
|
||||
show_home: planForm.show_home === true
|
||||
show_home: planForm.show_home === true,
|
||||
can_update: planForm.can_update === true
|
||||
}
|
||||
|
||||
// 只有创建时才传递 enable_fixed_price
|
||||
@@ -2674,5 +2896,8 @@ const submitPlanForm = () => {
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -94,10 +94,18 @@
|
||||
<el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="110px">
|
||||
<el-form-item label="备注"><el-input v-model="editForm.note" /></el-form-item>
|
||||
<el-form-item label="续费价格(元)">
|
||||
<el-input-number v-model="editForm.renew_price" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="续费价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="editForm.renew_price" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="基础价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="editForm.base_price" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="基础价格(元)"><el-input-number v-model="editForm.base_price" :min="0" :precision="2" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="到期时间"><el-date-picker v-model="editForm.expire_time" type="datetime" placeholder="选择到期时间" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="归属项">
|
||||
<div style="width:100%">
|
||||
@@ -146,7 +154,7 @@ import dayjs from 'dayjs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const goodsId = computed(() => parseInt(route.query.id) || 0)
|
||||
const goodsId = computed(() => parseInt(route.params.id) || 0)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
@@ -404,4 +412,7 @@ watch(goodsId, (newId, oldId) => {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
<div class="filter-content">
|
||||
<el-form :inline="true" class="search-form">
|
||||
<el-form-item label="用户ID">
|
||||
<el-input v-model="query.user_id" placeholder="筛选用户" clearable style="width:120px"
|
||||
@keyup.enter="handleSearch" @clear="handleSearch" />
|
||||
<el-input :model-value="filterUserName || (query.user_id ? `${query.user_id}` : '')"
|
||||
readonly placeholder="筛选用户" clearable style="width:140px;cursor:pointer"
|
||||
@click="showFilterUserSelector = true"
|
||||
@clear="query.user_id = ''; filterUserName = ''; handleSearch()" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品ID">
|
||||
<el-input v-model="query.good_id" placeholder="筛选商品" clearable style="width:120px"
|
||||
@keyup.enter="handleSearch" @clear="handleSearch" />
|
||||
<el-input :model-value="filterGoodName || (query.good_id ? `${query.good_id}` : '')"
|
||||
readonly placeholder="筛选商品" clearable style="width:140px;cursor:pointer"
|
||||
@click="showFilterProductSelector = true"
|
||||
@clear="query.good_id = ''; filterGoodName = ''; handleSearch()" />
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="query.key" placeholder="搜索关键词" clearable style="width:180px"
|
||||
@@ -23,7 +27,7 @@
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>查询
|
||||
</el-button>
|
||||
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; handleSearch()">重置</el-button>
|
||||
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="action-bar">
|
||||
@@ -69,8 +73,8 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.good?.name || '-' }}</template>
|
||||
<el-table-column label="商品" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.good?.name || '-' }} <span style="color:#909399;font-size:12px">(ID:{{ row.good?.id || row.goodId || '-' }})</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标签" width="100">
|
||||
<template #default="{ row }">
|
||||
@@ -97,11 +101,13 @@
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right">
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="warning" size="small" @click="openRemindList(row)">提醒记录</el-button>
|
||||
<el-button link type="success" size="small" @click="handleSendRemind(row)">发送提醒</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -189,11 +195,17 @@
|
||||
<div v-else class="form-hint">普通商品,点击将商品ID赋值为归属项</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="续费价格(元)">
|
||||
<el-input-number v-model="createForm._renewYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="续费价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="createForm._renewYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="基础价格(元)">
|
||||
<el-input-number v-model="createForm._baseYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="基础价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="createForm._baseYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="到期时间">
|
||||
<el-date-picker v-model="createForm.expire_time" type="datetime"
|
||||
@@ -226,11 +238,17 @@
|
||||
<div v-if="editForm._goodTag === '云服务器'" class="form-hint">云服务器商品,点击选择用户虚拟机作为归属项</div>
|
||||
<div v-else class="form-hint">普通商品,点击将商品ID赋值为归属项</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="续费价格(元)">
|
||||
<el-input-number v-model="editForm._renewYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="续费价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="editForm._renewYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="基础价格(元)">
|
||||
<el-input-number v-model="editForm._baseYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="基础价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="editForm._baseYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="到期时间">
|
||||
<el-date-picker v-model="editForm.expire_time" type="datetime"
|
||||
@@ -250,9 +268,9 @@
|
||||
|
||||
<ProductSelector v-model="showProductSelector" @confirm="handleProductSelected" />
|
||||
<UserSelector v-model:visible="showUserSelector" @select="u => { createForm.user_id = u.user_id; createForm._userName = u.user_name }" />
|
||||
<UserSelector v-model:visible="showVmUserSelector" @select="handleVmUserSelect" />
|
||||
<OrderSelector v-model="showOrderSelector" @confirm="o => { createForm.order_id = o.id; createForm._orderName = o.name }" />
|
||||
<PlanSelector v-model="showPlanSelector" :good-id="createForm.good_id" @confirm="handlePlanSelectedForCreate" />
|
||||
|
||||
<!-- 用户虚拟机选择弹窗 -->
|
||||
<el-dialog v-model="showVmListDialog" title="选择用户虚拟机" width="800px" append-to-body destroy-on-close>
|
||||
<div style="margin-bottom:12px">
|
||||
@@ -261,8 +279,24 @@
|
||||
<el-input v-model="vmListQuery.key" placeholder="搜索" clearable style="width:180px"
|
||||
@keyup.enter="loadVmListForItem" @clear="loadVmListForItem" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户ID">
|
||||
<div class="selector-row">
|
||||
<el-input :model-value="vmListQuery._userName || (vmListQuery.user_id ? `用户 #${vmListQuery.user_id}` : '')"
|
||||
readonly placeholder="按用户筛选" style="flex:1" @click="showVmUserSelector = true" />
|
||||
<el-button v-if="vmListQuery.user_id" @click="vmListQuery.user_id = ''; vmListQuery._userName = ''; loadVmListForItem()" style="margin-left:4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="vmListQuery.status" placeholder="筛选状态" clearable style="width:120px" @change="loadVmListForItem">
|
||||
<el-option label="运行中" value="running" />
|
||||
<el-option label="已停止" value="stopped" />
|
||||
<el-option label="未知" value="unknown" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadVmListForItem">搜索</el-button>
|
||||
<el-button @click="loadVmListForItem" :icon="Refresh">刷新</el-button>
|
||||
<el-button @click="resetVmListFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
@@ -270,17 +304,36 @@
|
||||
@current-change="r => vmListSelected = r" :height="350" style="width:100%"
|
||||
:header-cell-style="{ background: '#f8f9fa', color: '#2c3e50', fontWeight: 600 }">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="用户" min-width="120">
|
||||
<template #default="{ row }">{{ row.user?.UserName || row.user?.username || '-' }}</template>
|
||||
<el-table-column label="虚拟机名称" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.good?.name || '-' }}</template>
|
||||
<el-table-column label="配置" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.vcpu && row.memory">
|
||||
{{ row.vcpu }}核 / {{ formatMemory(row.memory) }}
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="归属项ID" width="100">
|
||||
<template #default="{ row }">{{ row.itemId || row.item_id || '-' }}</template>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="small">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期时间" width="170">
|
||||
<template #default="{ row }">{{ formatExpireTime(row.expireTime || row.expire_time) }}</template>
|
||||
<el-table-column label="绑定状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.bound ? 'success' : 'info'" size="small">
|
||||
{{ row.bound ? '已绑定' : '未绑定' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="IP地址" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.ips || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户ID" width="80">
|
||||
<template #default="{ row }">{{ row.user_id || '-' }}</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无虚拟机数据" :image-size="80" />
|
||||
@@ -316,9 +369,21 @@
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-else-if="spec.type === 'number'">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<el-input-number v-model="argsValues[spec.id]" :min="spec.min || 0" :max="spec.max || 9999" :step="spec.step || 1" :step-strictly="true" @change="buildArgsJson" style="width:200px" />
|
||||
<span class="form-hint" style="margin-top:0">范围: {{ spec.min || 0 }} ~ {{ spec.max || 9999 }},步长: {{ spec.step || 1 }}</span>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<el-input-number
|
||||
v-model="argsDisplayValues[spec.id]"
|
||||
:min="hasUnit(spec) ? fromBaseUnit(spec.min ?? 0, argsDisplayUnits[spec.id], getArgKey(spec)) : (spec.min ?? 0)"
|
||||
:max="hasUnit(spec) ? fromBaseUnit(spec.max ?? 0, argsDisplayUnits[spec.id], getArgKey(spec)) : (spec.max ?? 0)"
|
||||
:step="hasUnit(spec) ? (fromBaseUnit(spec.step ?? 1, argsDisplayUnits[spec.id], getArgKey(spec)) || 1) : (spec.step ?? 1)"
|
||||
:step-strictly="true"
|
||||
@change="onArgsNumberChange(spec)"
|
||||
style="width:200px"
|
||||
/>
|
||||
<el-select v-if="hasUnit(spec)" :model-value="argsDisplayUnits[spec.id]" size="default" style="width:90px" @change="(newUnit) => onArgsUnitChange(spec, newUnit)">
|
||||
<el-option v-for="u in getParamUnits(spec)" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
<span class="form-hint" style="margin-top:0">范围: {{ spec.min ?? 0 }} ~ {{ spec.max ?? 0 }}
|
||||
<template v-if="hasUnit(spec)"> {{ getBaseUnit(getArgKey(spec)) }}</template>,步长: {{ spec.step ?? 1 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -337,6 +402,40 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 到期提醒记录弹窗 -->
|
||||
<el-dialog v-model="remindVisible" title="到期提醒记录" width="700px" destroy-on-close append-to-body>
|
||||
<div style="margin-bottom:12px;display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:13px;color:#909399">用户商品 ID: {{ remindGoodsId }}</span>
|
||||
<el-button type="primary" size="small" :icon="Refresh" @click="loadRemindList">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="remindList" v-loading="remindLoading" stripe size="small" :max-height="400">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="user_goods_id" label="用户商品ID" width="110" />
|
||||
<el-table-column prop="user_id" label="用户ID" width="80" />
|
||||
<el-table-column label="提醒类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.type === 'manual' ? 'warning' : 'info'">{{ row.type === 'manual' ? '手动' : '自动' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发送状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.status === 'success' ? 'success' : row.status === 'failed' ? 'danger' : 'info'">{{ row.status || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发送时间" min-width="160">
|
||||
<template #default="{ row }">{{ row.created_at ? dayjs(row.created_at).format('YYYY-MM-DD HH:mm:ss') : (row.send_time || '-') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="内容" min-width="180" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:12px" v-if="remindTotal > remindQuery.count">
|
||||
<el-pagination v-model:current-page="remindQuery.page" :page-size="remindQuery.count" :total="remindTotal" layout="total, prev, pager, next" small background @current-change="loadRemindList" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 筛选区选择器 -->
|
||||
<UserSelector v-model:visible="showFilterUserSelector" @select="u => { query.user_id = u.user_id; filterUserName = u.user_name; handleSearch() }" />
|
||||
<ProductSelector v-model="showFilterProductSelector" @confirm="p => { query.good_id = p.id; filterGoodName = p.name; handleSearch() }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -345,10 +444,11 @@ import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList } from '@/api/admin/userVm'
|
||||
import { getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import { formatToApiTime } from '@/utils/tool'
|
||||
import { getProductParameterList, getProductPlanDetail } from '@/api/admin/product'
|
||||
import { hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit, toBaseUnit, fromBaseUnit } from '@/utils/dynamicUnit'
|
||||
import ProductSelector from '@/components/admin/ProductSelector.vue'
|
||||
import UserSelector from '@/components/UserSelector/index.vue'
|
||||
import OrderSelector from '@/components/admin/OrderSelector.vue'
|
||||
@@ -360,6 +460,10 @@ const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '' })
|
||||
const filterUserName = ref('')
|
||||
const filterGoodName = ref('')
|
||||
const showFilterUserSelector = ref(false)
|
||||
const showFilterProductSelector = ref(false)
|
||||
|
||||
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
|
||||
@@ -370,6 +474,31 @@ const formatExpireTime = (t) => {
|
||||
return d.format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const formatMemory = (kb) => {
|
||||
if (!kb) return '-'
|
||||
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
|
||||
if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'
|
||||
return kb + ' KB'
|
||||
}
|
||||
|
||||
const getStatusType = (status) => {
|
||||
switch (status) {
|
||||
case 'running': return 'success'
|
||||
case 'stop': return 'danger'
|
||||
case 'stopped': return 'danger'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
switch (status) {
|
||||
case 'running': return '运行中'
|
||||
case 'stop': return '已停止'
|
||||
case 'stopped': return '已停止'
|
||||
default: return status || '未知'
|
||||
}
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -392,6 +521,8 @@ const handleSearch = () => { query.page = 1; loadList() }
|
||||
const argsSpecList = ref([])
|
||||
const argsSpecLoading = ref(false)
|
||||
const argsValues = reactive({})
|
||||
const argsDisplayValues = reactive({})
|
||||
const argsDisplayUnits = reactive({})
|
||||
const showArgsDialog = ref(false)
|
||||
|
||||
const argsCount = computed(() => {
|
||||
@@ -419,13 +550,38 @@ const loadArgsSpec = async (goodId) => {
|
||||
argsSpecList.value = res.data.data || []
|
||||
for (const spec of argsSpecList.value) {
|
||||
if (spec.type === 'number' && argsValues[spec.id] === undefined) {
|
||||
argsValues[spec.id] = spec.min || 0
|
||||
argsValues[spec.id] = spec.min ?? 0
|
||||
if (hasUnit(spec)) {
|
||||
argsDisplayUnits[spec.id] = getParamDefaultUnit(spec)
|
||||
argsDisplayValues[spec.id] = fromBaseUnit(spec.min ?? 0, argsDisplayUnits[spec.id], getArgKey(spec))
|
||||
} else {
|
||||
argsDisplayValues[spec.id] = spec.min ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { argsSpecList.value = [] } finally { argsSpecLoading.value = false }
|
||||
}
|
||||
|
||||
const onArgsNumberChange = (spec) => {
|
||||
if (hasUnit(spec)) {
|
||||
argsValues[spec.id] = Math.round(toBaseUnit(argsDisplayValues[spec.id] || 0, argsDisplayUnits[spec.id], getArgKey(spec)))
|
||||
} else {
|
||||
argsValues[spec.id] = argsDisplayValues[spec.id]
|
||||
}
|
||||
buildArgsJson()
|
||||
}
|
||||
const onArgsUnitChange = (spec, newUnit) => {
|
||||
const argKey = getArgKey(spec)
|
||||
const oldUnit = argsDisplayUnits[spec.id]
|
||||
const oldDisplay = argsDisplayValues[spec.id] || 0
|
||||
const baseValue = oldUnit ? toBaseUnit(oldDisplay, oldUnit, argKey) : oldDisplay
|
||||
argsDisplayUnits[spec.id] = newUnit
|
||||
argsDisplayValues[spec.id] = fromBaseUnit(baseValue, newUnit, argKey)
|
||||
argsValues[spec.id] = Math.round(baseValue)
|
||||
buildArgsJson()
|
||||
}
|
||||
|
||||
const buildArgsJson = () => {
|
||||
const argsArray = []
|
||||
for (const spec of argsSpecList.value) {
|
||||
@@ -515,7 +671,8 @@ const vmListForItem = ref([])
|
||||
const vmListLoading = ref(false)
|
||||
const vmListSelected = ref(null)
|
||||
const vmListTotal = ref(0)
|
||||
const vmListQuery = reactive({ page: 1, count: 10, key: '' })
|
||||
const vmListQuery = reactive({ page: 1, count: 10, key: '', user_id: '', status: '', _userName: '' })
|
||||
const showVmUserSelector = ref(false)
|
||||
|
||||
const handleItemSelect = () => {
|
||||
if (createForm._goodTag === '云服务器') {
|
||||
@@ -534,6 +691,14 @@ const loadVmListForItem = async () => {
|
||||
try {
|
||||
const params = { page: vmListQuery.page, count: vmListQuery.count }
|
||||
if (vmListQuery.key) params.key = vmListQuery.key
|
||||
if (vmListQuery.user_id) params.user_id = parseInt(vmListQuery.user_id) || undefined
|
||||
if (vmListQuery.status) params.status = vmListQuery.status
|
||||
// 传递good_id参数
|
||||
if (vmItemTarget.value === 'edit' && editForm._goodId) {
|
||||
params.good_id = editForm._goodId
|
||||
} else if (vmItemTarget.value === 'create' && createForm.good_id) {
|
||||
params.good_id = createForm.good_id
|
||||
}
|
||||
const res = await getUserVmList(params)
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
@@ -555,7 +720,23 @@ const confirmVmForItem = () => {
|
||||
}
|
||||
showVmListDialog.value = false
|
||||
vmListSelected.value = null
|
||||
ElMessage.success('已选择虚拟机')
|
||||
ElMessage.success('虚拟机已选择')
|
||||
}
|
||||
|
||||
const resetVmListFilters = () => {
|
||||
vmListQuery.key = ''
|
||||
vmListQuery.user_id = ''
|
||||
vmListQuery.status = ''
|
||||
vmListQuery._userName = ''
|
||||
vmListQuery.page = 1
|
||||
loadVmListForItem()
|
||||
}
|
||||
|
||||
const handleVmUserSelect = (user) => {
|
||||
vmListQuery.user_id = user.user_id
|
||||
vmListQuery._userName = user.user_name
|
||||
showVmUserSelector.value = false
|
||||
loadVmListForItem()
|
||||
}
|
||||
|
||||
const submitCreate = async () => {
|
||||
@@ -638,7 +819,14 @@ const submitEdit = async () => {
|
||||
}
|
||||
|
||||
// ---- 详情 / 删除 ----
|
||||
const handleDetail = (row) => { router.push({ name: 'UserGoodsDetail', params: { id: row.id } }) }
|
||||
const handleDetail = (row) => {
|
||||
const tag = (row.tag || row.good?.tag || '').toLowerCase()
|
||||
if (tag === '云服务器') {
|
||||
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
|
||||
} else {
|
||||
router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确定删除该用户商品吗?`, '删除确认', { type: 'warning' })
|
||||
@@ -651,6 +839,45 @@ const handleDelete = (row) => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ---- 到期提醒 ----
|
||||
const remindVisible = ref(false)
|
||||
const remindLoading = ref(false)
|
||||
const remindList = ref([])
|
||||
const remindTotal = ref(0)
|
||||
const remindGoodsId = ref(0)
|
||||
const remindQuery = reactive({ page: 1, count: 10 })
|
||||
|
||||
const openRemindList = (row) => {
|
||||
remindGoodsId.value = row.id
|
||||
remindQuery.page = 1
|
||||
remindVisible.value = true
|
||||
loadRemindList()
|
||||
}
|
||||
|
||||
const loadRemindList = async () => {
|
||||
remindLoading.value = true
|
||||
try {
|
||||
const res = await getExpireRemindList({ user_goods_id: remindGoodsId.value, page: remindQuery.page, count: remindQuery.count })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
remindList.value = d.data || d.list || (Array.isArray(d) ? d : [])
|
||||
remindTotal.value = d.all_count ?? d.meta?.count ?? d.total ?? remindList.value.length
|
||||
} else { remindList.value = []; remindTotal.value = 0 }
|
||||
} catch { remindList.value = []; remindTotal.value = 0 }
|
||||
finally { remindLoading.value = false }
|
||||
}
|
||||
|
||||
const handleSendRemind = (row) => {
|
||||
ElMessageBox.confirm(`确定手动发送到期提醒给该用户商品(ID: ${row.id})吗?`, '发送确认', { type: 'warning', confirmButtonText: '确认发送' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const res = await sendExpireRemind({ user_goods_id: row.id, user_id: row.userId || row.user_id })
|
||||
if (res?.data?.code === 200) ElMessage.success('发送成功')
|
||||
else ElMessage.error(extractApiError(res?.data, '发送失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '发送失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
@@ -708,28 +935,6 @@ onMounted(loadList)
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
border: none;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background: #f8f9fa !important;
|
||||
border-bottom: 2px solid #e1e8ed;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-table td) {
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
:deep(.el-table tr:hover > td) {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -746,11 +951,6 @@ onMounted(loadList)
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.price-text {
|
||||
color: #e74c3c;
|
||||
font-weight: 600;
|
||||
@@ -882,4 +1082,7 @@ onMounted(loadList)
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
@@ -29,10 +29,18 @@
|
||||
<el-tag :type="getArgTypeTag(row.type)">{{ getArgTypeText(row.type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数值配置" min-width="180">
|
||||
<el-table-column label="数值配置" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.type === 'number'">
|
||||
<span class="number-config">步进: {{ row.step || '-' }} | 范围: {{ row.min ?? '-' }} ~ {{ row.max ?? '-' }}</span>
|
||||
<div class="number-config">
|
||||
<div>步进: {{ row.step || '-' }} | 范围: {{ row.min ?? '-' }} ~ {{ row.max ?? '-' }}
|
||||
<template v-if="hasUnit(row)"> ({{ getBaseUnit(getArgKey(row)) }})</template>
|
||||
</div>
|
||||
<div v-if="hasUnit(row)" class="unit-info">
|
||||
<el-tag size="small" type="success">{{ getArgKey(row) }}</el-tag>
|
||||
<el-tag size="small" type="warning">{{ getParamDefaultUnit(row) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
@@ -58,49 +66,88 @@
|
||||
:title="paramFormType === 'add' ? '新增商品参数' : '编辑商品参数'"
|
||||
width="600px"
|
||||
append-to-body
|
||||
class="tk-dialog"
|
||||
>
|
||||
<el-form ref="paramFormRef" :model="paramForm" :rules="paramRules" label-width="120px">
|
||||
<el-form-item label="参数名称" prop="arg_name">
|
||||
<el-input v-model="paramForm.arg_name" placeholder="请输入参数名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="参数类型" prop="arg_type">
|
||||
<el-radio-group v-model="paramForm.arg_type">
|
||||
<el-radio label="string">字符串</el-radio>
|
||||
<el-radio label="number">数字</el-radio>
|
||||
<el-radio label="select">选择</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否必选" prop="must">
|
||||
<el-switch v-model="paramForm.must" :active-value="true" :inactive-value="false" active-text="必选" inactive-text="可选" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">权限控制</el-divider>
|
||||
<el-form-item label="允许单独购买">
|
||||
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户组优惠">
|
||||
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户优惠">
|
||||
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠(代金券与优惠码)</div>
|
||||
</el-form-item>
|
||||
<el-form ref="paramFormRef" :model="paramForm" :rules="paramRules" label-width="100px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="参数名称" prop="arg_name">
|
||||
<el-input v-model="paramForm.arg_name" placeholder="请输入参数名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="参数类型" prop="arg_type">
|
||||
<el-radio-group v-model="paramForm.arg_type">
|
||||
<el-radio label="string">字符串</el-radio>
|
||||
<el-radio label="number">数字</el-radio>
|
||||
<el-radio label="select">选择</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否必选" prop="must">
|
||||
<el-switch v-model="paramForm.must" :active-value="true" :inactive-value="false" active-text="必选" inactive-text="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">权限控制</div>
|
||||
<el-form-item label="允许单独购买">
|
||||
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户组优惠">
|
||||
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户优惠">
|
||||
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠(代金券与优惠码)</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template v-if="paramForm.arg_type === 'number'">
|
||||
<el-divider content-position="left">数值参数配置</el-divider>
|
||||
<el-form-item label="步进值" prop="arg_step">
|
||||
<el-input-number v-model="paramForm.arg_step" :min="1" placeholder="步进值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最小值" prop="arg_min">
|
||||
<el-input-number v-model="paramForm.arg_min" placeholder="最小值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大值" prop="arg_max">
|
||||
<el-input-number v-model="paramForm.arg_max" placeholder="最大值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">数值参数配置</div>
|
||||
<el-form-item label="步进值" prop="arg_step">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="paramForm.step_display" :min="1" placeholder="步进值" style="flex: 1" />
|
||||
<el-select v-if="paramForm.enable_unit && paramForm.arg_key" :model-value="paramForm.step_unit" style="width: 100px" @change="(v) => onFieldUnitChange('step', v)">
|
||||
<el-option v-for="u in formUnits" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最小值" prop="arg_min">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="paramForm.min_display" placeholder="最小值" style="flex: 1" />
|
||||
<el-select v-if="paramForm.enable_unit && paramForm.arg_key" :model-value="paramForm.min_unit" style="width: 100px" @change="(v) => onFieldUnitChange('min', v)">
|
||||
<el-option v-for="u in formUnits" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大值" prop="arg_max">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="paramForm.max_display" placeholder="最大值" style="flex: 1" />
|
||||
<el-select v-if="paramForm.enable_unit && paramForm.arg_key" :model-value="paramForm.max_unit" style="width: 100px" @change="(v) => onFieldUnitChange('max', v)">
|
||||
<el-option v-for="u in formUnits" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-if="paramForm.enable_unit && paramForm.arg_key" class="form-tip">
|
||||
实际提交({{ getBaseUnit(paramForm.arg_key) }}): 步进={{ calcBase(paramForm.step_display, paramForm.step_unit) }} | 最小={{ calcBase(paramForm.min_display, paramForm.min_unit) }} | 最大={{ calcBase(paramForm.max_display, paramForm.max_unit) }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用动态单位">
|
||||
<el-switch v-model="paramForm.enable_unit" active-text="启用" inactive-text="禁用" @change="onEnableUnitChange" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">启用后可在输入框右侧切换单位,提交时自动转为基础单位</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="paramForm.enable_unit" label="参数键" prop="arg_key">
|
||||
<el-select v-model="paramForm.arg_key" placeholder="选择参数键" style="width: 100%" @change="onArgKeyChange">
|
||||
<el-option v-for="opt in argKeyOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">用于识别参数类型,基础单位:
|
||||
<strong v-if="paramForm.arg_key">{{ getBaseUnit(paramForm.arg_key) }}</strong>
|
||||
<span v-else>请先选择</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="paramFormDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitParamForm">确定</el-button>
|
||||
</div>
|
||||
@@ -110,7 +157,12 @@
|
||||
<!-- 参数值管理对话框 -->
|
||||
<el-dialog v-model="paramValuesDialogVisible" title="参数值管理" width="800px" append-to-body>
|
||||
<div class="values-header">
|
||||
<span>参数:{{ currentParam?.name }}</span>
|
||||
<div>
|
||||
<span>参数:{{ currentParam?.name }}</span>
|
||||
<el-tag v-if="hasUnit(currentParam)" size="small" type="success" style="margin-left: 8px">
|
||||
{{ getArgKey(currentParam) }} · 基础单位: {{ getBaseUnit(getArgKey(currentParam)) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleAddParamValue">
|
||||
<el-icon><Plus /></el-icon>添加参数值
|
||||
</el-button>
|
||||
@@ -123,11 +175,13 @@
|
||||
>
|
||||
<el-table-column prop="id" label="值ID" width="80" />
|
||||
<el-table-column prop="name" label="值名称" min-width="120" />
|
||||
<el-table-column label="值/范围" min-width="150">
|
||||
<el-table-column label="值/范围" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<template v-if="currentParam?.type === 'select'">{{ row.value || '-' }}</template>
|
||||
<template v-else-if="currentParam?.type === 'number'">
|
||||
<el-tag size="small" type="info">{{ getRangeTypeText(row.rangeType) }} {{ row.range }}</el-tag>
|
||||
<el-tag size="small" type="info">
|
||||
{{ getRangeTypeText(row.rangeType) }} {{ formatPhaseDisplay(row.phase || row.range) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template v-else>{{ row.value || '-' }}</template>
|
||||
</template>
|
||||
@@ -156,37 +210,65 @@
|
||||
:title="paramValueFormType === 'add' ? '添加参数值' : '编辑参数值'"
|
||||
width="550px"
|
||||
append-to-body
|
||||
class="tk-dialog"
|
||||
>
|
||||
<el-form ref="paramValueFormRef" :model="paramValueForm" :rules="paramValueRules" label-width="120px">
|
||||
<el-form-item label="值名称" prop="attr_name">
|
||||
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="currentParam?.type === 'select'" label="参数值" prop="attr_value">
|
||||
<el-input v-model="paramValueForm.attr_value" placeholder="请输入参数值" />
|
||||
</el-form-item>
|
||||
<el-form ref="paramValueFormRef" :model="paramValueForm" :rules="paramValueRules" label-width="100px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">参数值信息</div>
|
||||
<el-form-item label="值名称" prop="attr_name">
|
||||
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="currentParam?.type === 'select'" label="参数值" prop="attr_value">
|
||||
<el-input v-model="paramValueForm.attr_value" placeholder="请输入参数值" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number v-model="paramValueForm.index" :min="0" placeholder="排序索引" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格" prop="attr_price">
|
||||
<el-input-number v-model="paramValueForm.attr_price" :min="0" placeholder="请输入价格" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template v-if="currentParam?.type === 'number'">
|
||||
<el-divider content-position="left">数值范围配置(phase)</el-divider>
|
||||
<el-form-item label="范围类型" prop="range_type">
|
||||
<el-select v-model="paramValueForm.range_type" placeholder="请选择范围类型" style="width: 100%">
|
||||
<el-option label="小于等于 (before)" value="before" />
|
||||
<el-option label="大于等于 (after)" value="after" />
|
||||
<el-option label="等于 (equal)" value="equal" />
|
||||
</el-select>
|
||||
<div class="form-tip">before: 数值 ≤ phase 时匹配 | after: 数值 ≥ phase 时匹配</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="阈值" prop="attr_range">
|
||||
<el-input-number v-model="paramValueForm.attr_range" :min="0" placeholder="范围阈值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">数值范围配置</div>
|
||||
<el-form-item label="范围类型" prop="range_type">
|
||||
<el-select v-model="paramValueForm.range_type" placeholder="请选择范围类型" style="width: 100%">
|
||||
<el-option label="小于 (before)" value="before" />
|
||||
<el-option label="大于 (after)" value="after" />
|
||||
<el-option label="等于 (equal)" value="equal" />
|
||||
</el-select>
|
||||
<div class="form-tip">before: 数值 < phase 时匹配 | after: 数值 > phase 时匹配</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="阈值" prop="attr_range">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number
|
||||
v-model="paramValueForm.attr_range_display"
|
||||
:min="valueDisplayMin"
|
||||
:max="valueDisplayMax"
|
||||
:step="valueDisplayStep"
|
||||
:step-strictly="true"
|
||||
placeholder="范围阈值"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<el-select
|
||||
v-if="hasUnit(currentParam)"
|
||||
v-model="paramValueForm.display_unit"
|
||||
style="width: 100px"
|
||||
@change="onValueUnitChange"
|
||||
>
|
||||
<el-option v-for="u in currentParamUnits" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-if="hasUnit(currentParam)" class="form-tip">
|
||||
实际存储值: {{ computedBaseValue }} {{ getBaseUnit(getArgKey(currentParam)) }}
|
||||
(范围: {{ currentParam?.min ?? 0 }} ~ {{ currentParam?.max ?? '-' }} {{ getBaseUnit(getArgKey(currentParam)) }},步长: {{ currentParam?.step || 1 }})
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number v-model="paramValueForm.index" :min="0" placeholder="排序索引" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格" prop="attr_price">
|
||||
<el-input-number v-model="paramValueForm.attr_price" :min="0" placeholder="请输入价格" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="paramValueFormDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitParamValueForm">确定</el-button>
|
||||
</div>
|
||||
@@ -195,7 +277,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, nextTick } from 'vue'
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import {
|
||||
@@ -208,6 +290,11 @@ import {
|
||||
updateProductParameterValue,
|
||||
deleteProductParameterValue
|
||||
} from '@/api/admin/product'
|
||||
import {
|
||||
getAvailableUnits, getArgKeyOptions, hasUnit, getArgKey,
|
||||
getBaseUnit, getDefaultDisplayUnit, getParamDefaultUnit, getParamUnits,
|
||||
toBaseUnit, fromBaseUnit, formatValueWithUnit
|
||||
} from '@/utils/dynamicUnit'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
@@ -215,6 +302,8 @@ const props = defineProps({
|
||||
})
|
||||
const emit = defineEmits(['update:visible'])
|
||||
|
||||
const argKeyOptions = getArgKeyOptions()
|
||||
|
||||
const paramLoading = ref(false)
|
||||
const parameterList = ref([])
|
||||
|
||||
@@ -229,15 +318,70 @@ const paramForm = reactive({
|
||||
arg_step: 1,
|
||||
arg_min: 0,
|
||||
arg_max: 100,
|
||||
step_display: 1,
|
||||
min_display: 0,
|
||||
max_display: 100,
|
||||
step_unit: '',
|
||||
min_unit: '',
|
||||
max_unit: '',
|
||||
user_add: false,
|
||||
use_user_group_discount: false,
|
||||
use_user_discount: false
|
||||
use_user_discount: false,
|
||||
enable_unit: false,
|
||||
arg_key: ''
|
||||
})
|
||||
const paramRules = {
|
||||
arg_name: [{ required: true, message: '请输入参数名称', trigger: 'blur' }],
|
||||
arg_type: [{ required: true, message: '请选择参数类型', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const formUnits = computed(() => {
|
||||
if (!paramForm.arg_key) return []
|
||||
return getAvailableUnits(paramForm.arg_key)
|
||||
})
|
||||
|
||||
const calcBase = (displayVal, unit) => {
|
||||
if (!paramForm.enable_unit || !paramForm.arg_key || !unit) return displayVal
|
||||
return Math.round(toBaseUnit(displayVal || 0, unit, paramForm.arg_key))
|
||||
}
|
||||
|
||||
const onFieldUnitChange = (field, newUnit) => {
|
||||
const key = paramForm.arg_key
|
||||
const oldUnit = paramForm[`${field}_unit`]
|
||||
const oldDisplay = paramForm[`${field}_display`] || 0
|
||||
const baseVal = oldUnit ? toBaseUnit(oldDisplay, oldUnit, key) : oldDisplay
|
||||
paramForm[`${field}_unit`] = newUnit
|
||||
paramForm[`${field}_display`] = fromBaseUnit(baseVal, newUnit, key)
|
||||
}
|
||||
|
||||
const onArgKeyChange = () => {
|
||||
const key = paramForm.arg_key
|
||||
const defaultUnit = key ? getDefaultDisplayUnit(key) : ''
|
||||
paramForm.step_unit = defaultUnit
|
||||
paramForm.min_unit = defaultUnit
|
||||
paramForm.max_unit = defaultUnit
|
||||
if (key && defaultUnit) {
|
||||
paramForm.step_display = fromBaseUnit(paramForm.arg_step, defaultUnit, key)
|
||||
paramForm.min_display = fromBaseUnit(paramForm.arg_min, defaultUnit, key)
|
||||
paramForm.max_display = fromBaseUnit(paramForm.arg_max, defaultUnit, key)
|
||||
} else {
|
||||
paramForm.step_display = paramForm.arg_step
|
||||
paramForm.min_display = paramForm.arg_min
|
||||
paramForm.max_display = paramForm.arg_max
|
||||
}
|
||||
}
|
||||
|
||||
const onEnableUnitChange = (val) => {
|
||||
if (!val) {
|
||||
paramForm.step_display = paramForm.arg_step
|
||||
paramForm.min_display = paramForm.arg_min
|
||||
paramForm.max_display = paramForm.arg_max
|
||||
paramForm.step_unit = ''
|
||||
paramForm.min_unit = ''
|
||||
paramForm.max_unit = ''
|
||||
}
|
||||
}
|
||||
|
||||
const paramValuesDialogVisible = ref(false)
|
||||
const paramValuesLoading = ref(false)
|
||||
const paramValueList = ref([])
|
||||
@@ -253,12 +397,58 @@ const paramValueForm = reactive({
|
||||
attr_price: 0,
|
||||
index: 0,
|
||||
attr_range: 0,
|
||||
attr_range_display: 0,
|
||||
display_unit: '',
|
||||
range_type: 'equal'
|
||||
})
|
||||
const paramValueRules = {
|
||||
attr_name: [{ required: true, message: '请输入值名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const currentParamUnits = computed(() => {
|
||||
if (!hasUnit(currentParam.value)) return []
|
||||
return getParamUnits(currentParam.value)
|
||||
})
|
||||
|
||||
const valueDisplayMin = computed(() => {
|
||||
if (!hasUnit(currentParam.value)) return 0
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
const baseMin = currentParam.value?.min ?? 0
|
||||
return fromBaseUnit(baseMin, paramValueForm.display_unit, argKey)
|
||||
})
|
||||
const valueDisplayMax = computed(() => {
|
||||
if (!hasUnit(currentParam.value)) return 9999999
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
const baseMax = currentParam.value?.max
|
||||
if (baseMax === undefined || baseMax === null) return 9999999
|
||||
return fromBaseUnit(baseMax, paramValueForm.display_unit, argKey)
|
||||
})
|
||||
const valueDisplayStep = computed(() => {
|
||||
if (!hasUnit(currentParam.value)) return 1
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
const baseStep = currentParam.value?.step || 1
|
||||
return fromBaseUnit(baseStep, paramValueForm.display_unit, argKey)
|
||||
})
|
||||
|
||||
const computedBaseValue = computed(() => {
|
||||
if (!hasUnit(currentParam.value)) return paramValueForm.attr_range_display
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
return Math.round(toBaseUnit(paramValueForm.attr_range_display || 0, paramValueForm.display_unit, argKey))
|
||||
})
|
||||
|
||||
const formatPhaseDisplay = (phaseValue) => {
|
||||
if (phaseValue === undefined || phaseValue === null) return '-'
|
||||
if (!hasUnit(currentParam.value)) return String(phaseValue)
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
const displayUnit = getParamDefaultUnit(currentParam.value)
|
||||
const displayVal = fromBaseUnit(phaseValue, displayUnit, argKey)
|
||||
return formatValueWithUnit(displayVal, displayUnit)
|
||||
}
|
||||
|
||||
const onValueUnitChange = () => {
|
||||
// Recalculate: keep the base value, recalculate display value
|
||||
}
|
||||
|
||||
const getArgTypeText = (type) => {
|
||||
const typeMap = { 'string': '字符串', 'number': '数字', 'select': '选择' }
|
||||
return typeMap[type] || '未知'
|
||||
@@ -268,7 +458,7 @@ const getArgTypeTag = (type) => {
|
||||
return tagMap[type] || 'info'
|
||||
}
|
||||
const getRangeTypeText = (type) => {
|
||||
const typeMap = { 'after': '大于 >', 'before': '小于 <', 'equal': '等于 =' }
|
||||
const typeMap = { 'after': '>', 'before': '<', 'equal': '=' }
|
||||
return typeMap[type] || type || '-'
|
||||
}
|
||||
|
||||
@@ -290,14 +480,45 @@ const fetchParameterList = async () => {
|
||||
const handleAddParameter = () => {
|
||||
paramFormType.value = 'add'
|
||||
paramFormDialogVisible.value = true
|
||||
Object.assign(paramForm, { arg_id: undefined, arg_name: '', arg_type: 'string', must: false, arg_step: 1, arg_min: 0, arg_max: 100, user_add: false, use_user_group_discount: false, use_user_discount: false })
|
||||
Object.assign(paramForm, {
|
||||
arg_id: undefined, arg_name: '', arg_type: 'string', must: false,
|
||||
arg_step: 1, arg_min: 0, arg_max: 100,
|
||||
step_display: 1, min_display: 0, max_display: 100,
|
||||
step_unit: '', min_unit: '', max_unit: '',
|
||||
user_add: false, use_user_group_discount: false, use_user_discount: false,
|
||||
enable_unit: false, arg_key: ''
|
||||
})
|
||||
nextTick(() => { paramFormRef.value?.resetFields() })
|
||||
}
|
||||
|
||||
const handleEditParameter = (row) => {
|
||||
paramFormType.value = 'edit'
|
||||
paramFormDialogVisible.value = true
|
||||
Object.assign(paramForm, { arg_id: row.id, arg_name: row.name, arg_type: row.type, must: row.must || false, arg_step: row.step || 1, arg_min: row.min || 0, arg_max: row.max || 100, user_add: row.userAdd ?? row.user_add ?? false, use_user_group_discount: row.useUserGroupDiscount ?? row.use_user_group_discount ?? false, use_user_discount: row.useUserDiscount ?? row.use_user_discount ?? false })
|
||||
const enableUnit = row.enableUnit || false
|
||||
const argKey = row.argKey || ''
|
||||
const baseStep = row.step || 1
|
||||
const baseMin = row.min || 0
|
||||
const baseMax = row.max || 100
|
||||
let defaultUnit = ''
|
||||
let stepDisplay = baseStep
|
||||
let minDisplay = baseMin
|
||||
let maxDisplay = baseMax
|
||||
if (enableUnit && argKey) {
|
||||
defaultUnit = getDefaultDisplayUnit(argKey)
|
||||
stepDisplay = fromBaseUnit(baseStep, defaultUnit, argKey)
|
||||
minDisplay = fromBaseUnit(baseMin, defaultUnit, argKey)
|
||||
maxDisplay = fromBaseUnit(baseMax, defaultUnit, argKey)
|
||||
}
|
||||
Object.assign(paramForm, {
|
||||
arg_id: row.id, arg_name: row.name, arg_type: row.type, must: row.must || false,
|
||||
arg_step: baseStep, arg_min: baseMin, arg_max: baseMax,
|
||||
step_display: stepDisplay, min_display: minDisplay, max_display: maxDisplay,
|
||||
step_unit: defaultUnit, min_unit: defaultUnit, max_unit: defaultUnit,
|
||||
user_add: row.userAdd ?? row.user_add ?? false,
|
||||
use_user_group_discount: row.useUserGroupDiscount ?? row.use_user_group_discount ?? false,
|
||||
use_user_discount: row.useUserDiscount ?? row.use_user_discount ?? false,
|
||||
enable_unit: enableUnit, arg_key: argKey
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteParameter = (row) => {
|
||||
@@ -315,11 +536,27 @@ const submitParamForm = () => {
|
||||
paramFormRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
const submitData = { good_id: Number(props.goodId), arg_name: paramForm.arg_name, arg_type: paramForm.arg_type, must: paramForm.must === true, user_add: paramForm.user_add === true, use_user_group_discount: paramForm.use_user_group_discount === true, use_user_discount: paramForm.use_user_discount === true }
|
||||
const submitData = {
|
||||
good_id: Number(props.goodId),
|
||||
arg_name: paramForm.arg_name,
|
||||
arg_type: paramForm.arg_type,
|
||||
must: paramForm.must === true,
|
||||
user_add: paramForm.user_add === true,
|
||||
use_user_group_discount: paramForm.use_user_group_discount === true,
|
||||
use_user_discount: paramForm.use_user_discount === true
|
||||
}
|
||||
if (paramForm.arg_type === 'number') {
|
||||
submitData.arg_step = Number(paramForm.arg_step)
|
||||
submitData.arg_min = Number(paramForm.arg_min)
|
||||
submitData.arg_max = Number(paramForm.arg_max)
|
||||
if (paramForm.enable_unit && paramForm.arg_key) {
|
||||
submitData.arg_step = calcBase(paramForm.step_display, paramForm.step_unit)
|
||||
submitData.arg_min = calcBase(paramForm.min_display, paramForm.min_unit)
|
||||
submitData.arg_max = calcBase(paramForm.max_display, paramForm.max_unit)
|
||||
submitData.enable_unit = true
|
||||
submitData.arg_key = paramForm.arg_key
|
||||
} else {
|
||||
submitData.arg_step = Number(paramForm.step_display)
|
||||
submitData.arg_min = Number(paramForm.min_display)
|
||||
submitData.arg_max = Number(paramForm.max_display)
|
||||
}
|
||||
}
|
||||
if (paramFormType.value === 'edit') submitData.arg_id = paramForm.arg_id
|
||||
const res = paramFormType.value === 'add' ? await createProductParameter(submitData) : await updateProductParameter(submitData)
|
||||
@@ -348,14 +585,30 @@ const fetchParamValuesList = async () => {
|
||||
const handleAddParamValue = () => {
|
||||
paramValueFormType.value = 'add'
|
||||
paramValueFormDialogVisible.value = true
|
||||
Object.assign(paramValueForm, { attr_id: undefined, attr_name: '', attr_value: '', attr_price: 0, index: 0, attr_range: 0, range_type: 'equal' })
|
||||
nextTick(() => { paramValueFormRef.value?.resetFields() })
|
||||
const defaultUnit = hasUnit(currentParam.value) ? getParamDefaultUnit(currentParam.value) : ''
|
||||
nextTick(() => {
|
||||
paramValueFormRef.value?.resetFields()
|
||||
Object.assign(paramValueForm, { attr_id: undefined, attr_name: '', attr_value: '', attr_price: 0, index: 0, attr_range: 0, attr_range_display: 0, display_unit: defaultUnit, range_type: 'equal' })
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditParamValue = (row) => {
|
||||
paramValueFormType.value = 'edit'
|
||||
paramValueFormDialogVisible.value = true
|
||||
Object.assign(paramValueForm, { attr_id: row.id, attr_name: row.name, attr_value: row.value || '', attr_price: row.price / 100 || 0, index: row.index || 0, attr_range: row.phase || 0, range_type: row.rangeType || 'equal' })
|
||||
const baseValue = row.phase || 0
|
||||
let displayValue = baseValue
|
||||
let displayUnit = ''
|
||||
if (hasUnit(currentParam.value)) {
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
displayUnit = getParamDefaultUnit(currentParam.value)
|
||||
displayValue = fromBaseUnit(baseValue, displayUnit, argKey)
|
||||
}
|
||||
Object.assign(paramValueForm, {
|
||||
attr_id: row.id, attr_name: row.name, attr_value: row.value || '',
|
||||
attr_price: row.price / 100 || 0, index: row.index || 0,
|
||||
attr_range: baseValue, attr_range_display: displayValue,
|
||||
display_unit: displayUnit, range_type: row.rangeType || 'equal'
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteParamValue = (row) => {
|
||||
@@ -375,7 +628,27 @@ const submitParamValueForm = () => {
|
||||
try {
|
||||
const submitData = { good_id: Number(props.goodId), arg_id: Number(currentParam.value.id), attr_name: paramValueForm.attr_name, index: Number(paramValueForm.index), attr_price: Number(paramValueForm.attr_price) }
|
||||
if (currentParam.value.type === 'select') submitData.attr_value = paramValueForm.attr_value
|
||||
if (currentParam.value.type === 'number') { submitData.attr_range = Number(paramValueForm.attr_range); submitData.range_type = paramValueForm.range_type }
|
||||
if (currentParam.value.type === 'number') {
|
||||
let rangeValue
|
||||
if (hasUnit(currentParam.value)) {
|
||||
rangeValue = computedBaseValue.value
|
||||
} else {
|
||||
rangeValue = Number(paramValueForm.attr_range_display)
|
||||
}
|
||||
const baseMin = currentParam.value.min ?? 0
|
||||
const baseMax = currentParam.value.max
|
||||
const baseStep = currentParam.value.step || 1
|
||||
if (rangeValue < baseMin || (baseMax !== undefined && baseMax !== null && rangeValue > baseMax)) {
|
||||
ElMessage.warning(`阈值超出范围 (${baseMin} ~ ${baseMax ?? '∞'} ${hasUnit(currentParam.value) ? getBaseUnit(getArgKey(currentParam.value)) : ''})`)
|
||||
return
|
||||
}
|
||||
if (baseStep > 0 && (rangeValue - baseMin) % baseStep !== 0) {
|
||||
ElMessage.warning(`阈值必须符合步长 ${baseStep} 的要求`)
|
||||
return
|
||||
}
|
||||
submitData.attr_range = rangeValue
|
||||
submitData.range_type = paramValueForm.range_type
|
||||
}
|
||||
if (paramValueFormType.value === 'edit') submitData.attr_id = paramValueForm.attr_id
|
||||
const res = paramValueFormType.value === 'add' ? await addProductParameterValue(submitData) : await updateProductParameterValue(submitData)
|
||||
if (res.data.code === 200) { ElMessage.success(paramValueFormType.value === 'add' ? '添加成功' : '修改成功'); paramValueFormDialogVisible.value = false; fetchParamValuesList() }
|
||||
@@ -396,7 +669,11 @@ watch(() => props.visible, (val) => {
|
||||
.action-buttons .el-button { padding: 4px 8px; }
|
||||
.text-muted { color: #c0c4cc; font-size: 12px; }
|
||||
.number-config { color: #909399; font-size: 13px; }
|
||||
.number-config div { line-height: 1.4; }
|
||||
.unit-info { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
|
||||
.unit-text { font-size: 12px; color: #666; }
|
||||
.values-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.dialog-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 0; }
|
||||
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
.unit-input-row { display: flex; align-items: center; gap: 8px; width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="套餐名称" min-width="120" />
|
||||
<el-table-column label="参数配置" min-width="200">
|
||||
<el-table-column label="参数配置" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.argsParsed && row.argsParsed.length > 0" class="args-list">
|
||||
<el-tag v-for="(arg, index) in row.argsParsed" :key="index" size="small" type="info" style="margin-right: 4px; margin-bottom: 4px;">
|
||||
{{ arg.name || arg.value || `参数${arg.arg_id}` }}
|
||||
{{ arg.name }}: {{ formatArgTagDisplay(arg) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="text-muted">-</span>
|
||||
@@ -48,6 +48,13 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="允许升级" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.canUpdate || row.can_update ? 'success' : 'info'" size="small">
|
||||
{{ row.canUpdate || row.can_update ? '允许' : '不允许' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEditPlan(row)">编辑</el-button>
|
||||
@@ -84,12 +91,14 @@
|
||||
<div class="args-config-container">
|
||||
<div class="args-select-row">
|
||||
<el-select v-model="selectedArgIds" multiple placeholder="请选择需要配置的参数" style="width: 100%" @change="onSelectedArgsChange">
|
||||
<el-option v-for="spec in planSpecList" :key="spec.id" :label="spec.name" :value="spec.id" />
|
||||
<el-option v-for="spec in planSpecList" :key="spec.id" :label="spec.must ? `* ${spec.name}` : spec.name" :value="spec.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-if="selectedArgSpecs.length > 0" class="args-selector">
|
||||
<div v-for="spec in selectedArgSpecs" :key="spec.id" class="spec-item">
|
||||
<div class="spec-label">{{ spec.name }}</div>
|
||||
<div class="spec-label">
|
||||
<span v-if="spec.must" class="must-star">*</span>{{ spec.name }}
|
||||
</div>
|
||||
<div class="spec-values">
|
||||
<template v-if="spec.type === 'select' && spec.attrs && spec.attrs.length > 0">
|
||||
<el-radio-group v-model="selectedArgs[spec.id]" size="small" @change="updateArgsJson">
|
||||
@@ -98,8 +107,26 @@
|
||||
</template>
|
||||
<template v-else-if="spec.type === 'number'">
|
||||
<div class="number-input-wrapper">
|
||||
<el-input-number v-model="selectedArgs[spec.id]" :min="spec.min || 0" :max="spec.max || 9999" :step="spec.step || 1" :step-strictly="true" size="small" @change="updateArgsJson" />
|
||||
<span class="number-range">(范围: {{ spec.min || 0 }} - {{ spec.max || 9999 }},步长: {{ spec.step || 1 }})</span>
|
||||
<el-input-number
|
||||
v-model="displayValues[spec.id]"
|
||||
:min="getSpecDisplayMin(spec)"
|
||||
:max="getSpecDisplayMax(spec)"
|
||||
:step="getSpecDisplayStep(spec)"
|
||||
:step-strictly="true"
|
||||
size="small"
|
||||
@change="onNumberDisplayChange(spec)"
|
||||
/>
|
||||
<el-select
|
||||
v-if="hasUnit(spec)"
|
||||
:model-value="displayUnits[spec.id]"
|
||||
size="small"
|
||||
style="width: 90px"
|
||||
@change="(newUnit) => onPlanUnitChange(spec, newUnit)"
|
||||
>
|
||||
<el-option v-for="u in getParamUnits(spec)" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
<span class="number-range">({{ spec.min ?? 0 }} - {{ spec.max ?? 0 }}
|
||||
<template v-if="hasUnit(spec)">{{ getBaseUnit(getArgKey(spec)) }}</template>,步长: {{ spec.step ?? 1 }})</span>
|
||||
</div>
|
||||
<div v-if="spec.attrs && spec.attrs.length > 0 && selectedArgs[spec.id]" class="matched-attr-info">
|
||||
<el-tag type="success" size="small">匹配区间: {{ getMatchedAttrName(spec, selectedArgs[spec.id]) }}</el-tag>
|
||||
@@ -117,6 +144,12 @@
|
||||
<el-button type="info" plain size="small" @click="showArgsPreview = true">
|
||||
<el-icon><View /></el-icon>查看配置JSON
|
||||
</el-button>
|
||||
<el-button type="primary" plain size="small" @click="handleCopyArgsJson">
|
||||
<el-icon><CopyDocument /></el-icon>复制JSON
|
||||
</el-button>
|
||||
<el-button type="success" plain size="small" @click="handlePasteArgsJson">
|
||||
<el-icon><DocumentAdd /></el-icon>粘贴JSON
|
||||
</el-button>
|
||||
<el-button type="warning" plain size="small" @click="clearArgsSelection">
|
||||
<el-icon><Delete /></el-icon>清空选择
|
||||
</el-button>
|
||||
@@ -127,7 +160,7 @@
|
||||
<div class="args-config-container">
|
||||
<div class="form-tip" style="margin-bottom: 8px;">选择参数配置中未选择的参数作为额外参数</div>
|
||||
<el-select v-model="selectedExtraArgIds" multiple placeholder="请选择额外参数" style="width: 100%" @change="onSelectedExtraArgsChange">
|
||||
<el-option v-for="spec in extraSpecList" :key="spec.id" :label="`${spec.name} (ID: ${spec.id})`" :value="spec.id" />
|
||||
<el-option v-for="spec in extraSpecList" :key="spec.id" :label="spec.must ? `* ${spec.name} (ID: ${spec.id})` : `${spec.name} (ID: ${spec.id})`" :value="spec.id" />
|
||||
</el-select>
|
||||
<el-empty v-if="extraSpecList.length === 0" description="所有参数已在参数配置中选择" :image-size="40" />
|
||||
</div>
|
||||
@@ -140,8 +173,11 @@
|
||||
<el-switch v-model="planForm.enable_fixed_price" :active-value="true" :inactive-value="false" active-text="启用" inactive-text="禁用" :loading="fixedPriceLoading" @change="handleFixedPriceChange" />
|
||||
<div class="form-tip">启用后套餐价格将使用固定价格,不再根据参数计算</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="固定价格(元)" prop="fixed_price" v-if="planForm.enable_fixed_price === true">
|
||||
<el-input-number v-model="planForm.fixed_price" :min="0" :precision="2" :step="0.01" style="width: 100%" placeholder="请输入固定价格(元)" />
|
||||
<el-form-item label="固定价格" prop="fixed_price" v-if="planForm.enable_fixed_price === true">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="planForm.fixed_price" :min="0" :precision="2" :step="0.01" style="flex:1" placeholder="请输入固定价格(元)" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number v-model="planForm.index" :min="0" style="width: 100%" />
|
||||
@@ -156,6 +192,10 @@
|
||||
<el-switch v-model="planForm.show_home" active-text="展示" inactive-text="不展示" />
|
||||
<div class="form-tip">控制商品套餐是否在首页显示</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="允许升级" prop="can_update">
|
||||
<el-switch v-model="planForm.can_update" active-text="允许" inactive-text="不允许" />
|
||||
<div class="form-tip">控制用户是否可以升级到此套餐</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -166,6 +206,15 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 手动粘贴JSON对话框 -->
|
||||
<el-dialog v-model="showPasteDialog" title="粘贴JSON" width="500px" append-to-body>
|
||||
<el-input v-model="pasteJsonText" type="textarea" :rows="8" placeholder="请将JSON粘贴到此处" />
|
||||
<template #footer>
|
||||
<el-button @click="showPasteDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="doPasteFromText">确定导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 参数配置预览对话框 -->
|
||||
<el-dialog v-model="showArgsPreview" title="参数配置预览" width="500px" append-to-body>
|
||||
<div class="args-preview">
|
||||
@@ -192,7 +241,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Delete, View } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, Delete, View, CopyDocument, DocumentAdd } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getProductParameterList,
|
||||
getProductPlanList,
|
||||
@@ -205,6 +254,10 @@ import {
|
||||
disablePlanFixedPrice,
|
||||
enablePlanFixedPrice
|
||||
} from '@/api/admin/product'
|
||||
import {
|
||||
hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit,
|
||||
toBaseUnit, fromBaseUnit, formatValueWithUnit
|
||||
} from '@/utils/dynamicUnit'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
@@ -232,7 +285,8 @@ const planForm = reactive({
|
||||
enable_fixed_price: false,
|
||||
index: 0,
|
||||
disable: false,
|
||||
show_home: false
|
||||
show_home: false,
|
||||
can_update: false
|
||||
})
|
||||
const planFormRules = {
|
||||
name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }]
|
||||
@@ -241,12 +295,73 @@ const planFormRules = {
|
||||
const planSpecList = ref([])
|
||||
const selectedArgIds = ref([])
|
||||
const selectedArgs = reactive({})
|
||||
const displayValues = reactive({})
|
||||
const displayUnits = reactive({})
|
||||
const showArgsPreview = ref(false)
|
||||
const showPasteDialog = ref(false)
|
||||
const pasteJsonText = ref('')
|
||||
const selectedExtraArgIds = ref([])
|
||||
|
||||
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 getSpecDisplayMin = (spec) => {
|
||||
if (!hasUnit(spec)) return spec.min ?? 0
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
return unit ? fromBaseUnit(spec.min ?? 0, unit, argKey) : (spec.min ?? 0)
|
||||
}
|
||||
const getSpecDisplayMax = (spec) => {
|
||||
if (!hasUnit(spec)) return spec.max ?? 0
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
return unit ? fromBaseUnit(spec.max ?? 0, unit, argKey) : (spec.max ?? 0)
|
||||
}
|
||||
const getSpecDisplayStep = (spec) => {
|
||||
if (!hasUnit(spec)) return spec.step ?? 1
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
if (!unit) return spec.step ?? 1
|
||||
const converted = fromBaseUnit(spec.step ?? 1, unit, argKey)
|
||||
return converted > 0 ? converted : 1
|
||||
}
|
||||
|
||||
const onNumberDisplayChange = (spec) => {
|
||||
if (hasUnit(spec)) {
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
selectedArgs[spec.id] = Math.round(toBaseUnit(displayValues[spec.id] || 0, unit, argKey))
|
||||
} else {
|
||||
selectedArgs[spec.id] = displayValues[spec.id]
|
||||
}
|
||||
updateArgsJson()
|
||||
}
|
||||
|
||||
const onPlanUnitChange = (spec, newUnit) => {
|
||||
const argKey = getArgKey(spec)
|
||||
const oldUnit = displayUnits[spec.id]
|
||||
const oldDisplay = displayValues[spec.id] || 0
|
||||
const baseValue = oldUnit ? toBaseUnit(oldDisplay, oldUnit, argKey) : oldDisplay
|
||||
displayUnits[spec.id] = newUnit
|
||||
displayValues[spec.id] = fromBaseUnit(baseValue, newUnit, argKey)
|
||||
selectedArgs[spec.id] = Math.round(baseValue)
|
||||
updateArgsJson()
|
||||
}
|
||||
|
||||
const formatArgTagDisplay = (arg) => {
|
||||
if (arg.number !== undefined && arg.number !== 0) {
|
||||
const spec = planSpecList.value.find(s => s.id === arg.arg_id)
|
||||
if (spec && hasUnit(spec)) {
|
||||
const argKey = getArgKey(spec)
|
||||
const displayUnit = getParamDefaultUnit(spec)
|
||||
const displayVal = fromBaseUnit(arg.number, displayUnit, argKey)
|
||||
return formatValueWithUnit(displayVal, displayUnit)
|
||||
}
|
||||
return String(arg.number)
|
||||
}
|
||||
return arg.value || '-'
|
||||
}
|
||||
|
||||
const parseArgs = (argsStr) => {
|
||||
if (!argsStr) return []
|
||||
try { const parsed = JSON.parse(argsStr); return Array.isArray(parsed) ? parsed : [] }
|
||||
@@ -262,8 +377,14 @@ const fetchPlanSpecList = async () => {
|
||||
}
|
||||
|
||||
const onSelectedArgsChange = () => {
|
||||
for (const key in selectedArgs) { if (!selectedArgIds.value.includes(Number(key))) delete selectedArgs[key] }
|
||||
for (const key in selectedArgs) { if (!selectedArgIds.value.includes(Number(key))) { delete selectedArgs[key]; delete displayValues[key]; delete displayUnits[key] } }
|
||||
selectedExtraArgIds.value = selectedExtraArgIds.value.filter(id => !selectedArgIds.value.includes(id))
|
||||
for (const specId of selectedArgIds.value) {
|
||||
const spec = planSpecList.value.find(s => s.id === specId)
|
||||
if (spec && spec.type === 'number' && hasUnit(spec) && !displayUnits[specId]) {
|
||||
displayUnits[specId] = getParamDefaultUnit(spec)
|
||||
}
|
||||
}
|
||||
updateArgsJson()
|
||||
updateExtraArgIds()
|
||||
}
|
||||
@@ -280,13 +401,13 @@ const updateArgsJson = () => {
|
||||
if (selectedValue === undefined || selectedValue === '') continue
|
||||
if (spec.type === 'select') {
|
||||
const attrObj = spec.attrs?.find(a => a.id === selectedValue)
|
||||
if (attrObj) argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: attrObj.id, value: attrObj.value || '' })
|
||||
if (attrObj) argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: attrObj.id, value: attrObj.value || '', key: getArgKey(spec) || undefined })
|
||||
} else if (spec.type === 'number') {
|
||||
const numValue = Number(selectedValue)
|
||||
const matchedAttr = findMatchingNumberAttr(spec, numValue)
|
||||
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: matchedAttr ? matchedAttr.id : 0, number: numValue })
|
||||
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: matchedAttr ? matchedAttr.id : 0, number: numValue, key: getArgKey(spec) || undefined })
|
||||
} else {
|
||||
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(selectedValue) })
|
||||
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(selectedValue), key: getArgKey(spec) || undefined })
|
||||
}
|
||||
}
|
||||
planForm.args = argsArray.length > 0 ? JSON.stringify(argsArray) : ''
|
||||
@@ -298,8 +419,8 @@ const findMatchingNumberAttr = (spec, numValue) => {
|
||||
for (const attr of sortedAttrs) {
|
||||
const phase = attr.phase || 0
|
||||
const rangeType = attr.rangeType || 'before'
|
||||
if (rangeType === 'before' && numValue <= phase) return attr
|
||||
else if (rangeType === 'after' && numValue >= phase) return attr
|
||||
if (rangeType === 'before' && numValue < phase) return attr
|
||||
else if (rangeType === 'after' && numValue > phase) return attr
|
||||
else if (rangeType === 'equal' && numValue === phase) return attr
|
||||
}
|
||||
return sortedAttrs[sortedAttrs.length - 1]
|
||||
@@ -314,6 +435,8 @@ const getMatchedAttrName = (spec, numValue) => {
|
||||
const clearArgsSelection = () => {
|
||||
selectedArgIds.value = []
|
||||
for (const key in selectedArgs) delete selectedArgs[key]
|
||||
for (const key in displayValues) delete displayValues[key]
|
||||
for (const key in displayUnits) delete displayUnits[key]
|
||||
selectedExtraArgIds.value = []
|
||||
planForm.args = ''
|
||||
planForm.extra_arg_ids = ''
|
||||
@@ -324,6 +447,12 @@ const getSelectedValueDisplay = (spec) => {
|
||||
const selectedValue = selectedArgs[spec.id]
|
||||
if (selectedValue === undefined || selectedValue === '') return null
|
||||
if (spec.type === 'select') { const attrObj = spec.attrs?.find(a => a.id === selectedValue); return attrObj ? attrObj.name : null }
|
||||
if (hasUnit(spec)) {
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id] || getParamDefaultUnit(spec)
|
||||
const displayVal = fromBaseUnit(Number(selectedValue), unit, argKey)
|
||||
return formatValueWithUnit(displayVal, unit)
|
||||
}
|
||||
return String(selectedValue)
|
||||
}
|
||||
|
||||
@@ -334,6 +463,100 @@ const formatArgsJsonPreview = () => {
|
||||
try { return JSON.stringify(JSON.parse(planForm.args), null, 2) } catch { return planForm.args }
|
||||
}
|
||||
|
||||
const handleCopyArgsJson = async () => {
|
||||
if (!planForm.args) { ElMessage.warning('暂无参数配置可复制'); return }
|
||||
try {
|
||||
await navigator.clipboard.writeText(planForm.args)
|
||||
ElMessage.success('已复制参数配置JSON到剪贴板')
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = planForm.args
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
ElMessage.success('已复制参数配置JSON到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasteArgsJson = async () => {
|
||||
let clipText = ''
|
||||
try {
|
||||
clipText = await navigator.clipboard.readText()
|
||||
} catch {
|
||||
pasteJsonText.value = ''
|
||||
showPasteDialog.value = true
|
||||
return
|
||||
}
|
||||
if (!clipText || !clipText.trim()) { ElMessage.warning('剪贴板为空'); return }
|
||||
applyPastedJson(clipText)
|
||||
}
|
||||
|
||||
const doPasteFromText = () => {
|
||||
const text = pasteJsonText.value
|
||||
if (!text || !text.trim()) { ElMessage.warning('请输入JSON内容'); return }
|
||||
showPasteDialog.value = false
|
||||
applyPastedJson(text)
|
||||
}
|
||||
|
||||
const applyPastedJson = (jsonText) => {
|
||||
let pastedArgs
|
||||
try {
|
||||
pastedArgs = JSON.parse(jsonText)
|
||||
if (!Array.isArray(pastedArgs)) { ElMessage.error('JSON格式错误,需要数组格式'); return }
|
||||
} catch {
|
||||
ElMessage.error('JSON解析失败,请检查格式')
|
||||
return
|
||||
}
|
||||
|
||||
clearArgsSelection()
|
||||
const matchedIds = []
|
||||
for (const arg of pastedArgs) {
|
||||
let spec = null
|
||||
if (arg.key) {
|
||||
spec = planSpecList.value.find(s => getArgKey(s) === arg.key)
|
||||
}
|
||||
if (!spec && arg.name) {
|
||||
spec = planSpecList.value.find(s => s.name === arg.name)
|
||||
}
|
||||
if (!spec && arg.arg_id) {
|
||||
spec = planSpecList.value.find(s => s.id === arg.arg_id)
|
||||
}
|
||||
if (!spec) continue
|
||||
|
||||
matchedIds.push(spec.id)
|
||||
if (spec.type === 'select') {
|
||||
if (arg.attr_id) {
|
||||
const attrObj = spec.attrs?.find(a => a.id === arg.attr_id)
|
||||
if (attrObj) selectedArgs[spec.id] = attrObj.id
|
||||
else if (arg.value) {
|
||||
const byVal = spec.attrs?.find(a => a.value === arg.value || a.name === arg.value)
|
||||
if (byVal) selectedArgs[spec.id] = byVal.id
|
||||
}
|
||||
} else if (arg.value) {
|
||||
const byVal = spec.attrs?.find(a => a.value === arg.value || a.name === arg.value)
|
||||
if (byVal) selectedArgs[spec.id] = byVal.id
|
||||
}
|
||||
} else if (spec.type === 'number') {
|
||||
const numVal = Number(arg.number !== undefined ? arg.number : arg.value)
|
||||
selectedArgs[spec.id] = numVal
|
||||
if (hasUnit(spec)) {
|
||||
const unit = getParamDefaultUnit(spec)
|
||||
displayUnits[spec.id] = unit
|
||||
displayValues[spec.id] = fromBaseUnit(numVal, unit, getArgKey(spec))
|
||||
} else {
|
||||
displayValues[spec.id] = numVal
|
||||
}
|
||||
} else {
|
||||
selectedArgs[spec.id] = arg.value || ''
|
||||
}
|
||||
}
|
||||
selectedArgIds.value = matchedIds
|
||||
updateArgsJson()
|
||||
updateExtraArgIds()
|
||||
ElMessage.success(`已从JSON导入 ${matchedIds.length} 个参数配置`)
|
||||
}
|
||||
|
||||
const initSelectedArgsFromJson = (argsJson, extraArgIds = []) => {
|
||||
clearArgsSelection()
|
||||
const argsParamIds = []
|
||||
@@ -349,8 +572,19 @@ const initSelectedArgsFromJson = (argsJson, extraArgIds = []) => {
|
||||
if (arg.attr_id) selectedArgs[spec.id] = arg.attr_id
|
||||
else if (arg.id) selectedArgs[spec.id] = arg.id
|
||||
else { const attrObj = spec.attrs?.find(a => a.value === arg.value || a.name === arg.name); if (attrObj) selectedArgs[spec.id] = attrObj.id }
|
||||
} else if (spec.type === 'number') { selectedArgs[spec.id] = Number(arg.number !== undefined ? arg.number : arg.value) }
|
||||
else { selectedArgs[spec.id] = arg.value }
|
||||
} else if (spec.type === 'number') {
|
||||
const numVal = Number(arg.number !== undefined ? arg.number : arg.value)
|
||||
selectedArgs[spec.id] = numVal
|
||||
if (hasUnit(spec)) {
|
||||
const unit = getParamDefaultUnit(spec)
|
||||
displayUnits[spec.id] = unit
|
||||
displayValues[spec.id] = fromBaseUnit(numVal, unit, getArgKey(spec))
|
||||
} else {
|
||||
displayValues[spec.id] = numVal
|
||||
}
|
||||
} else {
|
||||
selectedArgs[spec.id] = arg.value
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('解析args失败:', e) }
|
||||
@@ -382,7 +616,12 @@ const handleAddPlan = async () => {
|
||||
await fetchPlanSpecList()
|
||||
clearArgsSelection()
|
||||
selectedArgIds.value = planSpecList.value.map(spec => spec.id)
|
||||
Object.assign(planForm, { plan_id: undefined, name: '', note: '', args: '', extra_arg_ids: '', extra_arg_ids_array: [], inventory: 0, fixed_price: 0, enable_fixed_price: false, index: 0, disable: false, show_home: false })
|
||||
for (const spec of planSpecList.value) {
|
||||
if (spec.type === 'number' && hasUnit(spec)) {
|
||||
displayUnits[spec.id] = getParamDefaultUnit(spec)
|
||||
}
|
||||
}
|
||||
Object.assign(planForm, { plan_id: undefined, name: '', note: '', args: '', extra_arg_ids: '', extra_arg_ids_array: [], inventory: 0, fixed_price: 0, enable_fixed_price: false, index: 0, disable: false, show_home: false, can_update: false })
|
||||
planFormDialogVisible.value = true
|
||||
nextTick(() => { planFormRef.value?.resetFields() })
|
||||
}
|
||||
@@ -408,7 +647,8 @@ const handleEditPlan = async (row) => {
|
||||
extra_arg_ids: extraArgIdsArray.join(','), extra_arg_ids_array: extraArgIdsArray,
|
||||
inventory: data.inventory || 0, fixed_price: ((data.fixedPrice || data.fixed_price || 0) / 100).toFixed(2) * 1,
|
||||
enable_fixed_price: !!(data.enableFixedPrice || data.enable_fixed_price),
|
||||
index: data.index || 0, disable: data.disable || false, show_home: !!(data.showHome || data.show_home)
|
||||
index: data.index || 0, disable: data.disable || false, show_home: !!(data.showHome || data.show_home),
|
||||
can_update: !!(data.canUpdate || data.can_update)
|
||||
})
|
||||
initSelectedArgsFromJson(data.args, extraArgIdsArray)
|
||||
planFormDialogVisible.value = true
|
||||
@@ -451,13 +691,21 @@ const handleFixedPriceChange = async (value) => {
|
||||
const submitPlanForm = () => {
|
||||
planFormRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
const mustSpecs = planSpecList.value.filter(s => s.must)
|
||||
const allSelectedIds = [...selectedArgIds.value, ...selectedExtraArgIds.value]
|
||||
const missingMust = mustSpecs.filter(s => !allSelectedIds.includes(s.id))
|
||||
if (missingMust.length > 0) {
|
||||
ElMessage.warning(`以下必选参数未配置:${missingMust.map(s => s.name).join('、')},请在参数配置或额外参数中选择`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const extraArgIdsStr = selectedExtraArgIds.value.join(',')
|
||||
const submitData = {
|
||||
good_id: String(props.goodId), name: planForm.name, note: planForm.note || '',
|
||||
args: planForm.args || '', extra_arg_ids: extraArgIdsStr || planForm.extra_arg_ids || '',
|
||||
inventory: Number(planForm.inventory) || 0, fixed_price: Math.round(Number(planForm.fixed_price) * 100) || 0,
|
||||
index: Number(planForm.index) || 0, show_home: planForm.show_home === true
|
||||
index: Number(planForm.index) || 0, show_home: planForm.show_home === true,
|
||||
can_update: planForm.can_update === true
|
||||
}
|
||||
if (planFormType.value === 'add') submitData.enable_fixed_price = planForm.enable_fixed_price === true
|
||||
let res
|
||||
@@ -490,16 +738,17 @@ watch(() => props.visible, (val) => {
|
||||
.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 { display: flex; align-items: flex-start; padding: 10px 0; border-bottom: 1px dashed #e4e7ed; gap: 12px; }
|
||||
.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-label { min-width: 80px; max-width: 160px; flex-shrink: 0; font-weight: 500; color: #606266; padding-top: 4px; display: flex; align-items: center; white-space: nowrap; }
|
||||
.must-star { color: #f56c6c; font-size: 16px; font-weight: 700; margin-right: 2px; line-height: 1; }
|
||||
.spec-values { flex: 1; display: flex; flex-direction: column; gap: 6px; }
|
||||
.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-input-wrapper { display: flex; align-items: center; gap: 8px; flex-wrap: nowrap; }
|
||||
.number-range { color: #909399; font-size: 12px; }
|
||||
.matched-attr-info { margin-top: 6px; }
|
||||
.args-actions { margin-top: 12px; display: flex; gap: 8px; }
|
||||
.matched-attr-info { margin-top: 2px; }
|
||||
.args-actions { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.args-preview { padding: 0; }
|
||||
.preview-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.preview-list { max-height: 200px; overflow-y: auto; }
|
||||
@@ -509,4 +758,6 @@ watch(() => props.visible, (val) => {
|
||||
.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; }
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user