Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
ea21d9e3f4
|
@ -139,7 +139,7 @@ export class BaseTreeService<
|
||||||
|
|
||||||
const result: any = await super.update(anyArgs, { tx: transaction });
|
const result: any = await super.update(anyArgs, { tx: transaction });
|
||||||
|
|
||||||
if (anyArgs.data.parentId !== current.parentId) {
|
if (anyArgs.data.parentId && anyArgs.data.parentId !== current.parentId) {
|
||||||
await transaction[this.ancestryType].deleteMany({
|
await transaction[this.ancestryType].deleteMany({
|
||||||
where: { descendantId: result.id },
|
where: { descendantId: result.id },
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
// import { Module } from '@nestjs/common';
|
|
||||||
// import { CourseRouter } from './course.router';
|
|
||||||
// import { CourseService } from './course.service';
|
|
||||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
|
||||||
|
|
||||||
// @Module({
|
|
||||||
// providers: [CourseRouter, CourseService, TrpcService],
|
|
||||||
// exports: [CourseRouter, CourseService]
|
|
||||||
// })
|
|
||||||
// export class CourseModule { }
|
|
|
@ -1,92 +0,0 @@
|
||||||
// import { Injectable } from '@nestjs/common';
|
|
||||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
|
||||||
// import { Prisma, UpdateOrderSchema } from '@nice/common';
|
|
||||||
// import { CourseService } from './course.service';
|
|
||||||
// import { z, ZodType } from 'zod';
|
|
||||||
// // const CourseCreateArgsSchema: ZodType<Prisma.CourseCreateArgs> = z.any();
|
|
||||||
// // const CourseUpdateArgsSchema: ZodType<Prisma.CourseUpdateArgs> = z.any();
|
|
||||||
// // const CourseCreateManyInputSchema: ZodType<Prisma.CourseCreateManyInput> =
|
|
||||||
// // z.any();
|
|
||||||
// // const CourseDeleteManyArgsSchema: ZodType<Prisma.CourseDeleteManyArgs> =
|
|
||||||
// // z.any();
|
|
||||||
// // const CourseFindManyArgsSchema: ZodType<Prisma.CourseFindManyArgs> = z.any();
|
|
||||||
// // const CourseFindFirstArgsSchema: ZodType<Prisma.CourseFindFirstArgs> = z.any();
|
|
||||||
// // const CourseWhereInputSchema: ZodType<Prisma.CourseWhereInput> = z.any();
|
|
||||||
// // const CourseSelectSchema: ZodType<Prisma.CourseSelect> = z.any();
|
|
||||||
|
|
||||||
// @Injectable()
|
|
||||||
// export class CourseRouter {
|
|
||||||
// constructor(
|
|
||||||
// private readonly trpc: TrpcService,
|
|
||||||
// private readonly courseService: CourseService,
|
|
||||||
// ) {}
|
|
||||||
// router = this.trpc.router({
|
|
||||||
// // create: this.trpc.protectProcedure
|
|
||||||
// // .input(CourseCreateArgsSchema)
|
|
||||||
// // .mutation(async ({ ctx, input }) => {
|
|
||||||
// // const { staff } = ctx;
|
|
||||||
// // return await this.courseService.create(input, { staff });
|
|
||||||
// // }),
|
|
||||||
// // update: this.trpc.protectProcedure
|
|
||||||
// // .input(CourseUpdateArgsSchema)
|
|
||||||
// // .mutation(async ({ ctx, input }) => {
|
|
||||||
// // const { staff } = ctx;
|
|
||||||
// // return await this.courseService.update(input, { staff });
|
|
||||||
// // }),
|
|
||||||
// // createMany: this.trpc.protectProcedure
|
|
||||||
// // .input(z.array(CourseCreateManyInputSchema))
|
|
||||||
// // .mutation(async ({ ctx, input }) => {
|
|
||||||
// // const { staff } = ctx;
|
|
||||||
// // return await this.courseService.createMany({ data: input }, staff);
|
|
||||||
// // }),
|
|
||||||
// // deleteMany: this.trpc.procedure
|
|
||||||
// // .input(CourseDeleteManyArgsSchema)
|
|
||||||
// // .mutation(async ({ input }) => {
|
|
||||||
// // return await this.courseService.deleteMany(input);
|
|
||||||
// // }),
|
|
||||||
// // findFirst: this.trpc.procedure
|
|
||||||
// // .input(CourseFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
|
||||||
// // .query(async ({ input }) => {
|
|
||||||
// // return await this.courseService.findFirst(input);
|
|
||||||
// // }),
|
|
||||||
// // softDeleteByIds: this.trpc.protectProcedure
|
|
||||||
// // .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
|
||||||
// // .mutation(async ({ input }) => {
|
|
||||||
// // return this.courseService.softDeleteByIds(input.ids);
|
|
||||||
// // }),
|
|
||||||
// // updateOrder: this.trpc.protectProcedure
|
|
||||||
// // .input(UpdateOrderSchema)
|
|
||||||
// // .mutation(async ({ input }) => {
|
|
||||||
// // return this.courseService.updateOrder(input);
|
|
||||||
// // }),
|
|
||||||
// // findMany: this.trpc.procedure
|
|
||||||
// // .input(CourseFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
|
||||||
// // .query(async ({ input }) => {
|
|
||||||
// // return await this.courseService.findMany(input);
|
|
||||||
// // }),
|
|
||||||
// // findManyWithCursor: this.trpc.protectProcedure
|
|
||||||
// // .input(
|
|
||||||
// // z.object({
|
|
||||||
// // cursor: z.any().nullish(),
|
|
||||||
// // take: z.number().optional(),
|
|
||||||
// // where: CourseWhereInputSchema.optional(),
|
|
||||||
// // select: CourseSelectSchema.optional(),
|
|
||||||
// // }),
|
|
||||||
// // )
|
|
||||||
// // .query(async ({ ctx, input }) => {
|
|
||||||
// // return await this.courseService.findManyWithCursor(input);
|
|
||||||
// // }),
|
|
||||||
// // findManyWithPagination: this.trpc.procedure
|
|
||||||
// // .input(
|
|
||||||
// // z.object({
|
|
||||||
// // page: z.number().optional(),
|
|
||||||
// // pageSize: z.number().optional(),
|
|
||||||
// // where: CourseWhereInputSchema.optional(),
|
|
||||||
// // select: CourseSelectSchema.optional(),
|
|
||||||
// // }),
|
|
||||||
// // ) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
|
||||||
// // .query(async ({ input }) => {
|
|
||||||
// // return await this.courseService.findManyWithPagination(input);
|
|
||||||
// // }),
|
|
||||||
// });
|
|
||||||
// }
|
|
|
@ -1,78 +0,0 @@
|
||||||
// import { Injectable } from '@nestjs/common';
|
|
||||||
// import { BaseService } from '../base/base.service';
|
|
||||||
// import {
|
|
||||||
// UserProfile,
|
|
||||||
// db,
|
|
||||||
// ObjectType,
|
|
||||||
// Prisma,
|
|
||||||
// InstructorRole,
|
|
||||||
// } from '@nice/common';
|
|
||||||
// @Injectable()
|
|
||||||
// export class CourseService extends BaseService<Prisma.CourseDelegate> {
|
|
||||||
// constructor() {
|
|
||||||
// super(db, ObjectType.COURSE);
|
|
||||||
// }
|
|
||||||
// async create(
|
|
||||||
// args: Prisma.CourseCreateArgs,
|
|
||||||
// params?: { staff?: UserProfile }
|
|
||||||
// ) {
|
|
||||||
// return await db.$transaction(async tx => {
|
|
||||||
// const result = await super.create(args, { tx });
|
|
||||||
// if (params?.staff?.id) {
|
|
||||||
// await tx.courseInstructor.create({
|
|
||||||
// data: {
|
|
||||||
// instructorId: params.staff.id,
|
|
||||||
// courseId: result.id,
|
|
||||||
// role: InstructorRole.MAIN,
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// return result;
|
|
||||||
// }, {
|
|
||||||
// timeout: 10000 // 10 seconds
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// async update(
|
|
||||||
// args: Prisma.CourseUpdateArgs,
|
|
||||||
// params?: { staff?: UserProfile }
|
|
||||||
// ) {
|
|
||||||
// return await db.$transaction(async tx => {
|
|
||||||
// const result = await super.update(args, { tx });
|
|
||||||
// return result;
|
|
||||||
// }, {
|
|
||||||
// timeout: 10000 // 10 seconds
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// async removeInstructor(courseId: string, instructorId: string) {
|
|
||||||
// return await db.courseInstructor.delete({
|
|
||||||
// where: {
|
|
||||||
// courseId_instructorId: {
|
|
||||||
// courseId,
|
|
||||||
// instructorId,
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// async addInstructor(params: {
|
|
||||||
// courseId: string;
|
|
||||||
// instructorId: string;
|
|
||||||
// role?: string;
|
|
||||||
// order?: number;
|
|
||||||
// }) {
|
|
||||||
// return await db.courseInstructor.create({
|
|
||||||
// data: {
|
|
||||||
// courseId: params.courseId,
|
|
||||||
// instructorId: params.instructorId,
|
|
||||||
// role: params.role || InstructorRole.ASSISTANT,
|
|
||||||
// order: params.order,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// async getInstructors(courseId: string) {
|
|
||||||
// return await db.courseInstructor.findMany({
|
|
||||||
// where: { courseId },
|
|
||||||
// include: { instructor: true },
|
|
||||||
// orderBy: { order: 'asc' },
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// }
|
|
|
@ -1,49 +0,0 @@
|
||||||
// import { db, EnrollmentStatus, PostType } from '@nice/common';
|
|
||||||
|
|
||||||
// // 更新课程评价统计
|
|
||||||
// export async function updateCourseReviewStats(courseId: string) {
|
|
||||||
// const reviews = await db.post.findMany({
|
|
||||||
// where: {
|
|
||||||
// courseId,
|
|
||||||
// type: PostType.COURSE_REVIEW,
|
|
||||||
// deletedAt: null,
|
|
||||||
// },
|
|
||||||
// select: { rating: true },
|
|
||||||
// });
|
|
||||||
// const numberOfReviews = reviews.length;
|
|
||||||
// const averageRating =
|
|
||||||
// numberOfReviews > 0
|
|
||||||
// ? reviews.reduce((sum, review) => sum + review.rating, 0) /
|
|
||||||
// numberOfReviews
|
|
||||||
// : 0;
|
|
||||||
|
|
||||||
// return db.course.update({
|
|
||||||
// where: { id: courseId },
|
|
||||||
// data: {
|
|
||||||
// // numberOfReviews,
|
|
||||||
// //averageRating,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // 更新课程注册统计
|
|
||||||
// export async function updateCourseEnrollmentStats(courseId: string) {
|
|
||||||
// const completedEnrollments = await db.enrollment.count({
|
|
||||||
// where: {
|
|
||||||
// courseId,
|
|
||||||
// status: EnrollmentStatus.COMPLETED,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// const totalEnrollments = await db.enrollment.count({
|
|
||||||
// where: { courseId },
|
|
||||||
// });
|
|
||||||
// const completionRate =
|
|
||||||
// totalEnrollments > 0 ? (completedEnrollments / totalEnrollments) * 100 : 0;
|
|
||||||
// return db.course.update({
|
|
||||||
// where: { id: courseId },
|
|
||||||
// data: {
|
|
||||||
// // numberOfStudents: totalEnrollments,
|
|
||||||
// // completionRate,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// }
|
|
|
@ -1,10 +0,0 @@
|
||||||
// import { Module } from '@nestjs/common';
|
|
||||||
// import { LectureRouter } from './lecture.router';
|
|
||||||
// import { LectureService } from './lecture.service';
|
|
||||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
|
||||||
|
|
||||||
// @Module({
|
|
||||||
// providers: [LectureRouter, LectureService, TrpcService],
|
|
||||||
// exports: [LectureRouter, LectureService]
|
|
||||||
// })
|
|
||||||
// export class LectureModule { }
|
|
|
@ -1,70 +0,0 @@
|
||||||
// import { Injectable } from '@nestjs/common';
|
|
||||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
|
||||||
// import { Prisma, UpdateOrderSchema } from '@nice/common';
|
|
||||||
// import { LectureService } from './lecture.service';
|
|
||||||
// import { z, ZodType } from 'zod';
|
|
||||||
// const LectureCreateArgsSchema: ZodType<Prisma.LectureCreateArgs> = z.any()
|
|
||||||
// const LectureCreateManyInputSchema: ZodType<Prisma.LectureCreateManyInput> = z.any()
|
|
||||||
// const LectureDeleteManyArgsSchema: ZodType<Prisma.LectureDeleteManyArgs> = z.any()
|
|
||||||
// const LectureFindManyArgsSchema: ZodType<Prisma.LectureFindManyArgs> = z.any()
|
|
||||||
// const LectureFindFirstArgsSchema: ZodType<Prisma.LectureFindFirstArgs> = z.any()
|
|
||||||
// const LectureWhereInputSchema: ZodType<Prisma.LectureWhereInput> = z.any()
|
|
||||||
// const LectureSelectSchema: ZodType<Prisma.LectureSelect> = z.any()
|
|
||||||
|
|
||||||
// @Injectable()
|
|
||||||
// export class LectureRouter {
|
|
||||||
// constructor(
|
|
||||||
// private readonly trpc: TrpcService,
|
|
||||||
// private readonly lectureService: LectureService,
|
|
||||||
// ) { }
|
|
||||||
// router = this.trpc.router({
|
|
||||||
// create: this.trpc.protectProcedure
|
|
||||||
// .input(LectureCreateArgsSchema)
|
|
||||||
// .mutation(async ({ ctx, input }) => {
|
|
||||||
// const { staff } = ctx;
|
|
||||||
// return await this.lectureService.create(input, {staff});
|
|
||||||
// }),
|
|
||||||
// createMany: this.trpc.protectProcedure.input(z.array(LectureCreateManyInputSchema))
|
|
||||||
// .mutation(async ({ ctx, input }) => {
|
|
||||||
// const { staff } = ctx;
|
|
||||||
|
|
||||||
// return await this.lectureService.createMany({ data: input }, staff);
|
|
||||||
// }),
|
|
||||||
// deleteMany: this.trpc.procedure
|
|
||||||
// .input(LectureDeleteManyArgsSchema)
|
|
||||||
// .mutation(async ({ input }) => {
|
|
||||||
// return await this.lectureService.deleteMany(input);
|
|
||||||
// }),
|
|
||||||
// findFirst: this.trpc.procedure
|
|
||||||
// .input(LectureFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
|
||||||
// .query(async ({ input }) => {
|
|
||||||
// return await this.lectureService.findFirst(input);
|
|
||||||
// }),
|
|
||||||
// softDeleteByIds: this.trpc.protectProcedure
|
|
||||||
// .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
|
||||||
// .mutation(async ({ input }) => {
|
|
||||||
// return this.lectureService.softDeleteByIds(input.ids);
|
|
||||||
// }),
|
|
||||||
// updateOrder: this.trpc.protectProcedure
|
|
||||||
// .input(UpdateOrderSchema)
|
|
||||||
// .mutation(async ({ input }) => {
|
|
||||||
// return this.lectureService.updateOrder(input);
|
|
||||||
// }),
|
|
||||||
// findMany: this.trpc.procedure
|
|
||||||
// .input(LectureFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
|
||||||
// .query(async ({ input }) => {
|
|
||||||
// return await this.lectureService.findMany(input);
|
|
||||||
// }),
|
|
||||||
// findManyWithCursor: this.trpc.protectProcedure
|
|
||||||
// .input(z.object({
|
|
||||||
// cursor: z.any().nullish(),
|
|
||||||
// take: z.number().nullish(),
|
|
||||||
// where: LectureWhereInputSchema.nullish(),
|
|
||||||
// select: LectureSelectSchema.nullish()
|
|
||||||
// }))
|
|
||||||
// .query(async ({ ctx, input }) => {
|
|
||||||
// const { staff } = ctx;
|
|
||||||
// return await this.lectureService.findManyWithCursor(input);
|
|
||||||
// }),
|
|
||||||
// });
|
|
||||||
// }
|
|
|
@ -1,35 +0,0 @@
|
||||||
// import { Injectable } from '@nestjs/common';
|
|
||||||
// import { BaseService } from '../base/base.service';
|
|
||||||
// import {
|
|
||||||
// UserProfile,
|
|
||||||
// db,
|
|
||||||
// ObjectType,
|
|
||||||
// Prisma
|
|
||||||
// } from '@nice/common';
|
|
||||||
// import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
|
||||||
|
|
||||||
// @Injectable()
|
|
||||||
// export class LectureService extends BaseService<Prisma.LectureDelegate> {
|
|
||||||
// constructor() {
|
|
||||||
// super(db, ObjectType.COURSE);
|
|
||||||
// }
|
|
||||||
// async create(args: Prisma.LectureCreateArgs, params?: { staff?: UserProfile }) {
|
|
||||||
// const result = await super.create(args)
|
|
||||||
// EventBus.emit('dataChanged', {
|
|
||||||
// type: ObjectType.LECTURE,
|
|
||||||
// operation: CrudOperation.CREATED,
|
|
||||||
// data: result,
|
|
||||||
// });
|
|
||||||
// return result;
|
|
||||||
// }
|
|
||||||
// async update(args: Prisma.LectureUpdateArgs) {
|
|
||||||
// const result = await super.update(args);
|
|
||||||
// EventBus.emit('dataChanged', {
|
|
||||||
// type: ObjectType.LECTURE,
|
|
||||||
// operation: CrudOperation.UPDATED,
|
|
||||||
// data: result,
|
|
||||||
// });
|
|
||||||
// return result;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// }
|
|
|
@ -1,48 +0,0 @@
|
||||||
import { db, PostType } from '@nice/common';
|
|
||||||
|
|
||||||
// export async function updateSectionLectureStats(sectionId: string) {
|
|
||||||
// const sectionStats = await db.post.aggregate({
|
|
||||||
// where: {
|
|
||||||
// parentId: sectionId,
|
|
||||||
// deletedAt: null,
|
|
||||||
// type: PostType.LECTURE,
|
|
||||||
// },
|
|
||||||
// _count: { _all: true },
|
|
||||||
// _sum: { duration: true },
|
|
||||||
// });
|
|
||||||
|
|
||||||
// await db.post.update({
|
|
||||||
// where: { id: sectionId },
|
|
||||||
// data: {
|
|
||||||
// // totalLectures: sectionStats._count._all,
|
|
||||||
// // totalDuration: sectionStats._sum.duration || 0,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// export async function updateParentLectureStats(parentId: string) {
|
|
||||||
// const ParentStats = await db.post.aggregate({
|
|
||||||
// where: {
|
|
||||||
// ancestors: {
|
|
||||||
// some: {
|
|
||||||
// ancestorId: parentId,
|
|
||||||
// descendant: {
|
|
||||||
// type: PostType.LECTURE,
|
|
||||||
// deletedAt: null,
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// _count: { _all: true },
|
|
||||||
// _sum: {
|
|
||||||
// duration: true,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// await db.post.update({
|
|
||||||
// where: { id: parentId },
|
|
||||||
// data: {
|
|
||||||
// //totalLectures: courseStats._count._all,
|
|
||||||
// //totalDuration: courseStats._sum.duration || 0,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// }
|
|
|
@ -156,6 +156,7 @@ export async function setCourseInfo({ data }: { data: Post }) {
|
||||||
sections.map((section) => section.id).includes(descendant.parentId)
|
sections.map((section) => section.id).includes(descendant.parentId)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
sections.forEach((section) => {
|
sections.forEach((section) => {
|
||||||
section.lectures = lectures.filter(
|
section.lectures = lectures.filter(
|
||||||
(lecture) => lecture.parentId === section.id,
|
(lecture) => lecture.parentId === section.id,
|
||||||
|
|
|
@ -14,7 +14,7 @@ export class ImageProcessor extends BaseProcessor {
|
||||||
const { url } = resource;
|
const { url } = resource;
|
||||||
const filepath = getUploadFilePath(url);
|
const filepath = getUploadFilePath(url);
|
||||||
const originMeta = resource.meta as unknown as FileMetadata;
|
const originMeta = resource.meta as unknown as FileMetadata;
|
||||||
if (!originMeta.mimeType?.startsWith('image/')) {
|
if (!originMeta.filetype?.startsWith('image/')) {
|
||||||
this.logger.log(`Skipping non-image resource: ${resource.id}`);
|
this.logger.log(`Skipping non-image resource: ${resource.id}`);
|
||||||
return resource;
|
return resource;
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ export class VideoProcessor extends BaseProcessor {
|
||||||
);
|
);
|
||||||
|
|
||||||
const originMeta = resource.meta as unknown as FileMetadata;
|
const originMeta = resource.meta as unknown as FileMetadata;
|
||||||
if (!originMeta.mimeType?.startsWith('video/')) {
|
if (!originMeta.filetype?.startsWith('video/')) {
|
||||||
this.logger.log(`Skipping non-video resource: ${resource.id}`);
|
this.logger.log(`Skipping non-video resource: ${resource.id}`);
|
||||||
return resource;
|
return resource;
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ export interface ProcessResult {
|
||||||
|
|
||||||
export interface BaseMetadata {
|
export interface BaseMetadata {
|
||||||
size: number
|
size: number
|
||||||
mimeType: string
|
filetype: string
|
||||||
filename: string
|
filename: string
|
||||||
extension: string
|
extension: string
|
||||||
modifiedAt: Date
|
modifiedAt: Date
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
// import { Module } from '@nestjs/common';
|
|
||||||
// import { SectionRouter } from './section.router';
|
|
||||||
// import { SectionService } from './section.service';
|
|
||||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
|
||||||
|
|
||||||
// @Module({
|
|
||||||
// exports: [SectionRouter, SectionService],
|
|
||||||
// providers: [SectionRouter, SectionService, TrpcService]
|
|
||||||
// })
|
|
||||||
// export class SectionModule { }
|
|
|
@ -1,70 +0,0 @@
|
||||||
// import { Injectable } from '@nestjs/common';
|
|
||||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
|
||||||
// import { Prisma, UpdateOrderSchema } from '@nice/common';
|
|
||||||
// import { SectionService } from './section.service';
|
|
||||||
// import { z, ZodType } from 'zod';
|
|
||||||
// const SectionCreateArgsSchema: ZodType<Prisma.SectionCreateArgs> = z.any()
|
|
||||||
// const SectionCreateManyInputSchema: ZodType<Prisma.SectionCreateManyInput> = z.any()
|
|
||||||
// const SectionDeleteManyArgsSchema: ZodType<Prisma.SectionDeleteManyArgs> = z.any()
|
|
||||||
// const SectionFindManyArgsSchema: ZodType<Prisma.SectionFindManyArgs> = z.any()
|
|
||||||
// const SectionFindFirstArgsSchema: ZodType<Prisma.SectionFindFirstArgs> = z.any()
|
|
||||||
// const SectionWhereInputSchema: ZodType<Prisma.SectionWhereInput> = z.any()
|
|
||||||
// const SectionSelectSchema: ZodType<Prisma.SectionSelect> = z.any()
|
|
||||||
|
|
||||||
// @Injectable()
|
|
||||||
// export class SectionRouter {
|
|
||||||
// constructor(
|
|
||||||
// private readonly trpc: TrpcService,
|
|
||||||
// private readonly sectionService: SectionService,
|
|
||||||
// ) { }
|
|
||||||
// router = this.trpc.router({
|
|
||||||
// create: this.trpc.protectProcedure
|
|
||||||
// .input(SectionCreateArgsSchema)
|
|
||||||
// .mutation(async ({ ctx, input }) => {
|
|
||||||
// const { staff } = ctx;
|
|
||||||
// return await this.sectionService.create(input, { staff });
|
|
||||||
// }),
|
|
||||||
// createMany: this.trpc.protectProcedure.input(z.array(SectionCreateManyInputSchema))
|
|
||||||
// .mutation(async ({ ctx, input }) => {
|
|
||||||
// const { staff } = ctx;
|
|
||||||
|
|
||||||
// return await this.sectionService.createMany({ data: input }, staff);
|
|
||||||
// }),
|
|
||||||
// deleteMany: this.trpc.procedure
|
|
||||||
// .input(SectionDeleteManyArgsSchema)
|
|
||||||
// .mutation(async ({ input }) => {
|
|
||||||
// return await this.sectionService.deleteMany(input);
|
|
||||||
// }),
|
|
||||||
// findFirst: this.trpc.procedure
|
|
||||||
// .input(SectionFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
|
||||||
// .query(async ({ input }) => {
|
|
||||||
// return await this.sectionService.findFirst(input);
|
|
||||||
// }),
|
|
||||||
// softDeleteByIds: this.trpc.protectProcedure
|
|
||||||
// .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
|
||||||
// .mutation(async ({ input }) => {
|
|
||||||
// return this.sectionService.softDeleteByIds(input.ids);
|
|
||||||
// }),
|
|
||||||
// updateOrder: this.trpc.protectProcedure
|
|
||||||
// .input(UpdateOrderSchema)
|
|
||||||
// .mutation(async ({ input }) => {
|
|
||||||
// return this.sectionService.updateOrder(input);
|
|
||||||
// }),
|
|
||||||
// findMany: this.trpc.procedure
|
|
||||||
// .input(SectionFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
|
||||||
// .query(async ({ input }) => {
|
|
||||||
// return await this.sectionService.findMany(input);
|
|
||||||
// }),
|
|
||||||
// findManyWithCursor: this.trpc.protectProcedure
|
|
||||||
// .input(z.object({
|
|
||||||
// cursor: z.any().nullish(),
|
|
||||||
// take: z.number().nullish(),
|
|
||||||
// where: SectionWhereInputSchema.nullish(),
|
|
||||||
// select: SectionSelectSchema.nullish()
|
|
||||||
// }))
|
|
||||||
// .query(async ({ ctx, input }) => {
|
|
||||||
// const { staff } = ctx;
|
|
||||||
// return await this.sectionService.findManyWithCursor(input);
|
|
||||||
// }),
|
|
||||||
// });
|
|
||||||
// }
|
|
|
@ -1,23 +0,0 @@
|
||||||
// import { Injectable } from '@nestjs/common';
|
|
||||||
// import { BaseService } from '../base/base.service';
|
|
||||||
// import {
|
|
||||||
// UserProfile,
|
|
||||||
// db,
|
|
||||||
// ObjectType,
|
|
||||||
// Prisma,
|
|
||||||
|
|
||||||
// } from '@nice/common';
|
|
||||||
// @Injectable()
|
|
||||||
// export class SectionService extends BaseService<Prisma.SectionDelegate> {
|
|
||||||
// constructor() {
|
|
||||||
// super(db, ObjectType.SECTION);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// create(args: Prisma.SectionCreateArgs, params?: { staff?: UserProfile }) {
|
|
||||||
// return super.create(args)
|
|
||||||
// }
|
|
||||||
// async update(args: Prisma.SectionUpdateArgs) {
|
|
||||||
// return super.update(args);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// }
|
|
|
@ -4,15 +4,16 @@ import { Prisma } from '@nice/common';
|
||||||
|
|
||||||
import { VisitService } from './visit.service';
|
import { VisitService } from './visit.service';
|
||||||
import { z, ZodType } from 'zod';
|
import { z, ZodType } from 'zod';
|
||||||
const VisitCreateArgsSchema: ZodType<Prisma.VisitCreateArgs> = z.any()
|
const VisitCreateArgsSchema: ZodType<Prisma.VisitCreateArgs> = z.any();
|
||||||
const VisitCreateManyInputSchema: ZodType<Prisma.VisitCreateManyInput> = z.any()
|
const VisitCreateManyInputSchema: ZodType<Prisma.VisitCreateManyInput> =
|
||||||
const VisitDeleteManyArgsSchema: ZodType<Prisma.VisitDeleteManyArgs> = z.any()
|
z.any();
|
||||||
|
const VisitDeleteManyArgsSchema: ZodType<Prisma.VisitDeleteManyArgs> = z.any();
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VisitRouter {
|
export class VisitRouter {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly trpc: TrpcService,
|
private readonly trpc: TrpcService,
|
||||||
private readonly visitService: VisitService,
|
private readonly visitService: VisitService,
|
||||||
) { }
|
) {}
|
||||||
router = this.trpc.router({
|
router = this.trpc.router({
|
||||||
create: this.trpc.protectProcedure
|
create: this.trpc.protectProcedure
|
||||||
.input(VisitCreateArgsSchema)
|
.input(VisitCreateArgsSchema)
|
||||||
|
@ -20,7 +21,8 @@ export class VisitRouter {
|
||||||
const { staff } = ctx;
|
const { staff } = ctx;
|
||||||
return await this.visitService.create(input, staff);
|
return await this.visitService.create(input, staff);
|
||||||
}),
|
}),
|
||||||
createMany: this.trpc.protectProcedure.input(z.array(VisitCreateManyInputSchema))
|
createMany: this.trpc.protectProcedure
|
||||||
|
.input(z.array(VisitCreateManyInputSchema))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { staff } = ctx;
|
const { staff } = ctx;
|
||||||
|
|
||||||
|
@ -31,7 +33,5 @@ export class VisitRouter {
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return await this.visitService.deleteMany(input);
|
return await this.visitService.deleteMany(input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,12 +30,17 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (troubleId && args.data.type === VisitType.READED) {
|
if (
|
||||||
// EventBus.emit('updateViewCount', {
|
[VisitType.READED, VisitType.LIKE, VisitType.HATE].includes(
|
||||||
// objectType: ObjectType.TROUBLE,
|
args.data.type as VisitType,
|
||||||
// id: troubleId,
|
)
|
||||||
// });
|
) {
|
||||||
// }
|
EventBus.emit('updateVisitCount', {
|
||||||
|
objectType: ObjectType.POST,
|
||||||
|
id: postId,
|
||||||
|
visitType: args.data.type, // 直接复用传入的类型
|
||||||
|
});
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) {
|
async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) {
|
||||||
|
@ -80,4 +85,61 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
|
||||||
|
|
||||||
return { count: updatePromises.length }; // Return the number of updates if no new creates
|
return { count: updatePromises.length }; // Return the number of updates if no new creates
|
||||||
}
|
}
|
||||||
|
async deleteMany(args: Prisma.VisitDeleteManyArgs, staff?: UserProfile) {
|
||||||
|
// const where = Array.isArray(args.where) ? args.where : [args.where];
|
||||||
|
// const updatePromises: any[] = [];
|
||||||
|
// const createData: Prisma.VisitCreateManyInput[] = [];
|
||||||
|
// super
|
||||||
|
// await Promise.all(
|
||||||
|
// data.map(async (item) => {
|
||||||
|
// if (staff && !item.visitorId) item.visitorId = staff.id;
|
||||||
|
// const { postId, messageId, visitorId } = item;
|
||||||
|
// const existingVisit = await db.visit.findFirst({
|
||||||
|
// where: {
|
||||||
|
// visitorId,
|
||||||
|
// OR: [{ postId }, { messageId }],
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (existingVisit) {
|
||||||
|
// updatePromises.push(
|
||||||
|
// super.update({
|
||||||
|
// where: { id: existingVisit.id },
|
||||||
|
// data: {
|
||||||
|
// ...item,
|
||||||
|
// views: existingVisit.views + 1,
|
||||||
|
// },
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
// } else {
|
||||||
|
// createData.push(item);
|
||||||
|
// }
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
// // Execute all updates in parallel
|
||||||
|
// await Promise.all(updatePromises);
|
||||||
|
// // Create new visits for those not existing
|
||||||
|
// if (createData.length > 0) {
|
||||||
|
// return super.createMany({
|
||||||
|
// ...args,
|
||||||
|
// data: createData,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// return { count: updatePromises.length }; // Return the number of updates if no new creates
|
||||||
|
const superDetele = super.deleteMany(args, staff);
|
||||||
|
if (args?.where?.postId) {
|
||||||
|
if (
|
||||||
|
[VisitType.READED, VisitType.LIKE, VisitType.HATE].includes(
|
||||||
|
args.where.type as any,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
EventBus.emit('updateVisitCount', {
|
||||||
|
objectType: ObjectType.POST,
|
||||||
|
id: args?.where?.postId as string,
|
||||||
|
visitType: args.where.type as any, // 直接复用传入的类型
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return superDetele;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import EventBus from '@server/utils/event-bus';
|
||||||
|
import { ObjectType, VisitType } from '@nice/common';
|
||||||
|
import {
|
||||||
|
QueueJobType,
|
||||||
|
updatePostStateJobData,
|
||||||
|
updateVisitCountJobData,
|
||||||
|
} from '@server/queue/types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PostQueueService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(PostQueueService.name);
|
||||||
|
constructor(@InjectQueue('general') private generalQueue: Queue) {}
|
||||||
|
onModuleInit() {
|
||||||
|
EventBus.on('updateVisitCount', ({ id, objectType, visitType }) => {
|
||||||
|
if (objectType === ObjectType.POST) {
|
||||||
|
this.addUpdateVisitCountJob({ id, type: visitType });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
EventBus.on('updatePostState', ({ id }) => {
|
||||||
|
this.addUpdatePostState({ id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async addUpdateVisitCountJob(data: updateVisitCountJobData) {
|
||||||
|
this.logger.log(`update post view count ${data.id}`);
|
||||||
|
await this.generalQueue.add(QueueJobType.UPDATE_POST_VISIT_COUNT, data, {
|
||||||
|
debounce: {
|
||||||
|
id: `${QueueJobType.UPDATE_POST_VISIT_COUNT}_${data.type}_${data.id}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async addUpdatePostState(data: updatePostStateJobData) {
|
||||||
|
this.logger.log(`update post state ${data.id}`);
|
||||||
|
await this.generalQueue.add(QueueJobType.UPDATE_POST_STATE, data, {
|
||||||
|
debounce: { id: `${QueueJobType.UPDATE_POST_STATE}_${data.id}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { db, VisitType } from '@nice/common';
|
||||||
|
export async function updatePostViewCount(id: string, type: VisitType) {
|
||||||
|
const totalViews = await db.visit.aggregate({
|
||||||
|
_sum: {
|
||||||
|
views: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
postId: id,
|
||||||
|
type: type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (type === VisitType.READED) {
|
||||||
|
await db.post.update({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
meta: {
|
||||||
|
views: totalViews._sum.views || 0,
|
||||||
|
}, // Use 0 if no visits exist
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (type === VisitType.LIKE) {
|
||||||
|
await db.post.update({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
meta: {
|
||||||
|
likes: totalViews._sum.views || 0, // Use 0 if no visits exist
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (type === VisitType.HATE) {
|
||||||
|
await db.post.update({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
meta: {
|
||||||
|
hates: totalViews._sum.views || 0, // Use 0 if no visits exist
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import { BullModule } from '@nestjs/bullmq';
|
||||||
import { Logger, Module } from '@nestjs/common';
|
import { Logger, Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { PostQueueService } from './models/post/post.queue.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -28,7 +29,7 @@ import { join } from 'path';
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
providers: [Logger],
|
providers: [Logger, PostQueueService],
|
||||||
exports: [],
|
exports: [],
|
||||||
})
|
})
|
||||||
export class QueueModule {}
|
export class QueueModule {}
|
||||||
|
|
|
@ -1,4 +1,14 @@
|
||||||
|
import { VisitType } from '@nice/common';
|
||||||
export enum QueueJobType {
|
export enum QueueJobType {
|
||||||
UPDATE_STATS = 'update_stats',
|
UPDATE_STATS = 'update_stats',
|
||||||
FILE_PROCESS = 'file_process',
|
FILE_PROCESS = 'file_process',
|
||||||
|
UPDATE_POST_VISIT_COUNT = 'updatePostVisitCount',
|
||||||
|
UPDATE_POST_STATE = 'updatePostState',
|
||||||
}
|
}
|
||||||
|
export type updateVisitCountJobData = {
|
||||||
|
id: string;
|
||||||
|
type: VisitType | string;
|
||||||
|
};
|
||||||
|
export type updatePostStateJobData = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
|
@ -1,16 +1,28 @@
|
||||||
import mitt from 'mitt';
|
import mitt from 'mitt';
|
||||||
import { ObjectType, UserProfile, MessageDto } from '@nice/common';
|
import { ObjectType, UserProfile, MessageDto, VisitType } from '@nice/common';
|
||||||
export enum CrudOperation {
|
export enum CrudOperation {
|
||||||
CREATED,
|
CREATED,
|
||||||
UPDATED,
|
UPDATED,
|
||||||
DELETED
|
DELETED,
|
||||||
}
|
}
|
||||||
type Events = {
|
type Events = {
|
||||||
genDataEvent: { type: "start" | "end" },
|
genDataEvent: { type: 'start' | 'end' };
|
||||||
markDirty: { objectType: string, id: string, staff?: UserProfile, subscribers?: string[] }
|
markDirty: {
|
||||||
updateViewCount: { id: string, objectType: ObjectType },
|
objectType: string;
|
||||||
onMessageCreated: { data: Partial<MessageDto> },
|
id: string;
|
||||||
dataChanged: { type: string, operation: CrudOperation, data: any }
|
staff?: UserProfile;
|
||||||
|
subscribers?: string[];
|
||||||
|
};
|
||||||
|
updateVisitCount: {
|
||||||
|
id: string;
|
||||||
|
objectType: ObjectType;
|
||||||
|
visitType: VisitType | string;
|
||||||
|
};
|
||||||
|
updatePostState: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
onMessageCreated: { data: Partial<MessageDto> };
|
||||||
|
dataChanged: { type: string; operation: CrudOperation; data: any };
|
||||||
};
|
};
|
||||||
const EventBus = mitt<Events>();
|
const EventBus = mitt<Events>();
|
||||||
export default EventBus;
|
export default EventBus;
|
||||||
|
|
|
@ -2,7 +2,6 @@ import HeroSection from './components/HeroSection';
|
||||||
import CategorySection from './components/CategorySection';
|
import CategorySection from './components/CategorySection';
|
||||||
import CoursesSection from './components/CoursesSection';
|
import CoursesSection from './components/CoursesSection';
|
||||||
import FeaturedTeachersSection from './components/FeaturedTeachersSection';
|
import FeaturedTeachersSection from './components/FeaturedTeachersSection';
|
||||||
import { TusUploader } from '@web/src/components/common/uploader/TusUploader';
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const mockCourses = [
|
const mockCourses = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import Quill from "quill";
|
||||||
|
import "quill/dist/quill.snow.css"; // 引入默认样式
|
||||||
|
interface CollapsibleContentProps {
|
||||||
|
content: string;
|
||||||
|
maxHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollapsibleContent: React.FC<CollapsibleContentProps> = ({
|
||||||
|
content,
|
||||||
|
maxHeight = 150,
|
||||||
|
}) => {
|
||||||
|
const contentWrapperRef = useRef(null);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [shouldCollapse, setShouldCollapse] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contentWrapperRef.current) {
|
||||||
|
const shouldCollapse =
|
||||||
|
contentWrapperRef.current.scrollHeight > maxHeight;
|
||||||
|
setShouldCollapse(shouldCollapse);
|
||||||
|
}
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className=" text-base ">
|
||||||
|
<div className=" flex flex-col gap-4 border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out rounded-xl p-6 ">
|
||||||
|
{/* 包装整个内容区域的容器 */}
|
||||||
|
<div
|
||||||
|
ref={contentWrapperRef}
|
||||||
|
className={`duration-300 ${
|
||||||
|
shouldCollapse && !isExpanded
|
||||||
|
? `max-h-[${maxHeight}px] overflow-hidden relative`
|
||||||
|
: ""
|
||||||
|
}`}>
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<div
|
||||||
|
className="ql-editor p-0 space-y-1 leading-relaxed"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: content || "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 渐变遮罩 */}
|
||||||
|
{shouldCollapse && !isExpanded && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-white to-transparent" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 展开/收起按钮 */}
|
||||||
|
{shouldCollapse && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="mt-2 text-blue-500 hover:text-blue-700">
|
||||||
|
{isExpanded ? "收起" : "展开"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* PostResources 组件 */}
|
||||||
|
{/* <PostResources post={post} /> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollapsibleContent;
|
|
@ -1,22 +1,29 @@
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Input, Button } from "antd";
|
import { Input, Button } from "antd";
|
||||||
import { DeleteOutlined } from "@ant-design/icons";
|
import { DeleteOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
interface InputListProps {
|
interface InputListProps {
|
||||||
initialValue?: string[];
|
value?: string[];
|
||||||
onChange?: (value: string[]) => void;
|
onChange?: (value: string[]) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputList: React.FC<InputListProps> = ({
|
const InputList: React.FC<InputListProps> = ({
|
||||||
initialValue,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = "请输入内容",
|
placeholder = "请输入内容",
|
||||||
}) => {
|
}) => {
|
||||||
// Internal state management with fallback to initial value or empty array
|
// Internal state management with fallback to initial value or empty array
|
||||||
const [inputValues, setInputValues] = useState<string[]>(
|
const [inputValues, setInputValues] = useState<string[]>([""]);
|
||||||
initialValue && initialValue.length > 0 ? initialValue : [""]
|
|
||||||
);
|
// Update inputValues when value changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (value && value.length > 0) {
|
||||||
|
setInputValues(value);
|
||||||
|
} else {
|
||||||
|
setInputValues([""]);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
// Handle individual input value change
|
// Handle individual input value change
|
||||||
const handleInputChange = (index: number, newValue: string) => {
|
const handleInputChange = (index: number, newValue: string) => {
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
import { api } from "@nice/client";
|
import { api, useVisitor } from "@nice/client";
|
||||||
import { courseDetailSelect, CourseDto, Lecture } from "@nice/common";
|
import {
|
||||||
|
courseDetailSelect,
|
||||||
|
CourseDto,
|
||||||
|
Lecture,
|
||||||
|
VisitType,
|
||||||
|
} from "@nice/common";
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
import React, { createContext, ReactNode, useEffect, useState } from "react";
|
import React, { createContext, ReactNode, useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
@ -10,6 +16,7 @@ interface CourseDetailContextType {
|
||||||
selectedLectureId?: string | undefined;
|
selectedLectureId?: string | undefined;
|
||||||
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
|
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
lectureIsLoading?: boolean;
|
||||||
isHeaderVisible: boolean; // 新增
|
isHeaderVisible: boolean; // 新增
|
||||||
setIsHeaderVisible: (visible: boolean) => void; // 新增
|
setIsHeaderVisible: (visible: boolean) => void; // 新增
|
||||||
}
|
}
|
||||||
|
@ -24,6 +31,8 @@ export function CourseDetailProvider({
|
||||||
editId,
|
editId,
|
||||||
}: CourseFormProviderProps) {
|
}: CourseFormProviderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { read } = useVisitor();
|
||||||
|
const { user } = useAuth();
|
||||||
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
|
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
|
||||||
(api.post as any).findFirst.useQuery(
|
(api.post as any).findFirst.useQuery(
|
||||||
{
|
{
|
||||||
|
@ -35,6 +44,7 @@ export function CourseDetailProvider({
|
||||||
},
|
},
|
||||||
{ enabled: Boolean(editId) }
|
{ enabled: Boolean(editId) }
|
||||||
);
|
);
|
||||||
|
|
||||||
const [selectedLectureId, setSelectedLectureId] = useState<
|
const [selectedLectureId, setSelectedLectureId] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
@ -46,6 +56,17 @@ export function CourseDetailProvider({
|
||||||
},
|
},
|
||||||
{ enabled: Boolean(editId) }
|
{ enabled: Boolean(editId) }
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (course) {
|
||||||
|
read.mutateAsync({
|
||||||
|
data: {
|
||||||
|
visitorId: user?.id || null,
|
||||||
|
postId: course.id,
|
||||||
|
type: VisitType.READED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [course]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigate(`/course/${editId}/detail/${selectedLectureId}`);
|
navigate(`/course/${editId}/detail/${selectedLectureId}`);
|
||||||
}, [selectedLectureId, editId]);
|
}, [selectedLectureId, editId]);
|
||||||
|
@ -59,6 +80,7 @@ export function CourseDetailProvider({
|
||||||
selectedLectureId,
|
selectedLectureId,
|
||||||
setSelectedLectureId,
|
setSelectedLectureId,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
lectureIsLoading,
|
||||||
isHeaderVisible,
|
isHeaderVisible,
|
||||||
setIsHeaderVisible,
|
setIsHeaderVisible,
|
||||||
}}>
|
}}>
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Course } from "@nice/common";
|
||||||
|
import React, { useContext } from "react";
|
||||||
|
import { Typography, Skeleton } from "antd"; // 引入 antd 组件
|
||||||
|
import { CourseDetailContext } from "./CourseDetailContext";
|
||||||
|
|
||||||
|
interface CourseDetailProps {
|
||||||
|
course: Course;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CourseDetailDescription: React.FC<CourseDetailProps> = () => {
|
||||||
|
const { course, isLoading } = useContext(CourseDetailContext);
|
||||||
|
const { Paragraph, Title } = Typography;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-6">
|
||||||
|
{isLoading || !course ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 4 }} />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-lg font-bold">{"课程简介"}</div>
|
||||||
|
<Paragraph
|
||||||
|
ellipsis={{
|
||||||
|
rows: 3,
|
||||||
|
expandable: true,
|
||||||
|
symbol: "展开",
|
||||||
|
onExpand: () => console.log("展开"),
|
||||||
|
// collapseText: "收起",
|
||||||
|
}}>
|
||||||
|
{course.content}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,29 +0,0 @@
|
||||||
import { CheckCircleIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { Course } from "@nice/common";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import React from "react";
|
|
||||||
import CourseDetailSkeleton from "../CourseDetailSkeleton";
|
|
||||||
import CourseDetailNavBar from "./CourseDetailNavBar";
|
|
||||||
interface CourseDetailProps {
|
|
||||||
course: Course;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CourseDetailDescription: React.FC<CourseDetailProps> = ({
|
|
||||||
course,
|
|
||||||
isLoading,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CourseDetailNavBar></CourseDetailNavBar>
|
|
||||||
|
|
||||||
<div className="w-[80%] mx-auto px-4 py-8">
|
|
||||||
{isLoading || !course ? (
|
|
||||||
<CourseDetailSkeleton />
|
|
||||||
) : (
|
|
||||||
<CourseDetailSkeleton />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,47 +0,0 @@
|
||||||
import { NavBar } from "@web/src/components/presentation/NavBar";
|
|
||||||
import { HomeIcon, BellIcon } from "@heroicons/react/24/outline";
|
|
||||||
import {
|
|
||||||
DocumentTextIcon,
|
|
||||||
MagnifyingGlassIcon,
|
|
||||||
StarIcon,
|
|
||||||
} from "@heroicons/react/24/solid";
|
|
||||||
|
|
||||||
export default function CourseDetailNavBar() {
|
|
||||||
const navItems = [
|
|
||||||
{
|
|
||||||
id: "search",
|
|
||||||
icon: <MagnifyingGlassIcon className="w-5 h-5" />,
|
|
||||||
label: "搜索",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "overview",
|
|
||||||
icon: <HomeIcon className="w-5 h-5" />,
|
|
||||||
label: "概述",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "notes",
|
|
||||||
icon: <DocumentTextIcon className="w-5 h-5" />,
|
|
||||||
label: "备注",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "announcements",
|
|
||||||
icon: <BellIcon className="w-5 h-5" />,
|
|
||||||
label: "公告",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "reviews",
|
|
||||||
icon: <StarIcon className="w-5 h-5" />,
|
|
||||||
label: "评价",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className=" bg-gray-50">
|
|
||||||
<NavBar
|
|
||||||
items={navItems}
|
|
||||||
defaultSelected="overview"
|
|
||||||
onSelect={(id) => console.log("Selected:", id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,67 +1,67 @@
|
||||||
import { useContext } from "react";
|
// import { useContext } from "react";
|
||||||
import { CourseDetailContext } from "../../CourseDetailContext";
|
// import { CourseDetailContext } from "../../CourseDetailContext";
|
||||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
// import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
export function Overview() {
|
// export function Overview() {
|
||||||
const { course } = useContext(CourseDetailContext);
|
// const { course } = useContext(CourseDetailContext);
|
||||||
return (
|
// return (
|
||||||
<>
|
// <>
|
||||||
<div className="space-y-8">
|
// <div className="space-y-8">
|
||||||
{/* 课程描述 */}
|
// {/* 课程描述 */}
|
||||||
<div className="prose max-w-none">
|
// <div className="prose max-w-none">
|
||||||
<p>{course?.description}</p>
|
// <p>{course?.description}</p>
|
||||||
</div>
|
// </div>
|
||||||
|
|
||||||
{/* 学习目标 */}
|
// {/* 学习目标 */}
|
||||||
<div>
|
// <div>
|
||||||
<h2 className="text-xl font-semibold mb-4">学习目标</h2>
|
// <h2 className="text-xl font-semibold mb-4">学习目标</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
// <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{course?.objectives.map((objective, index) => (
|
// {course?.objectives.map((objective, index) => (
|
||||||
<div key={index} className="flex items-start gap-2">
|
// <div key={index} className="flex items-start gap-2">
|
||||||
<CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-1" />
|
// <CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-1" />
|
||||||
<span>{objective}</span>
|
// <span>{objective}</span>
|
||||||
</div>
|
// </div>
|
||||||
))}
|
// ))}
|
||||||
</div>
|
// </div>
|
||||||
</div>
|
// </div>
|
||||||
|
|
||||||
{/* 适合人群 */}
|
// {/* 适合人群 */}
|
||||||
<div>
|
// <div>
|
||||||
<h2 className="text-xl font-semibold mb-4">适合人群</h2>
|
// <h2 className="text-xl font-semibold mb-4">适合人群</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
// <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{course?.audiences.map((audience, index) => (
|
// {course?.audiences.map((audience, index) => (
|
||||||
<div key={index} className="flex items-start gap-2">
|
// <div key={index} className="flex items-start gap-2">
|
||||||
<CheckCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0 mt-1" />
|
// <CheckCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0 mt-1" />
|
||||||
<span>{audience}</span>
|
// <span>{audience}</span>
|
||||||
</div>
|
// </div>
|
||||||
))}
|
// ))}
|
||||||
</div>
|
// </div>
|
||||||
</div>
|
// </div>
|
||||||
|
|
||||||
{/* 课程要求 */}
|
// {/* 课程要求 */}
|
||||||
<div>
|
// <div>
|
||||||
<h2 className="text-xl font-semibold mb-4">课程要求</h2>
|
// <h2 className="text-xl font-semibold mb-4">课程要求</h2>
|
||||||
<ul className="list-disc list-inside space-y-2 text-gray-700">
|
// <ul className="list-disc list-inside space-y-2 text-gray-700">
|
||||||
{course?.requirements.map((requirement, index) => (
|
// {course?.requirements.map((requirement, index) => (
|
||||||
<li key={index}>{requirement}</li>
|
// <li key={index}>{requirement}</li>
|
||||||
))}
|
// ))}
|
||||||
</ul>
|
// </ul>
|
||||||
</div>
|
// </div>
|
||||||
|
|
||||||
{/* 可获得技能 */}
|
// {/* 可获得技能 */}
|
||||||
<div>
|
// <div>
|
||||||
<h2 className="text-xl font-semibold mb-4">可获得技能</h2>
|
// <h2 className="text-xl font-semibold mb-4">可获得技能</h2>
|
||||||
<div className="flex flex-wrap gap-2">
|
// <div className="flex flex-wrap gap-2">
|
||||||
{course?.skills.map((skill, index) => (
|
// {course?.skills.map((skill, index) => (
|
||||||
<span
|
// <span
|
||||||
key={index}
|
// key={index}
|
||||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
// className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
||||||
{skill}
|
// {skill}
|
||||||
</span>
|
// </span>
|
||||||
))}
|
// ))}
|
||||||
</div>
|
// </div>
|
||||||
</div>
|
// </div>
|
||||||
</div>
|
// </div>
|
||||||
</>
|
// </>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
|
@ -1,55 +1,65 @@
|
||||||
// components/CourseDetailDisplayArea.tsx
|
// components/CourseDetailDisplayArea.tsx
|
||||||
import { motion, useScroll, useTransform } from "framer-motion";
|
import { motion, useScroll, useTransform } from "framer-motion";
|
||||||
import React, { useContext } from "react";
|
import React, { useContext, useRef, useState } from "react";
|
||||||
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
||||||
import { CourseDetailDescription } from "./CourseDetailDescription/CourseDetailDescription";
|
import { CourseDetailDescription } from "./CourseDetailDescription";
|
||||||
import { Course, PostType } from "@nice/common";
|
import { Course, LectureType, PostType } from "@nice/common";
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
import { CourseDetailContext } from "./CourseDetailContext";
|
||||||
|
import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent";
|
||||||
|
import { Skeleton } from "antd";
|
||||||
|
|
||||||
interface CourseDetailDisplayAreaProps {
|
// interface CourseDetailDisplayAreaProps {
|
||||||
// course: Course;
|
// // course: Course;
|
||||||
videoSrc?: string;
|
// // videoSrc?: string;
|
||||||
videoPoster?: string;
|
// // videoPoster?: string;
|
||||||
// isLoading?: boolean;
|
// // isLoading?: boolean;
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const CourseDetailDisplayArea: React.FC<
|
export const CourseDetailDisplayArea: React.FC = () => {
|
||||||
CourseDetailDisplayAreaProps
|
|
||||||
> = ({ videoSrc, videoPoster }) => {
|
|
||||||
// 创建滚动动画效果
|
// 创建滚动动画效果
|
||||||
const { course, isLoading, lecture } = useContext(CourseDetailContext);
|
const { course, isLoading, lecture, lectureIsLoading } =
|
||||||
|
useContext(CourseDetailContext);
|
||||||
const { scrollY } = useScroll();
|
const { scrollY } = useScroll();
|
||||||
const videoScale = useTransform(scrollY, [0, 200], [1, 0.8]);
|
|
||||||
const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]);
|
const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* 固定的视频区域 */}
|
{/* 固定的视频区域 */}
|
||||||
{/* 移除 sticky 定位,让视频区域随页面滚动 */}
|
{lectureIsLoading && (
|
||||||
|
<Skeleton active paragraph={{ rows: 4 }} title={false} />
|
||||||
|
)}
|
||||||
|
{!lectureIsLoading && lecture?.meta?.type === LectureType.VIDEO && (
|
||||||
|
<div className="flex justify-center flex-col items-center gap-2 w-full mt-2 px-4">
|
||||||
<motion.div
|
<motion.div
|
||||||
style={{
|
style={{
|
||||||
opacity: videoOpacity,
|
opacity: videoOpacity,
|
||||||
}}
|
}}
|
||||||
className="w-full bg-black">
|
className="w-full bg-black rounded-lg ">
|
||||||
{lecture.type === PostType.LECTURE && (
|
|
||||||
<div className=" w-full ">
|
<div className=" w-full ">
|
||||||
<VideoPlayer src={videoSrc} poster={videoPoster} />
|
<VideoPlayer src={lecture?.meta?.videoUrl} />
|
||||||
|
</div>
|
||||||
|
</motion.div>{" "}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
{!lectureIsLoading &&
|
||||||
|
lecture?.meta?.type === LectureType.ARTICLE && (
|
||||||
{/* 课程内容区域 */}
|
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-4">
|
||||||
<motion.div
|
<div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-6 ">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<CollapsibleContent
|
||||||
animate={{ opacity: 1, y: 0 }}
|
content={lecture?.content || ""}
|
||||||
transition={{ duration: 0.5, delay: 0.2 }}
|
maxHeight={150} // Optional, defaults to 150
|
||||||
className="w-full">
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-4">
|
||||||
<CourseDetailDescription
|
<CourseDetailDescription
|
||||||
course={course}
|
course={course}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</div>
|
||||||
|
{/* 课程内容区域 */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CourseDetailDisplayArea;
|
export default CourseDetailDisplayArea;
|
||||||
|
|
|
@ -1,64 +1,86 @@
|
||||||
// components/Header.tsx
|
import { useContext, useState } from "react";
|
||||||
import { motion, useScroll, useTransform } from "framer-motion";
|
import { Input, Layout, Avatar, Button, Dropdown } from "antd";
|
||||||
import { useContext, useEffect, useState } from "react";
|
import {
|
||||||
|
EditFilled,
|
||||||
|
HomeOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { UserMenu } from "@web/src/components/layout/element/usermenu/usermenu";
|
||||||
import { CourseDetailContext } from "../CourseDetailContext";
|
import { CourseDetailContext } from "../CourseDetailContext";
|
||||||
import { Button } from "antd";
|
|
||||||
|
|
||||||
export const CourseDetailHeader = () => {
|
const { Header } = Layout;
|
||||||
const { scrollY } = useScroll();
|
|
||||||
|
|
||||||
const [lastScrollY, setLastScrollY] = useState(0);
|
export function CourseDetailHeader() {
|
||||||
const { course, isHeaderVisible, setIsHeaderVisible, lecture } =
|
const [searchValue, setSearchValue] = useState("");
|
||||||
useContext(CourseDetailContext);
|
const { isAuthenticated, user } = useAuth();
|
||||||
useEffect(() => {
|
const navigate = useNavigate();
|
||||||
const updateHeader = () => {
|
const { course } = useContext(CourseDetailContext);
|
||||||
const current = scrollY.get();
|
|
||||||
const direction = current > lastScrollY ? "down" : "up";
|
|
||||||
|
|
||||||
if (direction === "down" && current > 100) {
|
|
||||||
setIsHeaderVisible(false);
|
|
||||||
} else if (direction === "up") {
|
|
||||||
setIsHeaderVisible(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLastScrollY(current);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 使用 requestAnimationFrame 来优化性能
|
|
||||||
const unsubscribe = scrollY.on("change", () => {
|
|
||||||
requestAnimationFrame(updateHeader);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, [lastScrollY, scrollY, setIsHeaderVisible]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.header
|
<Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
|
||||||
initial={{ y: 0 }}
|
<div className="w-full max-w-screen-2xl px-4 md:px-6 mx-auto flex items-center justify-between h-full">
|
||||||
animate={{ y: isHeaderVisible ? 0 : -100 }}
|
<div className="flex items-center space-x-2">
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
<HomeOutlined
|
||||||
className="fixed top-0 left-0 w-full h-16 bg-slate-900 backdrop-blur-sm z-50 shadow-sm">
|
|
||||||
<div className="w-full mx-auto px-4 h-full flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<h1 className="text-white text-xl ">{course?.title}</h1>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log(lecture);
|
navigate("/");
|
||||||
}}>
|
}}
|
||||||
123
|
className="text-2xl text-primary-500 hover:scale-105 cursor-pointer"
|
||||||
</Button>
|
/>
|
||||||
<nav className="flex items-center space-x-4">
|
|
||||||
{/* 添加你的导航项目 */}
|
|
||||||
<button className="px-4 py-2 rounded-full bg-blue-500 text-white hover:bg-blue-600 transition-colors">
|
|
||||||
开始学习
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</motion.header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CourseDetailHeader;
|
<div className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent transition-transform ">
|
||||||
|
{course?.title}
|
||||||
|
</div>
|
||||||
|
{/* <NavigationMenu /> */}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-6">
|
||||||
|
<div className="group relative">
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
prefix={
|
||||||
|
<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />
|
||||||
|
}
|
||||||
|
placeholder="搜索课程"
|
||||||
|
className="w-72 rounded-full"
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/course/editor")}
|
||||||
|
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
||||||
|
icon={<EditFilled />}>
|
||||||
|
创建课程
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Dropdown
|
||||||
|
overlay={<UserMenu />}
|
||||||
|
trigger={["click"]}
|
||||||
|
placement="bottomRight">
|
||||||
|
<Avatar
|
||||||
|
size="large"
|
||||||
|
className="cursor-pointer hover:scale-105 transition-all bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold">
|
||||||
|
{(user?.showname ||
|
||||||
|
user?.username ||
|
||||||
|
"")[0]?.toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
</Dropdown>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/login")}
|
||||||
|
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
||||||
|
icon={<UserOutlined />}>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,77 @@
|
||||||
|
// // components/Header.tsx
|
||||||
|
// import { motion, useScroll, useTransform } from "framer-motion";
|
||||||
|
// import { useContext, useEffect, useState } from "react";
|
||||||
|
// import { CourseDetailContext } from "../CourseDetailContext";
|
||||||
|
// import { Avatar, Button, Dropdown } from "antd";
|
||||||
|
// import { UserOutlined } from "@ant-design/icons";
|
||||||
|
// import { UserMenu } from "@web/src/app/main/layout/UserMenu";
|
||||||
|
// import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
|
||||||
|
// export const CourseDetailHeader = () => {
|
||||||
|
// const { scrollY } = useScroll();
|
||||||
|
// const { user, isAuthenticated } = useAuth();
|
||||||
|
// const [lastScrollY, setLastScrollY] = useState(0);
|
||||||
|
// const { course, isHeaderVisible, setIsHeaderVisible, lecture } =
|
||||||
|
// useContext(CourseDetailContext);
|
||||||
|
// useEffect(() => {
|
||||||
|
// const updateHeader = () => {
|
||||||
|
// const current = scrollY.get();
|
||||||
|
// const direction = current > lastScrollY ? "down" : "up";
|
||||||
|
|
||||||
|
// if (direction === "down" && current > 100) {
|
||||||
|
// setIsHeaderVisible(false);
|
||||||
|
// } else if (direction === "up") {
|
||||||
|
// setIsHeaderVisible(true);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// setLastScrollY(current);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // 使用 requestAnimationFrame 来优化性能
|
||||||
|
// const unsubscribe = scrollY.on("change", () => {
|
||||||
|
// requestAnimationFrame(updateHeader);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return () => {
|
||||||
|
// unsubscribe();
|
||||||
|
// };
|
||||||
|
// }, [lastScrollY, scrollY, setIsHeaderVisible]);
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <motion.header
|
||||||
|
// initial={{ y: 0 }}
|
||||||
|
// animate={{ y: isHeaderVisible ? 0 : -100 }}
|
||||||
|
// transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||||
|
// className="fixed top-0 left-0 w-full h-16 bg-slate-900 backdrop-blur-sm z-50 shadow-sm">
|
||||||
|
// <div className="w-full mx-auto px-4 h-full flex items-center justify-between">
|
||||||
|
// <div className="flex items-center space-x-4">
|
||||||
|
// <h1 className="text-white text-xl ">{course?.title}</h1>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {isAuthenticated ? (
|
||||||
|
// <Dropdown
|
||||||
|
// overlay={<UserMenu />}
|
||||||
|
// trigger={["click"]}
|
||||||
|
// placement="bottomRight">
|
||||||
|
// <Avatar
|
||||||
|
// size="large"
|
||||||
|
// className="cursor-pointer hover:scale-105 transition-all bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold">
|
||||||
|
// {(user?.showname ||
|
||||||
|
// user?.username ||
|
||||||
|
// "")[0]?.toUpperCase()}
|
||||||
|
// </Avatar>
|
||||||
|
// </Dropdown>
|
||||||
|
// ) : (
|
||||||
|
// <Button
|
||||||
|
// onClick={() => navigator("/login")}
|
||||||
|
// className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
||||||
|
// icon={<UserOutlined />}>
|
||||||
|
// 登录
|
||||||
|
// </Button>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// </motion.header>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export default CourseDetailHeader;
|
|
@ -4,8 +4,7 @@ import { CourseDetailContext } from "./CourseDetailContext";
|
||||||
|
|
||||||
import CourseDetailDisplayArea from "./CourseDetailDisplayArea";
|
import CourseDetailDisplayArea from "./CourseDetailDisplayArea";
|
||||||
import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus";
|
import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus";
|
||||||
import CourseDetailHeader from "./CourseDetailHeader/CourseDetailHeader";
|
import { CourseDetailHeader } from "./CourseDetailHeader/CourseDetailHeader";
|
||||||
import { Button } from "antd";
|
|
||||||
|
|
||||||
export default function CourseDetailLayout() {
|
export default function CourseDetailLayout() {
|
||||||
const {
|
const {
|
||||||
|
@ -39,8 +38,6 @@ export default function CourseDetailLayout() {
|
||||||
<CourseDetailDisplayArea
|
<CourseDetailDisplayArea
|
||||||
// course={course}
|
// course={course}
|
||||||
// isLoading={isLoading}
|
// isLoading={isLoading}
|
||||||
videoSrc="https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"
|
|
||||||
videoPoster="https://picsum.photos/800/450"
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
{/* 课程大纲侧边栏 */}
|
{/* 课程大纲侧边栏 */}
|
||||||
|
|
|
@ -83,7 +83,7 @@ export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{sections.map((section) => (
|
{sections.map((section, index) => (
|
||||||
<SectionItem
|
<SectionItem
|
||||||
key={section.id}
|
key={section.id}
|
||||||
ref={(el) =>
|
ref={(el) =>
|
||||||
|
@ -91,6 +91,7 @@ export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||||
section.id
|
section.id
|
||||||
] = el)
|
] = el)
|
||||||
}
|
}
|
||||||
|
index={index + 1}
|
||||||
section={section}
|
section={section}
|
||||||
isExpanded={expandedSections.includes(
|
isExpanded={expandedSections.includes(
|
||||||
section.id
|
section.id
|
||||||
|
|
|
@ -2,7 +2,11 @@
|
||||||
|
|
||||||
import { Lecture, LectureType } from "@nice/common";
|
import { Lecture, LectureType } from "@nice/common";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ClockCircleOutlined, FileTextOutlined, PlayCircleOutlined } from "@ant-design/icons"; // 使用 Ant Design 图标
|
import {
|
||||||
|
ClockCircleOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
} from "@ant-design/icons"; // 使用 Ant Design 图标
|
||||||
|
|
||||||
interface LectureItemProps {
|
interface LectureItemProps {
|
||||||
lecture: Lecture;
|
lecture: Lecture;
|
||||||
|
@ -28,9 +32,9 @@ export const LectureItem: React.FC<LectureItemProps> = ({
|
||||||
<p className="text-sm text-gray-500 mt-1">{lecture.subTitle}</p>
|
<p className="text-sm text-gray-500 mt-1">{lecture.subTitle}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
{/* <div className="flex items-center gap-1 text-sm text-gray-500">
|
||||||
<ClockCircleOutlined className="w-4 h-4" />
|
<ClockCircleOutlined className="w-4 h-4" />
|
||||||
<span>{lecture.duration}分钟</span>
|
<span>{lecture.duration}分钟</span>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { LectureItem } from "./LectureItem";
|
||||||
// components/CourseSyllabus/SectionItem.tsx
|
// components/CourseSyllabus/SectionItem.tsx
|
||||||
interface SectionItemProps {
|
interface SectionItemProps {
|
||||||
section: SectionDto;
|
section: SectionDto;
|
||||||
|
index?: number;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onToggle: (sectionId: string) => void;
|
onToggle: (sectionId: string) => void;
|
||||||
onLectureClick: (lectureId: string) => void;
|
onLectureClick: (lectureId: string) => void;
|
||||||
|
@ -14,7 +15,7 @@ interface SectionItemProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
||||||
({ section, isExpanded, onToggle, onLectureClick }, ref) => (
|
({ section, index, isExpanded, onToggle, onLectureClick }, ref) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
@ -26,9 +27,9 @@ export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
||||||
onClick={() => onToggle(section.id)}>
|
onClick={() => onToggle(section.id)}>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-lg font-medium text-gray-700">
|
<span className="text-lg font-medium text-gray-700">
|
||||||
第{Math.floor(section.order)}章
|
第{index}章
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div className="flex flex-col items-start">
|
||||||
<h3 className="text-left font-medium text-gray-900">
|
<h3 className="text-left font-medium text-gray-900">
|
||||||
{section.title}
|
{section.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
|
@ -66,9 +66,9 @@ export function CourseFormProvider({
|
||||||
title: course.title,
|
title: course.title,
|
||||||
subTitle: course.subTitle,
|
subTitle: course.subTitle,
|
||||||
content: course.content,
|
content: course.content,
|
||||||
|
meta: {
|
||||||
thumbnail: course?.meta?.thumbnail,
|
thumbnail: course?.meta?.thumbnail,
|
||||||
requirements: course?.meta?.requirements,
|
},
|
||||||
objectives: course?.meta?.objectives,
|
|
||||||
};
|
};
|
||||||
course.terms?.forEach((term) => {
|
course.terms?.forEach((term) => {
|
||||||
formData[term.taxonomyId] = term.id; // 假设 taxonomyName 是您在 Form.Item 中使用的 name
|
formData[term.taxonomyId] = term.id; // 假设 taxonomyName 是您在 Form.Item 中使用的 name
|
||||||
|
@ -87,8 +87,7 @@ export function CourseFormProvider({
|
||||||
const formattedValues = {
|
const formattedValues = {
|
||||||
...values,
|
...values,
|
||||||
meta: {
|
meta: {
|
||||||
requirements: values.requirements,
|
thumbnail: values.thumbnail,
|
||||||
objectives: values.objectives,
|
|
||||||
},
|
},
|
||||||
terms: {
|
terms: {
|
||||||
connect: termIds.map((id) => ({ id })), // 转换成 connect 格式
|
connect: termIds.map((id) => ({ id })), // 转换成 connect 格式
|
||||||
|
@ -98,8 +97,6 @@ export function CourseFormProvider({
|
||||||
taxonomies.forEach((tax) => {
|
taxonomies.forEach((tax) => {
|
||||||
delete formattedValues[tax.id];
|
delete formattedValues[tax.id];
|
||||||
});
|
});
|
||||||
delete formattedValues.requirements;
|
|
||||||
delete formattedValues.objectives;
|
|
||||||
delete formattedValues.sections;
|
delete formattedValues.sections;
|
||||||
try {
|
try {
|
||||||
if (editId) {
|
if (editId) {
|
||||||
|
@ -113,7 +110,7 @@ export function CourseFormProvider({
|
||||||
courseDetail: {
|
courseDetail: {
|
||||||
data: {
|
data: {
|
||||||
title: formattedValues.title || "12345",
|
title: formattedValues.title || "12345",
|
||||||
state: CourseStatus.DRAFT,
|
// state: CourseStatus.DRAFT,
|
||||||
type: PostType.COURSE,
|
type: PostType.COURSE,
|
||||||
...formattedValues,
|
...formattedValues,
|
||||||
},
|
},
|
||||||
|
|
|
@ -34,6 +34,14 @@ export function CourseBasicForm() {
|
||||||
rules={[{ max: 10, message: "副标题最多10个字符" }]}>
|
rules={[{ max: 10, message: "副标题最多10个字符" }]}>
|
||||||
<Input placeholder="请输入课程副标题" />
|
<Input placeholder="请输入课程副标题" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name={["meta", "thumbnail"]} label="课程封面">
|
||||||
|
<AvatarUploader
|
||||||
|
style={{
|
||||||
|
width: "192px",
|
||||||
|
height: "108px",
|
||||||
|
margin: " 0 10px",
|
||||||
|
}}></AvatarUploader>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="content" label="课程描述">
|
<Form.Item name="content" label="课程描述">
|
||||||
<TextArea
|
<TextArea
|
||||||
placeholder="请输入课程描述"
|
placeholder="请输入课程描述"
|
||||||
|
|
|
@ -65,7 +65,7 @@ export const LectureList: React.FC<LectureListProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
// 用 lectures 初始化 items 状态
|
// 用 lectures 初始化 items 状态
|
||||||
const [items, setItems] = useState<LectureData[]>(lectures);
|
const [items, setItems] = useState<Lecture[]>(lectures);
|
||||||
|
|
||||||
// 当 lectures 变化时更新 items
|
// 当 lectures 变化时更新 items
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -94,12 +94,6 @@ export const LectureList: React.FC<LectureListProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pl-8">
|
<div className="pl-8">
|
||||||
{/* <Button
|
|
||||||
onClick={() => {
|
|
||||||
console.log(lectures);
|
|
||||||
}}>
|
|
||||||
123
|
|
||||||
</Button> */}
|
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
|
@ -138,7 +132,7 @@ export const LectureList: React.FC<LectureListProps> = ({
|
||||||
meta: {
|
meta: {
|
||||||
type: LectureType.ARTICLE,
|
type: LectureType.ARTICLE,
|
||||||
},
|
},
|
||||||
},
|
} as Lecture,
|
||||||
]);
|
]);
|
||||||
}}>
|
}}>
|
||||||
添加课时
|
添加课时
|
||||||
|
|
|
@ -2,6 +2,7 @@ import {
|
||||||
DragOutlined,
|
DragOutlined,
|
||||||
CaretRightOutlined,
|
CaretRightOutlined,
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
|
CaretDownOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { Form, Button, Input, Select, Space } from "antd";
|
import { Form, Button, Input, Select, Space } from "antd";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
@ -9,13 +10,16 @@ import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
|
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
|
||||||
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
|
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
|
||||||
import { LectureType, LessonTypeLabel, PostType } from "@nice/common";
|
import { Lecture, LectureType, LessonTypeLabel, PostType } from "@nice/common";
|
||||||
import { usePost } from "@nice/client";
|
import { usePost } from "@nice/client";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { LectureData } from "./interface";
|
|
||||||
|
import { env } from "@web/src/env";
|
||||||
|
import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent";
|
||||||
|
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
||||||
|
|
||||||
interface SortableLectureProps {
|
interface SortableLectureProps {
|
||||||
field: LectureData;
|
field: Lecture;
|
||||||
remove: () => void;
|
remove: () => void;
|
||||||
sectionFieldKey: string;
|
sectionFieldKey: string;
|
||||||
}
|
}
|
||||||
|
@ -36,6 +40,11 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [editing, setEditing] = useState(field?.id ? false : true);
|
const [editing, setEditing] = useState(field?.id ? false : true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isContentVisible, setIsContentVisible] = useState(false); // State to manage content visibility
|
||||||
|
|
||||||
|
const handleToggleContent = () => {
|
||||||
|
setIsContentVisible(!isContentVisible); // Toggle content visibility
|
||||||
|
};
|
||||||
const lectureType =
|
const lectureType =
|
||||||
Form.useWatch(["meta", "type"], form) || LectureType.ARTICLE;
|
Form.useWatch(["meta", "type"], form) || LectureType.ARTICLE;
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
@ -43,7 +52,12 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
let result;
|
let result;
|
||||||
|
const videoUrlId = Array.isArray(values?.meta?.videoIds)
|
||||||
|
? values?.meta?.videoIds[0]
|
||||||
|
: typeof values?.meta?.videoIds === "string"
|
||||||
|
? values?.meta?.videoIds
|
||||||
|
: undefined;
|
||||||
|
console.log(sectionFieldKey);
|
||||||
if (!field.id) {
|
if (!field.id) {
|
||||||
result = await create.mutateAsync({
|
result = await create.mutateAsync({
|
||||||
data: {
|
data: {
|
||||||
|
@ -52,14 +66,17 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||||
title: values?.title,
|
title: values?.title,
|
||||||
meta: {
|
meta: {
|
||||||
type: values?.meta?.type,
|
type: values?.meta?.type,
|
||||||
fileIds: values?.meta?.fileIds,
|
videoIds: videoUrlId ? [videoUrlId] : [],
|
||||||
|
videoUrl: videoUrlId
|
||||||
|
? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8`
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
resources: {
|
resources: {
|
||||||
connect: (values?.meta?.fileIds || []).map(
|
connect: [videoUrlId]
|
||||||
(fileId) => ({
|
.filter(Boolean)
|
||||||
|
.map((fileId) => ({
|
||||||
fileId,
|
fileId,
|
||||||
})
|
})),
|
||||||
),
|
|
||||||
},
|
},
|
||||||
content: values?.content,
|
content: values?.content,
|
||||||
},
|
},
|
||||||
|
@ -70,17 +87,21 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||||
id: field?.id,
|
id: field?.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
parentId: sectionFieldKey,
|
||||||
title: values?.title,
|
title: values?.title,
|
||||||
meta: {
|
meta: {
|
||||||
type: values?.meta?.type,
|
type: values?.meta?.type,
|
||||||
fieldIds: values?.meta?.fileIds,
|
videoIds: videoUrlId ? [videoUrlId] : [],
|
||||||
|
videoUrl: videoUrlId
|
||||||
|
? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8`
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
resources: {
|
resources: {
|
||||||
connect: (values?.meta?.fileIds || []).map(
|
connect: [videoUrlId]
|
||||||
(fileId) => ({
|
.filter(Boolean)
|
||||||
|
.map((fileId) => ({
|
||||||
fileId,
|
fileId,
|
||||||
})
|
})),
|
||||||
),
|
|
||||||
},
|
},
|
||||||
content: values?.content,
|
content: values?.content,
|
||||||
},
|
},
|
||||||
|
@ -135,7 +156,7 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||||
<div className="mt-4 flex flex-1 ">
|
<div className="mt-4 flex flex-1 ">
|
||||||
{lectureType === LectureType.VIDEO ? (
|
{lectureType === LectureType.VIDEO ? (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={["meta", "fileIds"]}
|
name={["meta", "videoIds"]}
|
||||||
className="mb-0 flex-1"
|
className="mb-0 flex-1"
|
||||||
rules={[{ required: true }]}>
|
rules={[{ required: true }]}>
|
||||||
<TusUploader multiple={false} />
|
<TusUploader multiple={false} />
|
||||||
|
@ -177,7 +198,11 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||||
{...listeners}
|
{...listeners}
|
||||||
className="cursor-move"
|
className="cursor-move"
|
||||||
/>
|
/>
|
||||||
<CaretRightOutlined />
|
{isContentVisible ? (
|
||||||
|
<CaretDownOutlined onClick={handleToggleContent} />
|
||||||
|
) : (
|
||||||
|
<CaretRightOutlined onClick={handleToggleContent} />
|
||||||
|
)}
|
||||||
<span>{LessonTypeLabel[field?.meta?.type]}</span>
|
<span>{LessonTypeLabel[field?.meta?.type]}</span>
|
||||||
<span>{field?.title || "未命名课时"}</span>
|
<span>{field?.title || "未命名课时"}</span>
|
||||||
</Space>
|
</Space>
|
||||||
|
@ -191,6 +216,13 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isContentVisible &&
|
||||||
|
!editing && // Conditionally render content based on type
|
||||||
|
(field?.meta?.type === LectureType.ARTICLE ? (
|
||||||
|
<CollapsibleContent content={field?.content} />
|
||||||
|
) : (
|
||||||
|
<VideoPlayer src={field?.meta?.videoUrl} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,19 +1,19 @@
|
||||||
import { FormArrayField } from "@web/src/components/common/form/FormArrayField";
|
// import { FormArrayField } from "@web/src/components/common/form/FormArrayField";
|
||||||
import { useFormContext } from "react-hook-form";
|
// import { useFormContext } from "react-hook-form";
|
||||||
import { CourseFormData } from "../context/CourseEditorContext";
|
// import { CourseFormData } from "../context/CourseEditorContext";
|
||||||
import InputList from "@web/src/components/common/input/InputList";
|
// import InputList from "@web/src/components/common/input/InputList";
|
||||||
import { useState } from "react";
|
// import { useState } from "react";
|
||||||
import { Form } from "antd";
|
// import { Form } from "antd";
|
||||||
|
|
||||||
export function CourseGoalForm() {
|
// export function CourseGoalForm() {
|
||||||
return (
|
// return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6 p-6">
|
// <div className="max-w-2xl mx-auto space-y-6 p-6">
|
||||||
<Form.Item name="requirements" label="前置要求">
|
// <Form.Item name="requirements" label="前置要求">
|
||||||
<InputList placeholder="请输入前置要求"></InputList>
|
// <InputList placeholder="请输入前置要求"></InputList>
|
||||||
</Form.Item>
|
// </Form.Item>
|
||||||
<Form.Item name="objectives" label="学习目标">
|
// <Form.Item name="objectives" label="学习目标">
|
||||||
<InputList placeholder="请输入学习目标"></InputList>
|
// <InputList placeholder="请输入学习目标"></InputList>
|
||||||
</Form.Item>
|
// </Form.Item>
|
||||||
</div>
|
// </div>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
|
@ -1,26 +1,26 @@
|
||||||
import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader";
|
// import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader";
|
||||||
import { Form, Input } from "antd";
|
// import { Form, Input } from "antd";
|
||||||
|
|
||||||
export default function CourseSettingForm() {
|
// export default function CourseSettingForm() {
|
||||||
return (
|
// return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6 p-6">
|
// <div className="max-w-2xl mx-auto space-y-6 p-6">
|
||||||
<Form.Item
|
// <Form.Item
|
||||||
name="title"
|
// name="title"
|
||||||
label="课程预览图"
|
// label="课程预览图"
|
||||||
>
|
// >
|
||||||
<AvatarUploader
|
// <AvatarUploader
|
||||||
style={
|
// style={
|
||||||
{
|
// {
|
||||||
width: "120px",
|
// width: "120px",
|
||||||
height: "120px",
|
// height: "120px",
|
||||||
margin:" 0 10px"
|
// margin:" 0 10px"
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
onChange={(value) => {
|
// onChange={(value) => {
|
||||||
console.log(value);
|
// console.log(value);
|
||||||
}}
|
// }}
|
||||||
></AvatarUploader>
|
// ></AvatarUploader>
|
||||||
</Form.Item>
|
// </Form.Item>
|
||||||
</div>
|
// </div>
|
||||||
)
|
// )
|
||||||
}
|
// }
|
|
@ -41,7 +41,7 @@ export default function CourseEditorHeader() {
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
<Title level={4} style={{ margin: 0 }}>
|
||||||
{course?.title || "新建课程"}
|
{course?.title || "新建课程"}
|
||||||
</Title>
|
</Title>
|
||||||
<Tag
|
{/* <Tag
|
||||||
color={
|
color={
|
||||||
courseStatusVariant[
|
courseStatusVariant[
|
||||||
course?.state || CourseStatus.DRAFT
|
course?.state || CourseStatus.DRAFT
|
||||||
|
@ -50,20 +50,20 @@ export default function CourseEditorHeader() {
|
||||||
{course?.state
|
{course?.state
|
||||||
? CourseStatusLabel[course.state]
|
? CourseStatusLabel[course.state]
|
||||||
: CourseStatusLabel[CourseStatus.DRAFT]}
|
: CourseStatusLabel[CourseStatus.DRAFT]}
|
||||||
</Tag>
|
</Tag> */}
|
||||||
{course?.duration && (
|
{/* {course?.duration && (
|
||||||
<span className="hidden md:flex items-center text-gray-500 text-sm">
|
<span className="hidden md:flex items-center text-gray-500 text-sm">
|
||||||
<ClockCircleOutlined
|
<ClockCircleOutlined
|
||||||
style={{ marginRight: 4 }}
|
style={{ marginRight: 4 }}
|
||||||
/>
|
/>
|
||||||
总时长 {course.duration}
|
总时长 {course.duration}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
// size="small"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
// disabled={form
|
// disabled={form
|
||||||
// .getFieldsError()
|
// .getFieldsError()
|
||||||
|
|
|
@ -2,23 +2,22 @@ import { ReactNode, useEffect, useState } from "react";
|
||||||
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
||||||
import CourseEditorHeader from "./CourseEditorHeader";
|
import CourseEditorHeader from "./CourseEditorHeader";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { NavItem } from "@nice/client"
|
import { NavItem } from "@nice/client";
|
||||||
import CourseEditorSidebar from "./CourseEditorSidebar";
|
import CourseEditorSidebar from "./CourseEditorSidebar";
|
||||||
import { CourseFormProvider } from "../context/CourseEditorContext";
|
import { CourseFormProvider } from "../context/CourseEditorContext";
|
||||||
import { getNavItems } from "../navItems";
|
import { getNavItems } from "../navItems";
|
||||||
|
|
||||||
|
|
||||||
export default function CourseEditorLayout() {
|
export default function CourseEditorLayout() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [selectedSection, setSelectedSection] = useState<number>(0);
|
const [selectedSection, setSelectedSection] = useState<number>(0);
|
||||||
const [navItems, setNavItems] = useState<NavItem[]>(getNavItems(id));
|
const [navItems, setNavItems] = useState<NavItem[]>(getNavItems(id));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNavItems(getNavItems(id))
|
setNavItems(getNavItems(id));
|
||||||
}, [id])
|
}, [id]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentPath = location.pathname;
|
const currentPath = location.pathname;
|
||||||
const index = navItems.findIndex(item => item.path === currentPath);
|
const index = navItems.findIndex((item) => item.path === currentPath);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
setSelectedSection(index);
|
setSelectedSection(index);
|
||||||
}
|
}
|
||||||
|
@ -41,10 +40,14 @@ export default function CourseEditorLayout() {
|
||||||
onNavigate={handleNavigation}
|
onNavigate={handleNavigation}
|
||||||
/>
|
/>
|
||||||
<motion.main
|
<motion.main
|
||||||
animate={{ marginLeft: isHovered ? "16rem" : "5rem" }}
|
animate={{ marginLeft: "16rem" }}
|
||||||
transition={{ type: "spring", stiffness: 200, damping: 25, mass: 1 }}
|
transition={{
|
||||||
className="flex-1 p-8"
|
type: "spring",
|
||||||
>
|
stiffness: 200,
|
||||||
|
damping: 25,
|
||||||
|
mass: 1,
|
||||||
|
}}
|
||||||
|
className="flex-1 p-8">
|
||||||
<div className="max-w-6xl mx-auto bg-white rounded-xl shadow-lg">
|
<div className="max-w-6xl mx-auto bg-white rounded-xl shadow-lg">
|
||||||
<header className="p-6 border-b border-gray-100">
|
<header className="p-6 border-b border-gray-100">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
@ -59,6 +62,5 @@ export default function CourseEditorLayout() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CourseFormProvider>
|
</CourseFormProvider>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { NavItem } from "@nice/client";
|
import { NavItem } from "@nice/client";
|
||||||
import { useCourseEditor } from "../context/CourseEditorContext";
|
import { useCourseEditor } from "../context/CourseEditorContext";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
interface CourseSidebarProps {
|
interface CourseSidebarProps {
|
||||||
id?: string | undefined;
|
id?: string | undefined;
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
|
@ -23,21 +24,22 @@ export default function CourseEditorSidebar({
|
||||||
const { editId } = useCourseEditor();
|
const { editId } = useCourseEditor();
|
||||||
return (
|
return (
|
||||||
<motion.nav
|
<motion.nav
|
||||||
initial={{ width: "5rem" }}
|
animate={{ width: "16rem" }}
|
||||||
animate={{ width: isHovered ? "16rem" : "5rem" }}
|
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 40 }}
|
transition={{ type: "spring", stiffness: 300, damping: 40 }}
|
||||||
onHoverStart={() => setIsHovered(true)}
|
|
||||||
onHoverEnd={() => setIsHovered(false)}
|
|
||||||
className="fixed left-0 top-16 h-[calc(100vh-4rem)] bg-white border-r border-gray-200 shadow-lg overflow-x-hidden">
|
className="fixed left-0 top-16 h-[calc(100vh-4rem)] bg-white border-r border-gray-200 shadow-lg overflow-x-hidden">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{navItems.map((item, index) => (
|
{navItems.map((item, index) => (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!editId && !item.isInitialized}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
if (!editId && !item.isInitialized) {
|
||||||
|
e.preventDefault();
|
||||||
|
toast.error("请先完成课程概述填写并保存"); // 提示信息
|
||||||
|
} else {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onNavigate(item, index);
|
onNavigate(item, index);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full flex ${!isHovered ? "justify-center" : "items-center"} px-5 py-2.5 mb-1 rounded-lg transition-all duration-200 ${
|
className={`w-full flex ${!isHovered ? "justify-center" : "items-center"} px-5 py-2.5 mb-1 rounded-lg transition-all duration-200 ${
|
||||||
selectedSection === index
|
selectedSection === index
|
||||||
|
@ -45,16 +47,13 @@ export default function CourseEditorSidebar({
|
||||||
: "text-gray-600 hover:bg-gray-50"
|
: "text-gray-600 hover:bg-gray-50"
|
||||||
}`}>
|
}`}>
|
||||||
<span className="flex-shrink-0">{item.icon}</span>
|
<span className="flex-shrink-0">{item.icon}</span>
|
||||||
{isHovered && (
|
{
|
||||||
<>
|
<>
|
||||||
<motion.span
|
<span className="ml-3 font-medium flex-1 truncate">
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
className="ml-3 font-medium flex-1 truncate">
|
|
||||||
{item.label}
|
{item.label}
|
||||||
</motion.span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -15,22 +15,22 @@ export const getNavItems = (
|
||||||
path: `/course/${courseId ? `${courseId}/` : ""}editor`,
|
path: `/course/${courseId ? `${courseId}/` : ""}editor`,
|
||||||
isInitialized: true,
|
isInitialized: true,
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
label: "目标学员",
|
// label: "目标学员",
|
||||||
icon: <AcademicCapIcon className="w-5 h-5" />,
|
// icon: <AcademicCapIcon className="w-5 h-5" />,
|
||||||
path: `/course/${courseId ? `${courseId}/` : ""}editor/goal`,
|
// path: `/course/${courseId ? `${courseId}/` : ""}editor/goal`,
|
||||||
isInitialized: false,
|
// isInitialized: false,
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
label: "课程内容",
|
label: "课程内容",
|
||||||
icon: <VideoCameraIcon className="w-5 h-5" />,
|
icon: <VideoCameraIcon className="w-5 h-5" />,
|
||||||
path: `/course/${courseId ? `${courseId}/` : ""}editor/content`,
|
path: `/course/${courseId ? `${courseId}/` : ""}editor/content`,
|
||||||
isInitialized: false,
|
isInitialized: false,
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
label: "课程设置",
|
// label: "课程设置",
|
||||||
icon: <Cog6ToothIcon className="w-5 h-5" />,
|
// icon: <Cog6ToothIcon className="w-5 h-5" />,
|
||||||
path: `/course/${courseId ? `${courseId}/` : ""}editor/setting`,
|
// path: `/course/${courseId ? `${courseId}/` : ""}editor/setting`,
|
||||||
isInitialized: false,
|
// isInitialized: false,
|
||||||
},
|
// },
|
||||||
];
|
];
|
||||||
|
|
|
@ -8,9 +8,9 @@ export default function Brightness() {
|
||||||
<>
|
<>
|
||||||
{/* 亮度控制 */}
|
{/* 亮度控制 */}
|
||||||
<div className="relative group flex items-center">
|
<div className="relative group flex items-center">
|
||||||
<button className="text-white hover:text-primaryHover">
|
<div className="text-white hover:text-primaryHover">
|
||||||
<SunIcon className="w-10 h-10" />
|
<SunIcon className="w-10 h-10" />
|
||||||
</button>
|
</div>
|
||||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
<div className="bg-black/80 rounded-lg p-2">
|
<div className="bg-black/80 rounded-lg p-2">
|
||||||
<input
|
<input
|
||||||
|
|
|
@ -9,7 +9,7 @@ export default function FullScreen() {
|
||||||
const { videoRef } = useContext(VideoPlayerContext);
|
const { videoRef } = useContext(VideoPlayerContext);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (document.fullscreenElement) {
|
if (document.fullscreenElement) {
|
||||||
document.exitFullscreen();
|
document.exitFullscreen();
|
||||||
|
@ -23,7 +23,7 @@ export default function FullScreen() {
|
||||||
) : (
|
) : (
|
||||||
<ArrowsPointingOutIcon className="w-10 h-10" />
|
<ArrowsPointingOutIcon className="w-10 h-10" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,22 +4,25 @@ import { PauseIcon, PlayIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
export default function Play() {
|
export default function Play() {
|
||||||
const { isPlaying, videoRef } = useContext(VideoPlayerContext);
|
const { isPlaying, videoRef } = useContext(VideoPlayerContext);
|
||||||
|
const handleClick = (event) => {
|
||||||
|
event.stopPropagation(); // 阻止事件冒泡
|
||||||
|
if (videoRef.current?.paused) {
|
||||||
|
videoRef.current.play();
|
||||||
|
} else {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<div
|
||||||
onClick={() =>
|
onClick={handleClick}
|
||||||
videoRef.current?.paused
|
|
||||||
? videoRef.current.play()
|
|
||||||
: videoRef.current?.pause()
|
|
||||||
}
|
|
||||||
className="text-white hover:text-primaryHover">
|
className="text-white hover:text-primaryHover">
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<PauseIcon className="w-10 h-10" />
|
<PauseIcon className="w-10 h-10" />
|
||||||
) : (
|
) : (
|
||||||
<PlayIcon className="w-10 h-10" />
|
<PlayIcon className="w-10 h-10" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,11 +15,11 @@ export default function Setting() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<button
|
<div
|
||||||
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
||||||
className="text-white hover:text-primaryHover">
|
className="text-white hover:text-primaryHover">
|
||||||
<Cog6ToothIcon className="w-10 h-10" />
|
<Cog6ToothIcon className="w-10 h-10" />
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isSettingsOpen && (
|
{isSettingsOpen && (
|
||||||
|
|
|
@ -14,14 +14,14 @@ export default function Speed() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<button
|
<div
|
||||||
onClick={() => setIsSpeedOpen(!isSpeedOpen)}
|
onClick={() => setIsSpeedOpen(!isSpeedOpen)}
|
||||||
className="text-white hover:text-primaryHover flex items-center">
|
className="text-white hover:text-primaryHover flex items-center">
|
||||||
<span className="text-xl font-bold mr-1">
|
<span className="text-xl font-bold mr-1">
|
||||||
{playbackSpeed === 1.0 ? "倍速" : `${playbackSpeed}x`}
|
{playbackSpeed === 1.0 ? "倍速" : `${playbackSpeed}x`}
|
||||||
</span>
|
</span>
|
||||||
<ChevronUpDownIcon className="w-10 h-10" />
|
<ChevronUpDownIcon className="w-10 h-10" />
|
||||||
</button>
|
</div>
|
||||||
{isSpeedOpen && (
|
{isSpeedOpen && (
|
||||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2">
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2">
|
||||||
<div className="bg-black/80 rounded-lg p-2">
|
<div className="bg-black/80 rounded-lg p-2">
|
||||||
|
|
|
@ -9,7 +9,7 @@ export default function Volume() {
|
||||||
<>
|
<>
|
||||||
{/* 音量控制 */}
|
{/* 音量控制 */}
|
||||||
<div className="group relative flex items-center">
|
<div className="group relative flex items-center">
|
||||||
<button
|
<div
|
||||||
onClick={() => setIsMuted(!isMuted)}
|
onClick={() => setIsMuted(!isMuted)}
|
||||||
className="text-white hover:text-primaryHover">
|
className="text-white hover:text-primaryHover">
|
||||||
{isMuted ? (
|
{isMuted ? (
|
||||||
|
@ -17,7 +17,7 @@ export default function Volume() {
|
||||||
) : (
|
) : (
|
||||||
<SpeakerWaveIcon className="w-10 h-10" />
|
<SpeakerWaveIcon className="w-10 h-10" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
<div className="bg-black/80 rounded-lg p-2">
|
<div className="bg-black/80 rounded-lg p-2">
|
||||||
<input
|
<input
|
||||||
|
|
|
@ -14,6 +14,7 @@ export const VideoDisplay: React.FC<VideoDisplayProps> = ({
|
||||||
onError,
|
onError,
|
||||||
videoRef,
|
videoRef,
|
||||||
setIsReady,
|
setIsReady,
|
||||||
|
isPlaying,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
setError,
|
setError,
|
||||||
setBufferingState,
|
setBufferingState,
|
||||||
|
@ -204,6 +205,7 @@ export const VideoDisplay: React.FC<VideoDisplayProps> = ({
|
||||||
}, [src, onError, autoPlay]);
|
}, [src, onError, autoPlay]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
|
@ -218,5 +220,6 @@ export const VideoDisplay: React.FC<VideoDisplayProps> = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -55,6 +55,7 @@ export function VideoPlayer({
|
||||||
src,
|
src,
|
||||||
poster,
|
poster,
|
||||||
onError,
|
onError,
|
||||||
|
|
||||||
}: {
|
}: {
|
||||||
src: string;
|
src: string;
|
||||||
poster?: string;
|
poster?: string;
|
||||||
|
@ -82,6 +83,10 @@ export function VideoPlayer({
|
||||||
const progressRef = useRef<HTMLDivElement>(null);
|
const progressRef = useRef<HTMLDivElement>(null);
|
||||||
const [isSpeedOpen, setIsSpeedOpen] = useState(false);
|
const [isSpeedOpen, setIsSpeedOpen] = useState(false);
|
||||||
const [isBrightnessOpen, setIsBrightnessOpen] = useState(false);
|
const [isBrightnessOpen, setIsBrightnessOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation(); // 阻止事件向上传递
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<VideoPlayerContext.Provider
|
<VideoPlayerContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
@ -129,7 +134,9 @@ export function VideoPlayer({
|
||||||
resolutions,
|
resolutions,
|
||||||
setResolutions,
|
setResolutions,
|
||||||
}}>
|
}}>
|
||||||
|
<div onClick={handleClick}>
|
||||||
<VideoPlayerLayout></VideoPlayerLayout>
|
<VideoPlayerLayout></VideoPlayerLayout>
|
||||||
|
</div>
|
||||||
</VideoPlayerContext.Provider>
|
</VideoPlayerContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,6 @@ export default function VideoPlayerLayout() {
|
||||||
setIsHovering(true);
|
setIsHovering(true);
|
||||||
setShowControls(true);
|
setShowControls(true);
|
||||||
}}>
|
}}>
|
||||||
{!isReady && <div>123</div>}
|
|
||||||
{!isReady && <LoadingOverlay></LoadingOverlay>}
|
{!isReady && <LoadingOverlay></LoadingOverlay>}
|
||||||
<VideoDisplay></VideoDisplay>
|
<VideoDisplay></VideoDisplay>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
|
@ -6,15 +6,46 @@
|
||||||
border-top-left-radius: 8px;
|
border-top-left-radius: 8px;
|
||||||
border-top-right-radius: 8px;
|
border-top-right-radius: 8px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
border: none
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quill-editor-container .ql-container {
|
.quill-editor-container .ql-container {
|
||||||
border-bottom-left-radius: 8px;
|
border-bottom-left-radius: 8px;
|
||||||
border-bottom-right-radius: 8px;
|
border-bottom-right-radius: 8px;
|
||||||
border: none
|
border: none;
|
||||||
|
}
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
|
||||||
|
content: "标题 1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
|
||||||
|
content: "标题 2";
|
||||||
|
}
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
|
||||||
|
content: "标题 3";
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
|
||||||
|
content: "标题 4";
|
||||||
|
}
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
|
||||||
|
content: "标题 5";
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
|
||||||
|
content: "标题 6";
|
||||||
|
}
|
||||||
|
/* 针对下拉菜单中的选项 */
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item:not([data-value])::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label:not([data-value])::before {
|
||||||
|
content: "正文" !important;
|
||||||
|
}
|
||||||
.ag-custom-dragging-class {
|
.ag-custom-dragging-class {
|
||||||
@apply border-b-2 border-blue-200;
|
@apply border-b-2 border-blue-200;
|
||||||
}
|
}
|
||||||
|
@ -45,11 +76,11 @@
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-thead>tr>th {
|
.ant-table-thead > tr > th {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-tbody>tr>td {
|
.ant-table-tbody > tr > td {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
border-bottom-color: transparent !important;
|
border-bottom-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
@ -86,7 +117,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 覆盖 Ant Design 的默认样式 raido button左侧的按钮*/
|
/* 覆盖 Ant Design 的默认样式 raido button左侧的按钮*/
|
||||||
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled)::before {
|
.ant-radio-button-wrapper-checked:not(
|
||||||
|
.ant-radio-button-wrapper-disabled
|
||||||
|
)::before {
|
||||||
background-color: unset !important;
|
background-color: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,7 +132,7 @@
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-wrap-header .ant-table-thead>tr>th {
|
.no-wrap-header .ant-table-thead > tr > th {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,12 +148,12 @@
|
||||||
/* 设置单元格边框 */
|
/* 设置单元格边框 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-table .ant-table-tbody>tr>td {
|
.custom-table .ant-table-tbody > tr > td {
|
||||||
border-bottom: 1px solid #ddd;
|
border-bottom: 1px solid #ddd;
|
||||||
/* 设置表格行底部边框 */
|
/* 设置表格行底部边框 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-table .ant-table-tbody>tr:last-child>td {
|
.custom-table .ant-table-tbody > tr:last-child > td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
/* 去除最后一行的底部边框 */
|
/* 去除最后一行的底部边框 */
|
||||||
}
|
}
|
||||||
|
@ -129,4 +162,3 @@
|
||||||
height: 600px;
|
height: 600px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ export const uploader = async (
|
||||||
maxSizeMB: 0.8, // 最大文件大小(MB)
|
maxSizeMB: 0.8, // 最大文件大小(MB)
|
||||||
maxWidthOrHeight: 1920, // 最大宽高
|
maxWidthOrHeight: 1920, // 最大宽高
|
||||||
useWebWorker: true,
|
useWebWorker: true,
|
||||||
fileType: "image/webp", // 输出文件格式
|
filetype: "image/webp", // 输出文件格式
|
||||||
};
|
};
|
||||||
const compressedFile = await imageCompression(file, options);
|
const compressedFile = await imageCompression(file, options);
|
||||||
return new File([compressedFile], `${file.name.split(".")[0]}.webp`, {
|
return new File([compressedFile], `${file.name.split(".")[0]}.webp`, {
|
||||||
|
|
|
@ -13,8 +13,6 @@ import HomePage from "../app/main/home/page";
|
||||||
import { CourseDetailPage } from "../app/main/course/detail/page";
|
import { CourseDetailPage } from "../app/main/course/detail/page";
|
||||||
import { CourseBasicForm } from "../components/models/course/editor/form/CourseBasicForm";
|
import { CourseBasicForm } from "../components/models/course/editor/form/CourseBasicForm";
|
||||||
import CourseContentForm from "../components/models/course/editor/form/CourseContentForm/CourseContentForm";
|
import CourseContentForm from "../components/models/course/editor/form/CourseContentForm/CourseContentForm";
|
||||||
import { CourseGoalForm } from "../components/models/course/editor/form/CourseGoalForm";
|
|
||||||
import CourseSettingForm from "../components/models/course/editor/form/CourseSettingForm";
|
|
||||||
import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout";
|
import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout";
|
||||||
import { MainLayout } from "../app/main/layout/MainLayout";
|
import { MainLayout } from "../app/main/layout/MainLayout";
|
||||||
import CoursesPage from "../app/main/courses/page";
|
import CoursesPage from "../app/main/courses/page";
|
||||||
|
@ -112,22 +110,22 @@ export const routes: CustomRouteObject[] = [
|
||||||
index: true,
|
index: true,
|
||||||
element: <CourseBasicForm></CourseBasicForm>,
|
element: <CourseBasicForm></CourseBasicForm>,
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
path: "goal",
|
// path: "goal",
|
||||||
element: <CourseGoalForm></CourseGoalForm>,
|
// element: <CourseGoalForm></CourseGoalForm>,
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
path: "content",
|
path: "content",
|
||||||
element: (
|
element: (
|
||||||
<CourseContentForm></CourseContentForm>
|
<CourseContentForm></CourseContentForm>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
path: "setting",
|
// path: "setting",
|
||||||
element: (
|
// element: (
|
||||||
<CourseSettingForm></CourseSettingForm>
|
// <CourseSettingForm></CourseSettingForm>
|
||||||
),
|
// ),
|
||||||
},
|
// },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -100,7 +100,7 @@ server {
|
||||||
# 仅供内部使用
|
# 仅供内部使用
|
||||||
internal;
|
internal;
|
||||||
# 代理到认证服务
|
# 代理到认证服务
|
||||||
proxy_pass http://${SERVER_IP}:3000/auth/file;
|
proxy_pass http://${SERVER_IP}:${SERVER_PORT}/auth/file;
|
||||||
# 请求优化:不传递请求体
|
# 请求优化:不传递请求体
|
||||||
proxy_pass_request_body off;
|
proxy_pass_request_body off;
|
||||||
proxy_set_header Content-Length "";
|
proxy_set_header Content-Length "";
|
||||||
|
|
|
@ -5,7 +5,7 @@ for template in /etc/nginx/conf.d/*.template; do
|
||||||
# 将输出文件名改为 .conf 结尾
|
# 将输出文件名改为 .conf 结尾
|
||||||
conf="${template%.template}.conf"
|
conf="${template%.template}.conf"
|
||||||
echo "Processing $template"
|
echo "Processing $template"
|
||||||
if envsubst '$SERVER_IP' < "$template" > "$conf"; then
|
if envsubst '$SERVER_IP $SERVER_PORT' < "$template" > "$conf"; then
|
||||||
echo "Replaced $conf successfully"
|
echo "Replaced $conf successfully"
|
||||||
else
|
else
|
||||||
echo "Failed to replace $conf"
|
echo "Failed to replace $conf"
|
||||||
|
|
|
@ -43,8 +43,11 @@ export type PostDto = Post & {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LectureMeta = {
|
export type LectureMeta = {
|
||||||
|
type?: string;
|
||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
videoThumbnail?: string;
|
videoThumbnail?: string;
|
||||||
|
videoIds?: string[];
|
||||||
|
videoThumbnailIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Lecture = Post & {
|
export type Lecture = Post & {
|
||||||
|
@ -62,7 +65,7 @@ export type SectionDto = Section & {
|
||||||
};
|
};
|
||||||
export type CourseMeta = {
|
export type CourseMeta = {
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
requirements?: string[];
|
|
||||||
objectives?: string[];
|
objectives?: string[];
|
||||||
};
|
};
|
||||||
export type Course = Post & {
|
export type Course = Post & {
|
||||||
|
|
Loading…
Reference in New Issue