add 202501221855

This commit is contained in:
ditiqi 2025-01-22 18:56:27 +08:00
parent ad5fb5038d
commit 0da8a60bd6
44 changed files with 438 additions and 1385 deletions

View File

@ -37,10 +37,8 @@ export class BaseService<
constructor( constructor(
protected prisma: PrismaClient, protected prisma: PrismaClient,
protected objectType: string, protected objectType: string,
protected enableOrder: boolean = false protected enableOrder: boolean = false,
) { ) {}
}
/** /**
* Retrieves the name of the model dynamically. * Retrieves the name of the model dynamically.
@ -51,7 +49,7 @@ export class BaseService<
return modelName; return modelName;
} }
private getModel(tx?: TransactionType): D { private getModel(tx?: TransactionType): D {
return tx?.[this.objectType] || this.prisma[this.objectType] as D; return tx?.[this.objectType] || (this.prisma[this.objectType] as D);
} }
/** /**
* Error handling helper function * Error handling helper function
@ -85,7 +83,9 @@ export class BaseService<
*/ */
async findUnique(args: A['findUnique']): Promise<R['findUnique']> { async findUnique(args: A['findUnique']): Promise<R['findUnique']> {
try { try {
return this.getModel().findUnique(args as any) as Promise<R['findUnique']>; return this.getModel().findUnique(args as any) as Promise<
R['findUnique']
>;
} catch (error) { } catch (error) {
this.handleError(error, 'read'); this.handleError(error, 'read');
} }
@ -152,26 +152,27 @@ export class BaseService<
* const newUser = await service.create({ data: { name: 'John Doe' } }); * const newUser = await service.create({ data: { name: 'John Doe' } });
*/ */
async create(args: A['create'], params?: any): Promise<R['create']> { async create(args: A['create'], params?: any): Promise<R['create']> {
try { try {
if (this.enableOrder && !(args as any).data.order) { if (this.enableOrder && !(args as any).data.order) {
// 查找当前最大的 order 值 // 查找当前最大的 order 值
const maxOrderItem = await this.getModel(params?.tx).findFirst({ const maxOrderItem = (await this.getModel(params?.tx).findFirst({
orderBy: { order: 'desc' } orderBy: { order: 'desc' },
}) as any; })) as any;
// 设置新记录的 order 值 // 设置新记录的 order 值
const newOrder = maxOrderItem ? maxOrderItem.order + this.ORDER_INTERVAL : 1; const newOrder = maxOrderItem
? maxOrderItem.order + this.ORDER_INTERVAL
: 1;
// 将 order 添加到创建参数中 // 将 order 添加到创建参数中
(args as any).data.order = newOrder; (args as any).data.order = newOrder;
} }
return this.getModel(params?.tx).create(args as any) as Promise<R['create']>; return this.getModel(params?.tx).create(args as any) as Promise<
R['create']
>;
} catch (error) { } catch (error) {
this.handleError(error, 'create'); this.handleError(error, 'create');
} }
} }
/** /**
* Creates multiple new records with the given data. * Creates multiple new records with the given data.
* @param args - Arguments to create multiple records. * @param args - Arguments to create multiple records.
@ -179,9 +180,14 @@ export class BaseService<
* @example * @example
* const newUsers = await service.createMany({ data: [{ name: 'John' }, { name: 'Jane' }] }); * const newUsers = await service.createMany({ data: [{ name: 'John' }, { name: 'Jane' }] });
*/ */
async createMany(args: A['createMany'], params?: any): Promise<R['createMany']> { async createMany(
args: A['createMany'],
params?: any,
): Promise<R['createMany']> {
try { try {
return this.getModel(params?.tx).createMany(args as any) as Promise<R['createMany']>; return this.getModel(params?.tx).createMany(args as any) as Promise<
R['createMany']
>;
} catch (error) { } catch (error) {
this.handleError(error, 'create'); this.handleError(error, 'create');
} }
@ -196,8 +202,9 @@ export class BaseService<
*/ */
async update(args: A['update'], params?: any): Promise<R['update']> { async update(args: A['update'], params?: any): Promise<R['update']> {
try { try {
return this.getModel(params?.tx).update(args as any) as Promise<
return this.getModel(params?.tx).update(args as any) as Promise<R['update']>; R['update']
>;
} catch (error) { } catch (error) {
this.handleError(error, 'update'); this.handleError(error, 'update');
} }
@ -251,7 +258,9 @@ export class BaseService<
*/ */
async delete(args: A['delete'], params?: any): Promise<R['delete']> { async delete(args: A['delete'], params?: any): Promise<R['delete']> {
try { try {
return this.getModel(params?.tx).delete(args as any) as Promise<R['delete']>; return this.getModel(params?.tx).delete(args as any) as Promise<
R['delete']
>;
} catch (error) { } catch (error) {
this.handleError(error, 'delete'); this.handleError(error, 'delete');
} }
@ -309,10 +318,14 @@ export class BaseService<
* @example * @example
* const deleteResult = await service.deleteMany({ where: { isActive: false } }); * const deleteResult = await service.deleteMany({ where: { isActive: false } });
*/ */
async deleteMany(args: A['deleteMany'], params?: any): Promise<R['deleteMany']> { async deleteMany(
args: A['deleteMany'],
params?: any,
): Promise<R['deleteMany']> {
try { try {
return this.getModel(params?.tx).deleteMany(args as any) as Promise<
return this.getModel(params?.tx).deleteMany(args as any) as Promise<R['deleteMany']>; R['deleteMany']
>;
} catch (error) { } catch (error) {
this.handleError(error, 'delete'); this.handleError(error, 'delete');
} }
@ -327,7 +340,9 @@ export class BaseService<
*/ */
async updateMany(args: A['updateMany']): Promise<R['updateMany']> { async updateMany(args: A['updateMany']): Promise<R['updateMany']> {
try { try {
return this.getModel().updateMany(args as any) as Promise<R['updateMany']>; return this.getModel().updateMany(args as any) as Promise<
R['updateMany']
>;
} catch (error) { } catch (error) {
this.handleError(error, 'update'); this.handleError(error, 'update');
} }
@ -420,8 +435,7 @@ export class BaseService<
data: { ...data, deletedAt: null } as any, data: { ...data, deletedAt: null } as any,
}) as Promise<R['update'][]>; }) as Promise<R['update'][]>;
} catch (error) { } catch (error) {
this.handleError(error, "update"); this.handleError(error, 'update');
} }
} }
@ -436,25 +450,25 @@ export class BaseService<
page?: number; page?: number;
pageSize?: number; pageSize?: number;
where?: WhereArgs<A['findMany']>; where?: WhereArgs<A['findMany']>;
select?: SelectArgs<A['findMany']> select?: SelectArgs<A['findMany']>;
}): Promise<{ items: R['findMany']; totalPages: number }> { }): Promise<{ items: R['findMany']; totalPages: number }> {
const { page = 1, pageSize = 10, where, select } = args; const { page = 1, pageSize = 10, where, select } = args;
try { try {
// 获取总记录数 // 获取总记录数
const total = await this.getModel().count({ where }) as number; const total = (await this.getModel().count({ where })) as number;
// 获取分页数据 // 获取分页数据
const items = await this.getModel().findMany({ const items = (await this.getModel().findMany({
where, where,
select, select,
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
} as any) as R['findMany']; } as any)) as R['findMany'];
// 计算总页数 // 计算总页数
const totalPages = Math.ceil(total / pageSize); const totalPages = Math.ceil(total / pageSize);
return { return {
items, items,
totalPages totalPages,
}; };
} catch (error) { } catch (error) {
this.handleError(error, 'read'); this.handleError(error, 'read');
@ -483,7 +497,6 @@ export class BaseService<
: undefined, : undefined,
} as any)) as any[]; } as any)) as any[];
/** /**
* *
* @description * @description
@ -530,7 +543,7 @@ export class BaseService<
order: { gt: targetObject.order }, order: { gt: targetObject.order },
deletedAt: null, deletedAt: null,
}, },
orderBy: { order: 'asc' } orderBy: { order: 'asc' },
} as any)) as any; } as any)) as any;
const newOrder = nextObject const newOrder = nextObject

View File

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { CourseRouter } from './course.router';
import { CourseService } from './course.service';
import { TrpcService } from '@server/trpc/trpc.service';
@Module({
providers: [CourseRouter, CourseService, TrpcService],
exports: [CourseRouter, CourseService]
})
export class CourseModule { }

View File

@ -1,86 +0,0 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nice/common';
import { CourseService } from './course.service';
import { z, ZodType } from 'zod';
const CourseCreateArgsSchema: ZodType<Prisma.CourseCreateArgs> = z.any()
const CourseUpdateArgsSchema: ZodType<Prisma.CourseUpdateArgs> = z.any()
const CourseCreateManyInputSchema: ZodType<Prisma.CourseCreateManyInput> = z.any()
const CourseDeleteManyArgsSchema: ZodType<Prisma.CourseDeleteManyArgs> = z.any()
const CourseFindManyArgsSchema: ZodType<Prisma.CourseFindManyArgs> = z.any()
const CourseFindFirstArgsSchema: ZodType<Prisma.CourseFindFirstArgs> = z.any()
const CourseWhereInputSchema: ZodType<Prisma.CourseWhereInput> = z.any()
const CourseSelectSchema: ZodType<Prisma.CourseSelect> = z.any()
@Injectable()
export class CourseRouter {
constructor(
private readonly trpc: TrpcService,
private readonly courseService: CourseService,
) { }
router = this.trpc.router({
create: this.trpc.protectProcedure
.input(CourseCreateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.courseService.create(input, { staff });
}),
update: this.trpc.protectProcedure
.input(CourseUpdateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.courseService.update(input, { staff });
}),
createMany: this.trpc.protectProcedure.input(z.array(CourseCreateManyInputSchema))
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.courseService.createMany({ data: input }, staff);
}),
deleteMany: this.trpc.procedure
.input(CourseDeleteManyArgsSchema)
.mutation(async ({ input }) => {
return await this.courseService.deleteMany(input);
}),
findFirst: this.trpc.procedure
.input(CourseFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.courseService.findFirst(input);
}),
softDeleteByIds: this.trpc.protectProcedure
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
.mutation(async ({ input }) => {
return this.courseService.softDeleteByIds(input.ids);
}),
updateOrder: this.trpc.protectProcedure
.input(UpdateOrderSchema)
.mutation(async ({ input }) => {
return this.courseService.updateOrder(input);
}),
findMany: this.trpc.procedure
.input(CourseFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.courseService.findMany(input);
}),
findManyWithCursor: this.trpc.protectProcedure
.input(z.object({
cursor: z.any().nullish(),
take: z.number().optional(),
where: CourseWhereInputSchema.optional(),
select: CourseSelectSchema.optional()
}))
.query(async ({ ctx, input }) => {
return await this.courseService.findManyWithCursor(input);
}),
findManyWithPagination: this.trpc.procedure
.input(z.object({
page: z.number().optional(),
pageSize: z.number().optional(),
where: CourseWhereInputSchema.optional(),
select: CourseSelectSchema.optional()
})) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.courseService.findManyWithPagination(input);
}),
});
}

View File

@ -1,78 +0,0 @@
import { Injectable } from '@nestjs/common';
import { BaseService } from '../base/base.service';
import {
UserProfile,
db,
ObjectType,
Prisma,
InstructorRole,
} from '@nice/common';
@Injectable()
export class CourseService extends BaseService<Prisma.CourseDelegate> {
constructor() {
super(db, ObjectType.COURSE);
}
async create(
args: Prisma.CourseCreateArgs,
params?: { staff?: UserProfile }
) {
return await db.$transaction(async tx => {
const result = await super.create(args, { tx });
if (params?.staff?.id) {
await tx.courseInstructor.create({
data: {
instructorId: params.staff.id,
courseId: result.id,
role: InstructorRole.MAIN,
}
});
}
return result;
}, {
timeout: 10000 // 10 seconds
});
}
async update(
args: Prisma.CourseUpdateArgs,
params?: { staff?: UserProfile }
) {
return await db.$transaction(async tx => {
const result = await super.update(args, { tx });
return result;
}, {
timeout: 10000 // 10 seconds
});
}
async removeInstructor(courseId: string, instructorId: string) {
return await db.courseInstructor.delete({
where: {
courseId_instructorId: {
courseId,
instructorId,
},
},
});
}
async addInstructor(params: {
courseId: string;
instructorId: string;
role?: string;
order?: number;
}) {
return await db.courseInstructor.create({
data: {
courseId: params.courseId,
instructorId: params.instructorId,
role: params.role || InstructorRole.ASSISTANT,
order: params.order,
},
});
}
async getInstructors(courseId: string) {
return await db.courseInstructor.findMany({
where: { courseId },
include: { instructor: true },
orderBy: { order: 'asc' },
});
}
}

View File

@ -1,46 +0,0 @@
import { db, EnrollmentStatus, PostType } from "@nice/common";
// 更新课程评价统计
export async function updateCourseReviewStats(courseId: string) {
const reviews = await db.post.findMany({
where: {
courseId,
type: PostType.COURSE_REVIEW,
deletedAt: null
},
select: { rating: true }
});
const numberOfReviews = reviews.length;
const averageRating = numberOfReviews > 0
? reviews.reduce((sum, review) => sum + review.rating, 0) / numberOfReviews
: 0;
return db.course.update({
where: { id: courseId },
data: { numberOfReviews, averageRating }
});
}
// 更新课程注册统计
export async function updateCourseEnrollmentStats(courseId: string) {
const completedEnrollments = await db.enrollment.count({
where: {
courseId,
status: EnrollmentStatus.COMPLETED
}
});
const totalEnrollments = await db.enrollment.count({
where: { courseId }
});
const completionRate = totalEnrollments > 0
? (completedEnrollments / totalEnrollments) * 100
: 0;
return db.course.update({
where: { id: courseId },
data: {
numberOfStudents: totalEnrollments,
completionRate
}
});
}

View File

@ -1,11 +0,0 @@
import { z } from "zod";
export const EnrollSchema = z.object({
studentId: z.string(),
courseId: z.string(),
});
export const UnenrollSchema = z.object({
studentId: z.string(),
courseId: z.string(),
});

View File

@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { EnrollmentRouter } from './enrollment.router';
import { EnrollmentService } from './enrollment.service';
@Module({
exports: [EnrollmentRouter, EnrollmentService],
providers: [EnrollmentRouter, EnrollmentService]
})
export class EnrollmentModule { }

View File

@ -1,54 +0,0 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nice/common';
import { EnrollmentService } from './enrollment.service';
import { z, ZodType } from 'zod';
import { EnrollSchema, UnenrollSchema } from './enroll.schema';
const EnrollmentCreateArgsSchema: ZodType<Prisma.EnrollmentCreateArgs> = z.any()
const EnrollmentCreateManyInputSchema: ZodType<Prisma.EnrollmentCreateManyInput> = z.any()
const EnrollmentDeleteManyArgsSchema: ZodType<Prisma.EnrollmentDeleteManyArgs> = z.any()
const EnrollmentFindManyArgsSchema: ZodType<Prisma.EnrollmentFindManyArgs> = z.any()
const EnrollmentFindFirstArgsSchema: ZodType<Prisma.EnrollmentFindFirstArgs> = z.any()
const EnrollmentWhereInputSchema: ZodType<Prisma.EnrollmentWhereInput> = z.any()
const EnrollmentSelectSchema: ZodType<Prisma.EnrollmentSelect> = z.any()
@Injectable()
export class EnrollmentRouter {
constructor(
private readonly trpc: TrpcService,
private readonly enrollmentService: EnrollmentService,
) { }
router = this.trpc.router({
findFirst: this.trpc.procedure
.input(EnrollmentFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.enrollmentService.findFirst(input);
}),
findMany: this.trpc.procedure
.input(EnrollmentFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.enrollmentService.findMany(input);
}),
findManyWithCursor: this.trpc.protectProcedure
.input(z.object({
cursor: z.any().nullish(),
take: z.number().nullish(),
where: EnrollmentWhereInputSchema.nullish(),
select: EnrollmentSelectSchema.nullish()
}))
.query(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.enrollmentService.findManyWithCursor(input);
}),
enroll: this.trpc.protectProcedure
.input(EnrollSchema)
.mutation(async ({ input }) => {
return await this.enrollmentService.enroll(input);
}),
unenroll: this.trpc.protectProcedure
.input(UnenrollSchema)
.mutation(async ({ input }) => {
return await this.enrollmentService.unenroll(input);
}),
});
}

View File

@ -1,74 +0,0 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { BaseService } from '../base/base.service';
import {
UserProfile,
db,
ObjectType,
Prisma,
EnrollmentStatus
} from '@nice/common';
import { z } from 'zod';
import { EnrollSchema, UnenrollSchema } from './enroll.schema';
import EventBus, { CrudOperation } from '@server/utils/event-bus';
@Injectable()
export class EnrollmentService extends BaseService<Prisma.EnrollmentDelegate> {
constructor() {
super(db, ObjectType.COURSE);
}
async enroll(params: z.infer<typeof EnrollSchema>) {
const { studentId, courseId } = params
const result = await db.$transaction(async tx => {
// 检查是否已经报名
const existing = await tx.enrollment.findUnique({
where: {
studentId_courseId: {
studentId: studentId,
courseId: courseId,
},
},
});
if (existing) {
throw new ConflictException('Already enrolled in this course');
}
// 创建报名记录
const enrollment = await tx.enrollment.create({
data: {
studentId: studentId,
courseId: courseId,
status: EnrollmentStatus.ACTIVE,
},
});
return enrollment;
});
EventBus.emit('dataChanged', {
type: ObjectType.ENROLLMENT,
operation: CrudOperation.CREATED,
data: result,
});
return result
}
async unenroll(params: z.infer<typeof UnenrollSchema>) {
const { studentId, courseId } = params
const result = await db.enrollment.update({
where: {
studentId_courseId: {
studentId,
courseId,
},
},
data: {
status: EnrollmentStatus.CANCELLED
}
});
EventBus.emit('dataChanged', {
type: ObjectType.ENROLLMENT,
operation: CrudOperation.UPDATED,
data: result,
});
return result
}
}

View File

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { LectureRouter } from './lecture.router';
import { LectureService } from './lecture.service';
import { TrpcService } from '@server/trpc/trpc.service';
@Module({
providers: [LectureRouter, LectureService, TrpcService],
exports: [LectureRouter, LectureService]
})
export class LectureModule { }

View File

@ -1,70 +0,0 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nice/common';
import { LectureService } from './lecture.service';
import { z, ZodType } from 'zod';
const LectureCreateArgsSchema: ZodType<Prisma.LectureCreateArgs> = z.any()
const LectureCreateManyInputSchema: ZodType<Prisma.LectureCreateManyInput> = z.any()
const LectureDeleteManyArgsSchema: ZodType<Prisma.LectureDeleteManyArgs> = z.any()
const LectureFindManyArgsSchema: ZodType<Prisma.LectureFindManyArgs> = z.any()
const LectureFindFirstArgsSchema: ZodType<Prisma.LectureFindFirstArgs> = z.any()
const LectureWhereInputSchema: ZodType<Prisma.LectureWhereInput> = z.any()
const LectureSelectSchema: ZodType<Prisma.LectureSelect> = z.any()
@Injectable()
export class LectureRouter {
constructor(
private readonly trpc: TrpcService,
private readonly lectureService: LectureService,
) { }
router = this.trpc.router({
create: this.trpc.protectProcedure
.input(LectureCreateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.lectureService.create(input, {staff});
}),
createMany: this.trpc.protectProcedure.input(z.array(LectureCreateManyInputSchema))
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.lectureService.createMany({ data: input }, staff);
}),
deleteMany: this.trpc.procedure
.input(LectureDeleteManyArgsSchema)
.mutation(async ({ input }) => {
return await this.lectureService.deleteMany(input);
}),
findFirst: this.trpc.procedure
.input(LectureFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.lectureService.findFirst(input);
}),
softDeleteByIds: this.trpc.protectProcedure
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
.mutation(async ({ input }) => {
return this.lectureService.softDeleteByIds(input.ids);
}),
updateOrder: this.trpc.protectProcedure
.input(UpdateOrderSchema)
.mutation(async ({ input }) => {
return this.lectureService.updateOrder(input);
}),
findMany: this.trpc.procedure
.input(LectureFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.lectureService.findMany(input);
}),
findManyWithCursor: this.trpc.protectProcedure
.input(z.object({
cursor: z.any().nullish(),
take: z.number().nullish(),
where: LectureWhereInputSchema.nullish(),
select: LectureSelectSchema.nullish()
}))
.query(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.lectureService.findManyWithCursor(input);
}),
});
}

View File

@ -1,35 +0,0 @@
import { Injectable } from '@nestjs/common';
import { BaseService } from '../base/base.service';
import {
UserProfile,
db,
ObjectType,
Prisma
} from '@nice/common';
import EventBus, { CrudOperation } from '@server/utils/event-bus';
@Injectable()
export class LectureService extends BaseService<Prisma.LectureDelegate> {
constructor() {
super(db, ObjectType.COURSE);
}
async create(args: Prisma.LectureCreateArgs, params?: { staff?: UserProfile }) {
const result = await super.create(args)
EventBus.emit('dataChanged', {
type: ObjectType.LECTURE,
operation: CrudOperation.CREATED,
data: result,
});
return result;
}
async update(args: Prisma.LectureUpdateArgs) {
const result = await super.update(args);
EventBus.emit('dataChanged', {
type: ObjectType.LECTURE,
operation: CrudOperation.UPDATED,
data: result,
});
return result;
}
}

View File

@ -1,39 +0,0 @@
import { db, Lecture } from "@nice/common"
export async function updateSectionLectureStats(sectionId: string) {
const sectionStats = await db.lecture.aggregate({
where: {
sectionId,
deletedAt: null
},
_count: { _all: true },
_sum: { duration: true }
});
await db.section.update({
where: { id: sectionId },
data: {
totalLectures: sectionStats._count._all,
totalDuration: sectionStats._sum.duration || 0
}
});
}
export async function updateCourseLectureStats(courseId: string) {
const courseStats = await db.lecture.aggregate({
where: {
courseId,
deletedAt: null
},
_count: { _all: true },
_sum: { duration: true }
});
await db.course.update({
where: { id: courseId },
data: {
totalLectures: courseStats._count._all,
totalDuration: courseStats._sum.duration || 0
}
});
}

View File

@ -1,11 +1,8 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { PostService } from './post.service'; import { PostService } from './post.service';
import { AuthGuard } from '@server/auth/auth.guard';
import { db } from '@nice/common';
@Controller('post') @Controller('post')
export class PostController { export class PostController {
constructor(private readonly postService: PostService) { } constructor(private readonly postService: PostService) {}
} }

View File

@ -3,6 +3,8 @@ import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma } from '@nice/common'; import { Prisma } from '@nice/common';
import { PostService } from './post.service'; import { PostService } from './post.service';
import { z, ZodType } from 'zod'; import { z, ZodType } from 'zod';
import { PostMeta } from '@nice/common';
import { getClientIp } from './utils';
const PostCreateArgsSchema: ZodType<Prisma.PostCreateArgs> = z.any(); const PostCreateArgsSchema: ZodType<Prisma.PostCreateArgs> = z.any();
const PostUpdateArgsSchema: ZodType<Prisma.PostUpdateArgs> = z.any(); const PostUpdateArgsSchema: ZodType<Prisma.PostUpdateArgs> = z.any();
const PostFindFirstArgsSchema: ZodType<Prisma.PostFindFirstArgs> = z.any(); const PostFindFirstArgsSchema: ZodType<Prisma.PostFindFirstArgs> = z.any();
@ -15,12 +17,22 @@ export class PostRouter {
constructor( constructor(
private readonly trpc: TrpcService, private readonly trpc: TrpcService,
private readonly postService: PostService, private readonly postService: PostService,
) { } ) {}
router = this.trpc.router({ router = this.trpc.router({
create: this.trpc.protectProcedure create: this.trpc.protectProcedure
.input(PostCreateArgsSchema) .input(PostCreateArgsSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { staff } = ctx; const { staff, req } = ctx;
// 从请求中获取 IP
const ip = getClientIp(req);
const currentMeta =
typeof input.data.meta === 'object' && input.data.meta !== null
? input.data.meta
: {};
input.data.meta = {
...currentMeta,
ip: ip || '',
} as Prisma.InputJsonObject; // 明确指定类型
return await this.postService.create(input, { staff }); return await this.postService.create(input, { staff });
}), }),
softDeleteByIds: this.trpc.protectProcedure softDeleteByIds: this.trpc.protectProcedure

View File

@ -3,9 +3,7 @@ import {
db, db,
Prisma, Prisma,
UserProfile, UserProfile,
VisitType,
Post, Post,
PostType,
RolePerms, RolePerms,
ResPerm, ResPerm,
ObjectType, ObjectType,
@ -45,13 +43,13 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
operation: CrudOperation.UPDATED, operation: CrudOperation.UPDATED,
data: result, data: result,
}); });
return result return result;
} }
async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) { async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
if (!args.where) args.where = {} if (!args.where) args.where = {};
args.where.OR = await this.preFilter(args.where.OR, staff); args.where.OR = await this.preFilter(args.where.OR, staff);
return this.wrapResult(super.findManyWithCursor(args), async (result) => { return this.wrapResult(super.findManyWithCursor(args), async (result) => {
let { items } = result; const { items } = result;
await Promise.all( await Promise.all(
items.map(async (item) => { items.map(async (item) => {
await setPostRelation({ data: item, staff }); await setPostRelation({ data: item, staff });
@ -68,7 +66,7 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
delete: false, delete: false,
}; };
const isMySelf = data?.authorId === staff?.id; const isMySelf = data?.authorId === staff?.id;
const isDomain = staff.domainId === data.domainId; // const isDomain = staff.domainId === data.domainId;
const setManagePermissions = (perms: ResPerm) => { const setManagePermissions = (perms: ResPerm) => {
Object.assign(perms, { Object.assign(perms, {
delete: true, delete: true,
@ -84,11 +82,11 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
case RolePerms.MANAGE_ANY_POST: case RolePerms.MANAGE_ANY_POST:
setManagePermissions(perms); setManagePermissions(perms);
break; break;
case RolePerms.MANAGE_DOM_POST: // case RolePerms.MANAGE_DOM_POST:
if (isDomain) { // if (isDomain) {
setManagePermissions(perms); // setManagePermissions(perms);
} // }
break; // break;
} }
}); });
Object.assign(data, { perms }); Object.assign(data, { perms });
@ -99,51 +97,28 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
return outOR?.length > 0 ? outOR : undefined; return outOR?.length > 0 ? outOR : undefined;
} }
async getPostPreFilter(staff?: UserProfile) { async getPostPreFilter(staff?: UserProfile) {
if (!staff) return if (!staff) return;
const { deptId, domainId } = staff; // const { deptId, domainId } = staff;
if ( if (
staff.permissions.includes(RolePerms.READ_ANY_POST) || staff.permissions.includes(RolePerms.READ_ANY_POST) ||
staff.permissions.includes(RolePerms.MANAGE_ANY_POST) staff.permissions.includes(RolePerms.MANAGE_ANY_POST)
) { ) {
return undefined; return undefined;
} }
const parentDeptIds =
(await this.departmentService.getAncestorIds(staff.deptId)) || [];
const orCondition: Prisma.PostWhereInput[] = [ const orCondition: Prisma.PostWhereInput[] = [
staff?.id && { staff?.id && {
authorId: staff.id, authorId: staff.id,
}, },
{
isPublic: true,
},
staff?.id && { staff?.id && {
watchableStaffs: { receivers: {
some: { some: {
id: staff.id, id: staff.id,
}, },
}, },
}, },
deptId && {
watchableDepts: {
some: {
id: {
in: parentDeptIds,
},
},
},
},
{
AND: [
{
watchableStaffs: {
none: {}, // 匹配 watchableStaffs 为空
},
},
{
watchableDepts: {
none: {}, // 匹配 watchableDepts 为空
},
},
],
},
].filter(Boolean); ].filter(Boolean);
if (orCondition?.length > 0) return orCondition; if (orCondition?.length > 0) return orCondition;

View File

@ -1,7 +1,10 @@
import { db, Post, PostType, UserProfile, VisitType } from "@nice/common"; import { db, Post, PostType, UserProfile, VisitType } from '@nice/common';
export async function setPostRelation(params: { data: Post, staff?: UserProfile }) { export async function setPostRelation(params: {
const { data, staff } = params data: Post;
staff?: UserProfile;
}) {
const { data, staff } = params;
const limitedComments = await db.post.findMany({ const limitedComments = await db.post.findMany({
where: { where: {
parentId: data.id, parentId: data.id,
@ -39,6 +42,21 @@ export async function setPostRelation(params: { data: Post, staff?: UserProfile
limitedComments, limitedComments,
commentsCount, commentsCount,
// trouble // trouble
}) });
}
export function getClientIp(req: any): string {
let ip =
req.ip ||
(Array.isArray(req.headers['x-forwarded-for'])
? req.headers['x-forwarded-for'][0]
: req.headers['x-forwarded-for']) ||
req.socket.remoteAddress;
// 如果是 IPv4-mapped IPv6 地址,转换为 IPv4
if (typeof ip === 'string' && ip.startsWith('::ffff:')) {
ip = ip.substring(7);
}
return ip || '';
} }

View File

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { SectionRouter } from './section.router';
import { SectionService } from './section.service';
import { TrpcService } from '@server/trpc/trpc.service';
@Module({
exports: [SectionRouter, SectionService],
providers: [SectionRouter, SectionService, TrpcService]
})
export class SectionModule { }

View File

@ -1,70 +0,0 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nice/common';
import { SectionService } from './section.service';
import { z, ZodType } from 'zod';
const SectionCreateArgsSchema: ZodType<Prisma.SectionCreateArgs> = z.any()
const SectionCreateManyInputSchema: ZodType<Prisma.SectionCreateManyInput> = z.any()
const SectionDeleteManyArgsSchema: ZodType<Prisma.SectionDeleteManyArgs> = z.any()
const SectionFindManyArgsSchema: ZodType<Prisma.SectionFindManyArgs> = z.any()
const SectionFindFirstArgsSchema: ZodType<Prisma.SectionFindFirstArgs> = z.any()
const SectionWhereInputSchema: ZodType<Prisma.SectionWhereInput> = z.any()
const SectionSelectSchema: ZodType<Prisma.SectionSelect> = z.any()
@Injectable()
export class SectionRouter {
constructor(
private readonly trpc: TrpcService,
private readonly sectionService: SectionService,
) { }
router = this.trpc.router({
create: this.trpc.protectProcedure
.input(SectionCreateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.sectionService.create(input, { staff });
}),
createMany: this.trpc.protectProcedure.input(z.array(SectionCreateManyInputSchema))
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.sectionService.createMany({ data: input }, staff);
}),
deleteMany: this.trpc.procedure
.input(SectionDeleteManyArgsSchema)
.mutation(async ({ input }) => {
return await this.sectionService.deleteMany(input);
}),
findFirst: this.trpc.procedure
.input(SectionFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.sectionService.findFirst(input);
}),
softDeleteByIds: this.trpc.protectProcedure
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
.mutation(async ({ input }) => {
return this.sectionService.softDeleteByIds(input.ids);
}),
updateOrder: this.trpc.protectProcedure
.input(UpdateOrderSchema)
.mutation(async ({ input }) => {
return this.sectionService.updateOrder(input);
}),
findMany: this.trpc.procedure
.input(SectionFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.sectionService.findMany(input);
}),
findManyWithCursor: this.trpc.protectProcedure
.input(z.object({
cursor: z.any().nullish(),
take: z.number().nullish(),
where: SectionWhereInputSchema.nullish(),
select: SectionSelectSchema.nullish()
}))
.query(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.sectionService.findManyWithCursor(input);
}),
});
}

View File

@ -1,24 +0,0 @@
import { Injectable } from '@nestjs/common';
import { BaseService } from '../base/base.service';
import {
UserProfile,
db,
ObjectType,
Prisma,
} from '@nice/common';
@Injectable()
export class SectionService extends BaseService<Prisma.SectionDelegate> {
constructor() {
super(db, ObjectType.SECTION);
}
create(args: Prisma.SectionCreateArgs, params?: { staff?: UserProfile }) {
return super.create(args)
}
async update(args: Prisma.SectionUpdateArgs) {
return super.update(args);
}
}

View File

@ -1,12 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BaseService } from '../base/base.service'; import { BaseService } from '../base/base.service';
import { import { UserProfile, db, ObjectType, Prisma, VisitType } from '@nice/common';
UserProfile,
db,
ObjectType,
Prisma,
VisitType,
} from '@nice/common';
import EventBus from '@server/utils/event-bus'; import EventBus from '@server/utils/event-bus';
@Injectable() @Injectable()
export class VisitService extends BaseService<Prisma.VisitDelegate> { export class VisitService extends BaseService<Prisma.VisitDelegate> {
@ -14,14 +8,14 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
super(db, ObjectType.VISIT); super(db, ObjectType.VISIT);
} }
async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) { async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) {
const { postId, lectureId, messageId } = args.data; const { postId, messageId } = args.data;
const visitorId = args.data.visitorId || staff?.id; const visitorId = args.data.visitorId || staff?.id;
let result; let result;
const existingVisit = await db.visit.findFirst({ const existingVisit = await db.visit.findFirst({
where: { where: {
type: args.data.type, type: args.data.type,
visitorId, visitorId,
OR: [{ postId }, { lectureId }, { messageId }], OR: [{ postId }, { messageId }],
}, },
}); });
if (!existingVisit) { if (!existingVisit) {
@ -50,12 +44,12 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
const createData: Prisma.VisitCreateManyInput[] = []; const createData: Prisma.VisitCreateManyInput[] = [];
await Promise.all( await Promise.all(
data.map(async (item) => { data.map(async (item) => {
if (staff && !item.visitorId) item.visitorId = staff.id if (staff && !item.visitorId) item.visitorId = staff.id;
const { postId, lectureId, messageId, visitorId } = item; const { postId, messageId, visitorId } = item;
const existingVisit = await db.visit.findFirst({ const existingVisit = await db.visit.findFirst({
where: { where: {
visitorId, visitorId,
OR: [{ postId }, { lectureId }, { messageId }], OR: [{ postId }, { messageId }],
}, },
}); });

View File

@ -1,14 +1,8 @@
import { Job } from 'bullmq'; import { Job } from 'bullmq';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import {
updateCourseLectureStats,
updateSectionLectureStats
} from '@server/models/lecture/utils';
import { ObjectType } from '@nice/common'; import { ObjectType } from '@nice/common';
import {
updateCourseEnrollmentStats,
updateCourseReviewStats
} from '@server/models/course/utils';
import { QueueJobType } from '../types'; import { QueueJobType } from '../types';
const logger = new Logger('QueueWorker'); const logger = new Logger('QueueWorker');
export default async function processJob(job: Job<any, any, QueueJobType>) { export default async function processJob(job: Job<any, any, QueueJobType>) {
@ -16,34 +10,37 @@ export default async function processJob(job: Job<any, any, QueueJobType>) {
if (job.name === QueueJobType.UPDATE_STATS) { if (job.name === QueueJobType.UPDATE_STATS) {
const { sectionId, courseId, type } = job.data; const { sectionId, courseId, type } = job.data;
// 处理 section 统计 // 处理 section 统计
if (sectionId) { // if (sectionId) {
await updateSectionLectureStats(sectionId); // await updateSectionLectureStats(sectionId);
logger.debug(`Updated section stats for sectionId: ${sectionId}`); // logger.debug(`Updated section stats for sectionId: ${sectionId}`);
} // }
// 如果没有 courseId提前返回 // // 如果没有 courseId提前返回
if (!courseId) { // if (!courseId) {
return; // return;
} // }
// 处理 course 相关统计 // 处理 course 相关统计
switch (type) { switch (type) {
case ObjectType.LECTURE: // case ObjectType.LECTURE:
await updateCourseLectureStats(courseId); // await updateCourseLectureStats(courseId);
break; // break;
case ObjectType.ENROLLMENT: // case ObjectType.ENROLLMENT:
await updateCourseEnrollmentStats(courseId); // await updateCourseEnrollmentStats(courseId);
break; // break;
case ObjectType.POST: // case ObjectType.POST:
await updateCourseReviewStats(courseId); // await updateCourseReviewStats(courseId);
break; // break;
default: default:
logger.warn(`Unknown update stats type: ${type}`); logger.warn(`Unknown update stats type: ${type}`);
} }
logger.debug(`Updated course stats for courseId: ${courseId}, type: ${type}`); logger.debug(
`Updated course stats for courseId: ${courseId}, type: ${type}`,
);
} }
} catch (error: any) { } catch (error: any) {
logger.error(`Error processing stats update job: ${error.message}`, error.stack); logger.error(
`Error processing stats update job: ${error.message}`,
error.stack,
);
} }
} }

View File

@ -19,7 +19,7 @@ export class InitService {
private readonly minioService: MinioService, private readonly minioService: MinioService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly genDevService: GenDevService, private readonly genDevService: GenDevService,
) { } ) {}
private async createRoles() { private async createRoles() {
this.logger.log('Checking existing system roles'); this.logger.log('Checking existing system roles');
for (const role of InitRoles) { for (const role of InitRoles) {
@ -142,12 +142,13 @@ export class InitService {
await this.createRoot(); await this.createRoot();
await this.createOrUpdateTaxonomy(); await this.createOrUpdateTaxonomy();
await this.initAppConfigs(); await this.initAppConfigs();
try { //不需要minio
this.logger.log('Initialize minio'); // try {
await this.createBucket(); // this.logger.log('Initialize minio');
} catch (error) { // await this.createBucket();
this.logger.error('Minio initialization failed:', error); // } catch (error) {
} // this.logger.error('Minio initialization failed:', error);
// }
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
try { try {

View File

@ -14,9 +14,7 @@ import { VisitModule } from '@server/models/visit/visit.module';
import { WebSocketModule } from '@server/socket/websocket.module'; import { WebSocketModule } from '@server/socket/websocket.module';
import { RoleMapModule } from '@server/models/rbac/rbac.module'; import { RoleMapModule } from '@server/models/rbac/rbac.module';
import { TransformModule } from '@server/models/transform/transform.module'; import { TransformModule } from '@server/models/transform/transform.module';
import { CourseModule } from '@server/models/course/course.module';
import { LectureModule } from '@server/models/lecture/lecture.module';
import { SectionModule } from '@server/models/section/section.module';
@Module({ @Module({
imports: [ imports: [
AuthModule, AuthModule,
@ -31,12 +29,9 @@ import { SectionModule } from '@server/models/section/section.module';
AppConfigModule, AppConfigModule,
PostModule, PostModule,
VisitModule, VisitModule,
CourseModule, WebSocketModule,
LectureModule,
SectionModule,
WebSocketModule
], ],
controllers: [], controllers: [],
providers: [TrpcService, TrpcRouter, Logger], providers: [TrpcService, TrpcRouter, Logger],
}) })
export class TrpcModule { } export class TrpcModule {}

View File

@ -13,12 +13,10 @@ import { VisitRouter } from '@server/models/visit/visit.router';
import { RoleMapRouter } from '@server/models/rbac/rolemap.router'; import { RoleMapRouter } from '@server/models/rbac/rolemap.router';
import { TransformRouter } from '@server/models/transform/transform.router'; import { TransformRouter } from '@server/models/transform/transform.router';
import { RoleRouter } from '@server/models/rbac/role.router'; import { RoleRouter } from '@server/models/rbac/role.router';
import { CourseRouter } from '@server/models/course/course.router';
import { LectureRouter } from '@server/models/lecture/lecture.router';
import { SectionRouter } from '@server/models/section/section.router';
@Injectable() @Injectable()
export class TrpcRouter { export class TrpcRouter {
logger = new Logger(TrpcRouter.name) logger = new Logger(TrpcRouter.name);
constructor( constructor(
private readonly trpc: TrpcService, private readonly trpc: TrpcService,
private readonly post: PostRouter, private readonly post: PostRouter,
@ -32,13 +30,9 @@ export class TrpcRouter {
private readonly app_config: AppConfigRouter, private readonly app_config: AppConfigRouter,
private readonly message: MessageRouter, private readonly message: MessageRouter,
private readonly visitor: VisitRouter, private readonly visitor: VisitRouter,
private readonly course: CourseRouter,
private readonly lecture: LectureRouter,
private readonly section: SectionRouter
// private readonly websocketService: WebSocketService // private readonly websocketService: WebSocketService
) { } ) {}
appRouter = this.trpc.router({ appRouter = this.trpc.router({
transform: this.transform.router, transform: this.transform.router,
post: this.post.router, post: this.post.router,
department: this.department.router, department: this.department.router,
@ -50,11 +44,8 @@ export class TrpcRouter {
message: this.message.router, message: this.message.router,
app_config: this.app_config.router, app_config: this.app_config.router,
visitor: this.visitor.router, visitor: this.visitor.router,
course: this.course.router,
lecture: this.lecture.router,
section: this.section.router
}); });
wss: WebSocketServer = undefined wss: WebSocketServer = undefined;
async applyMiddleware(app: INestApplication) { async applyMiddleware(app: INestApplication) {
app.use( app.use(
@ -65,7 +56,7 @@ export class TrpcRouter {
onError(opts) { onError(opts) {
const { error, type, path, input, ctx, req } = opts; const { error, type, path, input, ctx, req } = opts;
// console.error('TRPC Error:', error); // console.error('TRPC Error:', error);
} },
}), }),
); );
// applyWSSHandler({ // applyWSSHandler({

View File

@ -1,3 +1,4 @@
//trpc.service.ts
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { initTRPC, TRPCError } from '@trpc/server'; import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson-cjs'; import superjson from 'superjson-cjs';
@ -12,9 +13,17 @@ export class TrpcService {
async createExpressContext( async createExpressContext(
opts: trpcExpress.CreateExpressContextOptions, opts: trpcExpress.CreateExpressContextOptions,
): Promise<{ staff: UserProfile | undefined }> { ): Promise<{
staff: UserProfile | undefined;
req: trpcExpress.CreateExpressContextOptions['req'];
}> {
const token = opts.req.headers.authorization?.split(' ')[1]; const token = opts.req.headers.authorization?.split(' ')[1];
return await UserProfileService.instance.getUserProfileByToken(token); const staff =
await UserProfileService.instance.getUserProfileByToken(token);
return {
staff: staff.staff,
req: opts.req,
};
} }
async createWSSContext( async createWSSContext(
opts: CreateWSSContextFnOptions, opts: CreateWSSContextFnOptions,
@ -45,6 +54,7 @@ export class TrpcService {
ctx: { ctx: {
// User value is confirmed to be non-null at this point // User value is confirmed to be non-null at this point
staff: ctx.staff, staff: ctx.staff,
req: ctx.req,
}, },
}); });
}); });

View File

@ -1,24 +1,25 @@
import GraphEditor from '@web/src/components/common/editor/graph/GraphEditor'; import GraphEditor from "@web/src/components/common/editor/graph/GraphEditor";
import React, { useState, useCallback } from 'react'; import { usePost } from "@nice/client";
import * as tus from 'tus-js-client'; import React, { useState, useCallback } from "react";
import * as tus from "tus-js-client";
import toast from "react-hot-toast";
interface TusUploadProps { interface TusUploadProps {
onSuccess?: (response: any) => void; onSuccess?: (response: any) => void;
onError?: (error: Error) => void; onError?: (error: Error) => void;
} }
const TusUploader: React.FC<TusUploadProps> = ({ const TusUploader: React.FC<TusUploadProps> = ({ onSuccess, onError }) => {
onSuccess, const { create } = usePost();
onError
}) => {
const [progress, setProgress] = useState<number>(0); const [progress, setProgress] = useState<number>(0);
const [isUploading, setIsUploading] = useState<boolean>(false); const [isUploading, setIsUploading] = useState<boolean>(false);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const handleFileUpload = useCallback((file: File) => { const handleFileUpload = useCallback(
(file: File) => {
if (!file) return; if (!file) return;
setIsUploading(true); setIsUploading(true);
setProgress(0); setProgress(0);
setUploadError(null); setUploadError(null);
// Extract file extension // Extract file extension
const extension = file.name.split('.').pop() || ''; const extension = file.name.split(".").pop() || "";
const upload = new tus.Upload(file, { const upload = new tus.Upload(file, {
endpoint: "http://localhost:3000/upload", endpoint: "http://localhost:3000/upload",
retryDelays: [0, 1000, 3000, 5000], retryDelays: [0, 1000, 3000, 5000],
@ -30,33 +31,51 @@ const TusUploader: React.FC<TusUploadProps> = ({
modifiedAt: new Date(file.lastModified).toISOString(), modifiedAt: new Date(file.lastModified).toISOString(),
}, },
onProgress: (bytesUploaded, bytesTotal) => { onProgress: (bytesUploaded, bytesTotal) => {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); const percentage = (
(bytesUploaded / bytesTotal) *
100
).toFixed(2);
setProgress(Number(percentage)); setProgress(Number(percentage));
}, },
onSuccess: () => { onSuccess: () => {
setIsUploading(false); setIsUploading(false);
setProgress(100); setProgress(100);
onSuccess && onSuccess(upload); onSuccess?.(upload);
}, },
onError: (error) => { onError: (error) => {
setIsUploading(false); setIsUploading(false);
setUploadError(error.message); setUploadError(error.message);
onError && onError(error); onError?.(error);
} },
}); });
upload.start(); upload.start();
}, [onSuccess, onError]); },
[onSuccess, onError]
);
return ( return (
<div> <div>
<div className='w-full' style={{ height: 800 }}> <button
onClick={async () => {
try {
await create.mutateAsync({
data: {
title: "123",
isPublic: true,
},
});
toast.success("创建成功");
} catch (err) {
console.log(err);
toast.error("创建失败");
}
}}>
123
</button>
<div className="w-full" style={{ height: 800 }}>
<GraphEditor></GraphEditor> <GraphEditor></GraphEditor>
</div> </div>
{/* <div className=' h-screen'>
<MindMap></MindMap>
</div> */}
{/* <MindMapEditor></MindMapEditor> */}
<input <input
type="file" type="file"
@ -72,9 +91,7 @@ const TusUploader: React.FC<TusUploadProps> = ({
</div> </div>
)} )}
{uploadError && ( {uploadError && (
<div style={{ color: 'red' }}> <div style={{ color: "red" }}>: {uploadError}</div>
: {uploadError}
</div>
)} )}
</div> </div>
); );

