fix: 提交修改
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 1m9s

This commit is contained in:
2026-04-15 16:02:36 +08:00
parent 2f06aa9f5f
commit b3ed406f84
61 changed files with 7476 additions and 7226 deletions
+42 -7
View File
@@ -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
View File
@@ -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: 数值 &lt; phase 时匹配 | after: 数值 &gt; 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>
+15 -4
View File
@@ -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>
+263 -60
View File
@@ -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: 数值 &lt; phase 时匹配 | after: 数值 &gt; 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>