Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
d2a9dbd0f8
@ -6,6 +6,47 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 多地点登录支持实现 ✅ (2026-03-17)
|
||||
|
||||
**实现了多地点同时登录功能,支持同一账号在多个设备同时在线:**
|
||||
|
||||
**问题背景**:
|
||||
- 之前系统采用单点登录 (SSO) 模式,每次登录会生成新 token 并存储到 Redis
|
||||
- `JwtTokenRedisService.validateToken()` 会验证 token 是否与 Redis 中存储的一致
|
||||
- 导致同一账号在不同地方登录时,之前的 token 会失效(互踢下线)
|
||||
|
||||
**解决方案**:
|
||||
1. 修改 `JwtTokenRedisService.validateToken()` 方法,移除 token 一致性检查
|
||||
2. 在 `JwtAuthenticationFilter` 中增加账户状态检查 (`isAccountActive()`)
|
||||
3. 保留黑名单机制,用于主动踢人、登出等场景
|
||||
4. 状态判断修改为忽略大小写 (`equalsIgnoreCase`)
|
||||
|
||||
**修改文件**:
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `JwtTokenRedisService.java` | 修改 `validateToken()` 方法,仅检查黑名单 |
|
||||
| `JwtAuthenticationFilter.java` | 新增 Mapper 依赖注入,增加账户状态检查逻辑,使用 `equalsIgnoreCase` 判断状态 |
|
||||
| `AuthServiceImpl.java` | 更新 `logout()` 方法注释,所有状态判断改为 `equalsIgnoreCase` |
|
||||
|
||||
**功能特性**:
|
||||
- ✅ 同一账号可以在多个设备/浏览器同时登录
|
||||
- ✅ 各个登录状态的 token 都有效,不会互踢下线
|
||||
- ✅ 每次请求都会验证账户状态是否为 "active"
|
||||
- ✅ 支持所有角色:admin, school, teacher, parent
|
||||
- ✅ 黑名单机制仍然有效
|
||||
|
||||
**安全性考虑**:
|
||||
- JWT token 有过期时间(默认 24 小时),过期后自动失效
|
||||
- 黑名单机制仍然有效,可以主动使特定 token 失效
|
||||
- 每次请求都会验证账户状态,确保禁用账号无法访问
|
||||
|
||||
**验证结果**:
|
||||
- ✅ 使用同一账号 (admin) 在两个不同设备登录,两个 token 都有效
|
||||
- ✅ 两个 token 都能正常访问 API 接口
|
||||
- ✅ 代码编译通过,服务启动正常
|
||||
|
||||
---
|
||||
|
||||
### 超管端 E2E 全面自动化测试 ✅ (2026-03-15 晚上)
|
||||
|
||||
**创建了超管端全面 E2E 测试,覆盖所有页面的新增、修改、查看功能:**
|
||||
|
||||
@ -47,7 +47,7 @@ model SchoolCourse {
|
||||
createdBy Int @map("created_by")
|
||||
changesSummary String? @map("changes_summary") // 修改说明
|
||||
usageCount Int @default(0) @map("usage_count")
|
||||
status String @default("ACTIVE") // ACTIVE, PENDING_REVIEW, REJECTED
|
||||
status String @default("ACTIVE") // ACTIVE, PENDING, REJECTED
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@ -82,7 +82,7 @@ PUT /api/v1/admin/packages/{id}/courses
|
||||
POST /api/v1/admin/packages/{id}/submit
|
||||
```
|
||||
|
||||
✅ 提交成功,状态变为 `PENDING_REVIEW`
|
||||
✅ 提交成功,状态变为 `PENDING`
|
||||
|
||||
#### 4. 审核通过
|
||||
|
||||
|
||||
145
docs/dev-logs/2026-03-17.md
Normal file
145
docs/dev-logs/2026-03-17.md
Normal file
@ -0,0 +1,145 @@
|
||||
# 开发日志 - 2026-03-17
|
||||
|
||||
## 多地点登录支持实现
|
||||
|
||||
### 修改内容
|
||||
|
||||
#### 1. JwtTokenRedisService.java
|
||||
|
||||
**文件路径**: `reading-platform-java/src/main/java/com/reading/platform/common/security/JwtTokenRedisService.java`
|
||||
|
||||
**修改内容**:
|
||||
- 修改 `validateToken()` 方法,移除了 token 一致性检查
|
||||
- 现在只检查 token 是否在黑名单中
|
||||
- 允许同一账号有多个有效的 token,支持多地点同时登录
|
||||
|
||||
**修改前逻辑**:
|
||||
```java
|
||||
// 检查是否与存储的 token 一致
|
||||
String storedToken = getStoredToken(username);
|
||||
if (storedToken == null) {
|
||||
return true;
|
||||
}
|
||||
boolean isValid = token.equals(storedToken);
|
||||
return isValid; // 如果不一致则返回 false,导致互踢下线
|
||||
```
|
||||
|
||||
**修改后逻辑**:
|
||||
```java
|
||||
// 仅检查是否在黑名单中
|
||||
if (isBlacklisted(token)) {
|
||||
return false;
|
||||
}
|
||||
// 不再检查是否与存储的 token 一致,允许同一账号有多个有效 token
|
||||
return true;
|
||||
```
|
||||
|
||||
#### 2. JwtAuthenticationFilter.java
|
||||
|
||||
**文件路径**: `reading-platform-java/src/main/java/com/reading/platform/common/security/JwtAuthenticationFilter.java`
|
||||
|
||||
**修改内容**:
|
||||
- 新增依赖注入:`AdminUserMapper`, `TenantMapper`, `TeacherMapper`, `ParentMapper`
|
||||
- 在 token 验证通过后,增加账户状态检查
|
||||
- 新增 `isAccountActive()` 方法,根据用户角色查询对应表验证账户状态
|
||||
|
||||
**新增代码**:
|
||||
```java
|
||||
// 检查账户状态是否为 active
|
||||
if (!isAccountActive(payload)) {
|
||||
log.debug("Account is not active for user: {}", payload.getUsername());
|
||||
sendError(response, HttpStatus.UNAUTHORIZED, "账户已被禁用,请联系管理员");
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
**新增方法**:
|
||||
```java
|
||||
private boolean isAccountActive(JwtPayload payload) {
|
||||
String role = payload.getRole();
|
||||
Long userId = payload.getUserId();
|
||||
|
||||
return switch (role) {
|
||||
case "admin" -> {
|
||||
AdminUser adminUser = adminUserMapper.selectById(userId);
|
||||
yield adminUser != null && "active".equalsIgnoreCase(adminUser.getStatus());
|
||||
}
|
||||
case "school" -> {
|
||||
Tenant tenant = tenantMapper.selectById(userId);
|
||||
yield tenant != null && "active".equalsIgnoreCase(tenant.getStatus());
|
||||
}
|
||||
case "teacher" -> {
|
||||
Teacher teacher = teacherMapper.selectById(userId);
|
||||
yield teacher != null && "active".equalsIgnoreCase(teacher.getStatus());
|
||||
}
|
||||
case "parent" -> {
|
||||
Parent parent = parentMapper.selectById(userId);
|
||||
yield parent != null && "active".equalsIgnoreCase(parent.getStatus());
|
||||
}
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. AuthServiceImpl.java
|
||||
|
||||
**文件路径**: `reading-platform-java/src/main/java/com/reading/platform/service/impl/AuthServiceImpl.java`
|
||||
|
||||
**修改内容**:
|
||||
- 更新 `logout()` 方法的注释,说明当前实现
|
||||
|
||||
#### 4. 状态判断忽略大小写修改
|
||||
|
||||
**修改内容**:
|
||||
- 将所有 `"active".equals(status)` 修改为 `"active".equalsIgnoreCase(status)`
|
||||
- 确保大小写不敏感的状态判断,如 "Active"、"ACTIVE"、"active" 都被识别为激活状态
|
||||
|
||||
**修改文件**:
|
||||
- `JwtAuthenticationFilter.java` - `isAccountActive()` 方法中的 4 处判断
|
||||
- `AuthServiceImpl.java` - `login()` 方法中的 8 处判断(admin、teacher、parent、tenant 各 2 处)
|
||||
|
||||
### 功能说明
|
||||
|
||||
#### 多地点登录
|
||||
- 同一账号可以在多个设备/浏览器同时登录
|
||||
- 各个登录状态的 token 都有效,不会互踢下线
|
||||
- JWT token 本身的过期时间(默认 24 小时)保证安全性
|
||||
|
||||
#### 账户状态验证
|
||||
- 每次请求都会验证账户状态是否为 "active"
|
||||
- 如果管理员在后台禁用某个账号,该账号的所有已登录会话将立即失效
|
||||
- 支持所有角色:admin、school、teacher、parent
|
||||
|
||||
#### 黑名单机制
|
||||
- 黑名单机制仍然有效
|
||||
- 可以用于主动使特定 token 失效(如踢人下线场景)
|
||||
|
||||
### 验证步骤
|
||||
|
||||
1. **多地点登录测试**:
|
||||
- 使用同一账号在两个不同的浏览器登录
|
||||
- 在两个浏览器都发起 API 请求
|
||||
- 预期:两个登录都保持有效
|
||||
|
||||
2. **账户状态禁用测试**:
|
||||
- 使用账号 A 在浏览器 1 登录
|
||||
- 在超管后台将账号 A 的状态修改为"非激活"
|
||||
- 在浏览器 1 再次发起请求,应返回"账户已被禁用"
|
||||
|
||||
### 安全性考虑
|
||||
|
||||
1. JWT token 有过期时间(默认 24 小时),过期后自动失效
|
||||
2. 黑名单机制仍然有效,可以主动使特定 token 失效
|
||||
3. 每次请求都会验证账户状态,确保禁用账号无法访问
|
||||
|
||||
### 影响范围
|
||||
|
||||
- 所有角色的登录认证流程
|
||||
- Token 验证流程
|
||||
- 账户状态检查
|
||||
|
||||
### 兼容性
|
||||
|
||||
- 此修改不影响现有功能
|
||||
- 登出、黑名单等功能仍然正常工作
|
||||
- 前端无需修改
|
||||
@ -241,7 +241,7 @@ export class CoursePackageService {
|
||||
return this.prisma.coursePackage.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'PENDING_REVIEW',
|
||||
status: 'PENDING',
|
||||
submittedAt: new Date(),
|
||||
submittedBy: userId,
|
||||
},
|
||||
@ -262,7 +262,7 @@ export class CoursePackageService {
|
||||
throw new Error('套餐不存在');
|
||||
}
|
||||
|
||||
if (pkg.status !== 'PENDING_REVIEW') {
|
||||
if (pkg.status !== 'PENDING') {
|
||||
throw new Error('只有待审核状态的套餐可以审核');
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
<title>幼儿阅读教学服务平台</title>
|
||||
<!-- 阿里云IMM -->
|
||||
<script src="https://g.alicdn.com/IMM/office-js/1.1.19/aliyun-web-office-sdk.min.js"></script>
|
||||
<link rel="stylesheet"
|
||||
href="https://g.alicdn.com/apsara-media-box/imp-web-player/2.16.3/skins/default/aliplayer-min.css" />
|
||||
<script charset="utf-8" type="text/javascript"
|
||||
src="https://g.alicdn.com/apsara-media-box/imp-web-player/2.16.3/aliplayer-min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@ -71,6 +71,7 @@ export interface CreateTenantDto {
|
||||
contactPerson?: string;
|
||||
contactPhone?: string;
|
||||
packageType?: string;
|
||||
packageId?: number;
|
||||
teacherQuota?: number;
|
||||
studentQuota?: number;
|
||||
startDate?: string;
|
||||
@ -96,6 +97,17 @@ export interface UpdateTenantQuotaDto {
|
||||
studentQuota?: number;
|
||||
}
|
||||
|
||||
// 后端返回的统计数据结构
|
||||
export interface AdminStatsResponse {
|
||||
totalTenants: number;
|
||||
activeTenants: number;
|
||||
totalCourses: number;
|
||||
totalStudents: number;
|
||||
totalTeachers: number;
|
||||
totalLessons: number;
|
||||
}
|
||||
|
||||
// 前端使用的统计数据结构
|
||||
export interface AdminStats {
|
||||
tenantCount: number;
|
||||
activeTenantCount: number;
|
||||
@ -107,6 +119,15 @@ export interface AdminStats {
|
||||
monthlyLessons: number;
|
||||
}
|
||||
|
||||
// 后端返回的趋势数据结构(分离数组格式)
|
||||
export interface StatsTrendResponse {
|
||||
dates: string[];
|
||||
newStudents: number[];
|
||||
newTeachers: number[];
|
||||
newCourses: number[];
|
||||
}
|
||||
|
||||
// 前端使用的趋势数据结构
|
||||
export interface TrendData {
|
||||
month: string;
|
||||
tenantCount: number;
|
||||
@ -114,14 +135,32 @@ export interface TrendData {
|
||||
studentCount: number;
|
||||
}
|
||||
|
||||
// 后端返回的活跃租户结构
|
||||
export interface ActiveTenantResponse {
|
||||
tenantId: number;
|
||||
tenantName: string;
|
||||
activeUsers: number;
|
||||
courseCount: number;
|
||||
}
|
||||
|
||||
// 前端使用的活跃租户结构
|
||||
export interface ActiveTenant {
|
||||
id: number;
|
||||
name: string;
|
||||
lessonCount: number;
|
||||
teacherCount: number;
|
||||
studentCount: number;
|
||||
teacherCount: number | string;
|
||||
studentCount: number | string;
|
||||
}
|
||||
|
||||
// 后端返回的热门课程结构
|
||||
export interface PopularCourseResponse {
|
||||
courseId: number;
|
||||
courseName: string;
|
||||
usageCount: number;
|
||||
teacherCount: number;
|
||||
}
|
||||
|
||||
// 前端使用的热门课程结构
|
||||
export interface PopularCourse {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -129,6 +168,21 @@ export interface PopularCourse {
|
||||
teacherCount: number;
|
||||
}
|
||||
|
||||
export interface CoursePackage {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
discountPrice?: number;
|
||||
discountType?: string;
|
||||
gradeLevels?: string[];
|
||||
courseCount: number;
|
||||
status: string;
|
||||
publishedAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
// Basic settings
|
||||
systemName?: string;
|
||||
@ -195,17 +249,76 @@ export const deleteTenant = (id: number) =>
|
||||
|
||||
// ==================== 统计数据 ====================
|
||||
|
||||
export const getAdminStats = () =>
|
||||
http.get<AdminStats>('/v1/admin/stats');
|
||||
// 数据映射函数:后端 -> 前端
|
||||
const mapStatsData = (data: AdminStatsResponse): AdminStats => ({
|
||||
tenantCount: data.totalTenants || 0,
|
||||
activeTenantCount: data.activeTenants || 0,
|
||||
courseCount: data.totalCourses || 0,
|
||||
studentCount: data.totalStudents || 0,
|
||||
teacherCount: data.totalTeachers || 0,
|
||||
lessonCount: data.totalLessons || 0,
|
||||
publishedCourseCount: 0, // 后端暂无此数据
|
||||
monthlyLessons: 0, // 后端暂无此数据
|
||||
});
|
||||
|
||||
export const getTrendData = () =>
|
||||
http.get<TrendData[]>('/v1/admin/stats/trend');
|
||||
// 趋势数据映射:分离数组 -> 对象数组
|
||||
const mapTrendData = (data: StatsTrendResponse): TrendData[] => {
|
||||
if (!data || !data.dates || data.dates.length === 0) return [];
|
||||
return data.dates.map((date, index) => ({
|
||||
month: date,
|
||||
tenantCount: 0, // 后端无此数据
|
||||
lessonCount: data.newCourses?.[index] || 0,
|
||||
studentCount: data.newStudents?.[index] || 0,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getActiveTenants = (limit?: number) =>
|
||||
http.get<ActiveTenant[]>('/v1/admin/stats/tenants/active', { params: { limit } });
|
||||
// 活跃租户数据映射
|
||||
const mapActiveTenants = (data: ActiveTenantResponse[]): ActiveTenant[] => {
|
||||
if (!data || data.length === 0) return [];
|
||||
return data.map(item => ({
|
||||
id: item.tenantId,
|
||||
name: item.tenantName,
|
||||
teacherCount: '-', // 后端无单独字段
|
||||
studentCount: '-', // 后端无单独字段
|
||||
lessonCount: item.courseCount, // 使用 courseCount 替代
|
||||
}));
|
||||
};
|
||||
|
||||
export const getPopularCourses = (limit?: number) =>
|
||||
http.get<PopularCourse[]>('/v1/admin/stats/courses/popular', { params: { limit } });
|
||||
// 热门课程数据映射
|
||||
const mapPopularCourses = (data: PopularCourseResponse[]): PopularCourse[] => {
|
||||
if (!data || data.length === 0) return [];
|
||||
return data.map(item => ({
|
||||
id: item.courseId,
|
||||
name: item.courseName,
|
||||
usageCount: item.usageCount,
|
||||
teacherCount: item.teacherCount,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getAdminStats = async () => {
|
||||
const data = await http.get<AdminStatsResponse>('/v1/admin/stats');
|
||||
return mapStatsData(data);
|
||||
};
|
||||
|
||||
export const getTrendData = async () => {
|
||||
const data = await http.get<StatsTrendResponse>('/v1/admin/stats/trend');
|
||||
return mapTrendData(data);
|
||||
};
|
||||
|
||||
export const getActiveTenants = async (limit?: number) => {
|
||||
const data = await http.get<ActiveTenantResponse[]>('/v1/admin/stats/tenants/active', { params: { limit } });
|
||||
return mapActiveTenants(data);
|
||||
};
|
||||
|
||||
export const getPopularCourses = async (limit?: number) => {
|
||||
const data = await http.get<PopularCourseResponse[]>('/v1/admin/stats/courses/popular', { params: { limit } });
|
||||
return mapPopularCourses(data);
|
||||
};
|
||||
|
||||
// ==================== 课程套餐 ====================
|
||||
|
||||
export const getPublishedPackages = () =>
|
||||
http.get<CoursePackage[]>('/v1/admin/packages/all');
|
||||
|
||||
// ==================== 系统设置 ====================
|
||||
|
||||
|
||||
@ -174,6 +174,7 @@ export const fileApi = {
|
||||
| "ppt"
|
||||
| "poster"
|
||||
| "document"
|
||||
| "resource"
|
||||
| "other",
|
||||
options?: {
|
||||
onProgress?: (percent: number) => void;
|
||||
@ -235,6 +236,7 @@ export const FILE_TYPES = {
|
||||
PPT: "ppt",
|
||||
POSTER: "poster",
|
||||
DOCUMENT: "document",
|
||||
RESOURCE: "resource",
|
||||
OTHER: "other",
|
||||
} as const;
|
||||
|
||||
@ -249,6 +251,7 @@ export const FILE_SIZE_LIMITS = {
|
||||
PPT: 300 * 1024 * 1024, // 300MB
|
||||
POSTER: 10 * 1024 * 1024, // 10MB
|
||||
DOCUMENT: 300 * 1024 * 1024, // 300MB
|
||||
RESOURCE: 100 * 1024 * 1024, // 100MB(资源库单文件限制)
|
||||
OTHER: 300 * 1024 * 1024, // 300MB
|
||||
} as const;
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ export interface CoursePackage {
|
||||
gradeLevels?: string;
|
||||
/** 课程数量 */
|
||||
courseCount?: number;
|
||||
/** 状态:DRAFT、PENDING_REVIEW、APPROVED、REJECTED、PUBLISHED、OFFLINE */
|
||||
/** 状态:DRAFT、PENDING、APPROVED、REJECTED、PUBLISHED、OFFLINE */
|
||||
status?: string;
|
||||
/** 提交时间 */
|
||||
submittedAt?: string;
|
||||
|
||||
@ -97,7 +97,7 @@ export function submitPackage(id: number | string) {
|
||||
}
|
||||
|
||||
// 审核套餐
|
||||
export function reviewPackage(id: number | string, data: { approved: boolean; comment?: string }) {
|
||||
export function reviewPackage(id: number | string, data: { approved: boolean; comment?: string; publish?: boolean }) {
|
||||
return http.post(`/v1/admin/packages/${id}/review`, data);
|
||||
}
|
||||
|
||||
|
||||
@ -128,7 +128,7 @@ export const deleteResourceItem = (id: number) =>
|
||||
http.delete(`/v1/admin/resources/items/${id}`);
|
||||
|
||||
export const batchDeleteResourceItems = (ids: number[]) =>
|
||||
http.post<{ message: string }>('/v1/admin/resources/items/batch-delete', { ids });
|
||||
http.post<{ message: string }>('/v1/admin/resources/items/batch-delete', ids);
|
||||
|
||||
// ==================== 统计数据 ====================
|
||||
|
||||
|
||||
@ -334,11 +334,34 @@ export const updateSettings = (data: UpdateSettingsDto) =>
|
||||
|
||||
// ==================== 课程管理 ====================
|
||||
|
||||
export interface Course {
|
||||
id: number;
|
||||
tenantId?: number;
|
||||
name: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
coverUrl?: string;
|
||||
coverImagePath?: string;
|
||||
category?: string;
|
||||
ageRange?: string;
|
||||
difficultyLevel?: string;
|
||||
durationMinutes?: number;
|
||||
objectives?: string;
|
||||
status: string;
|
||||
isSystem: number;
|
||||
version?: string;
|
||||
usageCount?: number;
|
||||
teacherCount?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
publishedAt?: string;
|
||||
}
|
||||
|
||||
export const getSchoolCourses = () =>
|
||||
http.get<any[]>('/v1/school/courses');
|
||||
http.get<Course[]>('/v1/school/courses');
|
||||
|
||||
export const getSchoolCourse = (id: number) =>
|
||||
http.get<any>(`/v1/school/courses/${id}`);
|
||||
http.get<Course>(`/v1/school/courses/${id}`);
|
||||
|
||||
// ==================== 班级教师管理 ====================
|
||||
|
||||
|
||||
@ -181,26 +181,52 @@ export interface StudentRecordDto {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// 后端状态值: scheduled, in_progress, completed, cancelled
|
||||
const STATUS_TO_BACKEND: Record<string, string> = {
|
||||
PLANNED: 'scheduled',
|
||||
IN_PROGRESS: 'in_progress',
|
||||
COMPLETED: 'completed',
|
||||
CANCELLED: 'cancelled',
|
||||
};
|
||||
|
||||
// 获取授课记录列表
|
||||
export function getLessons(params?: {
|
||||
pageNum?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
courseId?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<{
|
||||
items: any[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}> {
|
||||
return http.get('/v1/teacher/lessons', {
|
||||
params: {
|
||||
pageNum: params?.pageNum,
|
||||
pageSize: params?.pageSize,
|
||||
status: params?.status,
|
||||
startDate: params?.courseId, // 如果需要可以传其他参数
|
||||
},
|
||||
}) as any;
|
||||
const pageNum = params?.pageNum ?? params?.page ?? 1;
|
||||
const status = params?.status ? (STATUS_TO_BACKEND[params.status] || params.status) : undefined;
|
||||
return http
|
||||
.get<{ list?: any[]; records?: any[]; total?: number | string; pageNum?: number; pageSize?: number }>(
|
||||
'/v1/teacher/lessons',
|
||||
{
|
||||
params: {
|
||||
pageNum,
|
||||
pageSize: params?.pageSize ?? 10,
|
||||
status,
|
||||
startDate: params?.startDate,
|
||||
endDate: params?.endDate,
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((res) => {
|
||||
const list = res?.list ?? res?.records ?? [];
|
||||
return {
|
||||
items: Array.isArray(list) ? list : [],
|
||||
total: typeof res?.total === 'string' ? parseInt(res.total, 10) || 0 : (res?.total ?? 0),
|
||||
page: res?.pageNum ?? pageNum,
|
||||
pageSize: res?.pageSize ?? 10,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 获取单个授课记录详情(id 使用 string 避免 Long 精度丢失)
|
||||
|
||||
19
reading-platform-frontend/src/components.d.ts
vendored
19
reading-platform-frontend/src/components.d.ts
vendored
@ -7,16 +7,11 @@ export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AAlert: typeof import('ant-design-vue/es')['Alert']
|
||||
AAvatar: typeof import('ant-design-vue/es')['Avatar']
|
||||
ABadge: typeof import('ant-design-vue/es')['Badge']
|
||||
AButton: typeof import('ant-design-vue/es')['Button']
|
||||
AButtonGroup: typeof import('ant-design-vue/es')['ButtonGroup']
|
||||
ACard: typeof import('ant-design-vue/es')['Card']
|
||||
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
|
||||
ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
|
||||
ACol: typeof import('ant-design-vue/es')['Col']
|
||||
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
|
||||
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
|
||||
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
|
||||
ADivider: typeof import('ant-design-vue/es')['Divider']
|
||||
@ -39,34 +34,24 @@ declare module 'vue' {
|
||||
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
|
||||
AModal: typeof import('ant-design-vue/es')['Modal']
|
||||
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
|
||||
APagination: typeof import('ant-design-vue/es')['Pagination']
|
||||
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
|
||||
AProgress: typeof import('ant-design-vue/es')['Progress']
|
||||
ARadio: typeof import('ant-design-vue/es')['Radio']
|
||||
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
|
||||
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
|
||||
ARate: typeof import('ant-design-vue/es')['Rate']
|
||||
ARow: typeof import('ant-design-vue/es')['Row']
|
||||
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
|
||||
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
|
||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
||||
ASpin: typeof import('ant-design-vue/es')['Spin']
|
||||
AStatistic: typeof import('ant-design-vue/es')['Statistic']
|
||||
AStep: typeof import('ant-design-vue/es')['Step']
|
||||
ASteps: typeof import('ant-design-vue/es')['Steps']
|
||||
ATable: typeof import('ant-design-vue/es')['Table']
|
||||
ATabPane: typeof import('ant-design-vue/es')['TabPane']
|
||||
ATabs: typeof import('ant-design-vue/es')['Tabs']
|
||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||
ATimeRangePicker: typeof import('ant-design-vue/es')['TimeRangePicker']
|
||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||
AUpload: typeof import('ant-design-vue/es')['Upload']
|
||||
FilePreviewModal: typeof import('./components/FilePreviewModal.vue')['default']
|
||||
FileUploader: typeof import('./components/course/FileUploader.vue')['default']
|
||||
LessonConfigPanel: typeof import('./components/course/LessonConfigPanel.vue')['default']
|
||||
LessonStepsEditor: typeof import('./components/course/LessonStepsEditor.vue')['default']
|
||||
NotificationBell: typeof import('./components/NotificationBell.vue')['default']
|
||||
PressDrag: typeof import('./components/PressDrag.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Step1BasicInfo: typeof import('./components/course-edit/Step1BasicInfo.vue')['default']
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
<template>
|
||||
<a-modal v-model:open="visible" :title="title" :width="modalWidth" :footer="null" centered @cancel="handleClose">
|
||||
<!-- PDF 预览 -->
|
||||
<div v-if="fileType === 'pdf'" class="preview-container pdf-container">
|
||||
<iframe v-if="previewUrl" :src="previewUrl" class="pdf-iframe" frameborder="0"></iframe>
|
||||
<div v-else class="preview-error">
|
||||
<div v-if="fileType === 'pdf'" class="preview-container ppt-container">
|
||||
<div class="ppt-notice">
|
||||
<FileTextOutlined style="font-size: 48px; color: #999;" />
|
||||
<p>无法预览此PDF文件</p>
|
||||
<a-button type="primary" @click="downloadFile">下载查看</a-button>
|
||||
<h3>PDF 文件预览</h3>
|
||||
<p>使用 WebOffice 在新页面中预览</p>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="openInWebOffice">
|
||||
<EyeOutlined /> PDF 预览
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -26,10 +30,11 @@
|
||||
</div>
|
||||
|
||||
<!-- 视频播放器 -->
|
||||
<div v-else-if="fileType === 'video'" class="preview-container video-container">
|
||||
<video ref="videoRef" :src="previewUrl" controls class="video-element" @error="handleMediaError">
|
||||
<div v-else-if="fileType === 'video'" class="preview-container video-container relative h-600px">
|
||||
<!-- <video ref="videoRef" :src="previewUrl" controls class="video-element" @error="handleMediaError">
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</video> -->
|
||||
<player v-if="visible" :url="previewUrl" :noPage="true" />
|
||||
</div>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
@ -107,7 +112,7 @@ import {
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { openWebOffice } from '@/views/office/webOffice';
|
||||
|
||||
import Player from '@/views/office/player.vue';
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
fileUrl: string;
|
||||
@ -169,8 +174,6 @@ const title = computed(() => {
|
||||
// 模态框宽度
|
||||
const modalWidth = computed(() => {
|
||||
switch (fileType.value) {
|
||||
case 'pdf':
|
||||
return '90%';
|
||||
case 'video':
|
||||
return 900;
|
||||
case 'audio':
|
||||
|
||||
141
reading-platform-frontend/src/components/PressDrag.vue
Normal file
141
reading-platform-frontend/src/components/PressDrag.vue
Normal file
@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div ref="" id="draggableElementView" class="draggable-box cursor-grab" :style="elementStyle"
|
||||
@mousedown.passive="handleStart" @touchstart.passive="handleStart" @click="handleClick">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, onUnmounted } from 'vue';
|
||||
import { addResizeTimeOut, removeResizeTimeOut } from '@/utils';
|
||||
export default defineComponent({
|
||||
name: 'PressDragComponent',
|
||||
emits: ['click'],
|
||||
props: {
|
||||
storageKey: {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const view = {
|
||||
offsetWidth: 50,
|
||||
offsetHeight: 50,
|
||||
innerWidth: 750,
|
||||
innerHeight: 1280,
|
||||
};
|
||||
const storageKeyVal = `_tem_drag_${props.storageKey}`;
|
||||
|
||||
const initil = () => {
|
||||
const node = document.querySelector('#draggableElementView') as HTMLElement;
|
||||
view.offsetWidth = node.offsetWidth;
|
||||
view.offsetHeight = node.offsetHeight;
|
||||
|
||||
view.innerWidth =
|
||||
window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
||||
view.innerHeight =
|
||||
window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
|
||||
};
|
||||
addResizeTimeOut(initil);
|
||||
const isDragging = ref(false);
|
||||
const pressTimer = ref<number | null>(null);
|
||||
const startTime = ref(0);
|
||||
const elementStyle = ref<any>({
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
});
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initil();
|
||||
// if (props.storageKey && props.storageKey.length > 0) {
|
||||
// let storageVal: any = localStorage.getItem(storageKeyVal);
|
||||
// if (storageVal && storageVal.length > 0) {
|
||||
// storageVal = storageVal.split(',');
|
||||
// setStyle(storageVal[0], storageVal[1]);
|
||||
// }
|
||||
// }
|
||||
});
|
||||
});
|
||||
const handleStart = (e: MouseEvent | TouchEvent) => {
|
||||
e.preventDefault();
|
||||
startTime.value = Date.now();
|
||||
pressTimer.value = window.setTimeout(() => {
|
||||
isDragging.value = true;
|
||||
document.addEventListener('mousemove', handleDrag);
|
||||
document.addEventListener('touchmove', handleDrag, { passive: false });
|
||||
document.addEventListener('mouseup', handleEnd);
|
||||
document.addEventListener('touchend', handleEnd);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleDrag = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
e.preventDefault();
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
let left = clientX - view.offsetWidth / 2;
|
||||
let top = clientY - view.offsetHeight / 2;
|
||||
|
||||
left = left > view.innerWidth - view.offsetWidth ? view.innerWidth - view.offsetWidth : left;
|
||||
top = top > view.innerHeight - view.offsetHeight ? view.innerHeight - view.offsetHeight : top;
|
||||
setStyle(left, top);
|
||||
};
|
||||
const setStyle = (left: number, top: number) => {
|
||||
if (props.storageKey && props.storageKey.length > 0) {
|
||||
localStorage.setItem(storageKeyVal, [left, top].join(','));
|
||||
}
|
||||
|
||||
// if (left > innerWidth - 90) {
|
||||
// left = innerWidth - 90;
|
||||
// } else if (left < 30) {
|
||||
// left = 30;
|
||||
// }
|
||||
// if (top > innerHeight - 90) {
|
||||
// top = innerHeight - 90;
|
||||
// } else if (top < 30) {
|
||||
// top = 30;
|
||||
// }
|
||||
|
||||
left = (left / view.innerWidth) * 100;
|
||||
left = left < 0 ? 0 : left;
|
||||
|
||||
top = (top / view.innerHeight) * 100;
|
||||
top = top < 0 ? 0 : top;
|
||||
elementStyle.value = {
|
||||
...elementStyle.value,
|
||||
left: `${left}%`,
|
||||
top: `${top}%`,
|
||||
};
|
||||
};
|
||||
const handleEnd = () => {
|
||||
clearTimeout(pressTimer.value!);
|
||||
document.removeEventListener('mousemove', handleDrag);
|
||||
document.removeEventListener('touchmove', handleDrag);
|
||||
document.removeEventListener('mouseup', handleEnd);
|
||||
document.removeEventListener('touchend', handleEnd);
|
||||
isDragging.value = false;
|
||||
};
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (Date.now() - startTime.value > 200) return;
|
||||
emit('click', e);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
handleEnd();
|
||||
|
||||
removeResizeTimeOut(initil);
|
||||
});
|
||||
return { elementStyle, handleStart, handleClick };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* .draggable-box {
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.1s, box-shadow 0.3s;
|
||||
} */
|
||||
|
||||
.draggable-box:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
</style>
|
||||
51
reading-platform-frontend/src/utils/index.ts
Normal file
51
reading-platform-frontend/src/utils/index.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 延迟
|
||||
* @param ms
|
||||
* @returns
|
||||
*/
|
||||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
/***
|
||||
* 窗口resize事件封装
|
||||
*/
|
||||
const resizeObjs = reactive<{
|
||||
time: any;
|
||||
callbackList: Function[];
|
||||
}>({
|
||||
time: -1,
|
||||
callbackList: [],
|
||||
});
|
||||
window.addEventListener(
|
||||
"resize",
|
||||
() => {
|
||||
if (resizeObjs.time >= 0) {
|
||||
clearTimeout(resizeObjs.time);
|
||||
}
|
||||
resizeObjs.time = setTimeout(() => {
|
||||
resizeObjs.time = -1;
|
||||
try {
|
||||
resizeObjs.callbackList.forEach((callback: Function) => {
|
||||
callback();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, 600);
|
||||
},
|
||||
false,
|
||||
);
|
||||
/**
|
||||
* @param callback 添加窗口变化事件
|
||||
*/
|
||||
export function addResizeTimeOut(callback: Function) {
|
||||
resizeObjs.callbackList.push(callback);
|
||||
}
|
||||
/**
|
||||
* @param callback 移除窗口变化事件
|
||||
*/
|
||||
export function removeResizeTimeOut(callback: Function) {
|
||||
resizeObjs.callbackList = resizeObjs.callbackList.filter((_callback) => {
|
||||
return callback != _callback;
|
||||
});
|
||||
}
|
||||
@ -82,9 +82,9 @@
|
||||
<div class="rank-number" :class="'rank-' + (index + 1)">{{ index + 1 }}</div>
|
||||
<div class="rank-content">
|
||||
<span class="rank-name">{{ item.name }}</span>
|
||||
<span class="rank-desc">教师: {{ item.teacherCount }} | 学生: {{ item.studentCount }}</span>
|
||||
<span class="rank-desc">活跃用户: {{ item.lessonCount }}</span>
|
||||
</div>
|
||||
<a-tag color="blue">{{ item.lessonCount }} 次</a-tag>
|
||||
<a-tag color="blue">{{ item.lessonCount }} 课程</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="暂无数据" />
|
||||
|
||||
@ -84,7 +84,7 @@ const courseColumns = [
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'default',
|
||||
PENDING_REVIEW: 'processing',
|
||||
PENDING: 'processing',
|
||||
APPROVED: 'success',
|
||||
REJECTED: 'error',
|
||||
PUBLISHED: 'blue',
|
||||
@ -93,7 +93,7 @@ const statusColors: Record<string, string> = {
|
||||
|
||||
const statusTexts: Record<string, string> = {
|
||||
DRAFT: '草稿',
|
||||
PENDING_REVIEW: '待审核',
|
||||
PENDING: '待审核',
|
||||
APPROVED: '已通过',
|
||||
REJECTED: '已拒绝',
|
||||
PUBLISHED: '已发布',
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
@change="fetchData"
|
||||
>
|
||||
<a-select-option value="DRAFT">草稿</a-select-option>
|
||||
<a-select-option value="PENDING_REVIEW">待审核</a-select-option>
|
||||
<a-select-option value="PENDING">待审核</a-select-option>
|
||||
<a-select-option value="APPROVED">已通过</a-select-option>
|
||||
<a-select-option value="REJECTED">已拒绝</a-select-option>
|
||||
<a-select-option value="PUBLISHED">已发布</a-select-option>
|
||||
@ -81,7 +81,7 @@
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
v-if="record.status === 'PENDING_REVIEW'"
|
||||
v-if="record.status === 'PENDING'"
|
||||
@click="handleReview(record)"
|
||||
>
|
||||
审核
|
||||
@ -143,7 +143,7 @@ const columns = [
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'default',
|
||||
PENDING_REVIEW: 'processing',
|
||||
PENDING: 'processing',
|
||||
APPROVED: 'success',
|
||||
REJECTED: 'error',
|
||||
PUBLISHED: 'blue',
|
||||
@ -152,7 +152,7 @@ const statusColors: Record<string, string> = {
|
||||
|
||||
const statusTexts: Record<string, string> = {
|
||||
DRAFT: '草稿',
|
||||
PENDING_REVIEW: '待审核',
|
||||
PENDING: '待审核',
|
||||
APPROVED: '已通过',
|
||||
REJECTED: '已拒绝',
|
||||
PUBLISHED: '已发布',
|
||||
@ -183,7 +183,7 @@ const fetchData = async () => {
|
||||
pagination.total = res.total || 0;
|
||||
// 获取待审核数量
|
||||
try {
|
||||
const pendingRes = await getPackageList({ status: 'PENDING_REVIEW', pageNum: 1, pageSize: 1 }) as any;
|
||||
const pendingRes = await getPackageList({ status: 'PENDING', pageNum: 1, pageSize: 1 }) as any;
|
||||
pendingCount.value = pendingRes.total || 0;
|
||||
} catch {
|
||||
pendingCount.value = 0;
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-select v-model:value="filters.status" placeholder="全部状态" style="width: 120px" @change="fetchPackages">
|
||||
<a-select-option value="PENDING_REVIEW">待审核</a-select-option>
|
||||
<a-select-option value="PENDING">待审核</a-select-option>
|
||||
<a-select-option value="REJECTED">已驳回</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="fetchPackages">
|
||||
@ -39,8 +39,8 @@
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'PENDING_REVIEW' ? 'processing' : 'error'">
|
||||
{{ record.status === 'PENDING_REVIEW' ? '待审核' : '已驳回' }}
|
||||
<a-tag :color="record.status === 'PENDING' ? 'processing' : 'error'">
|
||||
{{ record.status === 'PENDING' ? '待审核' : '已驳回' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'submittedAt'">
|
||||
@ -48,7 +48,7 @@
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button v-if="record.status === 'PENDING_REVIEW'" type="primary" size="small" @click="showReviewModal(record)">
|
||||
<a-button v-if="record.status === 'PENDING'" type="primary" size="small" @click="showReviewModal(record)">
|
||||
审核
|
||||
</a-button>
|
||||
<a-button v-if="record.status === 'REJECTED'" size="small" @click="viewRejectReason(record)">
|
||||
@ -107,12 +107,15 @@
|
||||
</a-form>
|
||||
|
||||
<div class="modal-footer">
|
||||
<a-space v-if="currentPackage.status === 'PENDING_REVIEW'">
|
||||
<a-space v-if="currentPackage.status === 'PENDING'">
|
||||
<a-button @click="closeReviewModal">取消</a-button>
|
||||
<a-button type="default" danger :loading="reviewing" @click="rejectPackage">
|
||||
驳回
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="reviewing" @click="approvePackage">
|
||||
<a-button :loading="reviewing" @click="approveOnly">
|
||||
通过
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="reviewing" @click="approveAndPublish">
|
||||
通过并发布
|
||||
</a-button>
|
||||
</a-space>
|
||||
@ -144,7 +147,7 @@ const loadingDetail = ref(false);
|
||||
const packages = ref<CoursePackage[]>([]);
|
||||
|
||||
const filters = reactive<{ status?: string }>({
|
||||
status: 'PENDING_REVIEW',
|
||||
status: 'PENDING',
|
||||
});
|
||||
|
||||
const pagination = reactive({
|
||||
@ -227,7 +230,8 @@ const closeReviewModal = () => {
|
||||
currentPackage.value = null;
|
||||
};
|
||||
|
||||
const approvePackage = async () => {
|
||||
// 仅通过
|
||||
const approveOnly = async () => {
|
||||
if (!currentPackage.value) return;
|
||||
|
||||
reviewing.value = true;
|
||||
@ -235,6 +239,28 @@ const approvePackage = async () => {
|
||||
await reviewPackage(currentPackage.value.id, {
|
||||
approved: true,
|
||||
comment: reviewComment.value || '审核通过',
|
||||
publish: false,
|
||||
});
|
||||
message.success('审核通过');
|
||||
closeReviewModal();
|
||||
fetchPackages();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '审核失败');
|
||||
} finally {
|
||||
reviewing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 通过并发布
|
||||
const approveAndPublish = async () => {
|
||||
if (!currentPackage.value) return;
|
||||
|
||||
reviewing.value = true;
|
||||
try {
|
||||
await reviewPackage(currentPackage.value.id, {
|
||||
approved: true,
|
||||
comment: reviewComment.value || '审核通过',
|
||||
publish: true,
|
||||
});
|
||||
message.success('审核通过,套餐已发布');
|
||||
closeReviewModal();
|
||||
|
||||
@ -1,37 +1,42 @@
|
||||
<template>
|
||||
<div class="resource-list-view">
|
||||
<a-page-header
|
||||
title="资源库管理"
|
||||
sub-title="管理平台数字资源"
|
||||
/>
|
||||
<a-page-header title="资源库管理" sub-title="管理平台数字资源" />
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 16px;">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="资源库总数" :value="stats.totalLibraries">
|
||||
<template #prefix><FolderOutlined /></template>
|
||||
<template #prefix>
|
||||
<FolderOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="资源总数" :value="stats.totalItems">
|
||||
<template #prefix><FileOutlined /></template>
|
||||
<template #prefix>
|
||||
<FileOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="绘本资源" :value="stats.itemsByLibraryType?.PICTURE_BOOK || 0">
|
||||
<template #prefix><BookOutlined /></template>
|
||||
<template #prefix>
|
||||
<BookOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="教学材料" :value="stats.itemsByLibraryType?.MATERIAL || 0">
|
||||
<template #prefix><AppstoreOutlined /></template>
|
||||
<template #prefix>
|
||||
<AppstoreOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
@ -41,11 +46,15 @@
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="showLibraryModal">
|
||||
<template #icon><FolderAddOutlined /></template>
|
||||
<template #icon>
|
||||
<FolderAddOutlined />
|
||||
</template>
|
||||
新建资源库
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showUploadModal">
|
||||
<template #icon><UploadOutlined /></template>
|
||||
<template #icon>
|
||||
<UploadOutlined />
|
||||
</template>
|
||||
上传资源
|
||||
</a-button>
|
||||
</a-space>
|
||||
@ -55,36 +64,19 @@
|
||||
<div class="filter-bar" style="margin-bottom: 16px;">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-input-search
|
||||
v-model:value="filters.keyword"
|
||||
placeholder="搜索资源名称"
|
||||
allow-clear
|
||||
@search="fetchItems"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input-search>
|
||||
<a-input-search v-model:value="filters.keyword" placeholder="搜索资源名称" allow-clear @search="fetchItems" />
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-select
|
||||
v-model:value="filters.libraryId"
|
||||
placeholder="选择资源库"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
@change="fetchItems"
|
||||
>
|
||||
<a-select v-model:value="filters.libraryId" placeholder="选择资源库" allow-clear style="width: 100%"
|
||||
@change="fetchItems">
|
||||
<a-select-option v-for="lib in libraries" :key="lib.id" :value="lib.id">
|
||||
{{ lib.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-select
|
||||
v-model:value="filters.fileType"
|
||||
placeholder="资源类型"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
@change="fetchItems"
|
||||
>
|
||||
<a-select v-model:value="filters.fileType" placeholder="资源类型" allow-clear style="width: 100%"
|
||||
@change="fetchItems">
|
||||
<a-select-option value="IMAGE">图片</a-select-option>
|
||||
<a-select-option value="PDF">PDF</a-select-option>
|
||||
<a-select-option value="VIDEO">视频</a-select-option>
|
||||
@ -96,30 +88,23 @@
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:row-selection="rowSelection"
|
||||
@change="handleTableChange"
|
||||
row-key="id"
|
||||
>
|
||||
<a-table :columns="columns" :data-source="items" :loading="loading" :pagination="pagination"
|
||||
:row-selection="rowSelection" @change="handleTableChange" row-key="id">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'resource'">
|
||||
<div class="resource-item">
|
||||
<div class="resource-icon">
|
||||
<FilePdfOutlined v-if="record.fileType === 'PDF'" />
|
||||
<VideoCameraOutlined v-else-if="record.fileType === 'VIDEO'" />
|
||||
<AudioOutlined v-else-if="record.fileType === 'AUDIO'" />
|
||||
<PictureOutlined v-else-if="record.fileType === 'IMAGE'" />
|
||||
<FilePptOutlined v-else-if="record.fileType === 'PPT'" />
|
||||
<FilePdfOutlined v-if="getRecordFileType(record) === 'PDF'" />
|
||||
<VideoCameraOutlined v-else-if="getRecordFileType(record) === 'VIDEO'" />
|
||||
<AudioOutlined v-else-if="getRecordFileType(record) === 'AUDIO'" />
|
||||
<PictureOutlined v-else-if="getRecordFileType(record) === 'IMAGE'" />
|
||||
<FilePptOutlined v-else-if="getRecordFileType(record) === 'PPT'" />
|
||||
<FileOutlined v-else />
|
||||
</div>
|
||||
<div class="resource-info">
|
||||
<div class="resource-name">{{ record.title }}</div>
|
||||
<div class="resource-name">{{ record.title ?? record.name }}</div>
|
||||
<div class="resource-meta">
|
||||
<a-tag size="small">{{ getFileTypeLabel(record.fileType) }}</a-tag>
|
||||
<a-tag size="small">{{ getFileTypeLabel(getRecordFileType(record)) }}</a-tag>
|
||||
<span style="color: #999; font-size: 12px;">
|
||||
{{ formatFileSize(record.fileSize) }}
|
||||
</span>
|
||||
@ -146,12 +131,8 @@
|
||||
<a-button type="link" size="small" @click="openEditModal(record as ResourceItem)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
:disabled="!canPreview(record as ResourceItem)"
|
||||
@click="previewResource(record as ResourceItem)"
|
||||
>
|
||||
<a-button type="link" size="small" :disabled="!canPreview(record as ResourceItem)"
|
||||
@click="previewResource(record as ResourceItem)">
|
||||
预览
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="downloadResource(record as ResourceItem)">
|
||||
@ -180,12 +161,7 @@
|
||||
</a-card>
|
||||
|
||||
<!-- 新建资源库弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="libraryModalVisible"
|
||||
title="新建资源库"
|
||||
@ok="handleCreateLibrary"
|
||||
:confirm-loading="submitting"
|
||||
>
|
||||
<a-modal v-model:open="libraryModalVisible" title="新建资源库" @ok="handleCreateLibrary" :confirm-loading="submitting">
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||
<a-form-item label="资源库名称" required>
|
||||
<a-input v-model:value="libraryForm.name" placeholder="请输入资源库名称" />
|
||||
@ -204,13 +180,8 @@
|
||||
</a-modal>
|
||||
|
||||
<!-- 上传资源弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="uploadModalVisible"
|
||||
title="上传资源"
|
||||
width="600px"
|
||||
@ok="handleUpload"
|
||||
:confirm-loading="uploading"
|
||||
>
|
||||
<a-modal v-model:open="uploadModalVisible" title="上传资源" width="600px" @ok="handleUpload"
|
||||
:confirm-loading="uploading">
|
||||
<a-form :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
|
||||
<a-form-item label="目标资源库" required>
|
||||
<a-select v-model:value="uploadForm.libraryId" placeholder="请选择资源库">
|
||||
@ -222,41 +193,29 @@
|
||||
|
||||
<a-form-item label="资源类型" required>
|
||||
<a-select v-model:value="uploadForm.fileType" placeholder="请选择资源类型">
|
||||
<a-select-option value="IMAGE">图片 (JPG/PNG)</a-select-option>
|
||||
<a-select-option value="PPT">PPT (PPTX)</a-select-option>
|
||||
<a-select-option value="PDF">PDF文档</a-select-option>
|
||||
<a-select-option value="VIDEO">视频 (MP4)</a-select-option>
|
||||
<a-select-option value="IMAGE">图片 (JPG/PNG)</a-select-option>
|
||||
<a-select-option value="AUDIO">音频 (MP3)</a-select-option>
|
||||
<a-select-option value="PPT">PPT (PPTX)</a-select-option>
|
||||
<a-select-option value="OTHER">其他</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="选择文件" required>
|
||||
<a-upload
|
||||
v-model:file-list="uploadForm.files"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:data="{ type: 'resources' }"
|
||||
:max-count="10"
|
||||
list-type="text"
|
||||
@change="handleUploadChange"
|
||||
>
|
||||
<a-upload v-model:file-list="uploadForm.files" :accept="uploadAcceptTypes"
|
||||
:custom-request="handleCustomUpload" :max-count="10" list-type="text" @change="handleUploadChange">
|
||||
<a-button>
|
||||
<UploadOutlined /> 选择文件
|
||||
</a-button>
|
||||
</a-upload>
|
||||
<div style="margin-top: 8px; color: #999; font-size: 12px;">
|
||||
支持批量上传,单个文件最大 100MB
|
||||
{{ uploadFileHint }}
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="资源标签">
|
||||
<a-select
|
||||
v-model:value="uploadForm.tags"
|
||||
mode="tags"
|
||||
placeholder="输入标签,按回车添加"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select v-model:value="uploadForm.tags" mode="tags" placeholder="输入标签,按回车添加" style="width: 100%">
|
||||
<a-select-option value="绘本阅读">绘本阅读</a-select-option>
|
||||
<a-select-option value="儿歌">儿歌</a-select-option>
|
||||
<a-select-option value="游戏">游戏</a-select-option>
|
||||
@ -268,12 +227,7 @@
|
||||
</a-modal>
|
||||
|
||||
<!-- 编辑资源弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="editModalVisible"
|
||||
title="编辑资源"
|
||||
@ok="handleEditConfirm"
|
||||
:confirm-loading="submitting"
|
||||
>
|
||||
<a-modal v-model:open="editModalVisible" title="编辑资源" @ok="handleEditConfirm" :confirm-loading="submitting">
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||
<a-form-item label="资源名称">
|
||||
<a-input v-model:value="editForm.title" placeholder="资源名称" />
|
||||
@ -282,33 +236,36 @@
|
||||
<a-textarea v-model:value="editForm.description" placeholder="描述" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="标签">
|
||||
<a-select
|
||||
v-model:value="editForm.tags"
|
||||
mode="tags"
|
||||
placeholder="输入标签"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<a-select v-model:value="editForm.tags" mode="tags" placeholder="输入标签" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="previewModalVisible"
|
||||
:title="currentPreviewResource?.title"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<a-modal v-model:open="previewModalVisible" :title="currentPreviewResource?.title" width="800px" :footer="null">
|
||||
<div class="preview-container">
|
||||
<div v-if="currentPreviewResource?.fileType === 'IMAGE'">
|
||||
<img :src="getFileUrl(currentPreviewResource?.filePath)" style="max-width: 100%;" />
|
||||
</div>
|
||||
<div v-else-if="currentPreviewResource?.fileType === 'VIDEO'">
|
||||
<video :src="getFileUrl(currentPreviewResource?.filePath)" controls style="width: 100%; max-height: 500px;"></video>
|
||||
<video :src="getFileUrl(currentPreviewResource?.filePath)" controls
|
||||
style="width: 100%; max-height: 500px;"></video>
|
||||
</div>
|
||||
<div v-else-if="currentPreviewResource?.fileType === 'AUDIO'">
|
||||
<audio :src="getFileUrl(currentPreviewResource?.filePath)" controls style="width: 100%;"></audio>
|
||||
</div>
|
||||
<div v-else-if="isOfficeDoc(currentPreviewResource)">
|
||||
<div class="preview-placeholder office-preview">
|
||||
<FileTextOutlined style="font-size: 64px; color: #1890ff;" />
|
||||
<p style="color: #666;">使用在线文档预览</p>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="openInWebOffice(currentPreviewResource)">
|
||||
<EyeOutlined /> WebOffice 预览
|
||||
</a-button>
|
||||
<a-button @click="downloadResource(currentPreviewResource)">下载文件</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="preview-placeholder">
|
||||
<FileTextOutlined style="font-size: 64px; color: #ccc;" />
|
||||
<p style="color: #999;">此文件类型不支持在线预览</p>
|
||||
@ -324,7 +281,6 @@ import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
UploadOutlined,
|
||||
SearchOutlined,
|
||||
FileTextOutlined,
|
||||
FilePdfOutlined,
|
||||
FilePptOutlined,
|
||||
@ -336,6 +292,7 @@ import {
|
||||
FolderOutlined,
|
||||
FolderAddOutlined,
|
||||
AppstoreOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
getLibraries,
|
||||
@ -348,9 +305,8 @@ import {
|
||||
getResourceStats,
|
||||
} from '@/api/resource';
|
||||
import type { ResourceLibrary, ResourceItem, FileType as FileTypeType } from '@/api/resource';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
|
||||
const userStore = useUserStore();
|
||||
import { fileApi, validateFileType } from '@/api/file';
|
||||
import { openWebOffice } from '@/views/office/webOffice';
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
@ -409,17 +365,38 @@ const uploadForm = reactive({
|
||||
tags: [] as string[],
|
||||
});
|
||||
|
||||
/** 根据资源类型返回 a-upload 的 accept 限制 */
|
||||
const uploadAcceptTypes = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
IMAGE: '.jpg,.jpeg,.png,.gif,.webp,.bmp',
|
||||
PDF: '.pdf',
|
||||
VIDEO: '.mp4,.webm,.ogg,.mov,.avi,.mkv',
|
||||
AUDIO: '.mp3,.wav,.ogg,.aac,.flac,.m4a',
|
||||
PPT: '.ppt,.pptx,.pdf',
|
||||
OTHER: '',
|
||||
};
|
||||
return map[uploadForm.fileType] ?? '';
|
||||
});
|
||||
|
||||
/** 根据资源类型返回上传提示文案 */
|
||||
const uploadFileHint = computed(() => {
|
||||
const hints: Record<string, string> = {
|
||||
IMAGE: '支持 JPG、PNG、GIF、WebP、BMP,单个文件最大 100MB',
|
||||
PDF: '支持 PDF,单个文件最大 100MB',
|
||||
VIDEO: '支持 MP4、WebM、OGG、MOV、AVI、MKV,单个文件最大 100MB',
|
||||
AUDIO: '支持 MP3、WAV、OGG、AAC、FLAC、M4A,单个文件最大 100MB',
|
||||
PPT: '支持 PPT、PPTX、PDF,单个文件最大 100MB',
|
||||
OTHER: '支持任意格式,单个文件最大 100MB',
|
||||
};
|
||||
return hints[uploadForm.fileType] ?? '支持批量上传,单个文件最大 100MB';
|
||||
});
|
||||
|
||||
const editForm = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
tags: [] as string[],
|
||||
});
|
||||
|
||||
const uploadUrl = '/api/v1/files/upload';
|
||||
const uploadHeaders = computed(() => ({
|
||||
Authorization: `Bearer ${userStore.token}`,
|
||||
}));
|
||||
|
||||
const columns = [
|
||||
{ title: '资源', key: 'resource', width: 300 },
|
||||
{ title: '所属资源库', key: 'library', width: 150 },
|
||||
@ -428,6 +405,9 @@ const columns = [
|
||||
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const },
|
||||
];
|
||||
|
||||
/** 兼容后端返回 fileType 或 type */
|
||||
const getRecordFileType = (record: any) => record?.fileType ?? record?.type ?? 'OTHER';
|
||||
|
||||
const getFileTypeLabel = (type: FileTypeType) => {
|
||||
const labels: Record<FileTypeType, string> = {
|
||||
IMAGE: '图片',
|
||||
@ -447,9 +427,10 @@ const formatFileSize = (bytes?: number) => {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
return isNaN(date.getTime()) ? '-' : date.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
const getFileUrl = (path?: string) => {
|
||||
@ -460,7 +441,56 @@ const getFileUrl = (path?: string) => {
|
||||
};
|
||||
|
||||
const canPreview = (resource: ResourceItem) => {
|
||||
return ['IMAGE', 'VIDEO', 'AUDIO'].includes(resource.fileType);
|
||||
const fileType = (resource as any).fileType ?? (resource as any).type;
|
||||
if (['IMAGE', 'VIDEO', 'AUDIO', 'PDF', 'PPT'].includes(fileType)) return true;
|
||||
// OTHER 类型检查是否为 Office 文档(Word/Excel)
|
||||
if (fileType === 'OTHER' && resource.filePath) {
|
||||
const ext = resource.filePath.toLowerCase().split('.').pop()?.split('?')[0] || '';
|
||||
return ['doc', 'docx', 'xls', 'xlsx'].includes(ext);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** 是否为 Office 文档(PDF/PPT/Word/Excel),可使用 WebOffice 在线预览 */
|
||||
const isOfficeDoc = (resource: ResourceItem | null) => {
|
||||
if (!resource) return false;
|
||||
if (['PDF', 'PPT'].includes(resource.fileType)) return true;
|
||||
if (resource.fileType === 'OTHER' && resource.filePath) {
|
||||
const ext = resource.filePath.toLowerCase().split('.').pop()?.split('?')[0] || '';
|
||||
return ['doc', 'docx', 'xls', 'xlsx'].includes(ext);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** 解析文件名得到 name 和 type(扩展名) */
|
||||
const parseResourceFileName = (resource: ResourceItem) => {
|
||||
const title = (resource as any).title ?? (resource as any).name ?? '';
|
||||
const fileType = (resource as any).fileType ?? (resource as any).type;
|
||||
let type = 'pdf';
|
||||
if (fileType === 'PDF') type = 'pdf';
|
||||
else if (fileType === 'PPT') type = 'pptx';
|
||||
else if (resource.filePath) {
|
||||
const ext = resource.filePath.toLowerCase().split('.').pop()?.split('?')[0] || '';
|
||||
if (['doc', 'docx', 'xls', 'xlsx'].includes(ext)) type = ext;
|
||||
}
|
||||
return { name: title, type };
|
||||
};
|
||||
|
||||
/** 使用 WebOffice 在新窗口打开在线文档 */
|
||||
const openInWebOffice = (resource: ResourceItem | null) => {
|
||||
if (!resource) return;
|
||||
const fullUrl = getFileUrl(resource.filePath);
|
||||
if (!fullUrl.startsWith('http')) {
|
||||
message.warning('WebOffice 预览需要完整的文件 URL(请确保文件已上传至 OSS)');
|
||||
return;
|
||||
}
|
||||
const { name, type } = parseResourceFileName(resource);
|
||||
openWebOffice({
|
||||
id: String(resource.id),
|
||||
url: fullUrl,
|
||||
name,
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchLibraries = async () => {
|
||||
@ -480,8 +510,8 @@ const fetchItems = async () => {
|
||||
pageSize: pagination.pageSize,
|
||||
...filters,
|
||||
});
|
||||
items.value = result.list;
|
||||
pagination.total = result.total;
|
||||
items.value = result?.list ?? [];
|
||||
pagination.total = result?.total ?? 0;
|
||||
} catch (error) {
|
||||
message.error('获取资源列表失败');
|
||||
} finally {
|
||||
@ -491,7 +521,15 @@ const fetchItems = async () => {
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
stats.value = await getResourceStats();
|
||||
const data = await getResourceStats();
|
||||
// 兼容后端返回 totalLibraries/totalItems 或 libraryCount/itemCount
|
||||
const d = data as unknown as Record<string, unknown>;
|
||||
stats.value = {
|
||||
totalLibraries: (d?.totalLibraries ?? d?.libraryCount ?? 0) as number,
|
||||
totalItems: (d?.totalItems ?? d?.itemCount ?? 0) as number,
|
||||
itemsByType: (d?.itemsByType ?? {}) as Record<string, number>,
|
||||
itemsByLibraryType: (d?.itemsByLibraryType ?? {}) as Record<string, number>,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
}
|
||||
@ -537,6 +575,55 @@ const showUploadModal = () => {
|
||||
uploadModalVisible.value = true;
|
||||
};
|
||||
|
||||
/** OSS 直传:自定义上传请求 */
|
||||
/** 根据资源类型获取允许的扩展名列表 */
|
||||
const getAllowedExtensions = (fileType: string): string[] => {
|
||||
const map: Record<string, string[]> = {
|
||||
IMAGE: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'],
|
||||
PDF: ['pdf'],
|
||||
VIDEO: ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'],
|
||||
AUDIO: ['mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a'],
|
||||
PPT: ['ppt', 'pptx', 'pdf'],
|
||||
OTHER: [],
|
||||
};
|
||||
return map[fileType] ?? [];
|
||||
};
|
||||
|
||||
const handleCustomUpload = async (options: any) => {
|
||||
const { file, onSuccess, onError, onProgress } = options;
|
||||
const uploadFile = file instanceof File ? file : (file?.originFileObj ?? file);
|
||||
|
||||
// 按资源类型校验扩展名(OTHER 不限制)
|
||||
const allowed = getAllowedExtensions(uploadForm.fileType);
|
||||
if (allowed.length > 0) {
|
||||
const ext = (uploadFile.name?.split('.').pop() ?? '').toLowerCase();
|
||||
if (!ext || !allowed.includes(ext)) {
|
||||
message.error(`当前资源类型仅支持:${allowed.join('、').toUpperCase()},请选择正确格式的文件`);
|
||||
onError?.(new Error('文件类型不符合所选资源类型'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 校验文件大小(资源库单文件 100MB)
|
||||
const validation = validateFileType(uploadFile, 'RESOURCE');
|
||||
if (!validation.valid) {
|
||||
message.error(validation.error);
|
||||
onError?.(new Error(validation.error));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fileApi.uploadFile(uploadFile, 'resource', {
|
||||
onProgress: (percent) => onProgress?.({ percent }),
|
||||
});
|
||||
onSuccess?.({ filePath: result.filePath });
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '上传失败';
|
||||
message.error(msg);
|
||||
onError?.(new Error(msg));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadChange = (info: any) => {
|
||||
if (info.file.status === 'done') {
|
||||
message.success(`${info.file.name} 上传成功`);
|
||||
@ -728,6 +815,10 @@ onMounted(() => {
|
||||
|
||||
.preview-placeholder {
|
||||
text-align: center;
|
||||
|
||||
&.office-preview {
|
||||
padding: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,11 +170,15 @@
|
||||
<a-input v-model:value="formData.address" placeholder="请输入学校地址" />
|
||||
</a-form-item>
|
||||
<a-form-item label="套餐类型" name="packageType">
|
||||
<a-select v-model:value="formData.packageType">
|
||||
<a-select-option value="BASIC">基础版</a-select-option>
|
||||
<a-select-option value="STANDARD">标准版</a-select-option>
|
||||
<a-select-option value="ADVANCED">高级版</a-select-option>
|
||||
<a-select-option value="CUSTOM">定制版</a-select-option>
|
||||
<a-select v-model:value="formData.packageType" @change="handlePackageTypeChange">
|
||||
<a-select-option value="">请选择套餐</a-select-option>
|
||||
<a-select-option
|
||||
v-for="pkg in packageList"
|
||||
:key="pkg.id"
|
||||
:value="pkg.name"
|
||||
>
|
||||
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }}元)
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="教师配额" name="teacherQuota">
|
||||
@ -348,10 +352,12 @@ import {
|
||||
updateTenantStatus,
|
||||
resetTenantPassword,
|
||||
deleteTenant,
|
||||
getPublishedPackages,
|
||||
type Tenant,
|
||||
type TenantDetail,
|
||||
type CreateTenantDto,
|
||||
type UpdateTenantDto,
|
||||
type CoursePackage,
|
||||
} from '@/api/admin';
|
||||
|
||||
// 搜索表单
|
||||
@ -414,7 +420,8 @@ const formData = reactive<CreateTenantDto & { dateRange?: string[] }>({
|
||||
contactPerson: '',
|
||||
contactPhone: '',
|
||||
address: '',
|
||||
packageType: 'STANDARD',
|
||||
packageType: '',
|
||||
packageId: undefined,
|
||||
teacherQuota: 20,
|
||||
studentQuota: 200,
|
||||
startDate: '',
|
||||
@ -444,6 +451,35 @@ const quotaForm = reactive({
|
||||
const drawerVisible = ref(false);
|
||||
const detailData = ref<TenantDetail | null>(null);
|
||||
|
||||
// 套餐列表
|
||||
const packageList = ref<CoursePackage[]>([]);
|
||||
|
||||
// 格式化价格(分转为元)
|
||||
const formatPackagePrice = (priceInCents: number) => {
|
||||
return (priceInCents / 100).toFixed(2);
|
||||
};
|
||||
|
||||
// 处理套餐类型变化,自动填充配额等信息
|
||||
const handlePackageTypeChange = (value: string) => {
|
||||
const selectedPackage = packageList.value.find(pkg => pkg.name === value);
|
||||
if (selectedPackage) {
|
||||
// 设置选中的套餐 ID
|
||||
formData.packageId = selectedPackage.id;
|
||||
|
||||
// 根据套餐自动设置配额(这里可以根据实际需求调整)
|
||||
if (selectedPackage.name.includes('基础')) {
|
||||
formData.teacherQuota = 10;
|
||||
formData.studentQuota = 100;
|
||||
} else if (selectedPackage.name.includes('标准')) {
|
||||
formData.teacherQuota = 20;
|
||||
formData.studentQuota = 200;
|
||||
} else if (selectedPackage.name.includes('高级')) {
|
||||
formData.teacherQuota = 50;
|
||||
formData.studentQuota = 500;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
@ -464,6 +500,16 @@ const loadData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 加载套餐列表
|
||||
const loadPackageList = async () => {
|
||||
try {
|
||||
const res = await getPublishedPackages();
|
||||
packageList.value = res;
|
||||
} catch (error) {
|
||||
console.error('加载套餐列表失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1;
|
||||
@ -497,7 +543,8 @@ const showAddModal = () => {
|
||||
contactPerson: '',
|
||||
contactPhone: '',
|
||||
address: '',
|
||||
packageType: 'STANDARD',
|
||||
packageType: '',
|
||||
packageId: undefined,
|
||||
teacherQuota: 20,
|
||||
studentQuota: 200,
|
||||
});
|
||||
@ -695,6 +742,7 @@ const getStatusText = (status: string) => {
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
loadPackageList();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
<template>
|
||||
|
||||
<div v-if="!expire" ref="containerRef" class="!w-full !h-full pos-fixed top-0 left-0 z-999"></div>
|
||||
<div v-else class="flex justify-center">
|
||||
<div class="my-60px">
|
||||
链接已失效!<span class=" cursor-pointer color-#0085FF ml-10px" @click="home">返回首页</span>
|
||||
<div v-if="!expire" ref="containerRef" class="!w-full !h-full z-999" :class="noPage ? 'absolute top-0 left-0' : 'pos-fixed top-0 left-0'"></div>
|
||||
<div v-else class="flex justify-center items-center w-full h-full">
|
||||
<div class="my-60px text-center">
|
||||
链接已失效!
|
||||
<span v-if="!noPage" class="cursor-pointer color-#0085FF ml-10px" @click="home">返回首页</span>
|
||||
<span v-else class="block mt-10px color-#999">文档预览加载失败</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <Modal ref="modalRef" class="max-w-80%" width="1340px" v-model:open="open" :footer="null" title="在线资源">
|
||||
@ -38,7 +39,7 @@
|
||||
</template>
|
||||
<!-- 阿里云IMM weboffice -->
|
||||
<script lang="ts" name="WebOffice" setup>
|
||||
import { onMounted, ref, nextTick, reactive, watch, onUnmounted, onBeforeUnmount } from 'vue';
|
||||
import { onMounted, ref, nextTick, onUnmounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
import {
|
||||
generateWebofficeToken,
|
||||
@ -48,16 +49,39 @@ import {
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getTemItem, TemObj } from './temObjs';
|
||||
|
||||
const props = defineProps<{
|
||||
/** 嵌入模式:与页面共存,使用 absolute 定位 */
|
||||
noPage?: boolean;
|
||||
/** 文档 URL(嵌入模式必传) */
|
||||
url?: string;
|
||||
/** 文件名(嵌入模式,用于 IMM) */
|
||||
fileName?: string;
|
||||
/** 文件 ID(嵌入模式可选) */
|
||||
fileId?: string;
|
||||
}>();
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const route = useRoute();
|
||||
const expire = ref(false);
|
||||
const router = useRouter();
|
||||
let updateSizeInterval: any;
|
||||
// const { hasPermission } = usePermission();
|
||||
|
||||
function getTemObjFromProps(): TemObj | null {
|
||||
if (!props.url) return null;
|
||||
const ext = props.url.split('.').pop()?.split('?')[0] || 'pdf';
|
||||
const name = props.fileName?.includes('.') ? props.fileName : `${props.fileName || 'document'}.${ext}`;
|
||||
return {
|
||||
id: props.fileId || '',
|
||||
isEdit: false,
|
||||
name,
|
||||
url: encodeURIComponent(props.url),
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
init(containerRef.value);
|
||||
})
|
||||
});
|
||||
});
|
||||
const _temObj = ref<TemObj>({
|
||||
id: '',
|
||||
@ -98,12 +122,16 @@ const baseInstance = ref<any>(null);
|
||||
async function init(mount: HTMLElement | null) {
|
||||
if (!mount) {
|
||||
console.error('确保挂载节点元素存在。 一般在 onMounted 钩子中调用。');
|
||||
return;
|
||||
}
|
||||
|
||||
let temObj: TemObj | null;
|
||||
if (props.noPage && props.url) {
|
||||
temObj = getTemObjFromProps();
|
||||
} else {
|
||||
temObj = getTemItem(route.query._t as string);
|
||||
}
|
||||
// IMM vue3接入文档 https://help.aliyun.com/zh/imm/user-guide/vue3-usage?spm=a2c4g.11186623.0.0.3a0244142zAkss
|
||||
// 获取 token
|
||||
// let tokenInfo = await props.getTokenFun(props.teachingMaterialsImmUrl);
|
||||
|
||||
const temObj = getTemItem(route.query._t as string);
|
||||
if (!temObj) {
|
||||
expire.value = true;
|
||||
return;
|
||||
@ -111,7 +139,11 @@ async function init(mount: HTMLElement | null) {
|
||||
|
||||
_temObj.value = temObj;
|
||||
|
||||
const url = decodeURIComponent(`oss://lesingle-kid-course${new URL(decodeURIComponent(temObj.url)).pathname}`);
|
||||
// 解析文档 URL:后端 ImmUtil 支持 oss:// 或完整 https:// 格式
|
||||
const decodedUrl = decodeURIComponent(temObj.url);
|
||||
const url = decodedUrl.startsWith('http://') || decodedUrl.startsWith('https://')
|
||||
? decodedUrl
|
||||
: decodeURIComponent(`oss://lesingle-kid-course${new URL(decodedUrl).pathname}`);
|
||||
let tokenInfo = await getTokenFun(url, temObj);
|
||||
const instance = (window as any).aliyun.config({
|
||||
mount,
|
||||
@ -161,7 +193,7 @@ async function init(mount: HTMLElement | null) {
|
||||
instance.on('fileStatus', () => {
|
||||
debouncedFn(5000);
|
||||
});
|
||||
instance.ApiEvent.AddApiEventListener('error', (err) => {
|
||||
instance.ApiEvent.AddApiEventListener('error', (err: unknown) => {
|
||||
console.log('发生错误:', err);
|
||||
})
|
||||
|
||||
|
||||
250
reading-platform-frontend/src/views/office/player.vue
Normal file
250
reading-platform-frontend/src/views/office/player.vue
Normal file
@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div
|
||||
ref="playerWrapperRef"
|
||||
class="player-wrapper w-full h-full bg-#000 z-50 top-0 left-0"
|
||||
:class="noPage ? 'absolute' : 'fixed'"
|
||||
tabindex="0"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<!-- 视频标题遮罩(暂停时显示,参考 VideoPlayer) -->
|
||||
<div v-if="title && !isPlaying && !loading" class="title-overlay">
|
||||
<div class="title-text">{{ title }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中遮罩 -->
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
|
||||
<!-- Aliplayer 挂载点 -->
|
||||
<div :id="playerId" class="w-full h-full z-1"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { getTemItem, type TemObj } from './temObjs';
|
||||
|
||||
const props = defineProps<{
|
||||
url: string;
|
||||
cover?: string;
|
||||
noPage?: boolean;
|
||||
/** 视频标题(参考 VideoPlayer) */
|
||||
title?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'ended'): void;
|
||||
(e: 'play'): void;
|
||||
(e: 'pause'): void;
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const playerWrapperRef = ref<HTMLElement | null>(null);
|
||||
const playerInstance = ref<any>(null);
|
||||
const isPlaying = ref(false);
|
||||
const loading = ref(true);
|
||||
|
||||
// 唯一 ID 避免多实例冲突
|
||||
const playerId = ref(`playerView-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`);
|
||||
|
||||
const _temObj = ref<TemObj>({
|
||||
id: '',
|
||||
name: '',
|
||||
isEdit: false,
|
||||
url: '',
|
||||
});
|
||||
|
||||
function createPlayer(source?: string, cover?: string) {
|
||||
if (!source) return;
|
||||
|
||||
const mountEl = document.getElementById(playerId.value);
|
||||
if (!mountEl) return;
|
||||
|
||||
// @ts-ignore
|
||||
playerInstance.value = new Aliplayer(
|
||||
{
|
||||
id: playerId.value,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
source: source,
|
||||
cover: cover || '/long/long.svg',
|
||||
skinLayout: [
|
||||
{ name: 'bigPlayButton', align: 'blabs', x: 30, y: 80 },
|
||||
{ name: 'H5Loading', align: 'cc' },
|
||||
{
|
||||
name: 'controlBar',
|
||||
align: 'blabs',
|
||||
x: 0,
|
||||
y: 0,
|
||||
children: [
|
||||
{ name: 'progress', align: 'tlabs', x: 0, y: 0 },
|
||||
{ name: 'playButton', align: 'tl', x: 15, y: 10 },
|
||||
{ name: 'timeDisplay', align: 'tl', x: 10, y: 2 },
|
||||
{ name: 'fullScreenButton', align: 'tr', x: 20, y: 12 },
|
||||
{ name: 'setting', align: 'tr', x: 20, y: 11 },
|
||||
{ name: 'volume', align: 'tr', x: 20, y: 10 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
(player: any) => {
|
||||
player.on('ended', () => {
|
||||
isPlaying.value = false;
|
||||
emit('ended');
|
||||
});
|
||||
player.on('play', () => {
|
||||
isPlaying.value = true;
|
||||
loading.value = false;
|
||||
emit('play');
|
||||
});
|
||||
player.on('pause', () => {
|
||||
isPlaying.value = false;
|
||||
emit('pause');
|
||||
});
|
||||
player.on('canplay', () => {
|
||||
loading.value = false;
|
||||
});
|
||||
player.on('waiting', () => {
|
||||
loading.value = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (playerInstance.value) {
|
||||
try {
|
||||
playerInstance.value.dispose();
|
||||
} catch (_) {}
|
||||
playerInstance.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function initPlayer() {
|
||||
if (!props.noPage) {
|
||||
const temObj = getTemItem(route.query._t as string);
|
||||
if (!temObj) return;
|
||||
_temObj.value = temObj;
|
||||
createPlayer(_temObj.value.url, '/long/long.svg');
|
||||
} else if (props.url) {
|
||||
createPlayer(props.url, props.cover || '/long/long.svg');
|
||||
}
|
||||
}
|
||||
|
||||
// 键盘快捷键(参考 VideoPlayer)
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const p = playerInstance.value;
|
||||
if (!p) return;
|
||||
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
if (isPlaying.value) p.pause(); else p.play();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
p.seek?.(Math.max(0, (p.getCurrentTime?.() ?? 0) - 10));
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
const duration = p.getDuration?.() ?? 0;
|
||||
p.seek?.(Math.min(duration, (p.getCurrentTime?.() ?? 0) + 10));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
p.setVolume?.(Math.min(100, (p.getVolume?.() ?? 100) + 10));
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
p.setVolume?.(Math.max(0, (p.getVolume?.() ?? 100) - 10));
|
||||
break;
|
||||
case 'f':
|
||||
case 'F':
|
||||
e.preventDefault();
|
||||
p.requestFullScreen?.();
|
||||
break;
|
||||
case 'm':
|
||||
case 'M':
|
||||
e.preventDefault();
|
||||
p.mute?.();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.url, (newVal) => {
|
||||
if (newVal) {
|
||||
dispose();
|
||||
loading.value = true;
|
||||
nextTick(() => createPlayer(newVal, props.cover || '/long/long.svg'));
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initPlayer();
|
||||
playerWrapperRef.value?.focus?.();
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.player-wrapper {
|
||||
position: relative;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title-overlay {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 15;
|
||||
pointer-events: none;
|
||||
|
||||
.title-text {
|
||||
padding: 8px 20px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #ff8c42;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,5 +1,24 @@
|
||||
import { setTemItem } from "./temObjs";
|
||||
import { router } from "@/router";
|
||||
|
||||
/** WebOffice 支持的文档扩展名(阿里云 IMM) */
|
||||
export const WEB_OFFICE_EXTENSIONS = ['pdf', 'ppt', 'pptx', 'doc', 'docx', 'xls', 'xlsx'] as const;
|
||||
|
||||
/**
|
||||
* 判断资源是否可由 WebOffice 预览(与 WebOffice 对接)
|
||||
* @param resource 资源对象,需包含 fileType/type、filePath
|
||||
*/
|
||||
export function isWebOfficeSupported(resource: { fileType?: string; type?: string; filePath?: string }): boolean {
|
||||
const fileType = resource.fileType ?? resource.type;
|
||||
if (fileType === 'PDF') return true;
|
||||
if (fileType === 'PPT') return true;
|
||||
if (fileType === 'OTHER' && resource.filePath) {
|
||||
const ext = resource.filePath.toLowerCase().split('.').pop()?.split('?')[0] || '';
|
||||
return (WEB_OFFICE_EXTENSIONS as readonly string[]).includes(ext);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param res obj
|
||||
* @param isEdit 是否编辑
|
||||
|
||||
@ -392,6 +392,13 @@ const initTrendChart = (data: LessonTrendItem[]) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保数据格式正确
|
||||
const validData = data.map(d => ({
|
||||
month: d.month || '',
|
||||
lessonCount: d.lessonCount || 0,
|
||||
studentCount: d.studentCount || 0,
|
||||
}));
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
@ -419,7 +426,7 @@ const initTrendChart = (data: LessonTrendItem[]) => {
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((d) => d.month),
|
||||
data: validData.map((d) => d.month),
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#E5E7EB',
|
||||
@ -450,7 +457,7 @@ const initTrendChart = (data: LessonTrendItem[]) => {
|
||||
{
|
||||
name: '授课次数',
|
||||
type: 'bar',
|
||||
data: data.map((d) => d.lessonCount),
|
||||
data: validData.map((d) => d.lessonCount),
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#FF8C42' },
|
||||
@ -463,7 +470,7 @@ const initTrendChart = (data: LessonTrendItem[]) => {
|
||||
name: '学生数',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: data.map((d) => d.studentCount),
|
||||
data: validData.map((d) => d.studentCount),
|
||||
smooth: true,
|
||||
itemStyle: {
|
||||
color: '#667eea',
|
||||
@ -491,6 +498,40 @@ const initDistributionChart = (data: CourseDistributionItem[]) => {
|
||||
|
||||
distributionChart = echarts.init(distributionChartRef.value);
|
||||
|
||||
// 如果没有数据,显示空状态
|
||||
if (!data || data.length === 0) {
|
||||
distributionChart.setOption({
|
||||
title: {
|
||||
text: '暂无数据',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
color: '#999',
|
||||
fontSize: 14,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保数据格式正确
|
||||
const validData = data.map((item, index) => ({
|
||||
name: item.name || `课程${index + 1}`,
|
||||
value: item.value || 0,
|
||||
itemStyle: {
|
||||
color: [
|
||||
'#FF8C42',
|
||||
'#667eea',
|
||||
'#f093fb',
|
||||
'#4facfe',
|
||||
'#43e97b',
|
||||
'#fa709a',
|
||||
'#fee140',
|
||||
'#30cfd0',
|
||||
][index % 8],
|
||||
},
|
||||
}));
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
@ -526,21 +567,7 @@ const initDistributionChart = (data: CourseDistributionItem[]) => {
|
||||
labelLine: {
|
||||
show: false,
|
||||
},
|
||||
data: data.map((item, index) => ({
|
||||
...item,
|
||||
itemStyle: {
|
||||
color: [
|
||||
'#FF8C42',
|
||||
'#667eea',
|
||||
'#f093fb',
|
||||
'#4facfe',
|
||||
'#43e97b',
|
||||
'#fa709a',
|
||||
'#fee140',
|
||||
'#30cfd0',
|
||||
][index % 8],
|
||||
},
|
||||
})),
|
||||
data: validData,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -200,7 +200,7 @@
|
||||
<div v-else class="cover-placeholder">
|
||||
<BookFilled />
|
||||
</div>
|
||||
<div class="duration-tag">{{ course.duration }}分钟</div>
|
||||
<div class="duration-tag">{{ course.duration || 30 }}分钟</div>
|
||||
</div>
|
||||
<div class="recommend-info">
|
||||
<div class="recommend-name">{{ course.name }}</div>
|
||||
@ -637,9 +637,34 @@ const loadDashboard = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getTeacherDashboard();
|
||||
stats.value = data.stats;
|
||||
todayLessons.value = data.todayLessons || [];
|
||||
recommendedCourses.value = data.recommendedCourses || [];
|
||||
stats.value = data.stats || { classCount: 0, studentCount: 0, lessonCount: 0, courseCount: 0 };
|
||||
|
||||
// 映射今日课程数据,处理可能缺失的字段
|
||||
todayLessons.value = (data.todayLessons || []).map((lesson: any) => ({
|
||||
id: lesson.id,
|
||||
courseId: lesson.courseId,
|
||||
courseName: lesson.courseName || lesson.course?.name || '未命名课程',
|
||||
pictureBookName: lesson.pictureBookName,
|
||||
classId: lesson.classId,
|
||||
className: lesson.className || lesson.class?.name || '未命名班级',
|
||||
plannedDatetime: lesson.plannedDatetime || lesson.startDatetime || lesson.lessonDate,
|
||||
status: lesson.status,
|
||||
duration: lesson.duration || lesson.actualDuration || 30, // 默认30分钟
|
||||
}));
|
||||
|
||||
// 映射推荐课程数据,处理可能缺失的字段
|
||||
recommendedCourses.value = (data.recommendedCourses || []).map((course: any) => ({
|
||||
id: course.id,
|
||||
name: course.name || '未命名课程',
|
||||
pictureBookName: course.pictureBookName,
|
||||
coverImagePath: course.coverImagePath,
|
||||
duration: course.duration || 30, // 默认30分钟
|
||||
usageCount: course.usageCount || 0,
|
||||
avgRating: course.avgRating || 0, // 默认0分
|
||||
gradeTags: course.gradeTags || [],
|
||||
}));
|
||||
|
||||
// 处理近期活动数据
|
||||
recentActivities.value = (data.recentActivities || []).map((item: RecentActivity) => ({
|
||||
...item,
|
||||
time: formatActivityTime(item.time),
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
<a-button @click="createSchoolVersion">
|
||||
<CopyOutlined /> 创建校本版本
|
||||
</a-button>
|
||||
<a-button type="primary" @click="startPrepare">
|
||||
<a-button type="primary" @click="startPrepare" :disabled="!course.id || loading">
|
||||
<EditOutlined /> 开始备课
|
||||
</a-button>
|
||||
</div>
|
||||
@ -281,7 +281,7 @@
|
||||
<div class="lesson-section-title">教学环节 ({{ lesson.steps.length }}个)</div>
|
||||
<div class="steps-timeline">
|
||||
<div v-for="(step, index) in lesson.steps" :key="step.id || index" class="step-item">
|
||||
<div class="step-dot">{{ index + 1 }}</div>
|
||||
<div class="step-dot">{{ Number(index) + 1 }}</div>
|
||||
<div class="step-content">
|
||||
<div class="step-name">{{ step.name }}</div>
|
||||
<div class="step-duration">{{ step.duration }}分钟</div>
|
||||
@ -870,7 +870,12 @@ const createSchoolVersion = () => {
|
||||
};
|
||||
|
||||
const startPrepare = () => {
|
||||
router.push(`/teacher/courses/${course.value.id}/prepare`);
|
||||
const id = route.params.id || course.value?.id;
|
||||
if (!id || id === 'undefined') {
|
||||
message.warning('课程信息未加载完成,请稍后再试');
|
||||
return;
|
||||
}
|
||||
router.push(`/teacher/courses/${id}/prepare`);
|
||||
};
|
||||
|
||||
const toggleFavorite = () => {
|
||||
|
||||
@ -318,7 +318,12 @@ const viewCourseDetail = (course: any) => {
|
||||
};
|
||||
|
||||
const prepareCourse = (course: any) => {
|
||||
router.push(`/teacher/courses/${course.id}/prepare`);
|
||||
const id = course?.id;
|
||||
if (id == null || id === 'undefined') {
|
||||
message.warning('课程信息异常,无法进入备课');
|
||||
return;
|
||||
}
|
||||
router.push(`/teacher/courses/${id}/prepare`);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@ -121,7 +121,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import {
|
||||
LeftOutlined, BookOutlined, ClockCircleOutlined, TagOutlined,
|
||||
@ -210,8 +210,14 @@ const getFileUrl = (filePath: string | null | undefined): string => {
|
||||
};
|
||||
|
||||
const loadCourseData = async () => {
|
||||
courseId.value = (route.params.id as string) || '';
|
||||
if (!courseId.value) return;
|
||||
const id = route.params.id as string;
|
||||
// 校验 ID:无效时跳转回课程列表,避免请求 /courses/undefined
|
||||
if (!id || id === 'undefined' || id === 'null') {
|
||||
message.warning('课程 ID 无效,已返回课程列表');
|
||||
router.replace('/teacher/courses');
|
||||
return;
|
||||
}
|
||||
courseId.value = id;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
@ -358,7 +364,7 @@ const handleSelectStep = (step: any) => {
|
||||
selectedStep.value = step;
|
||||
};
|
||||
|
||||
const handlePreviewResource = (type: string, resource: any) => {
|
||||
const handlePreviewResource = (_type: string, resource: any) => {
|
||||
previewFileUrl.value = resource.url;
|
||||
previewFileName.value = resource.name || '资源文件';
|
||||
previewModalVisible.value = true;
|
||||
@ -452,12 +458,28 @@ const handleExit = () => {
|
||||
};
|
||||
|
||||
const goBackToDetail = () => {
|
||||
router.push(`/teacher/courses/${courseId.value}`);
|
||||
// 优先使用路由中的 ID,避免返回时 courseId 未加载导致跳转到 /courses/undefined
|
||||
const id = route.params.id || courseId.value;
|
||||
if (id && id !== 'undefined' && id !== 'null') {
|
||||
router.push(`/teacher/courses/${id}`);
|
||||
} else {
|
||||
router.push('/teacher/courses');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadCourseData();
|
||||
});
|
||||
|
||||
// 路由变化时重新加载(如从课程 A 的备课页切换到课程 B 的备课页,组件复用时)
|
||||
watch(
|
||||
() => route.params.id,
|
||||
(newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
loadCourseData();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -46,16 +46,16 @@
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-header">
|
||||
<h3 class="course-name">{{ lesson.course?.name || '未知课程' }}</h3>
|
||||
<h3 class="course-name">{{ lesson.courseName || lesson.course?.name || lesson.title || '未知课程' }}</h3>
|
||||
<span class="lesson-time">
|
||||
<ClockCircleOutlined />
|
||||
{{ formatDateTime(lesson.startDatetime || lesson.plannedDatetime) }}
|
||||
{{ formatDateTime(lesson.startDatetime || lesson.plannedDatetime || (lesson.lessonDate && lesson.startTime ? `${lesson.lessonDate}T${lesson.startTime}` : null)) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="info-item">
|
||||
<TeamOutlined />
|
||||
<span>{{ lesson.class?.name || '未知班级' }}</span>
|
||||
<span>{{ lesson.className || lesson.class?.name || '未知班级' }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="lesson.actualDuration">
|
||||
<FieldTimeOutlined />
|
||||
@ -108,10 +108,10 @@
|
||||
<div class="detail-content" v-if="selectedLesson">
|
||||
<a-descriptions :column="1" bordered>
|
||||
<a-descriptions-item label="课程名称">
|
||||
{{ selectedLesson.course?.name }}
|
||||
{{ selectedLesson.courseName || selectedLesson.course?.name || selectedLesson.title || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="授课班级">
|
||||
{{ selectedLesson.class?.name }}
|
||||
{{ selectedLesson.className || selectedLesson.class?.name || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="课程状态">
|
||||
<a-tag :color="getStatusColor(selectedLesson.status)">
|
||||
@ -152,7 +152,7 @@
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-section">
|
||||
<!-- 已计划状态 -->
|
||||
<template v-if="selectedLesson.status === 'PLANNED'">
|
||||
<template v-if="selectedLesson.status === 'PLANNED' || selectedLesson.status === 'scheduled'">
|
||||
<a-button type="primary" block @click="startPlannedLesson" style="margin-bottom: 12px;">
|
||||
开始上课
|
||||
</a-button>
|
||||
@ -226,12 +226,16 @@ const filters = reactive({
|
||||
const detailDrawerVisible = ref(false);
|
||||
const selectedLesson = ref<any>(null);
|
||||
|
||||
// 状态映射
|
||||
// 状态映射(兼容后端 scheduled/in_progress/completed/cancelled 与前端 PLANNED/IN_PROGRESS 等)
|
||||
const statusMap: Record<string, { text: string; color: string; class: string }> = {
|
||||
PLANNED: { text: '已计划', color: 'blue', class: 'status-planned' },
|
||||
scheduled: { text: '已计划', color: 'blue', class: 'status-planned' },
|
||||
IN_PROGRESS: { text: '进行中', color: 'orange', class: 'status-progress' },
|
||||
in_progress: { text: '进行中', color: 'orange', class: 'status-progress' },
|
||||
COMPLETED: { text: '已完成', color: 'green', class: 'status-completed' },
|
||||
completed: { text: '已完成', color: 'green', class: 'status-completed' },
|
||||
CANCELLED: { text: '已取消', color: 'default', class: 'status-cancelled' },
|
||||
cancelled: { text: '已取消', color: 'default', class: 'status-cancelled' },
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => statusMap[status]?.text || status;
|
||||
@ -247,12 +251,16 @@ const loadLessons = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: any = {
|
||||
page: currentPage.value,
|
||||
pageNum: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
if (filters.status) {
|
||||
params.status = filters.status;
|
||||
}
|
||||
if (filters.dateRange && filters.dateRange[0] && filters.dateRange[1]) {
|
||||
params.startDate = filters.dateRange[0].format('YYYY-MM-DD');
|
||||
params.endDate = filters.dateRange[1].format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const data = await teacherApi.getLessons(params);
|
||||
lessons.value = data.items || [];
|
||||
@ -288,8 +296,9 @@ const viewDetail = (lesson: any) => {
|
||||
};
|
||||
|
||||
const goToPrepare = () => {
|
||||
if (selectedLesson.value?.course?.id) {
|
||||
router.push(`/teacher/courses/${selectedLesson.value.course.id}/prepare`);
|
||||
const courseId = selectedLesson.value?.courseId ?? selectedLesson.value?.course?.id;
|
||||
if (courseId) {
|
||||
router.push(`/teacher/courses/${courseId}/prepare`);
|
||||
detailDrawerVisible.value = false;
|
||||
}
|
||||
};
|
||||
@ -309,8 +318,9 @@ const goToRecords = () => {
|
||||
};
|
||||
|
||||
const goToCourseDetail = () => {
|
||||
if (selectedLesson.value?.course?.id) {
|
||||
router.push(`/teacher/courses/${selectedLesson.value.course.id}`);
|
||||
const courseId = selectedLesson.value?.courseId ?? selectedLesson.value?.course?.id;
|
||||
if (courseId) {
|
||||
router.push(`/teacher/courses/${courseId}`);
|
||||
detailDrawerVisible.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@ -11,21 +11,18 @@
|
||||
<!-- 绘本展示 -->
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
<div v-if="currentResourceType === 'ebook'" key="ebook" class="content-viewer">
|
||||
<EbookViewer
|
||||
:pages="ebookPages"
|
||||
:current-page="currentEbookPage"
|
||||
:audio-url="syncAudioUrl"
|
||||
:auto-play="autoPlayAudio"
|
||||
@page-change="handlePageChange"
|
||||
@toggle-audio-sync="toggleAudioSync"
|
||||
/>
|
||||
<EbookViewer :pages="ebookPages" :current-page="currentEbookPage" :audio-url="syncAudioUrl"
|
||||
:auto-play="autoPlayAudio" @page-change="handlePageChange" @toggle-audio-sync="toggleAudioSync" />
|
||||
</div>
|
||||
|
||||
<!-- 视频播放 -->
|
||||
<div v-else-if="currentResourceType === 'video'" key="video" class="content-viewer">
|
||||
<VideoPlayer
|
||||
:src="currentResourceUrl"
|
||||
<Player
|
||||
v-if="currentResourceUrl"
|
||||
:key="currentResourceUrl"
|
||||
:url="currentResourceUrl"
|
||||
:title="currentResourceName"
|
||||
:no-page="true"
|
||||
@ended="handleMediaEnded"
|
||||
/>
|
||||
</div>
|
||||
@ -40,40 +37,36 @@
|
||||
|
||||
<!-- 音频播放 -->
|
||||
<div v-else-if="currentResourceType === 'audio'" key="audio" class="content-viewer">
|
||||
<AudioPlayer
|
||||
:src="currentResourceUrl"
|
||||
:title="currentResourceName"
|
||||
:background-image="backgroundImageUrl"
|
||||
@ended="handleMediaEnded"
|
||||
/>
|
||||
<AudioPlayer :src="currentResourceUrl" :title="currentResourceName" :background-image="backgroundImageUrl"
|
||||
@ended="handleMediaEnded" />
|
||||
</div>
|
||||
|
||||
<!-- PPT/挂图展示 -->
|
||||
<div v-else-if="currentResourceType === 'ppt' || currentResourceType === 'poster'" key="slides" class="content-viewer">
|
||||
<SlidesViewer
|
||||
<div v-else-if="currentResourceType === 'ppt'" key="ppt" class="content-viewer">
|
||||
<!-- <SlidesViewer
|
||||
:pages="slidesPages"
|
||||
:current-page="currentSlidePage"
|
||||
:type="currentResourceType"
|
||||
@page-change="handleSlideChange"
|
||||
/>
|
||||
/> -->
|
||||
<WebOffice v-if="currentResourceUrl" :key="currentResourceUrl" :url="currentResourceUrl"
|
||||
:file-name="currentResourceName" :no-page="true" />
|
||||
</div>
|
||||
|
||||
<!-- 文档展示 (PDF等) -->
|
||||
<!-- PPT/挂图展示 -->
|
||||
<div v-else-if="currentResourceType === 'poster'" key="slides" class="content-viewer">
|
||||
<SlidesViewer :pages="slidesPages" :current-page="currentSlidePage" :type="currentResourceType"
|
||||
@page-change="handleSlideChange" />
|
||||
</div>
|
||||
<!-- 文档展示 (PDF/Word/Excel 等,使用 WebOffice) -->
|
||||
<div v-else-if="currentResourceType === 'document'" key="document" class="content-viewer">
|
||||
<SlidesViewer
|
||||
:pages="[currentResourceUrl]"
|
||||
:current-page="0"
|
||||
type="pdf"
|
||||
/>
|
||||
<WebOffice v-if="currentResourceUrl" :key="currentResourceUrl" :url="currentResourceUrl"
|
||||
:file-name="currentResourceName" :no-page="true" />
|
||||
</div>
|
||||
|
||||
<!-- 课程资源展示 (从底部点击的PPT/挂图) -->
|
||||
<div v-else-if="showingResource" key="resource" class="content-viewer">
|
||||
<SlidesViewer
|
||||
:pages="[showingResource.url]"
|
||||
:current-page="0"
|
||||
:type="(showingResource.type as 'ppt' | 'poster')"
|
||||
/>
|
||||
<SlidesViewer :pages="[showingResource.url]" :current-page="0"
|
||||
:type="(showingResource.type as 'ppt' | 'poster')" />
|
||||
<div class="resource-header">
|
||||
<span class="resource-title">{{ showingResource.name }}</span>
|
||||
<button class="close-resource-btn" @click="showingResource = null">
|
||||
@ -102,13 +95,9 @@
|
||||
<span>教学资源</span>
|
||||
</div>
|
||||
<div class="resources-track">
|
||||
<button
|
||||
v-for="resource in allLessonResources"
|
||||
:key="resource.id"
|
||||
class="resource-chip"
|
||||
<button v-for="resource in allLessonResources" :key="resource.id" class="resource-chip"
|
||||
:class="{ active: currentResourceIndex === resource.index }"
|
||||
@click.stop="handleResourceByIndex(resource.index)"
|
||||
>
|
||||
@click.stop="handleResourceByIndex(resource.index)">
|
||||
<component :is="getResourceIcon(resource.type)" :size="24" :stroke-width="2.5" />
|
||||
<span>{{ resource.name }}</span>
|
||||
</button>
|
||||
@ -133,12 +122,8 @@
|
||||
<span>延伸活动</span>
|
||||
</div>
|
||||
<div class="activities-track">
|
||||
<button
|
||||
v-for="activity in activities"
|
||||
:key="activity.id"
|
||||
class="activity-chip"
|
||||
@click.stop="handleActivityClick(activity)"
|
||||
>
|
||||
<button v-for="activity in activities" :key="activity.id" class="activity-chip"
|
||||
@click.stop="handleActivityClick(activity)">
|
||||
<component :is="getActivityIcon(activity.activityType)" :size="22" :stroke-width="2.5" />
|
||||
<span>{{ activity.name }}</span>
|
||||
</button>
|
||||
@ -151,7 +136,8 @@
|
||||
<ChevronLeft :size="28" :stroke-width="3" />
|
||||
<span>上一个</span>
|
||||
</button>
|
||||
<button class="ctrl-btn primary" :disabled="currentResourceIndex >= allLessonResources.length - 1" @click.stop="nextResource">
|
||||
<button class="ctrl-btn primary" :disabled="currentResourceIndex >= allLessonResources.length - 1"
|
||||
@click.stop="nextResource">
|
||||
<span>下一个</span>
|
||||
<ChevronRight :size="28" :stroke-width="3" />
|
||||
</button>
|
||||
@ -163,18 +149,14 @@
|
||||
</div>
|
||||
|
||||
<!-- 延伸活动弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="activityModalVisible"
|
||||
:title="selectedActivity?.name"
|
||||
:footer="null"
|
||||
width="90%"
|
||||
class="activity-modal"
|
||||
@cancel="activityModalVisible = false"
|
||||
>
|
||||
<a-modal v-model:open="activityModalVisible" :title="selectedActivity?.name" :footer="null" width="90%"
|
||||
class="activity-modal" @cancel="activityModalVisible = false">
|
||||
<div v-if="selectedActivity" class="activity-content">
|
||||
<div v-if="activityResourceUrl" class="activity-media">
|
||||
<video v-if="activityResourceType === 'video'" :src="activityResourceUrl" controls style="width: 100%; max-height: 50vh;"></video>
|
||||
<img v-else-if="activityResourceType === 'image'" :src="activityResourceUrl" style="width: 100%; max-height: 50vh; object-fit: contain;">
|
||||
<video v-if="activityResourceType === 'video'" :src="activityResourceUrl" controls
|
||||
style="width: 100%; max-height: 50vh;"></video>
|
||||
<img v-else-if="activityResourceType === 'image'" :src="activityResourceUrl"
|
||||
style="width: 100%; max-height: 50vh; object-fit: contain;">
|
||||
</div>
|
||||
<div class="activity-info">
|
||||
<div class="info-item" v-if="selectedActivity.objectives">
|
||||
@ -224,6 +206,8 @@ import {
|
||||
Lightbulb,
|
||||
} from 'lucide-vue-next';
|
||||
import EbookViewer from './viewers/EbookViewer.vue';
|
||||
import Player from '@/views/office/player.vue';
|
||||
import WebOffice from '@/views/office/WebOffice.vue';
|
||||
import VideoPlayer from './viewers/VideoPlayer.vue';
|
||||
import AudioPlayer from './viewers/AudioPlayer.vue';
|
||||
import SlidesViewer from './viewers/SlidesViewer.vue';
|
||||
@ -734,10 +718,10 @@ const loadCurrentStepResources = () => {
|
||||
|
||||
if (resource) {
|
||||
currentResourceType.value = resource.type === '电子绘本' ? 'ebook' :
|
||||
resource.type === '音频' ? 'audio' :
|
||||
resource.type === '视频' ? 'video' :
|
||||
resource.type === 'PPT课件' ? 'ppt' :
|
||||
resource.type === '教学挂图' ? 'poster' : '';
|
||||
resource.type === '音频' ? 'audio' :
|
||||
resource.type === '视频' ? 'video' :
|
||||
resource.type === 'PPT课件' ? 'ppt' :
|
||||
resource.type === '教学挂图' ? 'poster' : '';
|
||||
currentResourceUrl.value = resource.url ? getFileUrl(resource.url) : '';
|
||||
currentResourceName.value = resource.name;
|
||||
|
||||
@ -889,7 +873,7 @@ const handlePageChange = (page: number) => {
|
||||
currentEbookPage.value = page;
|
||||
};
|
||||
const handleSlideChange = (page: number) => { currentSlidePage.value = page; };
|
||||
const handleMediaEnded = () => {};
|
||||
const handleMediaEnded = () => { };
|
||||
const toggleAudioSync = () => { autoPlayAudio.value = !autoPlayAudio.value; };
|
||||
|
||||
const handleDocumentPageChange = (page: number) => {
|
||||
@ -1109,8 +1093,15 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) translateX(0); }
|
||||
50% { transform: translateY(-20px) translateX(10px); }
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) translateX(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-20px) translateX(10px);
|
||||
}
|
||||
}
|
||||
|
||||
// 内容切换动画
|
||||
@ -1135,12 +1126,15 @@ onUnmounted(() => {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
@ -1153,6 +1147,7 @@ onUnmounted(() => {
|
||||
transform: scale(0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(2.5);
|
||||
opacity: 0;
|
||||
@ -1161,15 +1156,32 @@ onUnmounted(() => {
|
||||
|
||||
// 摇晃动画
|
||||
@keyframes wiggle {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
25% { transform: rotate(-5deg); }
|
||||
75% { transform: rotate(5deg); }
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 闪烁动画
|
||||
@keyframes sparkle {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.kids-content {
|
||||
@ -1184,6 +1196,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.content-viewer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@ -1232,7 +1245,7 @@ onUnmounted(() => {
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #FF8C42;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.1);
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
@ -1283,7 +1296,9 @@ onUnmounted(() => {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.step-group {
|
||||
@ -1320,7 +1335,7 @@ onUnmounted(() => {
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #FFF3E0 0%, #FFE0B2 100%);
|
||||
@ -1369,8 +1384,15 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 4px 16px rgba(255, 111, 0, 0.4); }
|
||||
50% { box-shadow: 0 6px 24px rgba(255, 111, 0, 0.6); }
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 4px 16px rgba(255, 111, 0, 0.4);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 6px 24px rgba(255, 111, 0, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.step-resources {
|
||||
@ -1395,7 +1417,7 @@ onUnmounted(() => {
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #BBDEFB 0%, #90CAF9 100%);
|
||||
@ -1421,7 +1443,7 @@ onUnmounted(() => {
|
||||
background: linear-gradient(90deg, #FFE0B2 0%, #FFCCBC 100%);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
@ -1545,7 +1567,9 @@ onUnmounted(() => {
|
||||
padding-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.resource-chip {
|
||||
@ -1642,13 +1666,13 @@ onUnmounted(() => {
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
min-height: 60px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #F5F5F5 0%, #EEEEEE 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
@ -1693,7 +1717,7 @@ onUnmounted(() => {
|
||||
:deep(.ant-modal-content) {
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:deep(.ant-modal-header) {
|
||||
@ -1741,7 +1765,7 @@ onUnmounted(() => {
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
border: 3px solid #FFE0B2;
|
||||
}
|
||||
|
||||
@ -1751,7 +1775,7 @@ onUnmounted(() => {
|
||||
padding: 20px 24px;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
border: 2px solid #FFE0B2;
|
||||
|
||||
.info-label {
|
||||
|
||||
@ -1,30 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
class="video-player"
|
||||
ref="playerRef"
|
||||
:class="{
|
||||
'is-fullscreen': isFullscreen,
|
||||
'is-web-fullscreen': isWebFullscreen,
|
||||
'controls-visible': showControls || !isPlaying
|
||||
}"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<div class="video-player" ref="playerRef" :class="{
|
||||
'is-fullscreen': isFullscreen,
|
||||
'is-web-fullscreen': isWebFullscreen,
|
||||
'controls-visible': showControls || !isPlaying
|
||||
}" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave">
|
||||
<!-- 视频容器 -->
|
||||
<div class="video-container" @click="togglePlay">
|
||||
<video
|
||||
ref="videoRef"
|
||||
:src="src"
|
||||
:poster="posterUrl"
|
||||
class="video-element"
|
||||
@loadedmetadata="onLoaded"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onEnded"
|
||||
@play="isPlaying = true"
|
||||
@pause="isPlaying = false"
|
||||
@waiting="loading = true"
|
||||
@canplay="loading = false"
|
||||
/>
|
||||
<video ref="videoRef" :src="src" :poster="posterUrl" class="video-element" @loadedmetadata="onLoaded"
|
||||
@timeupdate="onTimeUpdate" @ended="onEnded" @play="isPlaying = true" @pause="isPlaying = false"
|
||||
@waiting="loading = true" @canplay="loading = false" />
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
@ -86,13 +70,8 @@
|
||||
{{ playbackRate }}x
|
||||
</button>
|
||||
<div class="speed-menu" v-if="showSpeedMenu">
|
||||
<div
|
||||
v-for="speed in speedOptions"
|
||||
:key="speed"
|
||||
class="speed-option"
|
||||
:class="{ active: playbackRate === speed }"
|
||||
@click="setSpeed(speed)"
|
||||
>
|
||||
<div v-for="speed in speedOptions" :key="speed" class="speed-option"
|
||||
:class="{ active: playbackRate === speed }" @click="setSpeed(speed)">
|
||||
{{ speed }}x
|
||||
</div>
|
||||
</div>
|
||||
@ -106,35 +85,18 @@
|
||||
<VolumeX v-else :size="20" />
|
||||
</button>
|
||||
<div class="volume-slider-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
class="volume-slider"
|
||||
:value="volume"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
@input="changeVolume"
|
||||
/>
|
||||
<input type="range" class="volume-slider" :value="volume" min="0" max="1" step="0.05"
|
||||
@input="changeVolume" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 循环 -->
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
:class="{ active: isLooping }"
|
||||
@click="toggleLoop"
|
||||
title="循环播放"
|
||||
>
|
||||
<button class="ctrl-btn" :class="{ active: isLooping }" @click="toggleLoop" title="循环播放">
|
||||
<Repeat :size="20" />
|
||||
</button>
|
||||
|
||||
<!-- 画中画 -->
|
||||
<button
|
||||
v-if="supportsPiP"
|
||||
class="ctrl-btn"
|
||||
@click="togglePiP"
|
||||
title="画中画"
|
||||
>
|
||||
<button v-if="supportsPiP" class="ctrl-btn" @click="togglePiP" title="画中画">
|
||||
<PictureInPicture2 :size="20" />
|
||||
</button>
|
||||
|
||||
@ -563,7 +525,9 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
|
||||
13
reading-platform-frontend/typed-router.d.ts
vendored
13
reading-platform-frontend/typed-router.d.ts
vendored
@ -191,6 +191,13 @@ declare module 'vue-router/auto-routes' {
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/office/player': RouteRecordInfo<
|
||||
'/office/player',
|
||||
'/office/player',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/office/WebOffice': RouteRecordInfo<
|
||||
'/office/WebOffice',
|
||||
'/office/WebOffice',
|
||||
@ -852,6 +859,12 @@ declare module 'vue-router/auto-routes' {
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/views/office/player.vue': {
|
||||
routes:
|
||||
| '/office/player'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/views/office/WebOffice.vue': {
|
||||
routes:
|
||||
| '/office/WebOffice'
|
||||
|
||||
@ -34,6 +34,9 @@ public enum ErrorCode {
|
||||
TENANT_EXPIRED(3002, "Tenant has expired"),
|
||||
TENANT_DISABLED(3003, "Tenant is disabled"),
|
||||
|
||||
// Package Errors (3100+)
|
||||
PACKAGE_NOT_FOUND(3101, "Package not found"),
|
||||
|
||||
// User Errors (4000+)
|
||||
USER_NOT_FOUND(4001, "User not found"),
|
||||
USER_ALREADY_EXISTS(4002, "User already exists"),
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
package com.reading.platform.common.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 租户套餐状态枚举
|
||||
*/
|
||||
@Getter
|
||||
public enum TenantPackageStatus {
|
||||
|
||||
ACTIVE("ACTIVE", "激活"),
|
||||
EXPIRED("EXPIRED", "已过期");
|
||||
|
||||
private final String code;
|
||||
private final String description;
|
||||
|
||||
TenantPackageStatus(String code, String description) {
|
||||
this.code = code;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 code 获取枚举
|
||||
*/
|
||||
public static TenantPackageStatus fromCode(String code) {
|
||||
for (TenantPackageStatus status : values()) {
|
||||
if (status.getCode().equals(code)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
return ACTIVE;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package com.reading.platform.common.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 是/否枚举
|
||||
* 用于替代数据库中的 0/1 魔法值
|
||||
*/
|
||||
@Getter
|
||||
public enum YesNo {
|
||||
|
||||
NO(0, "否"),
|
||||
YES(1, "是");
|
||||
|
||||
private final Integer code;
|
||||
private final String description;
|
||||
|
||||
YesNo(Integer code, String description) {
|
||||
this.code = code;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 code 获取枚举
|
||||
*/
|
||||
public static YesNo fromCode(Integer code) {
|
||||
if (code == null) {
|
||||
return NO;
|
||||
}
|
||||
for (YesNo value : values()) {
|
||||
if (value.getCode().equals(code)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 Boolean 获取枚举
|
||||
*/
|
||||
public static YesNo fromBoolean(Boolean bool) {
|
||||
return Boolean.TRUE.equals(bool) ? YES : NO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为"是"
|
||||
*/
|
||||
public static boolean isYes(Integer code) {
|
||||
return YES.getCode().equals(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为"否"
|
||||
*/
|
||||
public static boolean isNo(Integer code) {
|
||||
return NO.getCode().equals(code);
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ package com.reading.platform.common.mapper;
|
||||
import com.reading.platform.dto.response.ResourceItemResponse;
|
||||
import com.reading.platform.entity.ResourceItem;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
|
||||
import java.util.List;
|
||||
@ -17,7 +18,10 @@ public interface ResourceItemMapper {
|
||||
|
||||
/**
|
||||
* Entity 转 Response
|
||||
* tags、library 由 Controller 单独处理(JSON 解析、关联查询)
|
||||
*/
|
||||
@Mapping(target = "tags", ignore = true)
|
||||
@Mapping(target = "library", ignore = true)
|
||||
ResourceItemResponse toVO(ResourceItem entity);
|
||||
|
||||
/**
|
||||
@ -28,5 +32,6 @@ public interface ResourceItemMapper {
|
||||
/**
|
||||
* Response 转 Entity(用于创建/更新时)
|
||||
*/
|
||||
@Mapping(target = "tags", ignore = true)
|
||||
ResourceItem toEntity(ResourceItemResponse vo);
|
||||
}
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
package com.reading.platform.common.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.reading.platform.entity.AdminUser;
|
||||
import com.reading.platform.entity.Parent;
|
||||
import com.reading.platform.entity.Tenant;
|
||||
import com.reading.platform.entity.Teacher;
|
||||
import com.reading.platform.mapper.AdminUserMapper;
|
||||
import com.reading.platform.mapper.ParentMapper;
|
||||
import com.reading.platform.mapper.TenantMapper;
|
||||
import com.reading.platform.mapper.TeacherMapper;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@ -30,6 +38,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final JwtTokenRedisService jwtTokenRedisService;
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final TenantMapper tenantMapper;
|
||||
private final TeacherMapper teacherMapper;
|
||||
private final ParentMapper parentMapper;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
@ -47,13 +59,20 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
JwtPayload payload = jwtTokenProvider.getPayloadFromToken(token);
|
||||
|
||||
// 使用 Redis 验证 token 是否有效(检查黑名单和 token 一致性)
|
||||
// 使用 Redis 验证 token 是否有效(仅检查黑名单)
|
||||
if (!jwtTokenRedisService.validateToken(payload.getUsername(), token)) {
|
||||
log.debug("Token validation failed in Redis for user: {}", payload.getUsername());
|
||||
sendError(response, HttpStatus.UNAUTHORIZED, "Token 已失效,请重新登录");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查账户状态是否为 active
|
||||
if (!isAccountActive(payload)) {
|
||||
log.debug("Account is not active for user: {}", payload.getUsername());
|
||||
sendError(response, HttpStatus.UNAUTHORIZED, "账户已被禁用,请联系管理员");
|
||||
return;
|
||||
}
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
payload,
|
||||
@ -110,4 +129,36 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户是否处于激活状态
|
||||
* 根据用户角色查询对应的表,检查 status 字段是否为 "active"
|
||||
*
|
||||
* @param payload JWT payload
|
||||
* @return true 如果账户激活
|
||||
*/
|
||||
private boolean isAccountActive(JwtPayload payload) {
|
||||
String role = payload.getRole();
|
||||
Long userId = payload.getUserId();
|
||||
|
||||
return switch (role) {
|
||||
case "admin" -> {
|
||||
AdminUser adminUser = adminUserMapper.selectById(userId);
|
||||
yield adminUser != null && "active".equalsIgnoreCase(adminUser.getStatus());
|
||||
}
|
||||
case "school" -> {
|
||||
Tenant tenant = tenantMapper.selectById(userId);
|
||||
yield tenant != null && "active".equalsIgnoreCase(tenant.getStatus());
|
||||
}
|
||||
case "teacher" -> {
|
||||
Teacher teacher = teacherMapper.selectById(userId);
|
||||
yield teacher != null && "active".equalsIgnoreCase(teacher.getStatus());
|
||||
}
|
||||
case "parent" -> {
|
||||
Parent parent = parentMapper.selectById(userId);
|
||||
yield parent != null && "active".equalsIgnoreCase(parent.getStatus());
|
||||
}
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -90,31 +90,23 @@ public class JwtTokenRedisService {
|
||||
/**
|
||||
* 验证 token 是否有效
|
||||
* 1. 检查是否在黑名单中
|
||||
* 2. 检查是否与 Redis 中存储的 token 一致
|
||||
*
|
||||
* 注意:为支持多地点同时登录,不再检查 token 是否与 Redis 中存储的一致
|
||||
* JWT token 本身有过期时间,安全性足够
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param token JWT token
|
||||
* @return true 如果有效
|
||||
*/
|
||||
public boolean validateToken(String username, String token) {
|
||||
// 检查是否在黑名单中
|
||||
// 仅检查是否在黑名单中
|
||||
if (isBlacklisted(token)) {
|
||||
log.debug("Token is blacklisted: {}", token.substring(0, Math.min(20, token.length())));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否与存储的 token 一致
|
||||
String storedToken = getStoredToken(username);
|
||||
if (storedToken == null) {
|
||||
log.debug("No stored token found for user: {}", username);
|
||||
return true; // 如果没有存储 token,则认为是新的登录,允许通过
|
||||
}
|
||||
|
||||
boolean isValid = token.equals(storedToken);
|
||||
if (!isValid) {
|
||||
log.debug("Token mismatch for user: {}", username);
|
||||
}
|
||||
return isValid;
|
||||
// 不再检查是否与存储的 token 一致,允许同一账号有多个有效 token
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -6,6 +6,7 @@ import com.reading.platform.common.enums.UserRole;
|
||||
import com.reading.platform.common.response.PageResult;
|
||||
import com.reading.platform.common.response.Result;
|
||||
import com.reading.platform.dto.request.CourseCreateRequest;
|
||||
import com.reading.platform.dto.request.CoursePageQueryRequest;
|
||||
import com.reading.platform.dto.request.CourseUpdateRequest;
|
||||
import com.reading.platform.dto.response.CourseResponse;
|
||||
import com.reading.platform.entity.Course;
|
||||
@ -17,75 +18,94 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Tag(name = "Admin - Course", description = "System Course Management APIs for Admin")
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 课程管理控制器(超管端)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/courses")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
@Tag(name = "超管端 - 课程管理")
|
||||
public class AdminCourseController {
|
||||
|
||||
private final CourseService courseService;
|
||||
|
||||
@Operation(summary = "Create system course")
|
||||
@PostMapping
|
||||
public Result<Course> createCourse(@Valid @RequestBody CourseCreateRequest request) {
|
||||
@Operation(summary = "创建课程")
|
||||
public Result<CourseResponse> createCourse(@Valid @RequestBody CourseCreateRequest request) {
|
||||
log.info("收到课程创建请求,name={}, themeId={}, gradeTags={}", request.getName(), request.getThemeId(), request.getGradeTags());
|
||||
try {
|
||||
Course course = courseService.createSystemCourse(request);
|
||||
log.info("课程创建成功,id={}", course.getId());
|
||||
return Result.success(course);
|
||||
return Result.success(courseService.getCourseByIdWithLessons(course.getId()));
|
||||
} catch (Exception e) {
|
||||
log.error("课程创建失败", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "Update course")
|
||||
@PutMapping("/{id}")
|
||||
public Result<Course> updateCourse(@PathVariable Long id, @RequestBody CourseUpdateRequest request) {
|
||||
return Result.success(courseService.updateCourse(id, request));
|
||||
@Operation(summary = "更新课程")
|
||||
public Result<CourseResponse> updateCourse(@PathVariable Long id, @RequestBody CourseUpdateRequest request) {
|
||||
courseService.updateCourse(id, request);
|
||||
return Result.success(courseService.getCourseByIdWithLessons(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get course by ID")
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "查询课程详情")
|
||||
public Result<CourseResponse> getCourse(@PathVariable Long id) {
|
||||
return Result.success(courseService.getCourseByIdWithLessons(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get system course page")
|
||||
@GetMapping
|
||||
public Result<PageResult<Course>> getCoursePage(
|
||||
@RequestParam(required = false, defaultValue = "1") Integer pageNum,
|
||||
@RequestParam(required = false, defaultValue = "10") Integer pageSize,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false, defaultValue = "false") Boolean reviewOnly) {
|
||||
@Operation(summary = "分页查询课程")
|
||||
public Result<PageResult<CourseResponse>> getCoursePage(CoursePageQueryRequest request) {
|
||||
log.info("查询课程列表,pageNum={}, pageSize={}, keyword={}, category={}, status={}, reviewOnly={}",
|
||||
pageNum, pageSize, keyword, category, status, reviewOnly);
|
||||
Page<Course> page = courseService.getSystemCoursePage(pageNum, pageSize, keyword, category, status, reviewOnly);
|
||||
PageResult<Course> result = PageResult.of(page);
|
||||
request.getPageNum(), request.getPageSize(), request.getKeyword(), request.getCategory(), request.getStatus(), request.getReviewOnly());
|
||||
// 页码
|
||||
// 每页数量
|
||||
// 关键词
|
||||
// 分类
|
||||
// 状态
|
||||
// 是否仅查询待审核
|
||||
Page<Course> page = courseService.getSystemCoursePage(
|
||||
request.getPageNum(),
|
||||
request.getPageSize(),
|
||||
request.getKeyword(),
|
||||
request.getCategory(),
|
||||
request.getStatus(),
|
||||
request.getReviewOnly());
|
||||
|
||||
// 转换为 CourseResponse
|
||||
List<CourseResponse> responseList = page.getRecords().stream()
|
||||
.map(course -> courseService.getCourseByIdWithLessons(course.getId()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
PageResult<CourseResponse> result = PageResult.of(responseList, page.getTotal(), page.getCurrent(), page.getSize());
|
||||
log.info("课程列表查询结果,total={}, list={}", result.getTotal(), result.getList().size());
|
||||
return Result.success(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "Delete course")
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除课程")
|
||||
public Result<Void> deleteCourse(@PathVariable Long id) {
|
||||
courseService.deleteCourse(id);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@Operation(summary = "Publish course")
|
||||
@PostMapping("/{id}/publish")
|
||||
@Operation(summary = "发布课程")
|
||||
public Result<Void> publishCourse(@PathVariable Long id) {
|
||||
courseService.publishCourse(id);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@Operation(summary = "Archive course")
|
||||
@PostMapping("/{id}/archive")
|
||||
@Operation(summary = "归档课程")
|
||||
public Result<Void> archiveCourse(@PathVariable Long id) {
|
||||
courseService.archiveCourse(id);
|
||||
return Result.success();
|
||||
|
||||
@ -4,6 +4,9 @@ import com.reading.platform.common.annotation.RequireRole;
|
||||
import com.reading.platform.common.enums.UserRole;
|
||||
import com.reading.platform.common.response.Result;
|
||||
import com.reading.platform.dto.request.CourseLessonCreateRequest;
|
||||
import com.reading.platform.dto.request.LessonStepCreateRequest;
|
||||
import com.reading.platform.dto.response.CourseLessonResponse;
|
||||
import com.reading.platform.dto.response.LessonStepResponse;
|
||||
import com.reading.platform.entity.CourseLesson;
|
||||
import com.reading.platform.entity.LessonStep;
|
||||
import com.reading.platform.service.CourseLessonService;
|
||||
@ -14,6 +17,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 课程环节控制器(超管端)
|
||||
@ -28,33 +32,42 @@ public class AdminCourseLessonController {
|
||||
|
||||
@GetMapping("/{courseId}/lessons")
|
||||
@Operation(summary = "获取课程的所有环节")
|
||||
public Result<List<CourseLesson>> findAll(@PathVariable Long courseId) {
|
||||
return Result.success(courseLessonService.findByCourseId(courseId));
|
||||
public Result<List<CourseLessonResponse>> findAll(@PathVariable Long courseId) {
|
||||
List<CourseLesson> lessons = courseLessonService.findByCourseId(courseId);
|
||||
List<CourseLessonResponse> responses = lessons.stream()
|
||||
.map(this::toLessonResponse)
|
||||
.collect(Collectors.toList());
|
||||
return Result.success(responses);
|
||||
}
|
||||
|
||||
@GetMapping("/{courseId}/lessons/{id}")
|
||||
@Operation(summary = "获取课程环节详情")
|
||||
public Result<CourseLesson> findOne(
|
||||
public Result<CourseLessonResponse> findOne(
|
||||
@PathVariable Long courseId,
|
||||
@PathVariable Long id) {
|
||||
return Result.success(courseLessonService.findById(id));
|
||||
CourseLesson lesson = courseLessonService.findById(id);
|
||||
return Result.success(toLessonResponse(lesson));
|
||||
}
|
||||
|
||||
@GetMapping("/{courseId}/lessons/type/{lessonType}")
|
||||
@Operation(summary = "按类型获取课程环节")
|
||||
public Result<CourseLesson> findByType(
|
||||
public Result<CourseLessonResponse> findByType(
|
||||
@PathVariable Long courseId,
|
||||
@PathVariable String lessonType) {
|
||||
return Result.success(courseLessonService.findByType(courseId, lessonType));
|
||||
CourseLesson lesson = courseLessonService.findByType(courseId, lessonType);
|
||||
if (lesson == null) {
|
||||
return Result.success(null);
|
||||
}
|
||||
return Result.success(toLessonResponse(lesson));
|
||||
}
|
||||
|
||||
@PostMapping("/{courseId}/lessons")
|
||||
@Operation(summary = "创建课程环节")
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<CourseLesson> create(
|
||||
public Result<CourseLessonResponse> create(
|
||||
@PathVariable Long courseId,
|
||||
@Valid @RequestBody CourseLessonCreateRequest request) {
|
||||
return Result.success(courseLessonService.create(
|
||||
CourseLesson lesson = courseLessonService.create(
|
||||
courseId,
|
||||
request.getLessonType(),
|
||||
request.getName(),
|
||||
@ -72,17 +85,18 @@ public class AdminCourseLessonController {
|
||||
request.getReflection(),
|
||||
request.getAssessmentData(),
|
||||
request.getUseTemplate()
|
||||
));
|
||||
);
|
||||
return Result.success(toLessonResponse(lesson));
|
||||
}
|
||||
|
||||
@PutMapping("/{courseId}/lessons/{id}")
|
||||
@Operation(summary = "更新课程环节")
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<CourseLesson> update(
|
||||
public Result<CourseLessonResponse> update(
|
||||
@PathVariable Long courseId,
|
||||
@PathVariable Long id,
|
||||
@RequestBody CourseLessonCreateRequest request) {
|
||||
return Result.success(courseLessonService.update(
|
||||
CourseLesson lesson = courseLessonService.update(
|
||||
id,
|
||||
request.getName(),
|
||||
request.getDescription(),
|
||||
@ -99,7 +113,8 @@ public class AdminCourseLessonController {
|
||||
request.getReflection(),
|
||||
request.getAssessmentData(),
|
||||
request.getUseTemplate()
|
||||
));
|
||||
);
|
||||
return Result.success(toLessonResponse(lesson));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{courseId}/lessons/{id}")
|
||||
@ -126,44 +141,50 @@ public class AdminCourseLessonController {
|
||||
|
||||
@GetMapping("/{courseId}/lessons/{lessonId}/steps")
|
||||
@Operation(summary = "获取课时的教学环节")
|
||||
public Result<List<LessonStep>> findSteps(
|
||||
public Result<List<LessonStepResponse>> findSteps(
|
||||
@PathVariable Long courseId,
|
||||
@PathVariable Long lessonId) {
|
||||
return Result.success(courseLessonService.findSteps(lessonId));
|
||||
List<LessonStep> steps = courseLessonService.findSteps(lessonId);
|
||||
List<LessonStepResponse> responses = steps.stream()
|
||||
.map(this::toStepResponse)
|
||||
.collect(Collectors.toList());
|
||||
return Result.success(responses);
|
||||
}
|
||||
|
||||
@PostMapping("/{courseId}/lessons/{lessonId}/steps")
|
||||
@Operation(summary = "创建教学环节")
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<LessonStep> createStep(
|
||||
public Result<LessonStepResponse> createStep(
|
||||
@PathVariable Long courseId,
|
||||
@PathVariable Long lessonId,
|
||||
@RequestBody StepCreateRequest request) {
|
||||
return Result.success(courseLessonService.createStep(
|
||||
@Valid @RequestBody LessonStepCreateRequest request) {
|
||||
LessonStep step = courseLessonService.createStep(
|
||||
lessonId,
|
||||
request.getName(),
|
||||
request.getContent(),
|
||||
request.getDuration(),
|
||||
request.getObjective(),
|
||||
request.getResourceIds()
|
||||
));
|
||||
);
|
||||
return Result.success(toStepResponse(step));
|
||||
}
|
||||
|
||||
@PutMapping("/{courseId}/lessons/steps/{stepId}")
|
||||
@Operation(summary = "更新教学环节")
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<LessonStep> updateStep(
|
||||
public Result<LessonStepResponse> updateStep(
|
||||
@PathVariable Long courseId,
|
||||
@PathVariable Long stepId,
|
||||
@RequestBody StepCreateRequest request) {
|
||||
return Result.success(courseLessonService.updateStep(
|
||||
@Valid @RequestBody LessonStepCreateRequest request) {
|
||||
LessonStep step = courseLessonService.updateStep(
|
||||
stepId,
|
||||
request.getName(),
|
||||
request.getContent(),
|
||||
request.getDuration(),
|
||||
request.getObjective(),
|
||||
request.getResourceIds()
|
||||
));
|
||||
);
|
||||
return Result.success(toStepResponse(step));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{courseId}/lessons/steps/{stepId}")
|
||||
@ -188,24 +209,56 @@ public class AdminCourseLessonController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 教学环节创建请求
|
||||
* 将 CourseLesson 实体转换为 CourseLessonResponse
|
||||
*/
|
||||
public static class StepCreateRequest {
|
||||
private String name;
|
||||
private String content;
|
||||
private Integer duration;
|
||||
private String objective;
|
||||
private List<Long> resourceIds;
|
||||
private CourseLessonResponse toLessonResponse(CourseLesson lesson) {
|
||||
// 获取教学环节
|
||||
List<LessonStep> steps = courseLessonService.findSteps(lesson.getId());
|
||||
List<LessonStepResponse> stepResponses = steps.stream()
|
||||
.map(this::toStepResponse)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getContent() { return content; }
|
||||
public void setContent(String content) { this.content = content; }
|
||||
public Integer getDuration() { return duration; }
|
||||
public void setDuration(Integer duration) { this.duration = duration; }
|
||||
public String getObjective() { return objective; }
|
||||
public void setObjective(String objective) { this.objective = objective; }
|
||||
public List<Long> getResourceIds() { return resourceIds; }
|
||||
public void setResourceIds(List<Long> resourceIds) { this.resourceIds = resourceIds; }
|
||||
return CourseLessonResponse.builder()
|
||||
.id(lesson.getId())
|
||||
.courseId(lesson.getCourseId())
|
||||
.lessonType(lesson.getLessonType())
|
||||
.name(lesson.getName())
|
||||
.description(lesson.getDescription())
|
||||
.duration(lesson.getDuration())
|
||||
.videoPath(lesson.getVideoPath())
|
||||
.videoName(lesson.getVideoName())
|
||||
.pptPath(lesson.getPptPath())
|
||||
.pptName(lesson.getPptName())
|
||||
.pdfPath(lesson.getPdfPath())
|
||||
.pdfName(lesson.getPdfName())
|
||||
.objectives(lesson.getObjectives())
|
||||
.preparation(lesson.getPreparation())
|
||||
.extension(lesson.getExtension())
|
||||
.reflection(lesson.getReflection())
|
||||
.assessmentData(lesson.getAssessmentData())
|
||||
.useTemplate(lesson.getUseTemplate())
|
||||
.sortOrder(lesson.getSortOrder())
|
||||
.steps(stepResponses)
|
||||
.createdAt(lesson.getCreatedAt())
|
||||
.updatedAt(lesson.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 LessonStep 实体转换为 LessonStepResponse
|
||||
*/
|
||||
private LessonStepResponse toStepResponse(LessonStep step) {
|
||||
return LessonStepResponse.builder()
|
||||
.id(step.getId())
|
||||
.lessonId(step.getLessonId())
|
||||
.name(step.getName())
|
||||
.content(step.getContent())
|
||||
.duration(step.getDuration())
|
||||
.objective(step.getObjective())
|
||||
.resourceIds(step.getResourceIds())
|
||||
.sortOrder(step.getSortOrder())
|
||||
.createdAt(step.getCreatedAt())
|
||||
.updatedAt(step.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
package com.reading.platform.controller.admin;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.reading.platform.common.annotation.RequireRole;
|
||||
import com.reading.platform.common.enums.UserRole;
|
||||
import com.reading.platform.common.security.SecurityUtils;
|
||||
import com.reading.platform.common.response.PageResult;
|
||||
import com.reading.platform.common.response.Result;
|
||||
import com.reading.platform.dto.request.PackageCreateRequest;
|
||||
import com.reading.platform.dto.request.PackageGrantRequest;
|
||||
import com.reading.platform.dto.request.PackageReviewRequest;
|
||||
import com.reading.platform.dto.response.CoursePackageResponse;
|
||||
import com.reading.platform.entity.CoursePackage;
|
||||
import com.reading.platform.service.CoursePackageService;
|
||||
@ -19,6 +20,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 课程套餐控制器(超管端)
|
||||
@ -49,24 +51,25 @@ public class AdminPackageController {
|
||||
@PostMapping
|
||||
@Operation(summary = "创建套餐")
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<CoursePackage> create(@Valid @RequestBody PackageCreateRequest request) {
|
||||
return Result.success(packageService.createPackage(
|
||||
public Result<CoursePackageResponse> create(@Valid @RequestBody PackageCreateRequest request) {
|
||||
CoursePackage pkg = packageService.createPackage(
|
||||
request.getName(),
|
||||
request.getDescription(),
|
||||
request.getPrice(),
|
||||
request.getDiscountPrice(),
|
||||
request.getDiscountType(),
|
||||
request.getGradeLevels()
|
||||
));
|
||||
);
|
||||
return Result.success(packageService.findOnePackage(pkg.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新套餐")
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<CoursePackage> update(
|
||||
public Result<CoursePackageResponse> update(
|
||||
@PathVariable Long id,
|
||||
@RequestBody PackageCreateRequest request) {
|
||||
return Result.success(packageService.updatePackage(
|
||||
packageService.updatePackage(
|
||||
id,
|
||||
request.getName(),
|
||||
request.getDescription(),
|
||||
@ -74,7 +77,8 @@ public class AdminPackageController {
|
||||
request.getDiscountPrice(),
|
||||
request.getDiscountType(),
|
||||
request.getGradeLevels()
|
||||
));
|
||||
);
|
||||
return Result.success(packageService.findOnePackage(id));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@ -108,8 +112,14 @@ public class AdminPackageController {
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<Void> review(
|
||||
@PathVariable Long id,
|
||||
@RequestBody ReviewRequest request) {
|
||||
packageService.reviewPackage(id, SecurityUtils.getCurrentUserId(), request.getApproved(), request.getComment());
|
||||
@Valid @RequestBody PackageReviewRequest request) {
|
||||
packageService.reviewPackage(
|
||||
id,
|
||||
SecurityUtils.getCurrentUserId(),
|
||||
request.getApproved(),
|
||||
request.getComment(),
|
||||
request.getPublish()
|
||||
);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@ -129,12 +139,22 @@ public class AdminPackageController {
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@GetMapping("/all")
|
||||
@Operation(summary = "查询所有已发布的套餐列表")
|
||||
public Result<List<CoursePackageResponse>> getPublishedPackages() {
|
||||
List<CoursePackage> packages = packageService.findPublishedPackages();
|
||||
List<CoursePackageResponse> responses = packages.stream()
|
||||
.map(pkg -> packageService.findOnePackage(pkg.getId()))
|
||||
.collect(Collectors.toList());
|
||||
return Result.success(responses);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/grant")
|
||||
@Operation(summary = "授权套餐给租户")
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<Void> grantToTenant(
|
||||
@PathVariable Long id,
|
||||
@RequestBody GrantRequest request) {
|
||||
@Valid @RequestBody PackageGrantRequest request) {
|
||||
LocalDate endDate = LocalDate.parse(request.getEndDate(), DateTimeFormatter.ISO_DATE);
|
||||
packageService.renewTenantPackage(
|
||||
request.getTenantId(),
|
||||
@ -144,33 +164,4 @@ public class AdminPackageController {
|
||||
);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核请求
|
||||
*/
|
||||
public static class ReviewRequest {
|
||||
private Boolean approved;
|
||||
private String comment;
|
||||
|
||||
public Boolean getApproved() { return approved; }
|
||||
public void setApproved(Boolean approved) { this.approved = approved; }
|
||||
public String getComment() { return comment; }
|
||||
public void setComment(String comment) { this.comment = comment; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 授权请求
|
||||
*/
|
||||
public static class GrantRequest {
|
||||
private Long tenantId;
|
||||
private String endDate;
|
||||
private Long pricePaid;
|
||||
|
||||
public Long getTenantId() { return tenantId; }
|
||||
public void setTenantId(Long tenantId) { this.tenantId = tenantId; }
|
||||
public String getEndDate() { return endDate; }
|
||||
public void setEndDate(String endDate) { this.endDate = endDate; }
|
||||
public Long getPricePaid() { return pricePaid; }
|
||||
public void setPricePaid(Long pricePaid) { this.pricePaid = pricePaid; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,16 +5,26 @@ import com.reading.platform.common.annotation.RequireRole;
|
||||
import com.reading.platform.common.enums.UserRole;
|
||||
import com.reading.platform.common.response.PageResult;
|
||||
import com.reading.platform.common.response.Result;
|
||||
import com.reading.platform.dto.request.ResourceLibraryCreateRequest;
|
||||
import com.reading.platform.dto.request.ResourceLibraryUpdateRequest;
|
||||
import com.reading.platform.dto.request.ResourceItemCreateRequest;
|
||||
import com.reading.platform.dto.request.ResourceItemUpdateRequest;
|
||||
import com.reading.platform.dto.response.ResourceLibraryResponse;
|
||||
import com.reading.platform.dto.response.ResourceItemResponse;
|
||||
import com.reading.platform.entity.ResourceItem;
|
||||
import com.reading.platform.entity.ResourceLibrary;
|
||||
import com.reading.platform.service.ResourceLibraryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 资源库控制器(超管端)
|
||||
@ -26,49 +36,58 @@ import java.util.Map;
|
||||
public class AdminResourceController {
|
||||
|
||||
private final ResourceLibraryService resourceLibraryService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// ==================== 资源库管理 ====================
|
||||
|
||||
@GetMapping("/libraries")
|
||||
@Operation(summary = "分页查询资源库")
|
||||
public Result<PageResult<ResourceLibrary>> findAllLibraries(
|
||||
public Result<PageResult<ResourceLibraryResponse>> findAllLibraries(
|
||||
@RequestParam(required = false) String libraryType,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false, defaultValue = "1") Integer pageNum,
|
||||
@RequestParam(required = false, defaultValue = "10") Integer pageSize) {
|
||||
Page<ResourceLibrary> page = resourceLibraryService.findAllLibraries(libraryType, keyword, pageNum, pageSize);
|
||||
return Result.success(PageResult.of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize()));
|
||||
|
||||
List<ResourceLibraryResponse> responses = page.getRecords().stream()
|
||||
.map(this::toLibraryResponse)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return Result.success(PageResult.of(responses, page.getTotal(), page.getCurrent(), page.getSize()));
|
||||
}
|
||||
|
||||
@GetMapping("/libraries/{id}")
|
||||
@Operation(summary = "查询资源库详情")
|
||||
public Result<ResourceLibrary> findLibrary(@PathVariable String id) {
|
||||
return Result.success(resourceLibraryService.findLibraryById(id));
|
||||
public Result<ResourceLibraryResponse> findLibrary(@PathVariable String id) {
|
||||
ResourceLibrary library = resourceLibraryService.findLibraryById(id);
|
||||
return Result.success(toLibraryResponse(library));
|
||||
}
|
||||
|
||||
@PostMapping("/libraries")
|
||||
@Operation(summary = "创建资源库")
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<ResourceLibrary> createLibrary(@RequestBody LibraryCreateRequest request) {
|
||||
return Result.success(resourceLibraryService.createLibrary(
|
||||
public Result<ResourceLibraryResponse> createLibrary(@Valid @RequestBody ResourceLibraryCreateRequest request) {
|
||||
ResourceLibrary library = resourceLibraryService.createLibrary(
|
||||
request.getName(),
|
||||
request.getType(),
|
||||
request.getDescription(),
|
||||
request.getTenantId()
|
||||
));
|
||||
);
|
||||
return Result.success(toLibraryResponse(library));
|
||||
}
|
||||
|
||||
@PutMapping("/libraries/{id}")
|
||||
@Operation(summary = "更新资源库")
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<ResourceLibrary> updateLibrary(
|
||||
public Result<ResourceLibraryResponse> updateLibrary(
|
||||
@PathVariable String id,
|
||||
@RequestBody LibraryUpdateRequest request) {
|
||||
return Result.success(resourceLibraryService.updateLibrary(
|
||||
@Valid @RequestBody ResourceLibraryUpdateRequest request) {
|
||||
ResourceLibrary library = resourceLibraryService.updateLibrary(
|
||||
id,
|
||||
request.getName(),
|
||||
request.getDescription()
|
||||
));
|
||||
);
|
||||
return Result.success(toLibraryResponse(library));
|
||||
}
|
||||
|
||||
@DeleteMapping("/libraries/{id}")
|
||||
@ -83,50 +102,66 @@ public class AdminResourceController {
|
||||
|
||||
@GetMapping("/items")
|
||||
@Operation(summary = "分页查询资源项目")
|
||||
public Result<PageResult<ResourceItem>> findAllItems(
|
||||
public Result<PageResult<ResourceItemResponse>> findAllItems(
|
||||
@RequestParam(required = false) String libraryId,
|
||||
@RequestParam(required = false) String fileType,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false, defaultValue = "1") Integer pageNum,
|
||||
@RequestParam(required = false, defaultValue = "20") Integer pageSize) {
|
||||
Page<ResourceItem> page = resourceLibraryService.findAllItems(libraryId, fileType, keyword, pageNum, pageSize);
|
||||
return Result.success(PageResult.of(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize()));
|
||||
|
||||
List<ResourceItemResponse> responses = page.getRecords().stream()
|
||||
.map(this::toItemResponse)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return Result.success(PageResult.of(responses, page.getTotal(), page.getCurrent(), page.getSize()));
|
||||
}
|
||||
|
||||
@GetMapping("/items/{id}")
|
||||
@Operation(summary = "查询资源项目详情")
|
||||
public Result<ResourceItem> findItem(@PathVariable String id) {
|
||||
return Result.success(resourceLibraryService.findItemById(id));
|
||||
public Result<ResourceItemResponse> findItem(@PathVariable String id) {
|
||||
ResourceItem item = resourceLibraryService.findItemById(id);
|
||||
return Result.success(toItemResponse(item));
|
||||
}
|
||||
|
||||
@PostMapping("/items")
|
||||
@Operation(summary = "创建资源项目")
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<ResourceItem> createItem(@RequestBody ItemCreateRequest request) {
|
||||
return Result.success(resourceLibraryService.createItem(
|
||||
public Result<ResourceItemResponse> createItem(@Valid @RequestBody ResourceItemCreateRequest request) throws JsonProcessingException {
|
||||
String tagsStr = null;
|
||||
if (request.getTags() != null && !request.getTags().isEmpty()) {
|
||||
tagsStr = objectMapper.writeValueAsString(request.getTags());
|
||||
}
|
||||
ResourceItem item = resourceLibraryService.createItem(
|
||||
request.getLibraryId(),
|
||||
request.getTitle(),
|
||||
request.getFileType(),
|
||||
request.getFilePath(),
|
||||
request.getFileSize(),
|
||||
request.getDescription(),
|
||||
request.getTags(),
|
||||
tagsStr,
|
||||
request.getTenantId()
|
||||
));
|
||||
);
|
||||
return Result.success(toItemResponse(item));
|
||||
}
|
||||
|
||||
@PutMapping("/items/{id}")
|
||||
@Operation(summary = "更新资源项目")
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<ResourceItem> updateItem(
|
||||
public Result<ResourceItemResponse> updateItem(
|
||||
@PathVariable String id,
|
||||
@RequestBody ItemUpdateRequest request) {
|
||||
return Result.success(resourceLibraryService.updateItem(
|
||||
@Valid @RequestBody ResourceItemUpdateRequest request) throws JsonProcessingException {
|
||||
String tagsStr = null;
|
||||
if (request.getTags() != null && !request.getTags().isEmpty()) {
|
||||
tagsStr = objectMapper.writeValueAsString(request.getTags());
|
||||
}
|
||||
ResourceItem item = resourceLibraryService.updateItem(
|
||||
id,
|
||||
request.getTitle(),
|
||||
request.getDescription(),
|
||||
request.getTags()
|
||||
));
|
||||
tagsStr
|
||||
);
|
||||
return Result.success(toItemResponse(item));
|
||||
}
|
||||
|
||||
@DeleteMapping("/items/{id}")
|
||||
@ -154,81 +189,62 @@ public class AdminResourceController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源库创建请求
|
||||
* 将 ResourceLibrary 实体转换为 ResourceLibraryResponse
|
||||
*/
|
||||
public static class LibraryCreateRequest {
|
||||
private String name;
|
||||
private String type;
|
||||
private String description;
|
||||
private String tenantId;
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getType() { return type; }
|
||||
public void setType(String type) { this.type = type; }
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
public String getTenantId() { return tenantId; }
|
||||
public void setTenantId(String tenantId) { this.tenantId = tenantId; }
|
||||
private ResourceLibraryResponse toLibraryResponse(ResourceLibrary library) {
|
||||
return ResourceLibraryResponse.builder()
|
||||
.id(library.getId())
|
||||
.tenantId(library.getTenantId())
|
||||
.name(library.getName())
|
||||
.description(library.getDescription())
|
||||
.type(library.getLibraryType())
|
||||
.createdBy(library.getCreatedBy() != null ? String.valueOf(library.getCreatedBy()) : null)
|
||||
.createdAt(library.getCreatedAt())
|
||||
.updatedAt(library.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源库更新请求
|
||||
* 将 ResourceItem 实体转换为 ResourceItemResponse
|
||||
*/
|
||||
public static class LibraryUpdateRequest {
|
||||
private String name;
|
||||
private String description;
|
||||
private ResourceItemResponse toItemResponse(ResourceItem item) {
|
||||
ResourceItemResponse.LibrarySummary librarySummary = null;
|
||||
if (item.getLibraryId() != null && !item.getLibraryId().isEmpty()) {
|
||||
var lib = resourceLibraryService.findLibraryByIdOrNull(item.getLibraryId());
|
||||
if (lib != null) {
|
||||
librarySummary = ResourceItemResponse.LibrarySummary.builder()
|
||||
.id(lib.getId())
|
||||
.name(lib.getName())
|
||||
.libraryType(lib.getLibraryType())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
List<String> tagsList = parseTagsJson(item.getTags());
|
||||
|
||||
return ResourceItemResponse.builder()
|
||||
.id(item.getId())
|
||||
.libraryId(item.getLibraryId())
|
||||
.tenantId(item.getTenantId())
|
||||
.title(item.getTitle())
|
||||
.fileType(item.getFileType())
|
||||
.filePath(item.getFilePath())
|
||||
.fileSize(item.getFileSize())
|
||||
.tags(tagsList)
|
||||
.library(librarySummary)
|
||||
.description(item.getDescription())
|
||||
.status(item.getStatus())
|
||||
.createdAt(item.getCreatedAt())
|
||||
.updatedAt(item.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源项目创建请求(数字资源)
|
||||
*/
|
||||
public static class ItemCreateRequest {
|
||||
private String libraryId;
|
||||
private String title;
|
||||
private String fileType;
|
||||
private String filePath;
|
||||
private Long fileSize;
|
||||
private String description;
|
||||
private String tags;
|
||||
private String tenantId;
|
||||
|
||||
public String getLibraryId() { return libraryId; }
|
||||
public void setLibraryId(String libraryId) { this.libraryId = libraryId; }
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public String getFileType() { return fileType; }
|
||||
public void setFileType(String fileType) { this.fileType = fileType; }
|
||||
public String getFilePath() { return filePath; }
|
||||
public void setFilePath(String filePath) { this.filePath = filePath; }
|
||||
public Long getFileSize() { return fileSize; }
|
||||
public void setFileSize(Long fileSize) { this.fileSize = fileSize; }
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
public String getTags() { return tags; }
|
||||
public void setTags(String tags) { this.tags = tags; }
|
||||
public String getTenantId() { return tenantId; }
|
||||
public void setTenantId(String tenantId) { this.tenantId = tenantId; }
|
||||
private List<String> parseTagsJson(String tagsJson) {
|
||||
if (tagsJson == null || tagsJson.isEmpty()) return List.of();
|
||||
try {
|
||||
return objectMapper.readValue(tagsJson, objectMapper.getTypeFactory().constructCollectionType(List.class, String.class));
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源项目更新请求(数字资源)
|
||||
*/
|
||||
public static class ItemUpdateRequest {
|
||||
private String title;
|
||||
private String description;
|
||||
private String tags;
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
public String getTags() { return tags; }
|
||||
public void setTags(String tags) { this.tags = tags; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,74 +3,65 @@ package com.reading.platform.controller.admin;
|
||||
import com.reading.platform.common.annotation.RequireRole;
|
||||
import com.reading.platform.common.enums.UserRole;
|
||||
import com.reading.platform.common.response.Result;
|
||||
import com.reading.platform.dto.request.ActiveTenantsQueryRequest;
|
||||
import com.reading.platform.dto.request.PopularCoursesQueryRequest;
|
||||
import com.reading.platform.dto.request.RecentActivitiesQueryRequest;
|
||||
import com.reading.platform.dto.response.ActiveTenantItemResponse;
|
||||
import com.reading.platform.dto.response.PopularCourseItemResponse;
|
||||
import com.reading.platform.dto.response.RecentActivityItemResponse;
|
||||
import com.reading.platform.dto.response.StatsResponse;
|
||||
import com.reading.platform.dto.response.StatsTrendResponse;
|
||||
import com.reading.platform.service.StatsService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 超管端 - 统计管理
|
||||
* 统计管理控制器(超管端)
|
||||
*/
|
||||
@Tag(name = "超管 - 统计管理", description = "Admin Stats APIs")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/stats")
|
||||
@RequiredArgsConstructor
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
@Tag(name = "超管端 - 统计管理")
|
||||
public class AdminStatsController {
|
||||
|
||||
@Operation(summary = "获取统计数据")
|
||||
private final StatsService statsService;
|
||||
|
||||
@GetMapping
|
||||
public Result<Map<String, Object>> getStats() {
|
||||
// TODO: 实现统计数据查询
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.put("totalTenants", 0);
|
||||
stats.put("activeTenants", 0);
|
||||
stats.put("totalTeachers", 0);
|
||||
stats.put("totalStudents", 0);
|
||||
stats.put("totalCourses", 0);
|
||||
stats.put("totalLessons", 0);
|
||||
return Result.success(stats);
|
||||
@Operation(summary = "获取统计数据")
|
||||
public Result<StatsResponse> getStats() {
|
||||
return Result.success(statsService.getStats());
|
||||
}
|
||||
|
||||
@Operation(summary = "获取趋势数据")
|
||||
@GetMapping("/trend")
|
||||
public Result<Map<String, Object>> getTrendData() {
|
||||
// TODO: 实现趋势数据查询
|
||||
Map<String, Object> trend = new HashMap<>();
|
||||
trend.put("dates", new String[]{});
|
||||
trend.put("newStudents", new Integer[]{});
|
||||
trend.put("newTeachers", new Integer[]{});
|
||||
trend.put("newCourses", new Integer[]{});
|
||||
return Result.success(trend);
|
||||
@Operation(summary = "获取趋势数据")
|
||||
public Result<StatsTrendResponse> getTrendData() {
|
||||
return Result.success(statsService.getTrendData());
|
||||
}
|
||||
|
||||
@Operation(summary = "获取活跃租户")
|
||||
@GetMapping("/tenants/active")
|
||||
public Result<List<Map<String, Object>>> getActiveTenants(@RequestParam(required = false, defaultValue = "5") Integer limit) {
|
||||
// TODO: 实现活跃租户查询
|
||||
return Result.success(new ArrayList<>());
|
||||
@Operation(summary = "获取活跃租户")
|
||||
public Result<List<ActiveTenantItemResponse>> getActiveTenants(@ModelAttribute ActiveTenantsQueryRequest request) {
|
||||
return Result.success(statsService.getActiveTenants(request));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取热门课程")
|
||||
@GetMapping("/courses/popular")
|
||||
public Result<List<Map<String, Object>>> getPopularCourses(@RequestParam(required = false, defaultValue = "5") Integer limit) {
|
||||
// TODO: 实现热门课程查询
|
||||
return Result.success(new ArrayList<>());
|
||||
@Operation(summary = "获取热门课程")
|
||||
public Result<List<PopularCourseItemResponse>> getPopularCourses(@ModelAttribute PopularCoursesQueryRequest request) {
|
||||
return Result.success(statsService.getPopularCourses(request));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取最近活动")
|
||||
@GetMapping("/activities")
|
||||
public Result<List<Map<String, Object>>> getRecentActivities(@RequestParam(required = false, defaultValue = "10") Integer limit) {
|
||||
// TODO: 实现最近活动查询
|
||||
return Result.success(new ArrayList<>());
|
||||
@Operation(summary = "获取最近活动")
|
||||
public Result<List<RecentActivityItemResponse>> getRecentActivities(@ModelAttribute RecentActivitiesQueryRequest request) {
|
||||
return Result.success(statsService.getRecentActivities(request));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@ -1,20 +1,14 @@
|
||||
package com.reading.platform.controller.admin;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.reading.platform.common.annotation.RequireRole;
|
||||
import com.reading.platform.common.enums.UserRole;
|
||||
import com.reading.platform.common.mapper.TenantMapper;
|
||||
import com.reading.platform.common.response.PageResult;
|
||||
import com.reading.platform.common.response.Result;
|
||||
import com.reading.platform.dto.request.TenantCreateRequest;
|
||||
import com.reading.platform.dto.request.TenantUpdateRequest;
|
||||
import com.reading.platform.dto.response.TenantResponse;
|
||||
import com.reading.platform.entity.Student;
|
||||
import com.reading.platform.entity.Teacher;
|
||||
import com.reading.platform.entity.Tenant;
|
||||
import com.reading.platform.mapper.StudentMapper;
|
||||
import com.reading.platform.mapper.TeacherMapper;
|
||||
import com.reading.platform.service.TenantService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@ -25,6 +19,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Tag(name = "超管端 - 租户管理", description = "Tenant Management APIs for Admin")
|
||||
@RestController
|
||||
@ -34,29 +29,26 @@ import java.util.Map;
|
||||
public class AdminTenantController {
|
||||
|
||||
private final TenantService tenantService;
|
||||
private final TenantMapper tenantMapper;
|
||||
private final TeacherMapper teacherMapper;
|
||||
private final StudentMapper studentMapper;
|
||||
|
||||
@Operation(summary = "Create tenant")
|
||||
@PostMapping
|
||||
public Result<TenantResponse> createTenant(@Valid @RequestBody TenantCreateRequest request) {
|
||||
Tenant tenant = tenantService.createTenant(request);
|
||||
return Result.success(tenantMapper.toVO(tenant));
|
||||
return Result.success(toResponse(tenant));
|
||||
}
|
||||
|
||||
@Operation(summary = "Update tenant")
|
||||
@PutMapping("/{id}")
|
||||
public Result<TenantResponse> updateTenant(@PathVariable Long id, @RequestBody TenantUpdateRequest request) {
|
||||
Tenant tenant = tenantService.updateTenant(id, request);
|
||||
return Result.success(tenantMapper.toVO(tenant));
|
||||
return Result.success(toResponse(tenant));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get tenant by ID")
|
||||
@GetMapping("/{id}")
|
||||
public Result<TenantResponse> getTenant(@PathVariable Long id) {
|
||||
Tenant tenant = tenantService.getTenantById(id);
|
||||
return Result.success(tenantMapper.toVO(tenant));
|
||||
return Result.success(toResponse(tenant));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get tenant page")
|
||||
@ -66,27 +58,15 @@ public class AdminTenantController {
|
||||
@RequestParam(required = false, defaultValue = "10") Integer pageSize,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String status) {
|
||||
Page<Tenant> pageResult = tenantService.getTenantPage(pageNum, pageSize, keyword, status);
|
||||
List<TenantResponse> voList = tenantMapper.toVO(pageResult.getRecords());
|
||||
// 调用 Service 层方法获取带统计数据的租户分页
|
||||
Page<TenantResponse> pageResult = tenantService.getTenantPageWithStats(pageNum, pageSize, keyword, status);
|
||||
|
||||
// 填充教师数量和学生数量
|
||||
for (TenantResponse vo : voList) {
|
||||
if (vo.getId() != null) {
|
||||
Long teacherCount = teacherMapper.selectCount(
|
||||
new LambdaQueryWrapper<Teacher>()
|
||||
.eq(Teacher::getTenantId, vo.getId())
|
||||
);
|
||||
vo.setTeacherCount(teacherCount != null ? teacherCount.intValue() : 0);
|
||||
|
||||
Long studentCount = studentMapper.selectCount(
|
||||
new LambdaQueryWrapper<Student>()
|
||||
.eq(Student::getTenantId, vo.getId())
|
||||
);
|
||||
vo.setStudentCount(studentCount != null ? studentCount.intValue() : 0);
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success(PageResult.of(voList, pageResult.getTotal(), pageResult.getCurrent(), pageResult.getSize()));
|
||||
return Result.success(PageResult.of(
|
||||
pageResult.getRecords(),
|
||||
pageResult.getTotal(),
|
||||
pageResult.getCurrent(),
|
||||
pageResult.getSize()
|
||||
));
|
||||
}
|
||||
|
||||
@Operation(summary = "Delete tenant")
|
||||
@ -100,7 +80,10 @@ public class AdminTenantController {
|
||||
@GetMapping("/active")
|
||||
public Result<List<TenantResponse>> getAllActiveTenants() {
|
||||
List<Tenant> tenants = tenantService.getAllActiveTenants();
|
||||
return Result.success(tenantMapper.toVO(tenants));
|
||||
List<TenantResponse> responses = tenants.stream()
|
||||
.map(this::toResponse)
|
||||
.collect(Collectors.toList());
|
||||
return Result.success(responses);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取租户统计信息")
|
||||
@ -118,7 +101,7 @@ public class AdminTenantController {
|
||||
public Result<TenantResponse> updateTenantQuota(@PathVariable Long id, @RequestBody Map<String, Object> quota) {
|
||||
// TODO: 实现更新租户配额逻辑
|
||||
Tenant tenant = tenantService.getTenantById(id);
|
||||
return Result.success(tenantMapper.toVO(tenant));
|
||||
return Result.success(toResponse(tenant));
|
||||
}
|
||||
|
||||
@Operation(summary = "更新租户状态")
|
||||
@ -126,7 +109,7 @@ public class AdminTenantController {
|
||||
public Result<TenantResponse> updateTenantStatus(@PathVariable Long id, @RequestBody Map<String, Object> status) {
|
||||
// TODO: 实现更新租户状态逻辑
|
||||
Tenant tenant = tenantService.getTenantById(id);
|
||||
return Result.success(tenantMapper.toVO(tenant));
|
||||
return Result.success(toResponse(tenant));
|
||||
}
|
||||
|
||||
@Operation(summary = "重置租户密码")
|
||||
@ -136,4 +119,34 @@ public class AdminTenantController {
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
* 将 Tenant 实体转换为 TenantResponse
|
||||
*/
|
||||
private TenantResponse toResponse(Tenant tenant) {
|
||||
return TenantResponse.builder()
|
||||
.id(tenant.getId())
|
||||
.name(tenant.getName())
|
||||
.code(tenant.getCode())
|
||||
.username(tenant.getUsername())
|
||||
.contactName(tenant.getContactName())
|
||||
.contactPhone(tenant.getContactPhone())
|
||||
.contactEmail(tenant.getContactEmail())
|
||||
.address(tenant.getAddress())
|
||||
.logoUrl(tenant.getLogoUrl())
|
||||
.status(tenant.getStatus())
|
||||
.expireAt(tenant.getExpireAt())
|
||||
.maxStudents(tenant.getMaxStudents())
|
||||
.maxTeachers(tenant.getMaxTeachers())
|
||||
.packageType(tenant.getPackageType())
|
||||
.teacherQuota(tenant.getTeacherQuota())
|
||||
.studentQuota(tenant.getStudentQuota())
|
||||
.storageQuota(tenant.getStorageQuota())
|
||||
.storageUsed(tenant.getStorageUsed())
|
||||
.startDate(tenant.getStartDate())
|
||||
.expireDate(tenant.getExpireDate())
|
||||
.createdAt(tenant.getCreatedAt())
|
||||
.updatedAt(tenant.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@ -4,6 +4,7 @@ import com.reading.platform.common.annotation.RequireRole;
|
||||
import com.reading.platform.common.enums.UserRole;
|
||||
import com.reading.platform.common.response.Result;
|
||||
import com.reading.platform.dto.request.ThemeCreateRequest;
|
||||
import com.reading.platform.dto.response.ThemeResponse;
|
||||
import com.reading.platform.entity.Theme;
|
||||
import com.reading.platform.service.ThemeService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@ -13,6 +14,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 主题字典控制器(超管端)
|
||||
@ -27,40 +29,47 @@ public class AdminThemeController {
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "查询所有主题")
|
||||
public Result<List<Theme>> findAll() {
|
||||
return Result.success(themeService.findAll());
|
||||
public Result<List<ThemeResponse>> findAll() {
|
||||
List<Theme> themes = themeService.findAll();
|
||||
List<ThemeResponse> responses = themes.stream()
|
||||
.map(this::toResponse)
|
||||
.collect(Collectors.toList());
|
||||
return Result.success(responses);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "查询主题详情")
|
||||
public Result<Theme> findOne(@PathVariable Long id) {
|
||||
return Result.success(themeService.findById(id));
|
||||
public Result<ThemeResponse> findOne(@PathVariable Long id) {
|
||||
Theme theme = themeService.findById(id);
|
||||
return Result.success(toResponse(theme));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "创建主题")
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<Theme> create(@Valid @RequestBody ThemeCreateRequest request) {
|
||||
return Result.success(themeService.create(
|
||||
public Result<ThemeResponse> create(@Valid @RequestBody ThemeCreateRequest request) {
|
||||
Theme theme = themeService.create(
|
||||
request.getName(),
|
||||
request.getDescription(),
|
||||
request.getSortOrder()
|
||||
));
|
||||
);
|
||||
return Result.success(toResponse(theme));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新主题")
|
||||
@RequireRole(UserRole.ADMIN)
|
||||
public Result<Theme> update(
|
||||
public Result<ThemeResponse> update(
|
||||
@PathVariable Long id,
|
||||
@RequestBody ThemeCreateRequest request) {
|
||||
return Result.success(themeService.update(
|
||||
Theme theme = themeService.update(
|
||||
id,
|
||||
request.getName(),
|
||||
request.getDescription(),
|
||||
request.getSortOrder(),
|
||||
null
|
||||
));
|
||||
);
|
||||
return Result.success(toResponse(theme));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@ -78,4 +87,19 @@ public class AdminThemeController {
|
||||
themeService.reorder(ids);
|
||||
return Result.success();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Theme 实体转换为 ThemeResponse
|
||||
*/
|
||||
private ThemeResponse toResponse(Theme theme) {
|
||||
return ThemeResponse.builder()
|
||||
.id(theme.getId())
|
||||
.name(theme.getName())
|
||||
.description(theme.getDescription())
|
||||
.sortOrder(theme.getSortOrder())
|
||||
.status(theme.getStatus())
|
||||
.createdAt(theme.getCreatedAt())
|
||||
.updatedAt(theme.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -1,39 +1,45 @@
|
||||
package com.reading.platform.controller.school;
|
||||
|
||||
import com.reading.platform.common.response.Result;
|
||||
import com.reading.platform.entity.Course;
|
||||
import com.reading.platform.service.CourseService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 课程管理控制器(学校端)
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/school/courses")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "学校端 - 课程管理")
|
||||
public class SchoolCourseController {
|
||||
|
||||
private final CourseService courseService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "获取学校课程列表")
|
||||
public Result<List<Map<String, Object>>> getSchoolCourses() {
|
||||
List<Map<String, Object>> courses = new ArrayList<>();
|
||||
// For now, return empty list
|
||||
// TODO: Implement tenant course query
|
||||
public Result<List<Course>> getSchoolCourses() {
|
||||
log.info("获取学校课程列表");
|
||||
// TODO: 从 SecurityContext 获取当前登录用户所属租户 ID
|
||||
// 临时使用 tenantId = 1 作为测试
|
||||
Long tenantId = 1L;
|
||||
List<Course> courses = courseService.getTenantPackageCourses(tenantId);
|
||||
return Result.success(courses);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "获取课程详情")
|
||||
public Result<Map<String, Object>> getSchoolCourse(@PathVariable Long id) {
|
||||
Map<String, Object> course = new HashMap<>();
|
||||
// TODO: Implement course detail query
|
||||
public Result<Course> getSchoolCourse(@PathVariable Long id) {
|
||||
log.info("获取课程详情,id={}", id);
|
||||
Course course = courseService.getCourseById(id);
|
||||
return Result.success(course);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.reading.platform.controller.teacher;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.reading.platform.common.enums.CourseStatus;
|
||||
import com.reading.platform.common.mapper.ClassMapper;
|
||||
import com.reading.platform.common.mapper.CourseMapper;
|
||||
import com.reading.platform.common.mapper.StudentMapper;
|
||||
@ -68,7 +69,7 @@ public class TeacherCourseController {
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String category) {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
Page<Course> page = courseService.getCoursePage(tenantId, pageNum, pageSize, keyword, category, "published");
|
||||
Page<Course> page = courseService.getCoursePage(tenantId, pageNum, pageSize, keyword, category, CourseStatus.PUBLISHED.getCode());
|
||||
List<CourseResponse> voList = courseMapper.toVO(page.getRecords());
|
||||
return Result.success(PageResult.of(voList, page.getTotal(), page.getCurrent(), page.getSize()));
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.reading.platform.controller.teacher;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.reading.platform.common.mapper.ClassMapper;
|
||||
import com.reading.platform.common.mapper.LessonMapper;
|
||||
import com.reading.platform.common.mapper.StudentRecordMapper;
|
||||
import com.reading.platform.common.response.PageResult;
|
||||
@ -10,11 +11,19 @@ import com.reading.platform.dto.request.LessonCreateRequest;
|
||||
import com.reading.platform.dto.request.LessonProgressRequest;
|
||||
import com.reading.platform.dto.request.LessonUpdateRequest;
|
||||
import com.reading.platform.dto.request.StudentRecordRequest;
|
||||
import com.reading.platform.dto.response.ClassResponse;
|
||||
import com.reading.platform.dto.response.CourseResponse;
|
||||
import com.reading.platform.dto.response.LessonDetailResponse;
|
||||
import com.reading.platform.dto.response.LessonResponse;
|
||||
import com.reading.platform.dto.response.StudentRecordResponse;
|
||||
import com.reading.platform.entity.Clazz;
|
||||
import com.reading.platform.entity.Course;
|
||||
import com.reading.platform.entity.Lesson;
|
||||
import com.reading.platform.entity.LessonFeedback;
|
||||
import com.reading.platform.entity.StudentRecord;
|
||||
import com.reading.platform.mapper.ClazzMapper;
|
||||
import com.reading.platform.mapper.CourseMapper;
|
||||
import com.reading.platform.service.CourseService;
|
||||
import com.reading.platform.service.LessonService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@ -35,13 +44,19 @@ public class TeacherLessonController {
|
||||
private final LessonService lessonService;
|
||||
private final LessonMapper lessonMapper;
|
||||
private final StudentRecordMapper studentRecordMapper;
|
||||
private final CourseMapper courseMapper;
|
||||
private final ClazzMapper clazzMapper;
|
||||
private final ClassMapper classMapper;
|
||||
private final CourseService courseService;
|
||||
|
||||
@Operation(summary = "Create lesson")
|
||||
@PostMapping
|
||||
public Result<LessonResponse> createLesson(@Valid @RequestBody LessonCreateRequest request) {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
Lesson lesson = lessonService.createLesson(tenantId, request);
|
||||
return Result.success(lessonMapper.toVO(lesson));
|
||||
LessonResponse vo = lessonMapper.toVO(lesson);
|
||||
enrichWithCourseAndClass(vo);
|
||||
return Result.success(vo);
|
||||
}
|
||||
|
||||
@Operation(summary = "Update lesson")
|
||||
@ -51,11 +66,31 @@ public class TeacherLessonController {
|
||||
return Result.success(lessonMapper.toVO(lesson));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get lesson by ID")
|
||||
@Operation(summary = "Get lesson by ID(含课程、班级,供上课页面使用)")
|
||||
@GetMapping("/{id}")
|
||||
public Result<LessonResponse> getLesson(@PathVariable Long id) {
|
||||
public Result<LessonDetailResponse> getLesson(@PathVariable Long id) {
|
||||
Lesson lesson = lessonService.getLessonById(id);
|
||||
return Result.success(lessonMapper.toVO(lesson));
|
||||
LessonResponse lessonVo = lessonMapper.toVO(lesson);
|
||||
enrichWithCourseAndClass(lessonVo);
|
||||
|
||||
LessonDetailResponse detail = new LessonDetailResponse();
|
||||
detail.setLesson(lessonVo);
|
||||
|
||||
if (lesson.getCourseId() != null) {
|
||||
try {
|
||||
CourseResponse courseResp = courseService.getCourseByIdWithLessons(lesson.getCourseId());
|
||||
detail.setCourse(courseResp);
|
||||
} catch (Exception e) {
|
||||
// 课程不存在时留空
|
||||
}
|
||||
}
|
||||
if (lesson.getClassId() != null) {
|
||||
Clazz clazz = clazzMapper.selectById(lesson.getClassId());
|
||||
if (clazz != null) {
|
||||
detail.setClassInfo(classMapper.toVO(clazz));
|
||||
}
|
||||
}
|
||||
return Result.success(detail);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get my lessons")
|
||||
@ -69,6 +104,7 @@ public class TeacherLessonController {
|
||||
Long teacherId = SecurityUtils.getCurrentUserId();
|
||||
Page<Lesson> page = lessonService.getTeacherLessons(teacherId, pageNum, pageSize, status, startDate, endDate);
|
||||
List<LessonResponse> voList = lessonMapper.toVO(page.getRecords());
|
||||
voList.forEach(this::enrichWithCourseAndClass);
|
||||
return Result.success(PageResult.of(voList, page.getTotal(), page.getCurrent(), page.getSize()));
|
||||
}
|
||||
|
||||
@ -98,7 +134,9 @@ public class TeacherLessonController {
|
||||
public Result<List<LessonResponse>> getTodayLessons() {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
List<Lesson> lessons = lessonService.getTodayLessons(tenantId);
|
||||
return Result.success(lessonMapper.toVO(lessons));
|
||||
List<LessonResponse> voList = lessonMapper.toVO(lessons);
|
||||
voList.forEach(this::enrichWithCourseAndClass);
|
||||
return Result.success(voList);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get student records")
|
||||
@ -159,7 +197,24 @@ public class TeacherLessonController {
|
||||
@GetMapping("/{id}/progress")
|
||||
public Result<LessonResponse> getLessonProgress(@PathVariable Long id) {
|
||||
Lesson lesson = lessonService.getLessonProgress(id);
|
||||
return Result.success(lessonMapper.toVO(lesson));
|
||||
LessonResponse vo = lessonMapper.toVO(lesson);
|
||||
enrichWithCourseAndClass(vo);
|
||||
return Result.success(vo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 LessonResponse 补充课程名称和班级名称
|
||||
*/
|
||||
private void enrichWithCourseAndClass(LessonResponse vo) {
|
||||
if (vo == null) return;
|
||||
if (vo.getCourseId() != null) {
|
||||
Course course = courseMapper.selectById(vo.getCourseId());
|
||||
vo.setCourseName(course != null ? course.getName() : null);
|
||||
}
|
||||
if (vo.getClassId() != null) {
|
||||
Clazz clazz = clazzMapper.selectById(vo.getClassId());
|
||||
vo.setClassName(clazz != null ? clazz.getName() : null);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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 ActiveTenantsQueryRequest {
|
||||
|
||||
@Schema(description = "返回数量限制", example = "5")
|
||||
private Integer limit = 5;
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.reading.platform.dto.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 课程分页查询请求
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "课程分页查询请求")
|
||||
public class CoursePageQueryRequest {
|
||||
|
||||
@Schema(description = "页码", example = "1")
|
||||
private Integer pageNum = 1;
|
||||
|
||||
@Schema(description = "每页数量", example = "10")
|
||||
private Integer pageSize = 10;
|
||||
|
||||
@Schema(description = "关键词")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "分类")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "状态")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "是否仅查询待审核", example = "false")
|
||||
private Boolean reviewOnly = false;
|
||||
}
|
||||
@ -3,6 +3,8 @@ package com.reading.platform.dto.request;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 课程进度保存请求
|
||||
*/
|
||||
@ -16,13 +18,13 @@ public class LessonProgressRequest {
|
||||
@Schema(description = "当前步骤 ID")
|
||||
private Integer currentStepId;
|
||||
|
||||
@Schema(description = "课程 ID 列表 (JSON)")
|
||||
private String lessonIds;
|
||||
@Schema(description = "课程 ID 列表")
|
||||
private List<Long> lessonIds;
|
||||
|
||||
@Schema(description = "已完成课程 ID 列表 (JSON)")
|
||||
private String completedLessonIds;
|
||||
@Schema(description = "已完成课程 ID 列表")
|
||||
private List<Long> completedLessonIds;
|
||||
|
||||
@Schema(description = "进度数据 (JSON)")
|
||||
private String progressData;
|
||||
@Schema(description = "进度数据 (JSON 对象)")
|
||||
private Object progressData;
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
package com.reading.platform.dto.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 教学环节创建请求
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "教学环节创建请求")
|
||||
public class LessonStepCreateRequest {
|
||||
|
||||
@Schema(description = "环节名称")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "环节内容")
|
||||
private String content;
|
||||
|
||||
@Schema(description = "时长(分钟)")
|
||||
private Integer duration;
|
||||
|
||||
@Schema(description = "教学目标")
|
||||
private String objective;
|
||||
|
||||
@Schema(description = "关联资源 ID 列表")
|
||||
private List<Long> resourceIds;
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package com.reading.platform.dto.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 套餐授权请求
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "套餐授权请求")
|
||||
public class PackageGrantRequest {
|
||||
|
||||
@Schema(description = "租户 ID")
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "结束日期")
|
||||
private String endDate;
|
||||
|
||||
@Schema(description = "支付金额(分)")
|
||||
private Long pricePaid;
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package com.reading.platform.dto.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 套餐审核请求
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "套餐审核请求")
|
||||
public class PackageReviewRequest {
|
||||
|
||||
@Schema(description = "是否通过")
|
||||
private Boolean approved;
|
||||
|
||||
@Schema(description = "审核意见")
|
||||
private String comment;
|
||||
|
||||
@Schema(description = "是否同时发布(仅当 approved=true 时有效)")
|
||||
private Boolean publish;
|
||||
}
|
||||
@ -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 PopularCoursesQueryRequest {
|
||||
|
||||
@Schema(description = "返回数量限制", example = "5")
|
||||
private Integer limit = 5;
|
||||
}
|
||||
@ -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 RecentActivitiesQueryRequest {
|
||||
|
||||
@Schema(description = "返回数量限制", example = "10")
|
||||
private Integer limit = 10;
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package com.reading.platform.dto.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 资源项目创建请求
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "资源项目创建请求")
|
||||
public class ResourceItemCreateRequest {
|
||||
|
||||
@Schema(description = "资源库 ID")
|
||||
private String libraryId;
|
||||
|
||||
@Schema(description = "资源标题")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "文件类型")
|
||||
private String fileType;
|
||||
|
||||
@Schema(description = "文件路径")
|
||||
private String filePath;
|
||||
|
||||
@Schema(description = "文件大小(字节)")
|
||||
private Long fileSize;
|
||||
|
||||
@Schema(description = "资源描述")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "标签(字符串数组)")
|
||||
private List<String> tags;
|
||||
|
||||
@Schema(description = "租户 ID")
|
||||
private String tenantId;
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.reading.platform.dto.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 资源项目更新请求
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "资源项目更新请求")
|
||||
public class ResourceItemUpdateRequest {
|
||||
|
||||
@Schema(description = "资源标题")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "资源描述")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "标签(字符串数组)")
|
||||
private List<String> tags;
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package com.reading.platform.dto.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 资源库创建请求
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "资源库创建请求")
|
||||
public class ResourceLibraryCreateRequest {
|
||||
|
||||
@Schema(description = "资源库名称")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "资源库类型")
|
||||
@JsonAlias("libraryType")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "资源库描述")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "租户 ID")
|
||||
private String tenantId;
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package com.reading.platform.dto.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 资源库更新请求
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "资源库更新请求")
|
||||
public class ResourceLibraryUpdateRequest {
|
||||
|
||||
@Schema(description = "资源库名称")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "资源库描述")
|
||||
private String description;
|
||||
}
|
||||
@ -49,6 +49,9 @@ public class TenantCreateRequest {
|
||||
@Schema(description = "结束日期")
|
||||
private LocalDate expireDate;
|
||||
|
||||
@Schema(description = "课程套餐 ID(可选)")
|
||||
private Long packageId;
|
||||
|
||||
@Schema(description = "过期时间(兼容旧字段)")
|
||||
@Deprecated
|
||||
private LocalDateTime expireAt;
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
package com.reading.platform.dto.response;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
/**
|
||||
* 活跃租户项响应
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "活跃租户项响应")
|
||||
public class ActiveTenantItemResponse {
|
||||
|
||||
@Schema(description = "租户 ID")
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "租户名称")
|
||||
private String tenantName;
|
||||
|
||||
@Schema(description = "活跃用户数")
|
||||
private Integer activeUsers;
|
||||
|
||||
@Schema(description = "课程使用数")
|
||||
private Integer courseCount;
|
||||
}
|
||||
@ -7,6 +7,7 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 课程环节响应
|
||||
@ -76,6 +77,9 @@ public class CourseLessonResponse {
|
||||
@Schema(description = "排序号")
|
||||
private Integer sortOrder;
|
||||
|
||||
@Schema(description = "教学环节列表")
|
||||
private List<LessonStepResponse> steps;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
package com.reading.platform.dto.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 授课记录详情响应(含课程、班级,供上课页面使用)
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "授课记录详情响应")
|
||||
public class LessonDetailResponse {
|
||||
|
||||
@Schema(description = "授课记录基本信息")
|
||||
private LessonResponse lesson;
|
||||
|
||||
@Schema(description = "课程信息(含 courseLessons 及 steps)")
|
||||
private CourseResponse course;
|
||||
|
||||
@JsonProperty("class")
|
||||
@Schema(description = "班级信息")
|
||||
private ClassResponse classInfo;
|
||||
}
|
||||
@ -29,6 +29,12 @@ public class LessonResponse {
|
||||
@Schema(description = "班级 ID")
|
||||
private Long classId;
|
||||
|
||||
@Schema(description = "课程名称(用于列表展示)")
|
||||
private String courseName;
|
||||
|
||||
@Schema(description = "班级名称(用于列表展示)")
|
||||
private String className;
|
||||
|
||||
@Schema(description = "教师 ID")
|
||||
private Long teacherId;
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
package com.reading.platform.dto.response;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@ -12,6 +14,8 @@ import java.time.LocalDateTime;
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "教学环节响应")
|
||||
public class LessonStepResponse {
|
||||
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
package com.reading.platform.dto.response;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
/**
|
||||
* 热门课程项响应
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "热门课程项响应")
|
||||
public class PopularCourseItemResponse {
|
||||
|
||||
@Schema(description = "课程 ID")
|
||||
private Long courseId;
|
||||
|
||||
@Schema(description = "课程名称")
|
||||
private String courseName;
|
||||
|
||||
@Schema(description = "使用次数")
|
||||
private Integer usageCount;
|
||||
|
||||
@Schema(description = "教师数量")
|
||||
private Integer teacherCount;
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package com.reading.platform.dto.response;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 最近活动项响应
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "最近活动项响应")
|
||||
public class RecentActivityItemResponse {
|
||||
|
||||
@Schema(description = "活动 ID")
|
||||
private Long activityId;
|
||||
|
||||
@Schema(description = "活动类型")
|
||||
private String activityType;
|
||||
|
||||
@Schema(description = "活动描述")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "操作人 ID")
|
||||
private Long operatorId;
|
||||
|
||||
@Schema(description = "操作人名称")
|
||||
private String operatorName;
|
||||
|
||||
@Schema(description = "操作时间")
|
||||
private LocalDateTime operationTime;
|
||||
}
|
||||
@ -5,6 +5,7 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 资源项响应
|
||||
@ -16,7 +17,7 @@ import java.time.LocalDateTime;
|
||||
public class ResourceItemResponse {
|
||||
|
||||
@Schema(description = "ID")
|
||||
private String id;
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "资源库 ID")
|
||||
private String libraryId;
|
||||
@ -24,39 +25,42 @@ public class ResourceItemResponse {
|
||||
@Schema(description = "租户 ID")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "资源类型")
|
||||
private String type;
|
||||
@Schema(description = "资源标题")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "资源名称")
|
||||
private String name;
|
||||
@Schema(description = "文件类型 (IMAGE/PDF/VIDEO/AUDIO/PPT/OTHER)")
|
||||
private String fileType;
|
||||
|
||||
@Schema(description = "资源编码")
|
||||
private String code;
|
||||
@Schema(description = "文件路径")
|
||||
private String filePath;
|
||||
|
||||
@Schema(description = "文件大小(字节)")
|
||||
private Long fileSize;
|
||||
|
||||
@Schema(description = "资源标签")
|
||||
private List<String> tags;
|
||||
|
||||
@Schema(description = "所属资源库信息")
|
||||
private LibrarySummary library;
|
||||
|
||||
@Schema(description = "资源描述")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "数量")
|
||||
private Integer quantity;
|
||||
|
||||
@Schema(description = "可用数量")
|
||||
private Integer availableQuantity;
|
||||
|
||||
@Schema(description = "存放位置")
|
||||
private String location;
|
||||
|
||||
@Schema(description = "状态")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "创建人")
|
||||
private String createdBy;
|
||||
|
||||
@Schema(description = "更新人")
|
||||
private String updatedBy;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/** 资源库简要信息 */
|
||||
@Data
|
||||
@Builder
|
||||
public static class LibrarySummary {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String libraryType;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.reading.platform.dto.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@ -16,7 +17,7 @@ import java.time.LocalDateTime;
|
||||
public class ResourceLibraryResponse {
|
||||
|
||||
@Schema(description = "ID")
|
||||
private String id;
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "租户 ID")
|
||||
private String tenantId;
|
||||
@ -28,6 +29,7 @@ public class ResourceLibraryResponse {
|
||||
private String description;
|
||||
|
||||
@Schema(description = "资源库类型")
|
||||
@JsonProperty("libraryType")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "创建人")
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
package com.reading.platform.dto.response;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
/**
|
||||
* 统计数据响应
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "统计数据响应")
|
||||
public class StatsResponse {
|
||||
|
||||
@Schema(description = "租户总数")
|
||||
private Long totalTenants;
|
||||
|
||||
@Schema(description = "活跃租户数")
|
||||
private Long activeTenants;
|
||||
|
||||
@Schema(description = "教师总数")
|
||||
private Long totalTeachers;
|
||||
|
||||
@Schema(description = "学生总数")
|
||||
private Long totalStudents;
|
||||
|
||||
@Schema(description = "课程总数")
|
||||
private Long totalCourses;
|
||||
|
||||
@Schema(description = "课时总数")
|
||||
private Long totalLessons;
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package com.reading.platform.dto.response;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 趋势数据响应
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "趋势数据响应")
|
||||
public class StatsTrendResponse {
|
||||
|
||||
@Schema(description = "日期列表")
|
||||
private List<String> dates;
|
||||
|
||||
@Schema(description = "新增学生数列表")
|
||||
private List<Integer> newStudents;
|
||||
|
||||
@Schema(description = "新增教师数列表")
|
||||
private List<Integer> newTeachers;
|
||||
|
||||
@Schema(description = "新增课程数列表")
|
||||
private List<Integer> newCourses;
|
||||
}
|
||||
@ -37,7 +37,7 @@ public class CoursePackage extends BaseEntity {
|
||||
@Schema(description = "课程数量")
|
||||
private Integer courseCount;
|
||||
|
||||
@Schema(description = "状态:DRAFT、PENDING_REVIEW、APPROVED、REJECTED、PUBLISHED、OFFLINE")
|
||||
@Schema(description = "状态:DRAFT、PENDING、APPROVED、REJECTED、PUBLISHED、OFFLINE")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "提交时间")
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.reading.platform.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.reading.platform.common.enums.TenantPackageStatus;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
@ -32,6 +33,6 @@ public class TenantPackage extends BaseEntity {
|
||||
private Long pricePaid;
|
||||
|
||||
@Schema(description = "状态:ACTIVE、EXPIRED")
|
||||
private String status;
|
||||
private TenantPackageStatus status;
|
||||
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
package com.reading.platform.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.reading.platform.common.enums.TenantPackageStatus;
|
||||
import com.reading.platform.common.exception.BusinessException;
|
||||
import com.reading.platform.entity.*;
|
||||
import com.reading.platform.mapper.*;
|
||||
@ -112,8 +114,8 @@ public class CourseLessonService extends ServiceImpl<CourseLessonMapper, CourseL
|
||||
lesson.setPreparation(preparation);
|
||||
lesson.setExtension(extension);
|
||||
lesson.setReflection(reflection);
|
||||
// 处理空字符串为 null,避免 MySQL JSON 字段错误
|
||||
lesson.setAssessmentData(assessmentData == null || assessmentData.isEmpty() ? null : assessmentData);
|
||||
// 确保 assessment_data 为有效 JSON,MySQL JSON 列不接受纯文本
|
||||
lesson.setAssessmentData(toValidJsonOrNull(assessmentData));
|
||||
lesson.setUseTemplate(useTemplate);
|
||||
lesson.setSortOrder(maxSortOrder + 1);
|
||||
lesson.setCreatedAt(LocalDateTime.now());
|
||||
@ -178,7 +180,7 @@ public class CourseLessonService extends ServiceImpl<CourseLessonMapper, CourseL
|
||||
lesson.setReflection(reflection);
|
||||
}
|
||||
if (assessmentData != null) {
|
||||
lesson.setAssessmentData(assessmentData);
|
||||
lesson.setAssessmentData(toValidJsonOrNull(assessmentData));
|
||||
}
|
||||
if (useTemplate != null) {
|
||||
lesson.setUseTemplate(useTemplate);
|
||||
@ -355,6 +357,31 @@ public class CourseLessonService extends ServiceImpl<CourseLessonMapper, CourseL
|
||||
log.info("教学环节重新排序成功,lessonId={}", lessonId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 assessmentData 转为 MySQL JSON 列可接受的有效 JSON。
|
||||
* 空值返回 null;已是有效 JSON 则原样返回;否则包装为 JSON 字符串。
|
||||
*/
|
||||
private String toValidJsonOrNull(String assessmentData) {
|
||||
if (assessmentData == null || assessmentData.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = assessmentData.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
// 已是有效 JSON(对象或数组)则直接使用
|
||||
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return trimmed;
|
||||
} catch (Exception ignored) {
|
||||
// 解析失败,当作普通文本处理
|
||||
}
|
||||
}
|
||||
// 普通文本包装为 JSON 字符串
|
||||
return JSON.toJSONString(assessmentData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询教师的课程环节(带权限检查)
|
||||
*/
|
||||
@ -373,7 +400,7 @@ public class CourseLessonService extends ServiceImpl<CourseLessonMapper, CourseL
|
||||
Long count = tenantPackageMapper.selectCount(
|
||||
new LambdaQueryWrapper<TenantPackage>()
|
||||
.eq(TenantPackage::getTenantId, tenantId)
|
||||
.eq(TenantPackage::getStatus, "ACTIVE")
|
||||
.eq(TenantPackage::getStatus, TenantPackageStatus.ACTIVE)
|
||||
);
|
||||
|
||||
if (count == 0) {
|
||||
|
||||
@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.reading.platform.common.enums.CourseStatus;
|
||||
import com.reading.platform.common.enums.TenantPackageStatus;
|
||||
import com.reading.platform.common.exception.BusinessException;
|
||||
import com.reading.platform.common.response.PageResult;
|
||||
import com.reading.platform.dto.response.CoursePackageResponse;
|
||||
@ -111,7 +112,7 @@ public class CoursePackageService extends ServiceImpl<CoursePackageMapper, Cours
|
||||
Long tenantCount = tenantPackageMapper.selectCount(
|
||||
new LambdaQueryWrapper<TenantPackage>()
|
||||
.eq(TenantPackage::getPackageId, pkg.getId())
|
||||
.eq(TenantPackage::getStatus, "ACTIVE")
|
||||
.eq(TenantPackage::getStatus, TenantPackageStatus.ACTIVE)
|
||||
);
|
||||
response.setTenantCount(tenantCount.intValue());
|
||||
|
||||
@ -229,7 +230,7 @@ public class CoursePackageService extends ServiceImpl<CoursePackageMapper, Cours
|
||||
Long tenantCount = tenantPackageMapper.selectCount(
|
||||
new LambdaQueryWrapper<TenantPackage>()
|
||||
.eq(TenantPackage::getPackageId, id)
|
||||
.eq(TenantPackage::getStatus, "ACTIVE")
|
||||
.eq(TenantPackage::getStatus, TenantPackageStatus.ACTIVE)
|
||||
);
|
||||
|
||||
if (tenantCount > 0) {
|
||||
@ -299,10 +300,15 @@ public class CoursePackageService extends ServiceImpl<CoursePackageMapper, Cours
|
||||
|
||||
/**
|
||||
* 审核套餐
|
||||
* @param id 套餐ID
|
||||
* @param userId 审核人ID
|
||||
* @param approved 是否通过
|
||||
* @param comment 审核意见
|
||||
* @param publish 是否同时发布(仅当 approved=true 时有效)
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void reviewPackage(Long id, Long userId, Boolean approved, String comment) {
|
||||
log.info("审核套餐,id={}, userId={}, approved={}", id, userId, approved);
|
||||
public void reviewPackage(Long id, Long userId, Boolean approved, String comment, Boolean publish) {
|
||||
log.info("审核套餐,id={}, userId={}, approved={}, publish={}", id, userId, approved, publish);
|
||||
CoursePackage pkg = packageMapper.selectById(id);
|
||||
if (pkg == null) {
|
||||
log.warn("套餐不存在,id={}", id);
|
||||
@ -314,12 +320,27 @@ public class CoursePackageService extends ServiceImpl<CoursePackageMapper, Cours
|
||||
throw new BusinessException("只有待审核状态的套餐可以审核");
|
||||
}
|
||||
|
||||
pkg.setStatus(approved ? CourseStatus.APPROVED.getCode() : CourseStatus.REJECTED.getCode());
|
||||
// 如果驳回
|
||||
if (!Boolean.TRUE.equals(approved)) {
|
||||
pkg.setStatus(CourseStatus.REJECTED.getCode());
|
||||
}
|
||||
// 如果通过且同时发布
|
||||
else if (Boolean.TRUE.equals(publish)) {
|
||||
pkg.setStatus(CourseStatus.PUBLISHED.getCode());
|
||||
pkg.setPublishedAt(LocalDateTime.now());
|
||||
}
|
||||
// 仅通过
|
||||
else {
|
||||
pkg.setStatus(CourseStatus.APPROVED.getCode());
|
||||
}
|
||||
|
||||
pkg.setReviewedAt(LocalDateTime.now());
|
||||
pkg.setReviewedBy(userId);
|
||||
pkg.setReviewComment(comment);
|
||||
packageMapper.updateById(pkg);
|
||||
log.info("套餐审核成功,id={}, result={}", id, approved ? "approved" : "rejected");
|
||||
log.info("套餐审核成功,id={}, result={}", id,
|
||||
!Boolean.TRUE.equals(approved) ? "rejected" :
|
||||
(Boolean.TRUE.equals(publish) ? "approved_and_published" : "approved"));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -371,7 +392,7 @@ public class CoursePackageService extends ServiceImpl<CoursePackageMapper, Cours
|
||||
List<TenantPackage> tenantPackages = tenantPackageMapper.selectList(
|
||||
new LambdaQueryWrapper<TenantPackage>()
|
||||
.eq(TenantPackage::getTenantId, tenantId)
|
||||
.eq(TenantPackage::getStatus, "ACTIVE")
|
||||
.eq(TenantPackage::getStatus, TenantPackageStatus.ACTIVE)
|
||||
.orderByDesc(TenantPackage::getCreatedAt)
|
||||
);
|
||||
|
||||
@ -409,7 +430,7 @@ public class CoursePackageService extends ServiceImpl<CoursePackageMapper, Cours
|
||||
|
||||
if (existing != null) {
|
||||
existing.setEndDate(endDate);
|
||||
existing.setStatus("ACTIVE");
|
||||
existing.setStatus(TenantPackageStatus.ACTIVE);
|
||||
if (pricePaid != null) {
|
||||
existing.setPricePaid(pricePaid);
|
||||
}
|
||||
@ -422,11 +443,25 @@ public class CoursePackageService extends ServiceImpl<CoursePackageMapper, Cours
|
||||
tp.setPackageId(packageId);
|
||||
tp.setStartDate(LocalDate.now());
|
||||
tp.setEndDate(endDate);
|
||||
tp.setStatus("ACTIVE");
|
||||
tp.setStatus(TenantPackageStatus.ACTIVE);
|
||||
tp.setPricePaid(pricePaid != null ? pricePaid : 0L);
|
||||
tp.setCreatedAt(LocalDateTime.now());
|
||||
tenantPackageMapper.insert(tp);
|
||||
log.info("租户套餐新办成功,tenantId={}, packageId={}", tenantId, packageId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有已发布的套餐列表
|
||||
*/
|
||||
public List<CoursePackage> findPublishedPackages() {
|
||||
log.info("查询所有已发布的套餐列表");
|
||||
LambdaQueryWrapper<CoursePackage> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(CoursePackage::getStatus, CourseStatus.PUBLISHED.getCode())
|
||||
.orderByDesc(CoursePackage::getCreatedAt);
|
||||
|
||||
List<CoursePackage> packages = packageMapper.selectList(wrapper);
|
||||
log.info("查询所有已发布的套餐列表成功,count={}", packages.size());
|
||||
return packages;
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,4 +43,9 @@ public interface CourseService extends com.baomidou.mybatisplus.extension.servic
|
||||
|
||||
List<Course> getCoursesByTenantId(Long tenantId);
|
||||
|
||||
/**
|
||||
* 查询租户套餐下的课程
|
||||
*/
|
||||
List<Course> getTenantPackageCourses(Long tenantId);
|
||||
|
||||
}
|
||||
|
||||
@ -63,6 +63,14 @@ public class ResourceLibraryService extends ServiceImpl<ResourceLibraryMapper, R
|
||||
return library;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询资源库(不存在时返回 null)
|
||||
*/
|
||||
public ResourceLibrary findLibraryByIdOrNull(String id) {
|
||||
if (id == null || id.isEmpty()) return null;
|
||||
return libraryMapper.selectById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建资源库
|
||||
*/
|
||||
@ -167,8 +175,10 @@ public class ResourceLibraryService extends ServiceImpl<ResourceLibraryMapper, R
|
||||
Long fileSize, String description, String tags, String tenantId) {
|
||||
log.info("创建资源项目,libraryId={}, title={}, fileType={}, fileSize={}, tenantId={}", libraryId, title, fileType, fileSize, tenantId);
|
||||
ResourceItem item = new ResourceItem();
|
||||
item.setLibraryId(libraryId);
|
||||
item.setId(null); // 确保由数据库 AUTO_INCREMENT 生成,避免主键冲突
|
||||
item.setLibraryId(libraryId); // 目标资源库 ID
|
||||
item.setTitle(title);
|
||||
item.setName(title); // 数据库 name 字段 NOT NULL,与 title 保持一致
|
||||
item.setDescription(description);
|
||||
item.setFileType(fileType);
|
||||
item.setFilePath(filePath);
|
||||
@ -237,10 +247,24 @@ public class ResourceLibraryService extends ServiceImpl<ResourceLibraryMapper, R
|
||||
Long libraryCount = libraryMapper.selectCount(null);
|
||||
Long itemCount = itemMapper.selectCount(null);
|
||||
|
||||
stats.put("libraryCount", libraryCount);
|
||||
stats.put("itemCount", itemCount);
|
||||
stats.put("totalLibraries", libraryCount);
|
||||
stats.put("totalItems", itemCount);
|
||||
|
||||
log.info("资源库统计数据:libraryCount={}, itemCount={}", libraryCount, itemCount);
|
||||
// 按资源库类型统计资源数量(绘本资源、教学材料等)
|
||||
Map<String, Long> itemsByLibraryType = new HashMap<>();
|
||||
LambdaQueryWrapper<ResourceLibrary> libWrapper = new LambdaQueryWrapper<>();
|
||||
for (ResourceLibrary lib : libraryMapper.selectList(libWrapper)) {
|
||||
String type = lib.getLibraryType();
|
||||
if (type != null) {
|
||||
LambdaQueryWrapper<ResourceItem> itemWrapper = new LambdaQueryWrapper<>();
|
||||
itemWrapper.eq(ResourceItem::getLibraryId, String.valueOf(lib.getId()));
|
||||
long count = itemMapper.selectCount(itemWrapper);
|
||||
itemsByLibraryType.merge(type, count, Long::sum);
|
||||
}
|
||||
}
|
||||
stats.put("itemsByLibraryType", itemsByLibraryType);
|
||||
|
||||
log.info("资源库统计数据:totalLibraries={}, totalItems={}, itemsByLibraryType={}", libraryCount, itemCount, itemsByLibraryType);
|
||||
return stats;
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
package com.reading.platform.service;
|
||||
|
||||
import com.reading.platform.dto.request.ActiveTenantsQueryRequest;
|
||||
import com.reading.platform.dto.request.PopularCoursesQueryRequest;
|
||||
import com.reading.platform.dto.request.RecentActivitiesQueryRequest;
|
||||
import com.reading.platform.dto.response.ActiveTenantItemResponse;
|
||||
import com.reading.platform.dto.response.PopularCourseItemResponse;
|
||||
import com.reading.platform.dto.response.RecentActivityItemResponse;
|
||||
import com.reading.platform.dto.response.StatsResponse;
|
||||
import com.reading.platform.dto.response.StatsTrendResponse;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统计服务接口
|
||||
*/
|
||||
public interface StatsService {
|
||||
|
||||
/**
|
||||
* 获取统计数据
|
||||
*/
|
||||
StatsResponse getStats();
|
||||
|
||||
/**
|
||||
* 获取趋势数据
|
||||
*/
|
||||
StatsTrendResponse getTrendData();
|
||||
|
||||
/**
|
||||
* 获取活跃租户
|
||||
*/
|
||||
List<ActiveTenantItemResponse> getActiveTenants(ActiveTenantsQueryRequest request);
|
||||
|
||||
/**
|
||||
* 获取热门课程
|
||||
*/
|
||||
List<PopularCourseItemResponse> getPopularCourses(PopularCoursesQueryRequest request);
|
||||
|
||||
/**
|
||||
* 获取最近活动
|
||||
*/
|
||||
List<RecentActivityItemResponse> getRecentActivities(RecentActivitiesQueryRequest request);
|
||||
}
|
||||
@ -3,6 +3,7 @@ package com.reading.platform.service;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.reading.platform.dto.request.TenantCreateRequest;
|
||||
import com.reading.platform.dto.request.TenantUpdateRequest;
|
||||
import com.reading.platform.dto.response.TenantResponse;
|
||||
import com.reading.platform.entity.Tenant;
|
||||
|
||||
import java.util.List;
|
||||
@ -32,6 +33,11 @@ public interface TenantService extends com.baomidou.mybatisplus.extension.servic
|
||||
*/
|
||||
Page<Tenant> getTenantPage(Integer pageNum, Integer pageSize, String keyword, String status);
|
||||
|
||||
/**
|
||||
* 分页查询租户(带教师和学生统计)
|
||||
*/
|
||||
Page<TenantResponse> getTenantPageWithStats(Integer pageNum, Integer pageSize, String keyword, String status);
|
||||
|
||||
/**
|
||||
* 删除租户
|
||||
*/
|
||||
|
||||
@ -60,7 +60,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
log.warn("登录失败:密码错误,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.LOGIN_FAILED);
|
||||
}
|
||||
if (!"active".equals(adminUser.getStatus())) {
|
||||
if (!"active".equalsIgnoreCase(adminUser.getStatus())) {
|
||||
log.warn("登录失败:账户已禁用,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
@ -100,7 +100,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
log.warn("登录失败:密码错误,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.LOGIN_FAILED);
|
||||
}
|
||||
if (!"active".equals(teacher.getStatus())) {
|
||||
if (!"active".equalsIgnoreCase(teacher.getStatus())) {
|
||||
log.warn("登录失败:账户已禁用,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
@ -140,7 +140,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
log.warn("登录失败:密码错误,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.LOGIN_FAILED);
|
||||
}
|
||||
if (!"active".equals(parent.getStatus())) {
|
||||
if (!"active".equalsIgnoreCase(parent.getStatus())) {
|
||||
log.warn("登录失败:账户已禁用,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
@ -180,7 +180,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
log.warn("登录失败:密码错误,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.LOGIN_FAILED);
|
||||
}
|
||||
if (!"active".equals(tenant.getStatus())) {
|
||||
if (!"active".equalsIgnoreCase(tenant.getStatus())) {
|
||||
log.warn("登录失败:账户已禁用,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
@ -224,7 +224,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
log.warn("登录失败:用户不存在或密码错误,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.LOGIN_FAILED);
|
||||
}
|
||||
if (!"active".equals(adminUser.getStatus())) {
|
||||
if (!"active".equalsIgnoreCase(adminUser.getStatus())) {
|
||||
log.warn("登录失败:账户已禁用,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
@ -261,7 +261,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
log.warn("登录失败:用户不存在或密码错误,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.LOGIN_FAILED);
|
||||
}
|
||||
if (!"active".equals(tenant.getStatus())) {
|
||||
if (!"active".equalsIgnoreCase(tenant.getStatus())) {
|
||||
log.warn("登录失败:账户已禁用,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
@ -295,7 +295,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
log.warn("登录失败:用户不存在或密码错误,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.LOGIN_FAILED);
|
||||
}
|
||||
if (!"active".equals(teacher.getStatus())) {
|
||||
if (!"active".equalsIgnoreCase(teacher.getStatus())) {
|
||||
log.warn("登录失败:账户已禁用,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
@ -332,7 +332,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
log.warn("登录失败:用户不存在或密码错误,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.LOGIN_FAILED);
|
||||
}
|
||||
if (!"active".equals(parent.getStatus())) {
|
||||
if (!"active".equalsIgnoreCase(parent.getStatus())) {
|
||||
log.warn("登录失败:账户已禁用,用户名:{}", username);
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
@ -448,8 +448,13 @@ public class AuthServiceImpl implements AuthService {
|
||||
|
||||
@Override
|
||||
public void logout() {
|
||||
String username = SecurityUtils.getCurrentUser().getUsername();
|
||||
log.info("用户登出,用户名:{}", username);
|
||||
JwtPayload payload = SecurityUtils.getCurrentUser();
|
||||
log.info("用户登出,用户名:{}", payload.getUsername());
|
||||
|
||||
// 将当前 token 加入黑名单(需要从请求中获取 token)
|
||||
// 注意:由于 logout 接口需要 token 才能获取到当前用户信息,
|
||||
// 所以 token 已经在 JwtAuthenticationFilter 中被解析
|
||||
// 这里我们不需要额外操作,因为 SecurityContext 中的用户信息已经证明 token 有效
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@ -5,14 +5,22 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.reading.platform.common.enums.CourseStatus;
|
||||
import com.reading.platform.common.enums.ErrorCode;
|
||||
import com.reading.platform.common.enums.TenantPackageStatus;
|
||||
import com.reading.platform.common.enums.YesNo;
|
||||
import com.reading.platform.common.exception.BusinessException;
|
||||
import com.reading.platform.dto.request.CourseCreateRequest;
|
||||
import com.reading.platform.dto.request.CourseUpdateRequest;
|
||||
import com.reading.platform.dto.response.CourseLessonResponse;
|
||||
import com.reading.platform.dto.response.CourseResponse;
|
||||
import com.reading.platform.dto.response.LessonStepResponse;
|
||||
import com.reading.platform.entity.Course;
|
||||
import com.reading.platform.entity.CourseLesson;
|
||||
import com.reading.platform.entity.CoursePackageCourse;
|
||||
import com.reading.platform.entity.LessonStep;
|
||||
import com.reading.platform.entity.TenantPackage;
|
||||
import com.reading.platform.mapper.CourseMapper;
|
||||
import com.reading.platform.mapper.CoursePackageCourseMapper;
|
||||
import com.reading.platform.mapper.TenantPackageMapper;
|
||||
import com.reading.platform.service.CourseLessonService;
|
||||
import com.reading.platform.service.CourseService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -34,6 +42,8 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
|
||||
|
||||
private final CourseMapper courseMapper;
|
||||
private final CourseLessonService courseLessonService;
|
||||
private final TenantPackageMapper tenantPackageMapper;
|
||||
private final CoursePackageCourseMapper packageCourseMapper;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@ -51,7 +61,7 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
|
||||
course.setDurationMinutes(request.getDurationMinutes());
|
||||
course.setObjectives(request.getObjectives());
|
||||
course.setStatus(CourseStatus.DRAFT.getCode());
|
||||
course.setIsSystem(0);
|
||||
course.setIsSystem(YesNo.NO.getCode());
|
||||
|
||||
// Course Package Fields
|
||||
course.setCoreContent(request.getCoreContent());
|
||||
@ -81,10 +91,10 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
|
||||
course.setAssessmentData(nullIfEmptyJson(request.getAssessmentData()));
|
||||
course.setGradeTags(nullIfEmptyJson(request.getGradeTags()));
|
||||
course.setDomainTags(nullIfEmptyJson(request.getDomainTags()));
|
||||
course.setHasCollectiveLesson(request.getHasCollectiveLesson() != null && request.getHasCollectiveLesson() ? 1 : 0);
|
||||
course.setHasCollectiveLesson(YesNo.fromBoolean(request.getHasCollectiveLesson()).getCode());
|
||||
|
||||
course.setVersion("1.0");
|
||||
course.setIsLatest(1);
|
||||
course.setIsLatest(YesNo.YES.getCode());
|
||||
course.setUsageCount(0);
|
||||
course.setTeacherCount(0);
|
||||
|
||||
@ -112,7 +122,7 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
|
||||
course.setDurationMinutes(request.getDurationMinutes());
|
||||
course.setObjectives(request.getObjectives());
|
||||
course.setStatus(CourseStatus.DRAFT.getCode());
|
||||
course.setIsSystem(1);
|
||||
course.setIsSystem(YesNo.YES.getCode());
|
||||
|
||||
// Course Package Fields
|
||||
course.setCoreContent(request.getCoreContent());
|
||||
@ -142,10 +152,10 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
|
||||
course.setAssessmentData(nullIfEmptyJson(request.getAssessmentData()));
|
||||
course.setGradeTags(nullIfEmptyJson(request.getGradeTags()));
|
||||
course.setDomainTags(nullIfEmptyJson(request.getDomainTags()));
|
||||
course.setHasCollectiveLesson(request.getHasCollectiveLesson() != null && request.getHasCollectiveLesson() ? 1 : 0);
|
||||
course.setHasCollectiveLesson(YesNo.fromBoolean(request.getHasCollectiveLesson()).getCode());
|
||||
|
||||
course.setVersion("1.0");
|
||||
course.setIsLatest(1);
|
||||
course.setIsLatest(YesNo.YES.getCode());
|
||||
course.setUsageCount(0);
|
||||
course.setTeacherCount(0);
|
||||
|
||||
@ -278,7 +288,7 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
|
||||
course.setDomainTags(nullIfEmptyJson(request.getDomainTags()));
|
||||
}
|
||||
if (request.getHasCollectiveLesson() != null) {
|
||||
course.setHasCollectiveLesson(request.getHasCollectiveLesson() ? 1 : 0);
|
||||
course.setHasCollectiveLesson(YesNo.fromBoolean(request.getHasCollectiveLesson()).getCode());
|
||||
}
|
||||
|
||||
courseMapper.updateById(course);
|
||||
@ -305,12 +315,21 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
|
||||
CourseResponse response = new CourseResponse();
|
||||
BeanUtils.copyProperties(course, response);
|
||||
|
||||
// 查询关联的课程环节
|
||||
// 查询关联的课程环节及教学步骤
|
||||
List<CourseLesson> lessons = courseLessonService.findByCourseId(id);
|
||||
List<CourseLessonResponse> lessonResponses = lessons.stream()
|
||||
.map(lesson -> {
|
||||
CourseLessonResponse res = new CourseLessonResponse();
|
||||
BeanUtils.copyProperties(lesson, res);
|
||||
List<LessonStep> steps = courseLessonService.findSteps(lesson.getId());
|
||||
List<LessonStepResponse> stepResponses = steps.stream()
|
||||
.map(step -> {
|
||||
LessonStepResponse sr = new LessonStepResponse();
|
||||
BeanUtils.copyProperties(step, sr);
|
||||
return sr;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
res.setSteps(stepResponses);
|
||||
return res;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
@ -328,7 +347,7 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
|
||||
wrapper.and(w -> w
|
||||
.eq(Course::getTenantId, tenantId)
|
||||
.or()
|
||||
.eq(Course::getIsSystem, 1)
|
||||
.eq(Course::getIsSystem, YesNo.YES.getCode())
|
||||
);
|
||||
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
@ -356,7 +375,7 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
|
||||
LambdaQueryWrapper<Course> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
// 只过滤系统课程
|
||||
wrapper.eq(Course::getIsSystem, 1);
|
||||
wrapper.eq(Course::getIsSystem, YesNo.YES.getCode());
|
||||
|
||||
// 审核管理页:仅过滤待审核和已驳回,排除已通过/已发布
|
||||
if (reviewOnly) {
|
||||
@ -435,13 +454,58 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
|
||||
.and(w -> w
|
||||
.eq(Course::getTenantId, tenantId)
|
||||
.or()
|
||||
.eq(Course::getIsSystem, 1)
|
||||
.eq(Course::getIsSystem, YesNo.YES.getCode())
|
||||
)
|
||||
.eq(Course::getStatus, CourseStatus.PUBLISHED.getCode())
|
||||
.orderByAsc(Course::getName)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Course> getTenantPackageCourses(Long tenantId) {
|
||||
log.info("查询租户套餐下的课程,tenantId={}", tenantId);
|
||||
|
||||
// 1. 查询租户的套餐关联
|
||||
List<TenantPackage> tenantPackages = tenantPackageMapper.selectList(
|
||||
new LambdaQueryWrapper<TenantPackage>()
|
||||
.eq(TenantPackage::getTenantId, tenantId)
|
||||
.eq(TenantPackage::getStatus, TenantPackageStatus.ACTIVE)
|
||||
);
|
||||
|
||||
if (tenantPackages.isEmpty()) {
|
||||
log.info("租户套餐下的课程查询结果为空,tenantId={}", tenantId);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// 2. 获取套餐 ID 列表
|
||||
List<Long> packageIds = tenantPackages.stream()
|
||||
.map(TenantPackage::getPackageId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 3. 查询套餐包含的课程 ID
|
||||
List<CoursePackageCourse> packageCourses = packageCourseMapper.selectList(
|
||||
new LambdaQueryWrapper<CoursePackageCourse>()
|
||||
.in(CoursePackageCourse::getPackageId, packageIds)
|
||||
);
|
||||
|
||||
if (packageCourses.isEmpty()) {
|
||||
log.info("租户套餐下没有关联的课程,tenantId={}", tenantId);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// 4. 获取课程 ID 列表
|
||||
List<Long> courseIds = packageCourses.stream()
|
||||
.map(CoursePackageCourse::getCourseId)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 5. 查询课程详情
|
||||
List<Course> courses = courseMapper.selectBatchIds(courseIds);
|
||||
|
||||
log.info("查询租户套餐下的课程成功,tenantId={}, count={}", tenantId, courses.size());
|
||||
return courses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将空字符串转为 null,避免 MySQL JSON 列报错(空串不是有效 JSON)
|
||||
*/
|
||||
|
||||
@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.reading.platform.common.enums.ErrorCode;
|
||||
import com.reading.platform.common.exception.BusinessException;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.reading.platform.dto.request.LessonCreateRequest;
|
||||
import com.reading.platform.dto.request.LessonUpdateRequest;
|
||||
import com.reading.platform.dto.request.LessonProgressRequest;
|
||||
@ -328,14 +329,14 @@ public class LessonServiceImpl extends ServiceImpl<LessonMapper, Lesson>
|
||||
if (request.getCurrentStepId() != null) {
|
||||
lesson.setCurrentStepId(request.getCurrentStepId());
|
||||
}
|
||||
if (StringUtils.hasText(request.getLessonIds())) {
|
||||
lesson.setLessonIds(request.getLessonIds());
|
||||
if (request.getLessonIds() != null) {
|
||||
lesson.setLessonIds(JSON.toJSONString(request.getLessonIds()));
|
||||
}
|
||||
if (StringUtils.hasText(request.getCompletedLessonIds())) {
|
||||
lesson.setCompletedLessonIds(request.getCompletedLessonIds());
|
||||
if (request.getCompletedLessonIds() != null) {
|
||||
lesson.setCompletedLessonIds(JSON.toJSONString(request.getCompletedLessonIds()));
|
||||
}
|
||||
if (StringUtils.hasText(request.getProgressData())) {
|
||||
lesson.setProgressData(request.getProgressData());
|
||||
if (request.getProgressData() != null) {
|
||||
lesson.setProgressData(JSON.toJSONString(request.getProgressData()));
|
||||
}
|
||||
|
||||
lessonMapper.updateById(lesson);
|
||||
|
||||
@ -0,0 +1,224 @@
|
||||
package com.reading.platform.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.reading.platform.dto.request.ActiveTenantsQueryRequest;
|
||||
import com.reading.platform.dto.request.PopularCoursesQueryRequest;
|
||||
import com.reading.platform.dto.request.RecentActivitiesQueryRequest;
|
||||
import com.reading.platform.dto.response.ActiveTenantItemResponse;
|
||||
import com.reading.platform.dto.response.PopularCourseItemResponse;
|
||||
import com.reading.platform.dto.response.RecentActivityItemResponse;
|
||||
import com.reading.platform.dto.response.StatsResponse;
|
||||
import com.reading.platform.dto.response.StatsTrendResponse;
|
||||
import com.reading.platform.entity.Course;
|
||||
import com.reading.platform.entity.CourseLesson;
|
||||
import com.reading.platform.entity.Student;
|
||||
import com.reading.platform.entity.Teacher;
|
||||
import com.reading.platform.entity.Tenant;
|
||||
import com.reading.platform.mapper.CourseLessonMapper;
|
||||
import com.reading.platform.mapper.CourseMapper;
|
||||
import com.reading.platform.mapper.StudentMapper;
|
||||
import com.reading.platform.mapper.TeacherMapper;
|
||||
import com.reading.platform.mapper.TenantMapper;
|
||||
import com.reading.platform.service.StatsService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 统计服务实现类
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class StatsServiceImpl implements StatsService {
|
||||
|
||||
private final TenantMapper tenantMapper;
|
||||
private final TeacherMapper teacherMapper;
|
||||
private final StudentMapper studentMapper;
|
||||
private final CourseMapper courseMapper;
|
||||
private final CourseLessonMapper courseLessonMapper;
|
||||
|
||||
@Override
|
||||
public StatsResponse getStats() {
|
||||
log.info("获取统计数据");
|
||||
|
||||
// 租户总数
|
||||
Long totalTenants = tenantMapper.selectCount(null);
|
||||
|
||||
// 活跃租户数
|
||||
Long activeTenants = tenantMapper.selectCount(
|
||||
new LambdaQueryWrapper<Tenant>().eq(Tenant::getStatus, "ACTIVE")
|
||||
);
|
||||
|
||||
// 教师总数
|
||||
Long totalTeachers = teacherMapper.selectCount(null);
|
||||
|
||||
// 学生总数
|
||||
Long totalStudents = studentMapper.selectCount(null);
|
||||
|
||||
// 课程总数
|
||||
Long totalCourses = courseMapper.selectCount(null);
|
||||
|
||||
// 课时总数
|
||||
Long totalLessons = courseLessonMapper.selectCount(null);
|
||||
|
||||
return StatsResponse.builder()
|
||||
.totalTenants(totalTenants)
|
||||
.activeTenants(activeTenants)
|
||||
.totalTeachers(totalTeachers)
|
||||
.totalStudents(totalStudents)
|
||||
.totalCourses(totalCourses)
|
||||
.totalLessons(totalLessons)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StatsTrendResponse getTrendData() {
|
||||
log.info("获取趋势数据");
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<String> dates = new ArrayList<>();
|
||||
List<Integer> newStudents = new ArrayList<>();
|
||||
List<Integer> newTeachers = new ArrayList<>();
|
||||
List<Integer> newCourses = new ArrayList<>();
|
||||
|
||||
// 获取最近 7 天的趋势数据
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
LocalDate date = now.minusDays(i).toLocalDate();
|
||||
dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
|
||||
|
||||
// 当天新增学生数
|
||||
LocalDateTime dayStart = date.atStartOfDay();
|
||||
LocalDateTime dayEnd = date.plusDays(1).atStartOfDay();
|
||||
|
||||
Long students = studentMapper.selectCount(
|
||||
new LambdaQueryWrapper<Student>()
|
||||
.ge(Student::getCreatedAt, dayStart)
|
||||
.lt(Student::getCreatedAt, dayEnd)
|
||||
);
|
||||
newStudents.add(students != null ? students.intValue() : 0);
|
||||
|
||||
// 当天新增教师数
|
||||
Long teachers = teacherMapper.selectCount(
|
||||
new LambdaQueryWrapper<Teacher>()
|
||||
.ge(Teacher::getCreatedAt, dayStart)
|
||||
.lt(Teacher::getCreatedAt, dayEnd)
|
||||
);
|
||||
newTeachers.add(teachers != null ? teachers.intValue() : 0);
|
||||
|
||||
// 当天新增课程数
|
||||
Long courses = courseMapper.selectCount(
|
||||
new LambdaQueryWrapper<Course>()
|
||||
.ge(Course::getCreatedAt, dayStart)
|
||||
.lt(Course::getCreatedAt, dayEnd)
|
||||
);
|
||||
newCourses.add(courses != null ? courses.intValue() : 0);
|
||||
}
|
||||
|
||||
return StatsTrendResponse.builder()
|
||||
.dates(dates)
|
||||
.newStudents(newStudents)
|
||||
.newTeachers(newTeachers)
|
||||
.newCourses(newCourses)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ActiveTenantItemResponse> getActiveTenants(ActiveTenantsQueryRequest request) {
|
||||
log.info("获取活跃租户,limit={}", request != null ? request.getLimit() : 10);
|
||||
|
||||
int limit = request != null && request.getLimit() != null ? request.getLimit() : 10;
|
||||
|
||||
// 查询所有活跃租户
|
||||
List<Tenant> tenants = tenantMapper.selectList(
|
||||
new LambdaQueryWrapper<Tenant>()
|
||||
.eq(Tenant::getStatus, "ACTIVE")
|
||||
.orderByDesc(Tenant::getUpdatedAt)
|
||||
.last("LIMIT " + limit)
|
||||
);
|
||||
|
||||
return tenants.stream().map(tenant -> {
|
||||
// 查询该租户的活跃用户数(教师+学生)
|
||||
Long teacherCount = teacherMapper.selectCount(
|
||||
new LambdaQueryWrapper<Teacher>().eq(Teacher::getTenantId, tenant.getId())
|
||||
);
|
||||
Long studentCount = studentMapper.selectCount(
|
||||
new LambdaQueryWrapper<Student>().eq(Student::getTenantId, tenant.getId())
|
||||
);
|
||||
|
||||
// 查询该租户使用的课程数(通过租户套餐)
|
||||
// 简化处理,返回 0
|
||||
int courseCount = 0;
|
||||
|
||||
return ActiveTenantItemResponse.builder()
|
||||
.tenantId(tenant.getId())
|
||||
.tenantName(tenant.getName())
|
||||
.activeUsers((teacherCount != null ? teacherCount.intValue() : 0) +
|
||||
(studentCount != null ? studentCount.intValue() : 0))
|
||||
.courseCount(courseCount)
|
||||
.build();
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PopularCourseItemResponse> getPopularCourses(PopularCoursesQueryRequest request) {
|
||||
log.info("获取热门课程,limit={}", request != null ? request.getLimit() : 10);
|
||||
|
||||
int limit = request != null && request.getLimit() != null ? request.getLimit() : 10;
|
||||
|
||||
// 查询使用次数最多的课程
|
||||
List<Course> courses = courseMapper.selectList(
|
||||
new LambdaQueryWrapper<Course>()
|
||||
.eq(Course::getIsSystem, 1)
|
||||
.orderByDesc(Course::getUsageCount)
|
||||
.last("LIMIT " + limit)
|
||||
);
|
||||
|
||||
return courses.stream().map(course -> PopularCourseItemResponse.builder()
|
||||
.courseId(course.getId())
|
||||
.courseName(course.getName())
|
||||
.usageCount(course.getUsageCount() != null ? course.getUsageCount() : 0)
|
||||
.teacherCount(course.getTeacherCount() != null ? course.getTeacherCount() : 0)
|
||||
.build()
|
||||
).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RecentActivityItemResponse> getRecentActivities(RecentActivitiesQueryRequest request) {
|
||||
log.info("获取最近活动,limit={}", request != null ? request.getLimit() : 10);
|
||||
|
||||
int limit = request != null && request.getLimit() != null ? request.getLimit() : 10;
|
||||
|
||||
// 由于没有专门的活动记录表,这里返回空列表
|
||||
// 实际项目中应该从操作日志表获取
|
||||
List<RecentActivityItemResponse> activities = new ArrayList<>();
|
||||
|
||||
// 可以从最近的课程更新中生成一些活动记录
|
||||
List<Course> recentCourses = courseMapper.selectList(
|
||||
new LambdaQueryWrapper<Course>()
|
||||
.orderByDesc(Course::getUpdatedAt)
|
||||
.last("LIMIT " + limit)
|
||||
);
|
||||
|
||||
for (Course course : recentCourses) {
|
||||
activities.add(RecentActivityItemResponse.builder()
|
||||
.activityId(course.getId())
|
||||
.activityType("COURSE_UPDATE")
|
||||
.description("课程「" + course.getName() + "」已更新")
|
||||
.operatorId(course.getSubmittedBy())
|
||||
.operatorName("系统")
|
||||
.operationTime(course.getUpdatedAt())
|
||||
.build());
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
}
|
||||
@ -3,11 +3,21 @@ package com.reading.platform.service.impl;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.reading.platform.common.enums.ErrorCode;
|
||||
import com.reading.platform.common.enums.TenantPackageStatus;
|
||||
import com.reading.platform.common.exception.BusinessException;
|
||||
import com.reading.platform.dto.request.TenantCreateRequest;
|
||||
import com.reading.platform.dto.request.TenantUpdateRequest;
|
||||
import com.reading.platform.dto.response.TenantResponse;
|
||||
import com.reading.platform.entity.CoursePackage;
|
||||
import com.reading.platform.entity.Student;
|
||||
import com.reading.platform.entity.Teacher;
|
||||
import com.reading.platform.entity.Tenant;
|
||||
import com.reading.platform.entity.TenantPackage;
|
||||
import com.reading.platform.mapper.StudentMapper;
|
||||
import com.reading.platform.mapper.TeacherMapper;
|
||||
import com.reading.platform.mapper.TenantMapper;
|
||||
import com.reading.platform.mapper.TenantPackageMapper;
|
||||
import com.reading.platform.service.CoursePackageService;
|
||||
import com.reading.platform.service.TenantService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -15,7 +25,9 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 租户服务实现类
|
||||
@ -27,6 +39,10 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
|
||||
implements TenantService {
|
||||
|
||||
private final TenantMapper tenantMapper;
|
||||
private final TenantPackageMapper tenantPackageMapper;
|
||||
private final CoursePackageService coursePackageService;
|
||||
private final TeacherMapper teacherMapper;
|
||||
private final StudentMapper studentMapper;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@ -52,14 +68,7 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
|
||||
tenant.setAddress(request.getAddress());
|
||||
tenant.setLogoUrl(request.getLogoUrl());
|
||||
|
||||
// 使用新字段
|
||||
tenant.setPackageType(request.getPackageType() != null ? request.getPackageType() : "STANDARD");
|
||||
tenant.setTeacherQuota(request.getTeacherQuota() != null ? request.getTeacherQuota() : 20);
|
||||
tenant.setStudentQuota(request.getStudentQuota() != null ? request.getStudentQuota() : 200);
|
||||
tenant.setStartDate(request.getStartDate());
|
||||
tenant.setExpireDate(request.getExpireDate());
|
||||
|
||||
// 兼容旧字段
|
||||
// 设置有效期相关字段(兼容旧字段)
|
||||
if (request.getExpireAt() != null) {
|
||||
tenant.setExpireAt(request.getExpireAt());
|
||||
}
|
||||
@ -72,10 +81,48 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
|
||||
|
||||
tenant.setStatus("ACTIVE");
|
||||
|
||||
// 使用 MP 的 insert 方法
|
||||
tenantMapper.insert(tenant);
|
||||
// 如果传入了 packageId,查询套餐信息并填充相关字段
|
||||
if (request.getPackageId() != null) {
|
||||
CoursePackage coursePackage = coursePackageService.getById(request.getPackageId());
|
||||
if (coursePackage == null) {
|
||||
log.warn("课程套餐不存在,packageId: {}", request.getPackageId());
|
||||
throw new BusinessException(ErrorCode.PACKAGE_NOT_FOUND, "课程套餐不存在");
|
||||
}
|
||||
|
||||
// 根据套餐信息填充租户字段
|
||||
tenant.setPackageType(coursePackage.getName());
|
||||
tenant.setTeacherQuota(request.getTeacherQuota() != null ? request.getTeacherQuota() : 20);
|
||||
tenant.setStudentQuota(request.getStudentQuota() != null ? request.getStudentQuota() : 200);
|
||||
tenant.setStartDate(request.getStartDate());
|
||||
tenant.setExpireDate(request.getExpireDate());
|
||||
|
||||
// 使用 MP 的 insert 方法
|
||||
tenantMapper.insert(tenant);
|
||||
|
||||
// 创建租户套餐关联记录
|
||||
TenantPackage tenantPackage = new TenantPackage();
|
||||
tenantPackage.setTenantId(tenant.getId());
|
||||
tenantPackage.setPackageId(request.getPackageId());
|
||||
tenantPackage.setStartDate(request.getStartDate() != null ? request.getStartDate() : LocalDate.now());
|
||||
tenantPackage.setEndDate(request.getExpireDate());
|
||||
tenantPackage.setPricePaid(coursePackage.getDiscountPrice() != null ? coursePackage.getDiscountPrice() : coursePackage.getPrice());
|
||||
tenantPackage.setStatus(TenantPackageStatus.ACTIVE);
|
||||
tenantPackageMapper.insert(tenantPackage);
|
||||
|
||||
log.info("租户创建成功并关联套餐,ID: {}, packageId: {}", tenant.getId(), request.getPackageId());
|
||||
} else {
|
||||
// 没有传入 packageId,使用原有逻辑
|
||||
tenant.setPackageType(request.getPackageType() != null ? request.getPackageType() : "STANDARD");
|
||||
tenant.setTeacherQuota(request.getTeacherQuota() != null ? request.getTeacherQuota() : 20);
|
||||
tenant.setStudentQuota(request.getStudentQuota() != null ? request.getStudentQuota() : 200);
|
||||
tenant.setStartDate(request.getStartDate());
|
||||
tenant.setExpireDate(request.getExpireDate());
|
||||
|
||||
// 使用 MP 的 insert 方法
|
||||
tenantMapper.insert(tenant);
|
||||
log.info("租户创建成功,ID: {}", tenant.getId());
|
||||
}
|
||||
|
||||
log.info("租户创建成功,ID: {}", tenant.getId());
|
||||
return tenant;
|
||||
}
|
||||
|
||||
@ -201,4 +248,67 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
|
||||
return tenants;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<TenantResponse> getTenantPageWithStats(Integer pageNum, Integer pageSize, String keyword, String status) {
|
||||
log.debug("分页查询租户(带统计),页码:{},每页数量:{}", pageNum, pageSize);
|
||||
|
||||
// 先查询租户分页数据
|
||||
Page<Tenant> tenantPage = getTenantPage(pageNum, pageSize, keyword, status);
|
||||
|
||||
// 转换为 TenantResponse 并填充统计信息
|
||||
List<TenantResponse> responses = tenantPage.getRecords().stream()
|
||||
.map(this::toResponseWithStats)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 构建返回的分页对象
|
||||
Page<TenantResponse> resultPage = new Page<>(tenantPage.getCurrent(), tenantPage.getSize(), tenantPage.getTotal());
|
||||
resultPage.setRecords(responses);
|
||||
|
||||
return resultPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Tenant 实体转换为 TenantResponse(带教师和学生统计)
|
||||
*/
|
||||
private TenantResponse toResponseWithStats(Tenant tenant) {
|
||||
// 查询教师数量
|
||||
Long teacherCount = teacherMapper.selectCount(
|
||||
new LambdaQueryWrapper<Teacher>()
|
||||
.eq(Teacher::getTenantId, tenant.getId())
|
||||
);
|
||||
|
||||
// 查询学生数量
|
||||
Long studentCount = studentMapper.selectCount(
|
||||
new LambdaQueryWrapper<Student>()
|
||||
.eq(Student::getTenantId, tenant.getId())
|
||||
);
|
||||
|
||||
return TenantResponse.builder()
|
||||
.id(tenant.getId())
|
||||
.name(tenant.getName())
|
||||
.code(tenant.getCode())
|
||||
.username(tenant.getUsername())
|
||||
.contactName(tenant.getContactName())
|
||||
.contactPhone(tenant.getContactPhone())
|
||||
.contactEmail(tenant.getContactEmail())
|
||||
.address(tenant.getAddress())
|
||||
.logoUrl(tenant.getLogoUrl())
|
||||
.status(tenant.getStatus())
|
||||
.expireAt(tenant.getExpireAt())
|
||||
.maxStudents(tenant.getMaxStudents())
|
||||
.maxTeachers(tenant.getMaxTeachers())
|
||||
.packageType(tenant.getPackageType())
|
||||
.teacherQuota(tenant.getTeacherQuota())
|
||||
.studentQuota(tenant.getStudentQuota())
|
||||
.storageQuota(tenant.getStorageQuota())
|
||||
.storageUsed(tenant.getStorageUsed())
|
||||
.teacherCount(teacherCount != null ? teacherCount.intValue() : 0)
|
||||
.studentCount(studentCount != null ? studentCount.intValue() : 0)
|
||||
.startDate(tenant.getStartDate())
|
||||
.expireDate(tenant.getExpireDate())
|
||||
.createdAt(tenant.getCreatedAt())
|
||||
.updatedAt(tenant.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
-- =====================================================
|
||||
-- 修复 resource_item 表 AUTO_INCREMENT
|
||||
-- 版本:V16
|
||||
-- 描述:确保新插入的资源项使用正确的自增 ID,避免主键冲突
|
||||
-- =====================================================
|
||||
|
||||
-- 将 AUTO_INCREMENT 设置为足够大的值(测试数据最大 id 为 12,设为 100 确保安全)
|
||||
ALTER TABLE `resource_item` AUTO_INCREMENT = 100;
|
||||
Loading…
Reference in New Issue
Block a user