Merge remote-tracking branch 'origin/master'

This commit is contained in:
Claude Opus 4.6 2026-03-17 16:56:41 +08:00
commit d2a9dbd0f8
89 changed files with 3213 additions and 786 deletions

View File

@ -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 测试,覆盖所有页面的新增、修改、查看功能:**

View File

@ -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")

View File

@ -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
View 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 验证流程
- 账户状态检查
### 兼容性
- 此修改不影响现有功能
- 登出、黑名单等功能仍然正常工作
- 前端无需修改

View File

@ -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('只有待审核状态的套餐可以审核');
}

View File

@ -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>

View File

@ -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');
// ==================== 系统设置 ====================

View File

@ -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;

View File

@ -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;

View File

@ -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);
}

View File

@ -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);
// ==================== 统计数据 ====================

View File

@ -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}`);
// ==================== 班级教师管理 ====================

View File

@ -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 精度丢失)

View File

@ -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']

View File

@ -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':

View 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>

View 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;
});
}

View File

@ -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="暂无数据" />

View File

@ -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: '已发布',

View File

@ -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;

View File

@ -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();

View File

@ -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;
}
}
}
}

View File

@ -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>

View File

@ -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);
})

View 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>

View File

@ -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/typefilePath
*/
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

View File

@ -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,
},
],
};

View File

@ -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),

View File

@ -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 = () => {

View File

@ -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(() => {

View File

@ -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>

View File

@ -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;
}
};

View File

@ -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 {

View File

@ -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 {

View File

@ -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'

View File

@ -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"),

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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
* tagslibrary 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);
}

View File

@ -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;
};
}
}

View File

@ -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;
}
/**

View File

@ -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();

View File

@ -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();
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}
}

View File

@ -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));
}
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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()));
}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,15 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 活跃租户查询请求
*/
@Data
@Schema(description = "活跃租户查询请求")
public class ActiveTenantsQueryRequest {
@Schema(description = "返回数量限制", example = "5")
private Integer limit = 5;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,15 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 热门课程查询请求
*/
@Data
@Schema(description = "热门课程查询请求")
public class PopularCoursesQueryRequest {
@Schema(description = "返回数量限制", example = "5")
private Integer limit = 5;
}

View File

@ -0,0 +1,15 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 最近活动查询请求
*/
@Data
@Schema(description = "最近活动查询请求")
public class RecentActivitiesQueryRequest {
@Schema(description = "返回数量限制", example = "10")
private Integer limit = 10;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -49,6 +49,9 @@ public class TenantCreateRequest {
@Schema(description = "结束日期")
private LocalDate expireDate;
@Schema(description = "课程套餐 ID可选")
private Long packageId;
@Schema(description = "过期时间(兼容旧字段)")
@Deprecated
private LocalDateTime expireAt;

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

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

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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 = "创建人")

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 = "提交时间")

View File

@ -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;
}

View File

@ -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 为有效 JSONMySQL 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) {

View File

@ -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;
}
}

View File

@ -43,4 +43,9 @@ public interface CourseService extends com.baomidou.mybatisplus.extension.servic
List<Course> getCoursesByTenantId(Long tenantId);
/**
* 查询租户套餐下的课程
*/
List<Course> getTenantPackageCourses(Long tenantId);
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
/**
* 删除租户
*/

View File

@ -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

View File

@ -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
*/

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,8 @@
-- =====================================================
-- 修复 resource_item 表 AUTO_INCREMENT
-- 版本V16
-- 描述:确保新插入的资源项使用正确的自增 ID避免主键冲突
-- =====================================================
-- 将 AUTO_INCREMENT 设置为足够大的值(测试数据最大 id 为 12设为 100 确保安全)
ALTER TABLE `resource_item` AUTO_INCREMENT = 100;