View File

@ -2,26 +2,26 @@ import { createContext, useContext, ReactNode, useEffect } from "react";
import { useForm, FormProvider, SubmitHandler } from "react-hook-form"; import { useForm, FormProvider, SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { CourseDto, CourseLevel, CourseStatus } from "@nice/common"; // import { CourseDto, CourseLevel, CourseStatus } from "@nice/common";
import { api, useCourse } from "@nice/client"; // import { api, useCourse } from "@nice/client";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
// 定义课程表单验证 Schema // 定义课程表单验证 Schema
const courseSchema = z.object({ const courseSchema = z.object({
title: z.string().min(1, '课程标题不能为空'), title: z.string().min(1, "课程标题不能为空"),
subTitle: z.string().nullish(), subTitle: z.string().nullish(),
description: z.string().nullish(), description: z.string().nullish(),
thumbnail: z.string().url().nullish(), thumbnail: z.string().url().nullish(),
level: z.nativeEnum(CourseLevel), // level: z.nativeEnum(CourseLevel),
requirements: z.array(z.string()).nullish(), requirements: z.array(z.string()).nullish(),
objectives: z.array(z.string()).nullish() objectives: z.array(z.string()).nullish(),
}); });
export type CourseFormData = z.infer<typeof courseSchema>; export type CourseFormData = z.infer<typeof courseSchema>;
interface CourseEditorContextType { interface CourseEditorContextType {
onSubmit: SubmitHandler<CourseFormData>; onSubmit: SubmitHandler<CourseFormData>;
editId?: string; // 添加 editId editId?: string; // 添加 editId
part?: string; part?: string;
course?: CourseDto; // course?: CourseDto;
} }
interface CourseFormProviderProps { interface CourseFormProviderProps {
children: ReactNode; children: ReactNode;
@ -29,67 +29,72 @@ interface CourseFormProviderProps {
part?: string; part?: string;
} }
const CourseEditorContext = createContext<CourseEditorContextType | null>(null); const CourseEditorContext = createContext<CourseEditorContextType | null>(null);
export function CourseFormProvider({ children, editId }: CourseFormProviderProps) { export function CourseFormProvider({
const { create, update } = useCourse() children,
const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery({ where: { id: editId } }, { enabled: Boolean(editId) }) editId,
const navigate = useNavigate() }: CourseFormProviderProps) {
// const { create, update } = useCourse()
// const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery({ where: { id: editId } }, { enabled: Boolean(editId) })
const navigate = useNavigate();
const methods = useForm<CourseFormData>({ const methods = useForm<CourseFormData>({
resolver: zodResolver(courseSchema), resolver: zodResolver(courseSchema),
defaultValues: { defaultValues: {
level: CourseLevel.BEGINNER, // level: CourseLevel.BEGINNER,
requirements: [], requirements: [],
objectives: [] objectives: [],
}, },
}); });
useEffect(() => { // useEffect(() => {
if (course) { // if (course) {
const formData = { // const formData = {
title: course.title, // title: course.title,
subTitle: course.subTitle, // subTitle: course.subTitle,
description: course.description, // description: course.description,
thumbnail: course.thumbnail, // thumbnail: course.thumbnail,
level: course.level, // level: course.level,
requirements: course.requirements, // requirements: course.requirements,
objectives: course.objectives, // objectives: course.objectives,
status: course.status, // status: course.status,
}; // };
methods.reset(formData as any); // methods.reset(formData as any);
} // }
}, [course, methods]); // }, [course, methods]);
const onSubmit: SubmitHandler<CourseFormData> = async (data: CourseFormData) => { const onSubmit: SubmitHandler<CourseFormData> = async (
data: CourseFormData
) => {
try { try {
if (editId) { if (editId) {
await update.mutateAsync({ // await update.mutateAsync({
where: { id: editId }, // where: { id: editId },
data: { // data: {
...data // ...data
} // }
}) // })
toast.success('课程更新成功!'); toast.success("课程更新成功!");
} else { } else {
const result = await create.mutateAsync({ // const result = await create.mutateAsync({
data: { // data: {
status: CourseStatus.DRAFT, // status: CourseStatus.DRAFT,
...data // ...data
} // }
}) // })
navigate(`/course/${result.id}/editor`, { replace: true }) // navigate(`/course/${result.id}/editor`, { replace: true });
toast.success('课程创建成功!'); toast.success("课程创建成功!");
} }
methods.reset(data); methods.reset(data);
} catch (error) { } catch (error) {
console.error('Error submitting form:', error); console.error("Error submitting form:", error);
toast.error('操作失败,请重试!'); toast.error("操作失败,请重试!");
} }
}; };
return ( return (
<CourseEditorContext.Provider value={{ onSubmit, editId, course }}> <CourseEditorContext.Provider
<FormProvider {...methods}> value={{
{children} onSubmit,
</FormProvider> editId,
// course
}}>
<FormProvider {...methods}>{children}</FormProvider>
</CourseEditorContext.Provider> </CourseEditorContext.Provider>
); );
} }

View File

@ -9,6 +9,7 @@ import {
import apiClient from "../utils/axios-client"; import apiClient from "../utils/axios-client";
import { AuthSchema, RolePerms, UserProfile } from "@nice/common"; import { AuthSchema, RolePerms, UserProfile } from "@nice/common";
import { z } from "zod"; import { z } from "zod";
interface AuthContextProps { interface AuthContextProps {
accessToken: string | null; accessToken: string | null;
refreshToken: string | null; refreshToken: string | null;
@ -144,6 +145,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
startTokenRefreshInterval(); startTokenRefreshInterval();
fetchUserProfile(); fetchUserProfile();
} catch (err: any) { } catch (err: any) {
console.log(err);
throw err; throw err;
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -157,6 +159,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
setIsLoading(true); setIsLoading(true);
await apiClient.post(`/auth/signup`, data); await apiClient.post(`/auth/signup`, data);
} catch (err: any) { } catch (err: any) {
console.log(err);
throw err; throw err;
} finally { } finally {
setIsLoading(false); setIsLoading(false);

View File

@ -12,6 +12,7 @@
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"dev": "tsup --watch", "dev": "tsup --watch",
"dev-static": "tsup --no-watch",
"clean": "rimraf dist", "clean": "rimraf dist",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },

View File

@ -9,4 +9,3 @@ export * from "./useTaxonomy"
export * from "./useVisitor" export * from "./useVisitor"
export * from "./useMessage" export * from "./useMessage"
export * from "./usePost" export * from "./usePost"
export * from "./useCourse"

View File

@ -1,56 +0,0 @@
import { api } from "../trpc";
export function useCourse() {
const utils = api.useUtils();
return {
// Queries
findMany: api.course.findMany.useQuery,
findFirst: api.course.findFirst.useQuery,
findManyWithCursor: api.course.findManyWithCursor.useQuery,
// Mutations
create: api.course.create.useMutation({
onSuccess: () => {
utils.course.invalidate()
utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate()
},
}),
update: api.course.update.useMutation({
onSuccess: () => {
utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate()
},
}),
createMany: api.course.createMany.useMutation({
onSuccess: () => {
utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate()
},
}),
deleteMany: api.course.deleteMany.useMutation({
onSuccess: () => {
utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate()
},
}),
softDeleteByIds: api.course.softDeleteByIds.useMutation({
onSuccess: () => {
utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate()
},
}),
updateOrder: api.course.updateOrder.useMutation({
onSuccess: () => {
utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate()
},
})
};
}

View File

@ -2,12 +2,12 @@ import { api } from "../trpc";
export function usePost() { export function usePost() {
const utils = api.useUtils(); const utils = api.useUtils();
const create = api.post.create.useMutation({ const create: any = api.post.create.useMutation({
onSuccess: () => { onSuccess: () => {
utils.post.invalidate(); utils.post.invalidate();
}, },
}); });
const update = api.post.update.useMutation({ const update: any = api.post.update.useMutation({
onSuccess: () => { onSuccess: () => {
utils.post.invalidate(); utils.post.invalidate();
}, },
@ -17,21 +17,21 @@ export function usePost() {
utils.post.invalidate(); utils.post.invalidate();
}, },
}); });
const softDeleteByIds = api.post.softDeleteByIds.useMutation({ const softDeleteByIds: any = api.post.softDeleteByIds.useMutation({
onSuccess: () => { onSuccess: () => {
utils.post.invalidate(); utils.post.invalidate();
}, },
}); });
const restoreByIds = api.post.restoreByIds.useMutation({ const restoreByIds: any = api.post.restoreByIds.useMutation({
onSuccess: () => { onSuccess: () => {
utils.post.invalidate(); utils.post.invalidate();
}, },
}) });
return { return {
create, create,
update, update,
deleteMany, deleteMany,
softDeleteByIds, softDeleteByIds,
restoreByIds restoreByIds,
}; };
} }

View File

@ -7,4 +7,4 @@ export * from "./hooks"
export * from "./websocket" export * from "./websocket"
export * from "./event" export * from "./event"
export * from "./types" export * from "./types"
export * from "./upload" // export * from "./upload"

View File

@ -1,3 +0,0 @@
export * from "./types"
export * from "./uploadManager"
export * from "./useUpload"

View File

@ -1,13 +0,0 @@
import { UploadProgress } from "@nice/common";
export interface UploadOptions {
baseUrl?: string;
chunkSize?: number;
concurrency?: number;
retries?: number;
clientId?: string;
onProgress?: (progress: UploadProgress) => void;
onError?: (error: Error) => void;
onSuccess?: (response: any) => void;
}

View File

@ -1,225 +0,0 @@
import axios, { AxiosInstance } from 'axios';
import { ChunkDto, UploadProgress, UploadStatusInfoDto, UUIDGenerator } from '@nice/common';
import { UploadOptions } from './types';
import { calculateFileIdentifier } from '../tools';
export class UploadManager {
private readonly axios: AxiosInstance;
private readonly chunkSize: number;
private readonly concurrency: number;
private readonly retries: number;
private readonly clientId: string;
private activeUploads: Map<string, boolean> = new Map();
private abortControllers: Map<string, AbortController> = new Map();
constructor(options: UploadOptions = {}) {
const {
baseUrl = '/upload',
chunkSize = 10 * 1024 * 1024,
concurrency = 3,
retries = 3,
} = options;
this.axios = axios.create({ baseURL: baseUrl });
this.chunkSize = chunkSize;
this.concurrency = concurrency;
this.retries = retries;
this.clientId = options.clientId || UUIDGenerator.generate();
}
async uploadFile(file: File, options: UploadOptions = {}): Promise<void> {
const identifier = await calculateFileIdentifier(file);
const controller = new AbortController();
this.abortControllers.set(identifier, controller);
try {
// Check if file is already uploaded
const statusInfo = await this.checkUploadStatusInfo(identifier);
if (statusInfo?.status === "completed") {
options.onSuccess?.({ identifier, filename: file.name });
return;
}
const chunks = await this.prepareChunks(file, identifier);
const uploadedChunks = statusInfo?.chunks || new Set<number>();
// Filter out already uploaded chunks
const remainingChunks = chunks.filter(chunk => !uploadedChunks.has(chunk.chunkNumber));
await this.uploadChunks(remainingChunks, file, options, controller.signal);
} catch (error) {
if (axios.isCancel(error)) {
return;
}
options.onError?.(error as Error);
throw error;
} finally {
this.abortControllers.delete(identifier);
this.activeUploads.delete(identifier);
}
}
private async prepareChunks(file: File, identifier: string): Promise<ChunkDto[]> {
const chunks: ChunkDto[] = [];
const totalChunks = Math.ceil(file.size / this.chunkSize);
for (let i = 0; i < totalChunks; i++) {
chunks.push({
identifier,
filename: file.name,
chunkNumber: i + 1,
totalChunks,
currentChunkSize: Math.min(this.chunkSize, file.size - i * this.chunkSize),
totalSize: file.size,
});
}
return chunks;
}
private async uploadChunks(
chunks: ChunkDto[],
file: File,
options: UploadOptions,
signal: AbortSignal
): Promise<void> {
const chunkQueue = [...chunks]; // Create a copy of chunks array
let activeUploads = 0;
let completedChunks = 0;
let uploadedBytes = 0;
// 记录最近几次chunk的上传速度
const speedBuffer: number[] = [];
const SPEED_BUFFER_SIZE = 5; // 保留最近5个chunk的速度
return new Promise((resolve, reject) => {
const uploadNextChunk = async () => {
if (completedChunks === chunks.length) {
if (activeUploads === 0) { // Only resolve when all active uploads are done
resolve();
}
return;
}
while (activeUploads < this.concurrency && chunkQueue.length > 0) {
const chunk = chunkQueue.shift();
if (!chunk) break;
const chunkStartTime = Date.now();
activeUploads++;
this.uploadChunk(chunk, file, signal)
.then(() => {
const chunkEndTime = Date.now();
const chunkUploadTime = (chunkEndTime - chunkStartTime) / 1000; // 秒
completedChunks++;
uploadedBytes += chunk.currentChunkSize;
activeUploads--;
// 计算当前chunk的上传速度
const currentSpeed = chunkUploadTime > 0
? chunk.currentChunkSize / chunkUploadTime
: 0;
// 维护速度缓冲区
speedBuffer.push(currentSpeed);
if (speedBuffer.length > SPEED_BUFFER_SIZE) {
speedBuffer.shift();
}
// 计算平均速度
const averageSpeed = speedBuffer.length > 0
? speedBuffer.reduce((a, b) => a + b, 0) / speedBuffer.length
: 0;
const totalUploadedBytes = uploadedBytes;
const remainingBytes = file.size - totalUploadedBytes;
// 使用平均速度计算剩余时间
const remainingTime = averageSpeed > 0
? remainingBytes / averageSpeed
: 0;
const progress: UploadProgress = {
identifier: chunk.identifier,
percentage: (completedChunks / (chunks.length + completedChunks)) * 100,
uploadedSize: totalUploadedBytes,
totalSize: file.size,
speed: averageSpeed, // 字节/秒
remainingTime: remainingTime // 秒
};
options.onProgress?.(progress);
uploadNextChunk();
})
.catch(reject);
}
};
uploadNextChunk();
});
}
private async uploadChunk(
chunk: ChunkDto,
file: File,
signal: AbortSignal
): Promise<void> {
const start = (chunk.chunkNumber - 1) * this.chunkSize;
const end = Math.min(start + this.chunkSize, file.size);
const chunkBlob = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', JSON.stringify(chunk));
formData.append('clientId', this.clientId)
formData.append('file', chunkBlob);
let attempts = 0;
while (attempts < this.retries) {
try {
await this.axios.post('/chunk', formData, { signal });
return;
} catch (error) {
attempts++;
if (attempts === this.retries) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * attempts));
}
}
}
async pauseUpload(identifier: string): Promise<void> {
const controller = this.abortControllers.get(identifier);
if (controller) {
controller.abort();
this.abortControllers.delete(identifier);
}
try {
// Call the pause API endpoint
await this.axios.post(`/pause/${identifier}`);
} catch (error) {
console.error('Error pausing upload:', error);
throw error;
}
}
async resumeUpload(file: File, options: UploadOptions = {}): Promise<void> {
const identifier = await calculateFileIdentifier(file);
try {
// Call the resume API endpoint
await this.axios.post(`/resume/${identifier}`);
// Then continue with the upload process
return this.uploadFile(file, options);
} catch (error) {
console.error('Error resuming upload:', error);
throw error;
}
}
private async checkUploadStatusInfo(identifier: string): Promise<UploadStatusInfoDto> {
try {
const response = await this.axios.get(`/status/${identifier}`);
return response.data;
} catch {
return null;
}
}
}

View File

@ -1,56 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { UploadManager } from './uploadManager';
import { UploadProgress } from '@nice/common';
import { UploadOptions } from './types';
export function useUpload(options: UploadOptions = {}) {
const [progress, setProgress] = useState<Record<string, UploadProgress>>({});
const [errors, setErrors] = useState<Record<string, Error>>({});
const uploadManagerRef = useRef<UploadManager>();
const getUploadManager = useCallback(() => {
if (!uploadManagerRef.current) {
uploadManagerRef.current = new UploadManager(options);
}
return uploadManagerRef.current;
}, [options]);
const upload = useCallback(async (files: File | File[]) => {
const fileList = Array.isArray(files) ? files : [files];
const manager = getUploadManager();
const uploads = fileList.map(file => {
return manager.uploadFile(file, {
...options,
onProgress: (progress) => {
setProgress(prev => ({
...prev,
[progress.identifier]: progress
}));
options.onProgress?.(progress);
},
onError: (error) => {
setErrors(prev => ({
...prev,
[file.name]: error
}));
options.onError?.(error);
}
});
});
return Promise.all(uploads);
}, [options, getUploadManager]);
const pauseUpload = useCallback((identifier: string) => {
getUploadManager().pauseUpload(identifier);
}, [getUploadManager]);
const resumeUpload = useCallback((file: File) => {
return getUploadManager().resumeUpload(file, options);
}, [getUploadManager, options]);
return {
upload,
pauseUpload,
resumeUpload,
progress,
errors
};
}

View File

@ -195,17 +195,14 @@ model Post {
// 关系类型字段 // 关系类型字段
authorId String? @map("author_id") authorId String? @map("author_id")
author Staff? @relation("post_author", fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型 author Staff? @relation("post_author", fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型
visits Visit[] // 访问记录,关联 Visit 模型 visits Visit[] // 访问记录,关联 Visit 模型
receivers Staff[] @relation("post_receiver") receivers Staff[] @relation("post_receiver")
parentId String? @map("parent_id") parentId String? @map("parent_id")
parent Post? @relation("PostChildren", fields: [parentId], references: [id]) // 父级帖子,关联 Post 模型 parent Post? @relation("PostChildren", fields: [parentId], references: [id]) // 父级帖子,关联 Post 模型
children Post[] @relation("PostChildren") // 子级帖子列表,关联 Post 模型 children Post[] @relation("PostChildren") // 子级帖子列表,关联 Post 模型
resources Resource[] // 附件列表 resources Resource[] // 附件列表
isPublic Boolean? @default(false) @map("is_public") isPublic Boolean? @default(false) @map("is_public")
meta Json? meta Json? // 签名 和 IP
// 复合索引 // 复合索引
@@index([type, domainId]) // 类型和域组合查询 @@index([type, domainId]) // 类型和域组合查询

View File

@ -158,6 +158,10 @@ export interface BaseSetting {
devDept?: string; devDept?: string;
}; };
} }
export interface PostMeta {
signature?: string;
ip?: string;
}
export type RowModelResult = { export type RowModelResult = {
rowData: any[]; rowData: any[];
rowCount: number; rowCount: number;

View File

@ -8,6 +8,7 @@
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"dev": "tsup --watch", "dev": "tsup --watch",
"dev-static": "tsup --no-watch",
"clean": "rimraf dist", "clean": "rimraf dist",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },

1
web-app/error.html Normal file
View File

@ -0,0 +1 @@
<h1>error</h1>

1
web-app/index.html Normal file
View File

@ -0,0 +1 @@
<h1>test1</h1>