feat: 租户管理与课程套餐优化
- 优化租户管理相关接口与实体类 - 更新课程套餐服务层实现 - 新增数据库迁移脚本 V42 - 同步更新前端 API 类型与页面组件
This commit is contained in:
parent
ed9371b21f
commit
c3b1056c29
@ -8,14 +8,35 @@
|
||||
|
||||
### 服务端口配置
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| 后端 API | **8480** | Spring Boot 服务(已修改) |
|
||||
| 前端 Dev Server | 5173 | Vite 开发服务器 |
|
||||
| 数据库 MySQL | 3306 | 开发环境数据库 |
|
||||
| Redis | 6379 | 缓存服务 |
|
||||
| 服务 | 默认端口 | 测试验证端口 | 说明 |
|
||||
|------|---------|------------|------|
|
||||
| 后端 API | **8480** | **8481** | Spring Boot 服务 |
|
||||
| 前端 Dev Server | 5173 | 5174 | Vite 开发服务器 |
|
||||
| 数据库 MySQL | 3306 | 3306 | 开发环境数据库 |
|
||||
| Redis | 6379 | 6379 | 缓存服务 |
|
||||
|
||||
**重要**: 后端服务已从默认的 8080 端口改为 **8480 端口**。
|
||||
**重要**:
|
||||
- 日常开发使用默认端口 **8480** 和 **5173**
|
||||
- **每次测试验证时启动通过变量启动后端 8481 和前端 5174 (重要),不要占用 8480 和 5173 端口**
|
||||
|
||||
### 测试验证环境启动方式
|
||||
|
||||
```bash
|
||||
# 启动测试验证环境(后端 8481 + 前端 5174)
|
||||
# 方式一:使用环境变量
|
||||
export SERVER_PORT=8481
|
||||
export VITE_APP_PORT=5174
|
||||
./start-all.sh
|
||||
|
||||
# 方式二:分别启动
|
||||
# 后端(端口 8481)
|
||||
export SERVER_PORT=8481
|
||||
cd reading-platform-java && mvn spring-boot:run
|
||||
|
||||
# 前端(端口 5174,新终端)
|
||||
export PORT=5174
|
||||
cd reading-platform-frontend && npm run dev
|
||||
```
|
||||
|
||||
### 启动服务
|
||||
|
||||
|
||||
@ -22283,9 +22283,29 @@
|
||||
"tags": [
|
||||
"学校端 - 课程管理"
|
||||
],
|
||||
"summary": "获取学校课程包列表",
|
||||
"summary": "获取学校课程包列表(分页)",
|
||||
"operationId": "getSchoolCourses",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "pageNum",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pageSize",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "keyword",
|
||||
"in": "query",
|
||||
@ -22301,6 +22321,22 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "domain",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lessonType",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -22309,7 +22345,7 @@
|
||||
"content": {
|
||||
"*/*": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ResultListCoursePackage"
|
||||
"$ref": "#/components/schemas/ResultPageResultCourseResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ export interface TenantQueryParams {
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
packageType?: string;
|
||||
collectionId?: number;
|
||||
}
|
||||
|
||||
export interface Tenant {
|
||||
@ -22,7 +22,7 @@ export interface Tenant {
|
||||
contactPerson?: string;
|
||||
contactPhone?: string;
|
||||
logoUrl?: string;
|
||||
packageType: string;
|
||||
packageNames?: string[];
|
||||
teacherQuota: number;
|
||||
studentQuota: number;
|
||||
storageQuota?: number;
|
||||
@ -73,8 +73,7 @@ export interface CreateTenantDto {
|
||||
address?: string;
|
||||
contactPerson?: string;
|
||||
contactPhone?: string;
|
||||
packageType?: string;
|
||||
packageId?: number;
|
||||
collectionIds?: number[];
|
||||
teacherQuota?: number;
|
||||
studentQuota?: number;
|
||||
startDate?: string;
|
||||
@ -86,7 +85,7 @@ export interface UpdateTenantDto {
|
||||
address?: string;
|
||||
contactPerson?: string;
|
||||
contactPhone?: string;
|
||||
packageType?: string;
|
||||
collectionIds?: number[];
|
||||
teacherQuota?: number;
|
||||
studentQuota?: number;
|
||||
startDate?: string;
|
||||
|
||||
@ -18,6 +18,7 @@ export interface CourseQueryParams {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
grade?: string;
|
||||
gradeTags?: string; // 年级筛选(逗号分隔,如 "小班,中班,大班")
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
/** 审核管理页专用:仅返回待审核和已驳回,排除已通过 */
|
||||
@ -125,6 +126,7 @@ const toFindAllParams = (params: CourseQueryParams): any => ({
|
||||
pageSize: params.pageSize ?? 10,
|
||||
keyword: params.keyword,
|
||||
category: params.grade,
|
||||
gradeTags: params.gradeTags, // 年级筛选(逗号分隔)
|
||||
status: params.status || undefined,
|
||||
reviewOnly: params.reviewOnly,
|
||||
});
|
||||
|
||||
@ -3391,7 +3391,7 @@ const exportGrowthRecords = (
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 获取学校课程包列表
|
||||
* @summary 获取学校课程包列表(分页)
|
||||
*/
|
||||
const getSchoolCourses = (
|
||||
params?: GetSchoolCoursesParams,
|
||||
|
||||
@ -7,6 +7,10 @@
|
||||
*/
|
||||
|
||||
export type GetSchoolCoursesParams = {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
grade?: string;
|
||||
domain?: string;
|
||||
lessonType?: string;
|
||||
};
|
||||
|
||||
@ -89,6 +89,7 @@ export interface CourseLesson {
|
||||
|
||||
export interface PackageListParams {
|
||||
status?: string;
|
||||
gradeLevels?: string; // 逗号分隔的年级列表,如 "小班,中班,大班"
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@ -111,9 +111,16 @@ const api = getReadingPlatformAPI();
|
||||
|
||||
// ==================== 学校端 API ====================
|
||||
|
||||
// 获取校本课程包列表
|
||||
export function getSchoolCourseList() {
|
||||
return api.getSchoolCourses() as any;
|
||||
// 获取校本课程包列表(分页)
|
||||
export function getSchoolCourseList(params?: {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
grade?: string;
|
||||
domain?: string;
|
||||
lessonType?: string;
|
||||
}) {
|
||||
return api.getSchoolCourses(params) as any;
|
||||
}
|
||||
|
||||
// 获取校本课程包详情
|
||||
|
||||
@ -412,6 +412,17 @@ export interface SchoolCourseQueryParams {
|
||||
export const getSchoolCourses = (params?: SchoolCourseQueryParams) =>
|
||||
http.get<Course[]>('/v1/school/courses', { params });
|
||||
|
||||
// 分页版本(用于课程管理页面)
|
||||
export const getSchoolCourseList = (params?: {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
grade?: string;
|
||||
domain?: string;
|
||||
lessonType?: string;
|
||||
}) =>
|
||||
http.get<{ list: Course[]; total: number; pageNum: number; pageSize: number; pages: number }>('/v1/school/courses', { params });
|
||||
|
||||
export const getSchoolCourse = (id: number) =>
|
||||
http.get<Course>(`/v1/school/courses/${id}`);
|
||||
|
||||
|
||||
@ -57,8 +57,10 @@
|
||||
<a-tag :color="getStatusColor(collection.status)">{{ getStatusText(collection.status) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="价格">{{ formatPrice(collection.price) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="折扣价格">{{ formatPrice(collection.discountPrice) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="折扣类型">{{ collection.discountType || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="优惠价">
|
||||
{{ formatPrice(collection.discountPrice) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="折扣类型">{{ getDiscountTypeText(collection.discountType) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="适用年级">
|
||||
<a-tag v-for="grade in collection.gradeLevels" :key="grade">{{ grade }}</a-tag>
|
||||
<span v-if="!collection.gradeLevels || collection.gradeLevels.length === 0">-</span>
|
||||
@ -206,6 +208,15 @@ const getStatusText = (status: string) => {
|
||||
return collectionsApi.getCollectionStatusInfo(status).label;
|
||||
};
|
||||
|
||||
const getDiscountTypeText = (type?: string) => {
|
||||
if (!type) return '-';
|
||||
const typeMap: Record<string, string> = {
|
||||
PERCENTAGE: '折扣',
|
||||
FIXED: '立减',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// 删除套餐
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
|
||||
@ -16,18 +16,29 @@
|
||||
<a-textarea v-model:value="formState.description" :rows="4" placeholder="请输入套餐描述" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="价格(分)" name="price">
|
||||
<a-input-number v-model:value="formState.price" :min="0" style="width: 100%" />
|
||||
<a-form-item label="价格(元)" name="price">
|
||||
<a-input-number v-model:value="formState.price" :min="0" :precision="2" style="width: 100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="折扣价格(分)" name="discountPrice">
|
||||
<a-input-number v-model:value="formState.discountPrice" :min="0" style="width: 100%" />
|
||||
<a-form-item label="优惠价(元)" name="discountPrice">
|
||||
<a-space>
|
||||
<a-input-number
|
||||
v-model:value="formState.discountPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
:placeholder="getDiscountPricePlaceholder()"
|
||||
/>
|
||||
<span v-if="formState.discountType" class="discount-hint" :style="{ color: getDiscountPriceHintColor() }">
|
||||
{{ getDiscountPriceHint() }}
|
||||
</span>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="折扣类型" name="discountType">
|
||||
<a-select v-model:value="formState.discountType" placeholder="请选择折扣类型" allowClear>
|
||||
<a-select-option value="PERCENTAGE">百分比</a-select-option>
|
||||
<a-select-option value="FIXED">固定金额</a-select-option>
|
||||
<a-form-item label="优惠类型" name="discountType">
|
||||
<a-select v-model:value="formState.discountType" placeholder="请选择优惠类型" allowClear>
|
||||
<a-select-option value="PERCENTAGE">折扣</a-select-option>
|
||||
<a-select-option value="FIXED">立减</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
@ -176,6 +187,54 @@ const parseGradeTags = (tags: string | string[]) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取折扣价格输入框的 placeholder
|
||||
const getDiscountPricePlaceholder = () => {
|
||||
if (!formState.value.discountType) return '请先选择优惠类型';
|
||||
if (formState.value.discountType === 'PERCENTAGE') return '请输入折扣率(0-100)';
|
||||
if (formState.value.discountType === 'FIXED') return '请输入立减金额';
|
||||
return '';
|
||||
};
|
||||
|
||||
// 获取折扣价格提示文本
|
||||
const getDiscountPriceHint = () => {
|
||||
if (!formState.value.discountType || !formState.value.price) return '';
|
||||
if (formState.value.discountType === 'PERCENTAGE') {
|
||||
if (formState.value.discountPrice !== undefined && formState.value.discountPrice !== null) {
|
||||
const finalPrice = formState.value.price * (1 - (formState.value.discountPrice / 100));
|
||||
if (finalPrice < 0) {
|
||||
return '折后价不能小于 0';
|
||||
}
|
||||
return `折后价:¥${finalPrice.toFixed(2)}`;
|
||||
}
|
||||
return '按百分比减免';
|
||||
}
|
||||
if (formState.value.discountType === 'FIXED') {
|
||||
if (formState.value.discountPrice !== undefined && formState.value.discountPrice !== null) {
|
||||
const finalPrice = formState.value.price - formState.value.discountPrice;
|
||||
if (finalPrice < 0) {
|
||||
return '立减金额超过原价';
|
||||
}
|
||||
return `折后价:¥${finalPrice.toFixed(2)}`;
|
||||
}
|
||||
return '按固定金额减免';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// 获取折扣价格提示文本的颜色
|
||||
const getDiscountPriceHintColor = () => {
|
||||
if (!formState.value.discountType || !formState.value.price || !formState.value.discountPrice) return '#666';
|
||||
if (formState.value.discountType === 'PERCENTAGE') {
|
||||
const finalPrice = formState.value.price * (1 - (formState.value.discountPrice / 100));
|
||||
return finalPrice < 0 ? 'red' : '#666';
|
||||
}
|
||||
if (formState.value.discountType === 'FIXED') {
|
||||
const finalPrice = formState.value.price - formState.value.discountPrice;
|
||||
return finalPrice < 0 ? 'red' : '#666';
|
||||
}
|
||||
return '#666';
|
||||
};
|
||||
|
||||
const fetchCollectionDetail = async () => {
|
||||
if (!isEdit.value) return;
|
||||
|
||||
@ -246,6 +305,23 @@ const handleSubmit = async () => {
|
||||
// 手动触发表单校验
|
||||
await formRef.value.validate();
|
||||
|
||||
// 校验折扣价格逻辑:折扣减免后价格不能小于 0
|
||||
if (formState.value.discountPrice !== undefined && formState.value.discountPrice !== null) {
|
||||
if (formState.value.discountType === 'PERCENTAGE') {
|
||||
// 折扣类型:折扣率应该是 0-100 之间的数值
|
||||
if (formState.value.discountPrice < 0 || formState.value.discountPrice > 100) {
|
||||
message.error('折扣率应该在 0-100 之间');
|
||||
return;
|
||||
}
|
||||
} else if (formState.value.discountType === 'FIXED') {
|
||||
// 立减类型:立减金额不能超过原价
|
||||
if (formState.value.discountPrice > formState.value.price) {
|
||||
message.error('立减金额不能超过原价');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const data = {
|
||||
name: formState.value.name,
|
||||
@ -310,4 +386,10 @@ onMounted(() => {
|
||||
.package-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.discount-hint {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -32,6 +32,9 @@
|
||||
<template v-else-if="column.key === 'discountPrice'">
|
||||
{{ formatPrice(record.discountPrice) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'discountType'">
|
||||
{{ getDiscountTypeText(record.discountType) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'gradeLevels'">
|
||||
<a-tag v-for="grade in parseGradeLevels(record.gradeLevels)" :key="grade">{{ grade }}</a-tag>
|
||||
</template>
|
||||
@ -135,6 +138,7 @@ const columns = [
|
||||
{ title: '套餐名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '价格', dataIndex: 'price', key: 'price', width: 100 },
|
||||
{ title: '优惠价', dataIndex: 'discountPrice', key: 'discountPrice', width: 100 },
|
||||
{ title: '折扣类型', dataIndex: 'discountType', key: 'discountType', width: 100 },
|
||||
{ title: '适用年级', dataIndex: 'gradeLevels', key: 'gradeLevels', width: 150 },
|
||||
{ title: '课程包数量', dataIndex: 'packageCount', key: 'packageCount', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||
@ -145,6 +149,15 @@ const getStatusColor = (status: string) => collectionsApi.getCollectionStatusInf
|
||||
const getStatusText = (status: string) => collectionsApi.getCollectionStatusInfo(status).label;
|
||||
const formatPrice = (price: number | null | undefined) => collectionsApi.formatPrice(price);
|
||||
|
||||
const getDiscountTypeText = (type?: string) => {
|
||||
if (!type) return '-';
|
||||
const typeMap: Record<string, string> = {
|
||||
PERCENTAGE: '折扣',
|
||||
FIXED: '立减',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
const parseGradeLevels = (gradeLevels: string | string[]) => {
|
||||
return collectionsApi.parseGradeLevels(gradeLevels);
|
||||
};
|
||||
|
||||
@ -4,11 +4,11 @@
|
||||
<a-row :gutter="16" align="middle">
|
||||
<a-col :span="16">
|
||||
<a-space>
|
||||
<a-select v-model:value="filters.grade" placeholder="年级" style="width: 120px" allow-clear
|
||||
@change="fetchCourses">
|
||||
<a-select-option value="small">小班</a-select-option>
|
||||
<a-select-option value="middle">中班</a-select-option>
|
||||
<a-select-option value="big">大班</a-select-option>
|
||||
<a-select v-model:value="filters.gradeTags" placeholder="年级" style="width: 150px" allow-clear
|
||||
mode="multiple" @change="fetchCourses">
|
||||
<a-select-option value="小班">小班</a-select-option>
|
||||
<a-select-option value="中班">中班</a-select-option>
|
||||
<a-select-option value="大班">大班</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-select v-model:value="filters.status" placeholder="状态" style="width: 120px" allow-clear
|
||||
@ -204,8 +204,8 @@ const courses = ref<any[]>([]);
|
||||
const pendingCount = ref(0);
|
||||
|
||||
const filters = reactive({
|
||||
grade: undefined,
|
||||
status: undefined,
|
||||
gradeTags: undefined as string[] | undefined,
|
||||
status: undefined as string | undefined,
|
||||
keyword: '',
|
||||
});
|
||||
|
||||
@ -244,7 +244,9 @@ const fetchCourses = async () => {
|
||||
const data = await courseApi.getCourses({
|
||||
pageNum: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
...filters,
|
||||
gradeTags: filters.gradeTags?.join(','), // 转为逗号分隔字符串
|
||||
status: filters.status,
|
||||
keyword: filters.keyword,
|
||||
});
|
||||
console.log('📡 课程列表 API 响应:', data);
|
||||
|
||||
|
||||
@ -49,6 +49,9 @@
|
||||
<a-descriptions-item label="优惠价">
|
||||
{{ pkg?.discountPrice ? '¥' + (pkg.discountPrice / 100).toFixed(2) : '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="折扣类型">
|
||||
{{ getDiscountTypeText(pkg?.discountType) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="适用年级">
|
||||
<a-tag v-for="grade in pkg?.gradeLevels" :key="grade">{{ grade }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
@ -125,6 +128,16 @@ const statusTexts: Record<string, string> = {
|
||||
const getStatusColor = (status: string) => statusColors[status] || 'default';
|
||||
const getStatusText = (status: string) => statusTexts[status] || status;
|
||||
|
||||
const discountTypeTexts: Record<string, string> = {
|
||||
PERCENTAGE: '折扣',
|
||||
FIXED: '立减',
|
||||
};
|
||||
|
||||
const getDiscountTypeText = (type?: string) => {
|
||||
if (!type) return '-';
|
||||
return discountTypeTexts[type] || type;
|
||||
};
|
||||
|
||||
const formatDate = (date?: string) => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleString();
|
||||
|
||||
@ -28,12 +28,23 @@
|
||||
</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-space>
|
||||
<a-input-number
|
||||
v-model:value="form.discountPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 200px"
|
||||
:placeholder="getDiscountPricePlaceholder()"
|
||||
/>
|
||||
<span v-if="form.discountType" class="discount-hint" :style="{ color: getDiscountPriceHintColor() }">
|
||||
{{ getDiscountPriceHint() }}
|
||||
</span>
|
||||
</a-space>
|
||||
</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="PERCENTAGE">折扣</a-select-option>
|
||||
<a-select-option value="FIXED">立减</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
@ -186,6 +197,54 @@ const parseGradeTags = (tags: string | string[]) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取折扣价格输入框的 placeholder
|
||||
const getDiscountPricePlaceholder = () => {
|
||||
if (!form.discountType) return '请先选择优惠类型';
|
||||
if (form.discountType === 'PERCENTAGE') return '请输入折扣率(0-100)';
|
||||
if (form.discountType === 'FIXED') return '请输入立减金额';
|
||||
return '';
|
||||
};
|
||||
|
||||
// 获取折扣价格提示文本
|
||||
const getDiscountPriceHint = () => {
|
||||
if (!form.discountType || !form.price) return '';
|
||||
if (form.discountType === 'PERCENTAGE') {
|
||||
if (form.discountPrice !== undefined && form.discountPrice !== null) {
|
||||
const finalPrice = form.price * (1 - (form.discountPrice / 100));
|
||||
if (finalPrice < 0) {
|
||||
return '折后价不能小于 0';
|
||||
}
|
||||
return `折后价:¥${finalPrice.toFixed(2)}`;
|
||||
}
|
||||
return '按百分比减免';
|
||||
}
|
||||
if (form.discountType === 'FIXED') {
|
||||
if (form.discountPrice !== undefined && form.discountPrice !== null) {
|
||||
const finalPrice = form.price - form.discountPrice;
|
||||
if (finalPrice < 0) {
|
||||
return '立减金额超过原价';
|
||||
}
|
||||
return `折后价:¥${finalPrice.toFixed(2)}`;
|
||||
}
|
||||
return '按固定金额减免';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// 获取折扣价格提示文本的颜色
|
||||
const getDiscountPriceHintColor = () => {
|
||||
if (!form.discountType || !form.price || !form.discountPrice) return '#666';
|
||||
if (form.discountType === 'PERCENTAGE') {
|
||||
const finalPrice = form.price * (1 - (form.discountPrice / 100));
|
||||
return finalPrice < 0 ? 'red' : '#666';
|
||||
}
|
||||
if (form.discountType === 'FIXED') {
|
||||
const finalPrice = form.price - form.discountPrice;
|
||||
return finalPrice < 0 ? 'red' : '#666';
|
||||
}
|
||||
return '#666';
|
||||
};
|
||||
|
||||
// 从已选课程包同步适用年级到表单(年级列只读,从课程包数据读取)
|
||||
const syncGradeLevelsFromPackages = () => {
|
||||
const grades = [...new Set(selectedPackages.value.flatMap((p) => p.gradeLevels || []).filter(Boolean))];
|
||||
@ -261,6 +320,23 @@ const handleSave = async () => {
|
||||
// 手动触发表单校验
|
||||
await formRef.value.validate();
|
||||
|
||||
// 校验折扣价格逻辑:折扣减免后价格不能小于 0
|
||||
if (form.discountPrice !== undefined && form.discountPrice !== null) {
|
||||
if (form.discountType === 'PERCENTAGE') {
|
||||
// 折扣类型:折扣率应该是 0-100 之间的数值
|
||||
if (form.discountPrice < 0 || form.discountPrice > 100) {
|
||||
message.error('折扣率应该在 0-100 之间');
|
||||
return;
|
||||
}
|
||||
} else if (form.discountType === 'FIXED') {
|
||||
// 立减类型:立减金额不能超过原价
|
||||
if (form.discountPrice > form.price) {
|
||||
message.error('立减金额不能超过原价');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
const data = {
|
||||
name: form.name,
|
||||
@ -326,4 +402,10 @@ onMounted(() => {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.discount-hint {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
|
||||
<!-- 筛选 -->
|
||||
<div class="filter-section">
|
||||
<a-space>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
placeholder="状态筛选"
|
||||
@ -33,6 +34,19 @@
|
||||
<a-select-option value="PUBLISHED">已发布</a-select-option>
|
||||
<a-select-option value="OFFLINE">已下架</a-select-option>
|
||||
</a-select>
|
||||
<a-select
|
||||
v-model:value="filters.gradeLevels"
|
||||
placeholder="适用年级"
|
||||
style="width: 150px"
|
||||
allowClear
|
||||
mode="multiple"
|
||||
@change="fetchData"
|
||||
>
|
||||
<a-select-option value="小班">小班</a-select-option>
|
||||
<a-select-option value="中班">中班</a-select-option>
|
||||
<a-select-option value="大班">大班</a-select-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
@ -48,6 +62,12 @@
|
||||
<template v-if="column.key === 'price'">
|
||||
¥{{ (record.price / 100).toFixed(2) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'discountPrice'">
|
||||
{{ record.discountPrice ? '¥' + (record.discountPrice / 100).toFixed(2) : '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'discountType'">
|
||||
{{ getDiscountTypeText(record.discountType) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'gradeLevels'">
|
||||
<a-tag v-for="grade in parseGradeLevels(record.gradeLevels)" :key="grade">{{ grade }}</a-tag>
|
||||
</template>
|
||||
@ -131,6 +151,7 @@ const dataSource = ref<CourseCollection[]>([]);
|
||||
const pendingCount = ref(0);
|
||||
const filters = reactive({
|
||||
status: undefined as string | undefined,
|
||||
gradeLevels: undefined as string[] | undefined,
|
||||
});
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
@ -142,6 +163,8 @@ const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '套餐名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '价格', dataIndex: 'price', key: 'price', width: 100 },
|
||||
{ title: '优惠价', dataIndex: 'discountPrice', key: 'discountPrice', width: 100 },
|
||||
{ title: '折扣类型', dataIndex: 'discountType', key: 'discountType', width: 100 },
|
||||
{ title: '适用年级', dataIndex: 'gradeLevels', key: 'gradeLevels', width: 150 },
|
||||
{ title: '课程包数', dataIndex: 'packageCount', key: 'packageCount', width: 80 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||
@ -169,6 +192,15 @@ const statusTexts: Record<string, string> = {
|
||||
const getStatusColor = (status: string) => statusColors[status] || 'default';
|
||||
const getStatusText = (status: string) => statusTexts[status] || status;
|
||||
|
||||
const getDiscountTypeText = (type?: string) => {
|
||||
if (!type) return '-';
|
||||
const typeMap: Record<string, string> = {
|
||||
PERCENTAGE: '折扣',
|
||||
FIXED: '立减',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
const parseGradeLevels = (gradeLevels: string | string[] | undefined): string[] => {
|
||||
if (!gradeLevels) return [];
|
||||
if (Array.isArray(gradeLevels)) {
|
||||
@ -194,6 +226,7 @@ const fetchData = async () => {
|
||||
try {
|
||||
const res = await getCollectionList({
|
||||
status: filters.status,
|
||||
gradeLevels: filters.gradeLevels?.join(','),
|
||||
pageNum: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
}) as any;
|
||||
|
||||
@ -16,11 +16,19 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="套餐">
|
||||
<a-select v-model:value="searchForm.packageType" placeholder="全部套餐" allow-clear style="width: 120px">
|
||||
<a-select-option value="BASIC">基础版</a-select-option>
|
||||
<a-select-option value="STANDARD">标准版</a-select-option>
|
||||
<a-select-option value="ADVANCED">高级版</a-select-option>
|
||||
<a-select-option value="CUSTOM">定制版</a-select-option>
|
||||
<a-select
|
||||
v-model:value="searchForm.collectionId"
|
||||
placeholder="全部套餐"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="pkg in packageList"
|
||||
:key="pkg.id"
|
||||
:value="pkg.id"
|
||||
>
|
||||
{{ pkg.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
@ -54,9 +62,16 @@
|
||||
</a-button>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'packageType'">
|
||||
<a-tag :color="getPackageTypeColor(record.packageType)">
|
||||
{{ getPackageTypeLabel(record.packageType) }}
|
||||
<a-space wrap>
|
||||
<a-tag
|
||||
v-for="(pkg, index) in (record.packageNames || [])"
|
||||
:key="index"
|
||||
:color="getPackageTypeColor(pkg)"
|
||||
>
|
||||
{{ pkg }}
|
||||
</a-tag>
|
||||
<span v-if="!record.packageNames || record.packageNames.length === 0">未关联套餐</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'quota'">
|
||||
<div>
|
||||
@ -126,14 +141,17 @@
|
||||
<a-form-item label="学习地址" name="address">
|
||||
<a-input v-model:value="formData.address" placeholder="请输入学习地址" />
|
||||
</a-form-item>
|
||||
<a-form-item label="套餐类型" name="packageType">
|
||||
<a-form-item label="套餐" name="collectionIds">
|
||||
<a-select
|
||||
v-model:value="formData.packageType"
|
||||
:disabled="isEdit"
|
||||
@change="handlePackageTypeChange"
|
||||
v-model:value="formData.collectionIds"
|
||||
mode="multiple"
|
||||
placeholder="请选择套餐"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="pkg in packageList"
|
||||
:key="pkg.id"
|
||||
:value="pkg.id"
|
||||
>
|
||||
<a-select-option value="">请选择套餐</a-select-option>
|
||||
<a-select-option v-for="pkg in packageList" :key="pkg.id" :value="pkg.name">
|
||||
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }}元)
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@ -158,25 +176,32 @@
|
||||
<span>{{ currentTenant?.name }}</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="套餐">
|
||||
<a-select v-model:value="quotaForm.packageType">
|
||||
<a-select-option value="BASIC">基础版</a-select-option>
|
||||
<a-select-option value="STANDARD">标准版</a-select-option>
|
||||
<a-select-option value="ADVANCED">高级版</a-select-option>
|
||||
<a-select-option value="CUSTOM">定制版</a-select-option>
|
||||
<a-select
|
||||
v-model:value="quotaForm.collectionIds"
|
||||
mode="multiple"
|
||||
placeholder="请选择套餐"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="pkg in packageList"
|
||||
:key="pkg.id"
|
||||
:value="pkg.id"
|
||||
>
|
||||
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }}元)
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="教师配额">
|
||||
<a-input-number v-model:value="quotaForm.teacherQuota" :min="currentTenant?.teacherCount || 1" :max="1000"
|
||||
style="width: 100%" />
|
||||
<div style="color: #999; font-size: 12px">
|
||||
已使用: {{ currentTenant?.teacherCount || 0 }}
|
||||
已使用:{{ currentTenant?.teacherCount || 0 }}
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="学生配额">
|
||||
<a-input-number v-model:value="quotaForm.studentQuota" :min="currentTenant?.studentCount || 1" :max="10000"
|
||||
style="width: 100%" />
|
||||
<div style="color: #999; font-size: 12px">
|
||||
已使用: {{ currentTenant?.studentCount || 0 }}
|
||||
已使用:{{ currentTenant?.studentCount || 0 }}
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
@ -205,9 +230,16 @@
|
||||
{{ detailData.address || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="套餐">
|
||||
<a-tag :color="getPackageTypeColor(detailData.packageType)">
|
||||
{{ getPackageTypeLabel(detailData.packageType) }}
|
||||
<a-space wrap>
|
||||
<a-tag
|
||||
v-for="(pkg, index) in (detailData.packageNames || [])"
|
||||
:key="index"
|
||||
:color="getPackageTypeColor(pkg)"
|
||||
>
|
||||
{{ pkg }}
|
||||
</a-tag>
|
||||
<span v-if="!detailData.packageNames || detailData.packageNames.length === 0">未关联套餐</span>
|
||||
</a-space>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="有效期">
|
||||
{{ detailData.startDate }} 至 {{ detailData.expireDate }}
|
||||
@ -277,7 +309,7 @@ import {
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
status: undefined as string | undefined,
|
||||
packageType: undefined as string | undefined,
|
||||
collectionId: undefined as number | undefined,
|
||||
});
|
||||
|
||||
// 数据列表
|
||||
@ -325,15 +357,14 @@ const isEdit = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const editingId = ref<number | null>(null);
|
||||
|
||||
const formData = reactive<CreateTenantDto & { dateRange?: [string, string]; collectionId?: number }>({
|
||||
const formData = reactive<CreateTenantDto & { dateRange?: [string, string]; collectionIds?: number[] }>({
|
||||
name: '',
|
||||
loginAccount: '',
|
||||
password: '',
|
||||
contactPerson: '',
|
||||
contactPhone: '',
|
||||
address: '',
|
||||
packageType: '',
|
||||
collectionId: undefined,
|
||||
collectionIds: undefined,
|
||||
teacherQuota: 20,
|
||||
studentQuota: 200,
|
||||
startDate: '',
|
||||
@ -359,7 +390,7 @@ const formRules = {
|
||||
},
|
||||
],
|
||||
address: [{ required: true, message: '请输入学习地址' }],
|
||||
packageType: [{ required: true, message: '请选择套餐' }],
|
||||
collectionIds: [{ required: true, message: '请选择套餐', type: 'array', trigger: 'change' }],
|
||||
teacherQuota: [
|
||||
{ required: true, message: '请输入教师配额' },
|
||||
{ type: 'number' as const, min: 1, max: 1000, message: '教师配额需为 1-1000 的整数' },
|
||||
@ -371,14 +402,18 @@ const formRules = {
|
||||
dateRange: [
|
||||
{ required: true, message: '请选择有效期', type: 'array' as const, trigger: 'change' },
|
||||
],
|
||||
};
|
||||
} as any;
|
||||
|
||||
// 配额弹窗
|
||||
const quotaModalVisible = ref(false);
|
||||
const quotaModalLoading = ref(false);
|
||||
const currentTenant = ref<Tenant | null>(null);
|
||||
const quotaForm = reactive({
|
||||
packageType: 'STANDARD',
|
||||
const quotaForm = reactive<{
|
||||
collectionIds?: number[];
|
||||
teacherQuota: number;
|
||||
studentQuota: number;
|
||||
}>({
|
||||
collectionIds: undefined,
|
||||
teacherQuota: 20,
|
||||
studentQuota: 200,
|
||||
});
|
||||
@ -401,26 +436,6 @@ const formatPackagePrice = (priceInCents?: number) => {
|
||||
return (priceInCents / 100).toFixed(2);
|
||||
};
|
||||
|
||||
// 处理套餐变化,自动填充配额等信息
|
||||
const handlePackageTypeChange = (value: string) => {
|
||||
const selectedPackage = packageList.value.find(pkg => pkg.name === value);
|
||||
if (selectedPackage && selectedPackage.name) {
|
||||
// 设置选中的课程套餐ID(用于三层架构关联)
|
||||
formData.collectionId = selectedPackage.id;
|
||||
|
||||
// 根据套餐自动设置配额(这里可以根据实际需求调整)
|
||||
if (selectedPackage.name?.includes('基础')) {
|
||||
formData.teacherQuota = 10;
|
||||
formData.studentQuota = 100;
|
||||
} else if (selectedPackage.name?.includes('标准')) {
|
||||
formData.teacherQuota = 20;
|
||||
formData.studentQuota = 200;
|
||||
} else if (selectedPackage.name?.includes('高级')) {
|
||||
formData.teacherQuota = 50;
|
||||
formData.studentQuota = 500;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
@ -431,7 +446,7 @@ const loadData = async () => {
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
status: searchForm.status,
|
||||
packageType: searchForm.packageType,
|
||||
collectionId: searchForm.collectionId,
|
||||
});
|
||||
tenants.value = res.list;
|
||||
pagination.total = res.total;
|
||||
@ -462,7 +477,7 @@ const handleSearch = () => {
|
||||
const handleReset = () => {
|
||||
searchForm.keyword = '';
|
||||
searchForm.status = undefined;
|
||||
searchForm.packageType = undefined;
|
||||
searchForm.collectionId = undefined;
|
||||
pagination.current = 1;
|
||||
loadData();
|
||||
};
|
||||
@ -485,8 +500,7 @@ const showAddModal = () => {
|
||||
contactPerson: '',
|
||||
contactPhone: '',
|
||||
address: '',
|
||||
packageType: '',
|
||||
collectionId: undefined,
|
||||
collectionIds: undefined,
|
||||
teacherQuota: 20,
|
||||
studentQuota: 200,
|
||||
dateRange: [
|
||||
@ -503,10 +517,15 @@ const handleEdit = (record: Tenant) => {
|
||||
editingId.value = record.id;
|
||||
Object.assign(formData, {
|
||||
name: record.name,
|
||||
contactPerson: record.contactName,
|
||||
contactPerson: (record as any).contactName,
|
||||
contactPhone: record.contactPhone,
|
||||
address: record.address,
|
||||
packageType: record.packageType,
|
||||
collectionIds: record.packageNames && record.packageNames.length > 0
|
||||
? record.packageNames.map((name: string) => {
|
||||
const pkg = packageList.value.find(p => p.name === name);
|
||||
return pkg?.id;
|
||||
}).filter((id: number | undefined) => id !== undefined)
|
||||
: undefined,
|
||||
teacherQuota: record.teacherQuota,
|
||||
studentQuota: record.studentQuota,
|
||||
dateRange: [record.startDate, record.expireDate] as [string, string],
|
||||
@ -567,7 +586,15 @@ const handleViewDetail = async (record: Tenant) => {
|
||||
// 配额调整
|
||||
const handleQuota = (record: Tenant) => {
|
||||
currentTenant.value = record;
|
||||
quotaForm.packageType = record.packageType;
|
||||
// 根据套餐名称获取对应的套餐 ID 列表
|
||||
if (record.packageNames && record.packageNames.length > 0) {
|
||||
quotaForm.collectionIds = record.packageNames.map(name => {
|
||||
const pkg = packageList.value.find(p => p.name === name);
|
||||
return pkg?.id;
|
||||
}).filter(id => id !== undefined) as number[];
|
||||
} else {
|
||||
quotaForm.collectionIds = undefined;
|
||||
}
|
||||
quotaForm.teacherQuota = record.teacherQuota;
|
||||
quotaForm.studentQuota = record.studentQuota;
|
||||
quotaModalVisible.value = true;
|
||||
@ -644,24 +671,20 @@ const handleDelete = (record: Tenant) => {
|
||||
|
||||
// 辅助函数
|
||||
const getPackageTypeColor = (type: string) => {
|
||||
// 根据套餐名称生成颜色
|
||||
const colors: Record<string, string> = {
|
||||
BASIC: 'green',
|
||||
STANDARD: 'blue',
|
||||
ADVANCED: 'purple',
|
||||
CUSTOM: 'orange',
|
||||
'基础版': 'green',
|
||||
'标准版': 'blue',
|
||||
'高级版': 'purple',
|
||||
'定制版': 'orange',
|
||||
'BASIC': 'green',
|
||||
'STANDARD': 'blue',
|
||||
'ADVANCED': 'purple',
|
||||
'CUSTOM': 'orange',
|
||||
};
|
||||
return colors[type] || 'default';
|
||||
};
|
||||
|
||||
const getPackageTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
BASIC: '基础版',
|
||||
STANDARD: '标准版',
|
||||
ADVANCED: '高级版',
|
||||
CUSTOM: '定制版',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
const getStatusType = (status: string): 'success' | 'default' | 'warning' | 'error' | 'processing' => {
|
||||
const types: Record<string, 'success' | 'default' | 'warning' | 'error' | 'processing'> = {
|
||||
|
||||
@ -135,6 +135,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页组件 -->
|
||||
<div class="pagination-wrapper" v-if="!loading && pagination.total > 0">
|
||||
<a-pagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
show-size-changer
|
||||
show-quick-jumper
|
||||
:show-total="(total: number) => `共 ${total} 条`"
|
||||
@change="handlePageChange"
|
||||
@showSizeChange="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div class="empty-state" v-if="!loading && courses.length === 0">
|
||||
<div class="empty-icon-wrapper">
|
||||
@ -265,6 +279,7 @@ const DOMAIN_TO_CODE: Record<string, string> = {
|
||||
};
|
||||
|
||||
const handleFilterChange = () => {
|
||||
pagination.current = 1; // 重置到第一页
|
||||
loadCourses();
|
||||
};
|
||||
|
||||
@ -322,17 +337,32 @@ const parseTags = (val: any): string[] => {
|
||||
return [];
|
||||
};
|
||||
|
||||
// 加载课程列表(支持年级、领域、课程类型、关键词筛选)
|
||||
// 加载课程列表(支持年级、领域、课程类型、关键词筛选,分页)
|
||||
const loadCourses = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: schoolApi.SchoolCourseQueryParams = {};
|
||||
const params: {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
grade?: string;
|
||||
domain?: string;
|
||||
lessonType?: string;
|
||||
} = {
|
||||
pageNum: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
};
|
||||
if (filters.keyword?.trim()) params.keyword = filters.keyword.trim();
|
||||
if (filters.grade) params.grade = filters.grade;
|
||||
if (filters.domain) params.domain = DOMAIN_TO_CODE[filters.domain] ?? filters.domain;
|
||||
if (filters.lessonType) params.lessonType = filters.lessonType;
|
||||
const data = await schoolApi.getSchoolCourses(params);
|
||||
courses.value = (data || []).map((course: any) => {
|
||||
|
||||
// mutator 已经处理了 { code, message, data } 响应,直接返回 data 字段
|
||||
// 对于分页接口,data 就是 PageResult { list, total, pageNum, pageSize, pages }
|
||||
const pageResult = await schoolApi.getSchoolCourseList(params);
|
||||
const { list, total } = pageResult || { list: [], total: 0 };
|
||||
|
||||
courses.value = (list || []).map((course: any) => {
|
||||
const gradeTags = parseTags(course.gradeTags);
|
||||
const domainTags = parseTags(course.domainTags);
|
||||
return {
|
||||
@ -345,17 +375,25 @@ const loadCourses = async () => {
|
||||
authorized: course.authorized ?? true,
|
||||
};
|
||||
});
|
||||
pagination.total = courses.value.length;
|
||||
pagination.total = total || 0;
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '加载课程列表失败');
|
||||
console.error('加载课程列表失败:', error);
|
||||
message.error(error.response?.data?.message || error.message || '加载课程列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableChange = (pag: any) => {
|
||||
pagination.current = pag.current;
|
||||
pagination.pageSize = pag.pageSize;
|
||||
const handlePageChange = (page: number, pageSize: number) => {
|
||||
pagination.current = page;
|
||||
pagination.pageSize = pageSize;
|
||||
loadCourses();
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (current: number, size: number) => {
|
||||
pagination.current = current;
|
||||
pagination.pageSize = size;
|
||||
loadCourses();
|
||||
};
|
||||
|
||||
const showAuthModal = () => {
|
||||
@ -766,6 +804,14 @@ onMounted(() => {
|
||||
color: #43e97b !important;
|
||||
}
|
||||
|
||||
/* 分页组件 */
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
|
||||
@ -33,6 +33,7 @@ public enum ErrorCode {
|
||||
TENANT_NOT_FOUND(3001, "Tenant not found"),
|
||||
TENANT_EXPIRED(3002, "Tenant has expired"),
|
||||
TENANT_DISABLED(3003, "Tenant is disabled"),
|
||||
TENANT_SUSPENDED(3004, "您的账户因租户服务暂停而无法登录,请联系学校管理员"),
|
||||
|
||||
// Package Errors (3100+)
|
||||
PACKAGE_NOT_FOUND(3101, "Package not found"),
|
||||
|
||||
@ -8,9 +8,9 @@ import lombok.Getter;
|
||||
@Getter
|
||||
public enum TenantStatus {
|
||||
|
||||
ACTIVE("active", "Active"),
|
||||
EXPIRED("expired", "Expired"),
|
||||
DISABLED("disabled", "Disabled");
|
||||
ACTIVE("ACTIVE", "生效中"),
|
||||
EXPIRED("EXPIRED", "已过期"),
|
||||
SUSPENDED("SUSPENDED", "已暂停");
|
||||
|
||||
private final String code;
|
||||
private final String description;
|
||||
@ -22,7 +22,7 @@ public enum TenantStatus {
|
||||
|
||||
public static TenantStatus fromCode(String code) {
|
||||
for (TenantStatus status : values()) {
|
||||
if (status.getCode().equals(code)) {
|
||||
if (status.getCode().equalsIgnoreCase(code)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.reading.platform.common.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.reading.platform.common.enums.TenantStatus;
|
||||
import com.reading.platform.entity.AdminUser;
|
||||
import com.reading.platform.entity.Parent;
|
||||
import com.reading.platform.entity.Tenant;
|
||||
@ -147,7 +148,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
case "school" -> {
|
||||
Tenant tenant = tenantMapper.selectById(userId);
|
||||
yield tenant != null && "active".equalsIgnoreCase(tenant.getStatus());
|
||||
// 租户状态需要是 ACTIVE 才算激活
|
||||
yield tenant != null && TenantStatus.ACTIVE.getCode().equalsIgnoreCase(tenant.getStatus());
|
||||
}
|
||||
case "teacher" -> {
|
||||
Teacher teacher = teacherMapper.selectById(userId);
|
||||
|
||||
@ -44,7 +44,8 @@ public class AdminCourseCollectionController {
|
||||
Page<CourseCollectionResponse> page = collectionService.pageCollections(
|
||||
request.getPageNum(),
|
||||
request.getPageSize(),
|
||||
request.getStatus()
|
||||
request.getStatus(),
|
||||
request.getGradeLevels()
|
||||
);
|
||||
return Result.success(PageResult.of(page));
|
||||
}
|
||||
|
||||
@ -68,13 +68,14 @@ public class AdminCourseController {
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询课程包")
|
||||
public Result<PageResult<CourseResponse>> getCoursePage(CoursePageQueryRequest request) {
|
||||
log.info("查询课程列表,pageNum={}, pageSize={}, keyword={}, category={}, status={}, reviewOnly={}",
|
||||
request.getPageNum(), request.getPageSize(), request.getKeyword(), request.getCategory(), request.getStatus(), request.getReviewOnly());
|
||||
log.info("查询课程列表,pageNum={}, pageSize={}, keyword={}, category={}, status={}, gradeTags={}, reviewOnly={}",
|
||||
request.getPageNum(), request.getPageSize(), request.getKeyword(), request.getCategory(), request.getStatus(), request.getGradeTags(), request.getReviewOnly());
|
||||
// 页码
|
||||
// 每页数量
|
||||
// 关键词
|
||||
// 分类
|
||||
// 状态
|
||||
// 年级筛选
|
||||
// 是否仅查询待审核
|
||||
Page<CoursePackage> page = courseService.getSystemCoursePage(
|
||||
request.getPageNum(),
|
||||
@ -82,6 +83,7 @@ public class AdminCourseController {
|
||||
request.getKeyword(),
|
||||
request.getCategory(),
|
||||
request.getStatus(),
|
||||
request.getGradeTags(),
|
||||
request.getReviewOnly());
|
||||
|
||||
// 转换为 CourseResponse
|
||||
|
||||
@ -9,6 +9,10 @@ import com.reading.platform.dto.request.TenantCreateRequest;
|
||||
import com.reading.platform.dto.request.TenantUpdateRequest;
|
||||
import com.reading.platform.dto.response.TenantResponse;
|
||||
import com.reading.platform.entity.Tenant;
|
||||
import com.reading.platform.entity.TenantPackage;
|
||||
import com.reading.platform.entity.CourseCollection;
|
||||
import com.reading.platform.mapper.TenantPackageMapper;
|
||||
import com.reading.platform.mapper.CourseCollectionMapper;
|
||||
import com.reading.platform.service.TenantService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@ -16,6 +20,7 @@ import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -29,6 +34,8 @@ import java.util.stream.Collectors;
|
||||
public class AdminTenantController {
|
||||
|
||||
private final TenantService tenantService;
|
||||
private final TenantPackageMapper tenantPackageMapper;
|
||||
private final CourseCollectionMapper collectionMapper;
|
||||
|
||||
@Operation(summary = "Create tenant")
|
||||
@PostMapping
|
||||
@ -107,7 +114,8 @@ public class AdminTenantController {
|
||||
@Operation(summary = "更新租户状态")
|
||||
@PutMapping("/{id}/status")
|
||||
public Result<TenantResponse> updateTenantStatus(@PathVariable Long id, @RequestBody Map<String, Object> status) {
|
||||
// TODO: 实现更新租户状态逻辑
|
||||
String newStatus = (String) status.get("status");
|
||||
tenantService.updateTenantStatus(id, newStatus);
|
||||
Tenant tenant = tenantService.getTenantById(id);
|
||||
return Result.success(toResponse(tenant));
|
||||
}
|
||||
@ -120,9 +128,28 @@ public class AdminTenantController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Tenant 实体转换为 TenantResponse
|
||||
* 将 Tenant 实体转换为 TenantResponse(包含套餐名称列表)
|
||||
*/
|
||||
private TenantResponse toResponse(Tenant tenant) {
|
||||
// 从 tenant_package 表查询所有套餐信息(只查询 ACTIVE 状态的关联)
|
||||
List<TenantPackage> tenantPackages = tenantPackageMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TenantPackage>()
|
||||
.eq(TenantPackage::getTenantId, tenant.getId())
|
||||
.eq(TenantPackage::getStatus, com.reading.platform.common.enums.TenantPackageStatus.ACTIVE)
|
||||
.orderByDesc(TenantPackage::getStartDate)
|
||||
);
|
||||
|
||||
// 获取所有关联的套餐名称列表
|
||||
List<String> packageNames = new ArrayList<>();
|
||||
for (TenantPackage tp : tenantPackages) {
|
||||
if (tp.getCollectionId() != null) {
|
||||
CourseCollection collection = collectionMapper.selectById(tp.getCollectionId());
|
||||
if (collection != null) {
|
||||
packageNames.add(collection.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TenantResponse.builder()
|
||||
.id(tenant.getId())
|
||||
.name(tenant.getName())
|
||||
@ -137,7 +164,7 @@ public class AdminTenantController {
|
||||
.expireAt(tenant.getExpireAt())
|
||||
.maxStudents(tenant.getMaxStudents())
|
||||
.maxTeachers(tenant.getMaxTeachers())
|
||||
.packageType(tenant.getPackageType())
|
||||
.packageNames(packageNames) // 套餐名称列表
|
||||
.teacherQuota(tenant.getTeacherQuota())
|
||||
.studentQuota(tenant.getStudentQuota())
|
||||
.storageQuota(tenant.getStorageQuota())
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package com.reading.platform.controller.school;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.reading.platform.common.response.PageResult;
|
||||
import com.reading.platform.common.response.Result;
|
||||
import com.reading.platform.common.security.SecurityUtils;
|
||||
import com.reading.platform.dto.response.LessonTagResponse;
|
||||
@ -34,18 +36,26 @@ public class SchoolCourseController {
|
||||
private final CourseLessonService courseLessonService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "获取学校课程包列表")
|
||||
public Result<List<SchoolCourseResponse>> getSchoolCourses(
|
||||
@Operation(summary = "获取学校课程包列表(分页)")
|
||||
public Result<PageResult<SchoolCourseResponse>> getSchoolCourses(
|
||||
@RequestParam(required = false, defaultValue = "1") Integer pageNum,
|
||||
@RequestParam(required = false, defaultValue = "10") Integer pageSize,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String grade,
|
||||
@RequestParam(required = false) String domain,
|
||||
@RequestParam(required = false) String lessonType) {
|
||||
log.info("获取学校课程包列表,keyword={}, grade={}, domain={}, lessonType={}", keyword, grade, domain, lessonType);
|
||||
log.info("获取学校课程包列表(分页),pageNum={}, pageSize={}, keyword={}, grade={}, domain={}, lessonType={}",
|
||||
pageNum, pageSize, keyword, grade, domain, lessonType);
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
List<CoursePackage> courses = courseService.getTenantPackageCourses(tenantId, keyword, grade, domain, lessonType);
|
||||
List<SchoolCourseResponse> list = courses.stream()
|
||||
|
||||
// 使用分页查询方法
|
||||
Page<CoursePackage> page = courseService.getTenantPackageCoursePage(
|
||||
tenantId, pageNum, pageSize, keyword, grade, domain, lessonType, null);
|
||||
|
||||
List<SchoolCourseResponse> list = page.getRecords().stream()
|
||||
.map(pkg -> toSchoolCourseResponse(pkg))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 填充 lessonTags
|
||||
for (SchoolCourseResponse vo : list) {
|
||||
List<CourseLesson> lessons = courseLessonService.findByCourseId(vo.getId());
|
||||
@ -56,7 +66,8 @@ public class SchoolCourseController {
|
||||
.build())
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
return Result.success(list);
|
||||
|
||||
return Result.success(PageResult.of(list, page.getTotal(), page.getCurrent(), page.getSize()));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
|
||||
@ -18,4 +18,7 @@ public class CourseCollectionPageQueryRequest {
|
||||
|
||||
@Schema(description = "状态")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "年级(支持多个,逗号分隔)")
|
||||
private String gradeLevels;
|
||||
}
|
||||
|
||||
@ -25,6 +25,9 @@ public class CoursePageQueryRequest {
|
||||
@Schema(description = "状态")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "年级(支持多个,逗号分隔)")
|
||||
private String gradeTags;
|
||||
|
||||
@Schema(description = "是否仅查询待审核", example = "false")
|
||||
private Boolean reviewOnly = false;
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "租户创建请求")
|
||||
@ -37,8 +38,6 @@ public class TenantCreateRequest {
|
||||
@Schema(description = "Logo URL")
|
||||
private String logoUrl;
|
||||
|
||||
@Schema(description = "套餐类型")
|
||||
private String packageType;
|
||||
|
||||
@Schema(description = "教师配额")
|
||||
private Integer teacherQuota;
|
||||
@ -52,8 +51,8 @@ public class TenantCreateRequest {
|
||||
@Schema(description = "结束日期")
|
||||
private LocalDate expireDate;
|
||||
|
||||
@Schema(description = "课程套餐 ID(可选)")
|
||||
private Long collectionId;
|
||||
@Schema(description = "课程套餐 ID 列表(可选,支持多选)")
|
||||
private List<Long> collectionIds;
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "租户更新请求")
|
||||
@ -31,11 +32,9 @@ public class TenantUpdateRequest {
|
||||
@Schema(description = "状态")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "套餐类型")
|
||||
private String packageType;
|
||||
|
||||
@Schema(description = "课程套餐ID(用于三层架构)")
|
||||
private Long collectionId;
|
||||
private List<Long> collectionIds;
|
||||
|
||||
@Schema(description = "教师配额")
|
||||
private Integer teacherQuota;
|
||||
|
||||
@ -6,6 +6,7 @@ import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 租户响应
|
||||
@ -55,8 +56,8 @@ public class TenantResponse {
|
||||
@Schema(description = "最大教师数")
|
||||
private Integer maxTeachers;
|
||||
|
||||
@Schema(description = "套餐类型")
|
||||
private String packageType;
|
||||
@Schema(description = "套餐名称列表")
|
||||
private List<String> packageNames;
|
||||
|
||||
@Schema(description = "教师配额")
|
||||
private Integer teacherQuota;
|
||||
|
||||
@ -55,9 +55,6 @@ public class Tenant extends BaseEntity {
|
||||
@Schema(description = "最大教师数")
|
||||
private Integer maxTeachers;
|
||||
|
||||
@Schema(description = "套餐类型 (BASIC/STANDARD/ADVANCED/CUSTOM)")
|
||||
private String packageType;
|
||||
|
||||
@Schema(description = "教师配额")
|
||||
private Integer teacherQuota;
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ public interface CourseCollectionService extends IService<CourseCollection> {
|
||||
/**
|
||||
* 分页查询课程套餐
|
||||
*/
|
||||
Page<CourseCollectionResponse> pageCollections(Integer pageNum, Integer pageSize, String status);
|
||||
Page<CourseCollectionResponse> pageCollections(Integer pageNum, Integer pageSize, String status, String gradeLevels);
|
||||
|
||||
/**
|
||||
* 获取课程套餐下的课程包列表
|
||||
|
||||
@ -36,7 +36,7 @@ public interface CoursePackageService extends com.baomidou.mybatisplus.extension
|
||||
|
||||
Page<CoursePackage> getCoursePage(Long tenantId, Integer pageNum, Integer pageSize, String keyword, String category, String status);
|
||||
|
||||
Page<CoursePackage> getSystemCoursePage(Integer pageNum, Integer pageSize, String keyword, String category, String status, boolean reviewOnly);
|
||||
Page<CoursePackage> getSystemCoursePage(Integer pageNum, Integer pageSize, String keyword, String category, String status, String gradeTags, boolean reviewOnly);
|
||||
|
||||
void deleteCourse(Long id);
|
||||
|
||||
|
||||
@ -48,4 +48,9 @@ public interface TenantService extends com.baomidou.mybatisplus.extension.servic
|
||||
*/
|
||||
List<Tenant> getAllActiveTenants();
|
||||
|
||||
/**
|
||||
* 更新租户状态
|
||||
*/
|
||||
void updateTenantStatus(Long id, String status);
|
||||
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package com.reading.platform.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.reading.platform.common.enums.ErrorCode;
|
||||
import com.reading.platform.common.enums.TenantStatus;
|
||||
import com.reading.platform.common.enums.UserRole;
|
||||
import com.reading.platform.common.exception.BusinessException;
|
||||
import com.reading.platform.common.security.JwtPayload;
|
||||
@ -104,6 +105,12 @@ public class AuthServiceImpl implements AuthService {
|
||||
log.warn("登录失败:账户已禁用,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
// 检查教师所属租户状态
|
||||
Tenant tenant = tenantMapper.selectById(teacher.getTenantId());
|
||||
if (tenant != null && TenantStatus.SUSPENDED.getCode().equalsIgnoreCase(tenant.getStatus())) {
|
||||
log.warn("登录失败:租户服务已暂停,租户 ID: {}, 教师用户名:{}", tenant.getId(), username);
|
||||
throw new BusinessException(ErrorCode.TENANT_SUSPENDED);
|
||||
}
|
||||
// 更新最后登录时间
|
||||
teacher.setLastLoginAt(LocalDateTime.now());
|
||||
teacherMapper.updateById(teacher);
|
||||
@ -180,7 +187,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
log.warn("登录失败:密码错误,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.LOGIN_FAILED);
|
||||
}
|
||||
if (!"active".equalsIgnoreCase(tenant.getStatus())) {
|
||||
if (!TenantStatus.ACTIVE.getCode().equalsIgnoreCase(tenant.getStatus())) {
|
||||
log.warn("登录失败:账户已禁用,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
@ -299,6 +306,12 @@ public class AuthServiceImpl implements AuthService {
|
||||
log.warn("登录失败:账户已禁用,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
// 检查教师所属租户状态
|
||||
Tenant tenant = tenantMapper.selectById(teacher.getTenantId());
|
||||
if (tenant != null && TenantStatus.SUSPENDED.getCode().equalsIgnoreCase(tenant.getStatus())) {
|
||||
log.warn("登录失败:租户服务已暂停,租户 ID: {}, 教师用户名:{}", tenant.getId(), username);
|
||||
throw new BusinessException(ErrorCode.TENANT_SUSPENDED);
|
||||
}
|
||||
// 更新最后登录时间
|
||||
teacher.setLastLoginAt(LocalDateTime.now());
|
||||
teacherMapper.updateById(teacher);
|
||||
|
||||
@ -111,13 +111,31 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
|
||||
* 分页查询课程套餐
|
||||
*/
|
||||
@Override
|
||||
public Page<CourseCollectionResponse> pageCollections(Integer pageNum, Integer pageSize, String status) {
|
||||
log.info("分页查询课程套餐,pageNum={}, pageSize={}, status={}", pageNum, pageSize, status);
|
||||
public Page<CourseCollectionResponse> pageCollections(Integer pageNum, Integer pageSize, String status, String gradeLevels) {
|
||||
log.info("分页查询课程套餐,pageNum={}, pageSize={}, status={}, gradeLevels={}", pageNum, pageSize, status, gradeLevels);
|
||||
|
||||
LambdaQueryWrapper<CourseCollection> wrapper = new LambdaQueryWrapper<>();
|
||||
if (StringUtils.hasText(status)) {
|
||||
wrapper.eq(CourseCollection::getStatus, status);
|
||||
}
|
||||
// 年级筛选:gradeLevels 为逗号分隔的字符串,如 "小班,中班,大班"
|
||||
// 数据库存储为 JSON 数组 ["小班","中班"],需要使用 JSON_CONTAINS 匹配
|
||||
// 多选年级应该是 OR 关系(包含任意一个所选年级即可)
|
||||
if (StringUtils.hasText(gradeLevels)) {
|
||||
String[] selectedGrades = gradeLevels.split(",");
|
||||
wrapper.and(w -> {
|
||||
for (int i = 0; i < selectedGrades.length; i++) {
|
||||
String grade = selectedGrades[i].trim();
|
||||
if (StringUtils.hasText(grade)) {
|
||||
if (i == 0) {
|
||||
w.apply("JSON_CONTAINS(grade_levels, ?)", "\"" + grade + "\"");
|
||||
} else {
|
||||
w.or().apply("JSON_CONTAINS(grade_levels, ?)", "\"" + grade + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
wrapper.orderByDesc(CourseCollection::getCreatedAt);
|
||||
|
||||
Page<CourseCollection> page = collectionMapper.selectPage(
|
||||
@ -188,7 +206,27 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CourseCollectionResponse createCollection(String name, String description, Long price,
|
||||
Long discountPrice, String discountType, String[] gradeLevels) {
|
||||
log.info("创建课程套餐,name={}", name);
|
||||
log.info("创建课程套餐,name={}, price={}, discountPrice={}, discountType={}", name, price, discountPrice, discountType);
|
||||
|
||||
// 校验价格不能为负
|
||||
if (price == null || price < 0) {
|
||||
throw new BusinessException("价格不能为空或负数");
|
||||
}
|
||||
|
||||
// 校验折扣价格逻辑
|
||||
if (discountPrice != null) {
|
||||
if ("PERCENTAGE".equals(discountType)) {
|
||||
// 折扣类型:折扣率应该是 0-10000 之间(支持两位小数,如 99.99%)
|
||||
if (discountPrice < 0 || discountPrice > 10000) {
|
||||
throw new BusinessException("折扣率应该在 0-10000 分之间(0-100%)");
|
||||
}
|
||||
} else if ("FIXED".equals(discountType)) {
|
||||
// 立减类型:立减金额不能超过原价
|
||||
if (discountPrice > price) {
|
||||
throw new BusinessException("立减金额不能超过原价");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CourseCollection collection = new CourseCollection();
|
||||
collection.setName(name);
|
||||
@ -264,13 +302,33 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CourseCollectionResponse updateCollection(Long id, String name, String description, Long price,
|
||||
Long discountPrice, String discountType, String[] gradeLevels) {
|
||||
log.info("更新课程套餐,id={}", id);
|
||||
log.info("更新课程套餐,id={}, name={}, price={}, discountPrice={}, discountType={}", id, name, price, discountPrice, discountType);
|
||||
|
||||
CourseCollection collection = collectionMapper.selectById(id);
|
||||
if (collection == null) {
|
||||
throw new IllegalArgumentException("课程套餐不存在");
|
||||
}
|
||||
|
||||
// 校验价格不能为负
|
||||
if (price == null || price < 0) {
|
||||
throw new BusinessException("价格不能为空或负数");
|
||||
}
|
||||
|
||||
// 校验折扣价格逻辑
|
||||
if (discountPrice != null) {
|
||||
if ("PERCENTAGE".equals(discountType)) {
|
||||
// 折扣类型:折扣率应该是 0-10000 之间(支持两位小数,如 99.99%)
|
||||
if (discountPrice < 0 || discountPrice > 10000) {
|
||||
throw new BusinessException("折扣率应该在 0-10000 分之间(0-100%)");
|
||||
}
|
||||
} else if ("FIXED".equals(discountType)) {
|
||||
// 立减类型:立减金额不能超过原价
|
||||
if (discountPrice > price) {
|
||||
throw new BusinessException("立减金额不能超过原价");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collection.setName(name);
|
||||
collection.setDescription(description);
|
||||
collection.setPrice(price);
|
||||
|
||||
@ -144,7 +144,7 @@ public class CoursePackageServiceImpl extends ServiceImpl<CoursePackageMapper, C
|
||||
@Override
|
||||
public Page<CoursePackage> getSystemCoursePage(Integer pageNum, Integer pageSize,
|
||||
String keyword, String category, String status,
|
||||
boolean reviewOnly) {
|
||||
String gradeTags, boolean reviewOnly) {
|
||||
int current = pageNum != null && pageNum > 0 ? pageNum : 1;
|
||||
int size = pageSize != null && pageSize > 0 ? pageSize : 10;
|
||||
Page<CoursePackage> page = new Page<>(current, size);
|
||||
@ -162,6 +162,24 @@ public class CoursePackageServiceImpl extends ServiceImpl<CoursePackageMapper, C
|
||||
if (StringUtils.hasText(status)) {
|
||||
wrapper.eq(CoursePackage::getStatus, status);
|
||||
}
|
||||
// 年级筛选:gradeTags 为逗号分隔的字符串,如 "小班,中班,大班"
|
||||
// 数据库存储为 JSON 数组 ["小班","中班"],需要使用 JSON_CONTAINS 匹配
|
||||
// 多选年级应该是 OR 关系(包含任意一个所选年级即可)
|
||||
if (StringUtils.hasText(gradeTags)) {
|
||||
String[] selectedGrades = gradeTags.split(",");
|
||||
wrapper.and(w -> {
|
||||
for (int i = 0; i < selectedGrades.length; i++) {
|
||||
String grade = selectedGrades[i].trim();
|
||||
if (StringUtils.hasText(grade)) {
|
||||
if (i == 0) {
|
||||
w.apply("JSON_CONTAINS(grade_tags, ?)", "\"" + grade + "\"");
|
||||
} else {
|
||||
w.or().apply("JSON_CONTAINS(grade_tags, ?)", "\"" + grade + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (reviewOnly) {
|
||||
wrapper.eq(CoursePackage::getStatus, CourseStatus.PENDING.getCode());
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.reading.platform.common.enums.ErrorCode;
|
||||
import com.reading.platform.common.enums.TenantPackageStatus;
|
||||
import com.reading.platform.common.enums.TenantStatus;
|
||||
import com.reading.platform.common.exception.BusinessException;
|
||||
import com.reading.platform.dto.request.TenantCreateRequest;
|
||||
import com.reading.platform.dto.request.TenantUpdateRequest;
|
||||
@ -29,7 +30,9 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@ -84,16 +87,7 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
|
||||
String rawPassword = StringUtils.hasText(request.getPassword()) ? request.getPassword() : "123456";
|
||||
tenant.setPassword(passwordEncoder.encode(rawPassword));
|
||||
|
||||
// 如果传入了 collectionId,查询课程套餐信息并填充相关字段
|
||||
if (request.getCollectionId() != null) {
|
||||
CourseCollection collection = collectionMapper.selectById(request.getCollectionId());
|
||||
if (collection == null) {
|
||||
log.warn("课程套餐不存在,collectionId: {}", request.getCollectionId());
|
||||
throw new BusinessException(ErrorCode.PACKAGE_NOT_FOUND, "课程套餐不存在");
|
||||
}
|
||||
|
||||
// 根据套餐信息填充租户字段
|
||||
tenant.setPackageType(collection.getName());
|
||||
// 设置配额和有效期默认值
|
||||
tenant.setTeacherQuota(request.getTeacherQuota() != null ? request.getTeacherQuota() : 20);
|
||||
tenant.setStudentQuota(request.getStudentQuota() != null ? request.getStudentQuota() : 200);
|
||||
tenant.setStartDate(request.getStartDate());
|
||||
@ -102,27 +96,33 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
|
||||
// 使用 MP 的 insert 方法
|
||||
tenantMapper.insert(tenant);
|
||||
|
||||
// 创建租户套餐关联记录(使用三层架构的 collectionId)
|
||||
// 处理课程套餐关联(三层架构,支持多选)
|
||||
if (request.getCollectionIds() != null && !request.getCollectionIds().isEmpty()) {
|
||||
List<TenantPackage> tenantPackages = new ArrayList<>();
|
||||
for (Long collectionId : request.getCollectionIds()) {
|
||||
CourseCollection collection = collectionMapper.selectById(collectionId);
|
||||
if (collection == null) {
|
||||
log.warn("课程套餐不存在,collectionId: {}", collectionId);
|
||||
throw new BusinessException(ErrorCode.PACKAGE_NOT_FOUND, "课程套餐不存在:" + collectionId);
|
||||
}
|
||||
|
||||
TenantPackage tenantPackage = new TenantPackage();
|
||||
tenantPackage.setTenantId(tenant.getId());
|
||||
tenantPackage.setCollectionId(request.getCollectionId());
|
||||
tenantPackage.setCollectionId(collectionId);
|
||||
tenantPackage.setStartDate(request.getStartDate() != null ? request.getStartDate() : LocalDate.now());
|
||||
tenantPackage.setEndDate(request.getExpireDate());
|
||||
tenantPackage.setPricePaid(collection.getDiscountPrice() != null ? collection.getDiscountPrice() : collection.getPrice());
|
||||
tenantPackage.setStatus(TenantPackageStatus.ACTIVE);
|
||||
tenantPackageMapper.insert(tenantPackage);
|
||||
tenantPackages.add(tenantPackage);
|
||||
}
|
||||
|
||||
log.info("租户创建成功并关联课程套餐,ID: {}, collectionId: {}", tenant.getId(), request.getCollectionId());
|
||||
// 批量插入套餐关联记录
|
||||
for (TenantPackage tp : tenantPackages) {
|
||||
tenantPackageMapper.insert(tp);
|
||||
}
|
||||
|
||||
log.info("租户创建成功并关联 {} 个课程套餐,ID: {}, collectionIds: {}", tenantPackages.size(), tenant.getId(), request.getCollectionIds());
|
||||
} else {
|
||||
// 没有传入 collectionId,使用原有逻辑
|
||||
tenant.setPackageType(request.getPackageType() != null ? request.getPackageType() : "STANDARD");
|
||||
tenant.setTeacherQuota(request.getTeacherQuota() != null ? request.getTeacherQuota() : 20);
|
||||
tenant.setStudentQuota(request.getStudentQuota() != null ? request.getStudentQuota() : 200);
|
||||
tenant.setStartDate(request.getStartDate());
|
||||
tenant.setExpireDate(request.getExpireDate());
|
||||
|
||||
// 使用 MP 的 insert 方法
|
||||
tenantMapper.insert(tenant);
|
||||
log.info("租户创建成功,ID: {}", tenant.getId());
|
||||
}
|
||||
|
||||
@ -157,9 +157,6 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
|
||||
if (StringUtils.hasText(request.getStatus())) {
|
||||
tenant.setStatus(request.getStatus());
|
||||
}
|
||||
if (request.getPackageType() != null) {
|
||||
tenant.setPackageType(request.getPackageType());
|
||||
}
|
||||
if (request.getTeacherQuota() != null) {
|
||||
tenant.setTeacherQuota(request.getTeacherQuota());
|
||||
}
|
||||
@ -173,39 +170,33 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
|
||||
tenant.setExpireDate(request.getExpireDate());
|
||||
}
|
||||
|
||||
// 处理课程套餐关联(三层架构)
|
||||
if (request.getCollectionId() != null) {
|
||||
Long collectionId = request.getCollectionId();
|
||||
// 处理课程套餐关联(三层架构,支持多选)
|
||||
if (request.getCollectionIds() != null) {
|
||||
LocalDate endDate = request.getExpireDate() != null ? request.getExpireDate() : tenant.getExpireDate();
|
||||
|
||||
// 查询现有租户套餐关联
|
||||
List<TenantPackage> existingPackages = tenantPackageMapper.selectList(
|
||||
new LambdaQueryWrapper<TenantPackage>()
|
||||
.eq(TenantPackage::getTenantId, id)
|
||||
.eq(TenantPackage::getCollectionId, collectionId)
|
||||
);
|
||||
|
||||
if (!existingPackages.isEmpty()) {
|
||||
// 更新现有记录
|
||||
TenantPackage existing = existingPackages.get(0);
|
||||
existing.setEndDate(endDate);
|
||||
existing.setStatus(TenantPackageStatus.ACTIVE);
|
||||
existing.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
tenantPackageMapper.updateById(existing);
|
||||
log.info("更新租户课程套餐关联,tenantId={}, collectionId={}", id, collectionId);
|
||||
} else {
|
||||
// 删除旧的套餐类型关联(如果有)- 使用 collectionId 判断
|
||||
// 1. 删除不在新列表中的关联记录
|
||||
tenantPackageMapper.delete(
|
||||
new LambdaQueryWrapper<TenantPackage>()
|
||||
.eq(TenantPackage::getTenantId, id)
|
||||
.ne(TenantPackage::getCollectionId, collectionId)
|
||||
.notIn(TenantPackage::getCollectionId, request.getCollectionIds())
|
||||
);
|
||||
|
||||
// 创建新记录
|
||||
// 2. 获取现有的关联集合 ID
|
||||
List<TenantPackage> existingPackages = tenantPackageMapper.selectList(
|
||||
new LambdaQueryWrapper<TenantPackage>()
|
||||
.eq(TenantPackage::getTenantId, id)
|
||||
);
|
||||
Set<Long> existingCollectionIds = existingPackages.stream()
|
||||
.map(TenantPackage::getCollectionId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 3. 创建新的关联记录
|
||||
for (Long collectionId : request.getCollectionIds()) {
|
||||
if (!existingCollectionIds.contains(collectionId)) {
|
||||
CourseCollection collection = collectionMapper.selectById(collectionId);
|
||||
if (collection == null) {
|
||||
log.warn("课程套餐不存在,collectionId: {}", collectionId);
|
||||
throw new BusinessException(ErrorCode.PACKAGE_NOT_FOUND, "课程套餐不存在");
|
||||
throw new BusinessException(ErrorCode.PACKAGE_NOT_FOUND, "课程套餐不存在:" + collectionId);
|
||||
}
|
||||
|
||||
TenantPackage tp = new TenantPackage();
|
||||
@ -221,6 +212,18 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 更新现有关联的结束日期
|
||||
for (TenantPackage tp : existingPackages) {
|
||||
if (request.getCollectionIds().contains(tp.getCollectionId())) {
|
||||
tp.setEndDate(endDate);
|
||||
tp.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
tenantPackageMapper.updateById(tp);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("更新租户课程套餐关联,tenantId={}, collectionIds={}", id, request.getCollectionIds());
|
||||
}
|
||||
|
||||
tenantMapper.updateById(tenant);
|
||||
|
||||
log.info("租户更新成功,ID: {}", id);
|
||||
@ -291,12 +294,34 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
|
||||
|
||||
List<Tenant> tenants = tenantMapper.selectList(
|
||||
new LambdaQueryWrapper<Tenant>()
|
||||
.eq(Tenant::getStatus, "active")
|
||||
.eq(Tenant::getStatus, TenantStatus.ACTIVE.getCode())
|
||||
.orderByAsc(Tenant::getName)
|
||||
);
|
||||
return tenants;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateTenantStatus(Long id, String status) {
|
||||
log.info("开始更新租户状态,ID: {}, status: {}", id, status);
|
||||
|
||||
Tenant tenant = getTenantById(id);
|
||||
|
||||
// 验证状态值
|
||||
TenantStatus targetStatus;
|
||||
try {
|
||||
targetStatus = TenantStatus.fromCode(status);
|
||||
} catch (Exception e) {
|
||||
log.warn("无效的租户状态值:{}", status);
|
||||
throw new BusinessException(ErrorCode.INVALID_PARAMETER, "无效的租户状态");
|
||||
}
|
||||
|
||||
tenant.setStatus(targetStatus.getCode());
|
||||
tenantMapper.updateById(tenant);
|
||||
|
||||
log.info("租户状态更新成功,ID: {}, status: {}", id, status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<TenantResponse> getTenantPageWithStats(Integer pageNum, Integer pageSize, String keyword, String status) {
|
||||
log.debug("分页查询租户(带统计),页码:{},每页数量:{}", pageNum, pageSize);
|
||||
@ -332,6 +357,25 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
|
||||
.eq(Student::getTenantId, tenant.getId())
|
||||
);
|
||||
|
||||
// 从 tenant_package 表查询所有套餐信息(只查询 ACTIVE 状态的关联)
|
||||
List<TenantPackage> tenantPackages = tenantPackageMapper.selectList(
|
||||
new LambdaQueryWrapper<TenantPackage>()
|
||||
.eq(TenantPackage::getTenantId, tenant.getId())
|
||||
.eq(TenantPackage::getStatus, TenantPackageStatus.ACTIVE)
|
||||
.orderByDesc(TenantPackage::getStartDate)
|
||||
);
|
||||
|
||||
// 获取所有关联的套餐名称列表
|
||||
List<String> packageNames = new ArrayList<>();
|
||||
for (TenantPackage tp : tenantPackages) {
|
||||
if (tp.getCollectionId() != null) {
|
||||
CourseCollection collection = collectionMapper.selectById(tp.getCollectionId());
|
||||
if (collection != null) {
|
||||
packageNames.add(collection.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TenantResponse.builder()
|
||||
.id(tenant.getId())
|
||||
.name(tenant.getName())
|
||||
@ -346,7 +390,7 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
|
||||
.expireAt(tenant.getExpireAt())
|
||||
.maxStudents(tenant.getMaxStudents())
|
||||
.maxTeachers(tenant.getMaxTeachers())
|
||||
.packageType(tenant.getPackageType())
|
||||
.packageNames(packageNames) // 套餐名称列表
|
||||
.teacherQuota(tenant.getTeacherQuota())
|
||||
.studentQuota(tenant.getStudentQuota())
|
||||
.storageQuota(tenant.getStorageQuota())
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
-- 删除 tenant 表的 package_type 字段(已废弃,改用 tenant_package 中间表关联)
|
||||
-- 注意:执行前请确保 tenant_package 表中已有正确的关联数据
|
||||
|
||||
-- 备份 package_type 数据(可选,用于数据迁移)
|
||||
-- UPDATE tenant t
|
||||
-- INNER JOIN tenant_package tp ON t.id = tp.tenant_id
|
||||
-- SET tp.collection_id = (SELECT id FROM course_collection WHERE name = t.package_type LIMIT 1)
|
||||
-- WHERE t.package_type IS NOT NULL AND tp.collection_id IS NULL;
|
||||
|
||||
-- 删除冗余的 package_type 字段
|
||||
ALTER TABLE `tenant` DROP COLUMN `package_type`;
|
||||
Loading…
Reference in New Issue
Block a user