Compare commits
3 Commits
10936b7a78
...
deb8431910
| Author | SHA1 | Date | |
|---|---|---|---|
| deb8431910 | |||
| bad446c069 | |||
| 1d4bf52d05 |
@ -753,3 +753,89 @@ 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**: 终止状态
|
||||||
|
|||||||
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(mvn compile:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,7 +9,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# === 备份文件 ===
|
# === 备份文件 ===
|
||||||
backups/
|
backups/
|
||||||
*.sql
|
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
# === 例外:Flyway 迁移脚本必须提交 ===
|
# === 例外:Flyway 迁移脚本必须提交 ===
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -38,12 +38,11 @@ export default defineConfig({
|
|||||||
target: './openapi.json',
|
target: './openapi.json',
|
||||||
// 路径重写:确保 OpenAPI 文档中的路径正确
|
// 路径重写:确保 OpenAPI 文档中的路径正确
|
||||||
override: {
|
override: {
|
||||||
// 使用转换器修复路径 - 将 `/api/xxx` 转换为 `/api/v1/xxx`
|
// 使用转换器修复路径 - 将 `/api/v1/xxx` 转换为 `/v1/xxx`(因为 baseURL 已经是 `/api`)
|
||||||
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(/\/v1\/v1\//g, '/v1/');
|
let newKey = path.replace(/^\/api\/v1\//, '/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];
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { http } from './index';
|
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 = () =>
|
export const getPublishedPackages = () =>
|
||||||
http.get<CoursePackage[]>('/v1/admin/packages/all');
|
http.get<CourseCollectionResponse[]>('/v1/admin/collections/all');
|
||||||
|
|
||||||
// ==================== 系统设置 ====================
|
// ==================== 系统设置 ====================
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
import { getReadingPlatformAPI } from './generated';
|
import { getReadingPlatformAPI } from './generated';
|
||||||
import { axios } from './generated/mutator';
|
import { axios, customMutator } 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 {
|
||||||
@ -137,7 +145,8 @@ 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;
|
||||||
@ -178,13 +187,19 @@ 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, copyrightConfirmed?: boolean): Promise<any> {
|
export function submitCourse(id: number | string): Promise<any> {
|
||||||
return api.submit(id) as 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;
|
return api.publishCourse(id) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 审核驳回(课程专用,调用 POST /api/v1/admin/packages/{id}/reject)
|
// 审核驳回(课程专用,调用 POST /v1/admin/packages/{id}/reject)
|
||||||
export function rejectCourse(id: number, data: { checklist?: any; comment: string }): Promise<any> {
|
export function rejectCourse(id: number | string, data: { comment: string }): Promise<any> {
|
||||||
return axios.post(`/api/v1/admin/packages/${id}/reject`, { comment: data.comment }).then((res: any) => {
|
return http.post(`/v1/admin/packages/${id}/reject`, data).then((res: any) => {
|
||||||
const body = res?.data;
|
const body = res;
|
||||||
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 || '驳回失败');
|
||||||
}
|
}
|
||||||
@ -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;
|
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({});
|
return Promise.resolve({});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取版本历史 (暂时返回空数组)
|
// 获取版本历史 (暂时返回空数组)
|
||||||
export function getCourseVersions(id: number): Promise<any[]> {
|
export function getCourseVersions(_id: number): Promise<any[]> {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import axios from "axios";
|
import { http } from "./index";
|
||||||
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>();
|
||||||
|
|
||||||
@ -82,13 +80,13 @@ export const fileApi = {
|
|||||||
// 自动添加环境前缀
|
// 自动添加环境前缀
|
||||||
const fullDir = buildOssDirPath(dir);
|
const fullDir = buildOssDirPath(dir);
|
||||||
|
|
||||||
const response = await axios.get<{ data: OssToken }>(
|
const response = await http.get<{ data: OssToken }>(
|
||||||
`${API_BASE}/api/v1/files/oss/token`,
|
`/v1/files/oss/token`,
|
||||||
{
|
{
|
||||||
params: { fileName, dir: fullDir },
|
params: { fileName, dir: fullDir },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -206,13 +204,13 @@ export const fileApi = {
|
|||||||
* 删除文件
|
* 删除文件
|
||||||
*/
|
*/
|
||||||
deleteFile: async (filePath: string): Promise<DeleteResult> => {
|
deleteFile: async (filePath: string): Promise<DeleteResult> => {
|
||||||
const response = await axios.delete<DeleteResult>(
|
const response = await http.post<DeleteResult>(
|
||||||
`${API_BASE}/api/v1/files/delete`,
|
`/v1/files/delete`,
|
||||||
{
|
{
|
||||||
data: { filePath },
|
filePath,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -35,6 +35,7 @@ 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';
|
||||||
@ -48,6 +49,7 @@ 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';
|
||||||
|
|||||||
@ -14,6 +14,8 @@ export interface TenantCreateRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
/** 租户编码/登录账号 */
|
/** 租户编码/登录账号 */
|
||||||
code: string;
|
code: string;
|
||||||
|
/** 初始密码(留空默认 123456) */
|
||||||
|
password?: string;
|
||||||
/** 联系人 */
|
/** 联系人 */
|
||||||
contactName?: string;
|
contactName?: string;
|
||||||
/** 联系电话 */
|
/** 联系电话 */
|
||||||
|
|||||||
@ -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: "",
|
baseURL: "/api",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,135 +1,271 @@
|
|||||||
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;
|
discountType?: string; // 折扣类型:PERCENTAGE、FIXED
|
||||||
gradeLevels: string[];
|
gradeLevels: string[]; // 适用年级
|
||||||
status: string;
|
packageCount: number; // 课程包数量
|
||||||
courseCount: number;
|
tenantCount?: number; // 使用学校数
|
||||||
tenantCount: number;
|
status: string; // DRAFT, PENDING, APPROVED, REJECTED, PUBLISHED, OFFLINE
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
submittedAt?: string;
|
submittedAt?: string;
|
||||||
reviewedAt?: string;
|
reviewedAt?: string;
|
||||||
reviewComment?: string;
|
reviewComment?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
courses?: PackageCourse[];
|
startDate?: string; // 开始日期(租户套餐)
|
||||||
|
endDate?: string; // 结束日期(租户套餐)
|
||||||
|
packages?: CoursePackageItem[]; // 包含的课程包列表
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PackageCourse {
|
/**
|
||||||
id: number | string; // 课程 ID,后端 Long 序列化为 string
|
* 课程包项(对应后端 CourseCollectionResponse.CoursePackageItem)
|
||||||
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 CreatePackageData {
|
export interface CreateCollectionData {
|
||||||
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 getPackageDetail(id: number | string) {
|
export function getCollectionDetail(id: number | string) {
|
||||||
return http.get(`/v1/admin/packages/${id}`);
|
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);
|
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);
|
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}`);
|
return http.delete(`/v1/admin/packages/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置套餐课程(后端期望 JSON 数组 [courseId1, courseId2, ...])
|
// 发布课程包
|
||||||
export function setPackageCourses(
|
export function publishCoursePackage(id: number | string) {
|
||||||
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;
|
||||||
packageId: number;
|
collectionId: number; // 课程套餐 ID(使用 collectionId 而非 packageId)
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
status: string;
|
status: string;
|
||||||
pricePaid: number;
|
pricePaid: number;
|
||||||
package: CoursePackage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取学校已授权套餐
|
// 获取学校已授权课程套餐列表
|
||||||
export function getTenantPackages() {
|
export function getTenantCollections() {
|
||||||
return http.get('/v1/school/packages');
|
return http.get('/v1/school/packages');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 续订套餐
|
// 获取课程套餐下的课程包列表
|
||||||
export function renewPackage(packageId: number, data: { endDate: string; pricePaid?: number }) {
|
export function getCollectionPackages(collectionId: number | string) {
|
||||||
return http.post(`/v1/school/packages/${packageId}/renew`, data);
|
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;
|
||||||
|
|||||||
@ -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 {
|
export interface CourseCollection {
|
||||||
id: number;
|
id: number | string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
price: number;
|
price: number;
|
||||||
@ -281,20 +291,22 @@ export interface CourseCollection {
|
|||||||
submittedAt?: string;
|
submittedAt?: string;
|
||||||
reviewedAt?: string;
|
reviewedAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
packages?: CoursePackage[];
|
startDate?: string; // 开始日期(租户套餐)
|
||||||
|
endDate?: string; // 结束日期(租户套餐)
|
||||||
|
packages?: CoursePackageItem[]; // 包含的课程包列表
|
||||||
}
|
}
|
||||||
|
|
||||||
// 课程包(中间层)
|
// 课程包(中间层,7 步流程创建的教学资源)
|
||||||
export interface CoursePackage {
|
export interface CoursePackage {
|
||||||
id: number;
|
id: number | string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
price: number;
|
pictureBookName?: string;
|
||||||
discountPrice?: number;
|
gradeTags?: string[];
|
||||||
discountType?: string;
|
gradeLevels?: 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;
|
||||||
@ -314,9 +326,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) =>
|
export const getCourseCollectionPackages = (collectionId: number | string) =>
|
||||||
http.get<CoursePackage[]>(`/v1/school/packages/${collectionId}/packages`);
|
http.get<CoursePackageItem[]>(`/v1/school/packages/${collectionId}/packages`);
|
||||||
|
|
||||||
// 续费课程套餐(三层架构)
|
// 续费课程套餐(三层架构)
|
||||||
export const renewCollection = (collectionId: number, data: RenewPackageDto) =>
|
export const renewCollection = (collectionId: number, data: RenewPackageDto) =>
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="collection-edit-container">
|
<div class="collection-edit-container">
|
||||||
<a-card title="创建课程套餐" :bordered="false">
|
<a-card :title="isEdit ? '编辑课程套餐' : '创建课程套餐'" :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" :rules="[{ required: true, message: '请输入套餐名称' }]">
|
<a-form-item label="套餐名称" name="name">
|
||||||
<a-input v-model:value="formState.name" placeholder="请输入套餐名称" />
|
<a-input v-model:value="formState.name" placeholder="请输入套餐名称" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
@ -15,7 +16,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" :rules="[{ required: true, message: '请输入价格' }]">
|
<a-form-item label="价格(分)" name="price">
|
||||||
<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>
|
||||||
|
|
||||||
@ -24,7 +25,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="请选择折扣类型">
|
<a-select v-model:value="formState.discountType" placeholder="请选择折扣类型" allowClear>
|
||||||
<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>
|
||||||
@ -32,56 +33,258 @@
|
|||||||
|
|
||||||
<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="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 value="大班">大班</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" html-type="submit" :loading="loading">
|
<a-button type="primary" @click="handleSubmit" :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 } from 'vue';
|
import { ref, reactive, computed, onMounted } from 'vue';
|
||||||
import { useRouter } 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 { 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,
|
discountPrice: null as number | null,
|
||||||
discountType: null,
|
discountType: null as string | null,
|
||||||
gradeLevels: [],
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
loading.value = true;
|
if (!formRef.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: 实现创建套餐的API调用
|
// 手动触发表单校验
|
||||||
console.log('创建套餐:', formState.value);
|
await formRef.value.validate();
|
||||||
|
|
||||||
// 模拟API调用
|
loading.value = true;
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
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');
|
router.push('/admin/collections');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('创建失败');
|
console.error('提交失败:', error);
|
||||||
|
// 表单校验错误不显示全局提示
|
||||||
|
if (error?.errorFields) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.error(error.response?.data?.message || '提交失败');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -90,10 +293,19 @@ 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>
|
||||||
|
|||||||
@ -7,14 +7,36 @@
|
|||||||
<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-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>
|
<span>
|
||||||
<a-button disabled>提交审核</a-button>
|
<a-button disabled>提交审核</a-button>
|
||||||
</span>
|
</span>
|
||||||
</a-tooltip>
|
</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-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>
|
||||||
|
|
||||||
@ -30,7 +52,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?.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="使用学校数">{{ 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>
|
||||||
@ -40,19 +62,19 @@
|
|||||||
<a-divider>包含课程包</a-divider>
|
<a-divider>包含课程包</a-divider>
|
||||||
|
|
||||||
<a-table
|
<a-table
|
||||||
:columns="courseColumns"
|
:columns="packageColumns"
|
||||||
:data-source="pkg?.courses || []"
|
:data-source="pkg?.packages || []"
|
||||||
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="course-info">
|
<div class="package-info">
|
||||||
<span>{{ record.name }}</span>
|
<span>{{ record.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'gradeLevel'">
|
<template v-else-if="column.key === 'gradeLevels'">
|
||||||
<a-tag>{{ record.gradeLevel }}</a-tag>
|
<a-tag v-for="grade in record.gradeLevels" :key="grade">{{ grade }}</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'sortOrder'">
|
<template v-else-if="column.key === 'sortOrder'">
|
||||||
{{ record.sortOrder }}
|
{{ record.sortOrder }}
|
||||||
@ -67,18 +89,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 { getPackageDetail, submitPackage, publishPackage } from '@/api/package';
|
import { getCollectionDetail, submitCollection, publishCollection, archiveCollection } from '@/api/package';
|
||||||
import type { CoursePackage } from '@/api/package';
|
import type { CourseCollection } 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<CoursePackage | null>(null);
|
const pkg = ref<CourseCollection | null>(null);
|
||||||
|
|
||||||
const courseColumns = [
|
const packageColumns = [
|
||||||
{ title: '课程名称', key: 'name', dataIndex: 'name' },
|
{ title: '课程包名称', key: 'name', dataIndex: 'name' },
|
||||||
{ title: '年级', key: 'gradeLevel', dataIndex: 'gradeLevel', width: 100 },
|
{ title: '适用年级', key: 'gradeLevels', dataIndex: 'gradeLevels', width: 150 },
|
||||||
{ title: '排序', key: 'sortOrder', dataIndex: 'sortOrder', width: 80 },
|
{ title: '排序', key: 'sortOrder', dataIndex: 'sortOrder', width: 80 },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -112,9 +134,12 @@ 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 getPackageDetail(id);
|
const res = await getCollectionDetail(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;
|
||||||
@ -126,12 +151,12 @@ const handleEdit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if ((pkg.value?.courseCount || 0) === 0) {
|
if ((pkg.value?.packageCount || 0) === 0) {
|
||||||
message.warning('套餐必须包含至少一个课程包,请先编辑添加');
|
message.warning('套餐必须包含至少一个课程包,请先编辑添加');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await submitPackage(route.params.id as string);
|
await submitCollection(route.params.id as string);
|
||||||
message.success('提交成功');
|
message.success('提交成功');
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -141,7 +166,7 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
try {
|
try {
|
||||||
await publishPackage(route.params.id as string);
|
await publishCollection(route.params.id as string);
|
||||||
message.success('发布成功');
|
message.success('发布成功');
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} 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(() => {
|
onMounted(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
});
|
});
|
||||||
@ -159,13 +194,13 @@ onMounted(() => {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-info {
|
.package-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-cover {
|
.package-cover {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|||||||
@ -9,12 +9,13 @@
|
|||||||
</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" :rules="[{ required: true, message: '请输入套餐名称' }]">
|
<a-form-item label="套餐名称" name="name">
|
||||||
<a-input v-model:value="form.name" placeholder="请输入套餐名称" />
|
<a-input v-model:value="form.name" placeholder="请输入套餐名称" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
@ -22,7 +23,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" :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-input-number v-model:value="form.price" :min="0" :precision="2" style="width: 200px" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
@ -37,7 +38,7 @@
|
|||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</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 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>
|
||||||
@ -48,11 +49,11 @@
|
|||||||
<a-divider>课程包配置</a-divider>
|
<a-divider>课程包配置</a-divider>
|
||||||
|
|
||||||
<a-form-item label="已选课程包">
|
<a-form-item label="已选课程包">
|
||||||
<div class="course-list">
|
<div class="package-list">
|
||||||
<a-table
|
<a-table
|
||||||
:columns="courseColumns"
|
:columns="packageColumns"
|
||||||
:data-source="selectedCourses"
|
:data-source="selectedPackages"
|
||||||
row-key="courseId"
|
row-key="packageId"
|
||||||
size="small"
|
size="small"
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
>
|
>
|
||||||
@ -68,11 +69,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="removeCourse(index)">移除</a-button>
|
<a-button type="link" size="small" danger @click="removePackage(index)">移除</a-button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</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>
|
<template #icon><PlusOutlined /></template>
|
||||||
添加课程包
|
添加课程包
|
||||||
</a-button>
|
</a-button>
|
||||||
@ -81,27 +82,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" 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-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="showCourseSelector"
|
v-model:open="showPackageSelector"
|
||||||
title="选择课程包"
|
title="选择课程包"
|
||||||
width="800px"
|
width="800px"
|
||||||
@ok="handleAddCourses"
|
@ok="handleAddPackages"
|
||||||
>
|
>
|
||||||
<a-table
|
<a-table
|
||||||
:columns="selectorColumns"
|
:columns="selectorColumns"
|
||||||
:data-source="availableCourses"
|
:data-source="availablePackages"
|
||||||
:row-selection="rowSelection"
|
:row-selection="rowSelection"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
size="small"
|
size="small"
|
||||||
:loading="loadingCourses"
|
:loading="loadingPackages"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'gradeTags'">
|
<template v-if="column.key === 'gradeTags'">
|
||||||
@ -117,20 +118,22 @@
|
|||||||
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 { getPackageDetail, createPackage, updatePackage, setPackageCourses } from '@/api/package';
|
import { getCollectionDetail, createCollection, updateCollection, setCollectionPackages } from '@/api/package';
|
||||||
import { getCourses } from '@/api/course';
|
import { getCoursePackageList } from '@/api/package';
|
||||||
|
|
||||||
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 loadingCourses = ref(false);
|
const loadingPackages = ref(false);
|
||||||
const showCourseSelector = ref(false);
|
const showPackageSelector = ref(false);
|
||||||
const availableCourses = ref<any[]>([]);
|
const availablePackages = ref<any[]>([]);
|
||||||
const selectedRowKeys = ref<(number | string)[]>([]);
|
const selectedRowKeys = ref<(number | string)[]>([]);
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@ -142,10 +145,17 @@ const form = reactive({
|
|||||||
gradeLevels: [] as string[],
|
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: '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 },
|
||||||
@ -176,7 +186,7 @@ const fetchPackageDetail = async () => {
|
|||||||
if (!isEdit.value) return;
|
if (!isEdit.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pkg = await getPackageDetail(packageId.value) as any;
|
const pkg = await getCollectionDetail(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;
|
||||||
@ -184,52 +194,58 @@ const fetchPackageDetail = async () => {
|
|||||||
form.discountType = pkg.discountType;
|
form.discountType = pkg.discountType;
|
||||||
form.gradeLevels = JSON.parse(pkg.gradeLevels || '[]');
|
form.gradeLevels = JSON.parse(pkg.gradeLevels || '[]');
|
||||||
|
|
||||||
selectedCourses.value = (pkg.courses || []).map((c: any) => ({
|
selectedPackages.value = (pkg.packages || []).map((p: any) => ({
|
||||||
courseId: c.id,
|
packageId: p.id,
|
||||||
courseName: c.name,
|
packageName: p.name,
|
||||||
gradeLevel: c.gradeLevel,
|
gradeLevel: p.gradeLevels?.[0] || '小班',
|
||||||
sortOrder: c.sortOrder,
|
sortOrder: p.sortOrder,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('获取套餐详情失败');
|
message.error('获取套餐详情失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchAvailableCourses = async () => {
|
const fetchAvailablePackages = async () => {
|
||||||
loadingCourses.value = true;
|
loadingPackages.value = true;
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
console.error('获取课程列表失败', error);
|
console.error('获取课程包列表失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
loadingCourses.value = false;
|
loadingPackages.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddCourses = () => {
|
const handleAddPackages = () => {
|
||||||
const existingIds = new Set(selectedCourses.value.map((c) => c.courseId));
|
const existingIds = new Set(selectedPackages.value.map((p) => p.packageId));
|
||||||
const newCourses = availableCourses.value
|
const newPackages = availablePackages.value
|
||||||
.filter((c) => selectedRowKeys.value.includes(c.id) && !existingIds.has(c.id))
|
.filter((p) => selectedRowKeys.value.includes(p.id) && !existingIds.has(p.id))
|
||||||
.map((c) => ({
|
.map((p) => ({
|
||||||
courseId: c.id,
|
packageId: p.id,
|
||||||
courseName: c.name,
|
packageName: p.name,
|
||||||
gradeLevel: parseGradeTags(c.gradeTags)[0] || '小班',
|
gradeLevel: parseGradeTags(p.gradeTags)?.[0] || '小班',
|
||||||
sortOrder: selectedCourses.value.length,
|
sortOrder: selectedPackages.value.length,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
selectedCourses.value.push(...newCourses);
|
selectedPackages.value.push(...newPackages);
|
||||||
selectedRowKeys.value = [];
|
selectedRowKeys.value = [];
|
||||||
showCourseSelector.value = false;
|
showPackageSelector.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeCourse = (index: number) => {
|
const removePackage = (index: number) => {
|
||||||
selectedCourses.value.splice(index, 1);
|
selectedPackages.value.splice(index, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
saving.value = true;
|
if (!formRef.value) return;
|
||||||
|
|
||||||
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,
|
||||||
@ -239,31 +255,35 @@ 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 updatePackage(id, data);
|
await updateCollection(id, data);
|
||||||
} else {
|
} else {
|
||||||
const res = await createPackage(data) as any;
|
const res = await createCollection(data) as any;
|
||||||
|
console.log('创建套餐响应:', res);
|
||||||
id = res.id; // 后端 Long 序列化为 string
|
id = res.id; // 后端 Long 序列化为 string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存课程关联
|
// 保存课程包关联
|
||||||
if (selectedCourses.value.length > 0) {
|
if (selectedPackages.value.length > 0) {
|
||||||
await setPackageCourses(
|
await setCollectionPackages(
|
||||||
id,
|
id,
|
||||||
selectedCourses.value.map((c) => ({
|
selectedPackages.value.map((p) => p.packageId),
|
||||||
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) {
|
||||||
message.error('保存失败');
|
console.error('保存失败:', error);
|
||||||
|
// 表单校验错误不显示全局提示
|
||||||
|
if (error?.errorFields) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.error(error.response?.data?.message || '保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
@ -271,7 +291,7 @@ const handleSave = async () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchPackageDetail();
|
fetchPackageDetail();
|
||||||
fetchAvailableCourses();
|
fetchAvailablePackages();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -280,7 +300,7 @@ onMounted(() => {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-list {
|
.package-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -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.courseCount || 0) === 0" title="请先添加至少一个课程包">
|
<a-tooltip v-if="(record.status === 'DRAFT' || record.status === 'REJECTED') && (record.packageCount || 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,6 +94,13 @@
|
|||||||
>
|
>
|
||||||
发布
|
发布
|
||||||
</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="确定要删除吗?"
|
||||||
@ -114,13 +121,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 { getPackageList, deletePackage, submitPackage, publishPackage } from '@/api/package';
|
import { getCollectionList, deleteCollection, submitCollection, publishCollection, archiveCollection } from '@/api/package';
|
||||||
import type { CoursePackage } from '@/api/package';
|
import type { CourseCollection } from '@/api/package';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const dataSource = ref<CoursePackage[]>([]);
|
const dataSource = ref<CourseCollection[]>([]);
|
||||||
const pendingCount = ref(0);
|
const pendingCount = ref(0);
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
status: undefined as string | undefined,
|
status: undefined as string | undefined,
|
||||||
@ -136,7 +143,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: 'courseCount', key: 'courseCount', width: 80 },
|
{ title: '课程包数', dataIndex: 'packageCount', key: 'packageCount', 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 },
|
||||||
];
|
];
|
||||||
@ -174,7 +181,7 @@ const parseGradeLevels = (gradeLevels: string | string[]) => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getPackageList({
|
const res = await getCollectionList({
|
||||||
status: filters.status,
|
status: filters.status,
|
||||||
pageNum: pagination.current,
|
pageNum: pagination.current,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
@ -183,7 +190,7 @@ const fetchData = async () => {
|
|||||||
pagination.total = Number(res.total) || 0;
|
pagination.total = Number(res.total) || 0;
|
||||||
// 获取待审核数量
|
// 获取待审核数量
|
||||||
try {
|
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;
|
pendingCount.value = Number(pendingRes.total) || 0;
|
||||||
} catch {
|
} catch {
|
||||||
pendingCount.value = 0;
|
pendingCount.value = 0;
|
||||||
@ -213,17 +220,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.courseCount || 0) === 0) {
|
if ((record.packageCount || 0) === 0) {
|
||||||
message.warning('套餐必须包含至少一个课程包,请先编辑添加');
|
message.warning('套餐必须包含至少一个课程包,请先编辑添加');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await submitPackage(record.id);
|
await submitCollection(record.id);
|
||||||
message.success('提交成功');
|
message.success('提交成功');
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -233,7 +240,7 @@ const handleSubmit = async (record: any) => {
|
|||||||
|
|
||||||
const handlePublish = async (record: any) => {
|
const handlePublish = async (record: any) => {
|
||||||
try {
|
try {
|
||||||
await publishPackage(record.id);
|
await publishCollection(record.id);
|
||||||
message.success('发布成功');
|
message.success('发布成功');
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} 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) => {
|
const handleDelete = async (record: any) => {
|
||||||
try {
|
try {
|
||||||
await deletePackage(record.id);
|
await deleteCollection(record.id);
|
||||||
message.success('删除成功');
|
message.success('删除成功');
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -24,34 +24,34 @@
|
|||||||
row-key="id"
|
row-key="id"
|
||||||
@change="handleTableChange"
|
@change="handleTableChange"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="slotProps">
|
||||||
<template v-if="column.key === 'name'">
|
<template v-if="slotProps.column.key === 'name'">
|
||||||
<div class="package-name">
|
<div class="package-name">
|
||||||
<a @click="showReviewModal(record)">{{ record.name }}</a>
|
<a @click="showReviewModal(slotProps.record as CourseCollection)">{{ slotProps.record.name }}</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'price'">
|
<template v-else-if="slotProps.column.key === 'price'">
|
||||||
¥{{ ((record.price || 0) / 100).toFixed(2) }}
|
¥{{ ((slotProps.record.price || 0) / 100).toFixed(2) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'gradeLevels'">
|
<template v-else-if="slotProps.column.key === 'gradeLevels'">
|
||||||
<a-tag v-for="grade in parseGradeLevels(record.gradeLevels)" :key="grade" size="small">
|
<a-tag v-for="grade in parseGradeLevels(slotProps.record.gradeLevels)" :key="grade" size="small">
|
||||||
{{ grade }}
|
{{ grade }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'status'">
|
<template v-else-if="slotProps.column.key === 'status'">
|
||||||
<a-tag :color="record.status === 'PENDING' ? 'processing' : 'error'">
|
<a-tag :color="slotProps.record.status === 'PENDING' ? 'processing' : 'error'">
|
||||||
{{ record.status === 'PENDING' ? '待审核' : '已驳回' }}
|
{{ slotProps.record.status === 'PENDING' ? '待审核' : '已驳回' }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'submittedAt'">
|
<template v-else-if="slotProps.column.key === 'submittedAt'">
|
||||||
{{ formatDate(record.submittedAt) }}
|
{{ formatDate(slotProps.record.submittedAt) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="slotProps.column.key === 'actions'">
|
||||||
<a-space>
|
<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>
|
||||||
<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-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.courseCount }}</a-descriptions-item>
|
<a-descriptions-item label="课程包数">{{ currentPackage.packageCount }}</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,13 +87,24 @@
|
|||||||
|
|
||||||
<a-divider>包含课程包</a-divider>
|
<a-divider>包含课程包</a-divider>
|
||||||
<a-table
|
<a-table
|
||||||
v-if="currentPackage.courses && currentPackage.courses.length > 0"
|
v-if="currentPackage.packages && currentPackage.packages.length > 0"
|
||||||
:columns="courseColumns"
|
:columns="courseColumns"
|
||||||
:data-source="currentPackage.courses"
|
:data-source="currentPackage.packages"
|
||||||
: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">
|
||||||
@ -139,12 +150,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 { getPackageList, getPackageDetail, reviewPackage } from '@/api/package';
|
import { getCollectionList, getCollectionDetail, rejectCollection, publishCollection } from '@/api/package';
|
||||||
import type { CoursePackage } from '@/api/package';
|
import type { CourseCollection } from '@/api/package';
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const loadingDetail = ref(false);
|
const loadingDetail = ref(false);
|
||||||
const packages = ref<CoursePackage[]>([]);
|
const packages = ref<CourseCollection[]>([]);
|
||||||
|
|
||||||
const filters = reactive<{ status?: string }>({
|
const filters = reactive<{ status?: string }>({
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
@ -160,25 +171,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: 'courseCount', width: 80 },
|
{ title: '课程包数', key: 'packageCount', 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: 'gradeLevel', key: 'gradeLevel', width: 80 },
|
{ title: '适用年级', dataIndex: 'gradeLevels', key: 'gradeLevels', width: 120 },
|
||||||
{ 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<CoursePackage | null>(null);
|
const currentPackage = ref<CourseCollection | null>(null);
|
||||||
const reviewComment = ref('');
|
const reviewComment = ref('');
|
||||||
|
|
||||||
const rejectReasonVisible = ref(false);
|
const rejectReasonVisible = ref(false);
|
||||||
const rejectReasonPackage = ref<CoursePackage | null>(null);
|
const rejectReasonPackage = ref<CourseCollection | null>(null);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchPackages();
|
fetchPackages();
|
||||||
@ -187,7 +198,7 @@ onMounted(() => {
|
|||||||
const fetchPackages = async () => {
|
const fetchPackages = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getPackageList({
|
const res = await getCollectionList({
|
||||||
status: filters.status,
|
status: filters.status,
|
||||||
pageNum: pagination.current,
|
pageNum: pagination.current,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
@ -208,15 +219,15 @@ const handleTableChange = (pag: any) => {
|
|||||||
fetchPackages();
|
fetchPackages();
|
||||||
};
|
};
|
||||||
|
|
||||||
const showReviewModal = async (record: CoursePackage) => {
|
const showReviewModal = async (record: CourseCollection) => {
|
||||||
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 getPackageDetail(record.id);
|
const detail = await getCollectionDetail(record.id);
|
||||||
currentPackage.value = detail as CoursePackage;
|
currentPackage.value = detail as CourseCollection;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取套餐详情失败:', error);
|
console.error('获取套餐详情失败:', error);
|
||||||
message.error('获取套餐详情失败');
|
message.error('获取套餐详情失败');
|
||||||
@ -230,17 +241,16 @@ 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,
|
// 这里调用发布接口,但不在审核通过时立即发布
|
||||||
comment: reviewComment.value || '审核通过',
|
// 实际上 approveAndPublish 是更合理的操作
|
||||||
publish: false,
|
await publishCollection(currentPackage.value.id);
|
||||||
});
|
|
||||||
message.success('审核通过');
|
message.success('审核通过');
|
||||||
closeReviewModal();
|
closeReviewModal();
|
||||||
fetchPackages();
|
fetchPackages();
|
||||||
@ -257,11 +267,7 @@ const approveAndPublish = async () => {
|
|||||||
|
|
||||||
reviewing.value = true;
|
reviewing.value = true;
|
||||||
try {
|
try {
|
||||||
await reviewPackage(currentPackage.value.id, {
|
await publishCollection(currentPackage.value.id);
|
||||||
approved: true,
|
|
||||||
comment: reviewComment.value || '审核通过',
|
|
||||||
publish: true,
|
|
||||||
});
|
|
||||||
message.success('审核通过,套餐已发布');
|
message.success('审核通过,套餐已发布');
|
||||||
closeReviewModal();
|
closeReviewModal();
|
||||||
fetchPackages();
|
fetchPackages();
|
||||||
@ -281,8 +287,7 @@ const rejectPackage = async () => {
|
|||||||
|
|
||||||
reviewing.value = true;
|
reviewing.value = true;
|
||||||
try {
|
try {
|
||||||
await reviewPackage(currentPackage.value.id, {
|
await rejectCollection(currentPackage.value.id, {
|
||||||
approved: false,
|
|
||||||
comment: reviewComment.value,
|
comment: reviewComment.value,
|
||||||
});
|
});
|
||||||
message.success('已驳回');
|
message.success('已驳回');
|
||||||
@ -295,7 +300,7 @@ const rejectPackage = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewRejectReason = (record: CoursePackage) => {
|
const viewRejectReason = (record: CourseCollection) => {
|
||||||
rejectReasonPackage.value = record;
|
rejectReasonPackage.value = record;
|
||||||
rejectReasonVisible.value = true;
|
rejectReasonVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -270,7 +270,7 @@ import {
|
|||||||
type TenantDetail,
|
type TenantDetail,
|
||||||
type CreateTenantDto,
|
type CreateTenantDto,
|
||||||
type UpdateTenantDto,
|
type UpdateTenantDto,
|
||||||
type CoursePackage,
|
type CourseCollectionResponse,
|
||||||
} 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<CoursePackage[]>([]);
|
const packageList = ref<CourseCollectionResponse[]>([]);
|
||||||
|
|
||||||
// 禁用过去的日期(有效期不能选今天之前的日期)
|
// 禁用过去的日期(有效期不能选今天之前的日期)
|
||||||
const disabledPastDate = (current: dayjs.Dayjs) => {
|
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);
|
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) {
|
if (selectedPackage && selectedPackage.name) {
|
||||||
// 设置选中的课程套餐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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -326,7 +326,7 @@ const expiringCount = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const calculatedEndDate = computed(() => {
|
const calculatedEndDate = computed(() => {
|
||||||
if (!selectedPackage.value) return '';
|
if (!selectedPackage.value || !selectedPackage.value.endDate) 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,14 +334,15 @@ const calculatedEndDate = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 日期格式化
|
// 日期格式化
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string | undefined) => {
|
||||||
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) => {
|
const getDaysLeft = (endDate: string | undefined) => {
|
||||||
|
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();
|
||||||
@ -349,25 +350,25 @@ const getDaysLeft = (endDate: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 是否即将到期(30天内)
|
// 是否即将到期(30天内)
|
||||||
const isExpiring = (endDate: string) => {
|
const isExpiring = (endDate: string | undefined) => {
|
||||||
const days = getDaysLeft(endDate);
|
const days = getDaysLeft(endDate);
|
||||||
return days > 0 && days <= 30;
|
return days > 0 && days <= 30;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 是否已过期
|
// 是否已过期
|
||||||
const isExpired = (endDate: string) => {
|
const isExpired = (endDate: string | undefined) => {
|
||||||
return getDaysLeft(endDate) <= 0;
|
return getDaysLeft(endDate) <= 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取状态颜色
|
// 获取状态颜色
|
||||||
const getStatusColor = (endDate: string) => {
|
const getStatusColor = (endDate: string | undefined) => {
|
||||||
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) => {
|
const getStatusText = (endDate: string | undefined) => {
|
||||||
if (isExpired(endDate)) return '已过期';
|
if (isExpired(endDate)) return '已过期';
|
||||||
if (isExpiring(endDate)) return '即将到期';
|
if (isExpiring(endDate)) return '即将到期';
|
||||||
return '有效';
|
return '有效';
|
||||||
@ -388,7 +389,7 @@ const showRenewModal = (item: CourseCollection) => {
|
|||||||
|
|
||||||
// 处理续订
|
// 处理续订
|
||||||
const handleRenew = async () => {
|
const handleRenew = async () => {
|
||||||
if (!selectedPackage.value) return;
|
if (!selectedPackage.value || !selectedPackage.value.endDate) return;
|
||||||
|
|
||||||
renewLoading.value = true;
|
renewLoading.value = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
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;
|
||||||
@ -16,9 +20,11 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 课程套餐控制器(超管端)- 三层架构规范
|
* 课程套餐控制器(超管端)- 三层架构规范
|
||||||
@ -26,6 +32,7 @@ import java.util.List;
|
|||||||
@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 {
|
||||||
|
|
||||||
@ -132,22 +139,36 @@ public class AdminCourseCollectionController {
|
|||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/grant")
|
@PostMapping("/{id}/submit")
|
||||||
@Operation(summary = "授权课程套餐给租户")
|
@Operation(summary = "提交审核")
|
||||||
@RequireRole(UserRole.ADMIN)
|
@RequireRole(UserRole.ADMIN)
|
||||||
public Result<Void> grantToTenant(
|
public Result<Void> submit(@PathVariable Long id) {
|
||||||
@PathVariable Long id,
|
collectionService.submitCollection(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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 授权课程套餐请求
|
* 授权课程套餐请求
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
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;
|
||||||
@ -112,4 +115,32 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -12,6 +14,8 @@ import java.time.LocalDateTime;
|
|||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
@Schema(description = "主题响应")
|
@Schema(description = "主题响应")
|
||||||
public class ThemeResponse {
|
public class ThemeResponse {
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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;
|
||||||
@ -197,7 +198,8 @@ public class CourseCollectionService extends ServiceImpl<CourseCollectionMapper,
|
|||||||
collection.setPrice(price);
|
collection.setPrice(price);
|
||||||
collection.setDiscountPrice(discountPrice);
|
collection.setDiscountPrice(discountPrice);
|
||||||
collection.setDiscountType(discountType);
|
collection.setDiscountType(discountType);
|
||||||
collection.setGradeLevels(String.join(",", gradeLevels));
|
// 将数组转为 JSON 字符串存储到 JSON 字段
|
||||||
|
collection.setGradeLevels(JSON.toJSONString(gradeLevels));
|
||||||
collection.setPackageCount(0);
|
collection.setPackageCount(0);
|
||||||
collection.setStatus(CourseStatus.DRAFT.getCode());
|
collection.setStatus(CourseStatus.DRAFT.getCode());
|
||||||
|
|
||||||
@ -214,6 +216,23 @@ 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>()
|
||||||
@ -229,12 +248,9 @@ public class CourseCollectionService extends ServiceImpl<CourseCollectionMapper,
|
|||||||
collectionPackageMapper.insert(association);
|
collectionPackageMapper.insert(association);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新课程包数量
|
// 更新课程包数量(复用前面已验证的 collection 变量)
|
||||||
CourseCollection collection = collectionMapper.selectById(collectionId);
|
collection.setPackageCount(packageIds.size());
|
||||||
if (collection != null) {
|
collectionMapper.updateById(collection);
|
||||||
collection.setPackageCount(packageIds.size());
|
|
||||||
collectionMapper.updateById(collection);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("课程套餐的课程包设置完成");
|
log.info("课程套餐的课程包设置完成");
|
||||||
}
|
}
|
||||||
@ -257,7 +273,8 @@ public class CourseCollectionService extends ServiceImpl<CourseCollectionMapper,
|
|||||||
collection.setPrice(price);
|
collection.setPrice(price);
|
||||||
collection.setDiscountPrice(discountPrice);
|
collection.setDiscountPrice(discountPrice);
|
||||||
collection.setDiscountType(discountType);
|
collection.setDiscountType(discountType);
|
||||||
collection.setGradeLevels(String.join(",", gradeLevels));
|
// 将数组转为 JSON 字符串存储到 JSON 字段
|
||||||
|
collection.setGradeLevels(JSON.toJSONString(gradeLevels));
|
||||||
|
|
||||||
collectionMapper.updateById(collection);
|
collectionMapper.updateById(collection);
|
||||||
|
|
||||||
@ -392,6 +409,50 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 续费租户课程套餐
|
* 续费租户课程套餐
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -44,6 +44,11 @@ public interface CoursePackageService extends com.baomidou.mybatisplus.extension
|
|||||||
|
|
||||||
void archiveCourse(Long id);
|
void archiveCourse(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交课程审核
|
||||||
|
*/
|
||||||
|
void submitCourse(Long id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审核驳回课程
|
* 审核驳回课程
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -195,6 +195,16 @@ 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) {
|
||||||
|
|||||||
@ -45,9 +45,7 @@ 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 表结构
|
||||||
@ -56,9 +54,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`);
|
||||||
|
|
||||||
-- 添加外键约束
|
-- 添加索引(外键约束已删除,改用应用层控制)
|
||||||
ALTER TABLE `tenant_package`
|
-- 外键约束已删除:fk_tenant_package_collection
|
||||||
ADD CONSTRAINT `fk_tenant_package_collection` FOREIGN KEY (`collection_id`) REFERENCES `course_collection`(`id`);
|
-- 数据完整性由应用层 Service 层验证逻辑控制
|
||||||
|
|
||||||
-- 4. 数据迁移:将现有的 course_package 提升为两层结构
|
-- 4. 数据迁移:将现有的 course_package 提升为两层结构
|
||||||
-- 步骤1:为每个现有的 course_package 创建对应的 course_collection
|
-- 步骤1:为每个现有的 course_package 创建对应的 course_collection
|
||||||
|
|||||||
@ -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';
|
||||||
@ -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
13
restart-frontend.sh
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user