ソースを参照

套餐模块修改

“陈博论” 10 ヶ月 前
親
コミット
351deb21d4

+ 1 - 1
web/.env

@@ -1,5 +1,5 @@
 # port 端口号
-VITE_PORT = 1818
+VITE_PORT = 1919
 
 # open 运行 npm run dev 时自动打开浏览器
 VITE_OPEN = false

+ 91 - 0
web/src/api/backend/package/package.ts

@@ -0,0 +1,91 @@
+import createAxios from '/@/utils/axios'
+
+// 列表
+export function list(data: anyObj) {
+    return createAxios({
+        url: '/admin/package.Package/index',
+        method: 'get',
+        params: data,
+    })
+}
+
+// 添加、编辑
+export function edit(data: anyObj) {
+    return createAxios({
+        url: '/admin/package.Package/edit',
+        method: 'post',
+        data: data,
+    })
+}
+
+// 删除
+export function del(data:anyObj) {
+    return createAxios({
+        url: '/admin/package.Package/delete',
+        method: 'post',
+        data: data,
+    })
+}
+
+// 更新产品状态
+export function batchStatus(data:anyObj) {
+    return createAxios({
+        url: '/admin/package.Package/batchStatus',
+        method: 'get',
+        params: data,
+    })
+}
+
+// 详情
+export function detail(data:anyObj) {
+    return createAxios({
+        url: '/admin/package.Package/detail',
+        method: 'GET',
+        params: data,
+    })
+}
+
+// 套餐统计信息
+export function statistics(data:anyObj) {
+    return createAxios({
+        url: '/admin/package.Package/statistics',
+        method: 'GET',
+        params: data,
+    })
+}
+
+// 规格列表
+export function specList(data:anyObj) {
+    return createAxios({
+        url: '/admin/package.Package/specList',
+        method: 'get',
+        params: data,
+    })
+}
+
+// 添加/编辑套餐规格
+export function editSpec(data:anyObj) {
+    return createAxios({
+        url: '/admin/package.Package/editSpec',
+        method: 'post',
+        data: data,
+    })
+}
+
+//删除套餐规格
+export function deleteSpec(data:anyObj) {
+    return createAxios({
+        url: '/admin/package.Package/deleteSpec',
+        method: 'post',
+        data: data,
+    })
+}
+
+//删除套餐规格
+export function batchUpdateStock(data:anyObj) {
+    return createAxios({
+        url: '/admin/package.Package/batchUpdateStock',
+        method: 'get',
+        params: data,
+    })
+}

+ 46 - 0
web/src/api/backend/package/product.ts

@@ -0,0 +1,46 @@
+import createAxios from '/@/utils/axios'
+
+// 列表
+export function list(data: anyObj) {
+    return createAxios({
+        url: '/admin/Package.product/index',
+        method: 'get',
+        params: data,
+    })
+}
+
+// 添加、编辑
+export function edit(data: anyObj) {
+    return createAxios({
+        url: '/admin/Package.product/edit',
+        method: 'post',
+        data: data,
+    })
+}
+
+// 删除
+export function del(data:anyObj) {
+    return createAxios({
+        url: '/admin/Package.product/delete',
+        method: 'post',
+        data: data,
+    })
+}
+
+// 更新产品状态
+export function batchStatus(data:anyObj) {
+    return createAxios({
+        url: '/admin/Package.product/batchStatus',
+        method: 'post',
+        data: data,
+    })
+}
+
+// 批量更新库存状态
+export function batchStockStatus(data:anyObj) {
+    return createAxios({
+        url: '/admin/Package.product/batchStockStatus',
+        method: 'post',
+        data: data,
+    })
+}

+ 151 - 103
web/src/views/backend/package/package/index.vue

@@ -1,176 +1,224 @@
 <template>
-    <!-- 外层容器,保持默认的表格布局框架 -->
     <div class="default-main ba-table-box">
-        <!-- 备注提示,当有 remark 时展示 -->
         <el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info"
             show-icon />
 
-        <!-- 顶部筛选与新增区域,左侧输入与下拉,右侧添加按钮 -->
         <!-- 表格顶部菜单 -->
-        <TableHeader :buttons="['refresh', 'columnDisplay']"
-            :quick-search-placeholder="t('Quick search placeholder', { fields: '产品名称' })">
-            <!-- 套餐名称输入框,支持清空 -->
-            <el-input class="filter-item" v-model="searchForm.name" placeholder="套餐名称" clearable @input="handleFilter"
-                @clear="handleFilter" />
-            <!-- 库存状态选择框,模拟原型的状态下拉 -->
-            <el-select class="filter-item" v-model="searchForm.inventory_status" placeholder="库存状态" clearable
-                @change="handleFilter" @clear="handleFilter">
-                <el-option v-for="item in inventoryOptions" :key="item.value" :label="item.label" :value="item.value" />
-            </el-select>
-            <!-- 右侧添加按钮 -->
+        <TableHeader :buttons="['refresh', 'quickSearch', 'columnDisplay']"
+            :quick-search-placeholder="t('Quick search placeholder', { fields: '套餐名称' })">
             <el-button type="primary" @click="onAdd">添加</el-button>
         </TableHeader>
-        <!-- 表格主体,使用通用 Table 组件并自定义操作列 -->
+
+        <!-- 表格 -->
+        <!-- 要使用`el-table`组件原有的属性,直接加在Table标签上即可 -->
         <Table>
