Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
87c5c6169e
|
@ -67,5 +67,6 @@ yarn-error.log*
|
|||
|
||||
# Ignore .idea files in the Expo monorepo
|
||||
**/.idea/
|
||||
|
||||
uploads
|
||||
packages/mind-elixir-core
|
||||
config/nginx/conf.d/web.conf
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"marscode.chatLanguage": "cn"
|
||||
}
|
|
@ -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 } });
|
||||
|
||||
// 资源验证
|
||||
|
|
|
@ -155,6 +155,7 @@ export class UserProfileService {
|
|||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
avatar: true,
|
||||
deptId: true,
|
||||
department: true,
|
||||
domainId: true,
|
||||
|
|
|
@ -4,17 +4,20 @@ 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<Prisma.AppConfigUncheckedCreateInput> = z.any()
|
||||
const AppConfigUpdateArgsSchema: ZodType<Prisma.AppConfigUpdateArgs> = z.any()
|
||||
const AppConfigDeleteManyArgsSchema: ZodType<Prisma.AppConfigDeleteManyArgs> = z.any()
|
||||
const AppConfigFindFirstArgsSchema: ZodType<Prisma.AppConfigFindFirstArgs> = z.any()
|
||||
const AppConfigUncheckedCreateInputSchema: ZodType<Prisma.AppConfigUncheckedCreateInput> =
|
||||
z.any();
|
||||
const AppConfigUpdateArgsSchema: ZodType<Prisma.AppConfigUpdateArgs> = z.any();
|
||||
const AppConfigDeleteManyArgsSchema: ZodType<Prisma.AppConfigDeleteManyArgs> =
|
||||
z.any();
|
||||
const AppConfigFindFirstArgsSchema: ZodType<Prisma.AppConfigFindFirstArgs> =
|
||||
z.any();
|
||||
@Injectable()
|
||||
export class AppConfigRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly appConfigService: AppConfigService,
|
||||
private readonly realtimeServer: RealtimeServer
|
||||
) { }
|
||||
private readonly realtimeServer: RealtimeServer,
|
||||
) {}
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(AppConfigUncheckedCreateInputSchema)
|
||||
|
@ -25,23 +28,24 @@ export class AppConfigRouter {
|
|||
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)
|
||||
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)
|
||||
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()
|
||||
return await this.appConfigService.clearRowCache();
|
||||
}),
|
||||
getClientCount: this.trpc.protectProcedure.query(() => {
|
||||
return this.realtimeServer.getClientCount()
|
||||
})
|
||||
return this.realtimeServer.getClientCount();
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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<Prisma.AppConfigDelegate> {
|
||||
constructor() {
|
||||
super(db, "appConfig");
|
||||
super(db, 'appConfig');
|
||||
}
|
||||
async clearRowCache() {
|
||||
await deleteByPattern("row-*")
|
||||
return true
|
||||
await deleteByPattern('row-*');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<A['findMany']>;
|
||||
orderBy?: OrderByArgs<A['findMany']>;
|
||||
select?: SelectArgs<A['findMany']>;
|
||||
}): 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'];
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { db, Prisma, PrismaClient } from "@nice/common";
|
||||
import { db, Prisma, PrismaClient } from '@nice/common';
|
||||
|
||||
export type Operations =
|
||||
| 'aggregate'
|
||||
|
@ -13,7 +13,9 @@ export type Operations =
|
|||
| 'update'
|
||||
| 'updateMany'
|
||||
| 'upsert';
|
||||
export type DelegateFuncs = { [K in Operations]: (args: any) => Promise<unknown> }
|
||||
export type DelegateFuncs = {
|
||||
[K in Operations]: (args: any) => Promise<unknown>;
|
||||
};
|
||||
export type DelegateArgs<T> = {
|
||||
[K in keyof T]: T[K] extends (args: infer A) => Promise<any> ? A : never;
|
||||
};
|
||||
|
@ -28,15 +30,15 @@ export type DataArgs<T> = T extends { data: infer D } ? D : never;
|
|||
export type IncludeArgs<T> = T extends { include: infer I } ? I : never;
|
||||
export type OrderByArgs<T> = T extends { orderBy: infer O } ? O : never;
|
||||
export type UpdateOrderArgs = {
|
||||
id: string
|
||||
overId: string
|
||||
}
|
||||
id: string;
|
||||
overId: string;
|
||||
};
|
||||
export interface FindManyWithCursorType<T extends DelegateFuncs> {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
where?: WhereArgs<DelegateArgs<T>['findUnique']>;
|
||||
select?: SelectArgs<DelegateArgs<T>['findUnique']>;
|
||||
orderBy?: OrderByArgs<DelegateArgs<T>['findMany']>
|
||||
orderBy?: OrderByArgs<DelegateArgs<T>['findMany']>;
|
||||
}
|
||||
export type TransactionType = Omit<
|
||||
PrismaClient,
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
// import { Module } from '@nestjs/common';
|
||||
// import { CourseRouter } from './course.router';
|
||||
// import { CourseService } from './course.service';
|
||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
||||
|
||||
// @Module({
|
||||
// providers: [CourseRouter, CourseService, TrpcService],
|
||||
// exports: [CourseRouter, CourseService]
|
||||
// })
|
||||
// export class CourseModule { }
|
|
@ -1,92 +0,0 @@
|
|||
// import { Injectable } from '@nestjs/common';
|
||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
||||
// import { Prisma, UpdateOrderSchema } from '@nice/common';
|
||||
// import { CourseService } from './course.service';
|
||||
// import { z, ZodType } from 'zod';
|
||||
// // const CourseCreateArgsSchema: ZodType<Prisma.CourseCreateArgs> = z.any();
|
||||
// // const CourseUpdateArgsSchema: ZodType<Prisma.CourseUpdateArgs> = z.any();
|
||||
// // const CourseCreateManyInputSchema: ZodType<Prisma.CourseCreateManyInput> =
|
||||
// // z.any();
|
||||
// // const CourseDeleteManyArgsSchema: ZodType<Prisma.CourseDeleteManyArgs> =
|
||||
// // z.any();
|
||||
// // const CourseFindManyArgsSchema: ZodType<Prisma.CourseFindManyArgs> = z.any();
|
||||
// // const CourseFindFirstArgsSchema: ZodType<Prisma.CourseFindFirstArgs> = z.any();
|
||||
// // const CourseWhereInputSchema: ZodType<Prisma.CourseWhereInput> = z.any();
|
||||
// // const CourseSelectSchema: ZodType<Prisma.CourseSelect> = z.any();
|
||||
|
||||
// @Injectable()
|
||||
// export class CourseRouter {
|
||||
// constructor(
|
||||
// private readonly trpc: TrpcService,
|
||||
// private readonly courseService: CourseService,
|
||||
// ) {}
|
||||
// router = this.trpc.router({
|
||||
// // create: this.trpc.protectProcedure
|
||||
// // .input(CourseCreateArgsSchema)
|
||||
// // .mutation(async ({ ctx, input }) => {
|
||||
// // const { staff } = ctx;
|
||||
// // return await this.courseService.create(input, { staff });
|
||||
// // }),
|
||||
// // update: this.trpc.protectProcedure
|
||||
// // .input(CourseUpdateArgsSchema)
|
||||
// // .mutation(async ({ ctx, input }) => {
|
||||
// // const { staff } = ctx;
|
||||
// // return await this.courseService.update(input, { staff });
|
||||
// // }),
|
||||
// // createMany: this.trpc.protectProcedure
|
||||
// // .input(z.array(CourseCreateManyInputSchema))
|
||||
// // .mutation(async ({ ctx, input }) => {
|
||||
// // const { staff } = ctx;
|
||||
// // return await this.courseService.createMany({ data: input }, staff);
|
||||
// // }),
|
||||
// // deleteMany: this.trpc.procedure
|
||||
// // .input(CourseDeleteManyArgsSchema)
|
||||
// // .mutation(async ({ input }) => {
|
||||
// // return await this.courseService.deleteMany(input);
|
||||
// // }),
|
||||
// // findFirst: this.trpc.procedure
|
||||
// // .input(CourseFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
// // .query(async ({ input }) => {
|
||||
// // return await this.courseService.findFirst(input);
|
||||
// // }),
|
||||
// // softDeleteByIds: this.trpc.protectProcedure
|
||||
// // .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
||||
// // .mutation(async ({ input }) => {
|
||||
// // return this.courseService.softDeleteByIds(input.ids);
|
||||
// // }),
|
||||
// // updateOrder: this.trpc.protectProcedure
|
||||
// // .input(UpdateOrderSchema)
|
||||
// // .mutation(async ({ input }) => {
|
||||
// // return this.courseService.updateOrder(input);
|
||||
// // }),
|
||||
// // findMany: this.trpc.procedure
|
||||
// // .input(CourseFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
// // .query(async ({ input }) => {
|
||||
// // return await this.courseService.findMany(input);
|
||||
// // }),
|
||||
// // findManyWithCursor: this.trpc.protectProcedure
|
||||
// // .input(
|
||||
// // z.object({
|
||||
// // cursor: z.any().nullish(),
|
||||
// // take: z.number().optional(),
|
||||
// // where: CourseWhereInputSchema.optional(),
|
||||
// // select: CourseSelectSchema.optional(),
|
||||
// // }),
|
||||
// // )
|
||||
// // .query(async ({ ctx, input }) => {
|
||||
// // return await this.courseService.findManyWithCursor(input);
|
||||
// // }),
|
||||
// // findManyWithPagination: this.trpc.procedure
|
||||
// // .input(
|
||||
// // z.object({
|
||||
// // page: z.number().optional(),
|
||||
// // pageSize: z.number().optional(),
|
||||
// // where: CourseWhereInputSchema.optional(),
|
||||
// // select: CourseSelectSchema.optional(),
|
||||
// // }),
|
||||
// // ) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
// // .query(async ({ input }) => {
|
||||
// // return await this.courseService.findManyWithPagination(input);
|
||||
// // }),
|
||||
// });
|
||||
// }
|
|
@ -1,78 +0,0 @@
|
|||
// import { Injectable } from '@nestjs/common';
|
||||
// import { BaseService } from '../base/base.service';
|
||||
// import {
|
||||
// UserProfile,
|
||||
// db,
|
||||
// ObjectType,
|
||||
// Prisma,
|
||||
// InstructorRole,
|
||||
// } from '@nice/common';
|
||||
// @Injectable()
|
||||
// export class CourseService extends BaseService<Prisma.CourseDelegate> {
|
||||
// constructor() {
|
||||
// super(db, ObjectType.COURSE);
|
||||
// }
|
||||
// async create(
|
||||
// args: Prisma.CourseCreateArgs,
|
||||
// params?: { staff?: UserProfile }
|
||||
// ) {
|
||||
// return await db.$transaction(async tx => {
|
||||
// const result = await super.create(args, { tx });
|
||||
// if (params?.staff?.id) {
|
||||
// await tx.courseInstructor.create({
|
||||
// data: {
|
||||
// instructorId: params.staff.id,
|
||||
// courseId: result.id,
|
||||
// role: InstructorRole.MAIN,
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// return result;
|
||||
// }, {
|
||||
// timeout: 10000 // 10 seconds
|
||||
// });
|
||||
// }
|
||||
// async update(
|
||||
// args: Prisma.CourseUpdateArgs,
|
||||
// params?: { staff?: UserProfile }
|
||||
// ) {
|
||||
// return await db.$transaction(async tx => {
|
||||
// const result = await super.update(args, { tx });
|
||||
// return result;
|
||||
// }, {
|
||||
// timeout: 10000 // 10 seconds
|
||||
// });
|
||||
// }
|
||||
// async removeInstructor(courseId: string, instructorId: string) {
|
||||
// return await db.courseInstructor.delete({
|
||||
// where: {
|
||||
// courseId_instructorId: {
|
||||
// courseId,
|
||||
// instructorId,
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// async addInstructor(params: {
|
||||
// courseId: string;
|
||||
// instructorId: string;
|
||||
// role?: string;
|
||||
// order?: number;
|
||||
// }) {
|
||||
// return await db.courseInstructor.create({
|
||||
// data: {
|
||||
// courseId: params.courseId,
|
||||
// instructorId: params.instructorId,
|
||||
// role: params.role || InstructorRole.ASSISTANT,
|
||||
// order: params.order,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// async getInstructors(courseId: string) {
|
||||
// return await db.courseInstructor.findMany({
|
||||
// where: { courseId },
|
||||
// include: { instructor: true },
|
||||
// orderBy: { order: 'asc' },
|
||||
// });
|
||||
// }
|
||||
// }
|
|
@ -1,49 +0,0 @@
|
|||
// import { db, EnrollmentStatus, PostType } from '@nice/common';
|
||||
|
||||
// // 更新课程评价统计
|
||||
// export async function updateCourseReviewStats(courseId: string) {
|
||||
// const reviews = await db.post.findMany({
|
||||
// where: {
|
||||
// courseId,
|
||||
// type: PostType.COURSE_REVIEW,
|
||||
// deletedAt: null,
|
||||
// },
|
||||
// select: { rating: true },
|
||||
// });
|
||||
// const numberOfReviews = reviews.length;
|
||||
// const averageRating =
|
||||
// numberOfReviews > 0
|
||||
// ? reviews.reduce((sum, review) => sum + review.rating, 0) /
|
||||
// numberOfReviews
|
||||
// : 0;
|
||||
|
||||
// return db.course.update({
|
||||
// where: { id: courseId },
|
||||
// data: {
|
||||
// // numberOfReviews,
|
||||
// //averageRating,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
// // 更新课程注册统计
|
||||
// export async function updateCourseEnrollmentStats(courseId: string) {
|
||||
// const completedEnrollments = await db.enrollment.count({
|
||||
// where: {
|
||||
// courseId,
|
||||
// status: EnrollmentStatus.COMPLETED,
|
||||
// },
|
||||
// });
|
||||
// const totalEnrollments = await db.enrollment.count({
|
||||
// where: { courseId },
|
||||
// });
|
||||
// const completionRate =
|
||||
// totalEnrollments > 0 ? (completedEnrollments / totalEnrollments) * 100 : 0;
|
||||
// return db.course.update({
|
||||
// where: { id: courseId },
|
||||
// data: {
|
||||
// // numberOfStudents: totalEnrollments,
|
||||
// // completionRate,
|
||||
// },
|
||||
// });
|
||||
// }
|
|
@ -1,10 +0,0 @@
|
|||
// import { Module } from '@nestjs/common';
|
||||
// import { LectureRouter } from './lecture.router';
|
||||
// import { LectureService } from './lecture.service';
|
||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
||||
|
||||
// @Module({
|
||||
// providers: [LectureRouter, LectureService, TrpcService],
|
||||
// exports: [LectureRouter, LectureService]
|
||||
// })
|
||||
// export class LectureModule { }
|
|
@ -1,70 +0,0 @@
|
|||
// import { Injectable } from '@nestjs/common';
|
||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
||||
// import { Prisma, UpdateOrderSchema } from '@nice/common';
|
||||
// import { LectureService } from './lecture.service';
|
||||
// import { z, ZodType } from 'zod';
|
||||
// const LectureCreateArgsSchema: ZodType<Prisma.LectureCreateArgs> = z.any()
|
||||
// const LectureCreateManyInputSchema: ZodType<Prisma.LectureCreateManyInput> = z.any()
|
||||
// const LectureDeleteManyArgsSchema: ZodType<Prisma.LectureDeleteManyArgs> = z.any()
|
||||
// const LectureFindManyArgsSchema: ZodType<Prisma.LectureFindManyArgs> = z.any()
|
||||
// const LectureFindFirstArgsSchema: ZodType<Prisma.LectureFindFirstArgs> = z.any()
|
||||
// const LectureWhereInputSchema: ZodType<Prisma.LectureWhereInput> = z.any()
|
||||
// const LectureSelectSchema: ZodType<Prisma.LectureSelect> = z.any()
|
||||
|
||||
// @Injectable()
|
||||
// export class LectureRouter {
|
||||
// constructor(
|
||||
// private readonly trpc: TrpcService,
|
||||
// private readonly lectureService: LectureService,
|
||||
// ) { }
|
||||
// router = this.trpc.router({
|
||||
// create: this.trpc.protectProcedure
|
||||
// .input(LectureCreateArgsSchema)
|
||||
// .mutation(async ({ ctx, input }) => {
|
||||
// const { staff } = ctx;
|
||||
// return await this.lectureService.create(input, {staff});
|
||||
// }),
|
||||
// createMany: this.trpc.protectProcedure.input(z.array(LectureCreateManyInputSchema))
|
||||
// .mutation(async ({ ctx, input }) => {
|
||||
// const { staff } = ctx;
|
||||
|
||||
// return await this.lectureService.createMany({ data: input }, staff);
|
||||
// }),
|
||||
// deleteMany: this.trpc.procedure
|
||||
// .input(LectureDeleteManyArgsSchema)
|
||||
// .mutation(async ({ input }) => {
|
||||
// return await this.lectureService.deleteMany(input);
|
||||
// }),
|
||||
// findFirst: this.trpc.procedure
|
||||
// .input(LectureFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
// .query(async ({ input }) => {
|
||||
// return await this.lectureService.findFirst(input);
|
||||
// }),
|
||||
// softDeleteByIds: this.trpc.protectProcedure
|
||||
// .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
||||
// .mutation(async ({ input }) => {
|
||||
// return this.lectureService.softDeleteByIds(input.ids);
|
||||
// }),
|
||||
// updateOrder: this.trpc.protectProcedure
|
||||
// .input(UpdateOrderSchema)
|
||||
// .mutation(async ({ input }) => {
|
||||
// return this.lectureService.updateOrder(input);
|
||||
// }),
|
||||
// findMany: this.trpc.procedure
|
||||
// .input(LectureFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
// .query(async ({ input }) => {
|
||||
// return await this.lectureService.findMany(input);
|
||||
// }),
|
||||
// findManyWithCursor: this.trpc.protectProcedure
|
||||
// .input(z.object({
|
||||
// cursor: z.any().nullish(),
|
||||
// take: z.number().nullish(),
|
||||
// where: LectureWhereInputSchema.nullish(),
|
||||
// select: LectureSelectSchema.nullish()
|
||||
// }))
|
||||
// .query(async ({ ctx, input }) => {
|
||||
// const { staff } = ctx;
|
||||
// return await this.lectureService.findManyWithCursor(input);
|
||||
// }),
|
||||
// });
|
||||
// }
|
|
@ -1,35 +0,0 @@
|
|||
// import { Injectable } from '@nestjs/common';
|
||||
// import { BaseService } from '../base/base.service';
|
||||
// import {
|
||||
// UserProfile,
|
||||
// db,
|
||||
// ObjectType,
|
||||
// Prisma
|
||||
// } from '@nice/common';
|
||||
// import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
|
||||
// @Injectable()
|
||||
// export class LectureService extends BaseService<Prisma.LectureDelegate> {
|
||||
// constructor() {
|
||||
// super(db, ObjectType.COURSE);
|
||||
// }
|
||||
// async create(args: Prisma.LectureCreateArgs, params?: { staff?: UserProfile }) {
|
||||
// const result = await super.create(args)
|
||||
// EventBus.emit('dataChanged', {
|
||||
// type: ObjectType.LECTURE,
|
||||
// operation: CrudOperation.CREATED,
|
||||
// data: result,
|
||||
// });
|
||||
// return result;
|
||||
// }
|
||||
// async update(args: Prisma.LectureUpdateArgs) {
|
||||
// const result = await super.update(args);
|
||||
// EventBus.emit('dataChanged', {
|
||||
// type: ObjectType.LECTURE,
|
||||
// operation: CrudOperation.UPDATED,
|
||||
// data: result,
|
||||
// });
|
||||
// return result;
|
||||
// }
|
||||
|
||||
// }
|
|
@ -1,48 +0,0 @@
|
|||
import { db, PostType } from '@nice/common';
|
||||
|
||||
// export async function updateSectionLectureStats(sectionId: string) {
|
||||
// const sectionStats = await db.post.aggregate({
|
||||
// where: {
|
||||
// parentId: sectionId,
|
||||
// deletedAt: null,
|
||||
// type: PostType.LECTURE,
|
||||
// },
|
||||
// _count: { _all: true },
|
||||
// _sum: { duration: true },
|
||||
// });
|
||||
|
||||
// await db.post.update({
|
||||
// where: { id: sectionId },
|
||||
// data: {
|
||||
// // totalLectures: sectionStats._count._all,
|
||||
// // totalDuration: sectionStats._sum.duration || 0,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
// export async function updateParentLectureStats(parentId: string) {
|
||||
// const ParentStats = await db.post.aggregate({
|
||||
// where: {
|
||||
// ancestors: {
|
||||
// some: {
|
||||
// ancestorId: parentId,
|
||||
// descendant: {
|
||||
// type: PostType.LECTURE,
|
||||
// deletedAt: null,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// _count: { _all: true },
|
||||
// _sum: {
|
||||
// duration: true,
|
||||
// },
|
||||
// });
|
||||
// await db.post.update({
|
||||
// where: { id: parentId },
|
||||
// data: {
|
||||
// //totalLectures: courseStats._count._all,
|
||||
// //totalDuration: courseStats._sum.duration || 0,
|
||||
// },
|
||||
// });
|
||||
// }
|
|
@ -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<Prisma.PostCreateArgs> = z.any();
|
||||
const PostUpdateArgsSchema: ZodType<Prisma.PostUpdateArgs> = z.any();
|
||||
const PostUpdateOrderArgsSchema: ZodType<UpdateOrderArgs> = z.any();
|
||||
const PostFindFirstArgsSchema: ZodType<Prisma.PostFindFirstArgs> = z.any();
|
||||
const PostFindManyArgsSchema: ZodType<Prisma.PostFindManyArgs> = z.any();
|
||||
const PostDeleteManyArgsSchema: ZodType<Prisma.PostDeleteManyArgs> = 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);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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<Prisma.PostDelegate> {
|
||||
|
@ -42,13 +45,14 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
|||
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<Prisma.PostDelegate> {
|
|||
parentId: courseId,
|
||||
title: title,
|
||||
authorId: staff?.id,
|
||||
},
|
||||
updatedAt: dayjs().toDate(),
|
||||
} as any,
|
||||
},
|
||||
{ tx },
|
||||
);
|
||||
|
@ -94,56 +99,23 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
|||
async createCourse(
|
||||
args: {
|
||||
courseDetail: Prisma.PostCreateArgs;
|
||||
sections?: z.infer<typeof CourseMethodSchema.createSection>[];
|
||||
},
|
||||
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<Prisma.PostDelegate> {
|
|||
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<Prisma.PostDelegate> {
|
|||
}
|
||||
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<Prisma.PostDelegate> {
|
|||
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<Prisma.PostDelegate> {
|
|||
return { ...result, items };
|
||||
});
|
||||
}
|
||||
async findManyWithPagination(args: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
where?: Prisma.PostWhereInput;
|
||||
orderBy?: OrderByArgs<(typeof db.post)['findMany']>;
|
||||
select?: Prisma.PostSelect<DefaultArgs>;
|
||||
}): 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<Prisma.PostDelegate> {
|
|||
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;
|
||||
|
|
|
@ -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,16 +137,21 @@ 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) => ({
|
||||
}) as any
|
||||
).map((section) => ({
|
||||
...section,
|
||||
lectures: [],
|
||||
}));
|
||||
|
@ -156,11 +161,28 @@ export async function setCourseInfo({ data }: { data: Post }) {
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
// import { Module } from '@nestjs/common';
|
||||
// import { SectionRouter } from './section.router';
|
||||
// import { SectionService } from './section.service';
|
||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
||||
|
||||
// @Module({
|
||||
// exports: [SectionRouter, SectionService],
|
||||
// providers: [SectionRouter, SectionService, TrpcService]
|
||||
// })
|
||||
// export class SectionModule { }
|
|
@ -1,70 +0,0 @@
|
|||
// import { Injectable } from '@nestjs/common';
|
||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
||||
// import { Prisma, UpdateOrderSchema } from '@nice/common';
|
||||
// import { SectionService } from './section.service';
|
||||
// import { z, ZodType } from 'zod';
|
||||
// const SectionCreateArgsSchema: ZodType<Prisma.SectionCreateArgs> = z.any()
|
||||
// const SectionCreateManyInputSchema: ZodType<Prisma.SectionCreateManyInput> = z.any()
|
||||
// const SectionDeleteManyArgsSchema: ZodType<Prisma.SectionDeleteManyArgs> = z.any()
|
||||
// const SectionFindManyArgsSchema: ZodType<Prisma.SectionFindManyArgs> = z.any()
|
||||
// const SectionFindFirstArgsSchema: ZodType<Prisma.SectionFindFirstArgs> = z.any()
|
||||
// const SectionWhereInputSchema: ZodType<Prisma.SectionWhereInput> = z.any()
|
||||
// const SectionSelectSchema: ZodType<Prisma.SectionSelect> = z.any()
|
||||
|
||||
// @Injectable()
|
||||
// export class SectionRouter {
|
||||
// constructor(
|
||||
// private readonly trpc: TrpcService,
|
||||
// private readonly sectionService: SectionService,
|
||||
// ) { }
|
||||
// router = this.trpc.router({
|
||||
// create: this.trpc.protectProcedure
|
||||
// .input(SectionCreateArgsSchema)
|
||||
// .mutation(async ({ ctx, input }) => {
|
||||
// const { staff } = ctx;
|
||||
// return await this.sectionService.create(input, { staff });
|
||||
// }),
|
||||
// createMany: this.trpc.protectProcedure.input(z.array(SectionCreateManyInputSchema))
|
||||
// .mutation(async ({ ctx, input }) => {
|
||||
// const { staff } = ctx;
|
||||
|
||||
// return await this.sectionService.createMany({ data: input }, staff);
|
||||
// }),
|
||||
// deleteMany: this.trpc.procedure
|
||||
// .input(SectionDeleteManyArgsSchema)
|
||||
// .mutation(async ({ input }) => {
|
||||
// return await this.sectionService.deleteMany(input);
|
||||
// }),
|
||||
// findFirst: this.trpc.procedure
|
||||
// .input(SectionFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
// .query(async ({ input }) => {
|
||||
// return await this.sectionService.findFirst(input);
|
||||
// }),
|
||||
// softDeleteByIds: this.trpc.protectProcedure
|
||||
// .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
||||
// .mutation(async ({ input }) => {
|
||||
// return this.sectionService.softDeleteByIds(input.ids);
|
||||
// }),
|
||||
// updateOrder: this.trpc.protectProcedure
|
||||
// .input(UpdateOrderSchema)
|
||||
// .mutation(async ({ input }) => {
|
||||
// return this.sectionService.updateOrder(input);
|
||||
// }),
|
||||
// findMany: this.trpc.procedure
|
||||
// .input(SectionFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
// .query(async ({ input }) => {
|
||||
// return await this.sectionService.findMany(input);
|
||||
// }),
|
||||
// findManyWithCursor: this.trpc.protectProcedure
|
||||
// .input(z.object({
|
||||
// cursor: z.any().nullish(),
|
||||
// take: z.number().nullish(),
|
||||
// where: SectionWhereInputSchema.nullish(),
|
||||
// select: SectionSelectSchema.nullish()
|
||||
// }))
|
||||
// .query(async ({ ctx, input }) => {
|
||||
// const { staff } = ctx;
|
||||
// return await this.sectionService.findManyWithCursor(input);
|
||||
// }),
|
||||
// });
|
||||
// }
|
|
@ -1,23 +0,0 @@
|
|||
// import { Injectable } from '@nestjs/common';
|
||||
// import { BaseService } from '../base/base.service';
|
||||
// import {
|
||||
// UserProfile,
|
||||
// db,
|
||||
// ObjectType,
|
||||
// Prisma,
|
||||
|
||||
// } from '@nice/common';
|
||||
// @Injectable()
|
||||
// export class SectionService extends BaseService<Prisma.SectionDelegate> {
|
||||
// constructor() {
|
||||
// super(db, ObjectType.SECTION);
|
||||
// }
|
||||
|
||||
// create(args: Prisma.SectionCreateArgs, params?: { staff?: UserProfile }) {
|
||||
// return super.create(args)
|
||||
// }
|
||||
// async update(args: Prisma.SectionUpdateArgs) {
|
||||
// return super.update(args);
|
||||
// }
|
||||
|
||||
// }
|
|
@ -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);
|
||||
|
|
|
@ -269,10 +269,10 @@ export class TermService extends BaseTreeService<Prisma.TermDelegate> {
|
|||
}
|
||||
|
||||
async getChildSimpleTree(
|
||||
staff: UserProfile,
|
||||
data: z.infer<typeof TermMethodSchema.getSimpleTree>,
|
||||
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);
|
||||
|
@ -352,7 +352,9 @@ export class TermService extends BaseTreeService<Prisma.TermDelegate> {
|
|||
staff: UserProfile,
|
||||
data: z.infer<typeof TermMethodSchema.getSimpleTree>,
|
||||
) {
|
||||
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);
|
||||
|
|
|
@ -4,23 +4,25 @@ import { Prisma } from '@nice/common';
|
|||
|
||||
import { VisitService } from './visit.service';
|
||||
import { z, ZodType } from 'zod';
|
||||
const VisitCreateArgsSchema: ZodType<Prisma.VisitCreateArgs> = z.any()
|
||||
const VisitCreateManyInputSchema: ZodType<Prisma.VisitCreateManyInput> = z.any()
|
||||
const VisitDeleteManyArgsSchema: ZodType<Prisma.VisitDeleteManyArgs> = z.any()
|
||||
const VisitCreateArgsSchema: ZodType<Prisma.VisitCreateArgs> = z.any();
|
||||
const VisitCreateManyInputSchema: ZodType<Prisma.VisitCreateManyInput> =
|
||||
z.any();
|
||||
const VisitDeleteManyArgsSchema: ZodType<Prisma.VisitDeleteManyArgs> = z.any();
|
||||
@Injectable()
|
||||
export class VisitRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly visitService: VisitService,
|
||||
) { }
|
||||
) {}
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
create: this.trpc.procedure
|
||||
.input(VisitCreateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.visitService.create(input, staff);
|
||||
}),
|
||||
createMany: this.trpc.protectProcedure.input(z.array(VisitCreateManyInputSchema))
|
||||
createMany: this.trpc.procedure
|
||||
.input(z.array(VisitCreateManyInputSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
|
||||
|
@ -31,7 +33,5 @@ export class VisitRouter {
|
|||
.mutation(async ({ input }) => {
|
||||
return await this.visitService.deleteMany(input);
|
||||
}),
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
|
|
|
@ -9,17 +9,22 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
|
|||
}
|
||||
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<Prisma.VisitDelegate> {
|
|||
});
|
||||
}
|
||||
|
||||
// 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<Prisma.VisitDelegate> {
|
|||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}` },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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 {}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -10,7 +10,7 @@ const pipeline = new ResourceProcessingPipeline()
|
|||
.addProcessor(new VideoProcessor());
|
||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||
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');
|
||||
|
|
|
@ -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<any, any, QueueJobType>) {
|
||||
try {
|
||||
|
@ -44,6 +48,15 @@ export default async function processJob(job: Job<any, any, QueueJobType>) {
|
|||
`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}`,
|
||||
|
|
|
@ -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<string, Department[]> = {};
|
||||
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,
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
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;
|
||||
|
||||
staffCount: number
|
||||
termCount: number
|
||||
staffCount: number;
|
||||
termCount: number;
|
||||
courseCount: number;
|
||||
}
|
||||
export async function getCounts(): Promise<DevDataCounts> {
|
||||
const counts = {
|
||||
|
@ -12,6 +19,11 @@ export async function getCounts(): Promise<DevDataCounts> {
|
|||
|
||||
staffCount: await db.staff.count(),
|
||||
termCount: await db.term.count(),
|
||||
courseCount: await db.post.count({
|
||||
where: {
|
||||
type: PostType.COURSE,
|
||||
},
|
||||
}),
|
||||
};
|
||||
return counts;
|
||||
}
|
||||
|
@ -30,5 +42,3 @@ export function getRandomImageLinks(count: number = 5): string[] {
|
|||
|
||||
return imageLinks;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<MessageDto> },
|
||||
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<MessageDto> };
|
||||
dataChanged: { type: string; operation: CrudOperation; data: any };
|
||||
};
|
||||
const EventBus = mitt<Events>();
|
||||
export default EventBus;
|
||||
|
|
|
@ -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"
|
||||
|
|
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 8.4 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 8.4 KiB |
|
@ -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, {
|
||||
const { data: clientCount } = api.app_config.getClientCount.useQuery(
|
||||
undefined,
|
||||
{
|
||||
refetchInterval: 3000,
|
||||
refetchIntervalInBackground: true
|
||||
})
|
||||
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">
|
||||
{/* <div
|
||||
|
@ -129,6 +127,13 @@ export default function BaseSettingPage() {
|
|||
<Input></Input>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="p-2 grid grid-cols-8 gap-2 border-b">
|
||||
<Form.Item
|
||||
label="首页轮播图"
|
||||
name={["appConfig", "slides"]}>
|
||||
<MultiAvatarUploader></MultiAvatarUploader>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{/* <div
|
||||
className="p-2 border-b flex items-center justify-between"
|
||||
style={{
|
||||
|
@ -173,7 +178,8 @@ export default function BaseSettingPage() {
|
|||
清除行模型缓存
|
||||
</Button>
|
||||
</div>
|
||||
{<div
|
||||
{
|
||||
<div
|
||||
className="p-2 border-b text-primary flex justify-between items-center"
|
||||
style={{
|
||||
fontSize: token.fontSize,
|
||||
|
@ -181,9 +187,12 @@ export default function BaseSettingPage() {
|
|||
}}>
|
||||
<span>app在线人数</span>
|
||||
<div>
|
||||
{clientCount && clientCount > 0 ? `${clientCount}人在线` : '无人在线'}
|
||||
{clientCount && clientCount > 0
|
||||
? `${clientCount}人在线`
|
||||
: "无人在线"}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -3,6 +3,5 @@ import { useParams } from "react-router-dom";
|
|||
|
||||
export function CourseDetailPage() {
|
||||
const { id, lectureId } = useParams();
|
||||
console.log("Course ID:", id);
|
||||
return <CourseDetail id={id} lectureId={lectureId}></CourseDetail>;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import { Checkbox, List } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
export function CoursePreviewTabmsg({data}){
|
||||
|
||||
|
||||
const renderItem = (item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
|
||||
return(
|
||||
<div className='my-2'>
|
||||
<List
|
||||
dataSource={data}
|
||||
split={false}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import type { MenuProps } from 'antd';
|
||||
import { Menu } from 'antd';
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
|
||||
export function CourseCatalog(){
|
||||
return (
|
||||
<>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,7 +1,73 @@
|
|||
export function coursePreviewAllmsg() {
|
||||
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 (
|
||||
<div className="min-h-screen">
|
||||
helloword
|
||||
<div className="min-h-screen max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="overflow-auto flex justify-around align-items-center w-full mx-auto my-8">
|
||||
<div className="relative w-[600px] h-[340px] m-4 overflow-hidden flex justify-center items-center">
|
||||
<Image
|
||||
src={
|
||||
previewMsg.isLoading
|
||||
? "error"
|
||||
: previewMsg.videoPreview
|
||||
}
|
||||
alt="example"
|
||||
preview={false}
|
||||
className="w-full h-full object-cover z-0"
|
||||
fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghgg0Aj8i0JO4OzsrPv69Wv+hi2qPHr0qNvf39+iI97soRIh4f3z58/u7du3SXX7Xt7Z2enevHmzfQe+oSN2apSAPj09TSrb+XKI/f379+08+A0cNRE2ANkupk+ACNPvkSPcAAEibACyXUyfABGm3yNHuAECRNgAZLuYPgEirKlHu7u7XdyytGwHAd8jjNyng4OD7vnz51dbPT8/7z58+NB9+/bt6jU/TI+AGWHEnrx48eJ/EsSmHzx40L18+fLyzxF3ZVMjEyDCiEDjMYZZS5wiPXnyZFbJaxMhQIQRGzHvWR7XCyOCXsOmiDAi1HmPMMQjDpbpEiDCiL358eNHurW/5SnWdIBbXiDCiA38/Pnzrce2YyZ4//59F3ePLNMl4PbpiL2J0L979+7yDtHDhw8vtzzvdGnEXdvUigSIsCLAWavHp/+qM0BcXMd/q25n1vF57TYBp0a3mUzilePj4+7k5KSLb6gt6ydAhPUzXnoPR0dHl79WGTNCfBnn1uvSCJdegQhLI1vvCk+fPu2ePXt2tZOYEV6/fn31dz+shwAR1sP1cqvLntbEN9MxA9xcYjsxS1jWR4AIa2Ibzx0tc44fYX/16lV6NDFLXH+YL32jwiACRBiEbf5KcXoTIsQSpzXx4N28Ja4BQoK7rgXiydbHjx/P25TaQAJEGAguWy0+2Q8PD6/Ki4R8EVl+bzBOnZY95fq9rj9zAkTI2SxdidBHqG9+skdw43borCXO/ZcJdraPWdv22uIEiLA4q7nvvCug8WTqzQveOH26fodo7g6uFe/a17W3+nFBAkRYENRdb1vkkz1CH9cPsVy/jrhr27PqMYvENYNlHAIesRiBYwRy0V+8iXP8+/fvX11Mr7L7ECueb/r48eMqm7FuI2BGWDEG8cm+7G3NEOfmdcTQw4h9/55lhm7DekRYKQPZF2ArbXTAyu4kDYB2YxUzwg0gi/41ztHnfQG26HbGel/crVrm7tNY+/1btkOEAZ2M05r4FB7r9GbAIdxaZYrHdOsgJ/wCEQY0J74TmOKnbxxT9n3FgGGWWsVdowHtjt9Nnvf7yQM2aZU/TIAIAxrw6dOnAWtZZcoEnBpNuTuObWMEiLAx1HY0ZQJEmHJ3HNvGCBBhY6jtaMoEiJB0Z29vL6ls58vxPcO8/zfrdo5qvKO+d3Fx8Wu8zf1dW4p/cPzLly/dtv9Ts/EbcvGAHhHyfBIhZ6NSiIBTo0LNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiEC/wGgKKC4YMA4TAAAAABJRU5ErkJggg=="
|
||||
/>
|
||||
<div className="w-[600px] h-[360px] absolute top-0 z-10 bg-black opacity-30 transition-opacity duration-300 ease-in-out hover:opacity-70 cursor-pointer">
|
||||
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10" />
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
<div className="flex flex-col justify-between w-2/5 content-start h-[340px] my-4 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<Skeleton className="my-5" active />
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl font-bold my-3 ">
|
||||
{previewMsg.Title}
|
||||
</span>
|
||||
<span className="text-xl font-semibold my-3 text-gray-700">
|
||||
{previewMsg.SubTitle}
|
||||
</span>
|
||||
<span className="text-lg font-light my-3 text-gray-500 text-clip">
|
||||
{previewMsg.Description}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button block type="primary" size="large">
|
||||
{" "}
|
||||
查看课程{" "}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-auto w-11/12 mx-auto my-8">
|
||||
<Tabs
|
||||
defaultActiveKey="1"
|
||||
tabBarGutter={100}
|
||||
items={items}
|
||||
onChange={TapOnChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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 ? <Skeleton className='my-5' active />: <CoursePreviewTabmsg data={tapData}/>,
|
||||
}
|
||||
];
|
||||
|
||||
return(
|
||||
<div className="min-h-screen">
|
||||
helloword
|
||||
<CoursePreviewAllmsg previewMsg= {previewMsg} isLoading={courseIsLoading } items = {items}></CoursePreviewAllmsg>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export interface CoursePreviewMsg {
|
||||
videoPreview: string;
|
||||
Title: string;
|
||||
SubTitle: string;
|
||||
Description: string;
|
||||
ToCourseUrl: string;
|
||||
isLoading: boolean;
|
||||
}
|
|
@ -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 (
|
||||
<Card
|
||||
hoverable
|
||||
className="w-full h-full transition-all duration-300 hover:shadow-lg"
|
||||
cover={
|
||||
<img
|
||||
alt={course.title}
|
||||
src={course.thumbnail}
|
||||
className="object-cover w-full h-40"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold line-clamp-2 hover:text-blue-600 transition-colors">
|
||||
{course.title}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-sm">{course.instructor}</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Rate disabled defaultValue={course.rating} className="text-sm" />
|
||||
<span className="text-gray-500 text-sm">{course.rating}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<UserOutlined className="text-gray-400" />
|
||||
<span>{course.enrollments} 人在学</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<ClockCircleOutlined className="text-gray-400" />
|
||||
<span>{course.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Tag color="blue" className="rounded-full px-3">{course.category}</Tag>
|
||||
<Tag color="green" className="rounded-full px-3">{course.level}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<div className="space-y-6">
|
||||
{courses.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{courses.map(course => (
|
||||
<CourseCard key={course.id} course={course} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center mt-8">
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onChange={onPageChange}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty description="暂无相关课程" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => <CourseCard post={post}></CourseCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
type: PostType.COURSE,
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoursesContainer;
|
|
@ -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 (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-4">课程分类</h3>
|
||||
<Radio.Group
|
||||
value={selectedCategory}
|
||||
onChange={(e) => onCategoryChange(e.target.value)}
|
||||
className="flex flex-col space-y-3"
|
||||
>
|
||||
{
|
||||
gateGory.isLoading?
|
||||
(<Spin/>)
|
||||
:
|
||||
(
|
||||
<>
|
||||
<Radio value="">全部课程</Radio>
|
||||
{gateGory.categories.map(category => (
|
||||
<Radio key={category} value={category}>
|
||||
{category}
|
||||
</Radio>
|
||||
))}
|
||||
</>)
|
||||
}
|
||||
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<Divider className="my-6" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-4">难度等级</h3>
|
||||
<Radio.Group
|
||||
value={selectedLevel}
|
||||
onChange={(e) => onLevelChange(e.target.value)}
|
||||
className="flex flex-col space-y-3"
|
||||
>
|
||||
{
|
||||
levels.isLoading ?
|
||||
(<Spin/>)
|
||||
:
|
||||
(
|
||||
<>
|
||||
<Radio value="">全部难度</Radio>
|
||||
{levels.categories.map(level => (
|
||||
<Radio key={level} value={level}>
|
||||
{level}
|
||||
</Radio>
|
||||
))}
|
||||
</>)
|
||||
}
|
||||
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<div className="bg-slate-50 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="text-3xl font-semibold text-slate-800">
|
||||
我教授的课程
|
||||
</h1>
|
||||
<Button
|
||||
onClick={() => navigate("/course/editor")}
|
||||
variant="primary"
|
||||
leftIcon={<PlusIcon className="w-5 h-5" />}>
|
||||
创建课程
|
||||
</Button>
|
||||
</div>
|
||||
<CourseList
|
||||
totalPages={paginationRes?.totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
currentPage={currentPage}
|
||||
courses={paginationRes?.items as any}
|
||||
renderItem={(course) => (
|
||||
<CourseCard
|
||||
onClick={() => {
|
||||
navigate(`/course/${course.id}/editor`, {
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
course={course}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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)}小时`
|
||||
}));
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex gap-4">
|
||||
{/* 左侧筛选区域 */}
|
||||
<div className="lg:w-1/5">
|
||||
<div className="sticky top-24">
|
||||
<FilterSection
|
||||
selectedCategory={selectedCategory}
|
||||
selectedLevel={selectedLevel}
|
||||
onCategoryChange={(category) => {
|
||||
setSelectedCategory(category);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onLevelChange={(level) => {
|
||||
setSelectedLevel(level);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧课程列表区域 */}
|
||||
<div className="lg:w-4/5">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<span className="text-gray-600">
|
||||
共找到 {filteredCourses.length} 门课程
|
||||
</span>
|
||||
</div>
|
||||
<CourseList
|
||||
courses={paginatedCourses}
|
||||
total={filteredCourses.length}
|
||||
pageSize={pageSize}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<BasePostLayout>
|
||||
<CoursesContainer></CoursesContainer>
|
||||
</BasePostLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<div className="bg-slate-50 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-semibold text-slate-800 mb-4">
|
||||
我参加的课程
|
||||
</h1>
|
||||
</div>
|
||||
<CourseList
|
||||
totalPages={paginationRes?.totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
currentPage={currentPage}
|
||||
courses={paginationRes?.items as any}
|
||||
renderItem={(course) => (
|
||||
<CourseCard course={course}></CourseCard>
|
||||
)}></CourseList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,73 +1,29 @@
|
|||
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<number | null>(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
/**
|
||||
* const {data,isLoading} :{data:TermDto[],isLoading:boolean}= api.term.findMany.useQuery({
|
||||
where:{
|
||||
const { selectedTerms, setSelectedTerms } = useMainContext();
|
||||
const {
|
||||
data: courseCategoriesData,
|
||||
isLoading,
|
||||
}: { data: TermDto[]; isLoading: boolean } = api.term.findMany.useQuery({
|
||||
where: {
|
||||
taxonomy: {
|
||||
slug:TaxonomySlug.CATEGORY
|
||||
}
|
||||
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])
|
||||
*/
|
||||
|
||||
parentId: null,
|
||||
},
|
||||
take: 8,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleMouseEnter = useCallback((index: number) => {
|
||||
setHoveredIndex(index);
|
||||
|
@ -77,15 +33,21 @@ const CategorySection = () => {
|
|||
setHoveredIndex(null);
|
||||
}, []);
|
||||
|
||||
const displayedCategories = showAll
|
||||
? courseCategories
|
||||
: courseCategories.slice(0, 8);
|
||||
|
||||
const handleMouseClick = useCallback((categoryId: string) => {
|
||||
setSelectedTerms({
|
||||
...selectedTerms,
|
||||
[TaxonomySlug.CATEGORY]: [categoryId],
|
||||
});
|
||||
navigate("/courses");
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
return (
|
||||
<section className="py-32 relative overflow-hidden">
|
||||
<section className="py-8 relative overflow-hidden">
|
||||
<div className="max-w-screen-2xl mx-auto px-4 relative">
|
||||
<div className="text-center mb-24">
|
||||
<Title level={2} className="font-bold text-5xl mb-6 bg-gradient-to-r from-gray-900 via-gray-700 to-gray-800 bg-clip-text text-transparent motion-safe:animate-gradient-x">
|
||||
<div className="text-center mb-12">
|
||||
<Title
|
||||
level={2}
|
||||
className="font-bold text-5xl mb-6 bg-gradient-to-r from-gray-900 via-gray-700 to-gray-800 bg-clip-text text-transparent motion-safe:animate-gradient-x">
|
||||
探索课程分类
|
||||
</Title>
|
||||
<Text type="secondary" className="text-xl font-light">
|
||||
|
@ -93,90 +55,30 @@ const CategorySection = () => {
|
|||
</Text>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{displayedCategories.map((category, index) => {
|
||||
{isLoading ? (
|
||||
<Skeleton paragraph={{ rows: 4 }}></Skeleton>
|
||||
) : (
|
||||
courseCategoriesData?.map((category, index) => {
|
||||
const categoryColor = stringToColor(category.name);
|
||||
const isHovered = hoveredIndex === index;
|
||||
|
||||
return (
|
||||
<div
|
||||
<CategorySectionCard
|
||||
key={index}
|
||||
className="group relative rounded-2xl transition-all duration-700 ease-out cursor-pointer will-change-transform hover:-translate-y-2"
|
||||
onMouseEnter={() => handleMouseEnter(index)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`查看${category.name}课程类别`}
|
||||
>
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-r from-gray-200 to-gray-300 opacity-50 rounded-2xl transition-all duration-700 group-hover:opacity-75" />
|
||||
<div
|
||||
className={`absolute inset-0 rounded-2xl bg-gradient-to-br from-white to-gray-50 shadow-lg transition-all duration-700 ease-out ${
|
||||
isHovered ? 'scale-[1.02] bg-opacity-95' : 'scale-100 bg-opacity-90'
|
||||
}`}
|
||||
index={index}
|
||||
category={category}
|
||||
categoryColor={categoryColor}
|
||||
isHovered={isHovered}
|
||||
handleMouseEnter={handleMouseEnter}
|
||||
handleMouseLeave={handleMouseLeave}
|
||||
handleMouseClick={handleMouseClick}
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-0 rounded-2xl transition-all duration-700 ease-out ${
|
||||
isHovered ? 'shadow-[0_8px_30px_rgb(0,0,0,0.12)]' : 'shadow-none opacity-0'
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-0 left-1/2 -translate-x-1/2 h-1 rounded-full transition-all duration-500 ease-out ${
|
||||
isHovered ? 'w-36 opacity-90' : 'w-24 opacity-60'
|
||||
}`}
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
/>
|
||||
<div className="relative p-6">
|
||||
<div className="flex flex-col space-y-4 mb-4">
|
||||
<Text strong className="text-xl font-semibold tracking-tight">
|
||||
{category.name}
|
||||
</Text>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-sm w-fit font-medium transition-all duration-500 ease-out ${
|
||||
isHovered ? 'shadow-md scale-105' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: `${categoryColor}15`,
|
||||
color: categoryColor
|
||||
}}
|
||||
>
|
||||
{category.count} 门课程
|
||||
</span>
|
||||
</div>
|
||||
<Text type="secondary" className="block text-sm leading-relaxed opacity-90">
|
||||
{category.description}
|
||||
</Text>
|
||||
<div
|
||||
className={`mt-6 text-sm font-medium flex items-center space-x-2 transition-all duration-500 ease-out ${
|
||||
isHovered ? 'translate-x-2' : ''
|
||||
}`}
|
||||
style={{ color: categoryColor }}
|
||||
>
|
||||
<span>了解更多</span>
|
||||
<span
|
||||
className={`transform transition-all duration-500 ease-out ${
|
||||
isHovered ? 'translate-x-2' : ''
|
||||
}`}
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{courseCategories.length > 8 && (
|
||||
<div className="flex justify-center mt-12">
|
||||
<Button
|
||||
type="default"
|
||||
size="large"
|
||||
className="px-8 h-12 text-base font-medium hover:shadow-md transition-all duration-300"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
>
|
||||
{showAll ? '收起' : '查看更多分类'}
|
||||
</Button>
|
||||
</div>
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<LookForMore to={"/courses"}></LookForMore>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<div
|
||||
key={index}
|
||||
className="group relative min-h-[130px] rounded-2xl transition-all duration-700 ease-out cursor-pointer will-change-transform hover:-translate-y-2"
|
||||
onMouseEnter={() => handleMouseEnter(index)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`查看${category.name}课程类别`}
|
||||
onClick={()=>{
|
||||
handleMouseClick(category.id)
|
||||
}}
|
||||
>
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-r from-gray-200 to-gray-300 opacity-50 rounded-2xl transition-all duration-700 group-hover:opacity-75" />
|
||||
<div
|
||||
className={`absolute inset-0 rounded-2xl bg-gradient-to-br from-white to-gray-50 shadow-lg transition-all duration-700 ease-out ${isHovered
|
||||
? "scale-[1.02] bg-opacity-95"
|
||||
: "scale-100 bg-opacity-90"
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-0 rounded-2xl transition-all duration-700 ease-out ${isHovered
|
||||
? "shadow-[0_8px_30px_rgb(0,0,0,0.12)]"
|
||||
: "shadow-none opacity-0"
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute w-1/2 top-0 left-1/2 -translate-x-1/2 h-1 rounded-full transition-all duration-500 ease-out `}
|
||||
style={{
|
||||
backgroundColor: categoryColor,
|
||||
}}
|
||||
/>
|
||||
<div className="relative w-full h-full p-6">
|
||||
<div className="flex w-2/3 absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 flex-col space-y-4 mb-4">
|
||||
<Text
|
||||
strong
|
||||
className="text-xl font-medium tracking-wide">
|
||||
{category.name}
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={` mt-6 absolute bottom-4 right-6 text-sm font-medium flex items-center space-x-2 transition-all duration-500 ease-out `}
|
||||
style={{ color: categoryColor }}>
|
||||
<span>了解更多</span>
|
||||
<span
|
||||
className={`transform transition-all duration-500 ease-out `}>
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -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';
|
||||
|
||||
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;
|
||||
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 CoursesSectionProps {
|
||||
title: string;
|
||||
description: string;
|
||||
courses: Course[];
|
||||
initialVisibleCoursesCount?: number;
|
||||
postType:string;
|
||||
render?:(post)=>ReactNode;
|
||||
to:string
|
||||
}
|
||||
|
||||
|
||||
const CoursesSection: React.FC<CoursesSectionProps> = ({
|
||||
title,
|
||||
description,
|
||||
courses,
|
||||
initialVisibleCoursesCount = 8,
|
||||
postType,
|
||||
render,
|
||||
to
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('全部');
|
||||
const [visibleCourses, setVisibleCourses] = useState(initialVisibleCoursesCount);
|
||||
const gateGory : GetTaxonomyProps = useGetTaxonomy({
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("全部");
|
||||
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 (
|
||||
<section className="relative py-16 overflow-hidden">
|
||||
<section className="relative py-16 overflow-hidden ">
|
||||
<div className="max-w-screen-2xl mx-auto px-4 relative">
|
||||
<div className="flex justify-between items-end mb-12">
|
||||
<div className="flex justify-between items-end mb-12 ">
|
||||
<div>
|
||||
<Title
|
||||
level={2}
|
||||
className="font-bold text-5xl mb-6 bg-gradient-to-r from-gray-900 via-blue-600 to-gray-800 bg-clip-text text-transparent motion-safe:animate-gradient-x"
|
||||
>
|
||||
className="font-bold text-5xl mb-6 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
{title}
|
||||
</Title>
|
||||
<Text type="secondary" className="text-xl font-light">
|
||||
<Text
|
||||
type="secondary"
|
||||
className="text-xl font-light text-gray-600">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex flex-wrap gap-3">
|
||||
{gateGory.isLoading ? <Spin className='m-3'/> :
|
||||
(
|
||||
<div className="mb-12 flex flex-wrap gap-4">
|
||||
{gateGory.isLoading ? (
|
||||
<Skeleton paragraph={{ rows: 2 }}></Skeleton>
|
||||
) : (
|
||||
<>
|
||||
<Tag
|
||||
color={selectedCategory === "全部" ? 'blue' : 'default'}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>全部</Tag>
|
||||
{
|
||||
gateGory.categories.map((category) => (
|
||||
<Tag
|
||||
key={category}
|
||||
color={selectedCategory === category ? 'blue' : 'default'}
|
||||
onClick={() => 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}
|
||||
</Tag>
|
||||
))
|
||||
{["全部", ...gateGory.categories].map(
|
||||
(category, idx) => (
|
||||
<CoursesSectionTag
|
||||
key={idx}
|
||||
category={category}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={
|
||||
setSelectedCategory
|
||||
}
|
||||
</>
|
||||
|
||||
/>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{displayedCourses.map((course) => (
|
||||
<Card
|
||||
key={course.id}
|
||||
hoverable
|
||||
className="group overflow-hidden rounded-2xl border-0 bg-white/70 backdrop-blur-sm
|
||||
shadow-[0_10px_40px_-15px_rgba(0,0,0,0.1)] hover:shadow-[0_20px_50px_-15px_rgba(0,0,0,0.15)]
|
||||
transition-all duration-700 ease-out transform hover:-translate-y-1 will-change-transform"
|
||||
cover={
|
||||
<div className="relative h-48 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transform transition-all duration-1000 ease-out group-hover:scale-110"
|
||||
style={{ backgroundImage: `url(${course.thumbnail})` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-black/60 via-black/40 to-transparent opacity-60 group-hover:opacity-40 transition-opacity duration-700" />
|
||||
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-6xl text-white/90 opacity-0 group-hover:opacity-100 transition-all duration-500 ease-out transform group-hover:scale-110 drop-shadow-lg" />
|
||||
{course.progress > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 backdrop-blur-md bg-black/20">
|
||||
<Progress
|
||||
percent={course.progress}
|
||||
showInfo={false}
|
||||
strokeColor={{
|
||||
from: '#3b82f6',
|
||||
to: '#60a5fa',
|
||||
<PostList
|
||||
renderItem={(post) => render(post)}
|
||||
params={{
|
||||
page: 1,
|
||||
pageSize: initialVisibleCoursesCount,
|
||||
where: {
|
||||
terms: !(selectedCategory === "全部")
|
||||
? {
|
||||
some: {
|
||||
name: selectedCategory,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
type: postType
|
||||
},
|
||||
}}
|
||||
className="m-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="px-2">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Tag
|
||||
color="blue"
|
||||
className="px-3 py-1 rounded-full border-0 shadow-[0_2px_8px_-4px_rgba(59,130,246,0.5)] transition-all duration-300 hover:shadow-[0_4px_12px_-4px_rgba(59,130,246,0.6)]"
|
||||
>
|
||||
{course.category}
|
||||
</Tag>
|
||||
<Tag
|
||||
color={
|
||||
course.level === '入门'
|
||||
? 'green'
|
||||
: course.level === '中级'
|
||||
? 'blue'
|
||||
: 'purple'
|
||||
}
|
||||
className="px-3 py-1 rounded-full border-0 shadow-sm transition-all duration-300 hover:shadow-md"
|
||||
>
|
||||
{course.level}
|
||||
</Tag>
|
||||
</div>
|
||||
<Title
|
||||
level={4}
|
||||
className="mb-4 line-clamp-2 font-bold leading-snug transition-colors duration-300 group-hover:text-blue-600"
|
||||
>
|
||||
{course.title}
|
||||
</Title>
|
||||
<div className="flex items-center mb-4 transition-all duration-300 group-hover:text-blue-500">
|
||||
<UserOutlined className="mr-2 text-blue-500" />
|
||||
<Text className="text-gray-600 font-medium group-hover:text-blue-500">
|
||||
{course.instructor}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-4 text-gray-500 text-sm">
|
||||
<span className="flex items-center">
|
||||
<ClockCircleOutlined className="mr-1.5" />
|
||||
{course.duration}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<TeamOutlined className="mr-1.5" />
|
||||
{course.students.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center text-yellow-500">
|
||||
<StarOutlined className="mr-1.5" />
|
||||
{course.rating}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-gray-100 text-center">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)]
|
||||
transform hover:translate-y-[-2px] transition-all duration-500 ease-out"
|
||||
>
|
||||
立即学习
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredCourses.length >= visibleCourses && (
|
||||
<div className=' flex items-center gap-4 justify-between mt-6'>
|
||||
<div className='h-[1px] flex-grow bg-gray-200'></div>
|
||||
<div className="flex justify-end ">
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
查看更多
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
showPagination={false}
|
||||
cols={4}></PostList>
|
||||
<LookForMore to={to}></LookForMore>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursesSection;
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import { Tag } from "antd";
|
||||
|
||||
export function CoursesSectionTag({category, selectedCategory, setSelectedCategory}) {
|
||||
return (
|
||||
<>
|
||||
<Tag
|
||||
key={category}
|
||||
color={
|
||||
selectedCategory === category
|
||||
? "blue"
|
||||
: "default"
|
||||
}
|
||||
onClick={() => {
|
||||
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}
|
||||
</Tag>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,14 +1,21 @@
|
|||
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';
|
||||
RightOutlined,
|
||||
EyeOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { CarouselRef } from "antd/es/carousel";
|
||||
import { useAppConfig } from "@nice/client";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
|
@ -22,37 +29,38 @@ interface CarouselItem {
|
|||
|
||||
interface PlatformStat {
|
||||
icon: React.ReactNode;
|
||||
value: string;
|
||||
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: <TeamOutlined />, value: '50,000+', label: '注册学员' },
|
||||
{ icon: <BookOutlined />, value: '1,000+', label: '精品课程' },
|
||||
{ icon: <StarOutlined />, value: '98%', label: '好评度' },
|
||||
{ icon: <ClockCircleOutlined />, value: '100万+', label: '学习时长' }
|
||||
];
|
||||
|
||||
const HeroSection = () => {
|
||||
const carouselRef = useRef<CarouselRef>(null);
|
||||
|
||||
const { statistics, slides } = useAppConfig();
|
||||
const [countStatistics, setCountStatistics] = useState<number>(4);
|
||||
const platformStats: PlatformStat[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
icon: <TeamOutlined />,
|
||||
value: statistics.staffs,
|
||||
label: "注册学员",
|
||||
},
|
||||
{
|
||||
icon: <StarOutlined />,
|
||||
value: statistics.courses,
|
||||
label: "精品课程",
|
||||
},
|
||||
{
|
||||
icon: <BookOutlined />,
|
||||
value: statistics.lectures,
|
||||
label: "课程章节",
|
||||
},
|
||||
{
|
||||
icon: <EyeOutlined />,
|
||||
value: statistics.reads,
|
||||
label: "播放次数",
|
||||
},
|
||||
];
|
||||
}, [statistics]);
|
||||
const handlePrev = useCallback(() => {
|
||||
carouselRef.current?.prev();
|
||||
}, []);
|
||||
|
@ -61,6 +69,15 @@ const HeroSection = () => {
|
|||
carouselRef.current?.next();
|
||||
}, []);
|
||||
|
||||
const countNonZeroValues = (statistics: Record<string, number>): number => {
|
||||
return Object.values(statistics).filter((value) => value !== 0).length;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const count = countNonZeroValues(statistics);
|
||||
console.log(count);
|
||||
setCountStatistics(count);
|
||||
}, [statistics]);
|
||||
return (
|
||||
<section className="relative ">
|
||||
<div className="group">
|
||||
|
@ -70,86 +87,73 @@ const HeroSection = () => {
|
|||
effect="fade"
|
||||
className="h-[600px] mb-24"
|
||||
dots={{
|
||||
className: 'carousel-dots !bottom-32 !z-20',
|
||||
}}
|
||||
>
|
||||
{carouselItems.map((item, index) => (
|
||||
className: "carousel-dots !bottom-32 !z-20",
|
||||
}}>
|
||||
{Array.isArray(slides) ? (
|
||||
slides.map((item, index) => (
|
||||
<div key={index} className="relative h-[600px]">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transform transition-[transform,filter] duration-[2000ms] group-hover:scale-105 group-hover:brightness-110 will-change-[transform,filter]"
|
||||
style={{
|
||||
backgroundImage: `url(${item.image})`,
|
||||
backfaceVisibility: 'hidden'
|
||||
//backgroundImage: `url(https://s.cn.bing.net/th?id=OHR.GiantCuttlefish_ZH-CN0670915878_1920x1080.webp&qlt=50)`,
|
||||
backgroundImage: `url(${item})`,
|
||||
backfaceVisibility: "hidden",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
{/* <div
|
||||
className={`absolute inset-0 bg-gradient-to-r ${item.color} to-transparent opacity-90 mix-blend-overlay transition-opacity duration-500`}
|
||||
/>
|
||||
/> */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
|
||||
{/* Content Container */}
|
||||
<div className="relative h-full max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 max-w-2xl">
|
||||
<Title
|
||||
className="text-white mb-8 text-5xl md:text-6xl xl:text-7xl !leading-tight font-bold tracking-tight"
|
||||
style={{
|
||||
transform: 'translateZ(0)'
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</Title>
|
||||
<Text className="text-white/95 text-lg md:text-xl block mb-12 font-light leading-relaxed">
|
||||
{item.desc}
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
className="h-14 px-12 text-lg font-semibold bg-gradient-to-r from-primary to-primary-600 border-0 shadow-lg hover:shadow-xl hover:from-primary-600 hover:to-primary-700 hover:scale-105 transform transition-all duration-300 ease-out"
|
||||
>
|
||||
{item.action}
|
||||
</Button>
|
||||
<div className="relative h-full max-w-7xl mx-auto px-6 lg:px-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
</Carousel>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
className="absolute left-4 md:left-8 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover:opacity-100 transition-all duration-300 bg-black/20 hover:bg-black/30 w-12 h-12 flex items-center justify-center rounded-full transform hover:scale-110 hover:shadow-lg"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
aria-label="Previous slide">
|
||||
<LeftOutlined className="text-white text-xl" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="absolute right-4 md:right-8 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover:opacity-100 transition-all duration-300 bg-black/20 hover:bg-black/30 w-12 h-12 flex items-center justify-center rounded-full transform hover:scale-110 hover:shadow-lg"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
aria-label="Next slide">
|
||||
<RightOutlined className="text-white text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Container */}
|
||||
<div className="absolute -bottom-24 left-1/2 -translate-x-1/2 w-full max-w-6xl px-4">
|
||||
<div className="rounded-2xl grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-8 p-6 md:p-8 bg-white border shadow-xl hover:shadow-2xl transition-shadow duration-500 will-change-[transform,box-shadow]">
|
||||
{platformStats.map((stat, index) => (
|
||||
{countStatistics > 1 && (
|
||||
<div className="absolute -bottom-20 left-1/2 -translate-x-1/2 w-3/5 max-w-6xl px-4">
|
||||
<div
|
||||
className={`rounded-2xl grid grid-cols-${countStatistics} lg:grid-cols-${countStatistics} md:grid-cols-${countStatistics} gap-4 md:gap-8 p-6 md:p-8 bg-white border shadow-xl hover:shadow-2xl transition-shadow duration-500 will-change-[transform,box-shadow]`}>
|
||||
{platformStats.map((stat, index) => {
|
||||
return stat.value ? (
|
||||
<div
|
||||
key={index}
|
||||
className="text-center transform hover:-translate-y-1 hover:scale-105 transition-transform duration-300 ease-out"
|
||||
>
|
||||
className="text-center transform hover:-translate-y-1 hover:scale-105 transition-transform duration-300 ease-out">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-primary-50 text-primary-600 text-3xl transition-colors duration-300 group-hover:text-primary-700">
|
||||
{stat.icon}
|
||||
</div>
|
||||
<div className="text-2xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent mb-1.5">
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="text-gray-600 font-medium">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-gray-600 font-medium">
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
<div className="flex items-center gap-4 justify-between mt-12">
|
||||
<div className="h-[1px] flex-grow bg-gradient-to-r from-transparent via-gray-300 to-transparent"></div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => {
|
||||
navigate(to)
|
||||
window.scrollTo({top: 0,behavior: "smooth"});
|
||||
}}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-blue-600 font-medium transition-colors duration-300">
|
||||
查看更多
|
||||
<ArrowRightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
|
@ -1,124 +1,32 @@
|
|||
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 (
|
||||
<div className="min-h-screen">
|
||||
<HeroSection />
|
||||
{/* <TusUploader></TusUploader> */}
|
||||
|
||||
<CoursesSection
|
||||
title="最受欢迎的思维导图"
|
||||
description="深受追捧的思维导图,点亮你的智慧人生"
|
||||
postType={PostType.PATH}
|
||||
render={(post)=><PathCard post={post}></PathCard>}
|
||||
to={"path"}
|
||||
/>
|
||||
<CoursesSection
|
||||
title="推荐课程"
|
||||
description="最受欢迎的精品课程,助你快速成长"
|
||||
courses={mockCourses}
|
||||
/>
|
||||
<CoursesSection
|
||||
title="热门课程"
|
||||
description="最受欢迎的精品课程,助你快速成长"
|
||||
courses={mockCourses}
|
||||
postType={PostType.COURSE}
|
||||
render={(post)=> <CourseCard post={post}></CourseCard>}
|
||||
to={"/courses"}
|
||||
/>
|
||||
|
||||
<CategorySection />
|
||||
<FeaturedTeachersSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className=" flex">
|
||||
<div className="w-1/6">
|
||||
<FilterSection></FilterSection>
|
||||
</div>
|
||||
<div className="w-5/6 p-4 py-8">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default BasePostLayout;
|
|
@ -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 (
|
||||
<div className=" flex z-0 p-6 flex-col mt-4 space-y-6 overscroll-contain overflow-x-hidden">
|
||||
{showSearchMode && <SearchModeRadio></SearchModeRadio>}
|
||||
{taxonomies?.map((tax, index) => {
|
||||
const items = Object.entries(selectedTerms).find(
|
||||
([key, items]) => key === tax.slug
|
||||
)?.[1];
|
||||
return (
|
||||
<div key={index}>
|
||||
<h3 className="text-lg font-medium mb-4">
|
||||
{tax?.name}
|
||||
{/* {JSON.stringify(items)} */}
|
||||
</h3>
|
||||
<TermParentSelector
|
||||
value={items}
|
||||
// slug={tax?.slug}
|
||||
className="w-70 max-h-[400px] overscroll-contain overflow-x-hidden"
|
||||
onChange={(selected) =>
|
||||
handleTermChange(
|
||||
tax?.slug,
|
||||
selected as string[]
|
||||
)
|
||||
}
|
||||
taxonomyId={tax?.id}></TermParentSelector>
|
||||
<Divider></Divider>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<Space direction="vertical" align="start" className="mb-2">
|
||||
<h3 className="text-lg font-medium mb-4">只搜索</h3>
|
||||
<Radio.Group
|
||||
value={searchMode}
|
||||
onChange={handleModeChange}
|
||||
buttonStyle="solid">
|
||||
<Radio.Button value={PostType.COURSE}>视频课程</Radio.Button>
|
||||
<Radio.Button value={PostType.PATH}>思维导图</Radio.Button>
|
||||
<Radio.Button value="both">所有资源</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
);
|
||||
}
|
|
@ -1,14 +1,20 @@
|
|||
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 (
|
||||
<footer className="bg-gradient-to-b from-slate-800 to-slate-900 text-secondary-200">
|
||||
<footer className="bg-gradient-to-b from-slate-800 to-slate-900 relative z-10 text-secondary-200">
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* 开发组织信息 */}
|
||||
<div className="text-center md:text-left space-y-2">
|
||||
<h3 className="text-white font-semibold text-sm flex items-center justify-center md:justify-start">
|
||||
创新高地 软件小组
|
||||
软件与数据小组
|
||||
</h3>
|
||||
<p className="text-gray-400 text-xs italic">
|
||||
提供技术支持
|
||||
|
@ -19,11 +25,15 @@ export function MainFooter() {
|
|||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<PhoneOutlined className="text-gray-400" />
|
||||
<span className="text-gray-300 text-xs">628118</span>
|
||||
<span className="text-gray-300 text-xs">
|
||||
628118
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<MailOutlined className="text-gray-400" />
|
||||
<span className="text-gray-300 text-xs">gcsjs6@tx3l.nb.kj</span>
|
||||
<span className="text-gray-300 text-xs">
|
||||
gcsjs6@tx3l.nb.kj
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -33,23 +43,20 @@ export function MainFooter() {
|
|||
<a
|
||||
href="https://27.57.72.21"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
title="访问门户网站"
|
||||
>
|
||||
title="访问门户网站">
|
||||
<HomeOutlined className="text-lg" />
|
||||
</a>
|
||||
<a
|
||||
href="https://27.57.72.14"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
title="访问烽火青云"
|
||||
>
|
||||
title="访问烽火青云">
|
||||
<CloudOutlined className="text-lg" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="http://27.57.72.38"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
title="访问烽火律询"
|
||||
>
|
||||
title="访问烽火律询">
|
||||
<FileSearchOutlined className="text-lg" />
|
||||
</a>
|
||||
</div>
|
||||
|
@ -59,7 +66,8 @@ export function MainFooter() {
|
|||
{/* 版权信息 */}
|
||||
<div className="border-t border-gray-700/50 mt-4 pt-4 text-center">
|
||||
<p className="text-gray-400 text-xs">
|
||||
© {new Date().getFullYear()} 南天烽火. All rights reserved.
|
||||
© {new Date().getFullYear()} 南天烽火. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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 (
|
||||
<Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
|
||||
<div className="w-full max-w-screen-2xl px-4 md:px-6 mx-auto flex items-center justify-between h-full">
|
||||
<div className="flex items-center space-x-8">
|
||||
<div className="select-none w-full flex items-center justify-between bg-white shadow-md border-b border-gray-100 fixed z-30 py-2 px-4 md:px-6">
|
||||
{/* 左侧区域 - 设置为不收缩 */}
|
||||
<div className="flex items-center justify-start space-x-4 flex-shrink-0">
|
||||
<img src="/logo.svg" className="h-12 w-12" />
|
||||
<div
|
||||
onClick={() => 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">
|
||||
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">
|
||||
烽火慕课
|
||||
</div>
|
||||
<NavigationMenu />
|
||||
</div>
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="group relative">
|
||||
|
||||
{/* 右侧区域 - 可以灵活收缩 */}
|
||||
<div className="flex justify-end gap-2 md:gap-4 flex-shrink">
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<Input
|
||||
size="large"
|
||||
prefix={
|
||||
<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />
|
||||
}
|
||||
placeholder="搜索课程"
|
||||
className="w-72 rounded-full"
|
||||
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",
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onPressEnter={(e) => {
|
||||
if (
|
||||
!window.location.pathname.startsWith("/search")
|
||||
) {
|
||||
navigate(`/search`);
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => navigate("/course/editor")}
|
||||
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
||||
icon={<EditFilled />}>
|
||||
创建课程
|
||||
size="large"
|
||||
shape="round"
|
||||
icon={<PlusOutlined></PlusOutlined>}
|
||||
onClick={() => {
|
||||
const url = id
|
||||
? `/course/${id}/editor`
|
||||
: "/course/editor";
|
||||
navigate(url);
|
||||
}}
|
||||
type="primary">
|
||||
{id ? "编辑课程" : "创建课程"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isAuthenticated ? (
|
||||
<Dropdown
|
||||
overlay={<UserMenu />}
|
||||
trigger={["click"]}
|
||||
placement="bottomRight">
|
||||
<Avatar
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
size="large"
|
||||
className="cursor-pointer hover:scale-105 transition-all bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold">
|
||||
{(user?.showname ||
|
||||
user?.username ||
|
||||
"")[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
</Dropdown>
|
||||
shape="round"
|
||||
onClick={() => {
|
||||
window.location.href = "/path/editor";
|
||||
}}
|
||||
ghost
|
||||
type="primary"
|
||||
icon={<PlusOutlined></PlusOutlined>}>
|
||||
创建思维导图
|
||||
</Button>
|
||||
)}
|
||||
{isAuthenticated ? (
|
||||
<UserMenu />
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
shape="round"
|
||||
onClick={() => navigate("/login")}
|
||||
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
||||
icon={<UserOutlined />}>
|
||||
登录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<Layout className="min-h-screen">
|
||||
|
||||
<MainProvider>
|
||||
<div className=" min-h-screen bg-gray-100">
|
||||
<MainHeader />
|
||||
<Content className="mt-16 bg-gray-50">
|
||||
<Content className=" flex-grow pt-16 bg-gray-50 ">
|
||||
<Outlet />
|
||||
</Content>
|
||||
<MainFooter />
|
||||
</Layout>
|
||||
</div>
|
||||
</MainProvider>
|
||||
);
|
||||
}
|
|
@ -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<React.SetStateAction<string>>;
|
||||
setSelectedTerms?: React.Dispatch<React.SetStateAction<SelectedTerms>>;
|
||||
searchCondition?: Prisma.PostWhereInput;
|
||||
termsCondition?: Prisma.PostWhereInput;
|
||||
searchMode?: PostType.COURSE | PostType.PATH | "both";
|
||||
setSearchMode?: React.Dispatch<
|
||||
React.SetStateAction<PostType.COURSE | PostType.PATH | "both">
|
||||
>;
|
||||
showSearchMode?: boolean;
|
||||
setShowSearchMode?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const MainContext = createContext<MainContextType | null>(null);
|
||||
interface MainProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function MainProvider({ children }: MainProviderProps) {
|
||||
const [searchMode, setSearchMode] = useState<
|
||||
PostType.COURSE | PostType.PATH | "both"
|
||||
>("both");
|
||||
const [showSearchMode, setShowSearchMode] = useState<boolean>(false);
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const [debouncedValue] = useDebounce<string>(searchValue, 500);
|
||||
const [selectedTerms, setSelectedTerms] = useState<SelectedTerms>({}); // 初始化状态
|
||||
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 (
|
||||
<MainContext.Provider
|
||||
value={{
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
selectedTerms,
|
||||
setSelectedTerms,
|
||||
searchCondition,
|
||||
termsCondition,
|
||||
searchMode,
|
||||
setSearchMode,
|
||||
showSearchMode,
|
||||
setShowSearchMode,
|
||||
}}>
|
||||
{children}
|
||||
</MainContext.Provider>
|
||||
);
|
||||
}
|
||||
export const useMainContext = () => {
|
||||
const context = useContext(MainContext);
|
||||
if (!context) {
|
||||
throw new Error("useMainContext must be used within MainProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
|
@ -1,28 +1,56 @@
|
|||
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 { isAuthenticated } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
const selectedKey = menuItems.find(item => item.path === pathname)?.key || '';
|
||||
|
||||
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 (
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
className="border-none font-medium"
|
||||
disabledOverflow={true}
|
||||
selectedKeys={[selectedKey]}
|
||||
onClick={({ key }) => {
|
||||
const selectedItem = menuItems.find(item => item.key === key);
|
||||
const selectedItem = menuItems.find((item) => item.key === key);
|
||||
if (selectedItem) navigate(selectedItem.path);
|
||||
}}
|
||||
>
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}>
|
||||
{menuItems.map(({ key, label }) => (
|
||||
<Menu.Item key={key} className="text-gray-600 hover:text-blue-600">
|
||||
<Menu.Item
|
||||
key={key}
|
||||
className="text-gray-600 hover:text-blue-600">
|
||||
{label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
|
|
|
@ -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 (
|
||||
<Menu className="w-48">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Menu.Item key="profile" className="px-4 py-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar className="bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold">
|
||||
{(user?.showname ||
|
||||
user?.username ||
|
||||
"")[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{user?.department?.name || user?.officerId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
key="user-settings"
|
||||
icon={<UserSwitchOutlined />}
|
||||
className="px-4">
|
||||
个人设置
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="settings"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => {
|
||||
navigate("/admin/staff");
|
||||
}}
|
||||
className="px-4">
|
||||
后台管理
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="logout"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={async () => await logout()}
|
||||
className="px-4 text-red-500 hover:text-red-600 hover:bg-red-50">
|
||||
退出登录
|
||||
</Menu.Item>
|
||||
</>
|
||||
) : (
|
||||
<Menu.Item
|
||||
key="login"
|
||||
onClick={() => navigate("/login")}
|
||||
className="px-4 text-blue-500 hover:text-blue-600 hover:bg-blue-50">
|
||||
登录/注册
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
|
@ -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 (
|
||||
<Modal
|
||||
width={400}
|
||||
onOk={handleOk}
|
||||
centered
|
||||
open={modalOpen}
|
||||
confirmLoading={formLoading}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
}}
|
||||
title={"编辑个人信息"}>
|
||||
<UserForm />
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<div className="relative">
|
||||
{isLoading && (
|
||||
<div className="absolute h-full inset-0 flex items-center justify-center bg-white bg-opacity-50 z-10">
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
<Form
|
||||
disabled={isLoading}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark="optional"
|
||||
autoComplete="off"
|
||||
onFinish={handleFinish}>
|
||||
<div className=" flex items-center gap-4 mb-2">
|
||||
<div>
|
||||
<Form.Item name={"avatar"} label="头像" noStyle>
|
||||
<AvatarUploader
|
||||
placeholder="点击上传头像"
|
||||
className="rounded-lg"
|
||||
style={{
|
||||
width: "120px",
|
||||
height: "150px",
|
||||
}}></AvatarUploader>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 flex-1">
|
||||
<Form.Item
|
||||
noStyle
|
||||
rules={[{ required: true }]}
|
||||
name={"showname"}
|
||||
label="名称">
|
||||
<Input
|
||||
placeholder="请输入姓名"
|
||||
allowClear
|
||||
autoComplete="new-name" // 使用非标准的自动完成值
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={"domainId"}
|
||||
label="所属域"
|
||||
noStyle
|
||||
rules={[{ required: true }]}>
|
||||
<DepartmentSelect
|
||||
placeholder="选择域"
|
||||
onChange={(value) => {
|
||||
setDomainId(value as string);
|
||||
}}
|
||||
domain={true}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
name={"deptId"}
|
||||
label="所属单位"
|
||||
rules={[{ required: true }]}>
|
||||
<DepartmentSelect rootId={domainId} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle label="密码" name={"password"}>
|
||||
<Input.Password
|
||||
placeholder="修改密码"
|
||||
spellCheck={false}
|
||||
visibilityToggle
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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<React.SetStateAction<string>>;
|
||||
modalOpen: boolean;
|
||||
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
form: FormInstance<any>;
|
||||
formLoading: boolean;
|
||||
setFormLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}>({
|
||||
modalOpen: false,
|
||||
domainId: undefined,
|
||||
setDomainId: undefined,
|
||||
setModalOpen: undefined,
|
||||
form: undefined,
|
||||
formLoading: undefined,
|
||||
setFormLoading: undefined,
|
||||
});
|
||||
|
||||
export function UserMenu() {
|
||||
const [form] = useForm();
|
||||
const [formLoading, setFormLoading] = useState<boolean>();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { user, logout, isLoading, hasSomePermissions } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||
const [domainId, setDomainId] = useState<string>();
|
||||
const toggleMenu = useCallback(() => {
|
||||
setShowMenu((prev) => !prev);
|
||||
}, []);
|
||||
const canManageAnyStaff = useMemo(() => {
|
||||
return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF);
|
||||
}, [user]);
|
||||
const menuItems: MenuItemType[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
icon: <UserOutlined className="text-lg" />,
|
||||
label: "个人信息",
|
||||
action: () => {
|
||||
setModalOpen(true);
|
||||
},
|
||||
},
|
||||
|
||||
canManageAnyStaff && {
|
||||
icon: <SettingOutlined className="text-lg" />,
|
||||
label: "设置",
|
||||
action: () => {
|
||||
navigate("/admin/staff");
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
icon: <LogoutOutlined className="text-lg" />,
|
||||
label: "注销",
|
||||
action: () => logout(),
|
||||
},
|
||||
].filter(Boolean),
|
||||
[logout]
|
||||
);
|
||||
|
||||
const handleMenuItemClick = useCallback((action: () => void) => {
|
||||
action();
|
||||
setShowMenu(false);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-10 h-10">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UserEditorContext.Provider
|
||||
value={{
|
||||
formLoading,
|
||||
setFormLoading,
|
||||
form,
|
||||
domainId,
|
||||
modalOpen,
|
||||
setDomainId,
|
||||
setModalOpen,
|
||||
}}>
|
||||
<div ref={menuRef} className="relative">
|
||||
<motion.button
|
||||
aria-label="用户菜单"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={showMenu}
|
||||
aria-controls="user-menu"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={toggleMenu}
|
||||
className="flex items-center rounded-full transition-all duration-200 ease-in-out">
|
||||
{/* Avatar 容器,相对定位 */}
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-white hover:ring-[#00538E]/90
|
||||
transition-all duration-200 ease-in-out shadow-md
|
||||
hover:shadow-lg focus:outline-none
|
||||
focus:ring-2 focus:ring-[#00538E]/80 focus:ring-offset-2
|
||||
focus:ring-offset-white "
|
||||
/>
|
||||
{/* 小绿点 */}
|
||||
<span
|
||||
className="absolute bottom-0 right-0 h-3 w-3
|
||||
rounded-full bg-emerald-500 ring-2 ring-white
|
||||
shadow-sm transition-transform duration-200
|
||||
ease-in-out hover:scale-110"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showMenu && (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
variants={menuVariants}
|
||||
role="menu"
|
||||
id="user-menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="user-menu-button"
|
||||
style={{ zIndex: 100 }}
|
||||
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 */}
|
||||
<div
|
||||
className="px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white
|
||||
border-b border-[#E5EDF5] ">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-white shadow-sm"
|
||||
/>
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<span className="text-sm font-semibold text-[#00538E]">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
<span className="text-xs text-[#718096] flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
在线
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="p-2">
|
||||
{menuItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
role="menuitem"
|
||||
tabIndex={showMenu ? 0 : -1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMenuItemClick(item.action);
|
||||
}}
|
||||
className={`flex items-center gap-3 w-full px-4 py-3
|
||||
text-sm font-medium rounded-lg transition-all
|
||||
focus:outline-none
|
||||
focus:ring-2 focus:ring-[#00538E]/20
|
||||
group relative overflow-hidden
|
||||
active:scale-[0.99]
|
||||
${
|
||||
item.label === "注销"
|
||||
? "text-[#B22234] hover:bg-red-50/80 hover:text-red-700"
|
||||
: "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]"
|
||||
}`}>
|
||||
<span
|
||||
className={`w-5 h-5 flex items-center justify-center
|
||||
transition-all duration-200 ease-in-out
|
||||
group-hover:scale-110 group-hover:rotate-6
|
||||
group-hover:translate-x-0.5 ${
|
||||
item.label === "注销"
|
||||
? "group-hover:text-red-600"
|
||||
: "group-hover:text-[#003F6A]"
|
||||
}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<UserEditModal></UserEditModal>
|
||||
</UserEditorContext.Provider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import React, { ReactNode } from "react";
|
||||
export interface MenuItemType {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
action: () => void;
|
||||
}
|
|
@ -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 (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => <PathCard post={post}></PathCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
type: PostType.PATH,
|
||||
students: {
|
||||
some: {
|
||||
id: user?.id,
|
||||
},
|
||||
},
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<BasePostLayout>
|
||||
<MyDutyPathContainer></MyDutyPathContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => <CourseCard post={post}></CourseCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
type: PostType.COURSE,
|
||||
authorId: user.id,
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<BasePostLayout>
|
||||
<MyDutyListContainer></MyDutyListContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => <CourseCard post={post}></CourseCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
type: PostType.COURSE,
|
||||
students: {
|
||||
some: {
|
||||
id: user?.id,
|
||||
},
|
||||
},
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<BasePostLayout>
|
||||
<MyLearningListContainer></MyLearningListContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => <PathCard post={post}></PathCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
type: PostType.PATH,
|
||||
authorId: user.id,
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<BasePostLayout>
|
||||
<MyPathListContainer></MyPathListContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<div className="gap-1 flex items-center justify-between flex-grow">
|
||||
<div className=" flex justify-start gap-1 items-center">
|
||||
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
|
||||
{post?.depts && post?.depts?.length > 0 ? (
|
||||
<Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]">
|
||||
{post?.depts?.length > 1
|
||||
? `${post.depts[0].name}等`
|
||||
: post?.depts?.[0]?.name}
|
||||
</Text>
|
||||
) : (
|
||||
<Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]">
|
||||
未设置单位
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{post && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
|
||||
浏览量
|
||||
<EyeOutlined />
|
||||
{`${post?.views || 0}`}
|
||||
</span>
|
||||
{post?.studentIds && post?.studentIds?.length > 0 && (
|
||||
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
|
||||
<BookOutlined />
|
||||
{`${post?.studentIds?.length || 0}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeptInfo;
|
|
@ -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 (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => <PathCard post={post}></PathCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
type: PostType.PATH,
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default PathListContainer;
|
|
@ -0,0 +1,41 @@
|
|||
import { Tag } from "antd";
|
||||
import { PostDto, TaxonomySlug, TermDto } from "@nice/common";
|
||||
|
||||
const TermInfo = ({ terms = [] }: { terms?: TermDto[] }) => {
|
||||
return (
|
||||
<div>
|
||||
{terms && terms?.length > 0 ? (
|
||||
<div className="flex gap-2 mb-4">
|
||||
{terms?.map((term: any) => {
|
||||
return (
|
||||
<Tag
|
||||
key={term.id}
|
||||
color={
|
||||
term?.taxonomy?.slug ===
|
||||
TaxonomySlug.CATEGORY
|
||||
? "green"
|
||||
: term?.taxonomy?.slug ===
|
||||
TaxonomySlug.LEVEL
|
||||
? "blue"
|
||||
: "orange"
|
||||
}
|
||||
className="px-3 py-1 rounded-full border-0">
|
||||
{term.name}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Tag
|
||||
color={"orange"}
|
||||
className="px-3 py-1 rounded-full border-0">
|
||||
{"未设置分类"}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TermInfo;
|
|
@ -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 <div className="">
|
||||
<MindEditor id={id}></MindEditor>
|
||||
</div>
|
||||
}
|
|
@ -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 (
|
||||
<BasePostLayout>
|
||||
<PathListContainer></PathListContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import MindEditor from "@web/src/components/common/editor/MindEditor";
|
||||
|
||||
export default function PathsPage() {
|
||||
return <MindEditor></MindEditor>;
|
||||
}
|
|
@ -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 (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => {
|
||||
const Component =
|
||||
POST_TYPE_COMPONENTS[post.type] || PostCard;
|
||||
return <Component post={post} />;
|
||||
}}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
type: searchMode === "both" ? undefined : searchMode,
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<BasePostLayout>
|
||||
<SearchListContainer></SearchListContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -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<CollapsibleContentProps> = ({
|
||||
content,
|
||||
maxHeight = 150,
|
||||
}) => {
|
||||
const contentWrapperRef = useRef<HTMLDivElement>(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<CollapsibleContentProps> = ({ content }) => {
|
||||
const contentWrapperRef = useRef(null);
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={contentWrapperRef}
|
||||
className={`duration-300 ${
|
||||
shouldCollapse && !isExpanded
|
||||
? `max-h-[${maxHeight}px] overflow-hidden relative`
|
||||
: ""
|
||||
}`}>
|
||||
<div className=" text-base ">
|
||||
<div className=" flex flex-col gap-4 transition-all duration-300 ease-in-out rounded-xl p-6 ">
|
||||
{/* 包装整个内容区域的容器 */}
|
||||
<div ref={contentWrapperRef}>
|
||||
{/* 内容区域 */}
|
||||
<div
|
||||
className="ql-editor p-0 space-y-1 leading-relaxed"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: content || "",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient overlay */}
|
||||
{shouldCollapse && !isExpanded && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-white to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expand/Collapse button */}
|
||||
{shouldCollapse && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="mt-2 text-blue-500 hover:text-blue-700">
|
||||
{isExpanded ? "收起" : "展开"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<MindElixirInstance>();
|
||||
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
|
||||
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<HTMLDivElement>(null);
|
||||
const [instance, setInstance] = useState<MindElixirInstance | null>(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,
|
||||
});
|
||||
// instance.install(NodeMenu);
|
||||
instance.init(MindElixir.new("新主题"));
|
||||
me.current = instance;
|
||||
const { handleFileUpload } = useTusUpload();
|
||||
const [form] = Form.useForm();
|
||||
useEffect(() => {
|
||||
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, // 禁用键盘快捷键
|
||||
});
|
||||
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 (
|
||||
<div>
|
||||
<div id="map" style={{ width: "100%" }} />
|
||||
<div className={` flex-col flex `}>
|
||||
{canEdit && taxonomies && (
|
||||
<Form form={form} className=" bg-white p-4 border-b">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{taxonomies.map((tax, index) => (
|
||||
<Form.Item
|
||||
key={tax.id}
|
||||
name={tax.id}
|
||||
// rules={[{ required: true }]}
|
||||
noStyle>
|
||||
<TermSelect
|
||||
disabled={!canEdit}
|
||||
className=" w-48"
|
||||
placeholder={`请选择${tax.name}`}
|
||||
taxonomyId={tax.id}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item
|
||||
// rules={[{ required: true }]}
|
||||
name="deptIds"
|
||||
noStyle>
|
||||
<DepartmentSelect
|
||||
disabled={!canEdit}
|
||||
className="w-96"
|
||||
placeholder="请选择制作单位"
|
||||
multiple
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button
|
||||
ghost
|
||||
type="primary"
|
||||
icon={<SaveOutlined></SaveOutlined>}
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
onClick={handleSave}>
|
||||
{id ? "更新" : "保存"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
{canEdit && instance && <NodeMenu mind={instance} />}
|
||||
{isLoading && (
|
||||
<div
|
||||
className="py-64 justify-center flex"
|
||||
style={{ height: "calc(100vh - 271px)" }}>
|
||||
<Spin size="large"></Spin>
|
||||
</div>
|
||||
)}
|
||||
{!post && id && !isLoading && (
|
||||
<div
|
||||
className="py-64"
|
||||
style={{ height: "calc(100vh - 271px)" }}>
|
||||
<Empty></Empty>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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<NodeMenuProps> = ({ mind }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedFontColor, setSelectedFontColor] = useState<string>('');
|
||||
const [selectedBgColor, setSelectedBgColor] = useState<string>('');
|
||||
const [selectedSize, setSelectedSize] = useState<string>('');
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const [url, setUrl] = useState<string>('');
|
||||
const containerRef = useRef<HTMLDivElement | null>(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<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setUrl(value);
|
||||
mind.reshapeNode(mind.currentNode, { hyperLink: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`node-menu-container absolute right-2 top-2 rounded-lg bg-slate-200 shadow-xl ring-2 ring-white transition-all duration-300 ${isOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4 pointer-events-none'
|
||||
}`}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="p-5 space-y-6">
|
||||
{/* Font Size Selector */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">文字样式</h3>
|
||||
<div className="flex gap-3 items-center justify-between">
|
||||
<Select
|
||||
value={selectedSize}
|
||||
onChange={handleSizeChange}
|
||||
prefix={<FontSizeOutlined className='mr-2' />}
|
||||
className="w-1/2"
|
||||
options={[
|
||||
{ value: '12', label: '12' },
|
||||
{ value: '14', label: '14' },
|
||||
{ value: '16', label: '16' },
|
||||
{ value: '18', label: '18' },
|
||||
{ value: '20', label: '20' },
|
||||
{ value: '24', label: '24' },
|
||||
{ value: '28', label: '28' },
|
||||
{ value: '32', label: '32' }
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type={isBold ? "primary" : "default"}
|
||||
onClick={handleBoldToggle}
|
||||
className='w-1/2'
|
||||
icon={<BoldOutlined />}
|
||||
>
|
||||
|
||||
加粗
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Picker */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-600">颜色设置</h3>
|
||||
|
||||
{/* Font Color Picker */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-500">文字颜色</h4>
|
||||
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
||||
{xmindColorPresets.map((color) => (
|
||||
<div
|
||||
key={`font-${color}`}
|
||||
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${selectedFontColor === color ? 'outline-blue-500' : 'outline-transparent'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => {
|
||||
|
||||
handleColorChange('font', color);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Color Picker */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-500">背景颜色</h4>
|
||||
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
||||
{xmindColorPresets.map((color) => (
|
||||
<div
|
||||
key={`bg-${color}`}
|
||||
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${selectedBgColor === color ? 'outline-blue-500' : 'outline-transparent'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => {
|
||||
handleColorChange('background', color);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-gray-600">关联链接</h3>
|
||||
{/* URL Input */}
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
placeholder="例如:https://example.com"
|
||||
value={url}
|
||||
onChange={handleUrlChange}
|
||||
addonBefore={<LinkOutlined />}
|
||||
/>
|
||||
{url && !/^https?:\/\/\S+$/.test(url) && (
|
||||
<p className="text-xs text-red-500">请输入有效的URL地址</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<>wojiao</>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeMenu;
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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']
|
||||
]
|
||||
[{ header: [1, 2, 3, 4, 5, 6, false] }],
|
||||
["bold", "italic", "underline", "strike"],
|
||||
[{ list: "ordered" }, { list: "bullet" }],
|
||||
[{ color: [] }, { background: [] }],
|
||||
[{ align: [] }],
|
||||
["link"],
|
||||
["clean"],
|
||||
],
|
||||
};
|
|
@ -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<void>;
|
||||
taxonomyType?: ObjectType;
|
||||
}
|
||||
|
||||
export interface MindEditorState {
|
||||
instance: MindElixirInstance | null;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
}
|
|
@ -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<InputListProps> = ({
|
||||
initialValue,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "请输入内容",
|
||||
}) => {
|
||||
// Internal state management with fallback to initial value or empty array
|
||||
const [inputValues, setInputValues] = useState<string[]>(
|
||||
initialValue && initialValue.length > 0 ? initialValue : [""]
|
||||
);
|
||||
const [inputValues, setInputValues] = useState<string[]>([""]);
|
||||
|
||||
// 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) => {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue