Files
ApiServer-Web-admin_dashboa…/src/views/marketing/DiscountGoods.vue
T
shiran bdf6dd9382
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 39s
feat: 优惠管理合并重构与商品续费价格参数
- 合并优惠码/代金券为商品管理下优惠管理页面,卡片化展示与过期遮罩

- 用户组新增优惠绑定,商品关联改用懒加载树选择器

- 商品/套餐表单新增 renew_price、renew_recommend_rebate、renew_fixed_price

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

922 lines
25 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>
<div class="discount-goods-container">
<!-- 主容器 -->
<el-card class="main-container" shadow="never">
<!-- 搜索和操作栏 -->
<div class="filter-section">
<div class="filter-content">
<el-form :inline="true" :model="queryParams" class="search-form" v-if="!codeId">
<el-form-item label="代金卷" v-if="!codeId">
<el-select
v-model="queryParams.code_id"
placeholder="请选择代金券"
filterable
clearable
style="width: 280px"
>
<el-option
v-for="item in voucherListOptions"
:key="item.id"
:label="`${item.name} (¥${(item.amount / 100).toFixed(2)})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery" :disabled="!queryParams.code_id">
<el-icon><Search /></el-icon>查询
</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增商品关联
</el-button>
<el-button type="success" @click="fetchGoodsList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>批量删除
</el-button>
</div>
</div>
</div>
<!-- 商品关联列表 -->
<div class="table-section">
<!-- 骨架屏 -->
<div v-if="loading" class="skeleton-container">
<div v-for="i in 5" :key="i" class="skeleton-row">
<div class="skeleton-cell skeleton-checkbox"></div>
<div class="skeleton-cell skeleton-id"></div>
<div class="skeleton-cell skeleton-discount-id"></div>
<div class="skeleton-cell skeleton-related-id"></div>
<div class="skeleton-cell skeleton-name"></div>
<div class="skeleton-cell skeleton-type"></div>
<div class="skeleton-cell skeleton-note"></div>
<div class="skeleton-cell skeleton-price"></div>
<div class="skeleton-cell skeleton-time"></div>
<div class="skeleton-cell skeleton-action"></div>
</div>
</div>
<el-table
v-else
v-loading="loading"
:data="goodsList"
@selection-change="handleSelectionChange"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="discountId" label="代金券ID" width="120" v-if="!codeId" />
<el-table-column label="关联对象ID" width="120">
<template #default="{ row }">
{{ row.goodId || row.goodGroupId || '-' }}
</template>
</el-table-column>
<el-table-column label="名称" min-width="200">
<template #default="{ row }">
{{ row.good?.name || row.goodGroup?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="类型" width="120">
<template #default="{ row }">
<el-tag :type="getGoodsTypeTagByRow(row)">
{{ getGoodsTypeNameByRow(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" min-width="150">
<template #default="{ row }">
{{ row.good?.table || row.goodGroup?.note || '-' }}
</template>
</el-table-column>
<el-table-column label="商品价格" width="120">
<template #default="{ row }">
<span v-if="row.good?.price" class="price">¥{{ (row.good.price / 100).toFixed(2) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
class="pagination"
/>
</div>
</el-card>
<!-- 添加/编辑商品关联对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增商品关联' : '编辑商品关联'"
width="600px"
append-to-body
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-width="120px"
>
<el-form-item label="代金券" prop="code_id">
<el-select
v-model="form.code_id"
placeholder="请选择代金券"
filterable
clearable
:disabled="dialogType === 'edit' || !!codeId"
style="width: 100%"
>
<el-option
v-for="item in voucherListOptions"
:key="item.id"
:label="`${item.name} (¥${(item.amount / 100).toFixed(2)})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="选择关联对象" v-if="dialogType === 'add'">
<div class="goods-tree-wrapper">
<div class="goods-tree-toolbar">
<span class="tree-tip">可自由勾选商品组与商品展开层级查看下属内容</span>
<div class="tree-summary">
已选 <b>{{ checkedSummary.groupCount }}</b> 个商品组 / <b>{{ checkedSummary.productCount }}</b> 个商品
</div>
</div>
<el-tree
ref="goodsTreeRef"
:props="treeProps"
:load="loadTreeNode"
lazy
show-checkbox
check-strictly
node-key="key"
class="goods-tree"
@check="handleTreeCheck"
>
<template #default="{ data }">
<span class="tree-node">
<el-tag size="small" :type="data.nodeType === 'group' ? 'warning' : 'primary'" effect="plain">
{{ data.nodeType === 'group' ? '组' : '品' }}
</el-tag>
<span class="tree-node-label">{{ data.label }}</span>
<span class="tree-node-id">ID: {{ data.rawId }}</span>
<span v-if="data.nodeType === 'product' && data.price != null" class="tree-node-price">
¥{{ (data.price / 100).toFixed(2) }}
</span>
</span>
</template>
</el-tree>
</div>
</el-form-item>
<!-- 编辑模式显示字段 -->
<template v-if="dialogType === 'edit'">
<el-form-item label="关联类型">
<el-tag :type="form.goods_type === 'product' ? 'primary' : 'warning'">
{{ form.goods_type === 'product' ? '商品' : '商品组' }}
</el-tag>
</el-form-item>
<el-form-item label="关联对象ID">
<el-select v-model="form.goods_id" style="width: 100%">
<template v-if="form.goods_type === 'product'">
<el-option v-for="item in productOptions" :key="item.id" :label="`${item.name} (ID: ${item.id})`" :value="item.id" />
</template>
<template v-else>
<el-option v-for="item in productGroupOptions" :key="item.id" :label="`${item.name} (ID: ${item.id})`" :value="item.id" />
</template>
</el-select>
</el-form-item>
</template>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Search, Plus, Refresh } from '@element-plus/icons-vue'
import {
getDiscountGoodsList,
addDiscountGoods,
updateDiscountGoods,
deleteDiscountGoods,
getDiscountCodeList
} from '@/api/admin/discount'
import {
getProductList,
getProductGroupList
} from '@/api/admin/product'
const props = defineProps({
codeId: {
type: [String, Number],
default: ''
}
})
// 查询参数
const queryParams = reactive({
code_id: props.codeId || '',
page: 1,
count: 10
})
watch(() => props.codeId, (newVal) => {
if (newVal) {
queryParams.code_id = newVal
fetchGoodsList()
}
})
// 表单数据
const form = reactive({
id: undefined,
code_id: undefined,
goods_id: undefined,
goods_name: '',
goods_type: ''
})
const formRules = {
code_id: [
{ required: true, message: '请选择代金券', trigger: 'change' }
],
goods_id: [
{ required: true, message: '请输入商品ID', trigger: 'blur' }
]
}
// 折叠层级选择器相关
const goodsTreeRef = ref(null)
const treeProps = {
label: 'label',
children: 'children',
isLeaf: 'isLeaf'
}
const checkedSummary = reactive({ groupCount: 0, productCount: 0 })
// 状态数据
const loading = ref(false)
const goodsList = ref([])
const total = ref(0)
const selectedRows = ref([])
const dialogVisible = ref(false)
const dialogType = ref('add')
const formRef = ref(null)
const voucherListOptions = ref([]) // 代金券列表选项
const productOptions = ref([]) // 商品列表选项
const productGroupOptions = ref([]) // 商品组列表选项
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
// 获取商品类型名称(根据行数据)
const getGoodsTypeNameByRow = (row) => {
// 判断是否有 goodGroup 对象(选择的是商品组)
if (row.goodGroup) {
return '商品组'
}
// 判断是否有 good 对象(选择的是商品)
if (row.good) {
return '商品'
}
return '-'
}
// 获取商品类型标签(根据行数据)
const getGoodsTypeTagByRow = (row) => {
// 商品组用橙色
if (row.goodGroup) {
return 'warning'
}
// 商品用蓝色
if (row.good) {
return 'primary'
}
return 'info'
}
// 获取商品类型名称(兼容旧版)
const getGoodsTypeName = (type) => {
const typeMap = {
'product': '商品',
'product_group': '商品组',
'cloud_server': '云服务器',
'cloud_database': '云数据库',
'cloud_storage': '云存储',
'cdn': 'CDN',
'other': '其他'
}
return typeMap[type] || type
}
// 获取商品类型标签(兼容旧版)
const getGoodsTypeTag = (type) => {
const tagMap = {
'product': 'primary',
'product_group': 'warning',
'cloud_server': 'primary',
'cloud_database': 'success',
'cloud_storage': 'warning',
'cdn': 'info',
'other': 'default'
}
return tagMap[type] || 'default'
}
// 获取代金券列表选项
const fetchVoucherListOptions = async () => {
try {
const res = await getDiscountCodeList({
page: 1,
count: 10,
discount_type: 'coupon'
})
console.log('获取代金券列表:', res.data)
if (res.data.code === 200) {
voucherListOptions.value = res.data.data?.data || []
}
} catch (error) {
console.error('获取代金券列表失败:', error)
ElMessage.error('获取代金券列表失败')
}
}
// 获取商品列表
const fetchProductList = async () => {
try {
const res = await getProductList({
page: 1,
count: 10
})
console.log('获取商品列表:', res.data)
if (res.data.code === 200) {
productOptions.value = res.data.data?.data || []
}
} catch (error) {
console.error('获取商品列表失败:', error)
ElMessage.error('获取商品列表失败')
}
}
// 获取商品组列表
const fetchProductGroupList = async () => {
try {
const res = await getProductGroupList({
page: 1,
count: 10
})
console.log('获取商品组列表:', res.data)
if (res.data.code === 200) {
productGroupOptions.value = res.data.data?.data || []
}
} catch (error) {
console.error('获取商品组列表失败:', error)
ElMessage.error('获取商品组列表失败')
}
}
// 折叠层级选择器:懒加载节点
// node.level === 0 时加载顶级商品组;展开商品组时加载其子分组与下属商品
const loadTreeNode = async (node, resolve) => {
try {
// 根节点:仅加载 level=1 的顶级商品组
if (node.level === 0) {
const res = await getProductGroupList({ level: 1 })
if (res.data.code === 200) {
const groups = res.data.data?.data || []
return resolve(groups.map(buildGroupNode))
}
return resolve([])
}
// 商品组节点:逐级加载子分组 + 下属商品
if (node.data?.nodeType === 'group') {
const groupId = node.data.rawId
const childLevel = (node.data.level || 1) + 1
const tasks = [
getProductList({ good_group_id: groupId, delete: false })
]
// 仅当存在子分组时才请求下一级分组
if (node.data.existSub) {
tasks.push(getProductGroupList({ parent_id: groupId, level: childLevel }))
}
const results = await Promise.all(tasks)
const productRes = results[0]
const productNodes = (productRes.data.code === 200 ? (productRes.data.data?.data || []) : [])
.map(buildProductNode)
let groupNodes = []
if (node.data.existSub && results[1]?.data.code === 200) {
groupNodes = (results[1].data.data?.data || []).map(buildGroupNode)
}
return resolve([...groupNodes, ...productNodes])
}
return resolve([])
} catch (error) {
console.error('加载层级数据失败:', error)
ElMessage.error('加载层级数据失败')
return resolve([])
}
}
// 构建商品组节点
const buildGroupNode = (group) => ({
key: `group_${group.id}`,
rawId: group.id,
nodeType: 'group',
label: group.name,
level: group.level || 1,
existSub: group.existSub || false,
isLeaf: false
})
// 构建商品节点
const buildProductNode = (product) => ({
key: `product_${product.id}`,
rawId: product.id,
nodeType: 'product',
label: product.name,
price: product.price,
isLeaf: true
})
// 勾选变化时更新汇总
const handleTreeCheck = () => {
const nodes = goodsTreeRef.value?.getCheckedNodes() || []
checkedSummary.groupCount = nodes.filter(n => n.nodeType === 'group').length
checkedSummary.productCount = nodes.filter(n => n.nodeType === 'product').length
}
// 获取商品关联列表
const fetchGoodsList = async () => {
if (!queryParams.code_id) {
ElMessage.warning('请选择代金券进行查询')
return
}
loading.value = true
try {
const res = await getDiscountGoodsList(queryParams)
console.log('商品关联列表数据:', res.data)
if (res.data.code === 200) {
goodsList.value = res.data.data || []
total.value = res.data.data?.length || 0
}
} catch (error) {
console.error('获取商品关联列表失败:', error)
ElMessage.error('获取商品关联列表失败')
} finally {
loading.value = false
}
}
// 查询
const handleQuery = () => {
queryParams.page = 1
fetchGoodsList()
}
// 重置查询
const resetQuery = () => {
queryParams.code_id = ''
queryParams.page = 1
goodsList.value = []
total.value = 0
}
// 选择项变化
const handleSelectionChange = (selection) => {
selectedRows.value = selection
}
// 分页
const handleSizeChange = (size) => {
queryParams.count = size
fetchGoodsList()
}
const handleCurrentChange = (page) => {
queryParams.page = page
fetchGoodsList()
}
// 新增商品关联
const handleAdd = () => {
dialogType.value = 'add'
dialogVisible.value = true
Object.assign(form, {
id: undefined,
code_id: queryParams.code_id ? Number(queryParams.code_id) : undefined,
goods_id: undefined,
goods_name: '',
goods_type: ''
})
checkedSummary.groupCount = 0
checkedSummary.productCount = 0
formRef.value?.resetFields()
// 等待对话框渲染后清空树的勾选状态
nextTick(() => {
goodsTreeRef.value?.setCheckedKeys([])
})
}
// 编辑商品关联
const handleEdit = (row) => {
dialogType.value = 'edit'
dialogVisible.value = true
// 判断是商品还是商品组
let goodsId, goodsName, goodsType
if (row.goodGroup) {
// 商品组
goodsId = row.goodGroupId
goodsName = row.goodGroup.name
goodsType = 'product_group'
} else if (row.good) {
// 商品
goodsId = row.goodId
goodsName = row.good.name
goodsType = 'product'
}
Object.assign(form, {
id: row.id,
code_id: row.discountId,
goods_id: goodsId,
goods_name: goodsName,
goods_type: goodsType
})
// 加载商品和商品组列表以便编辑
fetchProductList()
fetchProductGroupList()
}
// 删除商品关联
const handleDelete = (row) => {
ElMessageBox.confirm(`确认删除该商品关联吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteDiscountGoods({
discount_good_id: String(row.id),
code_id: String(row.discountId)
})
console.log('删除响应:', res.data)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchGoodsList()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
// 批量删除
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
loading.value = true
try {
const deletePromises = selectedRows.value.map(row =>
deleteDiscountGoods({
discount_good_id: String(row.id),
code_id: String(row.discountId)
})
)
const results = await Promise.allSettled(deletePromises)
const successCount = results.filter(r => r.status === 'fulfilled' && r.value?.data?.code === 200).length
const failCount = results.length - successCount
if (failCount === 0) {
ElMessage.success(`批量删除成功,共删除 ${successCount} 条记录`)
} else if (successCount === 0) {
ElMessage.error(`批量删除失败,所有 ${failCount} 条记录删除失败`)
} else {
ElMessage.warning(`批量删除完成,成功 ${successCount} 条,失败 ${failCount}`)
}
fetchGoodsList()
} catch (error) {
console.error('批量删除失败:', error)
ElMessage.error('批量删除操作异常')
} finally {
loading.value = false
}
}).catch(() => {})
}
// 提交表单
const submitForm = () => {
if (!form.code_id) {
ElMessage.warning('请选择代金券')
return
}
if (dialogType.value === 'add') {
// 收集树中勾选的商品组与商品
const checkedNodes = goodsTreeRef.value?.getCheckedNodes() || []
const goodIds = checkedNodes.filter(n => n.nodeType === 'product').map(n => n.rawId)
const goodGroupIds = checkedNodes.filter(n => n.nodeType === 'group').map(n => n.rawId)
if (goodIds.length === 0 && goodGroupIds.length === 0) {
ElMessage.warning('请至少勾选一个商品或商品组')
return
}
submitAdd(goodIds, goodGroupIds)
return
}
// 编辑模式
formRef.value?.validate(async (valid) => {
if (!valid) return
try {
const submitData = {
code_id: String(form.code_id),
discount_good_id: String(form.id)
}
if (form.goods_type === 'product_group') {
submitData.good_group_id = String(form.goods_id)
} else {
submitData.good_id = String(form.goods_id)
}
const res = await updateDiscountGoods(submitData)
if (res.data.code === 200) {
ElMessage.success('修改成功')
dialogVisible.value = false
fetchGoodsList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
})
}
// 新增提交:根据勾选构建 good_ids / good_group_ids(逗号分隔)
const submitAdd = async (goodIds, goodGroupIds) => {
try {
const submitData = { code_id: String(form.code_id) }
if (goodIds.length > 0) {
submitData.good_ids = goodIds.join(',')
}
if (goodGroupIds.length > 0) {
submitData.good_group_ids = goodGroupIds.join(',')
}
console.log('提交商品关联数据:', submitData)
const res = await addDiscountGoods(submitData)
if (res.data.code === 200) {
ElMessage.success('新增成功')
dialogVisible.value = false
fetchGoodsList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
}
// 初始化
onMounted(() => {
fetchVoucherListOptions()
if (queryParams.code_id) {
fetchGoodsList()
}
})
</script>
<style scoped>
.discount-goods-container {
padding: 0;
}
.main-container {
border: 1px solid #e1e8ed;
background: #ffffff;
}
.filter-section {
padding: 0;
border-bottom: 1px solid #e1e8ed;
background: #fafbfc;
}
.filter-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
gap: 20px;
flex-wrap: wrap;
}
.search-form {
margin: 0;
flex: 1;
display: flex;
align-items: center;
gap: 12px;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
}
.action-bar {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.table-section {
padding: 0;
}
.action-buttons {
display: flex;
gap: 8px;
align-items: center;
}
.price {
color: #f56c6c;
font-weight: bold;
font-size: 14px;
}
.pagination {
margin-top: 20px;
padding: 16px 20px;
border-top: 1px solid #e1e8ed;
background: #fafbfc;
justify-content: flex-end;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 0;
}
/* 折叠层级选择器样式 */
.goods-tree-wrapper {
width: 100%;
border: 1px solid #e1e8ed;
border-radius: 6px;
overflow: hidden;
}
.goods-tree-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #fafbfc;
border-bottom: 1px solid #e1e8ed;
font-size: 12px;
color: #909399;
}
.tree-summary b {
color: #409eff;
}
.goods-tree {
max-height: 320px;
overflow-y: auto;
padding: 8px;
}
.tree-node {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.tree-node-label {
color: #2c3e50;
}
.tree-node-id {
color: #909399;
font-size: 12px;
}
.tree-node-price {
color: #f56c6c;
font-size: 12px;
font-weight: bold;
}
:deep(.el-card__body) {
padding: 0;
}
/* 骨架屏样式 */
.skeleton-container {
padding: 20px;
}
.skeleton-row {
display: flex;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
gap: 16px;
}
.skeleton-row:last-child {
border-bottom: none;
}
.skeleton-cell {
height: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite;
border-radius: 4px;
}
.skeleton-checkbox { width: 55px; }
.skeleton-id { width: 80px; }
.skeleton-discount-id { width: 120px; }
.skeleton-related-id { width: 120px; }
.skeleton-name { width: 200px; }
.skeleton-type { width: 120px; }
.skeleton-note { width: 150px; }
.skeleton-price { width: 120px; }
.skeleton-time { width: 180px; }
.skeleton-action { width: 200px; height: 32px; }
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>