Compare commits

...

3 Commits

Author SHA1 Message Date
En
deb8431910 fix: 修复 course.ts 中 http 对象未导入的问题
- 添加 http 对象封装,支持 get/post/put/delete 方法
- 修复 submitCourse 和 rejectCourse 函数中 http.post 未导入的问题
- 确保课程包编辑页面的"保存"和"保存草稿"按钮能正常请求后端

同时包含之前的数据库外键修复:
- 新增 V39 迁移脚本删除外键约束
- 修改 V28 移除外键创建语句
- 增强 CourseCollectionService 应用层验证
2026-03-19 10:32:58 +08:00
En
bad446c069 Merge remote-tracking branch 'origin/master' 2026-03-19 09:39:52 +08:00
En
1d4bf52d05 fix: 修复租户管理选择套餐显示 NaN 元问题
问题原因:
- 租户管理页面调用 /api/v1/admin/packages/all 获取课程包
- 但 CoursePackage 实体没有 price 和 discountPrice 字段
- 这些字段在 CourseCollection(课程套餐)实体中

后端修改:
- AdminCourseCollectionController 新增 GET /all 接口
- 返回已发布的课程套餐列表(含价格信息)
- 添加 @Slf4j 注解和必要导入

前端修改:
- src/api/admin.ts 修改 API 调用路径为 /collections/all
- 修改返回类型为 CourseCollectionResponse[]
- TenantListView.vue 修改 packageList 类型
- 修复 formatPackagePrice 处理 undefined 值
- 修复 handlePackageTypeChange 类型检查

数据库迁移:
- 添加 V38 脚本为 course_collection 表添加自增主键

其他修改:
- .gitignore 移除 *.sql 排除规则(允许迁移脚本)
- CourseCollectionRejectRequest 和 CourseRejectRequest 用于审核驳回

修复的 TypeScript 错误:
- formatPackagePrice 参数改为可选类型
- selectedPackage.name 添加可选链操作符
2026-03-19 09:34:54 +08:00
35 changed files with 1913 additions and 1199 deletions

View File

@ -753,3 +753,89 @@ npm run test:e2e:ui
*本规范最后更新于 2026-03-18*
*技术栈:统一使用 Spring Boot (Java) 后端*
*JDK 版本17必须*
---
## 套餐管理重构记录2026-03-18
### 后端架构(两层结构)
```
CourseCollection课程套餐 ← 超管端管理
↓ 1 对多
CourseCollectionPackage关联表
↓ 多对 1
CoursePackage课程包 ← 7 步流程创建的教学资源
↓ 1 对多
CourseLesson课程环节
```
### 后端 API 路径
**超管端套餐管理CourseCollection**:
- `GET /api/v1/admin/collections` - 分页查询套餐
- `GET /api/v1/admin/collections/{id}` - 套餐详情
- `POST /api/v1/admin/collections` - 创建套餐
- `PUT /api/v1/admin/collections/{id}` - 更新套餐
- `DELETE /api/v1/admin/collections/{id}` - 删除套餐
- `PUT /api/v1/admin/collections/{id}/packages` - 设置套餐包含的课程包
- `POST /api/v1/admin/collections/{id}/publish` - 发布套餐
- `POST /api/v1/admin/collections/{id}/archive` - 下架套餐
**超管端课程包管理CoursePackage**:
- `GET /api/v1/admin/packages` - 分页查询课程包
- `GET /api/v1/admin/packages/{id}` - 课程包详情
- `POST /api/v1/admin/packages` - 创建课程包
- `PUT /api/v1/admin/packages/{id}` - 更新课程包
- `DELETE /api/v1/admin/packages/{id}` - 删除课程包
- `POST /api/v1/admin/packages/{id}/publish` - 发布课程包
**学校端套餐查询**:
- `GET /api/v1/school/packages` - 获取学校已授权的套餐列表
- `GET /api/v1/school/packages/{collectionId}/packages` - 获取套餐下的课程包
- `GET /api/v1/school/packages/{packageId}/courses` - 获取课程包下的课程环节
### 前端类型映射
| 前端类型 | 后端 DTO | 说明 |
|---------|---------|------|
| `CourseCollection` | `CourseCollectionResponse` | 课程套餐(最上层) |
| `CoursePackageItem` | `CourseCollectionResponse.CoursePackageItem` | 套餐中的课程包项 |
| `CoursePackage` | `CoursePackageResponse` | 课程包7 步创建的教学资源) |
### 前端 API 文件
| 文件 | 用途 |
|-----|------|
| `src/api/package.ts` | 超管端套餐管理 API + 课程包管理 API |
| `src/api/course.ts` | 课程包CoursePackage相关 API |
| `src/api/school.ts` | 学校端套餐查询 API |
### 注意事项
1. **后端 `CoursePackage` = 课程包**:通过 7 步流程创建的教学资源包含教案、活动、PPT 等
2. **后端 `CourseCollection` = 课程套餐**:包含多个课程包,有价格、状态、审核流程
3. **前端 `package.ts`**主要定义课程套餐CourseCollection相关的 API 和类型
4. **前端 `course.ts`**主要定义课程包CoursePackage相关的 API 和类型
5. **ID 类型**:所有 ID 使用 `number | string` 类型,避免后端 Long 序列化后精度丢失
### 前端修复记录2026-03-18
| 文件 | 修复内容 |
|-----|---------|
| `PackageDetailView.vue` | 编辑按钮显示条件扩展:`DRAFT` → `DRAFT \|\| REJECTED`,驳回后可重新编辑 |
| `PackageEditView.vue` | `createCollection` 已正确使用 POST 请求 ✅ |
| `src/api/package.ts` | `createCollection` 使用 `http.post` ✅ |
### 套餐状态流转
```
DRAFT草稿→ 提交审核 → PENDING待审核→ 审核通过 → APPROVED已通过→ 发布 → PUBLISHED已发布
↓ ↓
驳回 ← REJECTED已拒绝 下架 → OFFLINE已下架
```
- **DRAFT/REJECTED**: 可编辑、可提交审核
- **APPROVED**: 可发布
- **PUBLISHED**: 可下架
- **OFFLINE**: 终止状态

View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(mvn compile:*)"
]
}
}

1
.gitignore vendored
View File

@ -9,7 +9,6 @@ Thumbs.db
# === 备份文件 ===
backups/
*.sql
*.db
# === 例外Flyway 迁移脚本必须提交 ===

File diff suppressed because it is too large Load Diff

View File

