This commit is contained in:
Rao 2025-02-24 08:53:46 +08:00
commit ea21d9e3f4
64 changed files with 931 additions and 1012 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,9 +4,10 @@ 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(
@ -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);
}), }),
}); });
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
{/* 课程大纲侧边栏 */} {/* 课程大纲侧边栏 */}

View File

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

View File

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

View File

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

View File

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

View File

@ -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="请输入课程描述"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
} }
@ -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;
} }
@ -129,4 +162,3 @@
height: 600px; height: 600px;
width: 100%; width: 100%;
} }

View File

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

View File

@ -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>
), // ),
}, // },
], ],
}, },
{ {

View File

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

View File

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

View File

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