Files
ApiServer-Web-admin_dashboa…/src/views/product/ProductGroup.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

2487 lines
71 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="product-group-container">
<!-- 主容器 -->
<el-card class="main-container" shadow="never">
<!-- Tab切换 -->
<el-tabs v-model="activeTab" class="main-tabs">
<el-tab-pane label="商品分组" name="group">
<!-- 操作栏 -->
<div class="filter-section">
<div class="filter-content">
<el-form :inline="true" :model="queryParams" class="search-form">
<el-form-item label="分组标签">
<el-select v-model="queryParams.tag" placeholder="全部标签" clearable style="width: 140px" @change="fetchGroupList">
<el-option
v-for="tag in allTagOptions"
:key="tag.id"
:label="tag.name"
:value="tag.name"
/>
</el-select>
</el-form-item>
<el-form-item label="层级筛选">
<el-select v-model="queryParams.level" placeholder="全部层级" clearable style="width: 120px" @change="fetchGroupList">
<el-option v-for="n in 10" :key="n" :label="`${n}级`" :value="n" />
</el-select>
</el-form-item>
<el-form-item label="状态筛选">
<el-select v-model="queryParams.disable" placeholder="全部状态" clearable style="width: 120px" @change="fetchGroupList">
<el-option label="启用" :value="false" />
<el-option label="禁用" :value="true" />
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="queryParams.key" placeholder="搜索分组名称" clearable style="width: 180px" @keyup.enter="fetchGroupList" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchGroupList">
<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(null)">
<el-icon><Plus /></el-icon>新增顶级分组
</el-button>
<el-button type="success" @click="handleAddProduct">
<el-icon><Plus /></el-icon>新增商品
</el-button>
<el-button type="success" @click="fetchGroupList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<div class="view-switch">
<el-radio-group v-model="viewMode" size="default">
<el-radio-button value="tree">
<el-icon><Grid /></el-icon>
<span>树形视图</span>
</el-radio-button>
<el-radio-button value="list">
<el-icon><List /></el-icon>
<span>列表视图</span>
</el-radio-button>
</el-radio-group>
</div>
</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-id"></div>
<div class="skeleton-cell skeleton-name"></div>
<div class="skeleton-cell skeleton-note"></div>
<div class="skeleton-cell skeleton-level"></div>
<div class="skeleton-cell skeleton-status"></div>
<div class="skeleton-cell skeleton-action"></div>
</div>
</div>
<!-- 树形表格视图 -->
<el-table
v-else-if="viewMode === 'tree'"
ref="treeTableRef"
:data="treeDisplayData"
style="width: 100%"
row-key="id"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column prop="name" label="名称" min-width="320">
<template #default="{ row }">
<div
class="group-name-cell"
:class="{ 'is-clickable': row.isGroup || row.isProduct }"
:style="{ paddingLeft: row.isProduct ? ((row.level - 1) * 24 + 24) + 'px' : (row.level - 1) * 24 + 'px' }"
@click="handleNameCellClick(row)"
>
<!-- 分组的展开/收起按钮 -->
<span
v-if="row.isGroup"
class="expand-icon"
>
<el-icon v-if="row._loading"><Loading /></el-icon>
<el-icon v-else :class="{ 'is-expanded': row._expanded }"><ArrowRight /></el-icon>
</span>
<span v-else class="expand-placeholder"></span>
<!-- 类型图标 -->
<el-avatar v-if="row.isGroup && row.cover" :size="32" :src="row.cover" />
<el-avatar v-else-if="row.isGroup" :size="32" :style="{ background: getLevelColor(row.level) }">
<el-icon><Folder /></el-icon>
</el-avatar>
<el-avatar v-else-if="row.isProduct && row.data?.cover" :size="32" :src="row.data.cover" />
<el-avatar v-else-if="row.isProduct" :size="32" :style="{ background: '#409eff' }">
<el-icon><Document /></el-icon>
</el-avatar>
<!-- 类型标签 -->
<el-tag v-if="row.isGroup" type="primary" size="small" style="margin-left: 8px;">分组</el-tag>
<el-tag v-else-if="row.isProduct" type="success" size="small" style="margin-left: 8px;">商品</el-tag>
<span class="group-name">{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="ID" width="100">
<template #default="{ row }">
<span v-if="row.isProduct">{{ row.data?.id }}</span>
<span v-else>{{ row.id }}</span>
</template>
</el-table-column>
<el-table-column label="标签" width="120">
<template #default="{ row }">
<el-tag v-if="row.isGroup && row.tag" size="small">{{ row.tag.name }}</el-tag>
<el-tag v-else-if="row.isProduct && row.data?.tag" size="small" type="success">{{ row.data.tag }}</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="层级" width="80">
<template #default="{ row }">
<el-tag v-if="row.isGroup" :type="getLevelType(row.level)" size="small">
{{ getLevelText(row.level) }}
</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="排序" width="80">
<template #default="{ row }">
<el-tag type="info" size="small">{{ row.index || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="note" label="备注/价格" min-width="320" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.isGroup">{{ row.note || '-' }}</span>
<div v-else-if="row.isProduct" class="product-info-inline">
<span class="product-price">¥{{ (row.data.price / 100).toFixed(2) }}</span>
<el-tag v-if="row.data.inventoryControl" type="info" size="small">库存: {{ row.data.inventory }}</el-tag>
<el-tag v-if="row.data.recommend" type="warning" size="small">推荐</el-tag>
<el-tag v-if="row.data.expireTime && row.data.expireTime > 0" type="info" size="small">有效期: {{ row.data.expireTime }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-switch
v-model="row.disable"
:active-value="false"
:inactive-value="true"
@change="(val) => handleStatusChange(row, val)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<template v-if="row.isGroup">
<el-button type="success" link @click="handleAdd(row)">添加子级</el-button>
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
<template v-else-if="row.isProduct">
<el-button type="primary" link @click="handleEditProduct(row.data, row.parentId)">编辑</el-button>
<el-button type="success" link @click="handlePlan(row.data)">套餐</el-button>
<el-button type="warning" link @click="handleParameter(row.data)">参数</el-button>
<el-button type="danger" link @click="handleDeleteProduct(row.data)">删除</el-button>
</template>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无分组数据" :image-size="100" />
</template>
</el-table>
<!-- 列表视图 -->
<el-table
v-else
:data="groupList"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column prop="id" label="分组ID" width="100" />
<el-table-column prop="name" label="分组名称" min-width="200">
<template #default="{ row }">
<div class="group-name-cell">
<el-avatar v-if="row.cover" :size="32" :src="row.cover" />
<span class="group-name">{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="层级" width="100">
<template #default="{ row }">
<el-tag :type="getLevelType(row.level)" size="small">
{{ getLevelText(row.level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="父级分组" width="150">
<template #default="{ row }">
<span v-if="row.parentId">{{ getParentName(row.parentId) }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="200" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.disable"
:active-value="false"
:inactive-value="true"
@change="(val) => handleStatusChange(row, val)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="180" 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>
<template #empty>
<el-empty description="暂无分组数据" :image-size="100" />
</template>
</el-table>
<!-- 分页 -->
<el-pagination
v-if="viewMode === 'list'"
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-tab-pane>
<!-- 分组标签管理 -->
<el-tab-pane label="分组标签" name="tag">
<GroupTagManager ref="groupTagManagerRef" />
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 商品分组表单对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="860px"
append-to-body
class="product-form-dialog"
>
<el-form
ref="groupFormRef"
:model="groupForm"
:rules="groupRules"
label-position="top"
class="product-form"
>
<!-- 顶部封面 + 基本信息 -->
<div class="product-form-header">
<!-- 左侧封面 -->
<div class="cover-uploader" @click="showCoverSelector = true">
<img v-if="groupForm.cover_url" :src="groupForm.cover_url" class="cover-image" alt="分组封面" />
<div v-else class="cover-placeholder">
<el-icon class="cover-placeholder-icon"><Folder /></el-icon>
<span class="cover-placeholder-text">点击选择封面</span>
</div>
<div class="cover-mask">
<el-icon><Picture /></el-icon>
<span>更换封面</span>
</div>
<el-button
v-if="groupForm.cover_id"
type="danger"
size="small"
circle
class="cover-clear-btn"
@click.stop="clearGroupCover"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
<!-- 右侧基本信息 -->
<div class="basic-fields">
<el-form-item label="分组名称" prop="name">
<el-input v-model="groupForm.name" placeholder="请输入分组名称" />
</el-form-item>
<el-form-item label="父级分组" prop="parent_id">
<div
class="group-picker"
:class="{ 'is-empty': !selectedParentName, 'is-disabled': isAddingChild }"
@click="!isAddingChild && (showParentSelector = true)"
>
<el-icon class="group-picker-icon"><Folder /></el-icon>
<span class="group-picker-text">
{{ selectedParentName || (isAddingChild ? '已自动设置父级分组' : '无父级(顶级分组)') }}
</span>
<el-button
v-if="groupForm.parent_id && !isAddingChild"
type="danger"
link
size="small"
class="group-picker-clear"
@click.stop="clearParent"
>
清除
</el-button>
<el-icon v-if="!isAddingChild" class="group-picker-arrow"><ArrowRight /></el-icon>
</div>
<div v-if="isAddingChild" class="form-tip">
添加子级时自动继承父级分组不可修改
</div>
</el-form-item>
<el-form-item label="分组标签" prop="tag_id">
<div
class="group-picker"
:class="{ 'is-empty': !selectedTagName }"
@click="showTagSelector = true"
>
<el-icon class="group-picker-icon"><CollectionTag /></el-icon>
<span class="group-picker-text">{{ selectedTagName || '点击选择分组标签' }}</span>
<el-button
v-if="groupForm.tag_id"
type="danger"
link
size="small"
class="group-picker-clear"
@click.stop="clearTag"
>
清除
</el-button>
<el-icon class="group-picker-arrow"><ArrowRight /></el-icon>
</div>
</el-form-item>
</div>
</div>
<!-- 基本属性层级 / 排序 / 状态 -->
<div class="form-section">
<div class="form-section-title">基本属性</div>
<div class="form-grid">
<el-form-item label="分组层级" prop="level">
<el-input :model-value="`${groupForm.level} `" disabled style="width: 100%" />
<div class="form-tip">层级根据父级分组自动计算</div>
</el-form-item>
<el-form-item label="排序索引" prop="index">
<el-input-number
v-model="groupForm.index"
:min="0"
:max="9999"
controls-position="right"
placeholder="数值越小越靠前"
style="width: 100%"
/>
<div class="form-tip">数值越小排序越靠前相同索引按创建时间排序</div>
</el-form-item>
<el-form-item label="状态" prop="disable" class="group-status-item">
<el-radio-group v-model="groupForm.disable">
<el-radio-button :value="false">启用</el-radio-button>
<el-radio-button :value="true">禁用</el-radio-button>
</el-radio-group>
</el-form-item>
</div>
</div>
<!-- 备注 -->
<el-form-item prop="note" label="备注" class="note-form-item">
<el-input
v-model="groupForm.note"
type="textarea"
:rows="4"
placeholder="请输入备注(可选)"
resize="none"
/>
</el-form-item>
</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>
<!-- 父级分组选择弹窗 -->
<el-dialog
v-model="showParentSelector"
title="选择父级分组"
width="600px"
append-to-body
>
<el-table
:data="parentOptions"
highlight-current-row
@current-change="handleParentSelect"
:height="400"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="分组名称" min-width="150" />
<el-table-column label="层级" width="100">
<template #default="{ row }">
<el-tag :type="getLevelType(row.level)" size="small">
{{ getLevelText(row.level) }}
</el-tag>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无可选分组" :image-size="60" />
</template>
</el-table>
<template #footer>
<el-button @click="showParentSelector = false">取消</el-button>
<el-button type="primary" @click="confirmParentSelect">确定</el-button>
</template>
</el-dialog>
<!-- 封面选择器 -->
<AvatarSelector
v-model="showCoverSelector"
:user-id="1"
:current-cover-id="groupForm.cover_id"
@confirm="handleCoverSelect"
/>
<!-- 分组标签选择弹窗 -->
<el-dialog
v-model="showTagSelector"
title="选择分组标签"
width="650px"
append-to-body
>
<div class="tag-selector-header">
<el-input
v-model="tagSelectorSearch"
placeholder="搜索标签名称"
clearable
style="width: 200px"
@keyup.enter="fetchTagOptionsForSelector"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="fetchTagOptionsForSelector">搜索</el-button>
</div>
<el-table
v-loading="tagSelectorLoading"
:data="tagOptionsForSelector"
highlight-current-row
@current-change="handleTagSelect"
:height="350"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="标签名称" min-width="150">
<template #default="{ row }">
<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>
</el-table>
<template #footer>
<el-button @click="showTagSelector = false">取消</el-button>
<el-button type="primary" @click="confirmTagSelect">确定</el-button>
</template>
</el-dialog>
<!-- 商品表单对话框 -->
<el-dialog
v-model="productDialogVisible"
:title="productDialogType === 'add' ? '新增商品' : '编辑商品'"
width="860px"
append-to-body
class="product-form-dialog"
>
<el-form
ref="productFormRef"
:model="productForm"
:rules="productRules"
label-position="top"
class="product-form"
>
<!-- 顶部封面 + 基本信息 -->
<div class="product-form-header">
<!-- 左侧封面 -->
<div class="cover-uploader" @click="coverSelectorVisible = true">
<img v-if="productForm.cover_url" :src="productForm.cover_url" class="cover-image" alt="商品封面" />
<div v-else class="cover-placeholder">
<el-icon class="cover-placeholder-icon"><Picture /></el-icon>
<span class="cover-placeholder-text">点击选择封面</span>
</div>
<div class="cover-mask">
<el-icon><Picture /></el-icon>
<span>更换封面</span>
</div>
<el-button
v-if="productForm.cover_id"
type="danger"
size="small"
circle
class="cover-clear-btn"
@click.stop="clearProductCover"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
<!-- 右侧基本信息 -->
<div class="basic-fields">
<el-form-item label="商品名称" prop="name">
<el-input v-model="productForm.name" placeholder="请输入商品名称" />
</el-form-item>
<el-form-item label="所属分组" prop="good_group_id">
<div
class="group-picker"
:class="{ 'is-empty': !selectedGroupName }"
@click="showProductGroupSelector = true"
>
<el-icon class="group-picker-icon"><Folder /></el-icon>
<span class="group-picker-text">{{ selectedGroupName || '点击选择商品分组' }}</span>
<el-button
v-if="productForm.good_group_id"
type="danger"
link
size="small"
class="group-picker-clear"
@click.stop="clearProductGroup"
>
清除
</el-button>
<el-icon class="group-picker-arrow"><ArrowRight /></el-icon>
</div>
</el-form-item>
<el-form-item label="所属表" prop="table">
<el-input v-model="productForm.table" placeholder="请输入商品所属表(如 kvm_service" />
</el-form-item>
<el-form-item label="商品介绍" prop="content">
<el-input
v-model="productForm.content"
type="textarea"
:rows="4"
placeholder="请输入商品介绍"
resize="none"
/>
</el-form-item>
</div>
</div>
<!-- 封面选择器弹窗 -->
<AvatarSelector
v-model="coverSelectorVisible"
:user-id="1"
:current-cover-id="productForm.cover_id"
title="选择封面"
@confirm="handleProductCoverSelect"
/>
<!-- Tab 栏管理 -->
<el-tabs v-model="productFormTab" class="product-form-tabs">
<el-tab-pane label="价格与销售" name="sales">
<div class="form-grid">
<el-form-item prop="price">
<template #label>
<span>商品价格<span class="unit-suffix"></span></span>
</template>
<el-input-number
v-model="productForm.price"
:min="0"
:precision="2"
:step="0.01"
placeholder="请输入价格"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="单个商品数量" prop="pay_num">
<el-input-number
v-model="productForm.pay_num"
:min="1"
placeholder="请输入数量"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item prop="expire_time">
<template #label>
<span>有效期<span class="unit-suffix">0 为永久</span></span>
</template>
<el-input-number
v-model="productForm.expire_time"
:min="0"
placeholder="请输入有效期"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="允许续费" prop="can_renew">
<el-switch
v-model="productForm.can_renew"
active-text="允许"
inactive-text="禁止"
/>
</el-form-item>
<el-form-item prop="renew_price" v-if="productForm.can_renew">
<template #label>
<span>续费价格<span class="unit-suffix">0 沿用商品价格</span></span>
</template>
<el-input-number
v-model="productForm.renew_price"
:min="0"
:precision="2"
:step="0.01"
placeholder="续费基础价格"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="购买类型" prop="arg_type">
<el-select
v-model="productForm.arg_type"
placeholder="请选择购买类型"
style="width: 100%"
>
<el-option label="所有类型" value="all" />
<el-option label="仅套餐" value="plan" />
<el-option label="仅自定义参数" value="customize" />
</el-select>
</el-form-item>
<el-form-item label="需要实名" prop="require_real_name">
<el-switch
v-model="productForm.require_real_name"
active-text="需要"
inactive-text="不需要"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">启用后用户购买/续费/升级此商品前须完成实名认证</div>
</el-form-item>
</div>
</el-tab-pane>
<el-tab-pane label="库存管理" name="inventory">
<div class="form-grid">
<el-form-item label="库存控制" prop="inventory_control">
<el-switch
v-model="productForm.inventory_control"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
<el-form-item label="库存数量" prop="inventory">
<el-input-number
v-model="productForm.inventory"
:min="0"
:disabled="!productForm.inventory_control"
placeholder="请输入库存"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="售罄状态" prop="sold_out">
<el-switch
v-model="productForm.sold_out"
active-text="售罄"
inactive-text="正常"
active-color="#f56c6c"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">开启后用户端将无法选购该商品</div>
</el-form-item>
<el-form-item label="购买限制" prop="max_per_user">
<el-input-number
v-model="productForm.max_per_user"
:min="0"
placeholder="0 表示不限"
controls-position="right"
style="width: 100%"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">限制单用户最大购买数量0 表示不限制</div>
</el-form-item>
</div>
</el-tab-pane>
<el-tab-pane label="推荐与通知" name="promotion">
<div class="form-grid">
<el-form-item label="推荐" prop="recommend">
<el-switch
v-model="productForm.recommend"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
<el-form-item prop="recommend_rebate">
<template #label>
<span>推荐返还<span class="unit-suffix">%</span></span>
</template>
<el-input-number
v-model="productForm.recommend_rebate"
:min="0"
:max="100"
:disabled="!productForm.recommend"
placeholder="返还百分比"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item prop="renew_recommend_rebate">
<template #label>
<span>续费推介返还<span class="unit-suffix">%0 沿用推荐返还</span></span>
</template>
<el-input-number
v-model="productForm.renew_recommend_rebate"
:min="0"
:max="100"
:disabled="!productForm.recommend"
placeholder="续费推介返还百分比"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="购买通知" prop="send_notice">
<el-switch
v-model="productForm.send_notice"
active-text="发送"
inactive-text="不发送"
/>
<div style="font-size:12px;color:#909399;margin:4px">用户购买后是否发送通知给管理员</div>
</el-form-item>
</div>
</el-tab-pane>
</el-tabs>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="productDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitProductForm">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 商品分组选择弹窗 -->
<el-dialog
v-model="showProductGroupSelector"
title="选择商品分组"
width="600px"
append-to-body
>
<el-table
:data="allGroupList"
highlight-current-row
@current-change="handleProductGroupSelect"
style="width: 100%"
max-height="400px"
>
<el-table-column prop="name" label="分组名称" />
<el-table-column prop="level" label="层级" width="80">
<template #default="{ row }">
<el-tag :type="getLevelType(row.level)" size="small">
{{ getLevelText(row.level) }}
</el-tag>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无可选分组" :image-size="60" />
</template>
</el-table>
<template #footer>
<el-button @click="showProductGroupSelector = false">取消</el-button>
<el-button type="primary" @click="confirmProductGroupSelect">确定</el-button>
</template>
</el-dialog>
<!-- 商品参数管理子组件 -->
<ProductParameterManager
v-model:visible="paramDialogVisible"
:good-id="currentProductId"
/>
<!-- 商品套餐管理子组件 -->
<ProductPlanManager
v-model:visible="planDialogVisible"
:good-id="currentPlanProductId"
:good-name="currentPlanProductName"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, Folder, ArrowRight, Loading, Grid, List, Document, Picture, Delete, CollectionTag } from '@element-plus/icons-vue'
import {
getProductGroupList,
createProductGroup,
updateProductGroup,
deleteProductGroup,
hideProductGroup,
startProductGroup,
getProductGroupTagList,
deleteProductGroupTag,
getProductList,
createProduct,
updateProduct,
deleteProduct,
} from '@/api/admin/product'
import AvatarSelector from '@/components/admin/AvatarSelector.vue'
import GroupTagManager from './components/GroupTagManager.vue'
import ProductParameterManager from './components/ProductParameterManager.vue'
import ProductPlanManager from './components/ProductPlanManager.vue'
// Tab切换
const activeTab = ref('group')
// 视图模式
const viewMode = ref('tree')
// 查询参数
const queryParams = reactive({
page: 1,
count: 10,
tag: undefined,
level: undefined,
disable: undefined,
key: ''
})
// 监听视图模式变化
watch(viewMode, (newVal) => {
if (newVal === 'tree') {
fetchGroupList()
} else {
queryParams.page = 1
fetchGroupList()
}
})
// 商品分组表单
const groupForm = reactive({
id: undefined,
name: '',
note: '',
disable: false,
level: 1,
parent_id: undefined,
cover_id: undefined,
cover_url: '',
tag_id: undefined,
index: 0
})
const groupRules = {
name: [
{ required: true, message: '请输入分组名称', trigger: 'blur' }
]
}
// 状态数据
const loading = ref(false)
const groupList = ref([])
const allGroupList = ref([])
const treeDataMap = ref(new Map())
const total = ref(0)
const dialogVisible = ref(false)
const dialogType = ref('add')
const dialogTitle = computed(() => {
if (dialogType.value === 'add') {
return groupForm.parent_id ? '添加子级分组' : '新增顶级分组'
}
return '编辑商品分组'
})
// 商品相关状态
const productList = ref([])
const productLoading = ref(false)
const expandedGroups = ref(new Set())
const loadedProductGroups = ref(new Set())
const groupProductsMap = ref(new Map())
// 商品表单
const productForm = reactive({
id: undefined,
name: '',
table: '',
tag: '',
content: '',
cover_id: undefined,
cover_url: '',
good_group_id: undefined,
inventory_control: false,
inventory: 0,
price: 0,
pay_num: 1,
expire_time: 0,
recommend: false,
recommend_rebate: 0,
arg_type: 'all',
can_renew: true,
require_real_name: false,
sold_out: false,
max_per_user: 0,
send_notice: false,
renew_price: 0,
renew_recommend_rebate: 0
})
const productRules = {
name: [
{ required: true, message: '请输入商品名称', trigger: 'blur' }
],
content: [
{ required: true, message: '请输入商品内容', trigger: 'blur' }
],
good_group_id: [
{ required: true, message: '请选择商品分组', trigger: 'change' }
],
price: [
{ required: true, message: '请输入商品价格', trigger: 'blur' },
{ type: 'number', message: '请输入数字', trigger: 'blur' }
]
}
// 商品对话框状态
const productDialogVisible = ref(false)
const productDialogType = ref('add')
const productFormTab = ref('sales')
const productFormRef = ref(null)
const coverSelectorVisible = ref(false)
// 商品分组选择相关
const showProductGroupSelector = ref(false)
const selectedProductGroup = ref(null)
const selectedGroupName = computed(() => {
return selectedProductGroup.value?.name || ''
})
// 是否正在添加子级分组
const isAddingChild = computed(() => {
return dialogType.value === 'add' && groupForm.parent_id
})
const groupFormRef = ref(null)
// 树形表格展开控制
const treeTableRef = ref(null)
// 根据ID在树中查找原始分组对象
const findGroupById = (id) => {
const rootItems = treeDataMap.value.get(0) || []
const search = (items) => {
for (const item of items) {
if (item.id === id) return item
if (item._children && item._children.length > 0) {
const found = search(item._children)
if (found) return found
}
}
return null
}
return search(rootItems)
}
const treeVersion = ref(0)
// 树形显示数据
const treeDisplayData = computed(() => {
const _v = treeVersion.value
const result = []
const rootItems = treeDataMap.value.get(0) || []
const productsMap = groupProductsMap.value
const processGroup = (group, displayLevel) => {
result.push({
...group,
_origRef: group,
level: displayLevel,
isGroup: true
})
if (group._expanded) {
if (expandedGroups.value.has(group.id) && !loadedProductGroups.value.has(group.id)) {
loadProductsForGroup(group.id)
}
if (group._children && group._children.length > 0) {
for (const child of group._children) {
processGroup(child, displayLevel + 1)
}
}
const products = productsMap.get(group.id)
if (products && products.length > 0) {
for (const product of products) {
result.push({
...product,
level: displayLevel + 1,
isProduct: true
})
}
}
}
}
for (const item of rootItems) {
if (item.isGroup) {
processGroup(item, item.level || 1)
}
}
return result
})
const clearProductCache = () => {
loadedProductGroups.value.clear()
groupProductsMap.value = new Map()
console.log('清除所有商品加载缓存')
}
const loadProductsForGroup = async (groupId) => {
if (loadedProductGroups.value.has(groupId)) {
return
}
try {
const res = await getProductList({
good_group_id: groupId,
page: 1,
count: 10,
delete: false
})
if (res.data.code === 200) {
const products = res.data.data.data || []
console.log(`加载分组 ${groupId} 的商品:`, products)
loadedProductGroups.value.add(groupId)
const productItems = products.map(product => ({
id: `product_${product.id}`,
type: 'product',
isProduct: true,
data: product,
hasChildren: false,
existSub: false,
_expanded: false,
_children: [],
_loading: false,
name: product.name,
price: product.price,
image: product.image,
inventoryControl: product.inventoryControl,
inventory: product.inventory,
recommend: product.recommend,
disable: product.disable,
parentId: groupId
}))
const newMap = new Map(groupProductsMap.value)
newMap.set(groupId, productItems)
groupProductsMap.value = newMap
console.log(`成功加载 ${productItems.length} 个商品到分组 ${groupId}`)
}
} catch (error) {
console.error('加载商品失败:', error)
ElMessage.error('加载商品失败')
}
}
const handleNameCellClick = (row) => {
if (row.isGroup) {
toggleExpand(row)
} else if (row.isProduct) {
handleEditProduct(row.data, row.parentId)
}
}
const toggleExpand = async (row) => {
const group = row._origRef || findGroupById(row.id)
if (!group || group._loading) return
if (group._expanded) {
group._expanded = false
expandedGroups.value.delete(group.id)
loadedProductGroups.value.delete(group.id)
if (groupProductsMap.value.has(group.id)) {
const newMap = new Map(groupProductsMap.value)
newMap.delete(group.id)
groupProductsMap.value = newMap
}
} else {
group._expanded = true
expandedGroups.value.add(group.id)
if (group.isGroup) {
if (group.existSub && (!group._children || group._children.length === 0)) {
group._loading = true
treeVersion.value++
try {
const childLevel = (group.level || 1) + 1
const res = await getProductGroupList({
parent_id: group.id,
level: childLevel,
count: 10
})
if (res.data.code === 200) {
const children = res.data.data.data || []
console.log(`加载分组 ${group.name} 的子级分组:`, children)
group._children = children.map(child => ({
...child,
type: 'group',
isGroup: true,
_expanded: false,
_children: [],
_loading: false,
existSub: child.existSub || false
}))
allGroupList.value = [...allGroupList.value, ...children]
}
} catch (error) {
console.error('加载子级分组失败:', error)
ElMessage.error('加载子级分组失败')
} finally {
group._loading = false
}
}
loadProductsForGroup(group.id)
}
}
treeVersion.value++
}
const expandAll = async () => {
const loadAndExpand = async (items) => {
for (const item of items) {
if (item.existSub) {
if (!item._children || item._children.length === 0) {
item._origRef = item
await toggleExpand(item)
} else {
item._expanded = true
expandedGroups.value.add(item.id)
loadProductsForGroup(item.id)
}
if (item._children && item._children.length > 0) {
await loadAndExpand(item._children)
}
} else {
item._expanded = true
expandedGroups.value.add(item.id)
loadProductsForGroup(item.id)
}
}
}
const rootItems = treeDataMap.value.get(0) || []
await loadAndExpand(rootItems)
treeVersion.value++
}
const collapseAll = () => {
const collapse = (items) => {
items.forEach(item => {
item._expanded = false
if (item._children && item._children.length > 0) {
collapse(item._children)
}
})
}
const rootItems = treeDataMap.value.get(0) || []
collapse(rootItems)
expandedGroups.value.clear()
clearProductCache()
treeVersion.value++
}
// 父级选择相关
const showParentSelector = ref(false)
const showCoverSelector = ref(false)
const selectedParent = ref(null)
const selectedParentName = computed(() => {
if (groupForm.parent_id) {
const parent = allGroupList.value.find(g => g.id === groupForm.parent_id)
return parent ? `${parent.name} (ID: ${parent.id})` : `分组ID: ${groupForm.parent_id}`
}
return ''
})
const parentOptions = computed(() => {
return allGroupList.value.filter(g => g.id !== groupForm.id)
})
// 标签选择相关
const showTagSelector = ref(false)
const tagSelectorLoading = ref(false)
const tagSelectorSearch = ref('')
const tagOptionsForSelector = ref([])
const selectedTag = ref(null)
const allTagOptions = ref([])
const currentEditTag = ref(null)
const selectedTagName = computed(() => {
if (groupForm.tag_id) {
const tag = allTagOptions.value.find(t => t.id === groupForm.tag_id)
if (tag) {
return `${tag.name} (ID: ${tag.id})`
}
if (currentEditTag.value && currentEditTag.value.id === groupForm.tag_id) {
return `${currentEditTag.value.name} (ID: ${currentEditTag.value.id})`
}
return `标签ID: ${groupForm.tag_id}`
}
return ''
})
const fetchTagOptionsForSelector = async () => {
tagSelectorLoading.value = true
try {
const params = { page: 1, count: 10 }
if (tagSelectorSearch.value) {
params.key = tagSelectorSearch.value
}
const res = await getProductGroupTagList(params)
if (res.data.code === 200) {
const data = res.data.data
if (Array.isArray(data)) {
tagOptionsForSelector.value = data
} else if (data && data.list) {
tagOptionsForSelector.value = data.list
} else if (data && data.data) {
tagOptionsForSelector.value = data.data
} else {
tagOptionsForSelector.value = []
}
if (!tagSelectorSearch.value) {
allTagOptions.value = tagOptionsForSelector.value
}
}
} catch (error) {
console.error('获取标签列表失败:', error)
} finally {
tagSelectorLoading.value = false
}
}
const fetchAllTagOptions = async () => {
try {
const res = await getProductGroupTagList({ page: 1, count: 10 })
if (res.data.code === 200) {
const data = res.data.data
if (Array.isArray(data)) {
allTagOptions.value = data
} else if (data && data.list) {
allTagOptions.value = data.list
} else if (data && data.data) {
allTagOptions.value = data.data
}
}
} catch (error) {
console.error('获取标签列表失败:', error)
}
}
const handleTagSelect = (row) => {
selectedTag.value = row
}
const confirmTagSelect = () => {
if (selectedTag.value) {
groupForm.tag_id = selectedTag.value.id
showTagSelector.value = false
selectedTag.value = null
}
}
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 = ''
fetchTagOptionsForSelector()
}
})
const getLevelText = (level) => {
return `${level}`
}
const getLevelType = (level) => {
const types = ['primary', 'success', 'warning', 'danger', 'info']
return types[(level - 1) % types.length] || 'info'
}
const getLevelColor = (level) => {
const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#9c27b0', '#00bcd4', '#ff9800']
return colors[(level - 1) % colors.length] || '#909399'
}
const getParentName = (parentId) => {
const parent = allGroupList.value.find(g => g.id === parentId)
return parent ? parent.name : `ID: ${parentId}`
}
const fetchGroupList = async () => {
loading.value = true
clearProductCache()
try {
const params = { ...queryParams }
const hasFilter = params.tag || params.key || params.disable !== undefined || params.level !== undefined
if (viewMode.value === 'tree' && !hasFilter) {
params.level = 1
delete params.page
} else if (viewMode.value === 'tree' && hasFilter) {
delete params.page
}
if (!params.tag) delete params.tag
if (params.level === undefined) delete params.level
if (params.disable === undefined) delete params.disable
if (!params.key) delete params.key
const res = await getProductGroupList(params)
if (res.data.code === 200) {
const data = res.data.data.data || []
if (viewMode.value === 'tree') {
if (hasFilter) {
const treeResult = buildTreeFromFilteredData(data)
treeDataMap.value.set(0, treeResult)
} else {
const rootItems = data.map(item => ({
...item,
type: 'group',
isGroup: true,
_expanded: false,
_children: [],
_loading: false
}))
treeDataMap.value.set(0, rootItems)
}
allGroupList.value = data
} else {
groupList.value = data
allGroupList.value = data
}
total.value = res.data.data.all_count || data.length
}
} catch (error) {
ElMessage.error('获取商品分组列表失败')
} finally {
loading.value = false
}
}
const buildTreeFromFilteredData = (data) => {
const roots = []
const nodeMap = new Map()
data.forEach(item => {
nodeMap.set(item.id, {
...item,
type: 'group',
isGroup: true,
_expanded: true,
_children: [],
_loading: false
})
})
data.forEach(item => {
const node = nodeMap.get(item.id)
const parentId = item.parentId || 0
if (parentId === 0 || !nodeMap.has(parentId)) {
roots.push(node)
} else {
const parent = nodeMap.get(parentId)
parent._children.push(node)
parent.existSub = true
}
})
roots.sort((a, b) => a.level - b.level)
return roots
}
const resetQuery = () => {
queryParams.tag = undefined
queryParams.level = undefined
queryParams.disable = undefined
queryParams.key = ''
queryParams.page = 1
treeDataMap.value.clear()
fetchGroupList()
}
const handleSizeChange = (size) => {
queryParams.count = size
fetchGroupList()
}
const handleCurrentChange = (page) => {
queryParams.page = page
fetchGroupList()
}
const handleAdd = (parentRow) => {
dialogType.value = 'add'
groupFormRef.value?.resetFields()
if (parentRow) {
const parentTagId = parentRow.tag?.id || parentRow.tagId
currentEditTag.value = parentRow.tag || null
Object.assign(groupForm, {
id: undefined,
name: '',
note: '',
disable: false,
level: parentRow.level + 1,
parent_id: parentRow.id,
cover_id: undefined,
cover_url: '',
tag_id: parentTagId || undefined,
index: 0
})
console.log('添加子级,父级信息:', parentRow.name, 'ID:', parentRow.id, 'Level:', parentRow.level, '标签:', parentRow.tag)
} else {
currentEditTag.value = null
Object.assign(groupForm, {
id: undefined,
name: '',
note: '',
disable: false,
level: 1,
parent_id: undefined,
cover_id: undefined,
cover_url: '',
tag_id: undefined,
index: 0
})
}
dialogVisible.value = true
}
const handleEdit = (row) => {
dialogType.value = 'edit'
currentEditTag.value = row.tag || null
Object.assign(groupForm, {
id: row.id,
name: row.name,
note: row.note || '',
disable: row.disable === true,
level: row.level || 1,
parent_id: row.parentId || undefined,
cover_id: row.coverId || undefined,
cover_url: row.cover || '',
tag_id: row.tag?.id || row.tagId || undefined,
index: row.index || 0
})
dialogVisible.value = true
console.log('编辑分组数据:', row, '原始disable:', row.disable)
console.log('表单数据:', groupForm, '表单disable:', groupForm.disable)
}
const handleDeleteProduct = async (product) => {
try {
await ElMessageBox.confirm(
`确定要删除商品"${product.name}"吗?此操作不可恢复!`,
'删除确认',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}
)
const res = await deleteProduct({ id: product.id })
if (res.data.code === 200) {
ElMessage.success('删除商品成功')
const index = productList.value.findIndex(p => p.id === product.id)
if (index > -1) {
productList.value.splice(index, 1)
}
const groupId = product.good_group_id || product.goodGroupId
if (groupId) {
loadedProductGroups.value.delete(groupId)
const newMap = new Map(groupProductsMap.value)
newMap.delete(groupId)
groupProductsMap.value = newMap
loadProductsForGroup(groupId)
}
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除商品失败:', error)
ElMessage.error('删除商品失败')
}
}
}
const handleAddProduct = () => {
productDialogType.value = 'add'
productFormRef.value?.resetFields()
Object.assign(productForm, {
id: undefined,
name: '',
table: '',
tag: '',
content: '',
cover_id: undefined,
cover_url: '',
good_group_id: undefined,
inventory_control: false,
inventory: 0,
price: 0,
pay_num: 1,
expire_time: 0,
recommend: false,
recommend_rebate: 0,
arg_type: 'all',
require_real_name: false
})
selectedProductGroup.value = null
productDialogVisible.value = true
}
const handleEditProduct = (product, parentGroupId) => {
productDialogType.value = 'edit'
const groupId = parentGroupId || product.good_group_id || product.goodGroupId
if (groupId) {
selectedProductGroup.value = allGroupList.value.find(g => g.id === groupId)
}
Object.assign(productForm, {
id: product.id,
name: product.name || '',
table: product.table || '',
tag: typeof product.tag === 'string' ? product.tag : '',
content: product.content || '',
cover_id: product.coverId,
cover_url: product.cover || '',
good_group_id: groupId,
inventory_control: product.inventoryControl,
inventory: product.inventory,
price: product.price / 100,
pay_num: product.payNum,
expire_time: product.expireTime,
recommend: product.recommend,
recommend_rebate: product.recommendRebate,
arg_type: product.argType || 'all',
can_renew: product.canRenew !== undefined ? product.canRenew : (product.can_renew !== undefined ? product.can_renew : true),
require_real_name: product.requireRealName ?? product.require_real_name ?? false,
sold_out: !!product.soldOut,
max_per_user: product.maxPerUser ?? product.max_per_user ?? 0,
send_notice: !!product.sendNotice,
renew_price: (product.renewPrice ?? product.renew_price ?? 0) / 100,
renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0
})
productDialogVisible.value = true
}
const submitProductForm = () => {
productFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = {
name: (productForm.name || '').trim(),
table: (productForm.table || '').trim(),
tag: (typeof productForm.tag === 'string' ? productForm.tag : '').trim(),
content: (productForm.content || '').trim(),
cover_id: productForm.cover_id,
good_group_id: productForm.good_group_id,
inventory_control: productForm.inventory_control,
inventory: Number(productForm.inventory) || 0,
price: Number(productForm.price) || 0,
pay_num: Number(productForm.pay_num) || 1,
expire_time: Number(productForm.expire_time) || 0,
recommend: productForm.recommend,
recommend_rebate: Number(productForm.recommend_rebate) || 0,
arg_type: productForm.arg_type,
can_renew: productForm.can_renew,
require_real_name: productForm.require_real_name,
sold_out: productForm.sold_out === true,
max_per_user: Number(productForm.max_per_user) || 0,
send_notice: productForm.send_notice === true,
renew_price: Number(productForm.renew_price) || 0,
renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0
}
let res
if (productDialogType.value === 'add') {
res = await createProduct(submitData)
} else {
submitData.id = productForm.id
res = await updateProduct(submitData)
}
if (res.data.code === 200) {
ElMessage.success(productDialogType.value === 'add' ? '新增成功' : '修改成功')
productDialogVisible.value = false
if (productForm.good_group_id) {
loadedProductGroups.value.delete(productForm.good_group_id)
const newMap = new Map(groupProductsMap.value)
newMap.delete(productForm.good_group_id)
groupProductsMap.value = newMap
}
}
} catch (error) {
console.error('提交失败:', error)
ElMessage.error('操作失败')
}
}
})
}
const handleProductGroupSelect = (row) => {
selectedProductGroup.value = row
}
const confirmProductGroupSelect = () => {
if (selectedProductGroup.value) {
productForm.good_group_id = selectedProductGroup.value.id
showProductGroupSelector.value = false
}
}
const clearProductGroup = () => {
productForm.good_group_id = undefined
selectedProductGroup.value = null
}
const handleProductCoverSelect = (file) => {
productForm.cover_id = file.cover_id
productForm.cover_url = file.url || ''
coverSelectorVisible.value = false
}
const clearProductCover = () => {
productForm.cover_id = undefined
productForm.cover_url = ''
}
const clearParent = () => {
groupForm.parent_id = undefined
groupForm.level = 1
}
const handleParentSelect = (row) => {
selectedParent.value = row
}
const confirmParentSelect = () => {
if (selectedParent.value) {
groupForm.parent_id = selectedParent.value.id
groupForm.level = selectedParent.value.level + 1
showParentSelector.value = false
selectedParent.value = null
}
}
const handleCoverSelect = (file) => {
groupForm.cover_id = file.cover_id
groupForm.cover_url = file.url || ''
}
const clearGroupCover = () => {
groupForm.cover_id = undefined
groupForm.cover_url = ''
}
const handleStatusChange = async (row, disable) => {
try {
let res
if (disable === false) {
res = await startProductGroup({ id: row.id })
} else {
res = await hideProductGroup({ id: row.id })
}
if (res.data.code === 200) {
ElMessage.success('状态修改成功')
}
} catch (error) {
ElMessage.error('状态修改失败')
row.disable = !disable
}
}
const handleDelete = (row) => {
const hasChildren = allGroupList.value.some(g => g.parentId === row.id)
if (hasChildren) {
ElMessage.warning('该分组下有子分组,请先删除子分组')
return
}
ElMessageBox.confirm(`确认删除商品分组 ${row.name} 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteProductGroup({ id: row.id })
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchGroupList()
}
} catch (error) {
ElMessage.error('删除失败')
}
}).catch(() => {})
}
const submitForm = () => {
groupFormRef.value?.validate(async (valid) => {
if (valid) {
if (!groupForm.name || !groupForm.name.trim()) {
ElMessage.warning('请输入分组名称')
return
}
try {
const submitData = {
name: groupForm.name.trim(),
note: groupForm.note || '',
disable: groupForm.disable,
index: Number(groupForm.index) || 0
}
if (groupForm.parent_id) {
submitData.parent_id = Number(groupForm.parent_id)
const parentGroup = allGroupList.value.find(g => g.id === groupForm.parent_id)
console.log('父级分组:', parentGroup)
if (parentGroup && typeof parentGroup.level === 'number') {
submitData.level = String(parentGroup.level + 1)
} else {
submitData.level = String(groupForm.level || 2)
}
console.log('计算得到的level:', submitData.level, '父级level:', parentGroup?.level)
} else {
submitData.level = '1'
}
if (groupForm.cover_id) {
submitData.cover_id = Number(groupForm.cover_id)
}
if (groupForm.tag_id) {
submitData.tag_id = Number(groupForm.tag_id)
}
console.log('提交数据:', submitData)
let res
if (dialogType.value === 'add') {
res = await createProductGroup(submitData)
} else {
submitData.id = groupForm.id
res = await updateProductGroup(submitData)
}
if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
dialogVisible.value = false
fetchGroupList()
}
} catch (error) {
ElMessage.error('操作失败')
}
}
})
}
// ==================== 商品参数管理(子组件通信) ====================
const paramDialogVisible = ref(false)
const currentProductId = ref(null)
const handleParameter = (row) => {
currentProductId.value = row.id
paramDialogVisible.value = true
}
// ==================== 商品套餐管理(子组件通信) ====================
const planDialogVisible = ref(false)
const currentPlanProductId = ref(null)
const currentPlanProductName = ref('')
const handlePlan = (row) => {
currentPlanProductId.value = row.id
currentPlanProductName.value = row.name
planDialogVisible.value = true
}
// 监听Tab切换
watch(activeTab, (newVal) => {
if (newVal === 'tag') {
groupTagManagerRef.value?.fetchTagList()
}
})
const groupTagManagerRef = ref(null)
// 初始化
onMounted(() => {
fetchGroupList()
fetchAllTagOptions()
})
</script>
<style scoped>
.product-group-container {
padding: 0;
}
.main-container {
border: 1px solid #e1e8ed;
background: #ffffff;
}
.main-tabs {
padding: 0 20px;
}
.tag-selector-header {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
:deep(.el-tabs__header) {
margin-bottom: 0;
}
:deep(.el-tabs__content) {
padding: 0;
}
.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 {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin: 0;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 0;
}
.action-bar {
display: flex;
gap: 12px;
flex-shrink: 0;
flex-wrap: wrap;
align-items: center;
}
.view-switch {
margin-left: 8px;
}
.view-switch :deep(.el-radio-button__inner) {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
}
.view-switch :deep(.el-icon) {
font-size: 14px;
}
.table-section {
padding: 0;
}
.group-name-cell {
display: flex;
align-items: center;
gap: 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.group-name-cell.is-clickable {
cursor: pointer;
user-select: none;
}
.group-name-cell.is-clickable:hover {
background-color: #f0f7ff;
}
.group-name-cell.is-clickable:hover .expand-icon {
color: #409eff;
}
.group-name {
font-weight: 500;
}
.expand-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
color: #909399;
transition: transform 0.2s;
}
.expand-icon:hover {
color: #409eff;
}
.expand-icon .el-icon {
transition: transform 0.2s;
}
.expand-icon .el-icon.is-expanded {
transform: rotate(90deg);
}
.expand-placeholder {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.text-muted {
color: #c0c4cc;
font-size: 12px;
}
.action-buttons {
display: flex;
gap: 4px;
align-items: center;
flex-wrap: nowrap;
}
.action-buttons .el-button {
padding: 4px 8px;
}
.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;
}
.form-tip {
font-size: 12px;
color: #909399;
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;
gap: 8px;
width: 100%;
}
.recommend-user-selector .el-input {
flex: 1;
}
.recommend-user-selector .clear-btn {
flex-shrink: 0;
color: #f56c6c;
}
/* 商品表单优化布局 */
.product-form-dialog :deep(.el-dialog__body) {
padding-top: 16px;
}
.product-form {
--section-gap: 20px;
}
.product-form-header {
display: flex;
gap: 24px;
padding: 16px;
background: #fafbfc;
border: 1px solid #ebeef5;
border-radius: 8px;
margin-bottom: var(--section-gap);
}
.cover-uploader {
position: relative;
width: 180px;
height: 180px;
flex-shrink: 0;
border: 1px dashed #dcdfe6;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: #fff;
transition: border-color 0.2s;
}
.cover-uploader:hover {
border-color: #409eff;
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #909399;
}
.cover-placeholder-icon {
font-size: 40px;
}
.cover-placeholder-text {
font-size: 13px;
}
.cover-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
opacity: 0;
transition: opacity 0.2s;
font-size: 13px;
}
.cover-mask .el-icon {
font-size: 22px;
}
.cover-uploader:hover .cover-mask {
opacity: 1;
}
.cover-clear-btn {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
opacity: 0;
transition: opacity 0.2s;
}
.cover-uploader:hover .cover-clear-btn {
opacity: 1;
}
.basic-fields {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.basic-fields :deep(.el-form-item) {
margin-bottom: 12px;
}
.basic-fields :deep(.el-form-item:last-child) {
margin-bottom: 0;
}
.group-picker {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 32px;
padding: 0 11px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
cursor: pointer;
color: #303133;
font-size: 14px;
transition: border-color 0.2s;
box-sizing: border-box;
}
.group-picker:hover {
border-color: #c0c4cc;
}
.group-picker.is-empty .group-picker-text {
color: #a8abb2;
}
.group-picker.is-disabled {
background: #f5f7fa;
cursor: not-allowed;
color: #a8abb2;
}
.group-picker.is-disabled:hover {
border-color: #dcdfe6;
}
.group-picker.is-disabled .group-picker-text,
.group-picker.is-disabled .group-picker-icon {
color: #a8abb2;
}
.note-form-item {
margin-bottom: 0 !important;
padding: 16px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fff;
}
.group-status-item :deep(.el-radio-button__inner) {
padding: 6px 18px;
font-size: 13px;
}
.group-picker-icon {
color: #909399;
flex-shrink: 0;
}
.group-picker-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-picker-clear {
flex-shrink: 0;
padding: 0 4px;
}
.group-picker-arrow {
color: #c0c4cc;
font-size: 12px;
flex-shrink: 0;
}
.product-form-tabs {
margin-top: 16px;
}
.product-form-tabs :deep(.el-tabs__header) {
margin-bottom: 16px;
}
.product-form-tabs :deep(.el-tab-pane) {
padding: 4px 0;
}
.form-section {
margin-bottom: var(--section-gap);
padding: 16px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fff;
}
.form-section:last-child {
margin-bottom: 0;
}
.form-section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
padding-left: 8px;
border-left: 3px solid #409eff;
line-height: 1.2;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 24px;
}
.form-grid :deep(.el-form-item) {
margin-bottom: 12px;
}
.form-grid .arg-type-item {
grid-column: span 2;
}
.unit-suffix {
color: #909399;
font-weight: normal;
font-size: 12px;
margin-left: 2px;
}
.product-form :deep(.el-form-item__label) {
font-weight: 500;
padding-bottom: 4px !important;
line-height: 1.4;
}
@media (max-width: 720px) {
.product-form-header {
flex-direction: column;
align-items: center;
}
.form-grid {
grid-template-columns: 1fr;
}
.form-grid .arg-type-item {
grid-column: span 1;
}
}
.product-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.product-info .price {
font-weight: 600;
color: #e74c3c;
font-size: 14px;
}
.product-info .table-name {
font-size: 12px;
color: #606266;
}
.product-info .inventory {
font-size: 12px;
color: #909399;
}
.product-info .recommend {
margin-top: 2px;
}
.product-info .expire-time {
font-size: 11px;
color: #909399;
}
.product-info-inline {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.product-info-inline .product-price {
font-weight: 600;
color: #e74c3c;
font-size: 14px;
white-space: nowrap;
}
.product-tags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.product-tags .text-muted {
font-size: 12px;
color: #c0c4cc;
}
:deep(.el-table) {
border: none;
color: #2c3e50;
}
:deep(.el-table__expand-icon) {
display: none !important;
}
:deep(.el-table__header) {
background: #f8f9fa;
}
: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;
}
: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-id { width: 80px; }
.skeleton-name { width: 200px; }
.skeleton-note { flex: 1; min-width: 150px; }
.skeleton-level { width: 100px; }
.skeleton-status { width: 100px; }
.skeleton-action { width: 180px; height: 32px; }
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (max-width: 768px) {
.filter-content {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.search-form {
flex-direction: column;
width: 100%;
}
.search-form :deep(.el-form-item) {
width: 100%;
}
.search-form :deep(.el-input),
.search-form :deep(.el-select) {
width: 100% !important;
}
.action-bar {
width: 100%;
justify-content: flex-start;
}
}
</style>