diff --git a/.gitignore b/.gitignore index 7d4585e..2e6bac7 100755 --- a/.gitignore +++ b/.gitignore @@ -67,5 +67,6 @@ yarn-error.log* # Ignore .idea files in the Expo monorepo **/.idea/ - -uploads \ No newline at end of file +uploads +packages/mind-elixir-core +config/nginx/conf.d/web.conf \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a2c76e4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "marscode.chatLanguage": "cn" +} \ No newline at end of file diff --git a/apps/server/src/auth/auth.service.ts b/apps/server/src/auth/auth.service.ts index f7f679c..49d25c1 100755 --- a/apps/server/src/auth/auth.service.ts +++ b/apps/server/src/auth/auth.service.ts @@ -32,7 +32,7 @@ export class AuthService { return { isValid: false, error: FileValidationErrorType.INVALID_URI }; } const fileId = extractFileIdFromNginxUrl(params.originalUri); - console.log(params.originalUri, fileId); + // console.log(params.originalUri, fileId); const resource = await db.resource.findFirst({ where: { fileId } }); // 资源验证 diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 5e8cd9a..ef8968d 100755 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -155,6 +155,7 @@ export class UserProfileService { where: { id }, select: { id: true, + avatar: true, deptId: true, department: true, domainId: true, diff --git a/apps/server/src/models/app-config/app-config.router.ts b/apps/server/src/models/app-config/app-config.router.ts index ece1b35..350d047 100755 --- a/apps/server/src/models/app-config/app-config.router.ts +++ b/apps/server/src/models/app-config/app-config.router.ts @@ -4,44 +4,48 @@ import { AppConfigService } from './app-config.service'; import { z, ZodType } from 'zod'; import { Prisma } from '@nice/common'; import { RealtimeServer } from '@server/socket/realtime/realtime.server'; -const AppConfigUncheckedCreateInputSchema: ZodType = z.any() -const AppConfigUpdateArgsSchema: ZodType = z.any() -const AppConfigDeleteManyArgsSchema: ZodType = z.any() -const AppConfigFindFirstArgsSchema: ZodType = z.any() +const AppConfigUncheckedCreateInputSchema: ZodType = + z.any(); +const AppConfigUpdateArgsSchema: ZodType = z.any(); +const AppConfigDeleteManyArgsSchema: ZodType = + z.any(); +const AppConfigFindFirstArgsSchema: ZodType = + z.any(); @Injectable() export class AppConfigRouter { - constructor( - private readonly trpc: TrpcService, - private readonly appConfigService: AppConfigService, - private readonly realtimeServer: RealtimeServer - ) { } - router = this.trpc.router({ - create: this.trpc.protectProcedure - .input(AppConfigUncheckedCreateInputSchema) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.appConfigService.create({ data: input }); - }), - update: this.trpc.protectProcedure - .input(AppConfigUpdateArgsSchema) - .mutation(async ({ ctx, input }) => { - - const { staff } = ctx; - return await this.appConfigService.update(input); - }), - deleteMany: this.trpc.protectProcedure.input(AppConfigDeleteManyArgsSchema).mutation(async ({ input }) => { - return await this.appConfigService.deleteMany(input) - }), - findFirst: this.trpc.protectProcedure.input(AppConfigFindFirstArgsSchema). - query(async ({ input }) => { - - return await this.appConfigService.findFirst(input) - }), - clearRowCache: this.trpc.protectProcedure.mutation(async () => { - return await this.appConfigService.clearRowCache() - }), - getClientCount: this.trpc.protectProcedure.query(() => { - return this.realtimeServer.getClientCount() - }) - }); + constructor( + private readonly trpc: TrpcService, + private readonly appConfigService: AppConfigService, + private readonly realtimeServer: RealtimeServer, + ) {} + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(AppConfigUncheckedCreateInputSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.appConfigService.create({ data: input }); + }), + update: this.trpc.protectProcedure + .input(AppConfigUpdateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.appConfigService.update(input); + }), + deleteMany: this.trpc.protectProcedure + .input(AppConfigDeleteManyArgsSchema) + .mutation(async ({ input }) => { + return await this.appConfigService.deleteMany(input); + }), + findFirst: this.trpc.procedure + .input(AppConfigFindFirstArgsSchema) + .query(async ({ input }) => { + return await this.appConfigService.findFirst(input); + }), + clearRowCache: this.trpc.protectProcedure.mutation(async () => { + return await this.appConfigService.clearRowCache(); + }), + getClientCount: this.trpc.protectProcedure.query(() => { + return this.realtimeServer.getClientCount(); + }), + }); } diff --git a/apps/server/src/models/app-config/app-config.service.ts b/apps/server/src/models/app-config/app-config.service.ts index 733e620..bd003d7 100755 --- a/apps/server/src/models/app-config/app-config.service.ts +++ b/apps/server/src/models/app-config/app-config.service.ts @@ -1,10 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { - db, - ObjectType, - Prisma, -} from '@nice/common'; - +import { db, ObjectType, Prisma } from '@nice/common'; import { BaseService } from '../base/base.service'; import { deleteByPattern } from '@server/utils/redis/utils'; @@ -12,10 +7,10 @@ import { deleteByPattern } from '@server/utils/redis/utils'; @Injectable() export class AppConfigService extends BaseService { constructor() { - super(db, "appConfig"); + super(db, 'appConfig'); } async clearRowCache() { - await deleteByPattern("row-*") - return true + await deleteByPattern('row-*'); + return true; } } diff --git a/apps/server/src/models/base/base.service.ts b/apps/server/src/models/base/base.service.ts index d1b8b16..ae45e06 100755 --- a/apps/server/src/models/base/base.service.ts +++ b/apps/server/src/models/base/base.service.ts @@ -8,6 +8,7 @@ import { DelegateFuncs, UpdateOrderArgs, TransactionType, + OrderByArgs, SelectArgs, } from './base.type'; import { @@ -450,9 +451,10 @@ export class BaseService< page?: number; pageSize?: number; where?: WhereArgs; + orderBy?: OrderByArgs; select?: SelectArgs; }): Promise<{ items: R['findMany']; totalPages: number }> { - const { page = 1, pageSize = 10, where, select } = args; + const { page = 1, pageSize = 10, where, select, orderBy } = args; try { // 获取总记录数 @@ -461,6 +463,7 @@ export class BaseService< const items = (await this.getModel().findMany({ where, select, + orderBy, skip: (page - 1) * pageSize, take: pageSize, } as any)) as R['findMany']; diff --git a/apps/server/src/models/base/base.tree.service.ts b/apps/server/src/models/base/base.tree.service.ts index f62aecc..aa0e643 100755 --- a/apps/server/src/models/base/base.tree.service.ts +++ b/apps/server/src/models/base/base.tree.service.ts @@ -139,7 +139,7 @@ export class BaseTreeService< 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({ where: { descendantId: result.id }, }); diff --git a/apps/server/src/models/base/base.type.ts b/apps/server/src/models/base/base.type.ts index 878dffe..01254df 100755 --- a/apps/server/src/models/base/base.type.ts +++ b/apps/server/src/models/base/base.type.ts @@ -1,25 +1,27 @@ -import { db, Prisma, PrismaClient } from "@nice/common"; +import { db, Prisma, PrismaClient } from '@nice/common'; export type Operations = - | 'aggregate' - | 'count' - | 'create' - | 'createMany' - | 'delete' - | 'deleteMany' - | 'findFirst' - | 'findMany' - | 'findUnique' - | 'update' - | 'updateMany' - | 'upsert'; -export type DelegateFuncs = { [K in Operations]: (args: any) => Promise } + | 'aggregate' + | 'count' + | 'create' + | 'createMany' + | 'delete' + | 'deleteMany' + | 'findFirst' + | 'findMany' + | 'findUnique' + | 'update' + | 'updateMany' + | 'upsert'; +export type DelegateFuncs = { + [K in Operations]: (args: any) => Promise; +}; export type DelegateArgs = { - [K in keyof T]: T[K] extends (args: infer A) => Promise ? A : never; + [K in keyof T]: T[K] extends (args: infer A) => Promise ? A : never; }; export type DelegateReturnTypes = { - [K in keyof T]: T[K] extends (args: any) => Promise ? R : never; + [K in keyof T]: T[K] extends (args: any) => Promise ? R : never; }; export type WhereArgs = T extends { where?: infer W } ? W : never; @@ -28,17 +30,17 @@ export type DataArgs = T extends { data: infer D } ? D : never; export type IncludeArgs = T extends { include: infer I } ? I : never; export type OrderByArgs = T extends { orderBy: infer O } ? O : never; export type UpdateOrderArgs = { - id: string - overId: string -} + id: string; + overId: string; +}; export interface FindManyWithCursorType { - cursor?: string; - limit?: number; - where?: WhereArgs['findUnique']>; - select?: SelectArgs['findUnique']>; - orderBy?: OrderByArgs['findMany']> + cursor?: string; + limit?: number; + where?: WhereArgs['findUnique']>; + select?: SelectArgs['findUnique']>; + orderBy?: OrderByArgs['findMany']>; } export type TransactionType = Omit< - PrismaClient, - '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' ->; \ No newline at end of file + PrismaClient, + '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' +>; diff --git a/apps/server/src/models/course/course.module.ts b/apps/server/src/models/course/course.module.ts deleted file mode 100755 index 8438e80..0000000 --- a/apps/server/src/models/course/course.module.ts +++ /dev/null @@ -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 { } diff --git a/apps/server/src/models/course/course.router.ts b/apps/server/src/models/course/course.router.ts deleted file mode 100755 index fe9047f..0000000 --- a/apps/server/src/models/course/course.router.ts +++ /dev/null @@ -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 = z.any(); -// // const CourseUpdateArgsSchema: ZodType = z.any(); -// // const CourseCreateManyInputSchema: ZodType = -// // z.any(); -// // const CourseDeleteManyArgsSchema: ZodType = -// // z.any(); -// // const CourseFindManyArgsSchema: ZodType = z.any(); -// // const CourseFindFirstArgsSchema: ZodType = z.any(); -// // const CourseWhereInputSchema: ZodType = z.any(); -// // const CourseSelectSchema: ZodType = 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); -// // }), -// }); -// } diff --git a/apps/server/src/models/course/course.schema.ts b/apps/server/src/models/course/course.schema.ts deleted file mode 100755 index e69de29..0000000 diff --git a/apps/server/src/models/course/course.service.ts b/apps/server/src/models/course/course.service.ts deleted file mode 100755 index 55b1038..0000000 --- a/apps/server/src/models/course/course.service.ts +++ /dev/null @@ -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 { -// 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' }, -// }); -// } -// } diff --git a/apps/server/src/models/course/utils.ts b/apps/server/src/models/course/utils.ts deleted file mode 100755 index aef7966..0000000 --- a/apps/server/src/models/course/utils.ts +++ /dev/null @@ -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, -// }, -// }); -// } diff --git a/apps/server/src/models/lecture/lecture.module.ts b/apps/server/src/models/lecture/lecture.module.ts deleted file mode 100755 index 71f6573..0000000 --- a/apps/server/src/models/lecture/lecture.module.ts +++ /dev/null @@ -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 { } diff --git a/apps/server/src/models/lecture/lecture.router.ts b/apps/server/src/models/lecture/lecture.router.ts deleted file mode 100755 index 832c8a6..0000000 --- a/apps/server/src/models/lecture/lecture.router.ts +++ /dev/null @@ -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 = z.any() -// const LectureCreateManyInputSchema: ZodType = z.any() -// const LectureDeleteManyArgsSchema: ZodType = z.any() -// const LectureFindManyArgsSchema: ZodType = z.any() -// const LectureFindFirstArgsSchema: ZodType = z.any() -// const LectureWhereInputSchema: ZodType = z.any() -// const LectureSelectSchema: ZodType = 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); -// }), -// }); -// } diff --git a/apps/server/src/models/lecture/lecture.service.ts b/apps/server/src/models/lecture/lecture.service.ts deleted file mode 100755 index 4eb7250..0000000 --- a/apps/server/src/models/lecture/lecture.service.ts +++ /dev/null @@ -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 { -// 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; -// } - -// } diff --git a/apps/server/src/models/lecture/utils.ts b/apps/server/src/models/lecture/utils.ts deleted file mode 100755 index dc0c823..0000000 --- a/apps/server/src/models/lecture/utils.ts +++ /dev/null @@ -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, -// }, -// }); -// } diff --git a/apps/server/src/models/post/post.router.ts b/apps/server/src/models/post/post.router.ts index 458564d..bcde094 100755 --- a/apps/server/src/models/post/post.router.ts +++ b/apps/server/src/models/post/post.router.ts @@ -3,8 +3,10 @@ import { TrpcService } from '@server/trpc/trpc.service'; import { CourseMethodSchema, Prisma } from '@nice/common'; import { PostService } from './post.service'; import { z, ZodType } from 'zod'; +import { UpdateOrderArgs } from '../base/base.type'; const PostCreateArgsSchema: ZodType = z.any(); const PostUpdateArgsSchema: ZodType = z.any(); +const PostUpdateOrderArgsSchema: ZodType = z.any(); const PostFindFirstArgsSchema: ZodType = z.any(); const PostFindManyArgsSchema: ZodType = z.any(); const PostDeleteManyArgsSchema: ZodType = z.any(); @@ -56,13 +58,13 @@ export class PostRouter { const { staff } = ctx; return await this.postService.update(input, staff); }), - findById: this.trpc.protectProcedure + findById: this.trpc.procedure .input(z.object({ id: z.string(), args: PostFindFirstArgsSchema })) .query(async ({ ctx, input }) => { const { staff } = ctx; return await this.postService.findById(input.id, input.args); }), - findMany: this.trpc.protectProcedure + findMany: this.trpc.procedure .input(PostFindManyArgsSchema) .query(async ({ ctx, input }) => { const { staff } = ctx; @@ -82,7 +84,7 @@ export class PostRouter { .mutation(async ({ input }) => { return await this.postService.deleteMany(input); }), - findManyWithCursor: this.trpc.protectProcedure + findManyWithCursor: this.trpc.procedure .input( z.object({ cursor: z.any().nullish(), @@ -107,5 +109,21 @@ export class PostRouter { .query(async ({ input }) => { return await this.postService.findManyWithPagination(input); }), + updateOrder: this.trpc.protectProcedure + .input(PostUpdateOrderArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.postService.updateOrder(input); + }), + updateOrderByIds: this.trpc.protectProcedure + .input( + z.object({ + ids: z.array(z.string()), + }), + ) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.postService.updateOrderByIds(input.ids); + }), }); } diff --git a/apps/server/src/models/post/post.service.ts b/apps/server/src/models/post/post.service.ts index df99714..cbcf7bc 100755 --- a/apps/server/src/models/post/post.service.ts +++ b/apps/server/src/models/post/post.service.ts @@ -19,6 +19,9 @@ import { setCourseInfo, setPostRelation } from './utils'; import EventBus, { CrudOperation } from '@server/utils/event-bus'; import { BaseTreeService } from '../base/base.tree.service'; import { z } from 'zod'; +import { DefaultArgs } from '@prisma/client/runtime/library'; +import dayjs from 'dayjs'; +import { OrderByArgs } from '../base/base.type'; @Injectable() export class PostService extends BaseTreeService { @@ -42,13 +45,14 @@ export class PostService extends BaseTreeService { content: content, title: title, authorId: params?.staff?.id, + updatedAt: dayjs().toDate(), resources: { connect: resourceIds.map((fileId) => ({ fileId })), }, meta: { type: type, }, - }, + } as any, }, { tx }, ); @@ -70,7 +74,8 @@ export class PostService extends BaseTreeService { parentId: courseId, title: title, authorId: staff?.id, - }, + updatedAt: dayjs().toDate(), + } as any, }, { tx }, ); @@ -94,56 +99,23 @@ export class PostService extends BaseTreeService { async createCourse( args: { courseDetail: Prisma.PostCreateArgs; - sections?: z.infer[]; }, params: { staff?: UserProfile; tx?: Prisma.TransactionClient }, ) { - const { courseDetail, sections } = args; + const { courseDetail } = args; // If no transaction is provided, create a new one if (!params.tx) { return await db.$transaction(async (tx) => { const courseParams = { ...params, tx }; // Create the course first - console.log(courseParams?.staff?.id); - console.log('courseDetail', courseDetail); const createdCourse = await this.create(courseDetail, courseParams); // If sections are provided, create them - if (sections && sections.length > 0) { - const sectionPromises = sections.map((section) => - this.createSection( - { - courseId: createdCourse.id, - title: section.title, - lectures: section.lectures, - }, - courseParams, - ), - ); - // Create all sections (and their lectures) in parallel - await Promise.all(sectionPromises); - } return createdCourse; }); } // If transaction is provided, use it directly - console.log('courseDetail', courseDetail); const createdCourse = await this.create(courseDetail, params); // If sections are provided, create them - if (sections && sections.length > 0) { - const sectionPromises = sections.map((section) => - this.createSection( - { - courseId: createdCourse.id, - title: section.title, - lectures: section.lectures, - }, - params, - ), - ); - // Create all sections (and their lectures) in parallel - await Promise.all(sectionPromises); - } - return createdCourse; } async create( @@ -151,6 +123,8 @@ export class PostService extends BaseTreeService { params?: { staff?: UserProfile; tx?: Prisma.TransactionClient }, ) { args.data.authorId = params?.staff?.id; + args.data.updatedAt = dayjs().toDate(); + const result = await super.create(args); EventBus.emit('dataChanged', { type: ObjectType.POST, @@ -161,6 +135,7 @@ export class PostService extends BaseTreeService { } async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) { args.data.authorId = staff?.id; + args.data.updatedAt = dayjs().toDate(); const result = await super.update(args); EventBus.emit('dataChanged', { type: ObjectType.POST, @@ -182,25 +157,13 @@ export class PostService extends BaseTreeService { await this.setPerms(result, staff); await setCourseInfo({ data: result }); } - + // console.log(result); return result; }, ); return transDto; } - // async findMany(args: Prisma.PostFindManyArgs, staff?: UserProfile) { - // if (!args.where) args.where = {}; - // args.where.OR = await this.preFilter(args.where.OR, staff); - // return this.wrapResult(super.findMany(args), async (result) => { - // await Promise.all( - // result.map(async (item) => { - // await setPostRelation({ data: item, staff }); - // await this.setPerms(item, staff); - // }), - // ); - // return { ...result }; - // }); - // } + async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) { if (!args.where) args.where = {}; args.where.OR = await this.preFilter(args.where.OR, staff); @@ -215,6 +178,72 @@ export class PostService extends BaseTreeService { return { ...result, items }; }); } + async findManyWithPagination(args: { + page?: number; + pageSize?: number; + where?: Prisma.PostWhereInput; + orderBy?: OrderByArgs<(typeof db.post)['findMany']>; + select?: Prisma.PostSelect; + }): Promise<{ + items: { + id: string; + type: string | null; + level: string | null; + state: string | null; + title: string | null; + subTitle: string | null; + content: string | null; + important: boolean | null; + domainId: string | null; + order: number | null; + duration: number | null; + rating: number | null; + createdAt: Date; + views: number; + hates: number; + likes: number; + publishedAt: Date | null; + updatedAt: Date; + deletedAt: Date | null; + authorId: string | null; + parentId: string | null; + hasChildren: boolean | null; + meta: Prisma.JsonValue | null; + }[]; + totalPages: number; + }> { + // super.updateOrder; + return super.findManyWithPagination(args); + } + + async updateOrderByIds(ids: string[]) { + const posts = await db.post.findMany({ + where: { id: { in: ids } }, + select: { id: true, order: true }, + }); + const postMap = new Map(posts.map((post) => [post.id, post])); + const orderedPosts = ids + .map((id) => postMap.get(id)) + .filter((post): post is { id: string; order: number } => !!post); + + // 生成仅需更新的操作 + const updates = orderedPosts + .map((post, index) => ({ + id: post.id, + newOrder: index, // 按数组索引设置新顺序 + currentOrder: post.order, + })) + .filter(({ newOrder, currentOrder }) => newOrder !== currentOrder) + .map(({ id, newOrder }) => + db.post.update({ + where: { id }, + data: { order: newOrder }, + }), + ); + + // 批量执行更新 + return updates.length > 0 ? await db.$transaction(updates) : []; + } protected async setPerms(data: Post, staff?: UserProfile) { if (!staff) return; @@ -267,37 +296,37 @@ export class PostService extends BaseTreeService { staff?.id && { authorId: staff.id, }, - staff?.id && { - watchableStaffs: { - some: { - id: staff.id, - }, - }, - }, - deptId && { - watchableDepts: { - some: { - id: { - in: parentDeptIds, - }, - }, - }, - }, + // staff?.id && { + // watchableStaffs: { + // some: { + // id: staff.id, + // }, + // }, + // }, + // deptId && { + // watchableDepts: { + // some: { + // id: { + // in: parentDeptIds, + // }, + // }, + // }, + // }, - { - AND: [ - { - watchableStaffs: { - none: {}, // 匹配 watchableStaffs 为空 - }, - }, - { - watchableDepts: { - none: {}, // 匹配 watchableDepts 为空 - }, - }, - ], - }, + // { + // AND: [ + // { + // watchableStaffs: { + // none: {}, // 匹配 watchableStaffs 为空 + // }, + // }, + // { + // watchableDepts: { + // none: {}, // 匹配 watchableDepts 为空 + // }, + // }, + // ], + // }, ].filter(Boolean); if (orCondition?.length > 0) return orCondition; diff --git a/apps/server/src/models/post/utils.ts b/apps/server/src/models/post/utils.ts index 9185a0f..57b0fd0 100755 --- a/apps/server/src/models/post/utils.ts +++ b/apps/server/src/models/post/utils.ts @@ -1,6 +1,7 @@ import { db, EnrollmentStatus, + Lecture, Post, PostType, SectionDto, @@ -127,7 +128,6 @@ export async function updateCourseEnrollmentStats(courseId: string) { export async function setCourseInfo({ data }: { data: Post }) { // await db.term - if (data?.type === PostType.COURSE) { const ancestries = await db.postAncestry.findMany({ where: { @@ -137,30 +137,52 @@ export async function setCourseInfo({ data }: { data: Post }) { id: true, descendant: true, }, + orderBy: { + descendant: { + order: 'asc', + }, + }, }); const descendants = ancestries.map((ancestry) => ancestry.descendant); - const sections: SectionDto[] = descendants - .filter((descendant) => { + const sections: SectionDto[] = ( + descendants.filter((descendant) => { return ( descendant.type === PostType.SECTION && descendant.parentId === data.id ); - }) - .map((section) => ({ - ...section, - lectures: [], - })); + }) as any + ).map((section) => ({ + ...section, + lectures: [], + })); const lectures = descendants.filter((descendant) => { return ( descendant.type === PostType.LECTURE && sections.map((section) => section.id).includes(descendant.parentId) ); }); + + const lectureCount = lectures?.length || 0; sections.forEach((section) => { section.lectures = lectures.filter( (lecture) => lecture.parentId === section.id, - ); + ) as any as Lecture[]; }); - Object.assign(data, { sections }); + + const students = await db.staff.findMany({ + where: { + learningPosts: { + some: { + id: data.id, + }, + }, + }, + select: { + id: true, + }, + }); + + const studentIds = (students || []).map((student) => student?.id); + Object.assign(data, { sections, lectureCount, studentIds }); } } diff --git a/apps/server/src/models/section/section.module.ts b/apps/server/src/models/section/section.module.ts deleted file mode 100755 index a337cb6..0000000 --- a/apps/server/src/models/section/section.module.ts +++ /dev/null @@ -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 { } diff --git a/apps/server/src/models/section/section.router.ts b/apps/server/src/models/section/section.router.ts deleted file mode 100755 index 2658567..0000000 --- a/apps/server/src/models/section/section.router.ts +++ /dev/null @@ -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 = z.any() -// const SectionCreateManyInputSchema: ZodType = z.any() -// const SectionDeleteManyArgsSchema: ZodType = z.any() -// const SectionFindManyArgsSchema: ZodType = z.any() -// const SectionFindFirstArgsSchema: ZodType = z.any() -// const SectionWhereInputSchema: ZodType = z.any() -// const SectionSelectSchema: ZodType = 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); -// }), -// }); -// } diff --git a/apps/server/src/models/section/section.service.ts b/apps/server/src/models/section/section.service.ts deleted file mode 100755 index 177cf4d..0000000 --- a/apps/server/src/models/section/section.service.ts +++ /dev/null @@ -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 { -// 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); -// } - -// } diff --git a/apps/server/src/models/term/term.router.ts b/apps/server/src/models/term/term.router.ts index 43afff5..1b15a3d 100755 --- a/apps/server/src/models/term/term.router.ts +++ b/apps/server/src/models/term/term.router.ts @@ -61,7 +61,7 @@ export class TermRouter { .input(TermMethodSchema.getSimpleTree) .query(async ({ input, ctx }) => { const { staff } = ctx; - return await this.termService.getChildSimpleTree(staff, input); + return await this.termService.getChildSimpleTree(input, staff); }), getParentSimpleTree: this.trpc.procedure .input(TermMethodSchema.getSimpleTree) @@ -69,7 +69,7 @@ export class TermRouter { const { staff } = ctx; return await this.termService.getParentSimpleTree(staff, input); }), - getTreeData: this.trpc.protectProcedure + getTreeData: this.trpc.procedure .input(TermMethodSchema.getTreeData) .query(async ({ input }) => { return await this.termService.getTreeData(input); diff --git a/apps/server/src/models/term/term.service.ts b/apps/server/src/models/term/term.service.ts index cc119e6..7cbcc5f 100755 --- a/apps/server/src/models/term/term.service.ts +++ b/apps/server/src/models/term/term.service.ts @@ -269,10 +269,10 @@ export class TermService extends BaseTreeService { } async getChildSimpleTree( - staff: UserProfile, data: z.infer, + staff?: UserProfile, ) { - const { domainId = null, permissions } = staff; + const domainId = staff?.domainId || null; const hasAnyPerms = staff?.permissions?.includes(RolePerms.MANAGE_ANY_TERM) || staff?.permissions?.includes(RolePerms.READ_ANY_TERM); @@ -298,12 +298,12 @@ export class TermService extends BaseTreeService { ...(hasAnyPerms ? {} // 当有全局权限时,不添加任何额外条件 : { - // 当无全局权限时,添加域ID过滤 - OR: [ - { domainId: null }, // 通用记录 - { domainId: domainId }, // 特定域记录 - ], - }), + // 当无全局权限时,添加域ID过滤 + OR: [ + { domainId: null }, // 通用记录 + { domainId: domainId }, // 特定域记录 + ], + }), }, ancestorId: parentId, relDepth: 1, @@ -315,29 +315,29 @@ export class TermService extends BaseTreeService { }), termIds ? db.term.findMany({ - where: { - ...(termIds && { - OR: [ - ...(validTermIds.length - ? [{ id: { in: validTermIds } }] - : []), - ], - }), - taxonomyId: taxonomyId, - // 动态权限控制条件 - ...(hasAnyPerms - ? {} // 当有全局权限时,不添加任何额外条件 - : { - // 当无全局权限时,添加域ID过滤 + where: { + ...(termIds && { OR: [ - { domainId: null }, // 通用记录 - { domainId: domainId }, // 特定域记录 + ...(validTermIds.length + ? [{ id: { in: validTermIds } }] + : []), ], }), - }, - include: { children: true }, - orderBy: { order: 'asc' }, - }) + taxonomyId: taxonomyId, + // 动态权限控制条件 + ...(hasAnyPerms + ? {} // 当有全局权限时,不添加任何额外条件 + : { + // 当无全局权限时,添加域ID过滤 + OR: [ + { domainId: null }, // 通用记录 + { domainId: domainId }, // 特定域记录 + ], + }), + }, + include: { children: true }, + orderBy: { order: 'asc' }, + }) : [], ]); const children = childrenData @@ -352,7 +352,9 @@ export class TermService extends BaseTreeService { staff: UserProfile, data: z.infer, ) { - const { domainId = null, permissions } = staff; + // const { domainId = null, permissions } = staff; + const permissions = staff?.permissions || []; + const domainId = staff?.domainId || null; const hasAnyPerms = permissions.includes(RolePerms.READ_ANY_TERM) || permissions.includes(RolePerms.MANAGE_ANY_TERM); @@ -371,12 +373,12 @@ export class TermService extends BaseTreeService { ...(hasAnyPerms ? {} // 当有全局权限时,不添加任何额外条件 : { - // 当无全局权限时,添加域ID过滤 - OR: [ - { domainId: null }, // 通用记录 - { domainId: domainId }, // 特定域记录 - ], - }), + // 当无全局权限时,添加域ID过滤 + OR: [ + { domainId: null }, // 通用记录 + { domainId: domainId }, // 特定域记录 + ], + }), }, }, include: { @@ -398,12 +400,12 @@ export class TermService extends BaseTreeService { ...(hasAnyPerms ? {} // 当有全局权限时,不添加任何额外条件 : { - // 当无全局权限时,添加域ID过滤 - OR: [ - { domainId: null }, // 通用记录 - { domainId: domainId }, // 特定域记录 - ], - }), + // 当无全局权限时,添加域ID过滤 + OR: [ + { domainId: null }, // 通用记录 + { domainId: domainId }, // 特定域记录 + ], + }), }, include: { children: true }, // 包含子节点信息 orderBy: { order: 'asc' }, // 按顺序升序排序 diff --git a/apps/server/src/models/visit/visit.router.ts b/apps/server/src/models/visit/visit.router.ts index 2bb9064..e600ff2 100755 --- a/apps/server/src/models/visit/visit.router.ts +++ b/apps/server/src/models/visit/visit.router.ts @@ -4,34 +4,34 @@ import { Prisma } from '@nice/common'; import { VisitService } from './visit.service'; import { z, ZodType } from 'zod'; -const VisitCreateArgsSchema: ZodType = z.any() -const VisitCreateManyInputSchema: ZodType = z.any() -const VisitDeleteManyArgsSchema: ZodType = z.any() +const VisitCreateArgsSchema: ZodType = z.any(); +const VisitCreateManyInputSchema: ZodType = + z.any(); +const VisitDeleteManyArgsSchema: ZodType = z.any(); @Injectable() export class VisitRouter { - constructor( - private readonly trpc: TrpcService, - private readonly visitService: VisitService, - ) { } - router = this.trpc.router({ - create: this.trpc.protectProcedure - .input(VisitCreateArgsSchema) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.visitService.create(input, staff); - }), - createMany: this.trpc.protectProcedure.input(z.array(VisitCreateManyInputSchema)) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; + constructor( + private readonly trpc: TrpcService, + private readonly visitService: VisitService, + ) {} + router = this.trpc.router({ + create: this.trpc.procedure + .input(VisitCreateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.visitService.create(input, staff); + }), + createMany: this.trpc.procedure + .input(z.array(VisitCreateManyInputSchema)) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; - return await this.visitService.createMany({ data: input }, staff); - }), - deleteMany: this.trpc.procedure - .input(VisitDeleteManyArgsSchema) - .mutation(async ({ input }) => { - return await this.visitService.deleteMany(input); - }), - - - }); + return await this.visitService.createMany({ data: input }, staff); + }), + deleteMany: this.trpc.procedure + .input(VisitDeleteManyArgsSchema) + .mutation(async ({ input }) => { + return await this.visitService.deleteMany(input); + }), + }); } diff --git a/apps/server/src/models/visit/visit.service.ts b/apps/server/src/models/visit/visit.service.ts index a7f2ada..e050fa5 100755 --- a/apps/server/src/models/visit/visit.service.ts +++ b/apps/server/src/models/visit/visit.service.ts @@ -9,17 +9,22 @@ export class VisitService extends BaseService { } async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) { const { postId, lectureId, messageId } = args.data; - const visitorId = args.data.visitorId || staff?.id; + const visitorId = args.data?.visitorId || staff?.id; let result; + console.log(args.data.type); + console.log(visitorId); + console.log(postId); const existingVisit = await db.visit.findFirst({ where: { type: args.data.type, - visitorId, - OR: [{ postId }, { lectureId }, { messageId }], + // visitorId: visitorId ? visitorId : null, + OR: [{ postId }, { messageId }], }, }); + console.log('result', existingVisit); if (!existingVisit) { result = await super.create(args); + console.log('createdResult', result); } else if (args.data.type === VisitType.READED) { result = await super.update({ where: { id: existingVisit.id }, @@ -30,12 +35,21 @@ export class VisitService extends BaseService { }); } - // if (troubleId && args.data.type === VisitType.READED) { - // EventBus.emit('updateViewCount', { - // objectType: ObjectType.TROUBLE, - // id: troubleId, - // }); - // } + if ( + [VisitType.READED, VisitType.LIKE, VisitType.HATE].includes( + args.data.type as VisitType, + ) + ) { + EventBus.emit('updateVisitCount', { + objectType: ObjectType.POST, + id: postId, + visitType: args.data.type, // 直接复用传入的类型 + }); + EventBus.emit('updateTotalCourseViewCount', { + visitType: args.data.type, // 直接复用传入的类型 + }); + } + return result; } async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) { @@ -80,4 +94,64 @@ export class VisitService extends BaseService { 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, // 直接复用传入的类型 + }); + EventBus.emit('updateTotalCourseViewCount', { + visitType: args.where.type as any, // 直接复用传入的类型 + }); + } + } + return superDetele; + } } diff --git a/apps/server/src/queue/models/post/post.queue.service.ts b/apps/server/src/queue/models/post/post.queue.service.ts new file mode 100755 index 0000000..8e49a81 --- /dev/null +++ b/apps/server/src/queue/models/post/post.queue.service.ts @@ -0,0 +1,56 @@ +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 }); + }); + EventBus.on('updatePostState', ({ id }) => { + this.addUpdatePostState({ id }); + }); + EventBus.on('updateTotalCourseViewCount', ({ visitType }) => { + this.addUpdateTotalCourseViewCount({ visitType }); + }); + } + 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}` }, + }); + } + async addUpdateTotalCourseViewCount({ visitType }) { + this.logger.log(`update post state ${visitType}`); + await this.generalQueue.add( + QueueJobType.UPDATE_TOTAL_COURSE_VIEW_COUNT, + { type: visitType }, + { + debounce: { id: `${QueueJobType.UPDATE_POST_STATE}_${visitType}` }, + }, + ); + } +} diff --git a/apps/server/src/queue/models/post/utils.ts b/apps/server/src/queue/models/post/utils.ts new file mode 100755 index 0000000..7e3c3f1 --- /dev/null +++ b/apps/server/src/queue/models/post/utils.ts @@ -0,0 +1,136 @@ +import { + AppConfigSlug, + BaseSetting, + db, + PostType, + VisitType, +} from '@nice/common'; +export async function updateTotalCourseViewCount(type: VisitType) { + const posts = await db.post.findMany({ + where: { + // type: { in: [PostType.COURSE, PostType.LECTURE,] }, + deletedAt: null, + }, + select: { id: true, type: true }, + }); + + const courseIds = posts + .filter((post) => post.type === PostType.COURSE) + .map((course) => course.id); + const lectures = posts.filter((post) => post.type === PostType.LECTURE); + const totalViews = await db.visit.aggregate({ + _sum: { + views: true, + }, + where: { + postId: { in: posts.map((post) => post.id) }, + type: type, + }, + }); + const appConfig = await db.appConfig.findFirst({ + where: { + slug: AppConfigSlug.BASE_SETTING, + }, + select: { + id: true, + meta: true, + }, + }); + const staffs = await db.staff.count({ + where: { deletedAt: null }, + }); + + const baseSeting = appConfig.meta as BaseSetting; + await db.appConfig.update({ + where: { + slug: AppConfigSlug.BASE_SETTING, + }, + data: { + meta: { + ...baseSeting, + appConfig: { + ...(baseSeting?.appConfig || {}), + statistics: { + reads: totalViews._sum.views || 0, + courses: courseIds?.length || 0, + staffs: staffs || 0, + lectures: lectures?.length || 0, + }, + }, + }, + }, + }); +} +export async function updatePostViewCount(id: string, type: VisitType) { + const post = await db.post.findFirst({ + where: { id }, + select: { id: true, meta: true, type: true }, + }); + const metaFieldMap = { + [VisitType.READED]: 'views', + [VisitType.LIKE]: 'likes', + [VisitType.HATE]: 'hates', + }; + if (post?.type === PostType.LECTURE) { + const courseAncestry = await db.postAncestry.findFirst({ + where: { + descendantId: post?.id, + ancestor: { + type: PostType.COURSE, + }, + }, + select: { id: true, ancestorId: true }, + }); + const course = { id: courseAncestry.ancestorId }; + const lecturesAncestry = await db.postAncestry.findMany({ + where: { ancestorId: course.id, descendant: { type: PostType.LECTURE } }, + select: { + id: true, + descendantId: true, + }, + }); + const lectures = lecturesAncestry.map((ancestry) => ({ + id: ancestry.descendantId, + })); + const courseViews = await db.visit.aggregate({ + _sum: { + views: true, + }, + where: { + postId: { + in: [course.id, ...lectures.map((lecture) => lecture.id)], + }, + type: type, + }, + }); + await db.post.update({ + where: { id: course.id }, + data: { + [metaFieldMap[type]]: courseViews._sum.views || 0, + meta: { + ...((post?.meta as any) || {}), + [metaFieldMap[type]]: courseViews._sum.views || 0, + }, + }, + }); + } + const totalViews = await db.visit.aggregate({ + _sum: { + views: true, + }, + where: { + postId: id, + type: type, + }, + }); + await db.post.update({ + where: { id }, + data: { + [metaFieldMap[type]]: totalViews._sum.views || 0, + meta: { + ...((post?.meta as any) || {}), + [metaFieldMap[type]]: totalViews._sum.views || 0, + }, + }, + }); +} diff --git a/apps/server/src/queue/queue.module.ts b/apps/server/src/queue/queue.module.ts index ca825eb..aab8453 100755 --- a/apps/server/src/queue/queue.module.ts +++ b/apps/server/src/queue/queue.module.ts @@ -2,6 +2,7 @@ import { BullModule } from '@nestjs/bullmq'; import { Logger, Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { join } from 'path'; +import { PostQueueService } from './models/post/post.queue.service'; @Module({ imports: [ @@ -28,7 +29,7 @@ import { join } from 'path'; }, ), ], - providers: [Logger], + providers: [Logger, PostQueueService], exports: [], }) export class QueueModule {} diff --git a/apps/server/src/queue/types.ts b/apps/server/src/queue/types.ts index c509c71..11119ee 100755 --- a/apps/server/src/queue/types.ts +++ b/apps/server/src/queue/types.ts @@ -1,4 +1,15 @@ +import { VisitType } from '@nice/common'; export enum QueueJobType { UPDATE_STATS = 'update_stats', FILE_PROCESS = 'file_process', + UPDATE_POST_VISIT_COUNT = 'updatePostVisitCount', + UPDATE_POST_STATE = 'updatePostState', + UPDATE_TOTAL_COURSE_VIEW_COUNT = 'updateTotalCourseViewCount', } +export type updateVisitCountJobData = { + id: string; + type: VisitType | string; +}; +export type updatePostStateJobData = { + id: string; +}; diff --git a/apps/server/src/queue/worker/file.processor.ts b/apps/server/src/queue/worker/file.processor.ts index b416af4..1012a8c 100755 --- a/apps/server/src/queue/worker/file.processor.ts +++ b/apps/server/src/queue/worker/file.processor.ts @@ -10,7 +10,7 @@ const pipeline = new ResourceProcessingPipeline() .addProcessor(new VideoProcessor()); export default async function processJob(job: Job) { if (job.name === QueueJobType.FILE_PROCESS) { - console.log('job', job); + // console.log('job', job); const { resource } = job.data; if (!resource) { throw new Error('No resource provided in job data'); diff --git a/apps/server/src/queue/worker/processor.ts b/apps/server/src/queue/worker/processor.ts index 7273ecf..86d428b 100755 --- a/apps/server/src/queue/worker/processor.ts +++ b/apps/server/src/queue/worker/processor.ts @@ -11,6 +11,10 @@ import { updateCourseReviewStats, updateParentLectureStats, } from '@server/models/post/utils'; +import { + updatePostViewCount, + updateTotalCourseViewCount, +} from '../models/post/utils'; const logger = new Logger('QueueWorker'); export default async function processJob(job: Job) { try { @@ -44,6 +48,15 @@ export default async function processJob(job: Job) { `Updated course stats for courseId: ${courseId}, type: ${type}`, ); } + if (job.name === QueueJobType.UPDATE_POST_VISIT_COUNT) { + await updatePostViewCount(job.data.id, job.data.type); + } + if (job.name === QueueJobType.UPDATE_POST_STATE) { + await updatePostViewCount(job.data.id, job.data.type); + } + if (job.name === QueueJobType.UPDATE_TOTAL_COURSE_VIEW_COUNT) { + await updateTotalCourseViewCount(job.data.type); + } } catch (error: any) { logger.error( `Error processing stats update job: ${error.message}`, diff --git a/apps/server/src/tasks/init/gendev.service.ts b/apps/server/src/tasks/init/gendev.service.ts index addb264..e5c3f9f 100755 --- a/apps/server/src/tasks/init/gendev.service.ts +++ b/apps/server/src/tasks/init/gendev.service.ts @@ -7,6 +7,7 @@ import { Department, getRandomElement, getRandomElements, + PostType, Staff, TaxonomySlug, Term, @@ -14,6 +15,7 @@ import { import EventBus from '@server/utils/event-bus'; import { capitalizeFirstLetter, DevDataCounts, getCounts } from './utils'; import { StaffService } from '@server/models/staff/staff.service'; +import dayjs from 'dayjs'; @Injectable() export class GenDevService { private readonly logger = new Logger(GenDevService.name); @@ -29,13 +31,14 @@ export class GenDevService { domainDepts: Record = {}; staffs: Staff[] = []; deptGeneratedCount = 0; + courseGeneratedCount = 1; constructor( private readonly appConfigService: AppConfigService, private readonly departmentService: DepartmentService, private readonly staffService: StaffService, private readonly termService: TermService, - ) { } + ) {} async genDataEvent() { EventBus.emit('genDataEvent', { type: 'start' }); try { @@ -43,6 +46,7 @@ export class GenDevService { await this.generateDepartments(3, 6); await this.generateTerms(2, 6); await this.generateStaffs(4); + await this.generateCourses(8); } catch (err) { this.logger.error(err); } @@ -58,6 +62,7 @@ export class GenDevService { if (this.counts.termCount === 0) { this.logger.log('Generate terms'); await this.createTerms(null, TaxonomySlug.CATEGORY, depth, count); + await this.createLevelTerm(); const domains = this.depts.filter((item) => item.isDomain); for (const domain of domains) { await this.createTerms(domain, TaxonomySlug.CATEGORY, depth, count); @@ -139,6 +144,65 @@ export class GenDevService { collectChildren(domainId); return children; } + private async generateCourses(countPerCate: number = 3) { + const titleList = [ + '计算机科学导论', + '数据结构与算法', + '网络安全', + '机器学习', + '数据库管理系统', + 'Web开发', + '移动应用开发', + '人工智能', + '计算机网络', + '操作系统', + '数字信号处理', + '无线通信', + '信息论', + '密码学', + '计算机图形学', + ]; + + if (!this.counts.courseCount) { + this.logger.log('Generating courses...'); + const depts = await db.department.findMany({ + select: { id: true, name: true }, + }); + const cates = await db.term.findMany({ + where: { + taxonomy: { slug: TaxonomySlug.CATEGORY }, + }, + select: { id: true, name: true }, + }); + const total = cates.length * countPerCate; + const levels = await db.term.findMany({ + where: { + taxonomy: { slug: TaxonomySlug.LEVEL }, + }, + select: { id: true, name: true }, + }); + for (const cate of cates) { + for (let i = 0; i < countPerCate; i++) { + const randomTitle = `${titleList[Math.floor(Math.random() * titleList.length)]} ${Math.random().toString(36).substring(7)}`; + const randomLevelId = + levels[Math.floor(Math.random() * levels.length)].id; + const randomDeptId = + depts[Math.floor(Math.random() * depts.length)].id; + + await this.createCourse( + randomTitle, + randomDeptId, + cate.id, + randomLevelId, + ); + this.courseGeneratedCount++; + this.logger.log( + `Generated ${this.courseGeneratedCount}/${total} course`, + ); + } + } + } + } private async generateStaffs(countPerDept: number = 3) { if (this.counts.staffCount === 1) { this.logger.log('Generating staffs...'); @@ -174,7 +238,59 @@ export class GenDevService { } } } + private async createLevelTerm() { + try { + // 1. 获取分类时添加异常处理 + const taxLevel = await db.taxonomy.findFirst({ + where: { slug: TaxonomySlug.LEVEL }, + }); + if (!taxLevel) { + throw new Error('LEVEL taxonomy not found'); + } + + // 2. 使用数组定义初始化数据 + 名称去重 + const termsToCreate = [ + { name: '初级', taxonomyId: taxLevel.id }, + { name: '中级', taxonomyId: taxLevel.id }, + { name: '高级', taxonomyId: taxLevel.id }, // 改为高级更合理 + ]; + for (const termData of termsToCreate) { + await this.termService.create({ + data: termData, + }); + } + console.log('created level terms'); + } catch (error) { + console.error('Failed to create level terms:', error); + throw error; // 向上抛出错误供上层处理 + } + } + private async createCourse( + title: string, + deptId: string, + cateId: string, + levelId: string, + ) { + const course = await db.post.create({ + data: { + type: PostType.COURSE, + title: title, + updatedAt: dayjs().toDate(), + depts: { + connect: { + id: deptId, + }, + }, + terms: { + connect: [cateId, levelId].map((id) => ({ + id: id, + })), + }, + }, + }); + return course; + } private async createDepartment( name: string, parentId?: string | null, diff --git a/apps/server/src/tasks/init/utils.ts b/apps/server/src/tasks/init/utils.ts index 292eb9e..d55dcec 100755 --- a/apps/server/src/tasks/init/utils.ts +++ b/apps/server/src/tasks/init/utils.ts @@ -1,34 +1,44 @@ -import { db, getRandomElement, getRandomIntInRange, getRandomTimeInterval, } from '@nice/common'; +import { + db, + getRandomElement, + getRandomIntInRange, + getRandomTimeInterval, + PostType, +} from '@nice/common'; import dayjs from 'dayjs'; export interface DevDataCounts { - deptCount: number; + deptCount: number; - staffCount: number - termCount: number + staffCount: number; + termCount: number; + courseCount: number; } export async function getCounts(): Promise { - const counts = { - deptCount: await db.department.count(), + const counts = { + deptCount: await db.department.count(), - staffCount: await db.staff.count(), - termCount: await db.term.count(), - }; - return counts; + staffCount: await db.staff.count(), + termCount: await db.term.count(), + courseCount: await db.post.count({ + where: { + type: PostType.COURSE, + }, + }), + }; + return counts; } export function capitalizeFirstLetter(string: string) { - return string.charAt(0).toUpperCase() + string.slice(1); + return string.charAt(0).toUpperCase() + string.slice(1); } export function getRandomImageLinks(count: number = 5): string[] { - const baseUrl = 'https://picsum.photos/200/300?random='; - const imageLinks: string[] = []; + const baseUrl = 'https://picsum.photos/200/300?random='; + const imageLinks: string[] = []; - for (let i = 0; i < count; i++) { - // 生成随机数以确保每个链接都是唯一的 - const randomId = Math.floor(Math.random() * 1000); - imageLinks.push(`${baseUrl}${randomId}`); - } + for (let i = 0; i < count; i++) { + // 生成随机数以确保每个链接都是唯一的 + const randomId = Math.floor(Math.random() * 1000); + imageLinks.push(`${baseUrl}${randomId}`); + } - return imageLinks; + return imageLinks; } - - diff --git a/apps/server/src/upload/tus.service.ts b/apps/server/src/upload/tus.service.ts index 8844b97..e2dc45d 100755 --- a/apps/server/src/upload/tus.service.ts +++ b/apps/server/src/upload/tus.service.ts @@ -89,8 +89,8 @@ export class TusService implements OnModuleInit { upload: Upload, ) { try { - console.log('upload.id', upload.id); - console.log('fileId', this.getFileId(upload.id)); + // console.log('upload.id', upload.id); + // console.log('fileId', this.getFileId(upload.id)); const resource = await this.resourceService.update({ where: { fileId: this.getFileId(upload.id) }, data: { status: ResourceStatus.UPLOADED }, @@ -128,6 +128,7 @@ export class TusService implements OnModuleInit { } async handleTus(req: Request, res: Response) { + console.log(req) return this.tusServer.handle(req, res); } } diff --git a/apps/server/src/utils/event-bus.ts b/apps/server/src/utils/event-bus.ts index 8cc9c2e..3f96688 100755 --- a/apps/server/src/utils/event-bus.ts +++ b/apps/server/src/utils/event-bus.ts @@ -1,16 +1,31 @@ import mitt from 'mitt'; -import { ObjectType, UserProfile, MessageDto } from '@nice/common'; +import { ObjectType, UserProfile, MessageDto, VisitType } from '@nice/common'; export enum CrudOperation { CREATED, UPDATED, - DELETED + DELETED, } type Events = { - genDataEvent: { type: "start" | "end" }, - markDirty: { objectType: string, id: string, staff?: UserProfile, subscribers?: string[] } - updateViewCount: { id: string, objectType: ObjectType }, - onMessageCreated: { data: Partial }, - dataChanged: { type: string, operation: CrudOperation, data: any } + genDataEvent: { type: 'start' | 'end' }; + markDirty: { + objectType: string; + id: string; + staff?: UserProfile; + subscribers?: string[]; + }; + updateVisitCount: { + id: string; + objectType: ObjectType; + visitType: VisitType | string; + }; + updatePostState: { + id: string; + }; + updateTotalCourseViewCount: { + visitType: VisitType | string; + }; + onMessageCreated: { data: Partial }; + dataChanged: { type: string; operation: CrudOperation; data: any }; }; const EventBus = mitt(); export default EventBus; diff --git a/apps/web/package.json b/apps/web/package.json index f10e338..7f6c264 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,9 +34,8 @@ "@nice/common": "workspace:^", "@nice/config": "workspace:^", "@nice/iconer": "workspace:^", - "@nice/utils": "workspace:^", - "mind-elixir": "workspace:^", "@nice/ui": "workspace:^", + "@nice/utils": "workspace:^", "@tanstack/query-async-storage-persister": "^5.51.9", "@tanstack/react-query": "^5.51.21", "@tanstack/react-query-persist-client": "^5.51.9", @@ -59,6 +58,7 @@ "framer-motion": "^11.15.0", "hls.js": "^1.5.18", "idb-keyval": "^6.2.1", + "mind-elixir": "workspace:^", "mitt": "^3.0.1", "quill": "2.0.3", "react": "18.2.0", @@ -69,6 +69,7 @@ "react-router-dom": "^6.24.1", "superjson": "^2.2.1", "tailwind-merge": "^2.6.0", + "use-debounce": "^10.0.4", "uuid": "^10.0.0", "yjs": "^13.6.20", "zod": "^3.23.8" diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg new file mode 100755 index 0000000..39a6980 --- /dev/null +++ b/apps/web/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/placeholder.webp b/apps/web/public/placeholder.webp new file mode 100644 index 0000000..1f474a5 Binary files /dev/null and b/apps/web/public/placeholder.webp differ diff --git a/apps/web/public/vite.svg b/apps/web/public/vite.svg index e7b8dfb..78260dd 100755 --- a/apps/web/public/vite.svg +++ b/apps/web/public/vite.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/apps/web/src/app/admin/base-setting/page.tsx b/apps/web/src/app/admin/base-setting/page.tsx index ae1060d..d4a4efa 100755 --- a/apps/web/src/app/admin/base-setting/page.tsx +++ b/apps/web/src/app/admin/base-setting/page.tsx @@ -1,66 +1,66 @@ -import { - AppConfigSlug, - BaseSetting, - RolePerms, -} from "@nice/common"; +import { AppConfigSlug, BaseSetting, RolePerms } from "@nice/common"; import { useContext, useEffect, useState } from "react"; -import { - Button, - Form, - Input, - message, - theme, -} from "antd"; +import { Button, Form, Input, message, theme } from "antd"; import { useAppConfig } from "@nice/client"; import { useAuth } from "@web/src/providers/auth-provider"; +import MultiAvatarUploader from "@web/src/components/common/uploader/MultiAvatarUploader"; import FixedHeader from "@web/src/components/layout/fix-header"; import { useForm } from "antd/es/form/Form"; -import { api } from "@nice/client" +import { api } from "@nice/client"; import { MainLayoutContext } from "../layout"; export default function BaseSettingPage() { const { update, baseSetting } = useAppConfig(); - const utils = api.useUtils() - const [form] = useForm() + const utils = api.useUtils(); + const [form] = useForm(); const { token } = theme.useToken(); - const { data: clientCount } = api.app_config.getClientCount.useQuery(undefined, { - refetchInterval: 3000, - refetchIntervalInBackground: true - }) + const { data: clientCount } = api.app_config.getClientCount.useQuery( + undefined, + { + refetchInterval: 3000, + refetchIntervalInBackground: true, + } + ); const [isFormChanged, setIsFormChanged] = useState(false); const [loading, setLoading] = useState(false); const { user, hasSomePermissions } = useAuth(); - const { pageWidth } = useContext?.(MainLayoutContext); + const context = useContext(MainLayoutContext); + const pageWidth = context?.pageWidth; + // const [meta,setMeta ] = useState<>(baseSetting); function handleFieldsChange() { setIsFormChanged(true); } function onResetClick() { - if (!form) - return + if (!form) return; if (!baseSetting) { form.resetFields(); } else { form.resetFields(); form.setFieldsValue(baseSetting); - } setIsFormChanged(false); } function onSaveClick() { - if (form) - form.submit(); + if (form) form.submit(); } async function onSubmit(values: BaseSetting) { setLoading(true); - + const appConfig = values?.appConfig || {}; try { - await update.mutateAsync({ where: { slug: AppConfigSlug.BASE_SETTING, }, - data: { meta: JSON.stringify(values) } + data: { + meta: { + ...baseSetting, + appConfig: { + ...(baseSetting?.appConfig || {}), + ...appConfig, + }, + }, + }, }); setIsFormChanged(false); message.success("已保存"); @@ -72,7 +72,6 @@ export default function BaseSettingPage() { } useEffect(() => { if (baseSetting && form) { - form.setFieldsValue(baseSetting); } }, [baseSetting, form]); @@ -103,7 +102,6 @@ export default function BaseSettingPage() { !hasSomePermissions(RolePerms.MANAGE_BASE_SETTING) } onFinish={onSubmit} - onFieldsChange={handleFieldsChange} layout="vertical"> {/*
+
+ + + +
{/*
- {
- app在线人数 -
- {clientCount && clientCount > 0 ? `${clientCount}人在线` : '无人在线'} + { +
+ app在线人数 +
+ {clientCount && clientCount > 0 + ? `${clientCount}人在线` + : "无人在线"} +
-
} + }
); diff --git a/apps/web/src/app/main/course/detail/page.tsx b/apps/web/src/app/main/course/detail/page.tsx index 16f7173..82dd7ae 100755 --- a/apps/web/src/app/main/course/detail/page.tsx +++ b/apps/web/src/app/main/course/detail/page.tsx @@ -3,6 +3,5 @@ import { useParams } from "react-router-dom"; export function CourseDetailPage() { const { id, lectureId } = useParams(); - console.log("Course ID:", id); return ; } diff --git a/apps/web/src/app/main/course/preview/components/couresPreviewTabmsg.tsx b/apps/web/src/app/main/course/preview/components/couresPreviewTabmsg.tsx new file mode 100755 index 0000000..8f32fce --- /dev/null +++ b/apps/web/src/app/main/course/preview/components/couresPreviewTabmsg.tsx @@ -0,0 +1,25 @@ +import { Checkbox, List } from 'antd'; +import React from 'react'; + +export function CoursePreviewTabmsg({data}){ + + + const renderItem = (item) => ( + + + + ); + + return( +
+ +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/app/main/course/preview/components/courseCatalog.tsx b/apps/web/src/app/main/course/preview/components/courseCatalog.tsx new file mode 100755 index 0000000..b13e87d --- /dev/null +++ b/apps/web/src/app/main/course/preview/components/courseCatalog.tsx @@ -0,0 +1,11 @@ +import type { MenuProps } from 'antd'; +import { Menu } from 'antd'; + +type MenuItem = Required['items'][number]; + +export function CourseCatalog(){ + return ( + <> + + ) +} \ No newline at end of file diff --git a/apps/web/src/app/main/course/preview/components/coursePreviewAllmsg.tsx b/apps/web/src/app/main/course/preview/components/coursePreviewAllmsg.tsx index ee10b4a..73ed1f9 100755 --- a/apps/web/src/app/main/course/preview/components/coursePreviewAllmsg.tsx +++ b/apps/web/src/app/main/course/preview/components/coursePreviewAllmsg.tsx @@ -1,7 +1,73 @@ -export function coursePreviewAllmsg() { - return ( -
- helloword -
- ) -} \ No newline at end of file +import { useEffect } from "react"; +import { CoursePreviewMsg } from "@web/src/app/main/course/preview/type.ts"; +import { Button, Tabs, Image, Skeleton } from "antd"; +import type { TabsProps } from "antd"; +import { PlayCircleOutlined } from "@ant-design/icons"; +export function CoursePreviewAllmsg({ + previewMsg, + items, + isLoading, +}: { + previewMsg?: CoursePreviewMsg; + items: TabsProps["items"]; + isLoading: boolean; +}) { + useEffect(() => { + console.log(previewMsg); + }); + const TapOnChange = (key: string) => { + console.log(key); + }; + return ( +
+
+
+ example +
+ +
+
+
+ {isLoading ? ( + + ) : ( + <> + + {previewMsg.Title} + + + {previewMsg.SubTitle} + + + {previewMsg.Description} + + + )} + + +
+
+
+ +
+
+ ); +} diff --git a/apps/web/src/app/main/course/preview/page.tsx b/apps/web/src/app/main/course/preview/page.tsx index f345883..367c21a 100755 --- a/apps/web/src/app/main/course/preview/page.tsx +++ b/apps/web/src/app/main/course/preview/page.tsx @@ -1,7 +1,81 @@ +import { Skeleton, type TabsProps } from 'antd'; +import { CoursePreviewAllmsg } from "./components/coursePreviewAllmsg"; +import { CoursePreviewTabmsg } from "./components/couresPreviewTabmsg"; +import { CoursePreviewMsg } from "./type"; +import { api } from '@nice/client' +import { useNavigate, useParams } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { courseDetailSelect, CourseDto } from '@nice/common'; + export function CoursePreview(){ + const { id } = useParams() + const { data:course,isLoading:courseIsLoading}:{data:CourseDto,isLoading:boolean}= api.post.findFirst.useQuery({ + where:{ + id + }, + select:courseDetailSelect + }) + // course.sections[0].lectures[0] + // `/course/${course.id}/detail/${Lecture.id}` + useEffect(() => { + if(!courseIsLoading){ + setPreviewMsg({ + videoPreview: course?.meta?.thumbnail, + Title: course?.title, + SubTitle:course?.subTitle, + Description:course?.content, + ToCourseUrl:`/course/${id}`, + isLoading:courseIsLoading + }) + } + + },[courseIsLoading]) + const [previewMsg,setPreviewMsg] = useState({ + videoPreview: '', + Title: '', + SubTitle:'', + Description:'', + ToCourseUrl:'', + isLoading:courseIsLoading + }) + const tapData = [ + { + title: '掌握R语言的基本概念语法', + description: '学生将学习R语言和RStudio的基本知识', + }, + { + title: '掌握R语言的基本概念语法', + description: '学生将学习R语言的变量、数据类型、循环和条件语句等', + }, + { + title: '掌握R语言的数据导入管理', + description: '学生将学会如何将数据导入R环境,并且使用各种类型的数据', + }, + { + title: '掌握R语言的基本数据清洗', + description: '学生将学会使用R语言进行数据清洗、整理和管理', + }, + { + title: '掌握R语言的基本数据统计', + description: '学生将学会使用R语言进行基本的数据统计', + }, + { + title: '掌握R语言的基本绘图功能', + description: '学生将学会使用R语言基本的绘图功能和ggplot2的应用', + }, + ]; + const isLoading = false + const items: TabsProps['items'] = [ + { + key: '1', + label: '课程学习目标', + children: isLoading ? : , + } + ]; + return(
- helloword +
) } \ No newline at end of file diff --git a/apps/web/src/app/main/course/preview/type.ts b/apps/web/src/app/main/course/preview/type.ts new file mode 100755 index 0000000..fe3a2fd --- /dev/null +++ b/apps/web/src/app/main/course/preview/type.ts @@ -0,0 +1,8 @@ +export interface CoursePreviewMsg { + videoPreview: string; + Title: string; + SubTitle: string; + Description: string; + ToCourseUrl: string; + isLoading: boolean; +} diff --git a/apps/web/src/app/main/courses/components/CourseCard.tsx b/apps/web/src/app/main/courses/components/CourseCard.tsx deleted file mode 100755 index d549a1e..0000000 --- a/apps/web/src/app/main/courses/components/CourseCard.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Card, Rate, Tag } from 'antd'; -import { Course } from '../mockData'; -import { UserOutlined, ClockCircleOutlined } from '@ant-design/icons'; - -interface CourseCardProps { - course: Course; -} - -export default function CourseCard({ course }: CourseCardProps) { - return ( - - } - > -
-

- {course.title} -

-

{course.instructor}

-
- - {course.rating} -
-
-
- - {course.enrollments} 人在学 -
-
- - {course.duration} -
-
-
- {course.category} - {course.level} -
-
-
- ); -} diff --git a/apps/web/src/app/main/courses/components/CourseList.tsx b/apps/web/src/app/main/courses/components/CourseList.tsx deleted file mode 100755 index 3d67b1c..0000000 --- a/apps/web/src/app/main/courses/components/CourseList.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Pagination, Empty } from 'antd'; -import { Course } from '../mockData'; -import CourseCard from './CourseCard'; - -interface CourseListProps { - courses: Course[]; - total: number; - pageSize: number; - currentPage: number; - onPageChange: (page: number) => void; -} - -export default function CourseList({ - courses, - total, - pageSize, - currentPage, - onPageChange, -}: CourseListProps) { - return ( -
- {courses.length > 0 ? ( - <> -
- {courses.map(course => ( - - ))} -
-
- -
- - ) : ( - - )} -
- ); -} \ No newline at end of file diff --git a/apps/web/src/app/main/courses/components/CoursesContainer.tsx b/apps/web/src/app/main/courses/components/CoursesContainer.tsx new file mode 100755 index 0000000..72b9499 --- /dev/null +++ b/apps/web/src/app/main/courses/components/CoursesContainer.tsx @@ -0,0 +1,25 @@ +import { useMainContext } from "../../layout/MainProvider"; +import { PostType, Prisma } from "@nice/common"; +import PostList from "@web/src/components/models/course/list/PostList"; +import CourseCard from "@web/src/components/models/post/SubPost/CourseCard"; + +export function CoursesContainer() { + const { searchCondition, termsCondition } = useMainContext(); + return ( + <> + } + params={{ + pageSize: 12, + where: { + type: PostType.COURSE, + ...termsCondition, + ...searchCondition, + }, + }} + cols={4}> + + ); +} + +export default CoursesContainer; diff --git a/apps/web/src/app/main/courses/components/FilterSection.tsx b/apps/web/src/app/main/courses/components/FilterSection.tsx deleted file mode 100755 index 5cc1574..0000000 --- a/apps/web/src/app/main/courses/components/FilterSection.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Checkbox, Divider, Radio, Space , Spin} from 'antd'; -import { categories, levels } from '../mockData'; -import { TaxonomySlug, TermDto } from '@nice/common'; -import { GetTaxonomyProps, useGetTaxonomy } from '@web/src/hooks/useGetTaxonomy'; -import { useMemo } from 'react'; - -interface FilterSectionProps { - selectedCategory: string; - selectedLevel: string; - onCategoryChange: (category: string) => void; - onLevelChange: (level: string) => void; -} - -export default function FilterSection({ - selectedCategory, - selectedLevel, - onCategoryChange, - onLevelChange, -}: FilterSectionProps) { - const gateGory : GetTaxonomyProps = useGetTaxonomy({ - type: TaxonomySlug.CATEGORY, - }) - const levels : GetTaxonomyProps = useGetTaxonomy({ - type: TaxonomySlug.LEVEL, - }) - return ( -
-
-

课程分类

- onCategoryChange(e.target.value)} - className="flex flex-col space-y-3" - > - { - gateGory.isLoading? - () - : - ( - <> - 全部课程 - {gateGory.categories.map(category => ( - - {category} - - ))} - ) - } - - -
- - - -
-

难度等级

- onLevelChange(e.target.value)} - className="flex flex-col space-y-3" - > - { - levels.isLoading ? - () - : - ( - <> - 全部难度 - {levels.categories.map(level => ( - - {level} - - ))} - ) - } - - -
-
- ); -} diff --git a/apps/web/src/app/main/courses/instructor/page.tsx b/apps/web/src/app/main/courses/instructor/page.tsx deleted file mode 100755 index a914925..0000000 --- a/apps/web/src/app/main/courses/instructor/page.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { PlusIcon } from "@heroicons/react/24/outline"; -import { CourseList } from "@web/src/components/models/course/list/course-list"; -import { Button } from "@web/src/components/common/element/Button"; -import { api } from "@nice/client"; -import { useState } from "react"; -import { CourseCard } from "@web/src/components/models/course/card/CourseCard"; -import { useNavigate } from "react-router-dom"; -import { useAuth } from "@web/src/providers/auth-provider"; - -export default function InstructorCoursesPage() { - const navigate = useNavigate(); - const [currentPage, setCurrentPage] = useState(1); - const { user } = useAuth(); - - const { data: paginationRes, refetch } = - api.post.findManyWithPagination.useQuery({ - page: currentPage, - pageSize: 8, - where: { - instructors: { - some: { - instructorId: user?.id, - }, - }, - }, - }); - - const handlePageChange = (page: number) => { - setCurrentPage(page); - refetch(); - }; - - return ( -
-
-
-

- 我教授的课程 -

- -
- ( - { - navigate(`/course/${course.id}/editor`, { - replace: true, - }); - }} - course={course} - /> - )} - /> -
-
- ); -} diff --git a/apps/web/src/app/main/courses/mockData.ts b/apps/web/src/app/main/courses/mockData.ts deleted file mode 100755 index 096d174..0000000 --- a/apps/web/src/app/main/courses/mockData.ts +++ /dev/null @@ -1,44 +0,0 @@ -export interface Course { - id: string; - title: string; - description: string; - instructor: string; - price: number; - originalPrice: number; - category: string; - level: string; - thumbnail: string; - rating: number; - enrollments: number; - duration: string; -} - -export const categories = [ - "计算机科学", - "数据科学", - "商业管理", - "人工智能", - "软件开发", - "网络安全", - "云计算", - "前端开发", - "后端开发", - "移动开发" -]; - -export const levels = ["入门", "初级", "中级", "高级"]; - -export const mockCourses: Course[] = Array.from({ length: 50 }, (_, i) => ({ - id: `course-${i + 1}`, - title: `${categories[i % categories.length]}课程 ${i + 1}`, - description: "本课程将带你深入了解该领域的核心概念和实践应用,通过实战项目提升你的专业技能。", - instructor: `讲师 ${i + 1}`, - price: Math.floor(Math.random() * 500 + 99), - originalPrice: Math.floor(Math.random() * 1000 + 299), - category: categories[i % categories.length], - level: levels[i % levels.length], - thumbnail: `/api/placeholder/280/160`, - rating: Number((Math.random() * 2 + 3).toFixed(1)), - enrollments: Math.floor(Math.random() * 10000), - duration: `${Math.floor(Math.random() * 20 + 10)}小时` -})); \ No newline at end of file diff --git a/apps/web/src/app/main/courses/page.tsx b/apps/web/src/app/main/courses/page.tsx index 30cd309..79eda89 100755 --- a/apps/web/src/app/main/courses/page.tsx +++ b/apps/web/src/app/main/courses/page.tsx @@ -1,101 +1,18 @@ -import { useState, useMemo } from "react"; -import { mockCourses } from "./mockData"; -import FilterSection from "./components/FilterSection"; -import CourseList from "./components/CourseList"; -import { api } from "@nice/client"; -import { LectureType, PostType } from "@nice/common"; - - +import BasePostLayout from "../layout/BasePost/BasePostLayout"; +import CoursesContainer from "./components/CoursesContainer"; +import { useEffect } from "react"; +import { useMainContext } from "../layout/MainProvider"; +import { PostType } from "@nice/common"; export default function CoursesPage() { - const [currentPage, setCurrentPage] = useState(1); - const [selectedCategory, setSelectedCategory] = useState(""); - const [selectedLevel, setSelectedLevel] = useState(""); - const pageSize = 12; - const { data, isLoading } = api.post.findManyWithPagination.useQuery({ - where: { - type: PostType.COURSE, - terms: { - some: { - AND: [ - ...(selectedCategory - ? [ - { - name: selectedCategory, - }, - ] - : []), - ...(selectedLevel - ? [ - { - name: selectedLevel, - }, - ] - : []), - ], - }, - }, - }, - }); - const filteredCourses = useMemo(() => { - return mockCourses.filter((course) => { - const matchCategory = - !selectedCategory || course.category === selectedCategory; - const matchLevel = !selectedLevel || course.level === selectedLevel; - return matchCategory && matchLevel; - }); - }, [selectedCategory, selectedLevel]); - - const paginatedCourses = useMemo(() => { - const startIndex = (currentPage - 1) * pageSize; - return filteredCourses.slice(startIndex, startIndex + pageSize); - }, [filteredCourses, currentPage]); - - const handlePageChange = (page: number) => { - setCurrentPage(page); - window.scrollTo({ top: 0, behavior: "smooth" }); - }; - + const { setSearchMode } = useMainContext(); + useEffect(() => { + setSearchMode(PostType.COURSE); + }, [setSearchMode]); return ( -
-
-
- {/* 左侧筛选区域 */} -
-
- { - setSelectedCategory(category); - setCurrentPage(1); - }} - onLevelChange={(level) => { - setSelectedLevel(level); - setCurrentPage(1); - }} - /> -
-
- - {/* 右侧课程列表区域 */} -
-
-
- - 共找到 {filteredCourses.length} 门课程 - -
- -
-
-
-
-
+ <> + + + + ); } diff --git a/apps/web/src/app/main/courses/student/page.tsx b/apps/web/src/app/main/courses/student/page.tsx deleted file mode 100755 index 2508c50..0000000 --- a/apps/web/src/app/main/courses/student/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { CourseList } from "@web/src/components/models/course/list/course-list"; -import { api } from "@nice/client"; -import { useState } from "react"; -import { CourseCard } from "@web/src/components/models/course/card/CourseCard"; -import { useAuth } from "@web/src/providers/auth-provider"; - -export default function StudentCoursesPage() { - const [currentPage, setCurrentPage] = useState(1); - const { user } = useAuth(); - - const { data: paginationRes, refetch } = - api.post.findManyWithPagination.useQuery({ - page: currentPage, - pageSize: 8, - where: { - enrollments: { - some: { - studentId: user?.id, - }, - }, - }, - }); - - const handlePageChange = (page: number) => { - setCurrentPage(page); - refetch(); - }; - - return ( -
-
-
-

- 我参加的课程 -

-
- ( - - )}> -
-
- ); -} diff --git a/apps/web/src/app/main/home/components/CategorySection.tsx b/apps/web/src/app/main/home/components/CategorySection.tsx index a64b551..7d23030 100755 --- a/apps/web/src/app/main/home/components/CategorySection.tsx +++ b/apps/web/src/app/main/home/components/CategorySection.tsx @@ -1,184 +1,86 @@ -import React, { useState, useCallback, useEffect, useMemo } from 'react'; -import { Typography, Button } from 'antd'; -import { stringToColor, TaxonomySlug, TermDto } from '@nice/common'; -import { api } from '@nice/client'; +import React, { useState, useCallback, useEffect, useMemo } from "react"; +import { Typography, Skeleton } from "antd"; +import { stringToColor, TaxonomySlug, TermDto } from "@nice/common"; +import { api } from "@nice/client"; +import LookForMore from "./LookForMore"; +import CategorySectionCard from "./CategorySectionCard"; +import { useNavigate } from "react-router-dom"; +import { useMainContext } from "../../layout/MainProvider"; const { Title, Text } = Typography; - -interface CourseCategory { - name: string; - count: number; - description: string; -} - -const courseCategories: CourseCategory[] = [ - { - name: '计算机基础', - count: 120, - description: '计算机组成原理、操作系统、网络等基础知识' - }, - { - name: '编程语言', - count: 85, - description: 'Python、Java、JavaScript等主流编程语言' - }, - { - name: '人工智能', - count: 65, - description: '机器学习、深度学习、自然语言处理等前沿技术' - }, - { - name: '数据科学', - count: 45, - description: '数据分析、数据可视化、商业智能等' - }, - { - name: '云计算', - count: 38, - description: '云服务、容器化、微服务架构等' - }, - { - name: '网络安全', - count: 42, - description: '网络安全基础、渗透测试、安全防护等' - } -]; - - const CategorySection = () => { - const [hoveredIndex, setHoveredIndex] = useState(null); - const [showAll, setShowAll] = useState(false); - /** - * const {data,isLoading} :{data:TermDto[],isLoading:boolean}= api.term.findMany.useQuery({ - where:{ - taxonomy: { - slug:TaxonomySlug.CATEGORY - } - }, - include:{ - children :true - } - }) - const courseCategories: CourseCategory[] = useMemo(() => { - return data?.map((term) => ({ - name: term.name, - count: term.hasChildren ? term.children.length : 0, - description: term.description - })) || []; - },[data]) - */ - - - const handleMouseEnter = useCallback((index: number) => { - setHoveredIndex(index); - }, []); + const [hoveredIndex, setHoveredIndex] = useState(null); + const { selectedTerms, setSelectedTerms } = useMainContext(); + const { + data: courseCategoriesData, + isLoading, + }: { data: TermDto[]; isLoading: boolean } = api.term.findMany.useQuery({ + where: { + taxonomy: { + slug: TaxonomySlug.CATEGORY, + }, + parentId: null, + }, + take: 8, + }); + const navigate = useNavigate(); - const handleMouseLeave = useCallback(() => { - setHoveredIndex(null); - }, []); + const handleMouseEnter = useCallback((index: number) => { + setHoveredIndex(index); + }, []); - const displayedCategories = showAll - ? courseCategories - : courseCategories.slice(0, 8); + const handleMouseLeave = useCallback(() => { + setHoveredIndex(null); + }, []); - return ( -
-
-
- - 探索课程分类 - - - 选择你感兴趣的方向,开启学习之旅 - -
-
- {displayedCategories.map((category, index) => { - const categoryColor = stringToColor(category.name); - const isHovered = hoveredIndex === index; + const handleMouseClick = useCallback((categoryId: string) => { + setSelectedTerms({ + ...selectedTerms, + [TaxonomySlug.CATEGORY]: [categoryId], + }); + navigate("/courses"); + window.scrollTo({ top: 0, behavior: "smooth" }); + }, []); + return ( +
+
+
+ + 探索课程分类 + + + 选择你感兴趣的方向,开启学习之旅 + +
+
+ {isLoading ? ( + + ) : ( + courseCategoriesData?.map((category, index) => { + const categoryColor = stringToColor(category.name); + const isHovered = hoveredIndex === index; - return ( -
handleMouseEnter(index)} - onMouseLeave={handleMouseLeave} - role="button" - tabIndex={0} - aria-label={`查看${category.name}课程类别`} - > -
-
-
-
-
-
- - {category.name} - - - {category.count} 门课程 - -
- - {category.description} - -
- 了解更多 - - → - -
-
-
- ); - })} -
- {courseCategories.length > 8 && ( -
- -
- )} -
-
- ); + return ( + + ); + }) + )} +
+ +
+
+ ); }; -export default CategorySection; \ No newline at end of file +export default CategorySection; diff --git a/apps/web/src/app/main/home/components/CategorySectionCard.tsx b/apps/web/src/app/main/home/components/CategorySectionCard.tsx new file mode 100755 index 0000000..e3e1273 --- /dev/null +++ b/apps/web/src/app/main/home/components/CategorySectionCard.tsx @@ -0,0 +1,58 @@ +import { useNavigate } from "react-router-dom"; +import { Typography } from "antd"; +export default function CategorySectionCard({handleMouseClick, index,handleMouseEnter,handleMouseLeave,category,categoryColor,isHovered,}) { + const navigate = useNavigate() + const { Title, Text } = Typography; + return ( +
handleMouseEnter(index)} + onMouseLeave={handleMouseLeave} + role="button" + tabIndex={0} + aria-label={`查看${category.name}课程类别`} + onClick={()=>{ + handleMouseClick(category.id) + }} + > +
+
+
+
+
+
+ + {category.name} + +
+
+ 了解更多 + + → + +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/app/main/home/components/CoursesSection.tsx b/apps/web/src/app/main/home/components/CoursesSection.tsx index 8d6c1c5..cc68bd0 100755 --- a/apps/web/src/app/main/home/components/CoursesSection.tsx +++ b/apps/web/src/app/main/home/components/CoursesSection.tsx @@ -1,227 +1,112 @@ -import React, { useState, useMemo, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Button, Card, Typography, Tag, Progress,Spin } from 'antd'; -import { - PlayCircleOutlined, - UserOutlined, - ClockCircleOutlined, - TeamOutlined, - StarOutlined, - ArrowRightOutlined, -} from '@ant-design/icons'; -import { TaxonomySlug, TermDto } from '@nice/common'; -import { api } from '@nice/client'; -import { GetTaxonomyProps, useGetTaxonomy } from '@web/src/hooks/useGetTaxonomy'; - +import React, { useState, useMemo, ReactNode } from "react"; +import { Typography, Skeleton } from "antd"; +import { TaxonomySlug, TermDto } from "@nice/common"; +import { api } from "@nice/client"; +import { CoursesSectionTag } from "./CoursesSectionTag"; +import LookForMore from "./LookForMore"; +import PostList from "@web/src/components/models/course/list/PostList"; +interface GetTaxonomyProps { + categories: string[]; + isLoading: boolean; +} +function useGetTaxonomy({ type }): GetTaxonomyProps { + const { data, isLoading }: { data: TermDto[]; isLoading: boolean } = + api.term.findMany.useQuery({ + where: { + taxonomy: { + slug: type, + }, + parentId: null, + }, + take: 11, // 只取前10个 + }); + const categories = useMemo(() => { + const allCategories = isLoading + ? [] + : data?.map((course) => course.name); + return [...Array.from(new Set(allCategories))]; + }, [data]); + return { categories, isLoading }; +} const { Title, Text } = Typography; - -interface Course { - id: number; - title: string; - instructor: string; - students: number; - rating: number; - level: string; - duration: string; - category: string; - progress: number; - thumbnail: string; -} - interface CoursesSectionProps { - title: string; - description: string; - courses: Course[]; - initialVisibleCoursesCount?: number; + title: string; + description: string; + initialVisibleCoursesCount?: number; + postType:string; + render?:(post)=>ReactNode; + to:string } - - const CoursesSection: React.FC = ({ - title, - description, - courses, - initialVisibleCoursesCount = 8, + title, + description, + initialVisibleCoursesCount = 8, + postType, + render, + to }) => { - const navigate = useNavigate(); - const [selectedCategory, setSelectedCategory] = useState('全部'); - const [visibleCourses, setVisibleCourses] = useState(initialVisibleCoursesCount); - const gateGory : GetTaxonomyProps = useGetTaxonomy({ - type: TaxonomySlug.CATEGORY, - }) - useEffect(() => { - - }) - const filteredCourses = useMemo(() => { - return selectedCategory === '全部' - ? courses - : courses.filter((course) => course.category === selectedCategory); - }, [selectedCategory, courses]); - - const displayedCourses = filteredCourses.slice(0, visibleCourses); - - return ( -
-
-
-
- - {title} - - - {description} - -
-
- -
- {gateGory.isLoading ? : - ( - <> - setSelectedCategory("全部")} - className={`px-4 py-2 text-base cursor-pointer hover:scale-105 transform transition-all duration-300 ${selectedCategory === "全部" - ? 'shadow-[0_2px_8px_-4px_rgba(59,130,246,0.5)]' - : 'hover:shadow-md' - }`} - >全部 - { - gateGory.categories.map((category) => ( - setSelectedCategory(category)} - className={`px-4 py-2 text-base cursor-pointer hover:scale-105 transform transition-all duration-300 ${selectedCategory === category - ? 'shadow-[0_2px_8px_-4px_rgba(59,130,246,0.5)]' - : 'hover:shadow-md' - }`} - > - {category} - - )) - } - - - ) - } -
- -
- {displayedCourses.map((course) => ( - -
-
- - {course.progress > 0 && ( -
- -
- )} -
- } - > -
-
- - {course.category} - - - {course.level} - -
- - {course.title} - -
- - - {course.instructor} - -
-
- - - {course.duration} - - - - {course.students.toLocaleString()} - - - - {course.rating} - -
-
- -
-
- - ))} -
- - {filteredCourses.length >= visibleCourses && ( -
-
-
-
navigate('/courses')} - className="cursor-pointer tracking-widest text-gray-500 hover:text-primary font-medium flex items-center gap-2 transition-all duration-300 ease-in-out" - > - 查看更多 - -
- -
- -
- )} -
-
- ); + const [selectedCategory, setSelectedCategory] = useState("全部"); + const gateGory: GetTaxonomyProps = useGetTaxonomy({ + type: TaxonomySlug.CATEGORY, + }); + return ( +
+
+
+
+ + {title} + + + {description} + +
+
+
+ {gateGory.isLoading ? ( + + ) : ( + <> + {["全部", ...gateGory.categories].map( + (category, idx) => ( + + ) + )} + + )} +
+ render(post)} + params={{ + page: 1, + pageSize: initialVisibleCoursesCount, + where: { + terms: !(selectedCategory === "全部") + ? { + some: { + name: selectedCategory, + }, + } + : {}, + type: postType + }, + }} + showPagination={false} + cols={4}> + +
+
+ ); }; - export default CoursesSection; diff --git a/apps/web/src/app/main/home/components/CoursesSectionTag.tsx b/apps/web/src/app/main/home/components/CoursesSectionTag.tsx new file mode 100755 index 0000000..c03d718 --- /dev/null +++ b/apps/web/src/app/main/home/components/CoursesSectionTag.tsx @@ -0,0 +1,24 @@ +import { Tag } from "antd"; + +export function CoursesSectionTag({category, selectedCategory, setSelectedCategory}) { + return ( + <> + { + setSelectedCategory(category); + }} + className={`px-6 py-2 text-base cursor-pointer rounded-full transition-all duration-300 ${selectedCategory === category + ? "bg-blue-600 text-white shadow-lg" + : "bg-white text-gray-600 hover:bg-gray-100" + }`}> + {category} + + + ) +} diff --git a/apps/web/src/app/main/home/components/HeroSection.tsx b/apps/web/src/app/main/home/components/HeroSection.tsx index b577173..8f6b7a8 100755 --- a/apps/web/src/app/main/home/components/HeroSection.tsx +++ b/apps/web/src/app/main/home/components/HeroSection.tsx @@ -1,157 +1,161 @@ -import React, { useRef, useCallback } from 'react'; -import { Button, Carousel, Typography } from 'antd'; +import React, { + useRef, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { Carousel, Typography } from "antd"; import { - TeamOutlined, - BookOutlined, - StarOutlined, - ClockCircleOutlined, - LeftOutlined, - RightOutlined -} from '@ant-design/icons'; -import type { CarouselRef } from 'antd/es/carousel'; + TeamOutlined, + BookOutlined, + StarOutlined, + LeftOutlined, + RightOutlined, + EyeOutlined, +} from "@ant-design/icons"; +import type { CarouselRef } from "antd/es/carousel"; +import { useAppConfig } from "@nice/client"; const { Title, Text } = Typography; interface CarouselItem { - title: string; - desc: string; - image: string; - action: string; - color: string; + title: string; + desc: string; + image: string; + action: string; + color: string; } interface PlatformStat { - icon: React.ReactNode; - value: string; - label: string; + icon: React.ReactNode; + value: number; + label: string; } -const carouselItems: CarouselItem[] = [ - { - title: '探索编程世界', - desc: '从零开始学习编程,开启你的技术之旅', - image: '/images/banner1.jpg', - action: '立即开始', - color: 'from-blue-600/90' - }, - { - title: '人工智能课程', - desc: '掌握AI技术,引领未来发展', - image: '/images/banner2.jpg', - action: '了解更多', - color: 'from-purple-600/90' - } -]; - -const platformStats: PlatformStat[] = [ - { icon: , value: '50,000+', label: '注册学员' }, - { icon: , value: '1,000+', label: '精品课程' }, - { icon: , value: '98%', label: '好评度' }, - { icon: , value: '100万+', label: '学习时长' } -]; - const HeroSection = () => { - const carouselRef = useRef(null); + const carouselRef = useRef(null); + const { statistics, slides } = useAppConfig(); + const [countStatistics, setCountStatistics] = useState(4); + const platformStats: PlatformStat[] = useMemo(() => { + return [ + { + icon: , + value: statistics.staffs, + label: "注册学员", + }, + { + icon: , + value: statistics.courses, + label: "精品课程", + }, + { + icon: , + value: statistics.lectures, + label: "课程章节", + }, + { + icon: , + value: statistics.reads, + label: "播放次数", + }, + ]; + }, [statistics]); + const handlePrev = useCallback(() => { + carouselRef.current?.prev(); + }, []); - const handlePrev = useCallback(() => { - carouselRef.current?.prev(); - }, []); + const handleNext = useCallback(() => { + carouselRef.current?.next(); + }, []); - const handleNext = useCallback(() => { - carouselRef.current?.next(); - }, []); + const countNonZeroValues = (statistics: Record): number => { + return Object.values(statistics).filter((value) => value !== 0).length; + }; - return ( -
-
- - {carouselItems.map((item, index) => ( -
-
-
-
+ useEffect(() => { + const count = countNonZeroValues(statistics); + console.log(count); + setCountStatistics(count); + }, [statistics]); + return ( +
+
+ + {Array.isArray(slides) ? ( + slides.map((item, index) => ( +
+
+ {/*
*/} +
- {/* Content Container */} -
-
- - {item.title} - - - {item.desc} - - -
-
-
- ))} - + {/* Content Container */} +
+
+ )) + ) : ( +
+ )} + - {/* Navigation Buttons */} - - -
+ {/* Navigation Buttons */} + + +
- {/* Stats Container */} -
-
- {platformStats.map((stat, index) => ( -
-
- {stat.icon} -
-
- {stat.value} -
-
{stat.label}
-
- ))} -
-
-
- ); + {/* Stats Container */} + {countStatistics > 1 && ( +
+
+ {platformStats.map((stat, index) => { + return stat.value ? ( +
+
+ {stat.icon} +
+
+ {stat.value} +
+
+ {stat.label} +
+
+ ) : null; + })} +
+
+ )} +
+ ); }; -export default HeroSection; \ No newline at end of file +export default HeroSection; diff --git a/apps/web/src/app/main/home/components/LookForMore.tsx b/apps/web/src/app/main/home/components/LookForMore.tsx new file mode 100755 index 0000000..2bd74cc --- /dev/null +++ b/apps/web/src/app/main/home/components/LookForMore.tsx @@ -0,0 +1,27 @@ +import { ArrowRightOutlined } from "@ant-design/icons"; +import { Button } from "antd"; +import { useNavigate } from "react-router-dom"; + +export default function LookForMore({to}:{to:string}) { + const navigate = useNavigate(); + return ( + <> +
+
+
+ +
+
+ + ) + +} diff --git a/apps/web/src/app/main/home/page.tsx b/apps/web/src/app/main/home/page.tsx index 8715fbf..4482872 100755 --- a/apps/web/src/app/main/home/page.tsx +++ b/apps/web/src/app/main/home/page.tsx @@ -1,126 +1,34 @@ -import HeroSection from './components/HeroSection'; -import CategorySection from './components/CategorySection'; -import CoursesSection from './components/CoursesSection'; -import FeaturedTeachersSection from './components/FeaturedTeachersSection'; +import HeroSection from "./components/HeroSection"; +import CategorySection from "./components/CategorySection"; +import CoursesSection from "./components/CoursesSection"; +import { PostType } from "@nice/common"; +import PathCard from "@web/src/components/models/post/SubPost/PathCard"; +import CourseCard from "@web/src/components/models/post/SubPost/CourseCard"; + + const HomePage = () => { - const mockCourses = [ - { - id: 1, - title: 'Python 零基础入门', - instructor: '张教授', - students: 12000, - rating: 4.8, - level: '入门', - duration: '36小时', - category: '编程语言', - progress: 0, - thumbnail: '/images/course1.jpg', - }, - { - id: 2, - title: '数据结构与算法', - instructor: '李教授', - students: 8500, - rating: 4.9, - level: '进阶', - duration: '48小时', - category: '计算机基础', - progress: 35, - thumbnail: '/images/course2.jpg', - }, - { - id: 3, - title: '前端开发实战', - instructor: '王教授', - students: 10000, - rating: 4.7, - level: '中级', - duration: '42小时', - category: '前端开发', - progress: 68, - thumbnail: '/images/course3.jpg', - }, - { - id: 4, - title: 'Java企业级开发', - instructor: '刘教授', - students: 9500, - rating: 4.6, - level: '高级', - duration: '56小时', - category: '编程语言', - progress: 0, - thumbnail: '/images/course4.jpg', - }, - { - id: 5, - title: '人工智能基础', - instructor: '陈教授', - students: 11000, - rating: 4.9, - level: '中级', - duration: '45小时', - category: '人工智能', - progress: 20, - thumbnail: '/images/course5.jpg', - }, - { - id: 6, - title: '大数据分析', - instructor: '赵教授', - students: 8000, - rating: 4.8, - level: '进阶', - duration: '50小时', - category: '数据科学', - progress: 45, - thumbnail: '/images/course6.jpg', - }, - { - id: 7, - title: '云计算实践', - instructor: '孙教授', - students: 7500, - rating: 4.7, - level: '高级', - duration: '48小时', - category: '云计算', - progress: 15, - thumbnail: '/images/course7.jpg', - }, - { - id: 8, - title: '移动应用开发', - instructor: '周教授', - students: 9000, - rating: 4.8, - level: '中级', - duration: '40小时', - category: '移动开发', - progress: 0, - thumbnail: '/images/course8.jpg', - }, - ]; - return ( -
- - {/* */} - - - - - -
- ); + return ( +
+ + } + to={"path"} + /> + } + to={"/courses"} + /> + + +
+ ); }; -export default HomePage; \ No newline at end of file +export default HomePage; diff --git a/apps/web/src/app/main/layout/BasePost/BasePostLayout.tsx b/apps/web/src/app/main/layout/BasePost/BasePostLayout.tsx new file mode 100644 index 0000000..09c7852 --- /dev/null +++ b/apps/web/src/app/main/layout/BasePost/BasePostLayout.tsx @@ -0,0 +1,29 @@ +import { ReactNode, useEffect } from "react"; +import FilterSection from "./FilterSection"; +import { useMainContext } from "../MainProvider"; + +export function BasePostLayout({ + children, + showSearchMode = false, +}: { + children: ReactNode; + showSearchMode?: boolean; +}) { + const { setShowSearchMode } = useMainContext(); + useEffect(() => { + setShowSearchMode(showSearchMode); + }, [showSearchMode]); + return ( + <> +
+
+
+ +
+
{children}
+
+
+ + ); +} +export default BasePostLayout; diff --git a/apps/web/src/app/main/layout/BasePost/FilterSection.tsx b/apps/web/src/app/main/layout/BasePost/FilterSection.tsx new file mode 100755 index 0000000..084878c --- /dev/null +++ b/apps/web/src/app/main/layout/BasePost/FilterSection.tsx @@ -0,0 +1,46 @@ +import { Divider } from "antd"; +import { api } from "@nice/client"; +import { useMainContext } from "../MainProvider"; +import TermParentSelector from "@web/src/components/models/term/term-parent-selector"; +import SearchModeRadio from "./SearchModeRadio"; +export default function FilterSection() { + const { data: taxonomies } = api.taxonomy.getAll.useQuery({}); + const { selectedTerms, setSelectedTerms, showSearchMode } = + useMainContext(); + const handleTermChange = (slug: string, selected: string[]) => { + setSelectedTerms({ + ...selectedTerms, + [slug]: selected, // 更新对应 slug 的选择 + }); + }; + return ( +
+ {showSearchMode && } + {taxonomies?.map((tax, index) => { + const items = Object.entries(selectedTerms).find( + ([key, items]) => key === tax.slug + )?.[1]; + return ( +
+

+ {tax?.name} + {/* {JSON.stringify(items)} */} +

+ + handleTermChange( + tax?.slug, + selected as string[] + ) + } + taxonomyId={tax?.id}> + +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/app/main/layout/BasePost/SearchModeRadio.tsx b/apps/web/src/app/main/layout/BasePost/SearchModeRadio.tsx new file mode 100644 index 0000000..507e94f --- /dev/null +++ b/apps/web/src/app/main/layout/BasePost/SearchModeRadio.tsx @@ -0,0 +1,25 @@ +import { useMainContext } from "../MainProvider"; +import { Radio, Space, Typography } from "antd"; +import { PostType } from "@nice/common"; // Assuming PostType is defined in this path + +export default function SearchModeRadio() { + const { searchMode, setSearchMode } = useMainContext(); + + const handleModeChange = (e) => { + setSearchMode(e.target.value); + }; + + return ( + +

只搜索

+ + 视频课程 + 思维导图 + 所有资源 + +
+ ); +} diff --git a/apps/web/src/app/main/layout/MainFooter.tsx b/apps/web/src/app/main/layout/MainFooter.tsx index 1356335..b232752 100755 --- a/apps/web/src/app/main/layout/MainFooter.tsx +++ b/apps/web/src/app/main/layout/MainFooter.tsx @@ -1,68 +1,76 @@ -import { CloudOutlined, FileSearchOutlined, HomeOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons'; -import { Layout, Typography } from 'antd'; +import { + CloudOutlined, + FileSearchOutlined, + HomeOutlined, + MailOutlined, + PhoneOutlined, +} from "@ant-design/icons"; + export function MainFooter() { - return ( -
-
-
- {/* 开发组织信息 */} -
-

- 创新高地 软件小组 -

-

- 提供技术支持 -

-
+ return ( +
+
+
+ {/* 开发组织信息 */} +
+

+ 软件与数据小组 +

+

+ 提供技术支持 +

+
- {/* 联系方式 */} -
-
- - 628118 -
-
- - gcsjs6@tx3l.nb.kj -
-
+ {/* 联系方式 */} +
+
+ + + 628118 + +
+
+ + + gcsjs6@tx3l.nb.kj + +
+
- {/* 系统链接 */} -
-
- - - - - - + {/* 系统链接 */} +
+ -
-
+ + + +
+
+
- {/* 版权信息 */} -
-

- © {new Date().getFullYear()} 南天烽火. All rights reserved. -

-
-
-
- ); + {/* 版权信息 */} +
+

+ © {new Date().getFullYear()} 南天烽火. All rights + reserved. +

+
+
+ + ); } diff --git a/apps/web/src/app/main/layout/MainHeader.tsx b/apps/web/src/app/main/layout/MainHeader.tsx index 80ea733..29e70d7 100755 --- a/apps/web/src/app/main/layout/MainHeader.tsx +++ b/apps/web/src/app/main/layout/MainHeader.tsx @@ -1,75 +1,108 @@ -import { useState } from "react"; -import { Input, Layout, Avatar, Button, Dropdown } from "antd"; -import { EditFilled, SearchOutlined, UserOutlined } from "@ant-design/icons"; +import { Input, Button } from "antd"; +import { PlusOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons"; import { useAuth } from "@web/src/providers/auth-provider"; -import { useNavigate } from "react-router-dom"; -import { UserMenu } from "./UserMenu"; +import { useNavigate, useParams } from "react-router-dom"; +import { UserMenu } from "./UserMenu/UserMenu"; import { NavigationMenu } from "./NavigationMenu"; - -const { Header } = Layout; - +import { useMainContext } from "./MainProvider"; export function MainHeader() { - const [searchValue, setSearchValue] = useState(""); const { isAuthenticated, user } = useAuth(); + const { id } = useParams(); const navigate = useNavigate(); + const { searchValue, setSearchValue } = useMainContext(); return ( -
-
-
-
navigate("/")} - className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer"> - 烽火慕课 -
- +
+ {/* 左侧区域 - 设置为不收缩 */} +
+ +
navigate("/")} + className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer whitespace-nowrap"> + 烽火慕课
-
-
- + +
+ + {/* 右侧区域 - 可以灵活收缩 */} +
+
+ + } + placeholder="搜索课程" + className="w-full md:w-96 rounded-full" + value={searchValue} + onClick={(e) => { + if ( + !window.location.pathname.startsWith("/search") + ) { + navigate(`/search`); + window.scrollTo({ + top: 0, + behavior: "smooth", + }); } - placeholder="搜索课程" - className="w-72 rounded-full" - value={searchValue} - onChange={(e) => setSearchValue(e.target.value)} - /> -
+ }} + onChange={(e) => setSearchValue(e.target.value)} + onPressEnter={(e) => { + if ( + !window.location.pathname.startsWith("/search") + ) { + navigate(`/search`); + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + } + }} + /> {isAuthenticated && ( <> )} + {isAuthenticated && ( + + )} {isAuthenticated ? ( - } - trigger={["click"]} - placement="bottomRight"> - - {(user?.showname || - user?.username || - "")[0]?.toUpperCase()} - - + ) : ( )}
-
+
); } diff --git a/apps/web/src/app/main/layout/MainLayout.tsx b/apps/web/src/app/main/layout/MainLayout.tsx index 09f5670..fa3bad7 100755 --- a/apps/web/src/app/main/layout/MainLayout.tsx +++ b/apps/web/src/app/main/layout/MainLayout.tsx @@ -1,19 +1,21 @@ -import { Layout } from 'antd'; -import { Outlet } from 'react-router-dom'; -import { MainHeader } from './MainHeader'; -import { MainFooter } from './MainFooter'; +import { Layout } from "antd"; +import { Outlet } from "react-router-dom"; +import { MainHeader } from "./MainHeader"; +import { MainFooter } from "./MainFooter"; +import { MainProvider } from "./MainProvider"; const { Content } = Layout; export function MainLayout() { - return ( - - - - - - - - - ); -} \ No newline at end of file + return ( + +
+ + + + + +
+
+ ); +} diff --git a/apps/web/src/app/main/layout/MainProvider.tsx b/apps/web/src/app/main/layout/MainProvider.tsx new file mode 100755 index 0000000..73db96a --- /dev/null +++ b/apps/web/src/app/main/layout/MainProvider.tsx @@ -0,0 +1,109 @@ +import { PostType, Prisma } from "@nice/common"; +import React, { + createContext, + ReactNode, + useContext, + useMemo, + useState, +} from "react"; +import { useDebounce } from "use-debounce"; +interface SelectedTerms { + [key: string]: string[]; // 每个 slug 对应一个 string 数组 +} + +interface MainContextType { + searchValue?: string; + selectedTerms?: SelectedTerms; + setSearchValue?: React.Dispatch>; + setSelectedTerms?: React.Dispatch>; + searchCondition?: Prisma.PostWhereInput; + termsCondition?: Prisma.PostWhereInput; + searchMode?: PostType.COURSE | PostType.PATH | "both"; + setSearchMode?: React.Dispatch< + React.SetStateAction + >; + showSearchMode?: boolean; + setShowSearchMode?: React.Dispatch>; +} + +const MainContext = createContext(null); +interface MainProviderProps { + children: ReactNode; +} + +export function MainProvider({ children }: MainProviderProps) { + const [searchMode, setSearchMode] = useState< + PostType.COURSE | PostType.PATH | "both" + >("both"); + const [showSearchMode, setShowSearchMode] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const [debouncedValue] = useDebounce(searchValue, 500); + const [selectedTerms, setSelectedTerms] = useState({}); // 初始化状态 + const termFilters = useMemo(() => { + return Object.entries(selectedTerms) + .filter(([, terms]) => terms.length > 0) + ?.map(([, terms]) => terms); + }, [selectedTerms]); + + const termsCondition: Prisma.PostWhereInput = useMemo(() => { + return termFilters && termFilters?.length > 0 + ? { + AND: termFilters.map((termFilter) => ({ + terms: { + some: { + id: { + in: termFilter, // 确保至少有一个 term.id 在当前 termFilter 中 + }, + }, + }, + })), + } + : {}; + }, [termFilters]); + const searchCondition: Prisma.PostWhereInput = useMemo(() => { + const containTextCondition: Prisma.StringNullableFilter = { + contains: debouncedValue, + mode: "insensitive" as Prisma.QueryMode, // 使用类型断言 + }; + return debouncedValue + ? { + OR: [ + { title: containTextCondition }, + { subTitle: containTextCondition }, + { content: containTextCondition }, + { + terms: { + some: { + name: containTextCondition, + }, + }, + }, + ], + } + : {}; + }, [searchValue, debouncedValue]); + return ( + + {children} + + ); +} +export const useMainContext = () => { + const context = useContext(MainContext); + if (!context) { + throw new Error("useMainContext must be used within MainProvider"); + } + return context; +}; diff --git a/apps/web/src/app/main/layout/NavigationMenu.tsx b/apps/web/src/app/main/layout/NavigationMenu.tsx index b1a4fb6..1f50140 100755 --- a/apps/web/src/app/main/layout/NavigationMenu.tsx +++ b/apps/web/src/app/main/layout/NavigationMenu.tsx @@ -1,31 +1,59 @@ -import { Menu } from 'antd'; -import { useNavigate, useLocation } from 'react-router-dom'; - -const menuItems = [ - { key: 'home', path: '/', label: '首页' }, - { key: 'courses', path: '/courses', label: '全部课程' }, - { key: 'paths', path: '/paths', label: '学习路径' } -]; +import { useAuth } from "@web/src/providers/auth-provider"; +import { Menu } from "antd"; +import { useMemo } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; export const NavigationMenu = () => { - const navigate = useNavigate(); - const { pathname } = useLocation(); - const selectedKey = menuItems.find(item => item.path === pathname)?.key || ''; - return ( - { - const selectedItem = menuItems.find(item => item.key === key); - if (selectedItem) navigate(selectedItem.path); - }} - > - {menuItems.map(({ key, label }) => ( - - {label} - - ))} - - ); -}; \ No newline at end of file + const navigate = useNavigate(); + const { isAuthenticated } = useAuth(); + const { pathname } = useLocation(); + + const menuItems = useMemo(() => { + const baseItems = [ + { key: "home", path: "/", label: "首页" }, + { key: "path", path: "/path", label: "全部思维导图" }, + { key: "courses", path: "/courses", label: "所有课程" }, + ]; + + if (!isAuthenticated) { + return baseItems; + } else { + return [ + ...baseItems, + { key: "my-duty", path: "/my-duty", label: "我创建的课程" }, + { key: "my-learning", path: "/my-learning", label: "我学习的课程" }, + { key: "my-duty-path", path: "/my-duty-path", label: "我创建的思维导图" }, + { key: "my-path", path: "/my-path", label: "我学习的思维导图" }, + ]; + } + }, [isAuthenticated]); + + const selectedKey = useMemo(() => { + const normalizePath = (path: string): string => path.replace(/\/$/, ""); + return pathname === '/' ? "home" : menuItems.find((item) => normalizePath(pathname) === item.path)?.key || ""; + }, [pathname]); + + return ( + { + const selectedItem = menuItems.find((item) => item.key === key); + if (selectedItem) navigate(selectedItem.path); + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + }}> + {menuItems.map(({ key, label }) => ( + + {label} + + ))} + + ); +}; diff --git a/apps/web/src/app/main/layout/UserMenu.tsx b/apps/web/src/app/main/layout/UserMenu.tsx deleted file mode 100755 index 2591253..0000000 --- a/apps/web/src/app/main/layout/UserMenu.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Avatar, Menu, Dropdown } from "antd"; -import { - LogoutOutlined, - SettingOutlined, - UserAddOutlined, - UserSwitchOutlined, -} from "@ant-design/icons"; -import { useAuth } from "@web/src/providers/auth-provider"; -import { useNavigate } from "react-router-dom"; - -export const UserMenu = () => { - const { isAuthenticated, logout, user } = useAuth(); - const navigate = useNavigate(); - - return ( - - {isAuthenticated ? ( - <> - -
- - {(user?.showname || - user?.username || - "")[0]?.toUpperCase()} - -
- - {user?.showname || user?.username} - - - {user?.department?.name || user?.officerId} - -
-
-
- - } - className="px-4"> - 个人设置 - - } - onClick={() => { - navigate("/admin/staff"); - }} - className="px-4"> - 后台管理 - - } - onClick={async () => await logout()} - className="px-4 text-red-500 hover:text-red-600 hover:bg-red-50"> - 退出登录 - - - ) : ( - navigate("/login")} - className="px-4 text-blue-500 hover:text-blue-600 hover:bg-blue-50"> - 登录/注册 - - )} -
- ); -}; diff --git a/apps/web/src/app/main/layout/UserMenu/UserEditModal.tsx b/apps/web/src/app/main/layout/UserMenu/UserEditModal.tsx new file mode 100755 index 0000000..ed30812 --- /dev/null +++ b/apps/web/src/app/main/layout/UserMenu/UserEditModal.tsx @@ -0,0 +1,27 @@ +import { Button, Drawer, Modal } from "antd"; +import React, { useContext, useEffect, useState } from "react"; + +import { UserEditorContext } from "./UserMenu"; +import UserForm from "./UserForm"; + +export default function UserEditModal() { + const { formLoading, modalOpen, setModalOpen, form } = + useContext(UserEditorContext); + const handleOk = () => { + form.submit(); + }; + return ( + { + setModalOpen(false); + }} + title={"编辑个人信息"}> + + + ); +} diff --git a/apps/web/src/app/main/layout/UserMenu/UserForm.tsx b/apps/web/src/app/main/layout/UserMenu/UserForm.tsx new file mode 100755 index 0000000..edef377 --- /dev/null +++ b/apps/web/src/app/main/layout/UserMenu/UserForm.tsx @@ -0,0 +1,164 @@ +import { Button, Form, Input, Spin, Switch, message } from "antd"; +import { useContext, useEffect } from "react"; +import { useStaff } from "@nice/client"; +import DepartmentSelect from "@web/src/components/models/department/department-select"; +import { api } from "@nice/client"; + +import { useAuth } from "@web/src/providers/auth-provider"; +import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader"; +import { StaffDto } from "@nice/common"; +import { UserEditorContext } from "./UserMenu"; +import toast from "react-hot-toast"; +export default function StaffForm() { + const { user } = useAuth(); + const { create, update } = useStaff(); // Ensure you have these methods in your hooks + const { formLoading, modalOpen, setModalOpen, domainId, setDomainId, form, setFormLoading, } = useContext(UserEditorContext); + const { + data, + isLoading, + }: { + data: StaffDto; + isLoading: boolean; + } = api.staff.findFirst.useQuery( + { where: { id: user?.id } }, + { enabled: !!user?.id } + ); + const { isRoot } = useAuth(); + async function handleFinish(values: any) { + const { + username, + showname, + deptId, + domainId, + password, + phoneNumber, + officerId, + enabled, + avatar, + photoUrl, + email, + rank, + office, + } = values; + setFormLoading(true); + try { + if (data && user?.id) { + await update.mutateAsync({ + where: { id: data.id }, + data: { + username, + deptId, + showname, + domainId, + password, + phoneNumber, + officerId, + enabled, + avatar, + }, + }); + } + toast.success("提交成功"); + setModalOpen(false); + } catch (err: any) { + toast.error(err.message); + } finally { + setFormLoading(false); + } + } + useEffect(() => { + form.resetFields(); + console.log('cc', data); + + if (data) { + form.setFieldValue("username", data.username); + form.setFieldValue("showname", data.showname); + form.setFieldValue("domainId", data.domainId); + form.setFieldValue("deptId", data.deptId); + form.setFieldValue("officerId", data.officerId); + form.setFieldValue("phoneNumber", data.phoneNumber); + form.setFieldValue("enabled", data.enabled); + form.setFieldValue("avatar", data.avatar); + } + }, [data]); + // useEffect(() => { + // if (!data && domainId) { + // form.setFieldValue("domainId", domainId); + // form.setFieldValue("deptId", domainId); + // } + // }, [domainId, data as any]); + return ( +
+ {isLoading && ( +
+ +
+ )} +
+
+
+ + + +
+ +
+ + + + + { + setDomainId(value as string); + }} + domain={true} + /> + + + + + + + + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx b/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx new file mode 100755 index 0000000..fc63758 --- /dev/null +++ b/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx @@ -0,0 +1,246 @@ +import { useClickOutside } from "@web/src/hooks/useClickOutside"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { motion, AnimatePresence } from "framer-motion"; +import React, { + useState, + useRef, + useCallback, + useMemo, + createContext, +} from "react"; +import { Avatar } from "@web/src/components/common/element/Avatar"; +import { + UserOutlined, + SettingOutlined, + LogoutOutlined, +} from "@ant-design/icons"; +import { FormInstance, Spin } from "antd"; +import { useNavigate } from "react-router-dom"; +import { MenuItemType } from "./types"; +import { RolePerms } from "@nice/common"; +import { useForm } from "antd/es/form/Form"; +import UserEditModal from "./UserEditModal"; +const menuVariants = { + hidden: { opacity: 0, scale: 0.95, y: -10 }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { + type: "spring", + stiffness: 300, + damping: 30, + }, + }, + exit: { + opacity: 0, + scale: 0.95, + y: -10, + transition: { + duration: 0.2, + }, + }, +}; + +export const UserEditorContext = createContext<{ + domainId: string; + setDomainId: React.Dispatch>; + modalOpen: boolean; + setModalOpen: React.Dispatch>; + form: FormInstance; + formLoading: boolean; + setFormLoading: React.Dispatch>; +}>({ + modalOpen: false, + domainId: undefined, + setDomainId: undefined, + setModalOpen: undefined, + form: undefined, + formLoading: undefined, + setFormLoading: undefined, +}); + +export function UserMenu() { + const [form] = useForm(); + const [formLoading, setFormLoading] = useState(); + const [showMenu, setShowMenu] = useState(false); + const menuRef = useRef(null); + const { user, logout, isLoading, hasSomePermissions } = useAuth(); + const navigate = useNavigate(); + useClickOutside(menuRef, () => setShowMenu(false)); + const [modalOpen, setModalOpen] = useState(false); + const [domainId, setDomainId] = useState(); + const toggleMenu = useCallback(() => { + setShowMenu((prev) => !prev); + }, []); + const canManageAnyStaff = useMemo(() => { + return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF); + }, [user]); + const menuItems: MenuItemType[] = useMemo( + () => + [ + { + icon: , + label: "个人信息", + action: () => { + setModalOpen(true); + }, + }, + + canManageAnyStaff && { + icon: , + label: "设置", + action: () => { + navigate("/admin/staff"); + }, + }, + + { + icon: , + label: "注销", + action: () => logout(), + }, + ].filter(Boolean), + [logout] + ); + + const handleMenuItemClick = useCallback((action: () => void) => { + action(); + setShowMenu(false); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + +
+ + {/* Avatar 容器,相对定位 */} +
+ + {/* 小绿点 */} +
+
+ + + {showMenu && ( + + {/* User Profile Section */} +
+
+ +
+ + {user?.showname || user?.username} + + + + 在线 + +
+
+
+ + {/* Menu Items */} +
+ {menuItems.map((item, index) => ( + + ))} +
+
+ )} +
+
+ +
+ ); +} diff --git a/apps/web/src/app/main/layout/UserMenu/types.ts b/apps/web/src/app/main/layout/UserMenu/types.ts new file mode 100755 index 0000000..dfe4b00 --- /dev/null +++ b/apps/web/src/app/main/layout/UserMenu/types.ts @@ -0,0 +1,6 @@ +import React, { ReactNode } from "react"; +export interface MenuItemType { + icon: ReactNode; + label: string; + action: () => void; +} diff --git a/apps/web/src/app/main/my-duty-path/components/MyDutyPathContainer.tsx b/apps/web/src/app/main/my-duty-path/components/MyDutyPathContainer.tsx new file mode 100644 index 0000000..148706e --- /dev/null +++ b/apps/web/src/app/main/my-duty-path/components/MyDutyPathContainer.tsx @@ -0,0 +1,30 @@ +import PostList from "@web/src/components/models/course/list/PostList"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { useMainContext } from "../../layout/MainProvider"; +import { PostType } from "@nice/common"; +import PathCard from "@web/src/components/models/post/SubPost/PathCard"; + +export default function MyLearningListContainer() { + const { user } = useAuth(); + const { searchCondition, termsCondition } = useMainContext(); + return ( + <> + } + params={{ + pageSize: 12, + where: { + type: PostType.PATH, + students: { + some: { + id: user?.id, + }, + }, + ...termsCondition, + ...searchCondition, + }, + }} + cols={4}> + + ); +} diff --git a/apps/web/src/app/main/my-duty-path/page.tsx b/apps/web/src/app/main/my-duty-path/page.tsx new file mode 100755 index 0000000..24d7931 --- /dev/null +++ b/apps/web/src/app/main/my-duty-path/page.tsx @@ -0,0 +1,17 @@ +import { useEffect } from "react"; +import BasePostLayout from "../layout/BasePost/BasePostLayout"; +import { useMainContext } from "../layout/MainProvider"; +import { PostType } from "@nice/common"; +import MyDutyPathContainer from "./components/MyDutyPathContainer"; + +export default function MyDutyPathPage() { + const { setSearchMode } = useMainContext(); + useEffect(() => { + setSearchMode(PostType.PATH); + }, [setSearchMode]); + return ( + + + + ); +} diff --git a/apps/web/src/app/main/my-duty/components/MyDutyListContainer.tsx b/apps/web/src/app/main/my-duty/components/MyDutyListContainer.tsx new file mode 100644 index 0000000..727e828 --- /dev/null +++ b/apps/web/src/app/main/my-duty/components/MyDutyListContainer.tsx @@ -0,0 +1,27 @@ +import PostList from "@web/src/components/models/course/list/PostList"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { PostType } from "@nice/common"; +import { useMainContext } from "../../layout/MainProvider"; +import PostCard from "@web/src/components/models/post/PostCard"; +import CourseCard from "@web/src/components/models/post/SubPost/CourseCard"; + +export default function MyDutyListContainer() { + const { user } = useAuth(); + const { searchCondition, termsCondition } = useMainContext(); + return ( + <> + } + params={{ + pageSize: 12, + where: { + type: PostType.COURSE, + authorId: user.id, + ...termsCondition, + ...searchCondition, + }, + }} + cols={4}> + + ); +} diff --git a/apps/web/src/app/main/my-duty/page.tsx b/apps/web/src/app/main/my-duty/page.tsx new file mode 100755 index 0000000..fff4c91 --- /dev/null +++ b/apps/web/src/app/main/my-duty/page.tsx @@ -0,0 +1,16 @@ +import BasePostLayout from "../layout/BasePost/BasePostLayout"; +import MyDutyListContainer from "./components/MyDutyListContainer"; +import { useEffect } from "react"; +import { useMainContext } from "../layout/MainProvider"; +import { PostType } from "@nice/common"; +export default function MyDutyPage() { + const { setSearchMode } = useMainContext(); + useEffect(() => { + setSearchMode(PostType.COURSE); + }, [setSearchMode]); + return ( + + + + ); +} diff --git a/apps/web/src/app/main/my-learning/components/MyLearningListContainer.tsx b/apps/web/src/app/main/my-learning/components/MyLearningListContainer.tsx new file mode 100644 index 0000000..be281ae --- /dev/null +++ b/apps/web/src/app/main/my-learning/components/MyLearningListContainer.tsx @@ -0,0 +1,31 @@ +import PostList from "@web/src/components/models/course/list/PostList"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { useMainContext } from "../../layout/MainProvider"; +import { PostType } from "@nice/common"; +import PostCard from "@web/src/components/models/post/PostCard"; +import CourseCard from "@web/src/components/models/post/SubPost/CourseCard"; + +export default function MyLearningListContainer() { + const { user } = useAuth(); + const { searchCondition, termsCondition } = useMainContext(); + return ( + <> + } + params={{ + pageSize: 12, + where: { + type: PostType.COURSE, + students: { + some: { + id: user?.id, + }, + }, + ...termsCondition, + ...searchCondition, + }, + }} + cols={4}> + + ); +} diff --git a/apps/web/src/app/main/my-learning/page.tsx b/apps/web/src/app/main/my-learning/page.tsx new file mode 100755 index 0000000..cee2c94 --- /dev/null +++ b/apps/web/src/app/main/my-learning/page.tsx @@ -0,0 +1,17 @@ +import { useEffect } from "react"; +import BasePostLayout from "../layout/BasePost/BasePostLayout"; +import { useMainContext } from "../layout/MainProvider"; +import MyLearningListContainer from "./components/MyLearningListContainer"; +import { PostType } from "@nice/common"; + +export default function MyLearningPage() { + const { setSearchMode } = useMainContext(); + useEffect(() => { + setSearchMode(PostType.COURSE); + }, [setSearchMode]); + return ( + + + + ); +} diff --git a/apps/web/src/app/main/my-path/components/MyPathListContainer.tsx b/apps/web/src/app/main/my-path/components/MyPathListContainer.tsx new file mode 100644 index 0000000..33e0346 --- /dev/null +++ b/apps/web/src/app/main/my-path/components/MyPathListContainer.tsx @@ -0,0 +1,28 @@ +import PostList from "@web/src/components/models/course/list/PostList"; +import { useAuth } from "@web/src/providers/auth-provider"; + +import { PostType } from "@nice/common"; +import { useMainContext } from "../../layout/MainProvider"; +import PostCard from "@web/src/components/models/post/PostCard"; +import PathCard from "@web/src/components/models/post/SubPost/PathCard"; + +export default function MyPathListContainer() { + const { user } = useAuth(); + const { searchCondition, termsCondition } = useMainContext(); + return ( + <> + } + params={{ + pageSize: 12, + where: { + type: PostType.PATH, + authorId: user.id, + ...termsCondition, + ...searchCondition, + }, + }} + cols={4}> + + ); +} diff --git a/apps/web/src/app/main/my-path/page.tsx b/apps/web/src/app/main/my-path/page.tsx new file mode 100755 index 0000000..81f7298 --- /dev/null +++ b/apps/web/src/app/main/my-path/page.tsx @@ -0,0 +1,17 @@ +import { useEffect } from "react"; +import BasePostLayout from "../layout/BasePost/BasePostLayout"; +import { useMainContext } from "../layout/MainProvider"; +import MyPathListContainer from "./components/MyPathListContainer"; +import { PostType } from "@nice/common"; + +export default function MyPathPage() { + const { setSearchMode } = useMainContext(); + useEffect(() => { + setSearchMode(PostType.PATH); + }, [setSearchMode]); + return ( + + + + ); +} diff --git a/apps/web/src/app/main/path/components/DeptInfo.tsx b/apps/web/src/app/main/path/components/DeptInfo.tsx new file mode 100644 index 0000000..ed6510b --- /dev/null +++ b/apps/web/src/app/main/path/components/DeptInfo.tsx @@ -0,0 +1,42 @@ +import { BookOutlined, EyeOutlined, TeamOutlined } from "@ant-design/icons"; +import { Typography } from "antd"; +import { PostDto } from "@nice/common"; + +const { Title, Text } = Typography; +const DeptInfo = ({ post }: { post: PostDto }) => { + return ( +
+
+ + {post?.depts && post?.depts?.length > 0 ? ( + + {post?.depts?.length > 1 + ? `${post.depts[0].name}等` + : post?.depts?.[0]?.name} + + ) : ( + + 未设置单位 + + )} +
+ {post && ( +
+ + 浏览量 + + {`${post?.views || 0}`} + + {post?.studentIds && post?.studentIds?.length > 0 && ( + + + {`${post?.studentIds?.length || 0}`} + + )} +
+ )} +
+ ); +}; + +export default DeptInfo; diff --git a/apps/web/src/app/main/path/components/PathListContainer.tsx b/apps/web/src/app/main/path/components/PathListContainer.tsx new file mode 100755 index 0000000..4e7ade6 --- /dev/null +++ b/apps/web/src/app/main/path/components/PathListContainer.tsx @@ -0,0 +1,25 @@ +import PostList from "@web/src/components/models/course/list/PostList"; +import { useMainContext } from "../../layout/MainProvider"; +import { PostType, Prisma } from "@nice/common"; +import PostCard from "@web/src/components/models/post/PostCard"; +import PathCard from "@web/src/components/models/post/SubPost/PathCard"; + +export function PathListContainer() { + const { searchCondition, termsCondition } = useMainContext(); + return ( + <> + } + params={{ + pageSize: 12, + where: { + type: PostType.PATH, + ...termsCondition, + ...searchCondition, + }, + }} + cols={4}> + + ); +} +export default PathListContainer; diff --git a/apps/web/src/app/main/path/components/TermInfo.tsx b/apps/web/src/app/main/path/components/TermInfo.tsx new file mode 100644 index 0000000..b26f721 --- /dev/null +++ b/apps/web/src/app/main/path/components/TermInfo.tsx @@ -0,0 +1,41 @@ +import { Tag } from "antd"; +import { PostDto, TaxonomySlug, TermDto } from "@nice/common"; + +const TermInfo = ({ terms = [] }: { terms?: TermDto[] }) => { + return ( +
+ {terms && terms?.length > 0 ? ( +
+ {terms?.map((term: any) => { + return ( + + {term.name} + + ); + })} +
+ ) : ( +
+ + {"未设置分类"} + +
+ )} +
+ ); +}; + +export default TermInfo; diff --git a/apps/web/src/app/main/path/editor/page.tsx b/apps/web/src/app/main/path/editor/page.tsx new file mode 100755 index 0000000..eaed95d --- /dev/null +++ b/apps/web/src/app/main/path/editor/page.tsx @@ -0,0 +1,10 @@ +import MindEditor from "@web/src/components/common/editor/MindEditor"; +import { useParams } from "react-router-dom"; + +export default function PathEditorPage() { + const { id } = useParams(); + + return
+ +
+} diff --git a/apps/web/src/app/main/path/page.tsx b/apps/web/src/app/main/path/page.tsx new file mode 100755 index 0000000..65178db --- /dev/null +++ b/apps/web/src/app/main/path/page.tsx @@ -0,0 +1,17 @@ +import { useEffect } from "react"; +import BasePostLayout from "../layout/BasePost/BasePostLayout"; +import { useMainContext } from "../layout/MainProvider"; +import PathListContainer from "./components/PathListContainer"; +import { PostType } from "@nice/common"; + +export default function PathPage() { + const { setSearchMode } = useMainContext(); + useEffect(() => { + setSearchMode(PostType.PATH); + }, [setSearchMode]); + return ( + + + + ); +} diff --git a/apps/web/src/app/main/paths/page.tsx b/apps/web/src/app/main/paths/page.tsx deleted file mode 100755 index e1283a0..0000000 --- a/apps/web/src/app/main/paths/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import MindEditor from "@web/src/components/common/editor/MindEditor"; - -export default function PathsPage() { - return ; -} diff --git a/apps/web/src/app/main/search/components/SearchContainer.tsx b/apps/web/src/app/main/search/components/SearchContainer.tsx new file mode 100644 index 0000000..292d9b5 --- /dev/null +++ b/apps/web/src/app/main/search/components/SearchContainer.tsx @@ -0,0 +1,33 @@ +import PostList from "@web/src/components/models/course/list/PostList"; +import { useMainContext } from "../../layout/MainProvider"; +import PostCard from "@web/src/components/models/post/PostCard"; +import { PostType } from "@nice/common"; +import CourseCard from "@web/src/components/models/post/SubPost/CourseCard"; +import PathCard from "@web/src/components/models/post/SubPost/PathCard"; +const POST_TYPE_COMPONENTS = { + [PostType.COURSE]: CourseCard, + [PostType.PATH]: PathCard, +}; +export default function SearchListContainer() { + const { searchCondition, termsCondition, searchMode } = useMainContext(); + + return ( + <> + { + const Component = + POST_TYPE_COMPONENTS[post.type] || PostCard; + return ; + }} + params={{ + pageSize: 12, + where: { + type: searchMode === "both" ? undefined : searchMode, + ...termsCondition, + ...searchCondition, + }, + }} + cols={4}> + + ); +} diff --git a/apps/web/src/app/main/search/page.tsx b/apps/web/src/app/main/search/page.tsx new file mode 100755 index 0000000..cfb6153 --- /dev/null +++ b/apps/web/src/app/main/search/page.tsx @@ -0,0 +1,20 @@ +import { useEffect } from "react"; +import BasePostLayout from "../layout/BasePost/BasePostLayout"; +import SearchListContainer from "./components/SearchContainer"; +import { useMainContext } from "../layout/MainProvider"; + +export default function SearchPage() { + const { setShowSearchMode, setSearchValue } = useMainContext(); + useEffect(() => { + setShowSearchMode(true); + return () => { + setShowSearchMode(false); + setSearchValue(""); + }; + }, [setShowSearchMode]); + return ( + + + + ); +} diff --git a/apps/web/src/components/common/container/CollapsibleContent.tsx b/apps/web/src/components/common/container/CollapsibleContent.tsx index 6d1ea96..7480439 100755 --- a/apps/web/src/components/common/container/CollapsibleContent.tsx +++ b/apps/web/src/components/common/container/CollapsibleContent.tsx @@ -1,52 +1,27 @@ -import React, { useRef, useState } from "react"; - +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 = ({ - content, - maxHeight = 150, -}) => { - const contentWrapperRef = useRef(null); - const [isExpanded, setIsExpanded] = useState(false); - - // Determine if content needs to be collapsed - const shouldCollapse = contentWrapperRef.current - ? contentWrapperRef.current.scrollHeight > maxHeight - : false; - +const CollapsibleContent: React.FC = ({ content }) => { + const contentWrapperRef = useRef(null); return ( -
-
-
- - {/* Gradient overlay */} - {shouldCollapse && !isExpanded && ( -
- )} +
+
+ {/* 包装整个内容区域的容器 */} +
+ {/* 内容区域 */} +
+
- - {/* Expand/Collapse button */} - {shouldCollapse && ( - - )}
); }; diff --git a/apps/web/src/components/common/editor/MindEditor.tsx b/apps/web/src/components/common/editor/MindEditor.tsx index 732cfae..dfcff70 100755 --- a/apps/web/src/components/common/editor/MindEditor.tsx +++ b/apps/web/src/components/common/editor/MindEditor.tsx @@ -1,26 +1,241 @@ +import { Button, Empty, Form, Spin } from "antd"; +import NodeMenu from "./NodeMenu"; +import { api, usePost, useVisitor } from "@nice/client"; +import { + ObjectType, + PathDto, + postDetailSelect, + PostType, + Prisma, + RolePerms, + VisitType, +} from "@nice/common"; +import TermSelect from "../../models/term/term-select"; +import DepartmentSelect from "../../models/department/department-select"; +import { useEffect, useMemo, useRef, useState } from "react"; +import toast from "react-hot-toast"; import { MindElixirInstance } from "mind-elixir"; -import { useRef, useEffect } from "react"; import MindElixir from "mind-elixir"; - -export default function MindEditor() { - const me = useRef(); +import { useTusUpload } from "@web/src/hooks/useTusUpload"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { MIND_OPTIONS } from "./constant"; +import { SaveOutlined } from "@ant-design/icons"; +export default function MindEditor({ id }: { id?: string }) { + const containerRef = useRef(null); + const [instance, setInstance] = useState(null); + const { isAuthenticated, user, hasSomePermissions } = useAuth(); + const { read } = useVisitor(); + const { data: post, isLoading }: { data: PathDto; isLoading: boolean } = + api.post.findFirst.useQuery( + { + where: { + id, + }, + select: postDetailSelect, + }, + { enabled: Boolean(id) } + ); + const canEdit: boolean = useMemo(() => { + const isAuth = isAuthenticated && user?.id === post?.author?.id; + return !!id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST); + }, [user]); + const navigate = useNavigate(); + const { create, update } = usePost(); + const { data: taxonomies } = api.taxonomy.getAll.useQuery({ + type: ObjectType.COURSE, + }); + const { handleFileUpload } = useTusUpload(); + const [form] = Form.useForm(); useEffect(() => { - const instance = new MindElixir({ - el: "#map", - direction: MindElixir.SIDE, - draggable: true, // default true - contextMenu: true, // default true - toolBar: true, // default true - nodeMenu: true, // default true - keypress: true, // default true + if (post?.id && id) { + read.mutateAsync({ + data: { + visitorId: user?.id || null, + postId: post?.id, + type: VisitType.READED, + }, + }); + } + }, [post]); + useEffect(() => { + if (post && form && instance && id) { + instance.refresh((post as any).meta); + const deptIds = (post?.depts || [])?.map((dept) => dept.id); + const formData = { + title: post.title, + deptIds: deptIds, + }; + post.terms?.forEach((term) => { + formData[term.taxonomyId] = term.id; // 假设 taxonomyName是您在 Form.Item 中使用的name + }); + form.setFieldsValue(formData); + } + }, [post, form, instance, id]); + useEffect(() => { + if (!containerRef.current) return; + const mind = new MindElixir({ + ...MIND_OPTIONS, + el: containerRef.current, + before: { + beginEdit() { + return canEdit; + }, + }, + draggable: canEdit, // 禁用拖拽 + contextMenu: canEdit, // 禁用右键菜单 + toolBar: canEdit, // 禁用工具栏 + nodeMenu: canEdit, // 禁用节点右键菜单 + keypress: canEdit, // 禁用键盘快捷键 }); - // instance.install(NodeMenu); - instance.init(MindElixir.new("新主题")); - me.current = instance; + mind.init(MindElixir.new("新思维导图")); + containerRef.current.hidden = true; + //挂载实例 + setInstance(mind); + }, [canEdit]); + useEffect(() => { + if ((!id || post) && instance) { + containerRef.current.hidden = false; + instance.toCenter(); + if (post?.meta?.nodeData) { + instance.refresh(post?.meta); + } + } + }, [id, post, instance]); + //保存 按钮 函数 + const handleSave = async () => { + if (!instance) return; + const values = form.getFieldsValue(); + //以图片格式导出思维导图以作为思维导图封面 + const imgBlob = await instance?.exportPng(); + handleFileUpload( + imgBlob, + async (result) => { + const termIds = taxonomies + .map((tax) => values[tax.id]) + .filter((id) => id); + const deptIds = (values?.deptIds || []) as string[]; + const { theme, ...data } = instance.getData(); + try { + if (post && id) { + const params: Prisma.PostUpdateArgs = { + where: { + id, + }, + data: { + title: data.nodeData.topic, + meta: { + ...data, + thumbnail: result.compressedUrl, + }, + terms: { + set: termIds.map((id) => ({ id })), + }, + depts: { + set: deptIds.map((id) => ({ id })), + }, + updatedAt: new Date(), + }, + }; + await update.mutateAsync(params); + toast.success("更新成功"); + } else { + const params: Prisma.PostCreateInput = { + type: PostType.PATH, + title: data.nodeData.topic, + meta: { ...data, thumbnail: result.compressedUrl }, + terms: { + connect: termIds.map((id) => ({ id })), + }, + depts: { + connect: deptIds.map((id) => ({ id })), + }, + updatedAt: new Date(), + }; + + const res = await create.mutateAsync({ data: params }); + navigate(`/path/editor/${res.id}`, { replace: true }); + toast.success("创建成功"); + } + } catch (error) { + toast.error("保存失败"); + throw error; + } + console.log(result); + }, + (error) => {}, + `mind-thumb-${new Date().toString()}` + ); + }; + useEffect(() => { + containerRef.current.style.height = `${Math.floor(window.innerHeight - 271)}px`; }, []); + return ( -
-
+
+ {canEdit && taxonomies && ( +
+
+
+ {taxonomies.map((tax, index) => ( + + + + ))} + + + +
+ {canEdit && ( + + )} +
+
+ )} +
e.preventDefault()} + /> + {canEdit && instance && } + {isLoading && ( +
+ +
+ )} + {!post && id && !isLoading && ( +
+ +
+ )}
); -} +} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/NodeMenu.tsx b/apps/web/src/components/common/editor/NodeMenu.tsx new file mode 100755 index 0000000..7ad908e --- /dev/null +++ b/apps/web/src/components/common/editor/NodeMenu.tsx @@ -0,0 +1,196 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Input, Button, ColorPicker, Select } from 'antd'; +import { + FontSizeOutlined, + BoldOutlined, + LinkOutlined, +} from '@ant-design/icons'; +import type { MindElixirInstance, NodeObj } from 'mind-elixir'; + +const xmindColorPresets = [ + // 经典16色 + '#FFFFFF', '#F5F5F5', // 白色系 + '#2196F3', '#1976D2', // 蓝色系 + '#4CAF50', '#388E3C', // 绿色系 + '#FF9800', '#F57C00', // 橙色系 + '#F44336', '#D32F2F', // 红色系 + '#9C27B0', '#7B1FA2', // 紫色系 + '#424242', '#757575', // 灰色系 + '#FFEB3B', '#FBC02D' // 黄色系 +]; + +interface NodeMenuProps { + mind: MindElixirInstance; +} + +//管理节点样式状态 +const NodeMenu: React.FC = ({ mind }) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedFontColor, setSelectedFontColor] = useState(''); + const [selectedBgColor, setSelectedBgColor] = useState(''); + const [selectedSize, setSelectedSize] = useState(''); + const [isBold, setIsBold] = useState(false); + const [url, setUrl] = useState(''); + const containerRef = useRef(null); + + + //监听思维导图节点选择事件,更新节点菜单状态 + useEffect(() => { + const handleSelectNode = (nodeObj: NodeObj) => { + setIsOpen(true); + const style = nodeObj.style || {}; + setSelectedFontColor(style.color || ''); + setSelectedBgColor(style.background || ''); + setSelectedSize(style.fontSize || '24'); + setIsBold(style.fontWeight === 'bold'); + setUrl(nodeObj.hyperLink || ''); + }; + const handleUnselectNode = () => { + setIsOpen(false); + }; + mind.bus.addListener('selectNode', handleSelectNode); + mind.bus.addListener('unselectNode', handleUnselectNode); + }, [mind]); + + useEffect(() => { + if (containerRef.current && mind.container) { + mind.container.appendChild(containerRef.current); + } + + }, [mind.container]); + + const handleColorChange = (type: "font" | "background", color: string) => { + if (type === 'font') { + setSelectedFontColor(color); + } else { + setSelectedBgColor(color); + } + const patch = { style: {} as any }; + if (type === 'font') { + patch.style.color = color; + } else { + patch.style.background = color; + } + mind.reshapeNode(mind.currentNode, patch); + }; + + const handleSizeChange = (size: string) => { + setSelectedSize(size); + mind.reshapeNode(mind.currentNode, { style: { fontSize: size } }); + }; + + const handleBoldToggle = () => { + const fontWeight = isBold ? '' : 'bold'; + setIsBold(!isBold); + mind.reshapeNode(mind.currentNode, { style: { fontWeight } }); + }; + + const handleUrlChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setUrl(value); + mind.reshapeNode(mind.currentNode, { hyperLink: value }); + }; + + return ( +
+
+ {/* Font Size Selector */} +
+

文字样式

+
+ } + /> + {url && !/^https?:\/\/\S+$/.test(url) && ( +

请输入有效的URL地址

+ )} +
+
+ <>wojiao +
+
+
+ ); +}; + +export default NodeMenu; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/constant.ts b/apps/web/src/components/common/editor/constant.ts new file mode 100644 index 0000000..29e6890 --- /dev/null +++ b/apps/web/src/components/common/editor/constant.ts @@ -0,0 +1,34 @@ +import MindElixir from "mind-elixir"; +export const MIND_OPTIONS = { + direction: MindElixir.SIDE, + draggable: true, + contextMenu: true, + toolBar: true, + nodeMenu: true, + keypress: true, + locale: "zh_CN" as const, + theme: { + name: "Latte", + palette: [ + "#dd7878", + "#ea76cb", + "#8839ef", + "#e64553", + "#fe640b", + "#df8e1d", + "#40a02b", + "#209fb5", + "#1e66f5", + "#7287fd", + ], + cssVar: { + "--main-color": "#444446", + "--main-bgcolor": "#ffffff", + "--color": "#777777", + "--bgcolor": "#f6f6f6", + "--panel-color": "#444446", + "--panel-bgcolor": "#ffffff", + "--panel-border-color": "#eaeaea", + }, + }, +}; diff --git a/apps/web/src/components/common/editor/quill/constants.ts b/apps/web/src/components/common/editor/quill/constants.ts index d47f27b..fd92bd1 100755 --- a/apps/web/src/components/common/editor/quill/constants.ts +++ b/apps/web/src/components/common/editor/quill/constants.ts @@ -1,11 +1,11 @@ export const defaultModules = { - toolbar: [ - [{ 'header': [1, 2, 3, 4, 5, 6, false] }], - ['bold', 'italic', 'underline', 'strike'], - [{ 'list': 'ordered' }, { 'list': 'bullet' }], - [{ 'color': [] }, { 'background': [] }], - [{ 'align': [] }], - ['link', 'image'], - ['clean'] - ] -}; \ No newline at end of file + toolbar: [ + [{ header: [1, 2, 3, 4, 5, 6, false] }], + ["bold", "italic", "underline", "strike"], + [{ list: "ordered" }, { list: "bullet" }], + [{ color: [] }, { background: [] }], + [{ align: [] }], + ["link"], + ["clean"], + ], +}; diff --git a/apps/web/src/components/common/editor/types.ts b/apps/web/src/components/common/editor/types.ts new file mode 100755 index 0000000..3810ba7 --- /dev/null +++ b/apps/web/src/components/common/editor/types.ts @@ -0,0 +1,14 @@ +import { MindElixirInstance, MindElixirData } from 'mind-elixir'; +import { PostType, ObjectType } from '@nice/common'; + +export interface MindEditorProps { + initialData?: MindElixirData; + onSave?: (data: MindElixirData) => Promise; + taxonomyType?: ObjectType; +} + +export interface MindEditorState { + instance: MindElixirInstance | null; + isSaving: boolean; + error: Error | null; +} diff --git a/apps/web/src/components/common/input/InputList.tsx b/apps/web/src/components/common/input/InputList.tsx index f6247d5..b0964a5 100755 --- a/apps/web/src/components/common/input/InputList.tsx +++ b/apps/web/src/components/common/input/InputList.tsx @@ -1,22 +1,29 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Input, Button } from "antd"; import { DeleteOutlined } from "@ant-design/icons"; interface InputListProps { - initialValue?: string[]; + value?: string[]; onChange?: (value: string[]) => void; placeholder?: string; } const InputList: React.FC = ({ - initialValue, + value, onChange, placeholder = "请输入内容", }) => { // Internal state management with fallback to initial value or empty array - const [inputValues, setInputValues] = useState( - initialValue && initialValue.length > 0 ? initialValue : [""] - ); + const [inputValues, setInputValues] = useState([""]); + + // Update inputValues when value changes + useEffect(() => { + if (value && value.length > 0) { + setInputValues(value); + } else { + setInputValues([""]); + } + }, [value]); // Handle individual input value change const handleInputChange = (index: number, newValue: string) => { diff --git a/apps/web/src/components/common/uploader/AvatarUploader.tsx b/apps/web/src/components/common/uploader/AvatarUploader.tsx index d4077f3..33246c8 100755 --- a/apps/web/src/components/common/uploader/AvatarUploader.tsx +++ b/apps/web/src/components/common/uploader/AvatarUploader.tsx @@ -12,6 +12,8 @@ export interface AvatarUploaderProps { onChange?: (value: string) => void; compressed?: boolean; style?: React.CSSProperties; // 添加style属性 + successText?: string; + showCover?: boolean; } interface UploadingFile { @@ -31,12 +33,14 @@ const AvatarUploader: React.FC = ({ className, placeholder = "点击上传", style, // 解构style属性 + successText = "上传成功", + showCover = true, }) => { const { handleFileUpload, uploadProgress } = useTusUpload(); const [file, setFile] = useState(null); const avatarRef = useRef(null); const [previewUrl, setPreviewUrl] = useState(value || ""); - + const [imageSrc, setImageSrc] = useState(value); const [compressedUrl, setCompressedUrl] = useState(value || ""); const [url, setUrl] = useState(value || ""); const [uploading, setUploading] = useState(false); @@ -44,7 +48,11 @@ const AvatarUploader: React.FC = ({ // 在组件中定义 key 状态 const [avatarKey, setAvatarKey] = useState(0); const { token } = theme.useToken(); - + useEffect(() => { + if (!previewUrl || previewUrl?.length < 1) { + setPreviewUrl(value || ""); + } + }, [value]); const handleChange = async (event: React.ChangeEvent) => { const selectedFile = event.target.files?.[0]; if (!selectedFile) return; @@ -88,12 +96,12 @@ const AvatarUploader: React.FC = ({ // 使用 resolved 的最新值调用 onChange // 强制刷新 Avatar 组件 setAvatarKey((prev) => prev + 1); // 修改 key 强制重新挂载 - onChange?.(uploadedUrl); console.log(uploadedUrl); - toast.success("头像上传成功"); + onChange?.(uploadedUrl); + toast.success(successText); } catch (error) { console.error("上传错误:", error); - toast.error("头像上传失败"); + toast.error("上传失败"); setFile((prev) => ({ ...prev!, status: "error" })); } finally { setUploading(false); @@ -120,12 +128,20 @@ const AvatarUploader: React.FC = ({ accept="image/*" style={{ display: "none" }} /> - {previewUrl ? ( + {(previewUrl && showCover) ? ( { + if (value && previewUrl && imageSrc === value) { + // 当原始图片(value)加载失败时,切换到 previewUrl + setImageSrc(previewUrl); + return true; // 阻止默认的 fallback 行为,让它尝试新设置的 src + } + return false; // 如果 previewUrl 也失败了,显示默认头像 + }} className="w-full h-full object-cover" /> ) : ( diff --git a/apps/web/src/components/common/uploader/MultiAvatarUploader.tsx b/apps/web/src/components/common/uploader/MultiAvatarUploader.tsx new file mode 100755 index 0000000..bf324ea --- /dev/null +++ b/apps/web/src/components/common/uploader/MultiAvatarUploader.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useState } from "react"; +import { Upload, Progress, Button, Image, Form } from "antd"; +import { DeleteOutlined } from "@ant-design/icons"; +import AvatarUploader from "./AvatarUploader"; +import { isEqual } from "lodash"; + +interface MultiAvatarUploaderProps { + value?: string[]; + onChange?: (value: string[]) => void; + className?: string; + placeholder?: string; + style?: React.CSSProperties; +} + +export function MultiAvatarUploader({ + value, + onChange, + className, + style, + placeholder = "点击上传", +}: MultiAvatarUploaderProps) { + const [imageList, setImageList] = useState(value || []); + const [previewImage, setPreviewImage] = useState(""); + useEffect(() => { + if (!isEqual(value, imageList)) { + setImageList(value || []); + } + }, [value]); + useEffect(() => { + onChange?.(imageList); + }, [imageList]); + return ( + <> +
+ {(imageList || [])?.map((image, index) => { + return ( +
+ + setPreviewImage( + visible ? image || "" : "" + ), + }}> +
+ ); + })} +
+
+ { + console.log(value); + setImageList([...imageList, value]); + }}> +
+ + ); +} +export default MultiAvatarUploader; diff --git a/apps/web/src/components/common/uploader/ResourceShower.tsx b/apps/web/src/components/common/uploader/ResourceShower.tsx new file mode 100755 index 0000000..e1e1e01 --- /dev/null +++ b/apps/web/src/components/common/uploader/ResourceShower.tsx @@ -0,0 +1,136 @@ +import React, { useMemo } from "react"; +import { Image, Button, Row, Col, Tooltip } from "antd"; +import { ResourceDto } from "@nice/common"; +import { env } from "@web/src/env"; +import { getFileIcon } from "./utils"; +import { formatFileSize, getCompressedImageUrl } from "@nice/utils"; + +export default function ResourcesShower({ + resources = [], +}: { + resources: ResourceDto[]; +}) { + const { resources: dealedResources } = useMemo(() => { + if (!resources) return { resources: [] }; + + const isImage = (url: string) => + /\.(png|jpg|jpeg|gif|webp)$/i.test(url); + + const sortedResources = resources + .map((resource) => { + const original = `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${resource.url}`; + const isImg = isImage(resource.url); + return { + ...resource, + url: isImg ? getCompressedImageUrl(original) : original, + originalUrl: original, + isImage: isImg, + }; + }) + .sort((a, b) => (a.isImage === b.isImage ? 0 : a.isImage ? -1 : 1)); + + return { resources: sortedResources }; + }, [resources]); + + const imageResources = dealedResources.filter((res) => res.isImage); + const fileResources = dealedResources.filter((res) => !res.isImage); + return ( +
+ {imageResources.length > 0 && ( + + + {imageResources.map((resource) => ( + +
+
+ {resource.title} + 点击预览 +
+ ), + }} + style={{ + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + objectFit: "cover", + }} + rootClassName="w-full h-full" + /> +
+ {resource.title && ( +
+ {resource.title} +
+ )} +
+ + ))} + + + )} +
附件:
+ {fileResources.length > 0 && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/common/uploader/TusUploader.tsx b/apps/web/src/components/common/uploader/TusUploader.tsx index e9fa45b..17e2685 100755 --- a/apps/web/src/components/common/uploader/TusUploader.tsx +++ b/apps/web/src/components/common/uploader/TusUploader.tsx @@ -11,6 +11,7 @@ export interface TusUploaderProps { value?: string[]; onChange?: (value: string[]) => void; multiple?: boolean; + allowTypes?: string[]; } interface UploadingFile { @@ -25,8 +26,8 @@ export const TusUploader = ({ value = [], onChange, multiple = true, + allowTypes = undefined, }: TusUploaderProps) => { - const { handleFileUpload, uploadProgress } = useTusUpload(); const [uploadingFiles, setUploadingFiles] = useState([]); const [completedFiles, setCompletedFiles] = useState( @@ -39,7 +40,6 @@ export const TusUploader = ({ })) || [] ); const [uploadResults, setUploadResults] = useState(value || []); - const handleRemoveFile = useCallback( (fileId: string) => { setCompletedFiles((prev) => @@ -61,7 +61,10 @@ export const TusUploader = ({ const handleBeforeUpload = useCallback( (file: File) => { - + if (allowTypes && !allowTypes.includes(file.type)) { + toast.error(`文件类型 ${file.type} 不在允许范围内`); + return Upload.LIST_IGNORE; // 使用 antd 的官方阻止方式 + } const fileKey = `${file.name}-${Date.now()}`; setUploadingFiles((prev) => [ @@ -136,10 +139,10 @@ export const TusUploader = ({ return (

@@ -149,6 +152,11 @@ export const TusUploader = ({

{multiple ? "支持单个或批量上传文件" : "仅支持上传单个文件"} + {allowTypes && ( + + 允许类型: {allowTypes.join(", ")} + + )}

@@ -165,10 +173,10 @@ export const TusUploader = ({ file.status === "done" ? 100 : Math.round( - uploadProgress?.[ - file.fileKey! - ] || 0 - ) + uploadProgress?.[ + file.fileKey! + ] || 0 + ) } status={ file.status === "error" diff --git a/apps/web/src/components/common/uploader/utils.tsx b/apps/web/src/components/common/uploader/utils.tsx new file mode 100755 index 0000000..8437211 --- /dev/null +++ b/apps/web/src/components/common/uploader/utils.tsx @@ -0,0 +1,48 @@ +import { + FilePdfOutlined, + FileWordOutlined, + FileExcelOutlined, + FilePptOutlined, + FileTextOutlined, + FileZipOutlined, + FileImageOutlined, + FileUnknownOutlined, +} from "@ant-design/icons"; + +export const isContentEmpty = (html: string) => { + // 创建一个临时 div 来解析 HTML 内容 + const temp = document.createElement("div"); + temp.innerHTML = html; + // 获取纯文本内容并检查是否为空 + return !temp.textContent?.trim(); +}; +export const getFileIcon = (filename: string) => { + const extension = filename.split(".").pop()?.toLowerCase(); + switch (extension) { + case "pdf": + return ; + case "doc": + case "docx": + return ; + case "xls": + case "xlsx": + return ; + case "ppt": + case "pptx": + return ; + case "txt": + return ; + case "zip": + case "rar": + case "7z": + return ; + case "png": + case "jpg": + case "jpeg": + case "gif": + case "webp": + return ; + default: + return ; + } +}; diff --git a/apps/web/src/components/layout/breadcrumb.tsx b/apps/web/src/components/layout/breadcrumb.tsx index 1804dab..bffbb65 100755 --- a/apps/web/src/components/layout/breadcrumb.tsx +++ b/apps/web/src/components/layout/breadcrumb.tsx @@ -1,38 +1,42 @@ -import React from 'react'; -import { useLocation, Link, useMatches } from 'react-router-dom'; -import { theme } from 'antd'; -import { RightOutlined } from '@ant-design/icons'; +import React from "react"; +import { useLocation, Link, useMatches } from "react-router-dom"; +import { theme } from "antd"; +import { RightOutlined } from "@ant-design/icons"; export default function Breadcrumb() { - let matches = useMatches(); - const { token } = theme.useToken() + const matches = useMatches(); + const { token } = theme.useToken(); - let crumbs = matches - // first get rid of any matches that don't have handle and crumb - .filter((match) => Boolean((match.handle as any)?.crumb)) - // now map them into an array of elements, passing the loader - // data to each one - .map((match) => (match.handle as any).crumb(match.data)); + const crumbs = matches + // first get rid of any matches that don't have handle and crumb + .filter((match) => Boolean((match.handle as any)?.crumb)) + // now map them into an array of elements, passing the loader + // data to each one + .map((match) => (match.handle as any).crumb(match.data)); - return ( -
    - {crumbs.map((crumb, index) => ( - -
  1. - {crumb} -
  2. - {index < crumbs.length - 1 && ( -
  3. - -
  4. - )} -
    - ))} -
- ); + return ( +
    + {crumbs.map((crumb, index) => ( + +
  1. + {crumb} +
  2. + {index < crumbs.length - 1 && ( +
  3. + +
  4. + )} +
    + ))} +
+ ); } diff --git a/apps/web/src/components/layout/element/usermenu/usermenu.tsx b/apps/web/src/components/layout/element/usermenu/usermenu.tsx index e00965b..31460cd 100755 --- a/apps/web/src/components/layout/element/usermenu/usermenu.tsx +++ b/apps/web/src/components/layout/element/usermenu/usermenu.tsx @@ -185,13 +185,13 @@ export function UserMenu() { id="user-menu" aria-orientation="vertical" aria-labelledby="user-menu-button" - style={{ zIndex: 100 }} + style={{ zIndex: 1000 }} className="absolute right-0 mt-3 w-64 origin-top-right bg-white rounded-xl overflow-hidden shadow-lg border border-[#E5EDF5]"> {/* User Profile Section */}
+ }`}> + group-hover:translate-x-0.5 ${item.label === "注销" + ? "group-hover:text-red-600" + : "group-hover:text-[#003F6A]" + }`}> {item.icon} {item.label} diff --git a/apps/web/src/components/models/course/card/CourseCard.tsx b/apps/web/src/components/models/course/card/CourseCard.tsx deleted file mode 100755 index 60e1f48..0000000 --- a/apps/web/src/components/models/course/card/CourseCard.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { CourseDto } from "@nice/common"; -import { Card } from "@web/src/components/common/container/Card"; -import { CourseHeader } from "./CourseHeader"; -import { CourseStats } from "./CourseStats"; -import { Popover } from "@web/src/components/presentation/popover"; -import { useState } from "react"; - -interface CourseCardProps { - course: CourseDto; - onClick?: () => void; -} - -export const CourseCard = ({ course, onClick }: CourseCardProps) => { - return ( - - - - - ); -}; diff --git a/apps/web/src/components/models/course/card/CourseStats.tsx b/apps/web/src/components/models/course/card/CourseStats.tsx index 0ff3906..51f547a 100755 --- a/apps/web/src/components/models/course/card/CourseStats.tsx +++ b/apps/web/src/components/models/course/card/CourseStats.tsx @@ -6,7 +6,6 @@ interface CourseStatsProps { completionRate?: number; totalDuration?: number; } - export const CourseStats = ({ averageRating, numberOfReviews, diff --git a/apps/web/src/components/models/course/detail/CourseDetail.tsx b/apps/web/src/components/models/course/detail/CourseDetail.tsx index 353ec4f..c33c268 100755 --- a/apps/web/src/components/models/course/detail/CourseDetail.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetail.tsx @@ -8,11 +8,7 @@ export default function CourseDetail({ id?: string; lectureId?: string; }) { - const iframeStyle = { - width: "50%", - height: "100vh", - border: "none", - }; + return ( <> diff --git a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx index c67a896..c833dc5 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx @@ -1,7 +1,21 @@ -import { api } from "@nice/client"; -import { courseDetailSelect, CourseDto, Lecture } from "@nice/common"; -import React, { createContext, ReactNode, useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { api, useVisitor } from "@nice/client"; +import { + courseDetailSelect, + CourseDto, + Lecture, + lectureDetailSelect, + RolePerms, + VisitType, +} from "@nice/common"; +import { useAuth } from "@web/src/providers/auth-provider"; +import React, { + createContext, + ReactNode, + useEffect, + useMemo, + useState, +} from "react"; +import { useNavigate, useParams } from "react-router-dom"; interface CourseDetailContextType { editId?: string; // 添加 editId @@ -10,13 +24,19 @@ interface CourseDetailContextType { selectedLectureId?: string | undefined; setSelectedLectureId?: React.Dispatch>; isLoading?: boolean; + lectureIsLoading?: boolean; isHeaderVisible: boolean; // 新增 setIsHeaderVisible: (visible: boolean) => void; // 新增 + canEdit?: boolean; + userIsLearning?: boolean; + setUserIsLearning:(learning: boolean) => void; } + interface CourseFormProviderProps { children: ReactNode; editId?: string; // 添加 editId 参数 } + export const CourseDetailContext = createContext(null); export function CourseDetailProvider({ @@ -24,30 +44,73 @@ export function CourseDetailProvider({ editId, }: CourseFormProviderProps) { const navigate = useNavigate(); + const { read } = useVisitor(); + const { user, hasSomePermissions, isAuthenticated } = useAuth(); + const { lectureId } = useParams(); + const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } = (api.post as any).findFirst.useQuery( { where: { id: editId }, - include: { - // sections: { include: { lectures: true } }, - enrollments: true, - }, + select: courseDetailSelect, }, { enabled: Boolean(editId) } ); + + // const userIsLearning = useMemo(() => { + // return (course?.studentIds || []).includes(user?.id); + // }, [user, course, isLoading]); + const [userIsLearning, setUserIsLearning] = useState(false); + useEffect(()=>{ + console.log(course?.studentIds,user?.id) + setUserIsLearning((course?.studentIds || []).includes(user?.id)); + },[user, course, isLoading]) + const canEdit = useMemo(() => { + const isAuthor = isAuthenticated && user?.id === course?.authorId; + const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST); + return isAuthor || isRoot; + }, [user, course]); + const [selectedLectureId, setSelectedLectureId] = useState< string | undefined - >(undefined); + >(lectureId || undefined); const { data: lecture, isLoading: lectureIsLoading } = ( api.post as any ).findFirst.useQuery( { where: { id: selectedLectureId }, + select: lectureDetailSelect, }, { enabled: Boolean(editId) } ); + useEffect(() => { - navigate(`/course/${editId}/detail/${selectedLectureId}`); + if (lectureId) { + console.log(123); + console.log(lectureId); + read.mutateAsync({ + data: { + visitorId: user?.id || null, + postId: lectureId, + type: VisitType.READED, + }, + }); + } else { + console.log(321); + console.log(editId); + read.mutateAsync({ + data: { + visitorId: user?.id || null, + postId: editId, + type: VisitType.READED, + }, + }); + } + }, [editId, lectureId]); + useEffect(() => { + if (lectureId !== selectedLectureId) { + navigate(`/course/${editId}/detail/${selectedLectureId}`); + } }, [selectedLectureId, editId]); const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增 return ( @@ -59,8 +122,12 @@ export function CourseDetailProvider({ selectedLectureId, setSelectedLectureId, isLoading, + lectureIsLoading, isHeaderVisible, setIsHeaderVisible, + canEdit, + userIsLearning, + setUserIsLearning }}> {children} diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx b/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx new file mode 100755 index 0000000..019dcbc --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx @@ -0,0 +1,90 @@ +import { Course, TaxonomySlug } from "@nice/common"; +import React, { useContext, useEffect, useMemo } from "react"; +import { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件 +import { CourseDetailContext } from "./CourseDetailContext"; +import { useNavigate, useParams } from "react-router-dom"; +import { useStaff } from "@nice/client"; +import { useAuth } from "@web/src/providers/auth-provider"; +import TermInfo from "@web/src/app/main/path/components/TermInfo"; +import { PictureOutlined } from "@ant-design/icons"; + +export const CourseDetailDescription: React.FC = () => { + const { + course, + canEdit, + isLoading, + selectedLectureId, + setSelectedLectureId, + userIsLearning, + lecture = null, + } = useContext(CourseDetailContext); + const { Paragraph } = Typography; + const { user } = useAuth(); + const { update } = useStaff(); + const firstLectureId = useMemo(() => { + return course?.sections?.[0]?.lectures?.[0]?.id; + }, [course]); + const navigate = useNavigate(); + const { id } = useParams(); + return ( + //
+
+ {isLoading || !course ? ( + + ) : ( +
+ {!selectedLectureId && ( +
+ { +
+ } +
{ + setSelectedLectureId(firstLectureId); + if (!userIsLearning) { + await update.mutateAsync({ + where: { id: user?.id }, + data: { + learningPosts: { + connect: { + id: course.id, + }, + }, + }, + }); + } + }} + className="absolute rounded-xl top-0 left-0 right-0 bottom-0 z-10 bg-[rgba(0,0,0,0.3)] transition-all duration-300 ease-in-out hover:bg-[rgba(0,0,0,0.7)] cursor-pointer group"> +
+ 点击进入学习 +
+
+
+ )} +
{"课程简介:"}
+
+
+ {course?.subTitle &&
{course?.subTitle}
} + +
+
+ console.log("展开"), + }}> + {course?.content} + +
+ )} +
+ ); +}; diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailDescription.tsx b/apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailDescription.tsx deleted file mode 100755 index a3c8114..0000000 --- a/apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailDescription.tsx +++ /dev/null @@ -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 = ({ - course, - isLoading, -}) => { - return ( - <> - - -
- {isLoading || !course ? ( - - ) : ( - - )} -
- - ); -}; diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailNavBar.tsx b/apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailNavBar.tsx deleted file mode 100755 index a5b862d..0000000 --- a/apps/web/src/components/models/course/detail/CourseDetailDescription/CourseDetailNavBar.tsx +++ /dev/null @@ -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: , - label: "搜索", - }, - { - id: "overview", - icon: , - label: "概述", - }, - { - id: "notes", - icon: , - label: "备注", - }, - { - id: "announcements", - icon: , - label: "公告", - }, - { - id: "reviews", - icon: , - label: "评价", - }, - ]; - - return ( -
- console.log("Selected:", id)} - /> -
- ); -} diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/Overview.tsx b/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/Overview.tsx deleted file mode 100755 index 334c36b..0000000 --- a/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/Overview.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useContext } from "react"; -import { CourseDetailContext } from "../../CourseDetailContext"; -import { CheckCircleIcon } from "@heroicons/react/24/solid"; - -export function Overview() { - const { course } = useContext(CourseDetailContext); - return ( - <> -
- {/* 课程描述 */} -
-

{course?.description}

-
- - {/* 学习目标 */} -
-

学习目标

-
- {course?.objectives.map((objective, index) => ( -
- - {objective} -
- ))} -
-
- - {/* 适合人群 */} -
-

适合人群

-
- {course?.audiences.map((audience, index) => ( -
- - {audience} -
- ))} -
-
- - {/* 课程要求 */} -
-

课程要求

-
    - {course?.requirements.map((requirement, index) => ( -
  • {requirement}
  • - ))} -
-
- - {/* 可获得技能 */} -
-

可获得技能

-
- {course?.skills.map((skill, index) => ( - - {skill} - - ))} -
-
-
- - ); -} diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/index.ts b/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/index.ts deleted file mode 100755 index e69de29..0000000 diff --git a/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx b/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx index 3a2d6ff..7d89f1a 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx @@ -2,66 +2,75 @@ import { motion, useScroll, useTransform } from "framer-motion"; import React, { useContext, useRef, useState } from "react"; import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer"; -import { CourseDetailDescription } from "./CourseDetailDescription/CourseDetailDescription"; +import { CourseDetailDescription } from "./CourseDetailDescription"; import { Course, LectureType, PostType } from "@nice/common"; import { CourseDetailContext } from "./CourseDetailContext"; import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent"; +import { Skeleton } from "antd"; +import ResourcesShower from "@web/src/components/common/uploader/ResourceShower"; +import { useNavigate } from "react-router-dom"; +import CourseDetailTitle from "./CourseDetailTitle"; -interface CourseDetailDisplayAreaProps { - // course: Course; - videoSrc?: string; - videoPoster?: string; - // isLoading?: boolean; -} -export const CourseDetailDisplayArea: React.FC< - CourseDetailDisplayAreaProps -> = ({ videoSrc, videoPoster }) => { +export const CourseDetailDisplayArea: React.FC = () => { // 创建滚动动画效果 - const { course, isLoading, lecture } = useContext(CourseDetailContext); + const { + course, + isLoading, + canEdit, + lecture, + lectureIsLoading, + selectedLectureId, + } = useContext(CourseDetailContext); + const navigate = useNavigate(); const { scrollY } = useScroll(); - const videoScale = useTransform(scrollY, [0, 200], [1, 0.8]); const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]); - const contentWrapperRef = useRef(null); - const [isExpanded, setIsExpanded] = useState(false); - const [shouldCollapse, setShouldCollapse] = useState(false); return (
{/* 固定的视频区域 */} - {/* 移除 sticky 定位,让视频区域随页面滚动 */} - {lecture?.meta?.type === LectureType.VIDEO && ( - -
- -
-
+ {lectureIsLoading && ( + )} - {lecture?.meta?.type === LectureType.ARTICLE && ( -
-
- + + {selectedLectureId && + !lectureIsLoading && + lecture?.meta?.type === LectureType.VIDEO && ( +
+ +
+ +
+
-
- )} + )} + {!lectureIsLoading && + selectedLectureId && + lecture?.meta?.type === LectureType.ARTICLE && ( +
+
+ +
+ +
+
+
+ )} +
+ +
{/* 课程内容区域 */} - - -
); }; + export default CourseDetailDisplayArea; diff --git a/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx b/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx index bbcdffc..15eb7e5 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx @@ -1,63 +1,97 @@ -// components/Header.tsx -import { motion, useScroll, useTransform } from "framer-motion"; -import { useContext, useEffect, useState } from "react"; +import { useContext, useState } from "react"; +import { Input, Layout, Avatar, Button, Dropdown } from "antd"; +import { + EditFilled, + HomeOutlined, + SearchOutlined, + UserOutlined, +} from "@ant-design/icons"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { useNavigate, useParams } from "react-router-dom"; +import { UserMenu } from "@web/src/app/main/layout/UserMenu/UserMenu"; import { CourseDetailContext } from "../CourseDetailContext"; -import { Button } from "antd"; +import { usePost, useStaff } from "@nice/client"; +import toast from "react-hot-toast"; +import { NavigationMenu } from "@web/src/app/main/layout/NavigationMenu"; -export const CourseDetailHeader = () => { - const { scrollY } = useScroll(); +const { Header } = Layout; - 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]); +export function CourseDetailHeader() { + const { id } = useParams(); + const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } = + useAuth(); + const navigate = useNavigate(); + const { course, canEdit, userIsLearning } = useContext(CourseDetailContext); + const { update } = useStaff(); return ( - -
-
-

{course?.title}

+
+
+
+
navigate("/")} + className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer"> + 烽火慕课 +
+
- - -
- - ); -}; -export default CourseDetailHeader; +
+ {isAuthenticated && ( + + )} + {canEdit && ( + + )} + {isAuthenticated ? ( + + ) : ( + + )} +
+
+ + ); +} diff --git a/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx b/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx index 6686093..4ac0d73 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx @@ -4,44 +4,35 @@ import { CourseDetailContext } from "./CourseDetailContext"; import CourseDetailDisplayArea from "./CourseDetailDisplayArea"; import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus"; -import CourseDetailHeader from "./CourseDetailHeader/CourseDetailHeader"; -import { Button } from "antd"; +import { CourseDetailHeader } from "./CourseDetailHeader/CourseDetailHeader"; export default function CourseDetailLayout() { const { course, - selectedLectureId, - lecture, - isLoading, + setSelectedLectureId, } = useContext(CourseDetailContext); const handleLectureClick = (lectureId: string) => { setSelectedLectureId(lectureId); }; - const [isSyllabusOpen, setIsSyllabusOpen] = useState(false); + const [isSyllabusOpen, setIsSyllabusOpen] = useState(true); return (
- - {/* 添加 Header 组件 */} - {/* 主内容区域 */} - {/* 为了防止 Header 覆盖内容,添加上边距 */} -
+
{" "} {/* 添加这个包装 div */} - + {/* 课程大纲侧边栏 */} { return ( diff --git a/apps/web/src/components/models/course/detail/CourseDetailTitle.tsx b/apps/web/src/components/models/course/detail/CourseDetailTitle.tsx new file mode 100755 index 0000000..de577eb --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseDetailTitle.tsx @@ -0,0 +1,70 @@ +import { useContext } from "react"; +import { CourseDetailContext } from "./CourseDetailContext"; +import { useNavigate } from "react-router-dom"; +import { + BookOutlined, + CalendarOutlined, + EditTwoTone, + EyeOutlined, + ReloadOutlined, +} from "@ant-design/icons"; +import dayjs from "dayjs"; +import CourseOperationBtns from "./JoinLearingButton"; + +export default function CourseDetailTitle() { + const { course, lecture, selectedLectureId } = + useContext(CourseDetailContext); + return ( +
+
+ {!selectedLectureId ? course?.title : lecture?.title} +
+
+ {course?.author?.showname && ( +
+ 发布者: + {course?.author?.showname} +
+ )} + {course?.depts && course?.depts?.length > 0 && ( +
+ 发布单位: + {course?.depts?.map((dept) => dept.name)} +
+ )} +
+
+
+ + {"发布于:"} + {dayjs( + !selectedLectureId + ? course?.createdAt + : lecture?.createdAt + ).format("YYYY年M月D日")} +
+
+ {"最后更新:"} + {dayjs( + !selectedLectureId + ? course?.updatedAt + : lecture?.updatedAt + ).format("YYYY年M月D日")} +
+
+ +
{`观看次数${ + !selectedLectureId + ? course?.views || 0 + : lecture?.views || 0 + }`}
+
+
+ +
{`学习人数${course?.studentIds?.length || 0}`}
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/models/course/detail/CoursePreview/CoursePreview.tsx b/apps/web/src/components/models/course/detail/CoursePreview/CoursePreview.tsx new file mode 100755 index 0000000..7d6d4f4 --- /dev/null +++ b/apps/web/src/components/models/course/detail/CoursePreview/CoursePreview.tsx @@ -0,0 +1,50 @@ +import { useContext, useEffect } from "react"; +import { CoursePreviewMsg } from "@web/src/app/main/course/preview/type.ts"; +import { Button, Tabs, Image, Skeleton } from "antd"; +import type { TabsProps } from "antd"; +import { PlayCircleOutlined } from "@ant-design/icons"; +import { CourseDetailContext } from "../CourseDetailContext"; +export function CoursePreview() { + const { course, isLoading, lecture, lectureIsLoading, selectedLectureId } = + useContext(CourseDetailContext); + return ( +
+
+
+ example +
+ +
+
+
+ {isLoading ? ( + + ) : ( + <> + + {course.title} + + + {course.subTitle} + + + {course.content} + + + )} + + +
+
+
+ ); +} diff --git a/apps/web/src/components/models/course/detail/CoursePreview/couresPreviewTabmsg.tsx b/apps/web/src/components/models/course/detail/CoursePreview/couresPreviewTabmsg.tsx new file mode 100755 index 0000000..8f32fce --- /dev/null +++ b/apps/web/src/components/models/course/detail/CoursePreview/couresPreviewTabmsg.tsx @@ -0,0 +1,25 @@ +import { Checkbox, List } from 'antd'; +import React from 'react'; + +export function CoursePreviewTabmsg({data}){ + + + const renderItem = (item) => ( + + + + ); + + return( +
+ +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/models/course/detail/CoursePreview/courseCatalog.tsx b/apps/web/src/components/models/course/detail/CoursePreview/courseCatalog.tsx new file mode 100755 index 0000000..b13e87d --- /dev/null +++ b/apps/web/src/components/models/course/detail/CoursePreview/courseCatalog.tsx @@ -0,0 +1,11 @@ +import type { MenuProps } from 'antd'; +import { Menu } from 'antd'; + +type MenuItem = Required['items'][number]; + +export function CourseCatalog(){ + return ( + <> + + ) +} \ No newline at end of file diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx index 3b04395..84463cf 100755 --- a/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx +++ b/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx @@ -6,7 +6,6 @@ import { } from "@heroicons/react/24/outline"; import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; import React, { useState, useRef, useContext } from "react"; -import { motion, AnimatePresence } from "framer-motion"; import { SectionDto, TaxonomySlug } from "@nice/common"; import { SyllabusHeader } from "./SyllabusHeader"; import { SectionItem } from "./SectionItem"; @@ -28,13 +27,11 @@ export const CourseSyllabus: React.FC = ({ onToggle, }) => { const { isHeaderVisible } = useContext(CourseDetailContext); - const [expandedSections, setExpandedSections] = useState([]); + const [expandedSections, setExpandedSections] = useState( + sections.map((section) => section.id) // 默认展开所有章节 + ); const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); - // api.term.findMany.useQuery({ - // where: { - // taxonomy: { slug: TaxonomySlug.CATEGORY }, - // }, - // }); + const toggleSection = (sectionId: string) => { setExpandedSections((prev) => prev.includes(sectionId) @@ -42,70 +39,55 @@ export const CourseSyllabus: React.FC = ({ : [...prev, sectionId] ); - setTimeout(() => { - sectionRefs.current[sectionId]?.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - }, 100); + // 直接滚动,无需延迟 + sectionRefs.current[sectionId]?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); }; - return ( <> - - {/* 收起时的悬浮按钮 */} - {!isOpen && ( - - - - )} - - + +
+ )} + +
- - {isOpen && ( - - + {isOpen && ( +
+ -
-
- {sections.map((section, index) => ( - - (sectionRefs.current[ - section.id - ] = el) - } - index={index + 1} - section={section} - isExpanded={expandedSections.includes( - section.id - )} - onToggle={toggleSection} - onLectureClick={onLectureClick} - /> - ))} -
+
+
+ {sections.map((section, index) => ( + + (sectionRefs.current[section.id] = + el) + } + index={index + 1} + section={section} + isExpanded={expandedSections.includes( + section.id + )} + onToggle={toggleSection} + onLectureClick={onLectureClick} + /> + ))}
- - )} - - +
+
+ )} +
); }; diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus/LectureItem.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus/LectureItem.tsx index 0999f3c..9f41c82 100755 --- a/apps/web/src/components/models/course/detail/CourseSyllabus/LectureItem.tsx +++ b/apps/web/src/components/models/course/detail/CourseSyllabus/LectureItem.tsx @@ -1,8 +1,14 @@ // components/CourseSyllabus/LectureItem.tsx -import { Lecture, LectureType } from "@nice/common"; -import React from "react"; -import { ClockCircleOutlined, FileTextOutlined, PlayCircleOutlined } from "@ant-design/icons"; // 使用 Ant Design 图标 +import { Lecture, LectureType, LessonTypeLabel } from "@nice/common"; +import React, { useMemo } from "react"; +import { + ClockCircleOutlined, + EyeOutlined, + FileTextOutlined, + PlayCircleOutlined, +} from "@ant-design/icons"; // 使用 Ant Design 图标 +import { useParams } from "react-router-dom"; interface LectureItemProps { lecture: Lecture; @@ -12,25 +18,48 @@ interface LectureItemProps { export const LectureItem: React.FC = ({ lecture, onClick, -}) => ( -
onClick(lecture.id)}> - {lecture.type === LectureType.VIDEO && ( - - )} - {lecture.type === LectureType.ARTICLE && ( - // 为文章类型添加图标 - )} -
-

{lecture.title}

- {lecture.subTitle && ( -

{lecture.subTitle}

+}) => { + const { lectureId } = useParams(); + const isReading = useMemo(() => { + return lecture?.id === lectureId; + }, [lectureId, lecture]); + return ( +
onClick(lecture.id)}> + {lecture?.meta?.type === LectureType.VIDEO && ( +
+ + {LessonTypeLabel[lecture?.meta?.type]} +
)} + {lecture?.meta?.type === LectureType.ARTICLE && ( +
+ {" "} + {LessonTypeLabel[lecture?.meta?.type]} +
+ )} +
+

+ {lecture.title} +

+ {lecture.subTitle && ( + + {lecture.subTitle} + + )} +
+ + + {lecture?.views ? lecture?.views : 0} + +
+
-
- - {lecture.duration}分钟 -
-
-); + ); +}; diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus/SectionItem.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus/SectionItem.tsx index d2a4fe6..3c0a4c5 100755 --- a/apps/web/src/components/models/course/detail/CourseSyllabus/SectionItem.tsx +++ b/apps/web/src/components/models/course/detail/CourseSyllabus/SectionItem.tsx @@ -1,10 +1,9 @@ import { ChevronDownIcon } from "@heroicons/react/24/outline"; import { SectionDto } from "@nice/common"; import { AnimatePresence, motion } from "framer-motion"; -import React from "react"; +import React, { useMemo } from "react"; import { LectureItem } from "./LectureItem"; - -// components/CourseSyllabus/SectionItem.tsx +import { useParams } from "react-router-dom"; interface SectionItemProps { section: SectionDto; index?: number; @@ -13,57 +12,68 @@ interface SectionItemProps { onLectureClick: (lectureId: string) => void; ref: React.RefObject; } - export const SectionItem = React.forwardRef( - ({ section, index, isExpanded, onToggle, onLectureClick }, ref) => ( - - - - {isExpanded && ( - - {section.lectures.map((lecture) => ( - - ))} - - )} - - - ) + + {isExpanded && ( + + {section.lectures.map((lecture) => ( + + ))} + + )} + +
+ ); + } ); diff --git a/apps/web/src/components/models/course/detail/JoinLearingButton.tsx b/apps/web/src/components/models/course/detail/JoinLearingButton.tsx new file mode 100644 index 0000000..942a7de --- /dev/null +++ b/apps/web/src/components/models/course/detail/JoinLearingButton.tsx @@ -0,0 +1,101 @@ +import { useAuth } from "@web/src/providers/auth-provider"; +import { useContext, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { CourseDetailContext } from "./CourseDetailContext"; +import { useStaff } from "@nice/client"; +import { + CheckCircleFilled, + CheckCircleOutlined, + CloseCircleFilled, + CloseCircleOutlined, + EditFilled, + EditTwoTone, + LoginOutlined, +} from "@ant-design/icons"; +import toast from "react-hot-toast"; + +export default function CourseOperationBtns() { + const { id } = useParams(); + const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } = + useAuth(); + const navigate = useNavigate(); + const { course, canEdit, userIsLearning, setUserIsLearning} = useContext(CourseDetailContext); + const { update } = useStaff(); + const [isHovered, setIsHovered] = useState(false); + + const toggleLearning = async () => { + if (!userIsLearning) { + await update.mutateAsync({ + where: { id: user?.id }, + data: { + learningPosts: { + connect: { id: course.id }, + }, + }, + }); + setUserIsLearning(true) + toast.success("加入学习成功"); + } else { + + await update.mutateAsync({ + where: { id: user?.id }, + data: { + learningPosts: { + disconnect: { + id: course.id, + }, + }, + }, + }); + toast.success("退出学习成功"); + setUserIsLearning(false) + } + }; + return ( + <> + {isAuthenticated && ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className={`flex px-1 py-0.5 gap-1 hover:cursor-pointer transition-all ${ + userIsLearning + ? isHovered + ? "text-red-500 border-red-500 rounded-md " + : "text-green-500 " + : "text-primary " + }`}> + {userIsLearning ? ( + isHovered ? ( + + ) : ( + + ) + ) : ( + + )} + + {userIsLearning + ? isHovered + ? "退出学习" + : "正在学习" + : "加入学习"} + +
+ )} + {canEdit && ( +
{ + const url = course?.id + ? `/course/${course?.id}/editor` + : "/course/editor"; + navigate(url); + }}> + + {"编辑课程"} +
+ )} + + ); +} diff --git a/apps/web/src/components/models/course/detail/course-objectives.tsx b/apps/web/src/components/models/course/detail/course-objectives.tsx deleted file mode 100755 index b849eac..0000000 --- a/apps/web/src/components/models/course/detail/course-objectives.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { CheckOutlined } from '@ant-design/icons'; -import React from 'react'; -interface CourseObjectivesProps { - objectives: string[]; - title?: string; -} -const CourseObjectives: React.FC = ({ - objectives, - title = "您将会学到" -}) => { - return ( -
-

{title}

-
- {objectives.map((objective, index) => ( -
- - {objective} -
- ))} -
-
- ); -}; - -export default CourseObjectives; \ No newline at end of file diff --git a/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx b/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx index efa4962..6cbfd00 100755 --- a/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx +++ b/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx @@ -1,7 +1,9 @@ import { createContext, useContext, ReactNode, useEffect } from "react"; import { Form, FormInstance, message } from "antd"; import { + courseDetailSelect, CourseDto, + CourseMeta, CourseStatus, ObjectType, PostType, @@ -10,6 +12,7 @@ import { import { api, usePost } from "@nice/client"; import { useNavigate } from "react-router-dom"; import { z } from "zod"; +import { useAuth } from "@web/src/providers/auth-provider"; export type CourseFormData = { title: string; @@ -42,12 +45,11 @@ export function CourseFormProvider({ }: CourseFormProviderProps) { const [form] = Form.useForm(); const { create, update, createCourse } = usePost(); + const { user } = useAuth(); const { data: course }: { data: CourseDto } = api.post.findFirst.useQuery( { where: { id: editId }, - include: { - terms: true, - }, + select: courseDetailSelect, }, { enabled: Boolean(editId) } ); @@ -62,13 +64,15 @@ export function CourseFormProvider({ useEffect(() => { if (course) { + const deptIds = (course?.depts || [])?.map((dept) => dept.id); const formData = { title: course.title, subTitle: course.subTitle, content: course.content, - thumbnail: course?.meta?.thumbnail, - requirements: course?.meta?.requirements, - objectives: course?.meta?.objectives, + deptIds: deptIds, + meta: { + thumbnail: course?.meta?.thumbnail, + }, }; course.terms?.forEach((term) => { formData[term.taxonomyId] = term.id; // 假设 taxonomyName 是您在 Form.Item 中使用的 name @@ -77,51 +81,70 @@ export function CourseFormProvider({ } }, [course, form]); - const onSubmit = async (values: CourseFormData) => { - console.log(values); + const onSubmit = async (values: any) => { + const sections = values?.sections || []; + const deptIds = values?.deptIds || []; const termIds = taxonomies .map((tax) => values[tax.id]) // 获取每个 taxonomy 对应的选中值 .filter((id) => id); // 过滤掉空值 const formattedValues = { ...values, + type: PostType.COURSE, meta: { - requirements: values.requirements, - objectives: values.objectives, - }, - terms: { - connect: termIds.map((id) => ({ id })), // 转换成 connect 格式 + ...((course?.meta as CourseMeta) || {}), + ...(values?.meta?.thumbnail !== undefined && { + thumbnail: values?.meta?.thumbnail, + }), }, + terms: + termIds?.length > 0 + ? { + [editId ? "set" : "connect"]: termIds.map((id) => ({ + id, + })), // 转换成 connect 格式 + } + : undefined, + depts: + deptIds?.length > 0 + ? { + [editId ? "set" : "connect"]: deptIds.map((id) => ({ + id, + })), + } + : undefined, }; // 删除原始的 taxonomy 字段 taxonomies.forEach((tax) => { delete formattedValues[tax.id]; }); - delete formattedValues.requirements; - delete formattedValues.objectives; delete formattedValues.sections; + delete formattedValues.deptIds; + try { if (editId) { - await update.mutateAsync({ + const result = await update.mutateAsync({ where: { id: editId }, data: formattedValues, }); message.success("课程更新成功!"); + navigate(`/course/${result.id}/editor/content`); } else { const result = await createCourse.mutateAsync({ courseDetail: { data: { - title: formattedValues.title || "12345", - state: CourseStatus.DRAFT, + title: formattedValues.title, + + // state: CourseStatus.DRAFT, type: PostType.COURSE, ...formattedValues, }, }, sections, }); - navigate(`/course/${result.id}/editor`, { replace: true }); message.success("课程创建成功!"); + navigate(`/course/${result.id}/editor/content`); } form.resetFields(); } catch (error) { @@ -130,6 +153,7 @@ export function CourseFormProvider({ } }; + return ( ({ label: value, @@ -31,15 +32,26 @@ export function CourseBasicForm() { + rules={[{ max: 20, message: "副标题最多20个字符" }]}> + + +