Files
ApiServer-Web-admin_dashboa…/src/views/product/components/ProductParameterManager.vue
T
shiran 4180f73c53
Build and Deploy Vue3 / build (push) Successful in 1m27s
Build and Deploy Vue3 / deploy (push) Successful in 36s
feat(admin): 订单管理重构、设置管理增强、短信签名模板管理及通知渠道优化
- 订单列表重构为卡片式布局并新增筛选功能

- 设置管理支持struct/struct_list类型配置

- 新增短信签名和模板独立管理页面

- 通知渠道新增短信渠道配置

- 产品参数管理优化

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 18:27:23 +08:00

1170 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- 商品参数管理主对话框 -->
<el-dialog
:model-value="visible"
title="商品参数管理"
width="1000px"
top="6vh"
class="param-manager-dialog"
@update:model-value="$emit('update:visible', $event)"
>
<div class="param-manager-toolbar">
<div class="toolbar-info">
<el-icon><Collection /></el-icon>
<span> {{ parameterList.length }} 个参数</span>
</div>
<div class="action-bar">
<el-button type="primary" @click="handleAddParameter">
<el-icon><Plus /></el-icon>新增参数
</el-button>
<el-button type="success" @click="fetchParameterList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<div v-loading="paramLoading" class="parameter-cards-wrap">
<div v-if="!parameterList.length && !paramLoading" class="param-empty-state">
<el-empty description="暂无参数数据,点击右上角新增参数" :image-size="80" />
</div>
<div v-else class="parameter-cards-grid">
<div v-for="param in parameterList" :key="param.id" class="param-card">
<!-- 卡片头 -->
<div class="param-card-header">
<div class="param-card-title">
<el-tag :type="getArgTypeTag(param.type)" effect="dark" size="small" class="title-type-tag">
{{ getArgTypeText(param.type) }}
</el-tag>
<span class="param-name" :title="param.name">{{ param.name }}</span>
<el-tag v-if="param.must" type="danger" size="small" effect="plain" class="title-must-tag">必选</el-tag>
<span class="param-id">#{{ param.id }}</span>
</div>
<div class="param-card-actions">
<el-button type="primary" link @click="handleEditParameter(param)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button type="danger" link @click="handleDeleteParameter(param)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</div>
<!-- 权限 / 配置 元信息 -->
<div class="param-card-meta">
<el-tag size="small" :type="param.userAdd ? 'success' : 'info'" effect="plain" class="meta-tag">
<el-icon><ShoppingCart /></el-icon>
{{ param.userAdd ? '允许单独购买' : '不可单独购买' }}
</el-tag>
<el-tag size="small" :type="param.useUserGroupDiscount ? 'success' : 'info'" effect="plain" class="meta-tag">
<el-icon><User /></el-icon>
{{ param.useUserGroupDiscount ? '用户组优惠' : '无用户组优惠' }}
</el-tag>
<el-tag size="small" :type="param.useUserDiscount ? 'success' : 'info'" effect="plain" class="meta-tag">
<el-icon><Ticket /></el-icon>
{{ param.useUserDiscount ? '用户优惠' : '无用户优惠' }}
</el-tag>
</div>
<!-- number 类型配置 -->
<div v-if="param.type === 'number'" class="param-card-number-config">
<div class="number-config-item">
<span class="config-label">步进</span>
<span class="config-value">{{ param.step || '-' }}</span>
</div>
<div class="number-config-item">
<span class="config-label">范围</span>
<span class="config-value">{{ param.min ?? '-' }} ~ {{ param.max ?? '-' }}</span>
</div>
<div v-if="hasUnit(param)" class="number-config-item">
<span class="config-label">单位</span>
<el-tag size="small" type="success" effect="plain">{{ getArgKey(param) }}</el-tag>
<el-tag size="small" type="warning" effect="plain" style="margin-left: 4px">
{{ getBaseUnit(getArgKey(param)) }}
</el-tag>
</div>
</div>
<!-- 参数值列表 -->
<div class="param-card-values">
<div class="values-header">
<span class="values-title">
<el-icon><List /></el-icon>
参数值{{ (param.attrs || []).length }}
<span v-if="(param.attrs || []).length > 1" class="drag-tip-inline">
<el-icon><Rank /></el-icon>可拖动排序
</span>
</span>
<el-button type="primary" size="small" @click="handleAddParamValue(param)">
<el-icon><Plus /></el-icon>添加参数值
</el-button>
</div>
<div v-if="!(param.attrs || []).length" class="values-empty">
暂无参数值
</div>
<div v-else class="values-list">
<div
v-for="(attr, idx) in (param.attrs || [])"
:key="attr.id"
class="value-row"
:class="[
isDraggingAttr(param.id, idx) ? 'is-dragging' : '',
dragOverClass(param.id, idx)
]"
draggable="true"
@dragstart="onAttrDragStart($event, param, idx)"
@dragover="onAttrDragOver($event, param, idx)"
@dragleave="onAttrDragLeave($event, param, idx)"
@drop="onAttrDrop($event, param, idx)"
@dragend="onAttrDragEnd"
>
<el-icon class="value-drag-handle"><Rank /></el-icon>
<span class="value-order">#{{ idx }}</span>
<span class="value-name" :title="attr.name">{{ attr.name }}</span>
<span class="value-range">
<el-tag v-if="param.type === 'number'" size="small" type="info">
{{ getRangeTypeText(attr.rangeType) }}
{{ (attr.phase != null && attr.phase !== '') ? attr.phase : 0 }}
<template v-if="hasUnit(param)"> {{ getBaseUnit(getArgKey(param)) }}</template>
</el-tag>
<span v-else-if="param.type === 'select'" class="value-raw">{{ attr.value || '-' }}</span>
<span v-else class="value-raw">{{ attr.value || '-' }}</span>
</span>
<span class="value-price">
<el-icon><Money /></el-icon>
¥{{ (attr.price / 100).toFixed(2) }}
</span>
<div class="value-actions">
<el-button type="primary" link @click="handleEditParamValue(attr, param)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button type="danger" link @click="handleDeleteParamValue(attr, param)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</el-dialog>
<!-- 商品参数表单对话框 -->
<el-dialog
v-model="paramFormDialogVisible"
:title="paramFormType === 'add' ? '新增商品参数' : '编辑商品参数'"
width="600px"
append-to-body
class="tk-dialog"
>
<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'">
<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="tk-dialog-footer">
<el-button @click="paramFormDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitParamForm">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 参数值表单对话框 -->
<el-dialog
v-model="paramValueFormDialogVisible"
:title="paramValueFormType === 'add' ? '添加参数值' : '编辑参数值'"
width="550px"
append-to-body
class="tk-dialog"
>
<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">
<div class="name-input-row">
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" style="flex: 1" />
<el-button
v-if="currentParam?.type === 'number'"
type="primary"
plain
:disabled="!canGenerateValueName"
@click="generateValueName"
>
<el-icon><MagicStick /></el-icon>
<span style="margin-left: 4px">生成</span>
</el-button>
</div>
</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 v-if="currentParam?.type === 'number'" label="数值范围" prop="attr_range">
<div class="range-config-row">
<el-select v-model="paramValueForm.range_type" class="range-type-select">
<el-option label="≤" value="before">
<span class="range-opt-symbol"></span>
<span class="range-opt-desc">小于等于</span>
</el-option>
<el-option label="≥" value="after">
<span class="range-opt-symbol"></span>
<span class="range-opt-desc">大于等于</span>
</el-option>
<el-option label="" value="equal">
<span class="range-opt-symbol"></span>
<span class="range-opt-desc">等于</span>
</el-option>
</el-select>
<el-input-number
v-model="paramValueForm.attr_range_display"
:min="valueDisplayMin"
:max="valueDisplayMax"
:step="valueDisplayStep"
:step-strictly="true"
placeholder="数值"
controls-position="right"
class="range-value-input"
/>
<el-select
v-if="hasUnit(currentParam)"
v-model="paramValueForm.display_unit"
class="range-unit-select"
@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>
<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>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="paramValueFormDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitParamValueForm">确定</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, Refresh, Rank, MagicStick,
Edit, Delete, Collection, List, Money,
ShoppingCart, User, Ticket
} from '@element-plus/icons-vue'
import {
getProductParameterList,
getProductParameterDetail,
createProductParameter,
updateProductParameter,
deleteProductParameter,
addProductParameterValue,
updateProductParameterValue,
deleteProductParameterValue
} from '@/api/admin/product'
import {
getAvailableUnits, getArgKeyOptions, hasUnit, getArgKey,
getBaseUnit, getDefaultDisplayUnit, getParamDefaultUnit, getParamUnits,
toBaseUnit, fromBaseUnit
} from '@/utils/dynamicUnit'
const props = defineProps({
visible: { type: Boolean, default: false },
goodId: { type: Number, default: null }
})
const emit = defineEmits(['update:visible'])
const argKeyOptions = getArgKeyOptions()
const paramLoading = ref(false)
const parameterList = ref([])
const paramFormDialogVisible = ref(false)
const paramFormType = ref('add')
const paramFormRef = ref(null)
const paramForm = reactive({
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: ''
})
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 currentParam = ref(null)
const paramValueFormDialogVisible = ref(false)
const paramValueFormType = ref('add')
const paramValueFormRef = ref(null)
const paramValueForm = reactive({
attr_id: undefined,
attr_name: '',
attr_value: '',
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 onValueUnitChange = () => {
// Recalculate: keep the base value, recalculate display value
}
const canGenerateValueName = computed(() => {
if (currentParam.value?.type !== 'number') return false
const v = paramValueForm.attr_range_display
return v !== null && v !== undefined && v !== ''
})
const generateValueName = () => {
if (!canGenerateValueName.value) return
const val = paramValueForm.attr_range_display
const unit = hasUnit(currentParam.value) ? (paramValueForm.display_unit || '') : ''
const numText = `${val}${unit}`
let name = ''
switch (paramValueForm.range_type) {
case 'before':
name = `${numText} 以下`
break
case 'after':
name = `${numText} 以上`
break
case 'equal':
default:
name = numText
break
}
paramValueForm.attr_name = name
}
const dragSrc = ref({ paramId: null, index: null })
const dragOver = ref({ paramId: null, index: null, position: null })
const isReordering = ref(false)
const isDraggingAttr = (paramId, idx) => {
return dragSrc.value.paramId === paramId && dragSrc.value.index === idx
}
const dragOverClass = (paramId, idx) => {
if (dragOver.value.paramId !== paramId || dragOver.value.index !== idx) return ''
return dragOver.value.position === 'top' ? 'drag-over-top' : 'drag-over-bottom'
}
const onAttrDragStart = (e, param, idx) => {
dragSrc.value = { paramId: param.id, index: idx }
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
try { e.dataTransfer.setData('text/plain', String(idx)) } catch (_) {}
}
}
const onAttrDragOver = (e, param, idx) => {
if (dragSrc.value.paramId !== param.id) return
if (dragSrc.value.index === idx) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
const rect = e.currentTarget.getBoundingClientRect()
const position = e.clientY - rect.top < rect.height / 2 ? 'top' : 'bottom'
if (
dragOver.value.paramId !== param.id ||
dragOver.value.index !== idx ||
dragOver.value.position !== position
) {
dragOver.value = { paramId: param.id, index: idx, position }
}
}
const onAttrDragLeave = (e, param, idx) => {
if (
dragOver.value.paramId === param.id &&
dragOver.value.index === idx &&
!e.currentTarget.contains(e.relatedTarget)
) {
dragOver.value = { paramId: null, index: null, position: null }
}
}
const onAttrDrop = async (e, param, idx) => {
e.preventDefault()
if (dragSrc.value.paramId !== param.id) return
const src = dragSrc.value.index
const position = dragOver.value.position
let dst = idx
if (position === 'bottom') dst = idx + 1
dragSrc.value = { paramId: null, index: null }
dragOver.value = { paramId: null, index: null, position: null }
if (src === null || src === dst || src + 1 === dst) return
await reorderAttrs(param, src, dst)
}
const onAttrDragEnd = () => {
dragSrc.value = { paramId: null, index: null }
dragOver.value = { paramId: null, index: null, position: null }
}
const reorderAttrs = async (param, src, dst) => {
if (isReordering.value) return
isReordering.value = true
const newAttrs = [...(param.attrs || [])]
const [moved] = newAttrs.splice(src, 1)
const insertAt = dst > src ? dst - 1 : dst
newAttrs.splice(insertAt, 0, moved)
param.attrs = newAttrs
const updates = []
newAttrs.forEach((item, i) => {
if (Number(item.index) !== i) {
updates.push({ item, newIndex: i })
}
})
if (!updates.length) {
isReordering.value = false
return
}
try {
await Promise.all(updates.map(({ item, newIndex }) => {
const payload = {
good_id: Number(props.goodId),
arg_id: Number(param.id),
attr_id: item.id,
attr_name: item.name,
attr_value: item.value || '',
attr_price: Number(item.price) / 100 || 0,
index: newIndex
}
if (param?.type === 'number') {
payload.attr_range = item.phase ?? 0
payload.range_type = item.rangeType || 'equal'
}
return updateProductParameterValue(payload)
}))
ElMessage.success('排序已更新')
} catch (err) {
ElMessage.error('排序更新失败')
} finally {
isReordering.value = false
refreshParamAttrs(param)
}
}
const getArgTypeText = (type) => {
const typeMap = { 'string': '字符串', 'number': '数字', 'select': '选择' }
return typeMap[type] || '未知'
}
const getArgTypeTag = (type) => {
const tagMap = { 'string': 'primary', 'number': 'success', 'select': 'warning' }
return tagMap[type] || 'info'
}
const getRangeTypeText = (type) => {
const typeMap = { 'after': '≥', 'before': '≤', 'equal': '' }
return typeMap[type] || type || '-'
}
const fetchParameterList = async () => {
if (!props.goodId) return
paramLoading.value = true
try {
const res = await getProductParameterList({ good_id: props.goodId })
if (res.data.code === 200) {
const list = res.data.data || []
await Promise.all(list.map(async (param) => {
try {
const detail = await getProductParameterDetail({ good_id: props.goodId, arg_id: param.id })
if (detail.data.code === 200) {
const attrs = detail.data.data.attrs || []
attrs.sort((a, b) => (Number(a.index) || 0) - (Number(b.index) || 0))
param.attrs = attrs
} else {
param.attrs = []
}
} catch (_) {
param.attrs = []
}
}))
parameterList.value = list
}
} catch (error) {
ElMessage.error('获取参数列表失败')
} finally {
paramLoading.value = false
}
}
const refreshParamAttrs = async (param) => {
if (!param) return
try {
const res = await getProductParameterDetail({ good_id: props.goodId, arg_id: param.id })
if (res.data.code === 200) {
const list = res.data.data.attrs || []
list.sort((a, b) => (Number(a.index) || 0) - (Number(b.index) || 0))
param.attrs = list
}
} catch (_) {}
}
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,
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
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) => {
ElMessageBox.confirm(`确认删除参数 ${row.name} 吗?`, '警告', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteProductParameter({ good_id: props.goodId, arg_id: row.id })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchParameterList() }
} catch (error) { ElMessage.error('删除失败') }
}).catch(() => {})
}
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
}
if (paramForm.arg_type === 'number') {
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)
if (res.data.code === 200) { ElMessage.success(paramFormType.value === 'add' ? '新增成功' : '修改成功'); paramFormDialogVisible.value = false; fetchParameterList() }
} catch (error) { ElMessage.error(error.response?.data?.message || '操作失败') }
}
})
}
const handleAddParamValue = (param) => {
currentParam.value = param
paramValueFormType.value = 'add'
paramValueFormDialogVisible.value = true
const defaultUnit = hasUnit(param) ? getParamDefaultUnit(param) : ''
const attrs = param.attrs || []
const nextIndex = attrs.length
? Math.max(...attrs.map((it) => Number(it.index) || 0)) + 1
: 0
nextTick(() => {
paramValueFormRef.value?.resetFields()
Object.assign(paramValueForm, { attr_id: undefined, attr_name: '', attr_value: '', attr_price: 0, index: nextIndex, attr_range: 0, attr_range_display: 0, display_unit: defaultUnit, range_type: 'equal' })
})
}
const handleEditParamValue = (row, param) => {
currentParam.value = param
paramValueFormType.value = 'edit'
paramValueFormDialogVisible.value = true
const baseValue = row.phase || 0
let displayValue = baseValue
let displayUnit = ''
if (hasUnit(param)) {
const argKey = getArgKey(param)
displayUnit = getParamDefaultUnit(param)
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, param) => {
ElMessageBox.confirm(`确认删除参数值 ${row.name} 吗?`, '警告', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteProductParameterValue({ good_id: props.goodId, attr_id: row.id })
if (res.data.code === 200) { ElMessage.success('删除成功'); refreshParamAttrs(param) }
} catch (error) { ElMessage.error('删除失败') }
}).catch(() => {})
}
const submitParamValueForm = () => {
paramValueFormRef.value?.validate(async (valid) => {
if (valid) {
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') {
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
refreshParamAttrs(currentParam.value)
}
} catch (error) { ElMessage.error(error.response?.data?.message || '操作失败') }
}
})
}
watch(() => props.visible, (val) => {
if (val && props.goodId) fetchParameterList()
})
</script>
<style scoped>
/* 通用 */
.action-bar { display: flex; gap: 12px; flex-shrink: 0; flex-wrap: wrap; align-items: center; }
.action-buttons { display: flex; gap: 4px; align-items: center; flex-wrap: nowrap; }
.action-buttons .el-button { padding: 4px 8px; }
.text-muted { color: #c0c4cc; font-size: 12px; }
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
.unit-input-row { display: flex; align-items: center; gap: 8px; width: 100%; }
/* 顶部工具栏 */
.param-manager-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0 16px 0;
border-bottom: 1px solid #ebeef5;
margin-bottom: 16px;
}
.toolbar-info {
display: inline-flex;
align-items: center;
gap: 6px;
color: #606266;
font-size: 14px;
}
.toolbar-info .el-icon { color: #409eff; font-size: 16px; }
/* 参数卡片网格 */
.parameter-cards-wrap {
min-height: 200px;
max-height: 68vh;
overflow-y: auto;
padding-right: 4px;
}
.parameter-cards-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.param-empty-state {
padding: 40px 0;
}
/* 参数卡片 */
.param-card {
border: 1px solid #ebeef5;
border-radius: 10px;
background: #fff;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.param-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
border-color: #dcdfe6;
}
.param-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.param-card-title {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.title-type-tag { flex-shrink: 0; }
.param-name {
font-size: 16px;
font-weight: 600;
color: #303133;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title-must-tag { flex-shrink: 0; }
.param-id {
color: #909399;
font-size: 12px;
flex-shrink: 0;
}
.param-card-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
/* 元信息标签行 */
.param-card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.meta-tag {
display: inline-flex !important;
align-items: center;
gap: 4px;
}
.meta-tag .el-icon { font-size: 12px; }
/* number 类型配置信息 */
.param-card-number-config {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 18px;
padding: 10px 12px;
margin-bottom: 12px;
background: #f7f9fc;
border-radius: 6px;
border-left: 3px solid #409eff;
}
.number-config-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.config-label {
color: #909399;
}
.config-value {
color: #303133;
font-weight: 600;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
}
/* 参数值列表 */
.param-card-values {
border-top: 1px dashed #ebeef5;
padding-top: 12px;
}
.values-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.values-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #606266;
font-weight: 600;
}
.values-title .el-icon { color: #409eff; }
.drag-tip-inline {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 10px;
font-size: 12px;
color: #909399;
font-weight: normal;
}
.drag-tip-inline .el-icon { color: #909399; font-size: 12px; }
.values-empty {
padding: 14px 12px;
text-align: center;
color: #c0c4cc;
font-size: 13px;
background: #fafafa;
border-radius: 6px;
border: 1px dashed #e4e7ed;
}
.values-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.value-row {
display: grid;
grid-template-columns: 24px 40px 1.4fr 1.4fr auto auto;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: #fafafa;
border: 1px solid transparent;
border-radius: 6px;
cursor: grab;
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}
.value-row:hover {
background: #ecf5ff;
border-color: #d9ecff;
}
.value-row:active { cursor: grabbing; }
.value-row.is-dragging {
opacity: 0.45;
background: #ecf5ff;
}
.value-row.drag-over-top {
box-shadow: inset 0 2px 0 0 #409eff;
}
.value-row.drag-over-bottom {
box-shadow: inset 0 -2px 0 0 #409eff;
}
.value-drag-handle {
color: #c0c4cc;
font-size: 15px;
cursor: grab;
}
.value-row:hover .value-drag-handle { color: #409eff; }
.value-order {
font-size: 12px;
color: #909399;
font-family: 'Monaco', 'Menlo', monospace;
text-align: center;
}
.value-name {
color: #303133;
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.value-range { min-width: 0; }
.value-raw {
color: #606266;
font-size: 13px;
font-family: 'Monaco', 'Menlo', monospace;
}
.value-price {
display: inline-flex;
align-items: center;
gap: 3px;
color: #f56c6c;
font-weight: 600;
font-size: 13px;
padding: 2px 8px;
background: #fef0f0;
border-radius: 4px;
white-space: nowrap;
}
.value-price .el-icon { font-size: 12px; }
.value-actions {
display: flex;
gap: 2px;
}
.value-actions .el-button { padding: 4px 6px; }
/* 值名称 + 生成按钮 */
.name-input-row { display: flex; align-items: center; gap: 8px; width: 100%; }
/* 数值范围单行配置 */
.range-config-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.range-config-row .range-type-select { width: 96px; flex: 0 0 96px; }
.range-config-row .range-value-input { flex: 1; min-width: 0; }
.range-config-row .range-unit-select { width: 100px; flex: 0 0 100px; }
.range-config-row :deep(.el-select .el-input__inner) {
font-size: 16px;
font-weight: 600;
color: #409eff;
text-align: center;
}
.range-opt-symbol {
display: inline-block;
font-weight: 600;
font-size: 16px;
color: #409eff;
width: 24px;
}
.range-opt-desc {
margin-left: 12px;
color: #8492a6;
font-size: 12px;
}
/* 对话框内滚动区域自定义滚动条 */
.parameter-cards-wrap::-webkit-scrollbar { width: 6px; }
.parameter-cards-wrap::-webkit-scrollbar-track { background: transparent; }
.parameter-cards-wrap::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 3px;
}
.parameter-cards-wrap::-webkit-scrollbar-thumb:hover { background: #c0c4cc; }
/* 响应式:窄屏时参数值行改为纵向 */
@media (max-width: 768px) {
.value-row {
grid-template-columns: 24px 1fr auto;
grid-template-rows: auto auto;
}
.value-order { display: none; }
.value-range { grid-column: 2 / 3; }
.value-price { grid-column: 3 / 4; grid-row: 1 / 2; }
.value-actions { grid-column: 3 / 4; grid-row: 2 / 3; }
}
</style>