kindergarten_java/reading-platform-frontend/src/views/admin/packages/PackageEditView.vue
zhonghua 8f5a0fcdda fix: 套餐管理优化与 gradeLevels/重复包修复
- gradeLevels: 后端 JSON 解析修复,前端兼容错误格式
- 筛选下拉: 状态选项按业务流程排序
- 已下架: 列表与详情页增加编辑、删除操作
- 课程包关联: 前后端去重,修复 uk_collection_package 唯一约束冲突

Made-with: Cursor
2026-03-19 11:37:04 +08:00

330 lines
11 KiB
Vue

<template>
<div class="package-edit-page">
<a-card :bordered="false">
<template #title>
<span>{{ isEdit ? '编辑套餐' : '创建套餐' }}</span>
</template>
<template #extra>
<a-button @click="router.back()">返回</a-button>
</template>
<a-form
ref="formRef"
:model="form"
:rules="formRules"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="套餐名称" name="name">
<a-input v-model:value="form.name" placeholder="请输入套餐名称" />
</a-form-item>
<a-form-item label="套餐描述" name="description">
<a-textarea v-model:value="form.description" placeholder="请输入套餐描述" :rows="3" />
</a-form-item>
<a-form-item label="价格(元)" name="price">
<a-input-number v-model:value="form.price" :min="0" :precision="2" style="width: 200px" />
</a-form-item>
<a-form-item label="优惠价(元)" name="discountPrice">
<a-input-number v-model:value="form.discountPrice" :min="0" :precision="2" style="width: 200px" />
</a-form-item>
<a-form-item label="优惠类型" name="discountType">
<a-select v-model:value="form.discountType" placeholder="请选择优惠类型" allowClear style="width: 200px">
<a-select-option value="PERCENT">折扣</a-select-option>
<a-select-option value="FIXED">立减</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="适用年级" name="gradeLevels">
<a-checkbox-group v-model:value="form.gradeLevels" disabled>
<a-checkbox value="小班">小班</a-checkbox>
<a-checkbox value="中班">中班</a-checkbox>
<a-checkbox value="大班">大班</a-checkbox>
</a-checkbox-group>
<div v-if="form.gradeLevels.length === 0" class="grade-hint">根据已选课程包自动填充</div>
</a-form-item>
<a-divider>课程包配置</a-divider>
<a-form-item label="已选课程包">
<div class="package-list">
<a-table
:columns="packageColumns"
:data-source="selectedPackages"
row-key="packageId"
size="small"
:pagination="false"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'sortOrder'">
<a-input-number v-model:value="record.sortOrder" :min="0" size="small" />
</template>
<template v-else-if="column.key === 'gradeLevel'">
<a-tag v-for="g in (record.gradeLevels || [])" :key="g">{{ g }}</a-tag>
<span v-if="!record.gradeLevels?.length">-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" danger @click="removePackage(index)">移除</a-button>
</template>
</template>
</a-table>
<a-button type="dashed" block style="margin-top: 16px" @click="showPackageSelector = true">
<template #icon><PlusOutlined /></template>
添加课程包
</a-button>
</div>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 4, span: 16 }">
<a-space>
<a-button type="primary" @click="handleSave" :loading="saving">保存</a-button>
<a-button @click="router.back()">取消</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<!-- 课程包选择器 -->
<a-modal
v-model:open="showPackageSelector"
title="选择课程包"
width="800px"
@ok="handleAddPackages"
>
<a-table
:columns="selectorColumns"
:data-source="availablePackages"
:row-selection="rowSelection"
row-key="id"
size="small"
:loading="loadingPackages"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'gradeTags'">
<a-tag v-for="tag in parseGradeTags(record.gradeTags)" :key="tag">{{ tag }}</a-tag>
</template>
</template>
</a-table>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { getCollectionDetail, createCollection, updateCollection, setCollectionPackages } from '@/api/package';
import { getCoursePackageList } from '@/api/package';
const router = useRouter();
const route = useRoute();
const formRef = ref<FormInstance>();
const isEdit = computed(() => !!route.params.id);
const packageId = computed(() => route.params.id as string | undefined);
const saving = ref(false);
const loadingPackages = ref(false);
const showPackageSelector = ref(false);
const availablePackages = ref<any[]>([]);
const selectedRowKeys = ref<(number | string)[]>([]);
const form = reactive({
name: '',
description: '',
price: 0,
discountPrice: undefined as number | undefined,
discountType: undefined as string | undefined,
gradeLevels: [] as string[],
});
const selectedPackages = ref<{ packageId: number | string; gradeLevels: string[]; sortOrder: number; packageName: string }[]>([]);
// 表单校验规则(适用年级由已选课程包自动填充)
const formRules = {
name: [{ required: true, message: '请输入套餐名称' }],
price: [{ required: true, message: '请输入价格' }],
gradeLevels: [{
validator: () => {
if (selectedPackages.value.length === 0) return Promise.reject('请先添加至少一个课程包');
return Promise.resolve();
},
}],
};
const packageColumns = [
{ title: '课程包', dataIndex: 'packageName', key: 'packageName' },
{ title: '年级', dataIndex: 'gradeLevels', key: 'gradeLevel', width: 120 },
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80 },
{ title: '操作', key: 'action', width: 80 },
];
const selectorColumns = [
{ title: '课程包名称', dataIndex: 'name', key: 'name' },
{ title: '年级标签', dataIndex: 'gradeTags', key: 'gradeTags' },
{ title: '时长', dataIndex: 'duration', key: 'duration', width: 80 },
];
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: any[]) => {
selectedRowKeys.value = keys;
},
}));
const parseGradeTags = (tags: string | string[]) => {
if (Array.isArray(tags)) return tags;
try {
return JSON.parse(tags || '[]');
} catch {
return [];
}
};
// 从已选课程包同步适用年级到表单(年级列只读,从课程包数据读取)
const syncGradeLevelsFromPackages = () => {
const grades = [...new Set(selectedPackages.value.flatMap((p) => p.gradeLevels || []).filter(Boolean))];
form.gradeLevels = grades;
};
// 监听已选课程包变化(添加、移除、修改年级),自动同步适用年级
watch(() => selectedPackages.value, syncGradeLevelsFromPackages, { deep: true });
const fetchPackageDetail = async () => {
if (!isEdit.value) return;
try {
const pkg = await getCollectionDetail(packageId.value!) as any;
form.name = pkg.name;
form.description = pkg.description || '';
form.price = pkg.price / 100;
form.discountPrice = pkg.discountPrice ? pkg.discountPrice / 100 : undefined;
form.discountType = pkg.discountType;
// 加载已选课程包(年级从课程包读取,不可编辑;适用年级自动同步)
selectedPackages.value = (pkg.packages || []).map((p: any) => ({
packageId: p.id,
packageName: p.name,
gradeLevels: Array.isArray(p.gradeLevels) ? p.gradeLevels : (p.gradeLevels ? [p.gradeLevels] : []),
sortOrder: p.sortOrder ?? 0,
}));
} catch (error) {
message.error('获取套餐详情失败');
}
};
const fetchAvailablePackages = async () => {
loadingPackages.value = true;
try {
// 获取已发布的课程包列表
const res = await getCoursePackageList({ pageNum: 1, pageSize: 100, status: 'PUBLISHED' });
availablePackages.value = res.list || [];
} catch (error) {
console.error('获取课程包列表失败', error);
} finally {
loadingPackages.value = false;
}
};
const handleAddPackages = () => {
const existingIds = new Set(selectedPackages.value.map((p) => p.packageId));
const newPackages = availablePackages.value
.filter((p) => selectedRowKeys.value.includes(p.id) && !existingIds.has(p.id))
.map((p) => {
const tags = parseGradeTags(p.gradeTags);
return {
packageId: p.id,
packageName: p.name,
gradeLevels: Array.isArray(tags) && tags.length > 0 ? tags : ['小班'],
sortOrder: selectedPackages.value.length,
};
});
selectedPackages.value.push(...newPackages);
selectedRowKeys.value = [];
showPackageSelector.value = false;
};
const removePackage = (index: number) => {
selectedPackages.value.splice(index, 1);
};
const handleSave = async () => {
if (!formRef.value) return;
try {
// 手动触发表单校验
await formRef.value.validate();
saving.value = true;
const data = {
name: form.name,
description: form.description,
price: Math.round(form.price * 100),
discountPrice: form.discountPrice ? Math.round(form.discountPrice * 100) : undefined,
discountType: form.discountType,
gradeLevels: form.gradeLevels,
};
console.log('创建套餐请求数据:', data);
let id: number | string;
if (isEdit.value) {
id = packageId.value!;
await updateCollection(id, data);
} else {
const res = await createCollection(data) as any;
console.log('创建套餐响应:', res);
id = res.id; // 后端 Long 序列化为 string
}
// 保存课程包关联(按 sortOrder 排序,去重避免唯一约束冲突)
if (selectedPackages.value.length > 0) {
const sorted = [...selectedPackages.value].sort((a, b) => a.sortOrder - b.sortOrder);
const packageIds = [...new Map(sorted.map((p) => [p.packageId, p])).values()]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((p) => p.packageId);
await setCollectionPackages(id, packageIds);
}
message.success('保存成功');
router.push('/admin/packages');
} catch (error) {
console.error('保存失败:', error);
// 表单校验错误不显示全局提示
if (error?.errorFields) {
return;
}
message.error(error.response?.data?.message || '保存失败');
} finally {
saving.value = false;
}
};
onMounted(() => {
fetchPackageDetail();
fetchAvailablePackages();
});
</script>
<style scoped>
.package-edit-page {
padding: 24px;
}
.package-list {
width: 100%;
}
.grade-hint {
color: #999;
font-size: 12px;
margin-top: 4px;
}
</style>