Compare commits

..

No commits in common. "deb8431910bb76578071b84fffdf8bb2ebdcd7e2" and "10936b7a78be9ead06a9bc3d3651b902712cd3a3" have entirely different histories.

35 changed files with 1199 additions and 1913 deletions

View File

@ -753,89 +753,3 @@ npm run test:e2e:ui
*本规范最后更新于 2026-03-18* *本规范最后更新于 2026-03-18*
*技术栈:统一使用 Spring Boot (Java) 后端* *技术栈:统一使用 Spring Boot (Java) 后端*
*JDK 版本17必须* *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

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

1
.gitignore vendored
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +0,0 @@
/**
* 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

@ -1,15 +0,0 @@
/**
* 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,7 +35,6 @@ export * from './conflictCheckResult';
export * from './conflictInfo'; export * from './conflictInfo';
export * from './course'; export * from './course';
export * from './courseCollectionPageQueryRequest'; export * from './courseCollectionPageQueryRequest';
export * from './courseCollectionRejectRequest';
export * from './courseCollectionResponse'; export * from './courseCollectionResponse';
export * from './courseControllerFindAllParams'; export * from './courseControllerFindAllParams';
export * from './courseControllerGetReviewListParams'; export * from './courseControllerGetReviewListParams';
@ -49,7 +48,6 @@ export * from './coursePackageCourseItem';
export * from './coursePackageItem'; export * from './coursePackageItem';
export * from './coursePackageResponse'; export * from './coursePackageResponse';
export * from './coursePageQueryRequest'; export * from './coursePageQueryRequest';
export * from './courseRejectRequest';
export * from './courseReportResponse'; export * from './courseReportResponse';
export * from './courseResponse'; export * from './courseResponse';
export * from './courseUpdateRequest'; export * from './courseUpdateRequest';

View File

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

View File

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

View File

@ -1,271 +1,135 @@
import { http } from './index'; import { http } from './index';
// ==================== 课程套餐CourseCollection管理 ==================== // ==================== 套餐管理 ====================
/** export interface CoursePackage {
* CourseCollectionResponse
*
*/
export interface CourseCollection {
id: number | string; // 后端 Long 序列化为 string避免 JS 精度丢失 id: number | string; // 后端 Long 序列化为 string避免 JS 精度丢失
name: string; name: string;
description?: string; description?: string;
price: number; // 价格(分) price: number;
discountPrice?: number; // 折后价格(分) discountPrice?: number;
discountType?: string; // 折扣类型PERCENTAGE、FIXED discountType?: string;
gradeLevels: string[]; // 适用年级 gradeLevels: string[];
packageCount: number; // 课程包数量 status: string;
tenantCount?: number; // 使用学校数 courseCount: number;
status: string; // DRAFT, PENDING, APPROVED, REJECTED, PUBLISHED, OFFLINE tenantCount: number;
createdAt: string; createdAt: string;
publishedAt?: string; publishedAt?: string;
submittedAt?: string; submittedAt?: string;
reviewedAt?: string; reviewedAt?: string;
reviewComment?: string; reviewComment?: string;
updatedAt?: string; updatedAt?: string;
startDate?: string; // 开始日期(租户套餐) courses?: PackageCourse[];
endDate?: string; // 结束日期(租户套餐)
packages?: CoursePackageItem[]; // 包含的课程包列表
} }
/** export interface PackageCourse {
* CourseCollectionResponse.CoursePackageItem id: number | string; // 课程 ID后端 Long 序列化为 string
* name: string; // 课程名称
*/ gradeLevel: string; // 适用年级
export interface CoursePackageItem {
id: number | string; // 课程包 ID
name: string; // 课程包名称
description?: string; // 课程包描述
gradeLevels: string[]; // 适用年级
courseCount: number; // 课程数量
sortOrder: 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 { export interface PackageListParams {
status?: string; status?: string;
pageNum?: number; pageNum?: number;
pageSize?: number; pageSize?: number;
} }
export interface CreateCollectionData { export interface CreatePackageData {
name: string; name: string;
description?: string; description?: string;
price: number; // 价格(分) price: number;
discountPrice?: number; // 折后价格(分) discountPrice?: number;
discountType?: string; // 折扣类型 discountType?: string;
gradeLevels: string[]; // 适用年级 gradeLevels: string[];
} }
// ==================== 课程套餐 API超管端 ==================== // 获取套餐列表
export function getPackageList(params?: PackageListParams) {
// 获取课程套餐列表 return http.get<{ list: CoursePackage[]; total: number; pageNum: number; pageSize: number; pages: number }>('/v1/admin/packages', { params });
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避免大整数精度丢失 // 获取套餐详情id 支持 number | string避免大整数精度丢失
export function getCollectionDetail(id: number | string) { export function getPackageDetail(id: number | string) {
return http.get<CourseCollection>(`/v1/admin/collections/${id}`); return http.get(`/v1/admin/packages/${id}`);
} }
// 创建课程套餐 // 创建套餐
export function createCollection(data: CreateCollectionData) { export function createPackage(data: CreatePackageData) {
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); return http.post('/v1/admin/packages', data);
} }
// 更新课程包 // 更新套餐
export function updateCoursePackage(id: number | string, data: any) { export function updatePackage(id: number | string, data: Partial<CreatePackageData>) {
return http.put(`/v1/admin/packages/${id}`, data); return http.put(`/v1/admin/packages/${id}`, data);
} }
// 删除课程包 // 删除套餐
export function deleteCoursePackage(id: number | string) { export function deletePackage(id: number | string) {
return http.delete(`/v1/admin/packages/${id}`); return http.delete(`/v1/admin/packages/${id}`);
} }
// 发布课程包 // 设置套餐课程(后端期望 JSON 数组 [courseId1, courseId2, ...]
export function publishCoursePackage(id: number | string) { 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) {
return http.post(`/v1/admin/packages/${id}/publish`); return http.post(`/v1/admin/packages/${id}/publish`);
} }
// ==================== 学校端套餐 API ==================== // 下架套餐
export function offlinePackage(id: number | string) {
return http.post(`/v1/admin/packages/${id}/offline`);
}
// ==================== 学校端套餐 ====================
export interface TenantPackage { export interface TenantPackage {
id: number; id: number;
tenantId: number; tenantId: number;
collectionId: number; // 课程套餐 ID使用 collectionId 而非 packageId packageId: number;
startDate: string; startDate: string;
endDate: string; endDate: string;
status: string; status: string;
pricePaid: number; pricePaid: number;
package: CoursePackage;
} }
// 获取学校已授权课程套餐列表 // 获取学校已授权套餐
export function getTenantCollections() { export function getTenantPackages() {
return http.get('/v1/school/packages'); return http.get('/v1/school/packages');
} }
// 获取课程套餐下的课程包列表 // 续订套餐
export function getCollectionPackages(collectionId: number | string) { export function renewPackage(packageId: number, data: { endDate: string; pricePaid?: number }) {
return http.get<CoursePackageItem[]>(`/v1/school/packages/${collectionId}/packages`); return http.post(`/v1/school/packages/${packageId}/renew`, data);
} }
// 获取课程包下的课程环节
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,19 +265,9 @@ 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 { export interface CourseCollection {
id: number | string; id: number;
name: string; name: string;
description?: string; description?: string;
price: number; price: number;
@ -291,22 +281,20 @@ export interface CourseCollection {
submittedAt?: string; submittedAt?: string;
reviewedAt?: string; reviewedAt?: string;
updatedAt?: string; updatedAt?: string;
startDate?: string; // 开始日期(租户套餐) packages?: CoursePackage[];
endDate?: string; // 结束日期(租户套餐)
packages?: CoursePackageItem[]; // 包含的课程包列表
} }
// 课程包(中间层7 步流程创建的教学资源 // 课程包(中间层
export interface CoursePackage { export interface CoursePackage {
id: number | string; id: number;
name: string; name: string;
description?: string; description?: string;
pictureBookName?: string; price: number;
gradeTags?: string[]; discountPrice?: number;
gradeLevels?: string[]; discountType?: string;
gradeLevels: string[];
status: string; status: string;
courseCount: number; courseCount: number;
duration?: number;
sortOrder?: number; sortOrder?: number;
courses?: Array<{ courses?: Array<{
id: number; id: number;
@ -326,9 +314,9 @@ export interface RenewPackageDto {
export const getCourseCollections = () => export const getCourseCollections = () =>
http.get<CourseCollection[]>('/v1/school/packages'); http.get<CourseCollection[]>('/v1/school/packages');
// 获取课程套餐下的课程包列表(返回 CoursePackageItem 列表) // 获取课程套餐下的课程包列表
export const getCourseCollectionPackages = (collectionId: number | string) => export const getCourseCollectionPackages = (collectionId: number) =>
http.get<CoursePackageItem[]>(`/v1/school/packages/${collectionId}/packages`); http.get<CoursePackage[]>(`/v1/school/packages/${collectionId}/packages`);
// 续费课程套餐(三层架构) // 续费课程套餐(三层架构)
export const renewCollection = (collectionId: number, data: RenewPackageDto) => export const renewCollection = (collectionId: number, data: RenewPackageDto) =>

View File

@ -1,14 +1,13 @@
<template> <template>
<div class="collection-edit-container"> <div class="collection-edit-container">
<a-card :title="isEdit ? '编辑课程套餐' : '创建课程套餐'" :bordered="false"> <a-card title="创建课程套餐" :bordered="false">
<a-form <a-form
ref="formRef"
:model="formState" :model="formState"
:rules="formRules"
:label-col="{ span: 6 }" :label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }" :wrapper-col="{ span: 18 }"
@finish="handleSubmit"
> >
<a-form-item label="套餐名称" name="name"> <a-form-item label="套餐名称" name="name" :rules="[{ required: true, message: '请输入套餐名称' }]">
<a-input v-model:value="formState.name" placeholder="请输入套餐名称" /> <a-input v-model:value="formState.name" placeholder="请输入套餐名称" />
</a-form-item> </a-form-item>
@ -16,7 +15,7 @@
<a-textarea v-model:value="formState.description" :rows="4" placeholder="请输入套餐描述" /> <a-textarea v-model:value="formState.description" :rows="4" placeholder="请输入套餐描述" />
</a-form-item> </a-form-item>
<a-form-item label="价格(分)" name="price"> <a-form-item label="价格(分)" name="price" :rules="[{ required: true, message: '请输入价格' }]">
<a-input-number v-model:value="formState.price" :min="0" style="width: 100%" /> <a-input-number v-model:value="formState.price" :min="0" style="width: 100%" />
</a-form-item> </a-form-item>
@ -25,7 +24,7 @@
</a-form-item> </a-form-item>
<a-form-item label="折扣类型" name="discountType"> <a-form-item label="折扣类型" name="discountType">
<a-select v-model:value="formState.discountType" placeholder="请选择折扣类型" allowClear> <a-select v-model:value="formState.discountType" placeholder="请选择折扣类型">
<a-select-option value="PERCENTAGE">百分比</a-select-option> <a-select-option value="PERCENTAGE">百分比</a-select-option>
<a-select-option value="FIXED">固定金额</a-select-option> <a-select-option value="FIXED">固定金额</a-select-option>
</a-select> </a-select>
@ -33,258 +32,56 @@
<a-form-item label="适用年级" name="gradeLevels"> <a-form-item label="适用年级" name="gradeLevels">
<a-checkbox-group v-model:value="formState.gradeLevels"> <a-checkbox-group v-model:value="formState.gradeLevels">
<a-checkbox value="小班">小班</a-checkbox> <a-checkbox value="small">小班</a-checkbox>
<a-checkbox value="中班">中班</a-checkbox> <a-checkbox value="middle">中班</a-checkbox>
<a-checkbox value="大班">大班</a-checkbox> <a-checkbox value="big">大班</a-checkbox>
</a-checkbox-group> </a-checkbox-group>
</a-form-item> </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-form-item :wrapper-col="{ offset: 6, span: 18 }">
<a-space> <a-space>
<a-button type="primary" @click="handleSubmit" :loading="loading"> <a-button type="primary" html-type="submit" :loading="loading">
{{ isEdit ? '保存' : '创建' }} 提交审核
</a-button> </a-button>
<a-button @click="handleCancel">取消</a-button> <a-button @click="handleCancel">取消</a-button>
</a-space> </a-space>
</a-form-item> </a-form-item>
</a-form> </a-form>
</a-card> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'; import { ref } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue'; 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 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({ const formState = ref({
name: '', name: '',
description: '', description: '',
price: 0, price: 0,
discountPrice: null as number | null, discountPrice: null,
discountType: null as string | null, discountType: null,
gradeLevels: [] as string[], gradeLevels: [],
}); });
const selectedPackages = ref<{ packageId: number | string; gradeLevel: string; sortOrder: number; packageName: string }[]>([]); const loading = ref(false);
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 () => { const handleSubmit = async () => {
if (!formRef.value) return; loading.value = true;
try { try {
// // TODO: API
await formRef.value.validate(); console.log('创建套餐:', formState.value);
loading.value = true; // API
const data = { await new Promise(resolve => setTimeout(resolve, 1000));
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,
};
console.log('套餐请求数据:', data); message.success('套餐创建成功');
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'); router.push('/admin/collections');
} catch (error) { } catch (error) {
console.error('提交失败:', error); message.error('创建失败');
//
if (error?.errorFields) {
return;
}
message.error(error.response?.data?.message || '提交失败');
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -293,19 +90,10 @@ const handleSubmit = async () => {
const handleCancel = () => { const handleCancel = () => {
router.back(); router.back();
}; };
onMounted(() => {
fetchCollectionDetail();
fetchAvailablePackages();
});
</script> </script>
<style scoped> <style scoped>
.collection-edit-container { .collection-edit-container {
padding: 24px; padding: 24px;
} }
.package-list {
width: 100%;
}
</style> </style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,12 @@
package com.reading.platform.controller.admin; package com.reading.platform.controller.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.reading.platform.common.annotation.RequireRole; 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.enums.UserRole;
import com.reading.platform.common.response.PageResult; import com.reading.platform.common.response.PageResult;
import com.reading.platform.common.response.Result; import com.reading.platform.common.response.Result;
import com.reading.platform.dto.request.CourseCollectionPageQueryRequest; 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.dto.response.CourseCollectionResponse;
import com.reading.platform.entity.CourseCollection;
import com.reading.platform.service.CourseCollectionService; import com.reading.platform.service.CourseCollectionService;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@ -20,11 +16,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.Data; import lombok.Data;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* 课程套餐控制器超管端- 三层架构规范 * 课程套餐控制器超管端- 三层架构规范
@ -32,7 +26,6 @@ import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/v1/admin/collections") @RequestMapping("/api/v1/admin/collections")
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
@Tag(name = "超管端 - 课程套餐管理") @Tag(name = "超管端 - 课程套餐管理")
public class AdminCourseCollectionController { public class AdminCourseCollectionController {
@ -139,36 +132,22 @@ public class AdminCourseCollectionController {
return Result.success(); return Result.success();
} }
@PostMapping("/{id}/submit") @PostMapping("/{id}/grant")
@Operation(summary = "提交审核") @Operation(summary = "授权课程套餐给租户")
@RequireRole(UserRole.ADMIN) @RequireRole(UserRole.ADMIN)
public Result<Void> submit(@PathVariable Long id) { public Result<Void> grantToTenant(
collectionService.submitCollection(id); @PathVariable Long id,
@Valid @RequestBody GrantCollectionRequest request) {
LocalDate endDate = LocalDate.parse(request.getEndDate(), DateTimeFormatter.ISO_DATE);
collectionService.renewTenantCollection(
request.getTenantId(),
id,
endDate,
request.getPricePaid()
);
return Result.success(); 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,16 +1,13 @@
package com.reading.platform.controller.admin; package com.reading.platform.controller.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.reading.platform.common.annotation.RequireRole; 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.enums.UserRole;
import com.reading.platform.common.response.PageResult; import com.reading.platform.common.response.PageResult;
import com.reading.platform.common.response.Result; import com.reading.platform.common.response.Result;
import com.reading.platform.dto.request.CourseCreateRequest; import com.reading.platform.dto.request.CourseCreateRequest;
import com.reading.platform.dto.request.CoursePageQueryRequest; import com.reading.platform.dto.request.CoursePageQueryRequest;
import com.reading.platform.dto.request.CourseUpdateRequest; 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.dto.response.CourseResponse;
import com.reading.platform.entity.CoursePackage; import com.reading.platform.entity.CoursePackage;
import com.reading.platform.service.CoursePackageService; import com.reading.platform.service.CoursePackageService;
@ -115,32 +112,4 @@ public class AdminCourseController {
return Result.success(); 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

@ -1,15 +0,0 @@
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

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

View File

@ -1,6 +1,5 @@
package com.reading.platform.service; package com.reading.platform.service;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@ -198,8 +197,7 @@ public class CourseCollectionService extends ServiceImpl<CourseCollectionMapper,
collection.setPrice(price); collection.setPrice(price);
collection.setDiscountPrice(discountPrice); collection.setDiscountPrice(discountPrice);
collection.setDiscountType(discountType); collection.setDiscountType(discountType);
// 将数组转为 JSON 字符串存储到 JSON 字段 collection.setGradeLevels(String.join(",", gradeLevels));
collection.setGradeLevels(JSON.toJSONString(gradeLevels));
collection.setPackageCount(0); collection.setPackageCount(0);
collection.setStatus(CourseStatus.DRAFT.getCode()); collection.setStatus(CourseStatus.DRAFT.getCode());
@ -216,23 +214,6 @@ public class CourseCollectionService extends ServiceImpl<CourseCollectionMapper,
public void setCollectionPackages(Long collectionId, List<Long> packageIds) { public void setCollectionPackages(Long collectionId, List<Long> packageIds) {
log.info("设置课程套餐的课程包collectionId={}, packageCount={}", collectionId, packageIds.size()); 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( collectionPackageMapper.delete(
new LambdaQueryWrapper<CourseCollectionPackage>() new LambdaQueryWrapper<CourseCollectionPackage>()
@ -248,9 +229,12 @@ public class CourseCollectionService extends ServiceImpl<CourseCollectionMapper,
collectionPackageMapper.insert(association); collectionPackageMapper.insert(association);
} }
// 更新课程包数量复用前面已验证的 collection 变量 // 更新课程包数量
collection.setPackageCount(packageIds.size()); CourseCollection collection = collectionMapper.selectById(collectionId);
collectionMapper.updateById(collection); if (collection != null) {
collection.setPackageCount(packageIds.size());
collectionMapper.updateById(collection);
}
log.info("课程套餐的课程包设置完成"); log.info("课程套餐的课程包设置完成");
} }
@ -273,8 +257,7 @@ public class CourseCollectionService extends ServiceImpl<CourseCollectionMapper,
collection.setPrice(price); collection.setPrice(price);
collection.setDiscountPrice(discountPrice); collection.setDiscountPrice(discountPrice);
collection.setDiscountType(discountType); collection.setDiscountType(discountType);
// 将数组转为 JSON 字符串存储到 JSON 字段 collection.setGradeLevels(String.join(",", gradeLevels));
collection.setGradeLevels(JSON.toJSONString(gradeLevels));
collectionMapper.updateById(collection); collectionMapper.updateById(collection);
@ -409,50 +392,6 @@ public class CourseCollectionService extends ServiceImpl<CourseCollectionMapper,
log.info("课程套餐审核撤销成功id={}", id); 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,11 +44,6 @@ public interface CoursePackageService extends com.baomidou.mybatisplus.extension
void archiveCourse(Long id); void archiveCourse(Long id);
/**
* 提交课程审核
*/
void submitCourse(Long id);
/** /**
* 审核驳回课程 * 审核驳回课程
*/ */

View File

@ -195,16 +195,6 @@ public class CoursePackageServiceImpl extends ServiceImpl<CoursePackageMapper, C
log.info("课程归档成功id={}", id); 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 @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void rejectCourse(Long id, String comment) { public void rejectCourse(Long id, String comment) {

View File

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

View File

@ -1,12 +0,0 @@
-- 为 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

@ -1,26 +0,0 @@
-- -----------------------------------------------------
-- 迁移 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';

View File

@ -1,13 +0,0 @@
#!/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