-            <!-- 自定义操作列插槽,呈现编辑/删除/详情 -->
+            <template #status>
+                <el-table-column prop="status" align="center" label="状态" min-width="100">
+                    <template #default="scope">
+                        <el-switch v-model="scope.row.status" 
+                            :active-value="1" :inactive-value="0" />
+                    </template>
+                </el-table-column>
+            </template>
             <template #operate>
-                <el-table-column prop="operate" align="center" label="操作" min-width="180">
+                <el-table-column prop="operate" align="center" label="操作" min-width="150">
                     <template #default="scope">
                         <el-button type="primary" size="small" @click="onEdit(scope.row)">编辑</el-button>
-                        <el-button type="danger" size="small" @click="del(scope.row)">删除</el-button>
-                        <el-button style="color: #fff;" color="#9ca0fa" size="small"
-                            @click="viewDetail(scope.row)">套餐详情</el-button>
+                        <el-button type="danger" size="small" @click="handleDel(scope.row)">删除</el-button>
                     </template>
                 </el-table-column>
             </template>
         </Table>
 
-        <!-- 添加编辑弹框 -->
-        <PopupForm ref="popupFormRef"/>
-
-        <!-- 套餐详情 / 库存调整弹框 -->
-        <Detail ref="detailRef" @confirm="handleDetailConfirm" />
-
+        <!-- 表单 -->
+        <PopupForm ref="popupFormRef" @success="handleFormSuccess" />
     </div>
 </template>
 
 <script setup lang="ts">
-// 引入响应式工具与生命周期钩子
-import { provide, reactive, ref } from 'vue'
-// 引入表格封装类
+import { provide, useTemplateRef } from 'vue'
+import { cloneDeep } from 'lodash-es'
 import baTableClass from '/@/utils/baTable'
-// 引入通用表格组件
 import Table from '/@/components/table/index.vue'
 import TableHeader from '/@/components/table/header/index.vue'
 import PopupForm from './popupForm.vue'
-import Detail from './detail.vue'
-// 引入接口封装
 import { baTableApi } from '/@/api/common'
-// 引入国际化
 import { useI18n } from 'vue-i18n'
-// 引入消息提示
-import { ElMessage } from 'element-plus'
+import { del, edit } from '/@/api/backend/package/package'
+import { ElMessageBox, ElMessage } from 'element-plus'
+
+type TableRow = Record<string, any>
 
-// 组件名称定义
 defineOptions({
     name: 'package/package',
 })
 
-// 国际化方法
 const { t } = useI18n()
 
-const popupFormRef = ref<InstanceType<typeof PopupForm>>()
-const detailRef = ref<InstanceType<typeof Detail>>()
-
-// 筛选表单数据,包含套餐名称与库存状态
-const searchForm = reactive({
-    name: '', // 套餐名称关键词
-    inventory_status: '', // 库存状态筛选值
-})
-
-// 库存状态选项,映射下拉显示
-const inventoryOptions = [
-    { label: '充足', value: '1' },
-    { label: '即将售罄', value: '2' },
-    { label: '售罄', value: '3' },
-]
+const popupFormRef = useTemplateRef<InstanceType<typeof PopupForm>>('popupFormRef')
 
-// 初始化表格实例,配置列信息
 const baTable = new baTableClass(
     new baTableApi('/admin/package.package/'),
     {
         column: [
             { label: '编号', prop: 'id', align: 'center', width: 70 },
-            { label: '套餐名称', prop: 'name', align: 'center', minWidth: 140 },
             {
-                label: '库存状态',
-                prop: 'inventory_status',
+                label: '套餐名称',
+                prop: 'name',
                 align: 'center',
-                render: 'tag',
-                custom: { '1': 'primary', '2': 'warning', '3': 'danger' },
-                replaceValue: { '1': '充足', '2': '即将售罄', '3': '售罄' },
-                effect: 'plain',
-                minWidth: 120,
+                minWidth: 160,
             },
             {
-                label: '剩余库存',
-                prop: 'remaining_stock',
+                label: '套餐类型',
+                prop: 'type',
+                render: 'tag',
+                custom: {
+                    '1': 'primary',
+                    '2': 'success',
+                },
+                replaceValue: {
+                    '1': '个人套餐',
+                    '2': '企业套餐',
+                },
                 align: 'center',
                 minWidth: 140,
             },
-            { label: '已卖数量', prop: 'sold_quantity', align: 'center', minWidth: 100 },
-            { label: '创建时间', prop: 'create_time', align: 'center', minWidth: 160 },
-            { label: '操作', align: 'center', render: 'slot', slotName: 'operate', operator: false, minWidth: 180 },
+            
+            {
+                label: '状态',
+                prop: 'status',
+                align: 'center',
+                render: 'slot',
+                slotName: 'status',
+                minWidth: 100,
+            },
+            {
+                label: '规格数量',
+                prop: 'spec_count',
+                align: 'center',
+                minWidth: 110,
+            },
+            {
+                label: '已售数量',
+                prop: 'total_sold',
+                align: 'center',
+                minWidth: 110,
+            },
+            {
+                label: '套餐描述',
+                prop: 'description',
+                align: 'center',
+                showOverflowTooltip: true,
+                minWidth: 200,
+            },
+            {
+                label: '创建时间',
+                prop: 'create_time',
+                align: 'center',
+                minWidth: 170,
+            },
+            {
+                label: '操作',
+                align: 'center',
+                render: 'slot',
+                slotName: 'operate',
+                operator: false,
+            },
         ],
-        dblClickNotEditColumn: [undefined],
+        dblClickNotEditColumn: [undefined,],
     },
     {
         defaultItems: {
+            id: undefined,
             name: '',
-            inventory_status: '1',
+            description: '',
+            status: '1',
         },
     },
     {
-        onTableDblclick: () => {
-            return false
-        },
-    },
+		onTableDblclick: () => {
+			return false
+		},
+	},
 )
 