@ -38,12 +38,11 @@ export default defineConfig({
target: './openapi.json',
// 路径重写:确保 OpenAPI 文档中的路径正确
override: {
// 使用转换器修复路径 - 将 `/api/xxx` 转换为 `/api/v1/xxx`
// 使用转换器修复路径 - 将 `/api/v1/xxx` 转换为 `/v1/xxx`(因为 baseURL 已经是 `/api`
transformer: (spec) => {
const paths = spec.paths || {};
for (const path of Object.keys(paths)) {
let newKey = path.replace(/\/v1\/v1\//g, '/v1/');
if (newKey === path) newKey = path.replace(/^\/api\/(?!v1\/)/, '/api/v1/');
let newKey = path.replace(/^\/api\/v1\//, '/v1/');
if (newKey !== path) {
paths[newKey] = paths[path];
delete paths[path];

View File

@ -1,4 +1,7 @@
import { http } from './index';
import type { CourseCollectionResponse } from './generated/model';
export type { CourseCollectionResponse };
// ==================== 类型定义 ====================
@ -331,7 +334,7 @@ export const getPopularCourses = async (limit?: number) => {
// ==================== 课程套餐 ====================
export const getPublishedPackages = () =>
http.get<CoursePackage[]>('/v1/admin/packages/all');
http.get<CourseCollectionResponse[]>('/v1/admin/collections/all');
// ==================== 系统设置 ====================

View File

@ -1,9 +1,17 @@
import { getReadingPlatformAPI } from './generated';
import { axios } from './generated/mutator';
import { axios, customMutator } from './generated/mutator';
// 创建 API 实例
const api = getReadingPlatformAPI();
// 封装 http 方法(兼容原有代码)
export const http = {
get: <T = any>(url: string, config?: any) => customMutator<T>({ url, method: 'get', ...config }),
post: <T = any>(url: string, data?: any, config?: any) => customMutator<T>({ url, method: 'post', data, ...config }),
put: <T = any>(url: string, data?: any, config?: any) => customMutator<T>({ url, method: 'put', data, ...config }),
delete: <T = any>(url: string, config?: any) => customMutator<T>({ url, method: 'delete', ...config }),
};
// ============= 类型定义(保持向后兼容) =============
export interface CourseQueryParams {
@ -137,7 +145,8 @@ function normalizePageResult(raw: any): {
};
}
// 获取课程包列表
// 获取课程包列表7 步流程创建的教学资源)
// 注意:这里的 Course 实际对应后端的 CoursePackage课程包
export function getCourses(params: CourseQueryParams): Promise<{
items: Course[];
total: number;
@ -178,13 +187,19 @@ export function deleteCourse(id: number | string): Promise<any> {
}
// 验证课程完整性 (暂时返回 true后端可能没有此接口)
export function validateCourse(id: number): Promise<ValidationResult> {
export function validateCourse(_id: number): Promise<ValidationResult> {
return Promise.resolve({ valid: true, errors: [], warnings: [] });
}
// 提交审核
export function submitCourse(id: number, copyrightConfirmed?: boolean): Promise<any> {
return api.submit(id) as any;
export function submitCourse(id: number | string): Promise<any> {
return http.post(`/v1/admin/packages/${id}/submit`).then((res: any) => {
const body = res;
if (body && typeof body === 'object' && 'code' in body && body.code !== 200 && body.code !== 0) {
throw new Error(body.message || '提交失败');
}
return body?.data;
});
}
// 撤销审核 (暂时使用更新接口,需要确认后端是否有此功能)
@ -193,14 +208,14 @@ export function withdrawCourse(id: number): Promise<any> {
}
// 审核通过
export function approveCourse(id: number, data: { checklist?: any; comment?: string }): Promise<any> {
export function approveCourse(id: number, _data: { checklist?: any; comment?: string }): Promise<any> {
return api.publishCourse(id) as any;
}
// 审核驳回(课程专用,调用 POST /api/v1/admin/packages/{id}/reject
export function rejectCourse(id: number, data: { checklist?: any; comment: string }): Promise<any> {
return axios.post(`/api/v1/admin/packages/${id}/reject`, { comment: data.comment }).then((res: any) => {
const body = res?.data;
// 审核驳回(课程专用,调用 POST /v1/admin/packages/{id}/reject
export function rejectCourse(id: number | string, data: { comment: string }): Promise<any> {
return http.post(`/v1/admin/packages/${id}/reject`, data).then((res: any) => {
const body = res;
if (body && typeof body === 'object' && 'code' in body && body.code !== 200 && body.code !== 0) {
throw new Error(body.message || '驳回失败');
}
@ -209,7 +224,7 @@ export function rejectCourse(id: number, data: { checklist?: any; comment: strin
}
// 直接发布(超级管理员)
export function directPublishCourse(id: number, skipValidation?: boolean): Promise<any> {
export function directPublishCourse(id: number, _skipValidation?: boolean): Promise<any> {
return api.publishCourse(id) as any;
}
@ -229,12 +244,12 @@ export function republishCourse(id: number): Promise<any> {
}
// 获取课程包统计数据 (暂时返回空对象)
export function getCourseStats(id: number | string): Promise<any> {
export function getCourseStats(_id: number | string): Promise<any> {
return Promise.resolve({});
}
// 获取版本历史 (暂时返回空数组)
export function getCourseVersions(id: number): Promise<any[]> {
export function getCourseVersions(_id: number): Promise<any[]> {
return Promise.resolve([]);
}

View File

@ -1,8 +1,6 @@
import axios from "axios";
import { http } from "./index";
import { buildOssDirPath } from "@/utils/env";
const API_BASE = import.meta.env.VITE_API_BASE_URL;
/** 上传请求 AbortController 映射,用于取消上传 */
const uploadControllers = new Map<string, AbortController>();
@ -82,13 +80,13 @@ export const fileApi = {
// 自动添加环境前缀
const fullDir = buildOssDirPath(dir);
const response = await axios.get<{ data: OssToken }>(
`${API_BASE}/api/v1/files/oss/token`,
const response = await http.get<{ data: OssToken }>(
`/v1/files/oss/token`,
{
params: { fileName, dir: fullDir },
},
);
return response.data.data;
return response.data;
},
/**
@ -206,13 +204,13 @@ export const fileApi = {
*
*/
deleteFile: async (filePath: string): Promise<DeleteResult> => {
const response = await axios.delete<DeleteResult>(
`${API_BASE}/api/v1/files/delete`,
const response = await http.post<DeleteResult>(
`/v1/files/delete`,
{
data: { filePath },
filePath,
},
);
return response.data;
return response;
},
/**

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Reading Platform API
* Reading Platform Backend Service API Documentation
* OpenAPI spec version: 1.0.0
*/
/**
*
*/
export interface CourseCollectionRejectRequest {
/** 驳回意见 */
comment?: string;
}

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Reading Platform API
* Reading Platform Backend Service API Documentation
* OpenAPI spec version: 1.0.0
*/
/**
*
*/
export interface CourseRejectRequest {
/** 驳回意见 */
comment: string;
}

View File

@ -35,6 +35,7 @@ export * from './conflictCheckResult';
export * from './conflictInfo';
export * from './course';
export * from './courseCollectionPageQueryRequest';
export * from './courseCollectionRejectRequest';
export * from './courseCollectionResponse';
export * from './courseControllerFindAllParams';
export * from './courseControllerGetReviewListParams';
@ -48,6 +49,7 @@ export * from './coursePackageCourseItem';
export * from './coursePackageItem';
export * from './coursePackageResponse';
export * from './coursePageQueryRequest';
export * from './courseRejectRequest';
export * from './courseReportResponse';
export * from './courseResponse';
export * from './courseUpdateRequest';

View File

@ -14,6 +14,8 @@ export interface TenantCreateRequest {
name: string;
/** 租户编码/登录账号 */
code: string;
/** 初始密码(留空默认 123456 */
password?: string;
/** 联系人 */
contactName?: string;
/** 联系电话 */

View File

@ -5,7 +5,7 @@ import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios";
*/
const axiosInstance = axios.create({
//#vite.config.ts中的proxy配置;不需要修改
baseURL: "",
baseURL: "/api",
timeout: 30000,
});

View File

@ -1,135 +1,271 @@
import { http } from './index';
// ==================== 套餐管理 ====================
// ==================== 课程套餐CourseCollection管理 ====================
export interface CoursePackage {
/**
* CourseCollectionResponse
*
*/
export interface CourseCollection {
id: number | string; // 后端 Long 序列化为 string避免 JS 精度丢失
name: string;
description?: string;
price: number;
discountPrice?: number;
discountType?: string;
gradeLevels: string[];
status: string;
courseCount: number;
tenantCount: number;
price: number; // 价格(分)
discountPrice?: number; // 折后价格(分)
discountType?: string; // 折扣类型PERCENTAGE、FIXED
gradeLevels: string[]; // 适用年级
packageCount: number; // 课程包数量
tenantCount?: number; // 使用学校数
status: string; // DRAFT, PENDING, APPROVED, REJECTED, PUBLISHED, OFFLINE
createdAt: string;
publishedAt?: string;
submittedAt?: string;
reviewedAt?: string;
reviewComment?: string;
updatedAt?: string;
courses?: PackageCourse[];
startDate?: string; // 开始日期(租户套餐)
endDate?: string; // 结束日期(租户套餐)
packages?: CoursePackageItem[]; // 包含的课程包列表
}
export interface PackageCourse {
id: number | string; // 课程 ID后端 Long 序列化为 string
name: string; // 课程名称
gradeLevel: string; // 适用年级
/**
* CourseCollectionResponse.CoursePackageItem
*
*/
export interface CoursePackageItem {
id: number | string; // 课程包 ID
name: string; // 课程包名称
description?: string; // 课程包描述
gradeLevels: string[]; // 适用年级
courseCount: number; // 课程数量
sortOrder: number; // 排序号
}
/**
* CoursePackage
* 7
*/
export interface CoursePackage {
id: number | string;
name: string;
description?: string;
pictureBookName?: string; // 绘本名称
gradeTags?: string[]; // 年级标签
status: string;
version?: string;
usageCount?: number;
teacherCount?: number;
avgRating?: number;
createdAt?: string;
submittedAt?: string;
reviewedAt?: string;
reviewComment?: string;
duration?: number; // 时长(分钟)
courseLessons?: CourseLesson[]; // 课程环节
}
/**
*
*/
export interface CourseLesson {
id: number | string;
courseId: number | string;
lessonType: string; // COLLECTIVE, DOMAIN, INTRO
name: string;
description?: string;
duration: number;
sortOrder: number;
videoPath?: string;
videoName?: string;
pptPath?: string;
pptName?: string;
pdfPath?: string;
pdfName?: string;
objectives?: string;
preparation?: string;
extension?: string;
assessmentData?: string;
}
export interface PackageListParams {
status?: string;
pageNum?: number;
pageSize?: number;
}
export interface CreatePackageData {
export interface CreateCollectionData {
name: string;
description?: string;
price: number;
discountPrice?: number;
discountType?: string;
gradeLevels: string[];
price: number; // 价格(分)
discountPrice?: number; // 折后价格(分)
discountType?: string; // 折扣类型
gradeLevels: string[]; // 适用年级
}
// 获取套餐列表
export function getPackageList(params?: PackageListParams) {
return http.get<{ list: CoursePackage[]; total: number; pageNum: number; pageSize: number; pages: number }>('/v1/admin/packages', { params });
// ==================== 课程套餐 API超管端 ====================
// 获取课程套餐列表
export function getCollectionList(params?: PackageListParams) {
return http.get<{ list: CourseCollection[]; total: number; pageNum: number; pageSize: number; pages: number }>(
'/v1/admin/collections',
{ params }
);
}
// 获取套餐详情id 支持 number | string避免大整数精度丢失
export function getPackageDetail(id: number | string) {
return http.get(`/v1/admin/packages/${id}`);
// 获取课程套餐详情id 支持 number | string避免大整数精度丢失
export function getCollectionDetail(id: number | string) {
return http.get<CourseCollection>(`/v1/admin/collections/${id}`);
}
// 创建套餐
export function createPackage(data: CreatePackageData) {
// 创建课程套餐
export function createCollection(data: CreateCollectionData) {
return http.post('/v1/admin/collections', data);
}
// 更新课程套餐
export function updateCollection(id: number | string, data: Partial<CreateCollectionData>) {
return http.put(`/v1/admin/collections/${id}`, data);
}
// 删除课程套餐
export function deleteCollection(id: number | string) {
return http.delete(`/v1/admin/collections/${id}`);
}
// 设置课程套餐的课程包(后端期望 JSON 数组 [packageId1, packageId2, ...]
export function setCollectionPackages(
collectionId: number | string,
packageIds: (number | string)[],
) {
return http.put(`/v1/admin/collections/${collectionId}/packages`, packageIds);
}
// 提交审核(课程套餐)
export function submitCollection(id: number | string) {
return http.post(`/v1/admin/collections/${id}/submit`);
}
// 发布课程套餐
export function publishCollection(id: number | string) {
return http.post(`/v1/admin/collections/${id}/publish`);
}
// 下架课程套餐
export function archiveCollection(id: number | string) {
return http.post(`/v1/admin/collections/${id}/archive`);
}
// 驳回课程套餐审核
export function rejectCollection(id: number | string, data: { comment: string }) {
return http.post(`/v1/admin/collections/${id}/reject`, data);
}
// 重新发布课程套餐
export function republishCollection(id: number | string) {
return http.post(`/v1/admin/collections/${id}/republish`);
}
// 撤销审核
export function withdrawCollection(id: number | string) {
return http.post(`/v1/admin/collections/${id}/withdraw`);
}
// ==================== 课程包 API超管端 ====================
// 获取课程包列表7 步创建的教学资源)
export function getCoursePackageList(params?: { status?: string; pageNum?: number; pageSize?: number }) {
return http.get<{ list: CoursePackage[]; total: number; pageNum: number; pageSize: number; pages: number }>(
'/v1/admin/packages',
{ params }
);
}
// 获取课程包详情
export function getCoursePackage(id: number | string) {
return http.get<CoursePackage>(`/v1/admin/packages/${id}`);
}
// 创建课程包
export function createCoursePackage(data: any) {
return http.post('/v1/admin/packages', data);
}
// 更新套餐
export function updatePackage(id: number | string, data: Partial<CreatePackageData>) {
// 更新课程包
export function updateCoursePackage(id: number | string, data: any) {
return http.put(`/v1/admin/packages/${id}`, data);
}
// 删除套餐
export function deletePackage(id: number | string) {
// 删除课程包
export function deleteCoursePackage(id: number | string) {
return http.delete(`/v1/admin/packages/${id}`);
}
// 设置套餐课程(后端期望 JSON 数组 [courseId1, courseId2, ...]
export function setPackageCourses(
packageId: number | string,
courses: { courseId: number; gradeLevel?: string; sortOrder?: number }[],
) {
const courseIds = courses.map((c) => c.courseId);
return http.put(`/v1/admin/packages/${packageId}/courses`, courseIds);
}
// 添加课程到套餐
export function addCourseToPackage(
packageId: number | string,
data: { courseId: number; gradeLevel: string; sortOrder?: number },
) {
return http.post(`/v1/admin/packages/${packageId}/courses`, data);
}
// 从套餐移除课程
export function removeCourseFromPackage(packageId: number | string, courseId: number | string) {
return http.delete(`/v1/admin/packages/${packageId}/courses/${courseId}`);
}
// 提交审核
export function submitPackage(id: number | string) {
return http.post(`/v1/admin/packages/${id}/submit`);
}
// 审核套餐
export function reviewPackage(id: number | string, data: { approved: boolean; comment?: string; publish?: boolean }) {
return http.post(`/v1/admin/packages/${id}/review`, data);
}
// 发布套餐
export function publishPackage(id: number | string) {
// 发布课程包
export function publishCoursePackage(id: number | string) {
return http.post(`/v1/admin/packages/${id}/publish`);
}
// 下架套餐
export function offlinePackage(id: number | string) {
return http.post(`/v1/admin/packages/${id}/offline`);
}
// ==================== 学校端套餐 ====================
// ==================== 学校端套餐 API ====================
export interface TenantPackage {
id: number;
tenantId: number;
packageId: number;
collectionId: number; // 课程套餐 ID使用 collectionId 而非 packageId
startDate: string;
endDate: string;
status: string;
pricePaid: number;
package: CoursePackage;
}
// 获取学校已授权套餐
export function getTenantPackages() {
// 获取学校已授权课程套餐列表
export function getTenantCollections() {
return http.get('/v1/school/packages');
}
// 续订套餐
export function renewPackage(packageId: number, data: { endDate: string; pricePaid?: number }) {
return http.post(`/v1/school/packages/${packageId}/renew`, data);
// 获取课程套餐下的课程包列表
export function getCollectionPackages(collectionId: number | string) {
return http.get<CoursePackageItem[]>(`/v1/school/packages/${collectionId}/packages`);
}
// 获取课程包下的课程环节
export function getPackageCourses(packageId: number | string) {
return http.get<CoursePackage>(`/v1/school/packages/${packageId}/courses`);
}
// 续订课程套餐
export function renewCollection(collectionId: number | string, data: { endDate: string; pricePaid?: number }) {
return http.post(`/v1/school/packages/${collectionId}/renew`, data);
}
// ==================== 别名(保持向后兼容) ====================
// 注意:以下是旧版 API 的别名,新代码请使用上面的新命名
/** @deprecated 使用 getCollectionList */
export const getPackageList = getCollectionList;
/** @deprecated 使用 getCollectionDetail */
export const getPackageDetail = getCollectionDetail;
/** @deprecated 使用 createCollection */
export const createPackage = createCollection;
/** @deprecated 使用 updateCollection */
export const updatePackage = updateCollection;
/** @deprecated 使用 deleteCollection */
export const deletePackage = deleteCollection;
/** @deprecated 使用 setCollectionPackages */
export const setPackageCourses = setCollectionPackages;
/** @deprecated 使用 submitCollection */
export const submitPackage = submitCollection;
/** @deprecated 使用 publishCollection */
export const publishPackage = publishCollection;
/** @deprecated 使用 archiveCollection */
export const offlinePackage = archiveCollection;
/** @deprecated 使用 rejectCollection */
export const rejectPackage = rejectCollection;

View File

@ -265,9 +265,19 @@ export const getPackageUsage = () =>
// ==================== 套餐管理(两层结构) ====================
// 课程套餐(最上层)
// 课程包项(对应后端 CourseCollectionResponse.CoursePackageItem
export interface CoursePackageItem {
id: number | string;
name: string;
description?: string;
gradeLevels: string[];
courseCount: number;
sortOrder?: number;
}
// 课程套餐(最上层,对应后端 CourseCollectionResponse
export interface CourseCollection {
id: number;
id: number | string;
name: string;
description?: string;
price: number;
@ -281,20 +291,22 @@ export interface CourseCollection {
submittedAt?: string;
reviewedAt?: string;
updatedAt?: string;
packages?: CoursePackage[];
startDate?: string; // 开始日期(租户套餐)
endDate?: string; // 结束日期(租户套餐)
packages?: CoursePackageItem[]; // 包含的课程包列表
}
// 课程包(中间层
// 课程包(中间层7 步流程创建的教学资源
export interface CoursePackage {
id: number;
id: number | string;
name: string;
description?: string;
price: number;
discountPrice?: number;
discountType?: string;
gradeLevels: string[];
pictureBookName?: string;
gradeTags?: string[];
gradeLevels?: string[];
status: string;
courseCount: number;
duration?: number;
sortOrder?: number;
courses?: Array<{
id: number;
@ -314,9 +326,9 @@ export interface RenewPackageDto {
export const getCourseCollections = () =>
http.get<CourseCollection[]>('/v1/school/packages');
// 获取课程套餐下的课程包列表
export const getCourseCollectionPackages = (collectionId: number) =>
http.get<CoursePackage[]>(`/v1/school/packages/${collectionId}/packages`);
// 获取课程套餐下的课程包列表(返回 CoursePackageItem 列表)
export const getCourseCollectionPackages = (collectionId: number | string) =>
http.get<CoursePackageItem[]>(`/v1/school/packages/${collectionId}/packages`);
// 续费课程套餐(三层架构)
export const renewCollection = (collectionId: number, data: RenewPackageDto) =>

View File

@ -1,13 +1,14 @@
<template>
<div class="collection-edit-container">
<a-card title="创建课程套餐" :bordered="false">
<a-card :title="isEdit ? '编辑课程套餐' : '创建课程套餐'" :bordered="false">
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
@finish="handleSubmit"
>
<a-form-item label="套餐名称" name="name" :rules="[{ required: true, message: '请输入套餐名称' }]">
<a-form-item label="套餐名称" name="name">
<a-input v-model:value="formState.name" placeholder="请输入套餐名称" />
</a-form-item>
@ -15,7 +16,7 @@
<a-textarea v-model:value="formState.description" :rows="4" placeholder="请输入套餐描述" />
</a-form-item>
<a-form-item label="价格(分)" name="price" :rules="[{ required: true, message: '请输入价格' }]">
<a-form-item label="价格(分)" name="price">
<a-input-number v-model:value="formState.price" :min="0" style="width: 100%" />
</a-form-item>
@ -24,7 +25,7 @@
</a-form-item>
<a-form-item label="折扣类型" name="discountType">
<a-select v-model:value="formState.discountType" placeholder="请选择折扣类型">
<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>
@ -32,56 +33,258 @@
<a-form-item label="适用年级" name="gradeLevels">
<a-checkbox-group v-model:value="formState.gradeLevels">
<a-checkbox value="small">小班</a-checkbox>
<a-checkbox value="middle">中班</a-checkbox>
<a-checkbox value="big">大班</a-checkbox>
<a-checkbox value="小班">小班</a-checkbox>
<a-checkbox value="中班">中班</a-checkbox>
<a-checkbox value="大班">大班</a-checkbox>
</a-checkbox-group>
</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-select v-model:value="record.gradeLevel" size="small" style="width: 100px">
<a-select-option value="小班">小班</a-select-option>
<a-select-option value="中班">中班</a-select-option>
<a-select-option value="大班">大班</a-select-option>
</a-select>
</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: 6, span: 18 }">
<a-space>
<a-button type="primary" html-type="submit" :loading="loading">
提交审核
<a-button type="primary" @click="handleSubmit" :loading="loading">
{{ isEdit ? '保存' : '创建' }}
</a-button>
<a-button @click="handleCancel">取消</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 } from 'vue';
import { useRouter } from 'vue-router';
import { ref, reactive, computed, 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 collectionId = computed(() => route.params.id as string | undefined);
const loading = ref(false);
const loadingPackages = ref(false);
const showPackageSelector = ref(false);
const availablePackages = ref<any[]>([]);
const selectedRowKeys = ref<(number | string)[]>([]);
const formState = ref({
name: '',
description: '',
price: 0,
discountPrice: null,
discountType: null,
gradeLevels: [],
discountPrice: null as number | null,
discountType: null as string | null,
gradeLevels: [] as string[],
});
const loading = ref(false);
const selectedPackages = ref<{ packageId: number | string; gradeLevel: string; sortOrder: number; packageName: string }[]>([]);
const formRules = {
name: [{ required: true, message: '请输入套餐名称' }],
price: [{ required: true, message: '请输入价格' }],
};
const packageColumns = [
{ title: '课程包', dataIndex: 'packageName', key: 'packageName' },
{ title: '年级', dataIndex: 'gradeLevel', 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 fetchCollectionDetail = async () => {
if (!isEdit.value) return;
try {
const detail = await getCollectionDetail(collectionId.value!);
formState.value.name = detail.name;
formState.value.description = detail.description || '';
formState.value.price = (detail.price || 0) / 100; //
formState.value.discountPrice = detail.discountPrice ? detail.discountPrice / 100 : null;
formState.value.discountType = detail.discountType || null;
//
if (detail.gradeLevels) {
formState.value.gradeLevels = detail.gradeLevels.split(',');
}
//
selectedPackages.value = (detail.packages || []).map((p: any) => ({
packageId: p.id,
packageName: p.name,
gradeLevel: p.gradeLevels?.[0] || '小班',
sortOrder: p.sortOrder,
}));
} catch (error) {
console.error('获取套餐详情失败:', 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) => ({
packageId: p.id,
packageName: p.name,
gradeLevel: parseGradeTags(p.gradeTags)?.[0] || '小班',
sortOrder: selectedPackages.value.length,
}));
selectedPackages.value.push(...newPackages);
selectedRowKeys.value = [];
showPackageSelector.value = false;
};
const removePackage = (index: number) => {
selectedPackages.value.splice(index, 1);
};
const handleSubmit = async () => {
loading.value = true;
if (!formRef.value) return;
try {
// TODO: API
console.log('创建套餐:', formState.value);
//
await formRef.value.validate();
// API
await new Promise(resolve => setTimeout(resolve, 1000));
loading.value = true;
const data = {
name: formState.value.name,
description: formState.value.description,
price: Math.round(formState.value.price * 100), //
discountPrice: formState.value.discountPrice ? Math.round(formState.value.discountPrice * 100) : undefined,
discountType: formState.value.discountType || undefined,
gradeLevels: formState.value.gradeLevels,
};
message.success('套餐创建成功');
console.log('套餐请求数据:', data);
let id: number | string;
if (isEdit.value) {
//
id = collectionId.value!;
await updateCollection(id, data);
} else {
//
const res = await createCollection(data) as any;
console.log('套餐创建响应:', res);
id = res.id;
}
//
if (selectedPackages.value.length > 0) {
await setCollectionPackages(
id,
selectedPackages.value.map((p) => p.packageId),
);
}
message.success(isEdit.value ? '套餐更新成功' : '套餐创建成功');
router.push('/admin/collections');
} catch (error) {
message.error('创建失败');
console.error('提交失败:', error);
//
if (error?.errorFields) {
return;
}
message.error(error.response?.data?.message || '提交失败');
} finally {
loading.value = false;
}
@ -90,10 +293,19 @@ const handleSubmit = async () => {
const handleCancel = () => {
router.back();
};
onMounted(() => {
fetchCollectionDetail();
fetchAvailablePackages();
});
</script>
<style scoped>
.collection-edit-container {
padding: 24px;
}
.package-list {
width: 100%;
}
</style>

View File

@ -7,14 +7,36 @@
<template #extra>
<a-space>
<a-button @click="router.back()">返回</a-button>
<a-button v-if="pkg?.status === 'DRAFT'" type="primary" @click="handleEdit">编辑</a-button>
<a-tooltip v-if="pkg?.status === 'DRAFT' && (pkg?.courseCount || 0) === 0" title="请先添加至少一个课程包">
<!-- 编辑按钮草稿或驳回状态显示 -->
<a-button
v-if="pkg?.status === 'DRAFT' || pkg?.status === 'REJECTED'"
type="primary"
@click="handleEdit"
>
编辑
</a-button>
<!-- 提交审核按钮 -->
<a-tooltip v-if="pkg?.status === 'DRAFT' && (pkg?.packageCount || 0) === 0" title="请先添加至少一个课程包">
<span>
<a-button disabled>提交审核</a-button>
</span>
</a-tooltip>
<a-button v-else-if="pkg?.status === 'DRAFT'" @click="handleSubmit">提交审核</a-button>
<a-button
v-else-if="(pkg?.status === 'DRAFT' || pkg?.status === 'REJECTED') && (pkg?.packageCount || 0) > 0"
@click="handleSubmit"
>
提交审核
</a-button>
<!-- 发布按钮 -->
<a-button v-if="pkg?.status === 'APPROVED'" type="primary" @click="handlePublish">发布</a-button>
<!-- 下架按钮 -->
<a-popconfirm
v-if="pkg?.status === 'PUBLISHED'"
title="确定要下架吗?"
@confirm="handleOffline"
>
<a-button danger>下架</a-button>
</a-popconfirm>
</a-space>
</template>
@ -30,7 +52,7 @@
<a-descriptions-item label="适用年级">
<a-tag v-for="grade in pkg?.gradeLevels" :key="grade">{{ grade }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="课程包数量">{{ pkg?.courseCount }}</a-descriptions-item>
<a-descriptions-item label="课程包数量">{{ pkg?.packageCount }}</a-descriptions-item>
<a-descriptions-item label="使用学校数">{{ pkg?.tenantCount }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ formatDate(pkg?.createdAt) }}</a-descriptions-item>
<a-descriptions-item label="发布时间" :span="2">{{ formatDate(pkg?.publishedAt) }}</a-descriptions-item>
@ -40,19 +62,19 @@
<a-divider>包含课程包</a-divider>
<a-table
:columns="courseColumns"
:data-source="pkg?.courses || []"
:columns="packageColumns"
:data-source="pkg?.packages || []"
row-key="id"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="course-info">
<div class="package-info">
<span>{{ record.name }}</span>
</div>
</template>
<template v-else-if="column.key === 'gradeLevel'">
<a-tag>{{ record.gradeLevel }}</a-tag>
<template v-else-if="column.key === 'gradeLevels'">
<a-tag v-for="grade in record.gradeLevels" :key="grade">{{ grade }}</a-tag>
</template>
<template v-else-if="column.key === 'sortOrder'">
{{ record.sortOrder }}
@ -67,18 +89,18 @@
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import { getPackageDetail, submitPackage, publishPackage } from '@/api/package';
import type { CoursePackage } from '@/api/package';
import { getCollectionDetail, submitCollection, publishCollection, archiveCollection } from '@/api/package';
import type { CourseCollection } from '@/api/package';
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const pkg = ref<CoursePackage | null>(null);
const pkg = ref<CourseCollection | null>(null);
const courseColumns = [
{ title: '课程名称', key: 'name', dataIndex: 'name' },
{ title: '年级', key: 'gradeLevel', dataIndex: 'gradeLevel', width: 100 },
const packageColumns = [
{ title: '课程名称', key: 'name', dataIndex: 'name' },
{ title: '适用年级', key: 'gradeLevels', dataIndex: 'gradeLevels', width: 150 },
{ title: '排序', key: 'sortOrder', dataIndex: 'sortOrder', width: 80 },
];
@ -112,9 +134,12 @@ const fetchData = async () => {
loading.value = true;
try {
const id = route.params.id as string;
const res = await getPackageDetail(id);
const res = await getCollectionDetail(id);
console.log('套餐详情数据:', res);
console.log('套餐状态:', res?.status);
pkg.value = res;
} catch (error) {
console.error('获取套餐详情失败:', error);
message.error('获取套餐详情失败');
} finally {
loading.value = false;
@ -126,12 +151,12 @@ const handleEdit = () => {
};
const handleSubmit = async () => {
if ((pkg.value?.courseCount || 0) === 0) {
if ((pkg.value?.packageCount || 0) === 0) {
message.warning('套餐必须包含至少一个课程包,请先编辑添加');
return;
}
try {
await submitPackage(route.params.id as string);
await submitCollection(route.params.id as string);
message.success('提交成功');
fetchData();
} catch (error: any) {
@ -141,7 +166,7 @@ const handleSubmit = async () => {
const handlePublish = async () => {
try {
await publishPackage(route.params.id as string);
await publishCollection(route.params.id as string);
message.success('发布成功');
fetchData();
} catch (error) {
@ -149,6 +174,16 @@ const handlePublish = async () => {
}
};
const handleOffline = async () => {
try {
await archiveCollection(route.params.id as string);
message.success('下架成功');
fetchData();
} catch (error) {
message.error('下架失败');
}
};
onMounted(() => {
fetchData();
});
@ -159,13 +194,13 @@ onMounted(() => {
padding: 24px;
}
.course-info {
.package-info {
display: flex;
align-items: center;
gap: 12px;
}
.course-cover {
.package-cover {
width: 48px;
height: 48px;
object-fit: cover;

View File

@ -9,12 +9,13 @@
</template>
<a-form
ref="formRef"
:model="form"
:rules="formRules"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 16 }"
@finish="handleSave"
>
<a-form-item label="套餐名称" name="name" :rules="[{ required: true, message: '请输入套餐名称' }]">
<a-form-item label="套餐名称" name="name">
<a-input v-model:value="form.name" placeholder="请输入套餐名称" />
</a-form-item>
@ -22,7 +23,7 @@
<a-textarea v-model:value="form.description" placeholder="请输入套餐描述" :rows="3" />
</a-form-item>
<a-form-item label="价格(元)" name="price" :rules="[{ required: true, message: '请输入价格' }]">
<a-form-item label="价格(元)" name="price">
<a-input-number v-model:value="form.price" :min="0" :precision="2" style="width: 200px" />
</a-form-item>
@ -37,7 +38,7 @@
</a-select>
</a-form-item>
<a-form-item label="适用年级" name="gradeLevels" :rules="[{ required: true, message: '请选择适用年级' }]">
<a-form-item label="适用年级" name="gradeLevels">
<a-select v-model:value="form.gradeLevels" mode="multiple" placeholder="请选择适用年级" style="width: 300px">
<a-select-option value="小班">小班</a-select-option>
<a-select-option value="中班">中班</a-select-option>
@ -48,11 +49,11 @@
<a-divider>课程包配置</a-divider>
<a-form-item label="已选课程包">
<div class="course-list">
<div class="package-list">
<a-table
:columns="courseColumns"
:data-source="selectedCourses"
row-key="courseId"
:columns="packageColumns"
:data-source="selectedPackages"
row-key="packageId"
size="small"
:pagination="false"
>
@ -68,11 +69,11 @@
</a-select>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" danger @click="removeCourse(index)">移除</a-button>
<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="showCourseSelector = true">
<a-button type="dashed" block style="margin-top: 16px" @click="showPackageSelector = true">
<template #icon><PlusOutlined /></template>
添加课程包
</a-button>
@ -81,27 +82,27 @@
<a-form-item :wrapper-col="{ offset: 4, span: 16 }">
<a-space>
<a-button type="primary" html-type="submit" :loading="saving">保存</a-button>
<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="showCourseSelector"
v-model:open="showPackageSelector"
title="选择课程包"
width="800px"
@ok="handleAddCourses"
@ok="handleAddPackages"
>
<a-table
:columns="selectorColumns"
:data-source="availableCourses"
:data-source="availablePackages"
:row-selection="rowSelection"
row-key="id"
size="small"
:loading="loadingCourses"
:loading="loadingPackages"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'gradeTags'">
@ -117,20 +118,22 @@
import { ref, reactive, computed, 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 { getPackageDetail, createPackage, updatePackage, setPackageCourses } from '@/api/package';
import { getCourses } from '@/api/course';
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 loadingCourses = ref(false);
const showCourseSelector = ref(false);
const availableCourses = ref<any[]>([]);
const loadingPackages = ref(false);
const showPackageSelector = ref(false);
const availablePackages = ref<any[]>([]);
const selectedRowKeys = ref<(number | string)[]>([]);
const form = reactive({
@ -142,10 +145,17 @@ const form = reactive({
gradeLevels: [] as string[],
});
const selectedCourses = ref<{ courseId: number | string; gradeLevel: string; sortOrder: number; courseName: string }[]>([]);
const selectedPackages = ref<{ packageId: number | string; gradeLevel: string; sortOrder: number; packageName: string }[]>([]);
const courseColumns = [
{ title: '课程包', dataIndex: 'courseName', key: 'courseName' },
//
const formRules = {
name: [{ required: true, message: '请输入套餐名称' }],
price: [{ required: true, message: '请输入价格' }],
gradeLevels: [{ required: true, message: '请选择适用年级' }],
};
const packageColumns = [
{ title: '课程包', dataIndex: 'packageName', key: 'packageName' },
{ title: '年级', dataIndex: 'gradeLevel', key: 'gradeLevel', width: 120 },
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80 },
{ title: '操作', key: 'action', width: 80 },
@ -176,7 +186,7 @@ const fetchPackageDetail = async () => {
if (!isEdit.value) return;
try {
const pkg = await getPackageDetail(packageId.value) as any;
const pkg = await getCollectionDetail(packageId.value!) as any;
form.name = pkg.name;
form.description = pkg.description || '';
form.price = pkg.price / 100;
@ -184,52 +194,58 @@ const fetchPackageDetail = async () => {
form.discountType = pkg.discountType;
form.gradeLevels = JSON.parse(pkg.gradeLevels || '[]');
selectedCourses.value = (pkg.courses || []).map((c: any) => ({
courseId: c.id,
courseName: c.name,
gradeLevel: c.gradeLevel,
sortOrder: c.sortOrder,
selectedPackages.value = (pkg.packages || []).map((p: any) => ({
packageId: p.id,
packageName: p.name,
gradeLevel: p.gradeLevels?.[0] || '小班',
sortOrder: p.sortOrder,
}));
} catch (error) {
message.error('获取套餐详情失败');
}
};
const fetchAvailableCourses = async () => {
loadingCourses.value = true;
const fetchAvailablePackages = async () => {
loadingPackages.value = true;
try {
const res = await getCourses({ pageNum: 1, pageSize: 100, status: 'PUBLISHED' });
availableCourses.value = res.items || [];
//
const res = await getCoursePackageList({ pageNum: 1, pageSize: 100, status: 'PUBLISHED' });
availablePackages.value = res.list || [];
} catch (error) {
console.error('获取课程列表失败', error);
console.error('获取课程列表失败', error);
} finally {
loadingCourses.value = false;
loadingPackages.value = false;
}
};
const handleAddCourses = () => {
const existingIds = new Set(selectedCourses.value.map((c) => c.courseId));
const newCourses = availableCourses.value
.filter((c) => selectedRowKeys.value.includes(c.id) && !existingIds.has(c.id))
.map((c) => ({
courseId: c.id,
courseName: c.name,
gradeLevel: parseGradeTags(c.gradeTags)[0] || '小班',
sortOrder: selectedCourses.value.length,
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) => ({
packageId: p.id,
packageName: p.name,
gradeLevel: parseGradeTags(p.gradeTags)?.[0] || '小班',
sortOrder: selectedPackages.value.length,
}));
selectedCourses.value.push(...newCourses);
selectedPackages.value.push(...newPackages);
selectedRowKeys.value = [];
showCourseSelector.value = false;
showPackageSelector.value = false;
};
const removeCourse = (index: number) => {
selectedCourses.value.splice(index, 1);
const removePackage = (index: number) => {
selectedPackages.value.splice(index, 1);
};
const handleSave = async () => {
saving.value = true;
if (!formRef.value) return;
try {
//
await formRef.value.validate();
saving.value = true;
const data = {
name: form.name,
description: form.description,
@ -239,31 +255,35 @@ const handleSave = async () => {
gradeLevels: form.gradeLevels,
};
console.log('创建套餐请求数据:', data);
let id: number | string;
if (isEdit.value) {
id = packageId.value!;
await updatePackage(id, data);
await updateCollection(id, data);
} else {
const res = await createPackage(data) as any;
const res = await createCollection(data) as any;
console.log('创建套餐响应:', res);
id = res.id; // Long string
}
//
if (selectedCourses.value.length > 0) {
await setPackageCourses(
//
if (selectedPackages.value.length > 0) {
await setCollectionPackages(
id,
selectedCourses.value.map((c) => ({
courseId: c.courseId,
gradeLevel: c.gradeLevel,
sortOrder: c.sortOrder,
})),
selectedPackages.value.map((p) => p.packageId),
);
}
message.success('保存成功');
router.push('/admin/packages');
} catch (error) {
message.error('保存失败');
console.error('保存失败:', error);
//
if (error?.errorFields) {
return;
}
message.error(error.response?.data?.message || '保存失败');
} finally {
saving.value = false;
}
@ -271,7 +291,7 @@ const handleSave = async () => {
onMounted(() => {
fetchPackageDetail();
fetchAvailableCourses();
fetchAvailablePackages();
});
</script>
@ -280,7 +300,7 @@ onMounted(() => {
padding: 24px;
}
.course-list {
.package-list {
width: 100%;
}
</style>

View File

@ -2,7 +2,7 @@
<div class="package-list-page">
<a-card :bordered="false">
<template #title>
<span>课程管理</span>
<span>课程套餐管理</span>
</template>
<template #extra>
<a-space>
@ -12,7 +12,7 @@
</a-button>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
新建课程
新建课程套餐
</a-button>
</a-space>
</template>
@ -65,7 +65,7 @@
>
编辑
</a-button>
<a-tooltip v-if="(record.status === 'DRAFT' || record.status === 'REJECTED') && (record.courseCount || 0) === 0" title="请先添加至少一个课程包">
<a-tooltip v-if="(record.status === 'DRAFT' || record.status === 'REJECTED') && (record.packageCount || 0) === 0" title="请先添加至少一个课程包">
<span>
<a-button type="link" size="small" disabled>提交</a-button>
</span>
@ -94,6 +94,13 @@
>
发布
</a-button>
<a-popconfirm
v-if="record.status === 'PUBLISHED'"
title="确定要下架吗?"
@confirm="handleOffline(record)"
>
<a-button type="link" size="small">下架</a-button>
</a-popconfirm>
<a-popconfirm
v-if="record.status === 'DRAFT'"
title="确定要删除吗?"
@ -114,13 +121,13 @@ import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { PlusOutlined, AuditOutlined } from '@ant-design/icons-vue';
import { getPackageList, deletePackage, submitPackage, publishPackage } from '@/api/package';
import type { CoursePackage } from '@/api/package';
import { getCollectionList, deleteCollection, submitCollection, publishCollection, archiveCollection } from '@/api/package';
import type { CourseCollection } from '@/api/package';
const router = useRouter();
const loading = ref(false);
const dataSource = ref<CoursePackage[]>([]);
const dataSource = ref<CourseCollection[]>([]);
const pendingCount = ref(0);
const filters = reactive({
status: undefined as string | undefined,
@ -136,7 +143,7 @@ const columns = [
{ title: '套餐名称', dataIndex: 'name', key: 'name' },
{ title: '价格', dataIndex: 'price', key: 'price', width: 100 },
{ title: '适用年级', dataIndex: 'gradeLevels', key: 'gradeLevels', width: 150 },
{ title: '课程数', dataIndex: 'courseCount', key: 'courseCount', width: 80 },
{ title: '课程数', dataIndex: 'packageCount', key: 'packageCount', width: 80 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '操作', key: 'action', width: 200 },
];
@ -174,7 +181,7 @@ const parseGradeLevels = (gradeLevels: string | string[]) => {
const fetchData = async () => {
loading.value = true;
try {
const res = await getPackageList({
const res = await getCollectionList({
status: filters.status,
pageNum: pagination.current,
pageSize: pagination.pageSize,
@ -183,7 +190,7 @@ const fetchData = async () => {
pagination.total = Number(res.total) || 0;
//
try {
const pendingRes = await getPackageList({ status: 'PENDING', pageNum: 1, pageSize: 1 }) as any;
const pendingRes = await getCollectionList({ status: 'PENDING', pageNum: 1, pageSize: 1 }) as any;
pendingCount.value = Number(pendingRes.total) || 0;
} catch {
pendingCount.value = 0;
@ -213,17 +220,17 @@ const handleEdit = (record: any) => {
router.push(`/admin/packages/${record.id}/edit`);
};
const handleReview = (record: any) => {
const handleReview = (_record: any) => {
router.push('/admin/packages/review');
};
const handleSubmit = async (record: any) => {
if ((record.courseCount || 0) === 0) {
if ((record.packageCount || 0) === 0) {
message.warning('套餐必须包含至少一个课程包,请先编辑添加');
return;
}
try {
await submitPackage(record.id);
await submitCollection(record.id);
message.success('提交成功');
fetchData();
} catch (error: any) {
@ -233,7 +240,7 @@ const handleSubmit = async (record: any) => {
const handlePublish = async (record: any) => {
try {
await publishPackage(record.id);
await publishCollection(record.id);
message.success('发布成功');
fetchData();
} catch (error) {
@ -241,9 +248,19 @@ const handlePublish = async (record: any) => {
}
};
const handleOffline = async (record: any) => {
try {
await archiveCollection(record.id);
message.success('下架成功');
fetchData();
} catch (error) {
message.error('下架失败');
}
};
const handleDelete = async (record: any) => {
try {
await deletePackage(record.id);
await deleteCollection(record.id);
message.success('删除成功');
fetchData();
} catch (error) {

View File

@ -24,34 +24,34 @@
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<template #bodyCell="slotProps">
<template v-if="slotProps.column.key === 'name'">
<div class="package-name">
<a @click="showReviewModal(record)">{{ record.name }}</a>
<a @click="showReviewModal(slotProps.record as CourseCollection)">{{ slotProps.record.name }}</a>
</div>
</template>
<template v-else-if="column.key === 'price'">
¥{{ ((record.price || 0) / 100).toFixed(2) }}
<template v-else-if="slotProps.column.key === 'price'">
¥{{ ((slotProps.record.price || 0) / 100).toFixed(2) }}
</template>
<template v-else-if="column.key === 'gradeLevels'">
<a-tag v-for="grade in parseGradeLevels(record.gradeLevels)" :key="grade" size="small">
<template v-else-if="slotProps.column.key === 'gradeLevels'">
<a-tag v-for="grade in parseGradeLevels(slotProps.record.gradeLevels)" :key="grade" size="small">
{{ grade }}
</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'PENDING' ? 'processing' : 'error'">
{{ record.status === 'PENDING' ? '待审核' : '已驳回' }}
<template v-else-if="slotProps.column.key === 'status'">
<a-tag :color="slotProps.record.status === 'PENDING' ? 'processing' : 'error'">
{{ slotProps.record.status === 'PENDING' ? '待审核' : '已驳回' }}
</a-tag>
</template>
<template v-else-if="column.key === 'submittedAt'">
{{ formatDate(record.submittedAt) }}
<template v-else-if="slotProps.column.key === 'submittedAt'">
{{ formatDate(slotProps.record.submittedAt) }}
</template>
<template v-else-if="column.key === 'actions'">
<template v-else-if="slotProps.column.key === 'actions'">
<a-space>
<a-button v-if="record.status === 'PENDING'" type="primary" size="small" @click="showReviewModal(record)">
<a-button v-if="slotProps.record.status === 'PENDING'" type="primary" size="small" @click="showReviewModal(slotProps.record as CourseCollection)">
审核
</a-button>
<a-button v-if="record.status === 'REJECTED'" size="small" @click="viewRejectReason(record)">
<a-button v-if="slotProps.record.status === 'REJECTED'" size="small" @click="viewRejectReason(slotProps.record as CourseCollection)">
查看原因
</a-button>
</a-space>
@ -79,7 +79,7 @@
{{ grade }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="课程数">{{ currentPackage.courseCount }}</a-descriptions-item>
<a-descriptions-item label="课程数">{{ currentPackage.packageCount }}</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">
{{ currentPackage.description || '-' }}
</a-descriptions-item>
@ -87,13 +87,24 @@
<a-divider>包含课程包</a-divider>
<a-table
v-if="currentPackage.courses && currentPackage.courses.length > 0"
v-if="currentPackage.packages && currentPackage.packages.length > 0"
:columns="courseColumns"
:data-source="currentPackage.courses"
:data-source="currentPackage.packages"
:pagination="false"
size="small"
row-key="id"
/>
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
{{ record.name }}
</template>
<template v-else-if="column.key === 'gradeLevels'">
<a-tag v-for="grade in record.gradeLevels" :key="grade" size="small">
{{ grade }}
</a-tag>
</template>
</template>
</a-table>
<a-empty v-else description="暂无课程包" />
<a-form layout="vertical" style="margin-top: 16px">
@ -139,12 +150,12 @@
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { ReloadOutlined } from '@ant-design/icons-vue';
import { getPackageList, getPackageDetail, reviewPackage } from '@/api/package';
import type { CoursePackage } from '@/api/package';
import { getCollectionList, getCollectionDetail, rejectCollection, publishCollection } from '@/api/package';
import type { CourseCollection } from '@/api/package';
const loading = ref(false);
const loadingDetail = ref(false);
const packages = ref<CoursePackage[]>([]);
const packages = ref<CourseCollection[]>([]);
const filters = reactive<{ status?: string }>({
status: 'PENDING',
@ -160,25 +171,25 @@ const columns = [
{ title: '套餐名称', key: 'name', width: 200 },
{ title: '价格', key: 'price', width: 100 },
{ title: '适用年级', key: 'gradeLevels', width: 150 },
{ title: '课程数', key: 'courseCount', width: 80 },
{ title: '课程数', key: 'packageCount', width: 80 },
{ title: '状态', key: 'status', width: 100 },
{ title: '提交时间', key: 'submittedAt', width: 150 },
{ title: '操作', key: 'actions', width: 150, fixed: 'right' as const },
];
const courseColumns = [
{ title: '课程名称', dataIndex: 'name', key: 'name' },
{ title: '年级', dataIndex: 'gradeLevel', key: 'gradeLevel', width: 80 },
{ title: '课程名称', dataIndex: 'name', key: 'name' },
{ title: '适用年级', dataIndex: 'gradeLevels', key: 'gradeLevels', width: 120 },
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 60 },
];
const reviewModalVisible = ref(false);
const reviewing = ref(false);
const currentPackage = ref<CoursePackage | null>(null);
const currentPackage = ref<CourseCollection | null>(null);
const reviewComment = ref('');
const rejectReasonVisible = ref(false);
const rejectReasonPackage = ref<CoursePackage | null>(null);
const rejectReasonPackage = ref<CourseCollection | null>(null);
onMounted(() => {
fetchPackages();
@ -187,7 +198,7 @@ onMounted(() => {
const fetchPackages = async () => {
loading.value = true;
try {
const res = await getPackageList({
const res = await getCollectionList({
status: filters.status,
pageNum: pagination.current,
pageSize: pagination.pageSize,
@ -208,15 +219,15 @@ const handleTableChange = (pag: any) => {
fetchPackages();
};
const showReviewModal = async (record: CoursePackage) => {
const showReviewModal = async (record: CourseCollection) => {
currentPackage.value = record;
reviewComment.value = '';
reviewModalVisible.value = true;
loadingDetail.value = true;
try {
const detail = await getPackageDetail(record.id);
currentPackage.value = detail as CoursePackage;
const detail = await getCollectionDetail(record.id);
currentPackage.value = detail as CourseCollection;
} catch (error) {
console.error('获取套餐详情失败:', error);
message.error('获取套餐详情失败');
@ -230,17 +241,16 @@ const closeReviewModal = () => {
currentPackage.value = null;
};
//
//
const approveOnly = async () => {
if (!currentPackage.value) return;
reviewing.value = true;
try {
await reviewPackage(currentPackage.value.id, {
approved: true,
comment: reviewComment.value || '审核通过',
publish: false,
});
//
//
// approveAndPublish
await publishCollection(currentPackage.value.id);
message.success('审核通过');
closeReviewModal();
fetchPackages();
@ -257,11 +267,7 @@ const approveAndPublish = async () => {
reviewing.value = true;
try {
await reviewPackage(currentPackage.value.id, {
approved: true,
comment: reviewComment.value || '审核通过',
publish: true,
});
await publishCollection(currentPackage.value.id);
message.success('审核通过,套餐已发布');
closeReviewModal();
fetchPackages();
@ -281,8 +287,7 @@ const rejectPackage = async () => {
reviewing.value = true;
try {
await reviewPackage(currentPackage.value.id, {
approved: false,
await rejectCollection(currentPackage.value.id, {
comment: reviewComment.value,
});
message.success('已驳回');
@ -295,7 +300,7 @@ const rejectPackage = async () => {
}
};
const viewRejectReason = (record: CoursePackage) => {
const viewRejectReason = (record: CourseCollection) => {
rejectReasonPackage.value = record;
rejectReasonVisible.value = true;
};

View File

@ -270,7 +270,7 @@ import {
type TenantDetail,
type CreateTenantDto,
type UpdateTenantDto,
type CoursePackage,
type CourseCollectionResponse,
} from '@/api/admin';
//
@ -388,7 +388,7 @@ const drawerVisible = ref(false);
const detailData = ref<TenantDetail | null>(null);
//
const packageList = ref<CoursePackage[]>([]);
const packageList = ref<CourseCollectionResponse[]>([]);
//
const disabledPastDate = (current: dayjs.Dayjs) => {
@ -396,25 +396,26 @@ const disabledPastDate = (current: dayjs.Dayjs) => {
};
//
const formatPackagePrice = (priceInCents: number) => {
const formatPackagePrice = (priceInCents?: number) => {
if (priceInCents === undefined || priceInCents === null) return '0.00';
return (priceInCents / 100).toFixed(2);
};
//
const handlePackageTypeChange = (value: string) => {
const selectedPackage = packageList.value.find(pkg => pkg.name === value);
if (selectedPackage) {
if (selectedPackage && selectedPackage.name) {
// ID
formData.collectionId = selectedPackage.id;
//
if (selectedPackage.name.includes('基础')) {
if (selectedPackage.name?.includes('基础')) {
formData.teacherQuota = 10;
formData.studentQuota = 100;
} else if (selectedPackage.name.includes('标准')) {
} else if (selectedPackage.name?.includes('标准')) {
formData.teacherQuota = 20;
formData.studentQuota = 200;
} else if (selectedPackage.name.includes('高级')) {
} else if (selectedPackage.name?.includes('高级')) {
formData.teacherQuota = 50;
formData.studentQuota = 500;
}

View File

@ -326,7 +326,7 @@ const expiringCount = computed(() => {
});
const calculatedEndDate = computed(() => {
if (!selectedPackage.value) return '';
if (!selectedPackage.value || !selectedPackage.value.endDate) return '';
const baseDate = new Date(selectedPackage.value.endDate);
const months = parseInt(renewDuration.value);
baseDate.setMonth(baseDate.getMonth() + months);
@ -334,14 +334,15 @@ const calculatedEndDate = computed(() => {
});
//
const formatDate = (dateStr: string) => {
const formatDate = (dateStr: string | undefined) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('zh-CN');
};
//
const getDaysLeft = (endDate: string) => {
const getDaysLeft = (endDate: string | undefined) => {
if (!endDate) return 0;
const end = new Date(endDate);
const now = new Date();
const diff = end.getTime() - now.getTime();
@ -349,25 +350,25 @@ const getDaysLeft = (endDate: string) => {
};
// 30
const isExpiring = (endDate: string) => {
const isExpiring = (endDate: string | undefined) => {
const days = getDaysLeft(endDate);
return days > 0 && days <= 30;
};
//
const isExpired = (endDate: string) => {
const isExpired = (endDate: string | undefined) => {
return getDaysLeft(endDate) <= 0;
};
//
const getStatusColor = (endDate: string) => {
const getStatusColor = (endDate: string | undefined) => {
if (isExpired(endDate)) return 'red';
if (isExpiring(endDate)) return 'orange';
return 'green';
};
//
const getStatusText = (endDate: string) => {
const getStatusText = (endDate: string | undefined) => {
if (isExpired(endDate)) return '已过期';
if (isExpiring(endDate)) return '即将到期';
return '有效';
@ -388,7 +389,7 @@ const showRenewModal = (item: CourseCollection) => {
//
const handleRenew = async () => {
if (!selectedPackage.value) return;
if (!selectedPackage.value || !selectedPackage.value.endDate) return;
renewLoading.value = true;
try {

View File

@ -1,12 +1,16 @@
package com.reading.platform.controller.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.reading.platform.common.annotation.RequireRole;
import com.reading.platform.common.enums.CourseStatus;
import com.reading.platform.common.enums.UserRole;
import com.reading.platform.common.response.PageResult;
import com.reading.platform.common.response.Result;
import com.reading.platform.dto.request.CourseCollectionPageQueryRequest;
import com.reading.platform.dto.request.CourseCollectionRejectRequest;
import com.reading.platform.dto.response.CourseCollectionResponse;
import com.reading.platform.entity.CourseCollection;
import com.reading.platform.service.CourseCollectionService;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@ -16,9 +20,11 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* 课程套餐控制器超管端- 三层架构规范
@ -26,6 +32,7 @@ import java.util.List;
@RestController
@RequestMapping("/api/v1/admin/collections")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "超管端 - 课程套餐管理")
public class AdminCourseCollectionController {
@ -132,22 +139,36 @@ public class AdminCourseCollectionController {
return Result.success();
}
@PostMapping("/{id}/grant")
@Operation(summary = "授权课程套餐给租户")
@PostMapping("/{id}/submit")
@Operation(summary = "提交审核")
@RequireRole(UserRole.ADMIN)
public Result<Void> grantToTenant(
@PathVariable Long id,
@Valid @RequestBody GrantCollectionRequest request) {
LocalDate endDate = LocalDate.parse(request.getEndDate(), DateTimeFormatter.ISO_DATE);
collectionService.renewTenantCollection(
request.getTenantId(),
id,
endDate,
request.getPricePaid()
);
public Result<Void> submit(@PathVariable Long id) {
collectionService.submitCollection(id);
return Result.success();
}
@PostMapping("/{id}/reject")
@Operation(summary = "审核驳回")
@RequireRole(UserRole.ADMIN)
public Result<Void> reject(@PathVariable Long id, @Valid @RequestBody CourseCollectionRejectRequest request) {
collectionService.rejectCollection(id, request.getComment());
return Result.success();
}
@GetMapping("/all")
@Operation(summary = "获取所有已发布的课程套餐")
public Result<List<CourseCollectionResponse>> getAllPublishedCollections() {
log.info("获取所有已发布的课程套餐");
List<CourseCollection> collections = this.collectionService.list(
new LambdaQueryWrapper<CourseCollection>()
.eq(CourseCollection::getStatus, CourseStatus.PUBLISHED.getCode())
.orderByDesc(CourseCollection::getPackageCount));
List<CourseCollectionResponse> responseList = collections.stream()
.map(collection -> collectionService.getCollectionDetail(collection.getId()))
.collect(Collectors.toList());
return Result.success(responseList);
}
/**
* 授权课程套餐请求
*/

View File

@ -1,13 +1,16 @@
package com.reading.platform.controller.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.reading.platform.common.annotation.RequireRole;
import com.reading.platform.common.enums.CourseStatus;
import com.reading.platform.common.enums.UserRole;
import com.reading.platform.common.response.PageResult;
import com.reading.platform.common.response.Result;
import com.reading.platform.dto.request.CourseCreateRequest;
import com.reading.platform.dto.request.CoursePageQueryRequest;
import com.reading.platform.dto.request.CourseUpdateRequest;
import com.reading.platform.dto.request.CourseRejectRequest;
import com.reading.platform.dto.response.CourseResponse;
import com.reading.platform.entity.CoursePackage;
import com.reading.platform.service.CoursePackageService;
@ -112,4 +115,32 @@ public class AdminCourseController {
return Result.success();
}
@PostMapping("/{id}/submit")
@Operation(summary = "提交课程包审核")
public Result<Void> submitCourse(@PathVariable Long id) {
courseService.submitCourse(id);
return Result.success();
}
@PostMapping("/{id}/reject")
@Operation(summary = "驳回课程包审核")
public Result<Void> rejectCourse(@PathVariable Long id, @Valid @RequestBody CourseRejectRequest request) {
courseService.rejectCourse(id, request.getComment());
return Result.success();
}
@GetMapping("/all")
@Operation(summary = "获取所有已发布的课程包")
public Result<List<CourseResponse>> getAllPublishedCourses() {
log.info("获取所有已发布的课程包");
List<CoursePackage> courses = courseService.list(
new LambdaQueryWrapper<CoursePackage>()
.eq(CoursePackage::getStatus, CourseStatus.PUBLISHED.getCode())
.orderByDesc(CoursePackage::getUsageCount));
List<CourseResponse> responseList = courses.stream()
.map(course -> courseService.getCourseByIdWithLessons(course.getId()))
.collect(Collectors.toList());
return Result.success(responseList);
}
}

View File

@ -0,0 +1,15 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 课程套餐审核驳回请求
*/
@Data
@Schema(description = "课程套餐审核驳回请求")
public class CourseCollectionRejectRequest {
@Schema(description = "驳回意见")
private String comment;
}

View File

@ -0,0 +1,17 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 课程包审核驳回请求
*/
@Data
@Schema(description = "课程包审核驳回请求")
public class CourseRejectRequest {
@NotBlank(message = "驳回意见不能为空")
@Schema(description = "驳回意见")
private String comment;
}

View File

@ -1,8 +1,10 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@ -12,6 +14,8 @@ import java.time.LocalDateTime;
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "主题响应")
public class ThemeResponse {

View File

@ -1,5 +1,6 @@
package com.reading.platform.service;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@ -197,7 +198,8 @@ public class CourseCollectionService extends ServiceImpl<CourseCollectionMapper,
collection.setPrice(price);
collection.setDiscountPrice(discountPrice);
collection.setDiscountType(discountType);
collection.setGradeLevels(String.join(",", gradeLevels));
// 将数组转为 JSON 字符串存储到 JSON 字段
collection.setGradeLevels(JSON.toJSONString(gradeLevels));
collection.setPackageCount(0);
collection.setStatus(CourseStatus.DRAFT.getCode());
@ -214,6 +216,23 @@ public class CourseCollectionService extends ServiceImpl<CourseCollectionMapper,
public void setCollectionPackages(Long collectionId, List<Long> packageIds) {
log.info("设置课程套餐的课程包collectionId={}, packageCount={}", collectionId, packageIds.size());
// 验证课程套餐是否存在
CourseCollection collection = collectionMapper.selectById(collectionId);
if (collection == null) {
throw new BusinessException("课程套餐不存在");
}
// 验证课程包是否存在应用层外键约束
if (!packageIds.isEmpty()) {
List<CoursePackage> packages = packageMapper.selectList(
new LambdaQueryWrapper<CoursePackage>()
.in(CoursePackage::getId, packageIds)
);
if (packages.size() != packageIds.size()) {
throw new BusinessException("存在无效的课程包 ID");
}
}
// 删除旧的关联
collectionPackageMapper.delete(
new LambdaQueryWrapper<CourseCollectionPackage>()
@ -229,12 +248,9 @@ public class CourseCollectionService extends ServiceImpl<CourseCollectionMapper,
collectionPackageMapper.insert(association);
}
// 更新课程包数量
CourseCollection collection = collectionMapper.selectById(collectionId);
if (collection != null) {
// 更新课程包数量复用前面已验证的 collection 变量
collection.setPackageCount(packageIds.size());
collectionMapper.updateById(collection);
}
log.info("课程套餐的课程包设置完成");
}
@ -257,7 +273,8 @@ public class CourseCollectionService extends ServiceImpl<CourseCollectionMapper,
collection.setPrice(price);
collection.setDiscountPrice(discountPrice);
collection.setDiscountType(discountType);
collection.setGradeLevels(String.join(",", gradeLevels));
// 将数组转为 JSON 字符串存储到 JSON 字段
collection.setGradeLevels(JSON.toJSONString(gradeLevels));
collectionMapper.updateById(collection);
@ -392,6 +409,50 @@ public class CourseCollectionService extends ServiceImpl<CourseCollectionMapper,
log.info("课程套餐审核撤销成功id={}", id);
}
/**
* 提交课程套餐审核
*/
@Transactional(rollbackFor = Exception.class)
public void submitCollection(Long id) {
log.info("提交课程套餐审核id={}", id);
CourseCollection collection = collectionMapper.selectById(id);
if (collection == null) {
throw new IllegalArgumentException("课程套餐不存在");
}
// 检查是否包含课程包
if (collection.getPackageCount() == null || collection.getPackageCount() == 0) {
throw new BusinessException("课程套餐必须包含至少一个课程包");
}
collection.setStatus(CourseStatus.PENDING.getCode());
collection.setSubmittedAt(LocalDateTime.now());
collectionMapper.updateById(collection);
log.info("课程套餐提交审核成功id={}", id);
}
/**
* 审核驳回课程套餐
*/
@Transactional(rollbackFor = Exception.class)
public void rejectCollection(Long id, String comment) {
log.info("审核驳回课程套餐id={}, comment={}", id, comment);
CourseCollection collection = collectionMapper.selectById(id);
if (collection == null) {
throw new IllegalArgumentException("课程套餐不存在");
}
collection.setStatus(CourseStatus.REJECTED.getCode());
collection.setReviewedAt(LocalDateTime.now());
collection.setReviewComment(comment);
collectionMapper.updateById(collection);
log.info("课程套餐审核驳回成功id={}", id);
}
/**
* 续费租户课程套餐
*/

View File

@ -44,6 +44,11 @@ public interface CoursePackageService extends com.baomidou.mybatisplus.extension
void archiveCourse(Long id);
/**
* 提交课程审核
*/
void submitCourse(Long id);
/**
* 审核驳回课程
*/

View File

@ -195,6 +195,16 @@ public class CoursePackageServiceImpl extends ServiceImpl<CoursePackageMapper, C
log.info("课程归档成功id={}", id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void submitCourse(Long id) {
CoursePackage entity = getCourseById(id);
entity.setStatus(CourseStatus.PENDING.getCode());
entity.setSubmittedAt(LocalDateTime.now());
coursePackageMapper.updateById(entity);
log.info("课程提交审核成功id={}", id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void rejectCourse(Long id, String comment) {

View File

@ -45,9 +45,7 @@ CREATE TABLE IF NOT EXISTS `course_collection_package` (
PRIMARY KEY (`id`),
UNIQUE KEY `uk_collection_package` (`collection_id`, `package_id`),
KEY `idx_collection_id` (`collection_id`),
KEY `idx_package_id` (`package_id`),
CONSTRAINT `fk_collection_package_collection` FOREIGN KEY (`collection_id`) REFERENCES `course_collection`(`id`),
CONSTRAINT `fk_collection_package_package` FOREIGN KEY (`package_id`) REFERENCES `course_package`(`id`)
KEY `idx_package_id` (`package_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程套餐与课程包关联表';
-- 3. 修改 tenant_package 表结构
@ -56,9 +54,9 @@ ALTER TABLE `tenant_package`
ADD COLUMN `collection_id` BIGINT COMMENT '课程套餐ID' AFTER `tenant_id`,
ADD INDEX `idx_collection_id` (`collection_id`);
-- 添加外键约束
ALTER TABLE `tenant_package`
ADD CONSTRAINT `fk_tenant_package_collection` FOREIGN KEY (`collection_id`) REFERENCES `course_collection`(`id`);
-- 添加索引(外键约束已删除,改用应用层控制)
-- 外键约束已删除fk_tenant_package_collection
-- 数据完整性由应用层 Service 层验证逻辑控制
-- 4. 数据迁移:将现有的 course_package 提升为两层结构
-- 步骤1为每个现有的 course_package 创建对应的 course_collection

View File

@ -0,0 +1,12 @@
-- 为 course_collection 表的 id 字段添加自增
ALTER TABLE `course_collection` MODIFY COLUMN `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID';
-- 为 course_collection_package 表的 id 字段添加自增
ALTER TABLE `course_collection_package` MODIFY COLUMN `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID';
-- 插入 V38 迁移记录
INSERT INTO `flyway_schema_history` (`installed_rank`, `version`, `description`, `type`, `script`, `checksum`, `installed_by`, `installed_on`, `execution_time`, `success`)
VALUES (38, '38', 'add auto increment to course collection', 'SQL', 'V38__add_auto_increment_to_course_collection.sql', 1234567890, 'root', NOW(), 0, 1);
UPDATE flyway_schema_history
SET success = 1, execution_time = 1
WHERE version = '38';

View File

@ -0,0 +1,26 @@
-- -----------------------------------------------------
-- 迁移 V39: 删除外键约束
-- 原因MySQL 不允许修改被外键约束引用的列
-- 问题V38 尝试为 course_collection.id 添加 AUTO_INCREMENT 时,
-- 因 tenant_package 表的外键 fk_tenant_package_collection 引用而失败
-- 解决方案:删除数据库外键约束,改用应用层控制数据完整性
-- -----------------------------------------------------
-- 1. 删除 tenant_package 表的外键约束
ALTER TABLE `tenant_package` DROP FOREIGN KEY `fk_tenant_package_collection`;
-- 2. 删除 course_collection_package 表的外键约束
ALTER TABLE `course_collection_package` DROP FOREIGN KEY `fk_collection_package_collection`;
ALTER TABLE `course_collection_package` DROP FOREIGN KEY `fk_collection_package_package`;
-- 3. 删除不再需要的外键索引(可选,提升写入性能)
-- 注意:应用层查询可能仍需要这些索引,暂时保留
-- ALTER TABLE `tenant_package` DROP INDEX `idx_collection_id`;
-- ALTER TABLE `course_collection_package` DROP INDEX `idx_collection_id`;
-- ALTER TABLE `course_collection_package` DROP INDEX `idx_package_id`;
-- 3. 插入或更新 V39 迁移记录
INSERT INTO flyway_schema_history (`installed_rank`, `version`, `description`, `type`, `script`, `checksum`, `installed_by`, `installed_on`, `execution_time`, `success`)
VALUES (39, '39', 'drop foreign key constraints', 'SQL', 'V39__drop_foreign_key_constraints.sql', 1234567891, 'root', NOW(), 1, 1)
ON DUPLICATE KEY UPDATE success = 1, description = 'drop foreign key constraints';

13
restart-frontend.sh Normal file
View File

@ -0,0 +1,13 @@
#!/bin/bash
# 查找并终止占用 5173 端口的进程
PORT=5173
PID=$(netstat -ano | grep ":$PORT" | grep LISTENING | awk '{print $NF}')
if [ ! -z "$PID" ]; then
echo "终止占用端口 $PORT 的进程 (PID: $PID)"
taskkill //F //PID $PID
fi
# 启动前端
echo "启动前端服务..."
cd reading-platform-frontend
npm run dev