diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3c96db4..9d605ef 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 测试,覆盖所有页面的新增、修改、查看功能:** diff --git a/docs/design/23-校本课程包功能完善设计.md b/docs/design/23-校本课程包功能完善设计.md index f249154..cf9f667 100644 --- a/docs/design/23-校本课程包功能完善设计.md +++ b/docs/design/23-校本课程包功能完善设计.md @@ -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") diff --git a/docs/dev-logs/2026-03-15.md b/docs/dev-logs/2026-03-15.md index ae9a263..bdd898c 100644 --- a/docs/dev-logs/2026-03-15.md +++ b/docs/dev-logs/2026-03-15.md @@ -82,7 +82,7 @@ PUT /api/v1/admin/packages/{id}/courses POST /api/v1/admin/packages/{id}/submit ``` -✅ 提交成功,状态变为 `PENDING_REVIEW` +✅ 提交成功,状态变为 `PENDING` #### 4. 审核通过 diff --git a/docs/dev-logs/2026-03-17.md b/docs/dev-logs/2026-03-17.md new file mode 100644 index 0000000..adad915 --- /dev/null +++ b/docs/dev-logs/2026-03-17.md @@ -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 验证流程 +- 账户状态检查 + +### 兼容性 + +- 此修改不影响现有功能 +- 登出、黑名单等功能仍然正常工作 +- 前端无需修改 diff --git a/reading-platform-backend/src/modules/course-package/course-package.service.ts b/reading-platform-backend/src/modules/course-package/course-package.service.ts index a6385b0..8ddcbb8 100644 --- a/reading-platform-backend/src/modules/course-package/course-package.service.ts +++ b/reading-platform-backend/src/modules/course-package/course-package.service.ts @@ -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('只有待审核状态的套餐可以审核'); } diff --git a/reading-platform-frontend/index.html b/reading-platform-frontend/index.html index 23e8c02..dcb49f5 100644 --- a/reading-platform-frontend/index.html +++ b/reading-platform-frontend/index.html @@ -9,6 +9,10 @@ 幼儿阅读教学服务平台 + + diff --git a/reading-platform-frontend/src/api/admin.ts b/reading-platform-frontend/src/api/admin.ts index 3e53880..35341c6 100644 --- a/reading-platform-frontend/src/api/admin.ts +++ b/reading-platform-frontend/src/api/admin.ts @@ -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('/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('/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('/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('/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('/v1/admin/stats'); + return mapStatsData(data); +}; + +export const getTrendData = async () => { + const data = await http.get('/v1/admin/stats/trend'); + return mapTrendData(data); +}; + +export const getActiveTenants = async (limit?: number) => { + const data = await http.get('/v1/admin/stats/tenants/active', { params: { limit } }); + return mapActiveTenants(data); +}; + +export const getPopularCourses = async (limit?: number) => { + const data = await http.get('/v1/admin/stats/courses/popular', { params: { limit } }); + return mapPopularCourses(data); +}; + +// ==================== 课程套餐 ==================== + +export const getPublishedPackages = () => + http.get('/v1/admin/packages/all'); // ==================== 系统设置 ==================== diff --git a/reading-platform-frontend/src/api/file.ts b/reading-platform-frontend/src/api/file.ts index 4856642..3aab427 100644 --- a/reading-platform-frontend/src/api/file.ts +++ b/reading-platform-frontend/src/api/file.ts @@ -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; diff --git a/reading-platform-frontend/src/api/generated/model/coursePackage.ts b/reading-platform-frontend/src/api/generated/model/coursePackage.ts index b02502f..8252a67 100644 --- a/reading-platform-frontend/src/api/generated/model/coursePackage.ts +++ b/reading-platform-frontend/src/api/generated/model/coursePackage.ts @@ -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; diff --git a/reading-platform-frontend/src/api/package.ts b/reading-platform-frontend/src/api/package.ts index 7d82674..de56069 100644 --- a/reading-platform-frontend/src/api/package.ts +++ b/reading-platform-frontend/src/api/package.ts @@ -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); } diff --git a/reading-platform-frontend/src/api/resource.ts b/reading-platform-frontend/src/api/resource.ts index 79e07b7..39ba2a0 100644 --- a/reading-platform-frontend/src/api/resource.ts +++ b/reading-platform-frontend/src/api/resource.ts @@ -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); // ==================== 统计数据 ==================== diff --git a/reading-platform-frontend/src/api/school.ts b/reading-platform-frontend/src/api/school.ts index a9bcf0d..fb9415d 100644 --- a/reading-platform-frontend/src/api/school.ts +++ b/reading-platform-frontend/src/api/school.ts @@ -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('/v1/school/courses'); + http.get('/v1/school/courses'); export const getSchoolCourse = (id: number) => - http.get(`/v1/school/courses/${id}`); + http.get(`/v1/school/courses/${id}`); // ==================== 班级教师管理 ==================== diff --git a/reading-platform-frontend/src/api/teacher.ts b/reading-platform-frontend/src/api/teacher.ts index a05ca17..fbd3221 100644 --- a/reading-platform-frontend/src/api/teacher.ts +++ b/reading-platform-frontend/src/api/teacher.ts @@ -181,26 +181,52 @@ export interface StudentRecordDto { notes?: string; } +// 后端状态值: scheduled, in_progress, completed, cancelled +const STATUS_TO_BACKEND: Record = { + 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 精度丢失) diff --git a/reading-platform-frontend/src/components.d.ts b/reading-platform-frontend/src/components.d.ts index 70e729b..6c38de9 100644 --- a/reading-platform-frontend/src/components.d.ts +++ b/reading-platform-frontend/src/components.d.ts @@ -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'] diff --git a/reading-platform-frontend/src/components/FilePreviewModal.vue b/reading-platform-frontend/src/components/FilePreviewModal.vue index 0f2ab15..8544d1f 100644 --- a/reading-platform-frontend/src/components/FilePreviewModal.vue +++ b/reading-platform-frontend/src/components/FilePreviewModal.vue @@ -1,12 +1,16 @@