-// 注入表格实例供子组件使用
 baTable.mount()
 baTable.getData()
 provide('baTable', baTable)
 
-// 删除动作,展示提示
-const del = (row: TableRow) => {
-    ElMessage.success(`已删除套餐:${row.name}`)
-}
 
-// 编辑动作:打开弹框并带入当前行
-const onEdit = (row: TableRow) => {
-    popupFormRef.value?.editDialog(row)
+// 删除
+const handleDel = (row: TableRow) => {
+    ElMessageBox.confirm(`确定删除套餐"${row.name}"吗?`, '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning',
+    }).then(() => {
+        del({ id: row.id }).then(() => {
+            baTable.getIndex()
+        })
+    })
 }
 
-// 新增动作,展示提示
 const onAdd = () => {
     popupFormRef.value?.openDialog()
 }
 
-
-// 套餐详情动作,弹出详情弹框,可从按钮或库存单元格进入
-const viewDetail = (row: Record<string, any>, fromStockClick = false) => {
-    if (!row) return
-    detailRef.value?.open(row as any)
-    if (!fromStockClick) {
-        ElMessage.success(`查看套餐详情:${row.name}`)
-    }
-}
-
-// 详情弹框确认修改库存
-const handleDetailConfirm = () => {
-
+const onEdit = (row: TableRow) => {
+    popupFormRef.value?.editDialog(row)
 }
 
-// 输入或选择变化时触发过滤
-const handleFilter = () => {
-    baTable.getData()
+// 处理表单提交成功
+const handleFormSuccess = async (submitData: any) => {
+    try {
+        const res = await edit({
+            id: submitData.id,
+            name: submitData.name,
+        })
+        
+        // 如果是新增,获取返回的套餐ID,并切换到编辑模式
+        if (submitData.type === 'add') {
+            // 从响应中获取套餐ID,通常后端会返回新创建的记录ID
+            // 尝试多种可能的响应结构
+            const packageId = (res?.data as any)?.id || (res?.data as any)?.data?.id || (res as any)?.id
+            
+            if (packageId) {
+                // 通知 popupForm 保存成功,切换到编辑模式
+                popupFormRef.value?.handleSaveSuccess(packageId)
+                // 刷新列表
+                baTable.getIndex()
+            } else {
+                // 如果后端没有返回ID,刷新列表后通过名称查找(备用方案)
+                ElMessage.success('添加成功')
+                await baTable.getIndex()
+                
+                // 从刷新后的列表中查找刚创建的套餐
+                const tableData = baTable.table.data
+                if (tableData && Array.isArray(tableData)) {
+                    const newPackage = tableData.find((item: any) => item.name === submitData.name)
+                    if (newPackage && newPackage.id) {
+                        popupFormRef.value?.handleSaveSuccess(newPackage.id)
+                    } else {
+                        ElMessage.info('请点击编辑按钮添加规格')
+                    }
+                } else {
+                    ElMessage.info('请点击编辑按钮添加规格')
+                }
+            }
+        } else {
+            // 编辑模式,直接刷新列表
+            ElMessage.success('编辑成功')
+            baTable.getIndex()
+        }
+    } catch (error: any) {
+        ElMessage({
+            type: 'error',
+            message: error?.msg || (submitData.type === 'add' ? '添加失败' : '编辑失败'),
+        })
+    }
 }
+ 
 </script>
 
-<style scoped lang="scss">
-.filter-item {
-    width: 180px;
-    margin: 0 6px;
-}
-</style>
+<style scoped lang="scss"></style>
+

+ 167 - 261
web/src/views/backend/package/package/popupForm.vue

@@ -13,90 +13,68 @@
 					<el-form-item label="套餐名称" prop="name">
 						<el-input v-model="formData.name" placeholder="请输入套餐名称" />
 					</el-form-item>
-					<!-- 套餐规格配置 -->
-					<el-form-item prop="specificationsList">
+					<!-- 套餐规格列表 -->
+					<el-form-item label="套餐规格" v-if="currentEditId">
 						<div class="spec-section">
 							<div class="spec-header">
-								<span class="spec-title">套餐规格:</span>
+								<span class="spec-title">规格列表:</span>
 								<el-button
 									type="primary"
 									size="small"
 									plain
 									class="spec-add-btn"
-									@click="addSpecification"
+									@click="openSpecDialog"
 								>
 									<span class="spec-add-icon">+</span>
 									<span>新增规格</span>
 								</el-button>
 							</div>
-							<div class="spec-row" v-for="(item, index) in formData.specificationsList" :key="index">
-								<!-- 第一行:规格名称 / 价格 / 库存 / 套餐类型 -->
-								<div class="spec-row-line spec-row-line--top">
-									<el-input
-										v-model="item.specName"
-										placeholder="规格名称"
-										class="spec-input spec-name"
-									/>
-									<div class="spec-group">
-										<el-input-number
-											v-model.number="item.price"
-											:min="0"
-											placeholder="价格"
-											class="spec-input spec-price"
-										/>
-										<span class="spec-unit">元</span>
-									</div>
-									<el-select v-model="item.packageType" placeholder="套餐类型"
-										class="spec-input spec-select">
-										<el-option v-for="opt in packageTypeOptions" :key="opt.value" :label="opt.label"
-											:value="opt.value" />
-									</el-select>
-									<div class="spec-group">
-										<span class="spec-label">库存:</span>
-										<el-input-number v-model.number="item.stock" :min="0" placeholder="库存数量"
-											class="spec-input"  />
-									</div>
-
-								</div>
-
-								<!-- 第二行:计费方式 / 次数 / 说明 / 删除 -->
-								<div class="spec-row-line spec-row-line--bottom">
-									<el-select
-										v-model="item.chargeType"
-										placeholder="计费方式"
-										class="spec-input spec-select"
-									>
-										<el-option
-											v-for="opt in chargeTypeOptions"
-											:key="opt.value"
-											:label="opt.label"
-											:value="opt.value"
-										/>
-									</el-select>
-									<div class="spec-group" v-if="showQuantity(item.chargeType)">
-										<el-input-number
-											v-model.number="item.quantity"
-											:min="0"
-											placeholder="数量"
-											class="spec-input spec-quantity"
-										/>
-										<span class="spec-unit">{{ getUnitLabel(item.chargeType) }}</span>
-									</div>
-									<el-input
-										v-model="item.remark"
-										placeholder="说明"
-										class="spec-input spec-remark"
-									/>
-									<el-button
-										type="danger"
-										size="small"
-										text
-										class="spec-delete-btn"
-										@click="removeSpecification(index)"
-									>
-										<span class="spec-delete-icon">×</span>
-										<span>删除</span>
-									</el-button>
+							<div v-loading="specLoading" class="spec-list">
+								<el-table :data="specListData" border stripe style="width: 100%">
+									<el-table-column prop="spec_name" label="规格名称" min-width="120" />
+									<el-table-column prop="price" label="价格" min-width="100">
+										<template #default="scope">
+											<span>¥{{ scope.row.price }}</span>
+										</template>
+									</el-table-column>
+									<el-table-column prop="stock" label="库存" min-width="80" />
+									<el-table-column prop="type" label="套餐类型" min-width="100">
+										<template #default="scope">
+											<el-tag :type="scope.row.type === '1' ? 'primary' : 'success'">
+												{{ scope.row.type === '1' ? '个人套餐' : '企业套餐' }}
+											</el-tag>
+										</template>
+									</el-table-column>
+									<el-table-column prop="card_type" label="卡类型" min-width="100">
+										<template #default="scope">
+											{{ getCardTypeLabel(scope.row.card_type) }}
+										</template>
+									</el-table-column>
+									<el-table-column label="次数/积分" min-width="100">
+										<template #default="scope">
+											<span v-if="scope.row.card_type === '1' && scope.row.unit_value">
+												{{ scope.row.unit_value }}次
+											</span>
+											<span v-else-if="scope.row.card_type === '2' && scope.row.points_value">
+												{{ scope.row.points_value }}分
+											</span>
+											<span v-else>-</span>
+										</template>
+									</el-table-column>
+									<el-table-column prop="description" label="说明" min-width="150" show-overflow-tooltip />
+									<el-table-column label="操作" width="150" fixed="right">
+										<template #default="scope">
+											<el-button type="primary" size="small" @click="editSpecItem(scope.row)">
+												编辑
+											</el-button>
+											<el-button type="danger" size="small" @click="deleteSpecItem(scope.row)">
+												删除
+											</el-button>
+										</template>
+									</el-table-column>
+								</el-table>
+								<div v-if="!specLoading && specListData.length === 0" class="spec-empty">
+									<el-empty description="暂无规格,请添加规格" :image-size="80" />
 								</div>
 							</div>
 						</div>
@@ -112,12 +90,17 @@
 			</div>
 		</template>
 	</el-dialog>
+	
+	<!-- 规格弹框 -->
+	<SpecDialog ref="specDialogRef" @success="loadSpecList" />
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, computed } from 'vue'
-import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
+import { ref, reactive, watch } from 'vue'
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
 import { useI18n } from 'vue-i18n'
+import { specList, deleteSpec } from '/@/api/backend/package/package'
+import SpecDialog from './specDialog.vue'
 
 const { t } = useI18n()
 
@@ -129,49 +112,25 @@ const dialogType = ref<'add' | 'edit'>('add')
 const currentEditId = ref<number | null>(null)
 
 const loading = ref(false)
+const specLoading = ref(false)
 
 const formRef = ref<FormInstance>()
+const specDialogRef = ref<InstanceType<typeof SpecDialog>>()
 
 const emits = defineEmits(['success'])
 
-
 const formData = reactive({
 	name: '',
-	specificationsList: [] as SpecificationItem[],
 })
 
-
+// 规格列表
+const specListData = ref<any[]>([])
 
 // 表单验证规则
 const rules: FormRules = {
 	name: [
 		{ required: true, message: '请输入套餐名称', trigger: 'change' }
 	],
-	specificationsList: [
-		{
-			validator: (_rule, value, callback) => {
-				if (!value || !value.length) {
-					return callback(new Error('请至少添加一个套餐规格'))
-				}
-				const invalid = value.some((item: SpecificationItem) => {
-					const needQuantity = showQuantity(item.chargeType)
-					return (
-						!item.specName ||
-						item.price == null ||
-						item.stock == null ||
-						!item.packageType ||
-						!item.chargeType ||
-						(needQuantity && item.quantity == null)
-					)
-				})
-				if (invalid) {
-					return callback(new Error('请完整填写每一行套餐规格'))
-				}
-				return callback()
-			},
-			trigger: 'change',
-		},
-	]
 }
 
 // 打开对话框(新增)
@@ -180,11 +139,10 @@ const openDialog = () => {
 	dialogType.value = 'add'
 	currentEditId.value = null
 	resetForm()
-
 }
 
 // 编辑:接收列表行数据,回填到表单
-const editDialog = (row: TableRow & { id?: number; name?: string; specificationsList?: SpecificationItem[] }) => {
+const editDialog = (row: TableRow & { id?: number; name?: string }) => {
 	dialogVisible.value = true
 	dialogType.value = 'edit'
 	currentEditId.value = row.id ?? null
@@ -195,12 +153,9 @@ const editDialog = (row: TableRow & { id?: number; name?: string; specifications
 	// 回填基础信息
 	formData.name = row.name ?? ''
 
-	// 回填规格列表
-	if (row.specificationsList && row.specificationsList.length) {
-		formData.specificationsList = row.specificationsList.map((item) => ({ ...item }))
-	} else {
-		formData.specificationsList = []
-		addSpecification()
+	// 加载规格列表
+	if (currentEditId.value) {
+		loadSpecList()
 	}
 }
 
@@ -208,8 +163,79 @@ const editDialog = (row: TableRow & { id?: number; name?: string; specifications
 const resetForm = () => {
 	formRef.value?.clearValidate()
 	formData.name = ''
-	formData.specificationsList = []
-	addSpecification()
+	specListData.value = []
+}
+
+// 加载规格列表
+const loadSpecList = async () => {
+	if (!currentEditId.value) return
+	
+	specLoading.value = true
+	try {
+		const res = await specList({ package_id: currentEditId.value })
+		if (res.data && Array.isArray(res.data)) {
+			specListData.value = res.data
+		} else {
+			specListData.value = []
+		}
+	} catch (error: any) {
+		ElMessage({
+			type: 'error',
+			message: error?.msg || '加载规格列表失败',
+		})
+		specListData.value = []
+	} finally {
+		specLoading.value = false
+	}
+}
+
+// 打开规格弹框(新增)
+const openSpecDialog = () => {
+	if (!currentEditId.value) {
+		ElMessage.warning('请先保存套餐')
+		return
+	}
+	specDialogRef.value?.openDialog(currentEditId.value)
+}
+
+// 编辑规格
+const editSpecItem = (row: any) => {
+	if (!currentEditId.value) return
+	specDialogRef.value?.editDialog(currentEditId.value, row)
+}
+
+// 删除规格
+const deleteSpecItem = (row: any) => {
+	ElMessageBox.confirm(`确定删除规格"${row.spec_name}"吗?`, '提示', {
+		confirmButtonText: '确定',
+		cancelButtonText: '取消',
+		type: 'warning',
+	}).then(async () => {
+		try {
+			await deleteSpec({ id: row.id })
+			ElMessage.success('删除成功')
+			loadSpecList()
+		} catch (error: any) {
+			ElMessage({
+				type: 'error',
+				message: error?.msg || '删除失败,请重试',
+			})
+		}
+	})
+}
+
+// 获取卡类型标签
+const getCardTypeLabel = (cardType: string) => {
+	const map: Record<string, string> = {
+		'1': '次卡',
+		'2': '按积分',
+		'3': '日卡',
+		'4': '周卡',
+		'5': '季卡',
+		'6': '月卡',
+		'7': '年卡',
+	}
+	return map[cardType] || '-'
 }
 
 // 提交表单
@@ -225,88 +251,45 @@ const onSubmit = async () => {
 			id: currentEditId.value,
 			type: dialogType.value,
 			name: formData.name,
-			specificationsList: formData.specificationsList
 		}
 
 		emits('success', submitData)
-		handleClose()
+		// 注意:不在这里关闭弹框,由父组件处理成功后回调
 	} catch (error) {
 		ElMessage({
 			type: 'error',
-			message: '账户添加失败,请重试!',
+			message: '保存失败,请重试!',
 		})
 	} finally {
 		loading.value = false
 	}
 }
 
+// 处理保存成功后的回调(由父组件调用)
+const handleSaveSuccess = (packageId: number) => {
+	// 切换到编辑模式
+	dialogType.value = 'edit'
+	currentEditId.value = packageId
+	// 加载规格列表
+	loadSpecList()
+	ElMessage.success('套餐保存成功,现在可以添加规格了')
+}
+
 // 关闭弹框
 const handleClose = () => {
 	dialogVisible.value = false
 	resetForm()
 }
 
-// 规格项类型
-interface SpecificationItem {
-	specName: string
-	price: number | undefined
-	stock: number | undefined
-	packageType: string
-	chargeType: string
-	quantity: number | undefined
-	remark: string
-}
-
-// 套餐类型选项(值改为数字编码,方便与后端枚举对应)
-const packageTypeOptions = [
-	{ label: '个人套餐', value: '1' },
-	{ label: '团队套餐', value: '2' },
-]
-
-// 计费方式选项(值使用数字编码,便于与后端对接)
-const chargeTypeOptions = [
-	{ label: '按次数', value: '1' },
-	{ label: '按积分', value: '2' },
-	{ label: '日卡', value: '3' },
-	{ label: '周卡', value: '4' },
-	{ label: '月卡', value: '5' },
-	{ label: '季卡', value: '6' },
-	{ label: '年卡', value: '7' },
-]
-
-// 新增一行规格
-const addSpecification = () => {
-	formData.specificationsList.push({
-		specName: '',
-		price: undefined,
-		stock: undefined,
-		packageType: packageTypeOptions[0].value,
-		chargeType: chargeTypeOptions[0].value,
-		quantity: undefined,
-		remark: '',
-	})
-}
-
-// 删除一行规格
-const removeSpecification = (index: number) => {
-	formData.specificationsList.splice(index, 1)
-}
-
-// 是否需要展示“次数 / 积分”等数量输入框
-const showQuantity = (chargeType: string) => {
-	// 1: 按次数,2: 按积分
-	return chargeType === '1' || chargeType === '2'
-}
-
-// 根据计费方式返回数量单位
-const getUnitLabel = (chargeType: string) => {
-	if (chargeType === '1') return '次'
-	if (chargeType === '2') return '分'
-	return ''
-}
+// 监听对话框打开,如果是编辑模式且有ID,加载规格列表
+watch([dialogVisible, currentEditId], ([visible, id]) => {
+	if (visible && dialogType.value === 'edit' && id) {
+		loadSpecList()
+	}
+})
 
 // 导出方法
-defineExpose({ openDialog, editDialog });
+defineExpose({ openDialog, editDialog, handleSaveSuccess })
 </script>
 
 <style scoped lang="scss">
@@ -322,7 +305,7 @@ defineExpose({ openDialog, editDialog });
 			display: flex;
 			justify-content: space-between;
 			align-items: center;
-			margin-bottom: 8px;
+			margin-bottom: 12px;
 
 			.spec-title {
 				font-weight: 600;
@@ -343,94 +326,17 @@ defineExpose({ openDialog, editDialog });
 			}
 		}
 
-		.spec-row {
-			position: relative;
-			display: flex;
-			flex-direction: column;
-			row-gap: 6px;
-			margin-bottom: 12px;
-			padding: 10px 12px 10px 36px;
-			border-radius: 6px;
-			background-color: #fff;
-			box-shadow: 0 0 0 1px #f0f0f0;
-
-			.spec-row-line {
-				display: flex;
-				align-items: center;
-				column-gap: 10px;
-				flex-wrap: wrap;
-			}
-
-			.spec-input {
-				margin-right: 0;
-			}
-
-			.spec-name {
-				width: 140px;
-			}
-
-			.spec-price {
-				width: 120px;
-			}
-
-			.spec-stock {
-				width: 100px;
-			}
-
-			.spec-select {
-				width: 140px;
-			}
-
-			.spec-quantity {
-				width: 120px;
-			}
-
-			.spec-remark {
-				flex: 1;
-				min-width: 260px;
-			}
-
-			.spec-group {
-				display: inline-flex;
-				align-items: center;
-				column-gap: 4px;
-			}
-
-			.spec-delete-btn {
-				display: inline-flex;
-				align-items: center;
-				gap: 2px;
-				padding: 0 6px;
-
-				.spec-delete-icon {
-					font-size: 14px;
-					transform: translateY(-1px);
-				}
-			}
-
-			.spec-unit,
-			.spec-label {
-				color: #666;
-				white-space: nowrap;
-			}
+		.spec-list {
+			min-height: 200px;
+		}
 
-			.spec-badge {
-				position: absolute;
-				left: 10px;
-				top: 50%;
-				transform: translateY(-50%);
-				width: 20px;
-				height: 20px;
-				border-radius: 10px;
-				background: linear-gradient(135deg, #409eff, #66b1ff);
-				color: #fff;
-				font-size: 12px;
-				display: flex;
-				align-items: center;
-				justify-content: center;
-				box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.8);
-			}
+		.spec-empty {
+			padding: 40px 0;
 		}
 	}
+
+	.spec-tip {
+		width: 100%;
+	}
 }
 </style>

+ 322 - 0
web/src/views/backend/package/package/specDialog.vue

@@ -0,0 +1,322 @@
+<template>
+	<!-- 规格对话框表单 -->
+	<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :destroy-on-close="true" v-model="dialogVisible"
+		@close="handleClose" width="700px">
+		<template #header>
+			<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
+				{{ dialogType === 'add' ? '添加规格' : '编辑规格' }}
+			</div>
+		</template>
+		<el-scrollbar v-loading="loading" class="ba-table-form-scrollbar">
+			<div class="ba-operate-form">
+				<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px" class="form-container">
+					<el-form-item label="规格名称" prop="spec_name">
+						<el-input v-model="formData.spec_name" placeholder="请输入规格名称" />
+					</el-form-item>
+					
+					<el-form-item label="价格" prop="price">
+						<el-input-number
+							v-model.number="formData.price"
+							:min="0"
+							placeholder="价格"
+							style="width: 100%"
+						/>
+						<span class="form-unit">元</span>
+					</el-form-item>
+					
+					<el-form-item label="库存" prop="stock">
+						<el-input-number
+							v-model.number="formData.stock"
+							:min="0"
+							placeholder="库存数量"
+							style="width: 100%"
+						/>
+					</el-form-item>
+					
+					<el-form-item label="套餐类型" prop="type">
+						<el-select v-model="formData.type" placeholder="请选择套餐类型" style="width: 100%">
+							<el-option
+								v-for="opt in packageTypeOptions"
+								:key="opt.value"
+								:label="opt.label"
+								:value="opt.value"
+							/>
+						</el-select>
+					</el-form-item>
+					
+					<el-form-item label="卡类型" prop="card_type">
+						<el-select
+							v-model="formData.card_type"
+							placeholder="请选择卡类型"
+							style="width: 100%"
+							@change="handleCardTypeChange"
+						>
+							<el-option
+								v-for="opt in cardTypeOptions"
+								:key="opt.value"
+								:label="opt.label"
+								:value="opt.value"
+							/>
+						</el-select>
+					</el-form-item>
+					
+					<el-form-item
+						label="次卡次数"
+						prop="unit_value"
+						v-if="formData.card_type === '1'"
+					>
+						<el-input-number
+							v-model.number="formData.unit_value"
+							:min="0"
+							placeholder="请输入次卡次数"
+							style="width: 100%"
+						/>
+					</el-form-item>
+					
+					<el-form-item
+						label="积分数"
+						prop="points_value"
+						v-if="formData.card_type === '2'"
+					>
+						<el-input-number
+							v-model.number="formData.points_value"
+							:min="0"
+							placeholder="请输入积分数"
+							style="width: 100%"
+						/>
+					</el-form-item>
+					
+					<el-form-item label="说明描述" prop="description">
+						<el-input
+							v-model="formData.description"
+							type="textarea"
+							:rows="4"
+							placeholder="请输入说明描述"
+						/>
+					</el-form-item>
+				</el-form>
+			</div>
+		</el-scrollbar>
+		<template #footer>
+			<div class="dialog-footer">
+				<el-button @click="handleClose" size="large" class="cancel-btn">{{ t('Cancel') }}</el-button>
+				<el-button @click="onSubmit()" type="primary" size="large" :loading="loading"
+					class="submit-btn">确定</el-button>
+			</div>
+		</template>
+	</el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive } from 'vue'
+import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
+import { useI18n } from 'vue-i18n'
+import { editSpec } from '/@/api/backend/package/package'
+
+const { t } = useI18n()
+
+// 显示隐藏
+const dialogVisible = ref(false)
+// 类型:add、edit
+const dialogType = ref<'add' | 'edit'>('add')
+// 当前套餐 ID
+const packageId = ref<number | null>(null)
+// 当前编辑的规格 ID(新增时为 null)
+const currentSpecId = ref<number | null>(null)
+
+const loading = ref(false)
+
+const formRef = ref<FormInstance>()
+
+const emits = defineEmits(['success'])
+
+const formData = reactive({
+	spec_name: '',
+	price: undefined as number | undefined,
+	stock: undefined as number | undefined,
+	type: '',
+	card_type: '',
+	unit_value: undefined as number | undefined,
+	points_value: undefined as number | undefined,
+	description: '',
+})
+
+// 表单验证规则
+const rules: FormRules = {
+	spec_name: [
+		{ required: true, message: '请输入规格名称', trigger: 'change' }
+	],
+	price: [
+		{ required: true, message: '请输入价格', trigger: 'change' }
+	],
+	stock: [
+		{ required: true, message: '请输入库存', trigger: 'change' }
+	],
+	type: [
+		{ required: true, message: '请选择套餐类型', trigger: 'change' }
+	],
+	card_type: [
+		{ required: true, message: '请选择卡类型', trigger: 'change' }
+	],
+	unit_value: [
+		{
+			validator: (_rule, value, callback) => {
+				if (formData.card_type === '1' && (value === undefined || value === null || value <= 0)) {
+					return callback(new Error('次卡次数为必填项,且必须大于0'))
+				}
+				return callback()
+			},
+			trigger: 'change',
+		},
+	],
+	points_value: [
+		{
+			validator: (_rule, value, callback) => {
+				if (formData.card_type === '2' && (value === undefined || value === null || value <= 0)) {
+					return callback(new Error('积分数为必填项,且必须大于0'))
+				}
+				return callback()
+			},
+			trigger: 'change',
+		},
+	],
+}
+
+// 套餐类型选项
+const packageTypeOptions = [
+	{ label: '个人套餐', value: '1' },
+	{ label: '企业套餐', value: '2' },
+]
+
+// 卡类型选项
+const cardTypeOptions = [
+	{ label: '次卡', value: '1' },
+	{ label: '按积分', value: '2' },
+	{ label: '日卡', value: '3' },
+	{ label: '周卡', value: '4' },
+	{ label: '季卡', value: '5' },
+	{ label: '月卡', value: '6' },
+	{ label: '年卡', value: '7' },
+]
+
+// 打开对话框(新增)
+const openDialog = (pkgId: number) => {
+	dialogVisible.value = true
+	dialogType.value = 'add'
+	packageId.value = pkgId
+	currentSpecId.value = null
+	resetForm()
+}
+
+// 编辑:接收规格数据,回填到表单
+const editDialog = (pkgId: number, specData: any) => {
+	dialogVisible.value = true
+	dialogType.value = 'edit'
+	packageId.value = pkgId
+	currentSpecId.value = specData.id ?? null
+
+	// 清理校验状态
+	formRef.value?.clearValidate()
+
+	// 回填数据
+	formData.spec_name = specData.spec_name || specData.specName || ''
+	formData.price = specData.price
+	formData.stock = specData.stock
+	formData.type = String(specData.type || specData.packageType || '')
+	formData.card_type = String(specData.card_type || specData.chargeType || '')
+	formData.unit_value = specData.unit_value || (specData.card_type === '1' ? specData.quantity : undefined)
+	formData.points_value = specData.points_value || (specData.card_type === '2' ? specData.quantity : undefined)
+	formData.description = specData.description || specData.remark || ''
+}
+
+// 重置表单
+const resetForm = () => {
+	formRef.value?.clearValidate()
+	formData.spec_name = ''
+	formData.price = undefined
+	formData.stock = undefined
+	formData.type = ''
+	formData.card_type = ''
+	formData.unit_value = undefined
+	formData.points_value = undefined
+	formData.description = ''
+}
+
+// 卡类型变化处理
+const handleCardTypeChange = () => {
+	// 切换卡类型时,清空对应的值
+	if (formData.card_type !== '1') {
+		formData.unit_value = undefined
+	}
+	if (formData.card_type !== '2') {
+		formData.points_value = undefined
+	}
+}
+
+// 提交表单
+const onSubmit = async () => {
+	if (!formRef.value) return
+
+	try {
+		await formRef.value.validate()
+		loading.value = true
+
+		// 构建提交数据(按照用户要求的字段)
+		const submitData: any = {
+			id: currentSpecId.value || '',
+			spec_name: formData.spec_name,
+			price: formData.price,
+			stock: formData.stock,
+			type: formData.type,
+			card_type: formData.card_type,
+			description: formData.description,
+		}
+
+		// 根据卡类型添加对应的值
+		// 当选择次卡时必填 unit_value,选择其他天数的卡可以不传
+		if (formData.card_type === '1' && formData.unit_value !== undefined) {
+			submitData.unit_value = formData.unit_value
+		}
+		// 当选择积分时必填 points_value,选择其他天数的卡可以不传
+		if (formData.card_type === '2' && formData.points_value !== undefined) {
+			submitData.points_value = formData.points_value
+		}
+
+		// 调用接口
+		await editSpec(submitData)
+		
+		ElMessage({
+			type: 'success',
+			message: dialogType.value === 'add' ? '添加成功' : '编辑成功',
+		})
+
+		emits('success')
+		handleClose()
+	} catch (error: any) {
+		ElMessage({
+			type: 'error',
+			message: error?.msg || (dialogType.value === 'add' ? '添加失败,请重试!' : '编辑失败,请重试!'),
+		})
+	} finally {
+		loading.value = false
+	}
+}
+
+// 关闭弹框
+const handleClose = () => {
+	dialogVisible.value = false
+	resetForm()
+}
+
+// 导出方法
+defineExpose({ openDialog, editDialog })
+</script>
+
+<style scoped lang="scss">
+.form-container {
+	.form-unit {
+		margin-left: 8px;
+		color: #666;
+	}
+}
+</style>

+ 1 - 1
web/src/views/backend/package/product/index.vue

@@ -146,7 +146,7 @@ const baTable = new baTableClass(
         defaultItems: {
             id: undefined,
             name: '',
-            package: '',
+            package_id: '',
             description: '',
             image: '',
             stock_status: '1',

+ 33 - 12
web/src/views/backend/package/product/popupForm.vue

@@ -20,7 +20,7 @@
             >
                 <el-form
                     ref="formRef"
-                    @keyup.enter="baTable.onSubmit(formRef)"
+                    @keyup.enter="btnSubmit"
                     :model="baTable.form.items"
                     :label-position="config.layout.shrink ? 'top' : 'right'"
                     :label-width="baTable.form.labelWidth + 'px'"
@@ -34,9 +34,9 @@
                         type="string"
                         :placeholder="t('Please input field', { field: '产品名称' })"
                     />
-                    <el-form-item label="关联套餐" prop="package">
+                    <el-form-item label="关联套餐" prop="package_id">
                         <el-select
-                            v-model="baTable.form.items!.package"
+                            v-model="baTable.form.items!.package_id"
                             placeholder="请选择关联套餐"
                             style="width: 100%"
                         >
@@ -89,7 +89,7 @@
         <template #footer>
             <div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
                 <el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
-                <el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
+                <el-button v-blur :loading="baTable.form.submitLoading" @click="btnSubmit" type="primary">
                     {{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
                 </el-button>
             </div>
@@ -105,21 +105,22 @@ import FormItem from '/@/components/formItem/index.vue'
 import type { FormItemRule } from 'element-plus'
 import { useConfig } from '/@/stores/config'
 import { buildValidatorData } from '/@/utils/validate'
+import { edit } from '/@/api/backend/package/product'
 
 const config = useConfig()
 const formRef = useTemplateRef('formRef')
 const baTable = inject('baTable') as baTableClass
+const emit = defineEmits(['success'])
 
 const { t } = useI18n()
 
 const rules: Partial<Record<string, FormItemRule[]>> = reactive({
     name: [buildValidatorData({ name: 'required', title: '产品名称' })],
-    package: [buildValidatorData({ name: 'required', trigger: 'change', title: '关联套餐' })],
+    package_id: [buildValidatorData({ name: 'required', trigger: 'change', title: '关联套餐' })],
     description: [buildValidatorData({ name: 'required', title: '产品描述' })],
     image: [buildValidatorData({ name: 'required', title: '产品图片' })],
     type: [buildValidatorData({ name: 'required', title: '产品类型' })],
     link: [buildValidatorData({ name: 'required', title: '产品链接' })],
-    inventory_status: [buildValidatorData({ name: 'required', title: '库存状态' })],
     status: [buildValidatorData({ name: 'required', title: '产品状态' })],
 })
 
@@ -134,17 +135,37 @@ const typeOptions = {
     '2': '软件',
 }
 
-const inventoryOptions = {
-    '1': '充足',
-    '2': '即将售罄',
-    '3': '售罄',
-}
-
 const statusOptions = {
     '1': '启用',
     '0': '禁用',
 }
 
+// 提交
+const btnSubmit = () => {
+	formRef.value?.validate((valid: boolean) => {
+		if (valid) {
+            baTable.table.loading = true
+
+            let params = {
+                id: baTable.form.items!.id,
+                name: baTable.form.items!.name,
+                email:baTable.form.items!.email,
+                company:baTable.form.items!.company,
+                password:baTable.form.items!.password,
+                status: baTable.form.items!.status,
+            }
+
+            edit(params).then(() => {
+                baTable.toggleForm()
+                emit('success')
+            }).finally(()=>{
+                baTable.table.loading = false
+            })
+			
+		}
+	})
+}
+
 </script>
 
 <style scoped lang="scss">