From cfa4be626de19a2c3d0199d8ee50b726e5a6edb7 Mon Sep 17 00:00:00 2001 From: longdayi <13477510+longdayilongdayi@user.noreply.gitee.com> Date: Tue, 31 Dec 2024 15:57:32 +0800 Subject: [PATCH] 12311557 --- apps/server/.env.example | 2 +- apps/server/src/auth/auth.router.ts | 2 +- apps/server/src/models/base/base.service.ts | 27 +- apps/server/src/models/base/base.type.ts | 4 +- .../src/models/base/row-model.service.ts | 32 +- .../server/src/models/course/course.module.ts | 10 + .../server/src/models/course/course.router.ts | 86 ++++ .../src/models/course/course.service.ts | 14 + .../models/enrollment/enrollment.module.ts | 9 + .../models/enrollment/enrollment.router.ts | 70 +++ .../models/enrollment/enrollment.service.ts | 17 + .../src/models/lecture/lecture.module.ts | 10 + .../src/models/lecture/lecture.router.ts | 70 +++ .../src/models/lecture/lecture.service.ts | 17 + .../src/models/message/message.controller.ts | 4 +- .../src/models/message/message.router.ts | 2 +- .../src/models/message/message.service.ts | 4 +- apps/server/src/models/message/utils.ts | 2 +- .../server/src/models/post/post.controller.ts | 80 +--- apps/server/src/models/post/post.service.ts | 11 +- apps/server/src/models/post/utils.ts | 6 +- apps/server/src/models/rbac/role.router.ts | 108 +++-- .../src/models/rbac/role.row.service.ts | 47 ++ apps/server/src/models/rbac/role.service.ts | 182 +------ apps/server/src/models/rbac/rolemap.router.ts | 1 - .../server/src/models/rbac/rolemap.service.ts | 3 +- .../src/models/resource/resource.module.ts | 9 + .../src/models/resource/resource.router.ts | 70 +++ .../src/models/resource/resource.service.ts | 17 + .../src/models/section/section.module.ts | 10 + .../src/models/section/section.router.ts | 70 +++ .../src/models/section/section.service.ts | 17 + .../src/models/transform/transform.service.ts | 7 - apps/server/src/models/visit/visit.router.ts | 4 +- apps/server/src/models/visit/visit.service.ts | 30 +- .../src/queue/push/push.queue.service.ts | 11 +- apps/server/src/queue/push/push.service.ts | 8 +- .../src/socket/realtime/realtime.server.ts | 8 +- apps/server/src/tasks/init/gendev.service.ts | 25 +- apps/server/src/tasks/init/utils.ts | 72 +-- apps/server/src/trpc/trpc.module.ts | 6 + apps/server/src/trpc/trpc.router.ts | 11 +- apps/server/src/utils/event-bus.ts | 2 +- apps/web/index.html | 2 +- apps/web/package.json | 25 +- apps/web/src/App.tsx | 42 +- apps/web/src/app/admin/base-setting/page.tsx | 3 +- apps/web/src/app/{ => admin}/layout.tsx | 0 apps/web/src/app/main/course/editor/page.tsx | 5 + apps/web/src/app/main/course/page.tsx | 34 ++ apps/web/src/app/main/courses/page.tsx | 198 ++++++++ apps/web/src/app/main/page.tsx | 3 - .../src/components/layout/main/MainLayout.tsx | 39 ++ .../src/components/layout/main/nav-data.tsx | 41 ++ .../layout/main/notifications-dropdown.tsx | 40 ++ .../layout/main/notifications-panel.tsx | 64 +++ .../src/components/layout/main/search-bar.tsx | 55 +++ .../layout/main/search-dropdown.tsx | 58 +++ .../src/components/layout/main/side-bar.tsx | 44 ++ .../components/layout/main/top-nav-bar.tsx | 50 ++ .../layout/main/usermenu-dropdown.tsx | 68 +++ .../src/components/layout/sidebar-content.tsx | 7 +- .../models/course/card/CourseCard.tsx | 30 ++ .../models/course/card/CourseHeader.tsx | 59 +++ .../models/course/card/CourseStats.tsx | 59 +++ .../models/course/detail/course-detail.tsx | 75 +++ .../course/detail/course-objectives.tsx | 29 ++ .../models/course/detail/course-syllabus.tsx | 78 +++ .../models/course/list/course-list.tsx | 42 ++ .../models/course/manage/CourseBasicForm.tsx | 35 ++ .../models/course/manage/CourseEditor.tsx | 12 + .../course/manage/CourseEditorContext.tsx | 71 +++ .../course/manage/CourseEditorHeader.tsx | 35 ++ .../course/manage/CourseEditorLayout.tsx | 50 ++ .../course/manage/CourseEditorSidebar.tsx | 55 +++ .../models/course/manage/navItems.tsx | 28 ++ .../models/role/role-editor/role-form.tsx | 15 +- .../models/role/role-editor/role-list.tsx | 19 +- .../components/models/role/role-select.tsx | 8 +- .../presentation/EmptyStateIllustration.tsx | 75 +++ .../presentation/container/Card.tsx | 28 ++ .../presentation/form/FormArrayField.tsx | 89 ++++ .../presentation/form/FormError.tsx | 22 + .../presentation/form/FormInput.tsx | 88 ++++ .../presentation/form/FormSelect.tsx | 117 +++++ .../components/presentation/space/Empty.tsx | 25 + .../components/presentation/user/Avatar.tsx | 50 ++ apps/web/src/hooks/useClickOutside.ts | 14 + apps/web/src/routes/index.tsx | 20 +- docker-compose.example.yml | 6 +- packages/client/src/api/hooks/index.ts | 3 +- packages/client/src/api/hooks/useCourse.ts | 49 ++ packages/client/src/api/hooks/useRole.ts | 60 ++- packages/client/src/index.ts | 3 +- packages/client/src/types/index.ts | 5 + packages/common/.env.example | 2 +- packages/common/prisma/schema.prisma | 452 ++++++++---------- packages/common/src/db.ts | 9 +- packages/common/src/enum.ts | 5 + packages/common/src/schema.ts | 10 +- packages/common/src/select.ts | 6 +- packages/common/src/types.ts | 27 +- packages/iconer/src/generated/icon-names.ts | 4 +- pnpm-lock.yaml | 182 ++++++- 104 files changed, 3193 insertions(+), 833 deletions(-) create mode 100644 apps/server/src/models/course/course.module.ts create mode 100644 apps/server/src/models/course/course.router.ts create mode 100644 apps/server/src/models/course/course.service.ts create mode 100644 apps/server/src/models/enrollment/enrollment.module.ts create mode 100644 apps/server/src/models/enrollment/enrollment.router.ts create mode 100644 apps/server/src/models/enrollment/enrollment.service.ts create mode 100644 apps/server/src/models/lecture/lecture.module.ts create mode 100644 apps/server/src/models/lecture/lecture.router.ts create mode 100644 apps/server/src/models/lecture/lecture.service.ts create mode 100644 apps/server/src/models/rbac/role.row.service.ts create mode 100644 apps/server/src/models/resource/resource.module.ts create mode 100644 apps/server/src/models/resource/resource.router.ts create mode 100644 apps/server/src/models/resource/resource.service.ts create mode 100644 apps/server/src/models/section/section.module.ts create mode 100644 apps/server/src/models/section/section.router.ts create mode 100644 apps/server/src/models/section/section.service.ts rename apps/web/src/app/{ => admin}/layout.tsx (100%) create mode 100644 apps/web/src/app/main/course/editor/page.tsx create mode 100644 apps/web/src/app/main/course/page.tsx create mode 100644 apps/web/src/app/main/courses/page.tsx delete mode 100755 apps/web/src/app/main/page.tsx create mode 100644 apps/web/src/components/layout/main/MainLayout.tsx create mode 100644 apps/web/src/components/layout/main/nav-data.tsx create mode 100644 apps/web/src/components/layout/main/notifications-dropdown.tsx create mode 100644 apps/web/src/components/layout/main/notifications-panel.tsx create mode 100644 apps/web/src/components/layout/main/search-bar.tsx create mode 100644 apps/web/src/components/layout/main/search-dropdown.tsx create mode 100644 apps/web/src/components/layout/main/side-bar.tsx create mode 100644 apps/web/src/components/layout/main/top-nav-bar.tsx create mode 100644 apps/web/src/components/layout/main/usermenu-dropdown.tsx create mode 100644 apps/web/src/components/models/course/card/CourseCard.tsx create mode 100644 apps/web/src/components/models/course/card/CourseHeader.tsx create mode 100644 apps/web/src/components/models/course/card/CourseStats.tsx create mode 100644 apps/web/src/components/models/course/detail/course-detail.tsx create mode 100644 apps/web/src/components/models/course/detail/course-objectives.tsx create mode 100644 apps/web/src/components/models/course/detail/course-syllabus.tsx create mode 100644 apps/web/src/components/models/course/list/course-list.tsx create mode 100644 apps/web/src/components/models/course/manage/CourseBasicForm.tsx create mode 100644 apps/web/src/components/models/course/manage/CourseEditor.tsx create mode 100644 apps/web/src/components/models/course/manage/CourseEditorContext.tsx create mode 100644 apps/web/src/components/models/course/manage/CourseEditorHeader.tsx create mode 100644 apps/web/src/components/models/course/manage/CourseEditorLayout.tsx create mode 100644 apps/web/src/components/models/course/manage/CourseEditorSidebar.tsx create mode 100644 apps/web/src/components/models/course/manage/navItems.tsx create mode 100644 apps/web/src/components/presentation/EmptyStateIllustration.tsx create mode 100644 apps/web/src/components/presentation/container/Card.tsx create mode 100644 apps/web/src/components/presentation/form/FormArrayField.tsx create mode 100644 apps/web/src/components/presentation/form/FormError.tsx create mode 100644 apps/web/src/components/presentation/form/FormInput.tsx create mode 100644 apps/web/src/components/presentation/form/FormSelect.tsx create mode 100644 apps/web/src/components/presentation/space/Empty.tsx create mode 100644 apps/web/src/components/presentation/user/Avatar.tsx create mode 100644 apps/web/src/hooks/useClickOutside.ts create mode 100644 packages/client/src/api/hooks/useCourse.ts create mode 100644 packages/client/src/types/index.ts diff --git a/apps/server/.env.example b/apps/server/.env.example index 7f3d381..d3c6f3b 100755 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -1,4 +1,4 @@ -DATABASE_URL="postgresql://root:Letusdoit000@localhost:5432/defender_app?schema=public" +DATABASE_URL="postgresql://root:Letusdoit000@localhost:5432/app?schema=public" REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD=Letusdoit000 diff --git a/apps/server/src/auth/auth.router.ts b/apps/server/src/auth/auth.router.ts index 1392d41..790cde9 100755 --- a/apps/server/src/auth/auth.router.ts +++ b/apps/server/src/auth/auth.router.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { TrpcService } from '@server/trpc/trpc.service'; -import { AuthSchema, ObjectModelMethodSchema } from '@nicestack/common'; +import { AuthSchema } from '@nicestack/common'; import { AuthService } from './auth.service'; @Injectable() diff --git a/apps/server/src/models/base/base.service.ts b/apps/server/src/models/base/base.service.ts index 457c91c..ba45d42 100644 --- a/apps/server/src/models/base/base.service.ts +++ b/apps/server/src/models/base/base.service.ts @@ -8,6 +8,7 @@ import { DelegateFuncs, UpdateOrderArgs, TransactionType, + SelectArgs, } from './base.type'; import { NotFoundException, @@ -153,7 +154,7 @@ export class BaseService< async create(args: A['create'], params?: any): Promise { try { - + if (this.enableOrder && !(args as any).data.order) { // 查找当前最大的 order 值 const maxOrderItem = await this.getModel(params?.tx).findFirst({ @@ -396,7 +397,6 @@ export class BaseService< }) as Promise; } catch (error) { this.handleError(error, 'delete'); - throw error; // Re-throw the error to be handled higher up } } /** @@ -433,16 +433,19 @@ export class BaseService< * const users = await service.findManyWithPagination({ page: 1, pageSize: 10, where: { isActive: true } }); */ async findManyWithPagination(args: { - page: number; - pageSize: number; - where?: WhereArgs; + page?: number; + pageSize?: number; + where?: WhereArgs; + select?: SelectArgs }): Promise { - const { page, pageSize, where } = args; + const { page = 1, pageSize = 10, where, select } = args; try { return this.getModel().findMany({ where, + select, skip: (page - 1) * pageSize, take: pageSize, + } as any) as Promise; } catch (error) { this.handleError(error, 'read'); @@ -456,21 +459,11 @@ export class BaseService< */ async findManyWithCursor( args: A['findMany'], - ): Promise<{ items: R['findMany']; nextCursor: string }> { + ): Promise<{ items: R['findMany']; nextCursor: string | null }> { // 解构查询参数,设置默认每页取10条记录 const { cursor, take = 6, where, orderBy, select } = args as any; try { - - /** - * 执行查询 - * @description 查询条件包含: - * 1. where - 过滤条件 - * 2. orderBy - 排序规则,除了传入的排序外,还加入updatedAt和id的降序作为稳定排序 - * 3. select - 选择返回的字段 - * 4. take - 实际取n+1条记录,用于判断是否还有下一页 - * 5. cursor - 游标定位,基于updatedAt和id的组合 - */ const items = (await this.getModel().findMany({ where: where, orderBy: [{ ...orderBy }, { updatedAt: 'desc' }, { id: 'desc' }], diff --git a/apps/server/src/models/base/base.type.ts b/apps/server/src/models/base/base.type.ts index 8a98d1d..5ac6b8a 100644 --- a/apps/server/src/models/base/base.type.ts +++ b/apps/server/src/models/base/base.type.ts @@ -22,8 +22,8 @@ export type DelegateReturnTypes = { [K in keyof T]: T[K] extends (args: any) => Promise ? R : never; }; -export type WhereArgs = T extends { where: infer W } ? W : never; -export type SelectArgs = T extends { select: infer S } ? S : never; +export type WhereArgs = T extends { where?: infer W } ? W : never; +export type SelectArgs = T extends { select?: infer S } ? S : never; export type DataArgs = T extends { data: infer D } ? D : never; export type IncludeArgs = T extends { include: infer I } ? I : never; export type OrderByArgs = T extends { orderBy: infer O } ? O : never; diff --git a/apps/server/src/models/base/row-model.service.ts b/apps/server/src/models/base/row-model.service.ts index 1148209..6afd12a 100644 --- a/apps/server/src/models/base/row-model.service.ts +++ b/apps/server/src/models/base/row-model.service.ts @@ -1,5 +1,5 @@ import { Logger } from "@nestjs/common"; -import { UserProfile, db, getUniqueItems, ObjectWithId, Prisma, RowModelRequest } from "@nicestack/common"; +import { UserProfile, db, RowModelRequest } from "@nicestack/common"; import { LogicalCondition, OperatorType, SQLBuilder } from './sql-builder'; export interface GetRowOptions { id?: string; @@ -14,19 +14,21 @@ export abstract class RowModelService { ]); protected logger = new Logger(this.tableName); protected constructor(protected tableName: string) { } - protected async getRowDto(row: ObjectWithId, staff?: UserProfile): Promise { + protected async getRowDto(row: any, staff?: UserProfile): Promise { return row; } protected async getRowsSqlWrapper(sql: string, request?: RowModelRequest, staff?: UserProfile) { - return SQLBuilder.join([sql, this.getLimitSql(request)]) + if (request) + return SQLBuilder.join([sql, this.getLimitSql(request)]) + return sql } protected getLimitSql(request: RowModelRequest) { return SQLBuilder.limit(request.endRow - request.startRow, request.startRow) } abstract createJoinSql(request?: RowModelRequest): string[]; - async getRows(request: RowModelRequest, staff?: UserProfile): Promise<{ rowCount: number, rowData: any[] }> { + async getRows(request: RowModelRequest, staff?: UserProfile) { try { - // this.logger.debug('request', request) + let SQL = SQLBuilder.join([ SQLBuilder.select(this.getRowSelectCols(request)), SQLBuilder.from(this.tableName), @@ -39,17 +41,13 @@ export abstract class RowModelService { this.logger.debug('getrows', SQL) - const results: any[] = await db.$queryRawUnsafe(SQL) || []; + const results: any[] = await db?.$queryRawUnsafe(SQL) || []; let rowDataDto = await Promise.all(results.map(row => this.getRowDto(row, staff))) - - // if (this.getGroupByColumns(request).length === 0) - // rowDataDto = getUniqueItems(rowDataDto, "id") - // this.logger.debug('result', results.length, this.getRowCount(request, rowDataDto)) return { rowCount: this.getRowCount(request, rowDataDto) || 0, rowData: rowDataDto }; } catch (error: any) { this.logger.error('Error executing getRows:', error); - // throw new Error(`Failed to get rows: ${error.message}`); + } } getRowCount(request: RowModelRequest, results: any[]) { @@ -79,7 +77,7 @@ export abstract class RowModelService { } protected createGetRowsFilters(request: RowModelRequest, staff?: UserProfile): LogicalCondition { - let groupConditions = [] + let groupConditions: LogicalCondition[] = [] if (this.isDoingTreeGroup(request)) { groupConditions = [ { @@ -89,7 +87,7 @@ export abstract class RowModelService { } ] } else { - groupConditions = request.groupKeys.map((key, index) => ({ + groupConditions = request?.groupKeys?.map((key, index) => ({ field: request.rowGroupCols[index].field, op: "equals" as OperatorType, value: key @@ -124,9 +122,9 @@ export abstract class RowModelService { } protected createGroupingRowSelect(request: RowModelRequest, wrapperSql: boolean = false): string[] { const { rowGroupCols, valueCols, groupKeys } = request; - const colsToSelect = []; + const colsToSelect: string[] = []; - const rowGroupCol = rowGroupCols[groupKeys.length]; + const rowGroupCol = rowGroupCols[groupKeys!.length]; if (rowGroupCol) { colsToSelect.push(`${rowGroupCol.field} AS ${rowGroupCol.field.replace('.', '_')}`); } @@ -139,7 +137,7 @@ export abstract class RowModelService { getGroupByColumns(request: RowModelRequest): string[] { return this.isDoingGroup(request) - ? [request.rowGroupCols[request.groupKeys.length]?.field] + ? [request.rowGroupCols[request.groupKeys!.length]?.field] : []; } @@ -206,7 +204,7 @@ export abstract class RowModelService { SQLBuilder.where(this.createGetRowsFilters(request)), SQLBuilder.groupBy(this.buildAggGroupBy()) ]); - const result = await db.$queryRawUnsafe(SQL); + const result: any[] = await db.$queryRawUnsafe(SQL); return result[0]; } catch (error) { this.logger.error('Error executing query:', error); diff --git a/apps/server/src/models/course/course.module.ts b/apps/server/src/models/course/course.module.ts new file mode 100644 index 0000000..c776075 --- /dev/null +++ b/apps/server/src/models/course/course.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CourseRouter } from './course.router'; +import { CourseService } from './course.service'; +import { TrpcService } from '@server/trpc/trpc.service'; + +@Module({ + providers: [CourseRouter, CourseService, TrpcService], + exports: [CourseRouter, CourseService] +}) +export class CourseModule { } diff --git a/apps/server/src/models/course/course.router.ts b/apps/server/src/models/course/course.router.ts new file mode 100644 index 0000000..758d8a8 --- /dev/null +++ b/apps/server/src/models/course/course.router.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { Prisma, UpdateOrderSchema } from '@nicestack/common'; +import { CourseService } from './course.service'; +import { z, ZodType } from 'zod'; +const CourseCreateArgsSchema: ZodType = z.any() +const CourseUpdateArgsSchema: ZodType = z.any() +const CourseCreateManyInputSchema: ZodType = z.any() +const CourseDeleteManyArgsSchema: ZodType = z.any() +const CourseFindManyArgsSchema: ZodType = z.any() +const CourseFindFirstArgsSchema: ZodType = z.any() +const CourseWhereInputSchema: ZodType = z.any() +const CourseSelectSchema: ZodType = z.any() + +@Injectable() +export class CourseRouter { + constructor( + private readonly trpc: TrpcService, + private readonly courseService: CourseService, + ) { } + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(CourseCreateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.courseService.create(input, staff); + }), + update: this.trpc.protectProcedure + .input(CourseUpdateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.courseService.update(input, staff); + }), + createMany: this.trpc.protectProcedure.input(z.array(CourseCreateManyInputSchema)) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + + return await this.courseService.createMany({ data: input }, staff); + }), + deleteMany: this.trpc.procedure + .input(CourseDeleteManyArgsSchema) + .mutation(async ({ input }) => { + return await this.courseService.deleteMany(input); + }), + findFirst: this.trpc.procedure + .input(CourseFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.courseService.findFirst(input); + }), + softDeleteByIds: this.trpc.protectProcedure + .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema + .mutation(async ({ input }) => { + return this.courseService.softDeleteByIds(input.ids); + }), + updateOrder: this.trpc.protectProcedure + .input(UpdateOrderSchema) + .mutation(async ({ input }) => { + return this.courseService.updateOrder(input); + }), + findMany: this.trpc.procedure + .input(CourseFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.courseService.findMany(input); + }), + findManyWithCursor: this.trpc.protectProcedure + .input(z.object({ + cursor: z.any().nullish(), + take: z.number().optional(), + where: CourseWhereInputSchema.optional(), + select: CourseSelectSchema.optional() + })) + .query(async ({ ctx, input }) => { + return await this.courseService.findManyWithCursor(input); + }), + findManyWithPagination: this.trpc.procedure + .input(z.object({ + page: z.number(), + pageSize: z.number().optional(), + where: CourseWhereInputSchema.optional(), + select: CourseSelectSchema.optional() + })) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.courseService.findManyWithPagination(input); + }), + }); +} diff --git a/apps/server/src/models/course/course.service.ts b/apps/server/src/models/course/course.service.ts new file mode 100644 index 0000000..9374a7b --- /dev/null +++ b/apps/server/src/models/course/course.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { BaseService } from '../base/base.service'; +import { + UserProfile, + db, + ObjectType, + Prisma, +} from '@nicestack/common'; +@Injectable() +export class CourseService extends BaseService { + constructor() { + super(db, ObjectType.COURSE); + } +} diff --git a/apps/server/src/models/enrollment/enrollment.module.ts b/apps/server/src/models/enrollment/enrollment.module.ts new file mode 100644 index 0000000..71a7e35 --- /dev/null +++ b/apps/server/src/models/enrollment/enrollment.module.ts @@ -0,0 +1,9 @@ +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 { } diff --git a/apps/server/src/models/enrollment/enrollment.router.ts b/apps/server/src/models/enrollment/enrollment.router.ts new file mode 100644 index 0000000..0b8ad1d --- /dev/null +++ b/apps/server/src/models/enrollment/enrollment.router.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { Prisma, UpdateOrderSchema } from '@nicestack/common'; +import { EnrollmentService } from './enrollment.service'; +import { z, ZodType } from 'zod'; +const EnrollmentCreateArgsSchema: ZodType = z.any() +const EnrollmentCreateManyInputSchema: ZodType = z.any() +const EnrollmentDeleteManyArgsSchema: ZodType = z.any() +const EnrollmentFindManyArgsSchema: ZodType = z.any() +const EnrollmentFindFirstArgsSchema: ZodType = z.any() +const EnrollmentWhereInputSchema: ZodType = z.any() +const EnrollmentSelectSchema: ZodType = z.any() + +@Injectable() +export class EnrollmentRouter { + constructor( + private readonly trpc: TrpcService, + private readonly enrollmentService: EnrollmentService, + ) { } + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(EnrollmentCreateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.enrollmentService.create(input, staff); + }), + createMany: this.trpc.protectProcedure.input(z.array(EnrollmentCreateManyInputSchema)) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + + return await this.enrollmentService.createMany({ data: input }, staff); + }), + deleteMany: this.trpc.procedure + .input(EnrollmentDeleteManyArgsSchema) + .mutation(async ({ input }) => { + return await this.enrollmentService.deleteMany(input); + }), + 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); + }), + softDeleteByIds: this.trpc.protectProcedure + .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema + .mutation(async ({ input }) => { + return this.enrollmentService.softDeleteByIds(input.ids); + }), + updateOrder: this.trpc.protectProcedure + .input(UpdateOrderSchema) + .mutation(async ({ input }) => { + return this.enrollmentService.updateOrder(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); + }), + }); +} diff --git a/apps/server/src/models/enrollment/enrollment.service.ts b/apps/server/src/models/enrollment/enrollment.service.ts new file mode 100644 index 0000000..09e7326 --- /dev/null +++ b/apps/server/src/models/enrollment/enrollment.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { BaseService } from '../base/base.service'; +import { + UserProfile, + db, + ObjectType, + Prisma, + +} from '@nicestack/common'; +@Injectable() +export class EnrollmentService extends BaseService { + constructor() { + super(db, ObjectType.COURSE); + } + + +} diff --git a/apps/server/src/models/lecture/lecture.module.ts b/apps/server/src/models/lecture/lecture.module.ts new file mode 100644 index 0000000..a7d7561 --- /dev/null +++ b/apps/server/src/models/lecture/lecture.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { LectureRouter } from './lecture.router'; +import { LectureService } from './lecture.service'; +import { TrpcService } from '@server/trpc/trpc.service'; + +@Module({ + providers: [LectureRouter, LectureService, TrpcService], + exports: [LectureRouter, LectureService] +}) +export class LectureModule { } diff --git a/apps/server/src/models/lecture/lecture.router.ts b/apps/server/src/models/lecture/lecture.router.ts new file mode 100644 index 0000000..f99b70c --- /dev/null +++ b/apps/server/src/models/lecture/lecture.router.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { Prisma, UpdateOrderSchema } from '@nicestack/common'; +import { LectureService } from './lecture.service'; +import { z, ZodType } from 'zod'; +const LectureCreateArgsSchema: ZodType = z.any() +const LectureCreateManyInputSchema: ZodType = z.any() +const LectureDeleteManyArgsSchema: ZodType = z.any() +const LectureFindManyArgsSchema: ZodType = z.any() +const LectureFindFirstArgsSchema: ZodType = z.any() +const LectureWhereInputSchema: ZodType = z.any() +const LectureSelectSchema: ZodType = z.any() + +@Injectable() +export class LectureRouter { + constructor( + private readonly trpc: TrpcService, + private readonly lectureService: LectureService, + ) { } + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(LectureCreateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.lectureService.create(input, staff); + }), + createMany: this.trpc.protectProcedure.input(z.array(LectureCreateManyInputSchema)) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + + return await this.lectureService.createMany({ data: input }, staff); + }), + deleteMany: this.trpc.procedure + .input(LectureDeleteManyArgsSchema) + .mutation(async ({ input }) => { + return await this.lectureService.deleteMany(input); + }), + findFirst: this.trpc.procedure + .input(LectureFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.lectureService.findFirst(input); + }), + softDeleteByIds: this.trpc.protectProcedure + .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema + .mutation(async ({ input }) => { + return this.lectureService.softDeleteByIds(input.ids); + }), + updateOrder: this.trpc.protectProcedure + .input(UpdateOrderSchema) + .mutation(async ({ input }) => { + return this.lectureService.updateOrder(input); + }), + findMany: this.trpc.procedure + .input(LectureFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.lectureService.findMany(input); + }), + findManyWithCursor: this.trpc.protectProcedure + .input(z.object({ + cursor: z.any().nullish(), + take: z.number().nullish(), + where: LectureWhereInputSchema.nullish(), + select: LectureSelectSchema.nullish() + })) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.lectureService.findManyWithCursor(input); + }), + }); +} diff --git a/apps/server/src/models/lecture/lecture.service.ts b/apps/server/src/models/lecture/lecture.service.ts new file mode 100644 index 0000000..2f1efa4 --- /dev/null +++ b/apps/server/src/models/lecture/lecture.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { BaseService } from '../base/base.service'; +import { + UserProfile, + db, + ObjectType, + Prisma, + +} from '@nicestack/common'; +@Injectable() +export class LectureService extends BaseService { + constructor() { + super(db, ObjectType.COURSE); + } + + +} diff --git a/apps/server/src/models/message/message.controller.ts b/apps/server/src/models/message/message.controller.ts index e86a115..b6638dc 100755 --- a/apps/server/src/models/message/message.controller.ts +++ b/apps/server/src/models/message/message.controller.ts @@ -53,7 +53,7 @@ export class MessageController { visits: { none: { id: staffId, - visitType: VisitType.READED + type: VisitType.READED }, }, receivers: { @@ -92,7 +92,7 @@ export class MessageController { visits: { none: { id: staffId, - visitType: VisitType.READED + type: VisitType.READED }, }, receivers: { diff --git a/apps/server/src/models/message/message.router.ts b/apps/server/src/models/message/message.router.ts index 1cb12a1..188ef26 100755 --- a/apps/server/src/models/message/message.router.ts +++ b/apps/server/src/models/message/message.router.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { TrpcService } from '@server/trpc/trpc.service'; import { MessageService } from './message.service'; -import { ChangedRows, Prisma } from '@nicestack/common'; +import { Prisma } from '@nicestack/common'; import { z, ZodType } from 'zod'; const MessageUncheckedCreateInputSchema: ZodType = z.any() const MessageWhereInputSchema: ZodType = z.any() diff --git a/apps/server/src/models/message/message.service.ts b/apps/server/src/models/message/message.service.ts index 68b640a..f6b9525 100644 --- a/apps/server/src/models/message/message.service.ts +++ b/apps/server/src/models/message/message.service.ts @@ -9,7 +9,7 @@ export class MessageService extends BaseService { super(db, ObjectType.MESSAGE); } async create(args: Prisma.MessageCreateArgs, params?: { tx?: Prisma.MessageDelegate, staff?: UserProfile }) { - args.data.senderId = params?.staff?.id; + args.data!.senderId = params?.staff?.id; args.include = { receivers: { select: { id: true, registerToken: true, username: true } @@ -46,7 +46,7 @@ export class MessageService extends BaseService { visits: { none: { visitorId: staff?.id, - visitType: VisitType.READED + type: VisitType.READED } } } diff --git a/apps/server/src/models/message/utils.ts b/apps/server/src/models/message/utils.ts index 8ebbf40..40379ff 100644 --- a/apps/server/src/models/message/utils.ts +++ b/apps/server/src/models/message/utils.ts @@ -8,7 +8,7 @@ export async function setMessageRelation( (await db.visit.count({ where: { messageId: data.id, - visitType: VisitType.READED, + type: VisitType.READED, visitorId: staff?.id, }, })) > 0; diff --git a/apps/server/src/models/post/post.controller.ts b/apps/server/src/models/post/post.controller.ts index 86b21fb..f9d5076 100755 --- a/apps/server/src/models/post/post.controller.ts +++ b/apps/server/src/models/post/post.controller.ts @@ -7,83 +7,5 @@ import { db } from '@nicestack/common'; @Controller('post') export class PostController { constructor(private readonly postService: PostService) { } - @UseGuards(AuthGuard) - @Get('find-last-one') - async findLastOne(@Query('trouble-id') troubleId: string) { - try { - const result = await this.postService.findFirst({ - where: { referenceId: troubleId }, - orderBy: { createdAt: 'desc' } - }); - return { - data: result, - errmsg: 'success', - errno: 0, - }; - } catch (e) { - return { - data: {}, - errmsg: (e as any)?.message || 'error', - errno: 1, - }; - } - } - @UseGuards(AuthGuard) - @Get('find-all') - async findAll(@Query('trouble-id') troubleId: string) { - try { - const result = await db.post.findMany({ - where: { - OR: [{ referenceId: troubleId }], - }, - orderBy: { createdAt: 'desc' }, - select: { - title: true, - content: true, - attachments: true, - type: true, - author: { - select: { - id: true, - showname: true, - username: true, - }, - }, - }, - }); - return { - data: result, - errmsg: 'success', - errno: 0, - }; - } catch (e) { - return { - data: {}, - errmsg: (e as any)?.message || 'error', - errno: 1, - }; - } - } - @UseGuards(AuthGuard) - @Get('count') - async count(@Query('trouble-id') troubleId: string) { - try { - const result = await db.post.count({ - where: { - OR: [{ referenceId: troubleId }], - }, - }); - return { - data: result, - errmsg: 'success', - errno: 0, - }; - } catch (e) { - return { - data: {}, - errmsg: (e as any)?.message || 'error', - errno: 1, - }; - } - } + } diff --git a/apps/server/src/models/post/post.service.ts b/apps/server/src/models/post/post.service.ts index 87ec5d9..9abdf1a 100755 --- a/apps/server/src/models/post/post.service.ts +++ b/apps/server/src/models/post/post.service.ts @@ -28,7 +28,7 @@ export class PostService extends BaseService { args: Prisma.PostCreateArgs, params: { staff?: UserProfile; tx?: Prisma.PostDelegate }, ) { - args.data.authorId = params?.staff.id; + args.data.authorId = params?.staff?.id; const result = await super.create(args); EventBus.emit('dataChanged', { type: ObjectType.POST, @@ -38,12 +38,12 @@ export class PostService extends BaseService { return result; } async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) { - args.data.authorId = staff.id; + args.data.authorId = staff?.id; return super.update(args); } async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) { + if (!args.where) args.where = {} args.where.OR = await this.preFilter(args.where.OR, staff); - // console.log(`findwithcursor_post ${JSON.stringify(args.where)}`) return this.wrapResult(super.findManyWithCursor(args), async (result) => { let { items } = result; @@ -57,7 +57,7 @@ export class PostService extends BaseService { }); } - protected async setPerms(data: Post, staff: UserProfile) { + protected async setPerms(data: Post, staff?: UserProfile) { if (!staff) return; const perms: ResPerm = { delete: false, @@ -93,7 +93,8 @@ export class PostService extends BaseService { const outOR = OR ? [...OR, ...preFilter].filter(Boolean) : preFilter; return outOR?.length > 0 ? outOR : undefined; } - async getPostPreFilter(staff: UserProfile) { + async getPostPreFilter(staff?: UserProfile) { + if (!staff) return const { deptId, domainId } = staff; if ( staff.permissions.includes(RolePerms.READ_ANY_POST) || diff --git a/apps/server/src/models/post/utils.ts b/apps/server/src/models/post/utils.ts index 35211cd..63a5776 100644 --- a/apps/server/src/models/post/utils.ts +++ b/apps/server/src/models/post/utils.ts @@ -22,17 +22,17 @@ export async function setPostRelation(params: { data: Post, staff?: UserProfile (await db.visit.count({ where: { postId: data.id, - visitType: VisitType.READED, + type: VisitType.READED, visitorId: staff?.id, }, })) > 0; const readedCount = await db.visit.count({ where: { postId: data.id, - visitType: VisitType.READED, + type: VisitType.READED, }, }); - // const trouble = await getTroubleWithRelation(data.referenceId, staff) + Object.assign(data, { readed, readedCount, diff --git a/apps/server/src/models/rbac/role.router.ts b/apps/server/src/models/rbac/role.router.ts index a31316e..b52308a 100755 --- a/apps/server/src/models/rbac/role.router.ts +++ b/apps/server/src/models/rbac/role.router.ts @@ -1,44 +1,88 @@ -import { Injectable } from "@nestjs/common"; -import { TrpcService } from "@server/trpc/trpc.service"; -import { RoleService } from "./role.service"; -import { RoleMethodSchema } from "@nicestack/common"; -import { z } from "zod"; - +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { Prisma, UpdateOrderSchema } from '@nicestack/common'; +import { RoleService } from './role.service'; +import { z, ZodType } from 'zod'; +const RoleCreateArgsSchema: ZodType = z.any() +const RoleUpdateArgsSchema: ZodType = z.any() +const RoleCreateManyInputSchema: ZodType = z.any() +const RoleDeleteManyArgsSchema: ZodType = z.any() +const RoleFindManyArgsSchema: ZodType = z.any() +const RoleFindFirstArgsSchema: ZodType = z.any() +const RoleWhereInputSchema: ZodType = z.any() +const RoleSelectSchema: ZodType = z.any() +const RoleUpdateInputSchema: ZodType = z.any(); @Injectable() export class RoleRouter { constructor( private readonly trpc: TrpcService, - private readonly roleService: RoleService + private readonly roleService: RoleService, ) { } - router = this.trpc.router({ - create: this.trpc.protectProcedure.input(RoleMethodSchema.create).mutation(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.roleService.create(input); - }), - deleteMany: this.trpc.protectProcedure.input(RoleMethodSchema.deleteMany).mutation(async ({ input }) => { - return await this.roleService.deleteMany(input); - }), - update: this.trpc.protectProcedure.input(RoleMethodSchema.update).mutation(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.roleService.update(input); - }), - paginate: this.trpc.protectProcedure.input(RoleMethodSchema.paginate).query(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.roleService.paginate(input); - }), - findById: this.trpc.protectProcedure - .input(z.object({ id: z.string().nullish() })) - .query(async ({ ctx, input }) => { + create: this.trpc.protectProcedure + .input(RoleCreateArgsSchema) + .mutation(async ({ ctx, input }) => { const { staff } = ctx; - return await this.roleService.findById(input.id); + return await this.roleService.create(input, staff); + }), + update: this.trpc.protectProcedure + .input(RoleUpdateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.roleService.update(input, staff); + }), + createMany: this.trpc.protectProcedure.input(z.array(RoleCreateManyInputSchema)) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + + return await this.roleService.createMany({ data: input }, staff); + }), + softDeleteByIds: this.trpc.protectProcedure + .input( + z.object({ + ids: z.array(z.string()), + data: RoleUpdateInputSchema.optional() + }), + ) + .mutation(async ({ input }) => { + return await this.roleService.softDeleteByIds(input.ids, input.data); + }), + findFirst: this.trpc.procedure + .input(RoleFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.roleService.findFirst(input); + }), + + updateOrder: this.trpc.protectProcedure + .input(UpdateOrderSchema) + .mutation(async ({ input }) => { + return this.roleService.updateOrder(input); }), findMany: this.trpc.procedure - .input(RoleMethodSchema.findMany) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .input(RoleFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword .query(async ({ input }) => { return await this.roleService.findMany(input); - }) - } - ) + }), + findManyWithCursor: this.trpc.protectProcedure + .input(z.object({ + cursor: z.any().nullish(), + take: z.number().optional(), + where: RoleWhereInputSchema.optional(), + select: RoleSelectSchema.optional() + })) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.roleService.findManyWithCursor(input); + }), + findManyWithPagination: this.trpc.procedure + .input(z.object({ + page: z.number(), + pageSize: z.number().optional(), + where: RoleWhereInputSchema.optional(), + select: RoleSelectSchema.optional() + })) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.roleService.findManyWithPagination(input); + }), + }); } - diff --git a/apps/server/src/models/rbac/role.row.service.ts b/apps/server/src/models/rbac/role.row.service.ts new file mode 100644 index 0000000..7a034eb --- /dev/null +++ b/apps/server/src/models/rbac/role.row.service.ts @@ -0,0 +1,47 @@ +import { db, ObjectType, RowModelRequest, RowRequestSchema, UserProfile } from "@nicestack/common"; +import { RowCacheService } from "../base/row-cache.service"; +import { isFieldCondition, LogicalCondition } from "../base/sql-builder"; +import { z } from "zod"; +export class RoleRowService extends RowCacheService { + protected createGetRowsFilters( + request: z.infer, + staff?: UserProfile + ) { + const condition = super.createGetRowsFilters(request) + if (isFieldCondition(condition)) + return {} + const baseModelCondition: LogicalCondition[] = [{ + field: `${this.tableName}.deleted_at`, + op: "blank", + type: "date" + }] + condition.AND = [...baseModelCondition, ...condition.AND!] + return condition + } + createUnGroupingRowSelect(): string[] { + return [ + `${this.tableName}.id AS id`, + `${this.tableName}.name AS name`, + `${this.tableName}.system AS system`, + `${this.tableName}.permissions AS permissions` + ]; + } + protected async getRowDto(data: any, staff?: UserProfile): Promise { + if (!data.id) + return data + const roleMaps = await db.roleMap.findMany({ + where: { + roleId: data.id + } + }) + const deptIds = roleMaps.filter(item => item.objectType === ObjectType.DEPARTMENT).map(roleMap => roleMap.objectId) + const staffIds = roleMaps.filter(item => item.objectType === ObjectType.STAFF).map(roleMap => roleMap.objectId) + const depts = await db.department.findMany({ where: { id: { in: deptIds } } }) + const staffs = await db.staff.findMany({ where: { id: { in: staffIds } } }) + const result = { ...data, depts, staffs } + return result + } + createJoinSql(request?: RowModelRequest): string[] { + return []; + } +} \ No newline at end of file diff --git a/apps/server/src/models/rbac/role.service.ts b/apps/server/src/models/rbac/role.service.ts index a44b7d2..cd7d0ce 100755 --- a/apps/server/src/models/rbac/role.service.ts +++ b/apps/server/src/models/rbac/role.service.ts @@ -1,100 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { db, RoleMethodSchema, RowModelRequest, UserProfile, RowRequestSchema, ObjectWithId, ObjectType } from "@nicestack/common"; -import { DepartmentService } from '@server/models/department/department.service'; -import EventBus, { CrudOperation } from '@server/utils/event-bus'; - -import { TRPCError } from '@trpc/server'; -import { RowModelService } from '../base/row-model.service'; -import { isFieldCondition, LogicalCondition } from '../base/sql-builder'; -import { z } from 'zod'; +import { db, RoleMethodSchema, ObjectType, Prisma } from "@nicestack/common"; +import { BaseService } from '../base/base.service'; @Injectable() -export class RoleService extends RowModelService { - protected createGetRowsFilters( - request: z.infer, - staff?: UserProfile - ) { - const condition = super.createGetRowsFilters(request) - if (isFieldCondition(condition)) - return {} - const baseModelCondition: LogicalCondition[] = [{ - field: `${this.tableName}.deleted_at`, - op: "blank", - type: "date" - }] - condition.AND = [...baseModelCondition, ...condition.AND] - return condition - } - createUnGroupingRowSelect(): string[] { - return [ - `${this.tableName}.id AS id`, - `${this.tableName}.name AS name`, - `${this.tableName}.system AS system`, - `${this.tableName}.permissions AS permissions` - ]; - } - protected async getRowDto(data: ObjectWithId, staff?: UserProfile): Promise { - if (!data.id) - return data - const roleMaps = await db.roleMap.findMany({ - where: { - roleId: data.id - } - }) - const deptIds = roleMaps.filter(item => item.objectType === ObjectType.DEPARTMENT).map(roleMap => roleMap.objectId) - const staffIds = roleMaps.filter(item => item.objectType === ObjectType.STAFF).map(roleMap => roleMap.objectId) - const depts = await db.department.findMany({ where: { id: { in: deptIds } } }) - const staffs = await db.staff.findMany({ where: { id: { in: staffIds } } }) - const result = { ...data, depts, staffs } - return result - } - createJoinSql(request?: RowModelRequest): string[] { - return []; - } - constructor( - private readonly departmentService: DepartmentService - ) { - super("role") - } - /** - * 创建角色 - * @param data 包含创建角色所需信息的数据 - * @returns 创建的角色 - */ - async create(data: z.infer) { - const result = await db.role.create({ data }) - EventBus.emit('dataChanged', { - type: ObjectType.ROLE, - operation: CrudOperation.CREATED, - data: result, - }); - return result - } - async findById(id: string) { - return await db.role.findUnique({ - where: { - id - } - }) - } - /** - * 更新角色 - * @param data 包含更新角色所需信息的数据 - * @returns 更新后的角色 - */ - async update(data: z.infer) { - const { id, ...others } = data; - // 开启事务 - const result = await db.role.update({ - where: { id }, - data: { ...others } - }); - - EventBus.emit('dataChanged', { - type: ObjectType.ROLE, - operation: CrudOperation.UPDATED, - data: result, - }); - return result +export class RoleService extends BaseService { + constructor() { + super(db, ObjectType.ROLE) } /** * 批量删除角色 @@ -102,79 +12,15 @@ export class RoleService extends RowModelService { * @returns 删除结果 * @throws 如果未提供ID,将抛出错误 */ - async deleteMany(data: z.infer) { - const { ids } = data; - if (!ids || ids.length === 0) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'No IDs provided for deletion.' - }); - } - // 开启事务 - const result = await db.$transaction(async (prisma) => { - await prisma.roleMap.deleteMany({ - where: { - roleId: { - in: ids - } - } - }); - const deletedRoles = await prisma.role.deleteMany({ - where: { - id: { in: ids } - } - }); - return { success: true, count: deletedRoles.count }; - }); - EventBus.emit('dataChanged', { - type: ObjectType.ROLE, - operation: CrudOperation.DELETED, - data: result, - }); - return result - } - /** - * 分页获取角色 - * @param data 包含分页信息的数据 - * @returns 分页结果,包含角色列表和总数 - */ - async paginate(data: z.infer) { - const { page, pageSize } = data; - const [items, totalCount] = await Promise.all([ - db.role.findMany({ - skip: (page - 1) * pageSize, - take: pageSize, - orderBy: { name: "asc" }, - where: { deletedAt: null }, - include: { - roleMaps: true, - } - }), - db.role.count({ where: { deletedAt: null } }), - ]); - const result = { items, totalCount }; - return result; - } - /** - * 根据关键字查找多个角色 - * @param data 包含关键字的数据 - * @returns 查找到的角色列表 - */ - async findMany(data: z.infer) { - const { keyword = '' } = data - return await db.role.findMany({ + async softDeleteByIds(ids: string[], data?: Prisma.RoleUpdateInput) { + await db.roleMap.deleteMany({ where: { - deletedAt: null, - OR: [ - { - name: { - contains: keyword - } - } - ] - }, - orderBy: { createdAt: "asc" }, - take: 10 - }) + roleId: { + in: ids + } + } + }); + return await super.softDeleteByIds(ids, data) } + } diff --git a/apps/server/src/models/rbac/rolemap.router.ts b/apps/server/src/models/rbac/rolemap.router.ts index d0ecd08..ed862c9 100755 --- a/apps/server/src/models/rbac/rolemap.router.ts +++ b/apps/server/src/models/rbac/rolemap.router.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { TrpcService } from '@server/trpc/trpc.service'; import { - ChangedRows, ObjectType, RoleMapMethodSchema, } from '@nicestack/common'; diff --git a/apps/server/src/models/rbac/rolemap.service.ts b/apps/server/src/models/rbac/rolemap.service.ts index f026b9a..932f851 100755 --- a/apps/server/src/models/rbac/rolemap.service.ts +++ b/apps/server/src/models/rbac/rolemap.service.ts @@ -6,7 +6,6 @@ import { Prisma, RowModelRequest, UserProfile, - ObjectWithId, } from '@nicestack/common'; import { DepartmentService } from '@server/models/department/department.service'; import { TRPCError } from '@trpc/server'; @@ -66,7 +65,7 @@ export class RoleMapService extends RowModelService { } protected async getRowDto( - row: ObjectWithId, + row: any, staff?: UserProfile, ): Promise { if (!row.id) return row; diff --git a/apps/server/src/models/resource/resource.module.ts b/apps/server/src/models/resource/resource.module.ts new file mode 100644 index 0000000..7d0f578 --- /dev/null +++ b/apps/server/src/models/resource/resource.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ResourceRouter } from './resource.router'; +import { ResourceService } from './resource.service'; + +@Module({ + exports: [ResourceRouter, ResourceService], + providers: [ResourceRouter, ResourceService], +}) +export class ResourceModule { } diff --git a/apps/server/src/models/resource/resource.router.ts b/apps/server/src/models/resource/resource.router.ts new file mode 100644 index 0000000..59124d8 --- /dev/null +++ b/apps/server/src/models/resource/resource.router.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { Prisma, UpdateOrderSchema } from '@nicestack/common'; +import { ResourceService } from './resource.service'; +import { z, ZodType } from 'zod'; +const ResourceCreateArgsSchema: ZodType = z.any() +const ResourceCreateManyInputSchema: ZodType = z.any() +const ResourceDeleteManyArgsSchema: ZodType = z.any() +const ResourceFindManyArgsSchema: ZodType = z.any() +const ResourceFindFirstArgsSchema: ZodType = z.any() +const ResourceWhereInputSchema: ZodType = z.any() +const ResourceSelectSchema: ZodType = z.any() + +@Injectable() +export class ResourceRouter { + constructor( + private readonly trpc: TrpcService, + private readonly resourceService: ResourceService, + ) { } + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(ResourceCreateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.resourceService.create(input, staff); + }), + createMany: this.trpc.protectProcedure.input(z.array(ResourceCreateManyInputSchema)) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + + return await this.resourceService.createMany({ data: input }, staff); + }), + deleteMany: this.trpc.procedure + .input(ResourceDeleteManyArgsSchema) + .mutation(async ({ input }) => { + return await this.resourceService.deleteMany(input); + }), + findFirst: this.trpc.procedure + .input(ResourceFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.resourceService.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.resourceService.softDeleteByIds(input.ids); + }), + updateOrder: this.trpc.protectProcedure + .input(UpdateOrderSchema) + .mutation(async ({ input }) => { + return this.resourceService.updateOrder(input); + }), + findMany: this.trpc.procedure + .input(ResourceFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.resourceService.findMany(input); + }), + findManyWithCursor: this.trpc.protectProcedure + .input(z.object({ + cursor: z.any().nullish(), + take: z.number().nullish(), + where: ResourceWhereInputSchema.nullish(), + select: ResourceSelectSchema.nullish() + })) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.resourceService.findManyWithCursor(input); + }), + }); +} diff --git a/apps/server/src/models/resource/resource.service.ts b/apps/server/src/models/resource/resource.service.ts new file mode 100644 index 0000000..4be9da4 --- /dev/null +++ b/apps/server/src/models/resource/resource.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { BaseService } from '../base/base.service'; +import { + UserProfile, + db, + ObjectType, + Prisma, + +} from '@nicestack/common'; +@Injectable() +export class ResourceService extends BaseService { + constructor() { + super(db, ObjectType.RESOURCE); + } + + +} diff --git a/apps/server/src/models/section/section.module.ts b/apps/server/src/models/section/section.module.ts new file mode 100644 index 0000000..0a44529 --- /dev/null +++ b/apps/server/src/models/section/section.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SectionRouter } from './section.router'; +import { SectionService } from './section.service'; +import { TrpcService } from '@server/trpc/trpc.service'; + +@Module({ + exports: [SectionRouter, SectionService], + providers: [SectionRouter, SectionService, TrpcService] +}) +export class SectionModule { } diff --git a/apps/server/src/models/section/section.router.ts b/apps/server/src/models/section/section.router.ts new file mode 100644 index 0000000..bba664b --- /dev/null +++ b/apps/server/src/models/section/section.router.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { Prisma, UpdateOrderSchema } from '@nicestack/common'; +import { SectionService } from './section.service'; +import { z, ZodType } from 'zod'; +const SectionCreateArgsSchema: ZodType = z.any() +const SectionCreateManyInputSchema: ZodType = z.any() +const SectionDeleteManyArgsSchema: ZodType = z.any() +const SectionFindManyArgsSchema: ZodType = z.any() +const SectionFindFirstArgsSchema: ZodType = z.any() +const SectionWhereInputSchema: ZodType = z.any() +const SectionSelectSchema: ZodType = z.any() + +@Injectable() +export class SectionRouter { + constructor( + private readonly trpc: TrpcService, + private readonly sectionService: SectionService, + ) { } + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(SectionCreateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.sectionService.create(input, staff); + }), + createMany: this.trpc.protectProcedure.input(z.array(SectionCreateManyInputSchema)) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + + return await this.sectionService.createMany({ data: input }, staff); + }), + deleteMany: this.trpc.procedure + .input(SectionDeleteManyArgsSchema) + .mutation(async ({ input }) => { + return await this.sectionService.deleteMany(input); + }), + findFirst: this.trpc.procedure + .input(SectionFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.sectionService.findFirst(input); + }), + softDeleteByIds: this.trpc.protectProcedure + .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema + .mutation(async ({ input }) => { + return this.sectionService.softDeleteByIds(input.ids); + }), + updateOrder: this.trpc.protectProcedure + .input(UpdateOrderSchema) + .mutation(async ({ input }) => { + return this.sectionService.updateOrder(input); + }), + findMany: this.trpc.procedure + .input(SectionFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.sectionService.findMany(input); + }), + findManyWithCursor: this.trpc.protectProcedure + .input(z.object({ + cursor: z.any().nullish(), + take: z.number().nullish(), + where: SectionWhereInputSchema.nullish(), + select: SectionSelectSchema.nullish() + })) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.sectionService.findManyWithCursor(input); + }), + }); +} diff --git a/apps/server/src/models/section/section.service.ts b/apps/server/src/models/section/section.service.ts new file mode 100644 index 0000000..15d4498 --- /dev/null +++ b/apps/server/src/models/section/section.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { BaseService } from '../base/base.service'; +import { + UserProfile, + db, + ObjectType, + Prisma, + +} from '@nicestack/common'; +@Injectable() +export class SectionService extends BaseService { + constructor() { + super(db, ObjectType.SECTION); + } + + +} diff --git a/apps/server/src/models/transform/transform.service.ts b/apps/server/src/models/transform/transform.service.ts index b3015ef..9509bdc 100755 --- a/apps/server/src/models/transform/transform.service.ts +++ b/apps/server/src/models/transform/transform.service.ts @@ -1,17 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import * as ExcelJS from 'exceljs'; import { - TroubleType, - TroubleState, TransformMethodSchema, db, Prisma, Staff, - GetTroubleLevel, - UserProfile, - TroubleDto, - ObjectType, - RiskState, } from '@nicestack/common'; import dayjs from 'dayjs'; import * as argon2 from 'argon2'; diff --git a/apps/server/src/models/visit/visit.router.ts b/apps/server/src/models/visit/visit.router.ts index 1a274a0..f3dc2df 100644 --- a/apps/server/src/models/visit/visit.router.ts +++ b/apps/server/src/models/visit/visit.router.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { TrpcService } from '@server/trpc/trpc.service'; -import { ChangedRows, ObjectType, Prisma } from '@nicestack/common'; +import { Prisma } from '@nicestack/common'; import { VisitService } from './visit.service'; import { z, ZodType } from 'zod'; @@ -31,7 +31,7 @@ export class VisitRouter { .mutation(async ({ input }) => { return await this.visitService.deleteMany(input); }), - + }); } diff --git a/apps/server/src/models/visit/visit.service.ts b/apps/server/src/models/visit/visit.service.ts index 690c318..c3ed87e 100644 --- a/apps/server/src/models/visit/visit.service.ts +++ b/apps/server/src/models/visit/visit.service.ts @@ -14,19 +14,19 @@ export class VisitService extends BaseService { super(db, ObjectType.VISIT); } async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) { - const { postId, troubleId, messageId } = args.data; + const { postId, lectureId, messageId } = args.data; const visitorId = args.data.visitorId || staff?.id; let result; const existingVisit = await db.visit.findFirst({ where: { - visitType: args.data.visitType, + type: args.data.type, visitorId, - OR: [{ postId }, { troubleId }, { messageId }], + OR: [{ postId }, { lectureId }, { messageId }], }, }); if (!existingVisit) { result = await super.create(args); - } else if (args.data.visitType === VisitType.READED) { + } else if (args.data.type === VisitType.READED) { result = await super.update({ where: { id: existingVisit.id }, data: { @@ -36,26 +36,26 @@ export class VisitService extends BaseService { }); } - if (troubleId && args.data.visitType === VisitType.READED) { - EventBus.emit('updateViewCount', { - objectType: ObjectType.TROUBLE, - id: troubleId, - }); - } + // if (troubleId && args.data.type === VisitType.READED) { + // EventBus.emit('updateViewCount', { + // objectType: ObjectType.TROUBLE, + // id: troubleId, + // }); + // } return result; } async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) { const data = Array.isArray(args.data) ? args.data : [args.data]; - const updatePromises = []; - const createData = []; + const updatePromises: any[] = []; + const createData: Prisma.VisitCreateManyInput[] = []; await Promise.all( data.map(async (item) => { - item.visitorId = item.visitorId || staff?.id; - const { postId, troubleId, messageId, visitorId } = item; + if (staff && !item.visitorId) item.visitorId = staff.id + const { postId, lectureId, messageId, visitorId } = item; const existingVisit = await db.visit.findFirst({ where: { visitorId, - OR: [{ postId }, { troubleId }, { messageId }], + OR: [{ postId }, { lectureId }, { messageId }], }, }); diff --git a/apps/server/src/queue/push/push.queue.service.ts b/apps/server/src/queue/push/push.queue.service.ts index f5717bb..87f1525 100755 --- a/apps/server/src/queue/push/push.queue.service.ts +++ b/apps/server/src/queue/push/push.queue.service.ts @@ -16,8 +16,9 @@ export class PushQueueService implements OnModuleInit { onModuleInit() { EventBus.on("dataChanged", async ({ data, type, operation }) => { if (type === ObjectType.MESSAGE && operation === CrudOperation.CREATED) { - const message = data as Partial + const message = data as MessageDto const uniqueStaffs = getUniqueItems(message.receivers, "id") + uniqueStaffs.forEach(item => { const token = item.registerToken if (token) { @@ -25,11 +26,11 @@ export class PushQueueService implements OnModuleInit { registerToken: token, messageContent: { data: { - title: message.title, - content: message.content, + title: message.title!, + content: message.content!, click_action: { - intent: message.intent, - url: message.url + intent: message.intent!, + url: message.url! } }, option: message.option as any diff --git a/apps/server/src/queue/push/push.service.ts b/apps/server/src/queue/push/push.service.ts index f6c146d..8dfa56f 100755 --- a/apps/server/src/queue/push/push.service.ts +++ b/apps/server/src/queue/push/push.service.ts @@ -5,15 +5,13 @@ interface LoginResponse { message: string; authtoken?: string; } - interface MessagePushResponse { retcode: string; message: string; messageid?: string; } - interface Notification { - title: string; // 通知标题(不超过128字节) / Title of notification (upper limit is 128 bytes) + title?: string; // 通知标题(不超过128字节) / Title of notification (upper limit is 128 bytes) content?: string; // 通知内容(不超过256字节) / Content of notification (upper limit is 256 bytes) click_action?: { url?: string; // 点击通知栏消息,打开指定的URL地址 / URL to open when notification is clicked @@ -47,11 +45,9 @@ export class PushService { appsecret: this.appsecret, }); this.handleError(response.data.retcode); - - this.authToken = response.data.authtoken; + this.authToken = response.data.authtoken!; return response.data; } - async messagePush( registerToken: string, messageContent: MessageContent, diff --git a/apps/server/src/socket/realtime/realtime.server.ts b/apps/server/src/socket/realtime/realtime.server.ts index f19cc83..0c642c6 100644 --- a/apps/server/src/socket/realtime/realtime.server.ts +++ b/apps/server/src/socket/realtime/realtime.server.ts @@ -2,7 +2,7 @@ import { Injectable, OnModuleInit } from "@nestjs/common"; import { WebSocketType } from "../types"; import { BaseWebSocketServer } from "../base/base-websocket-server"; import EventBus, { CrudOperation } from "@server/utils/event-bus"; -import { ObjectType, SocketMsgType, MessageDto, PostDto, PostType } from "@nicestack/common"; +import { ObjectType, SocketMsgType, MessageDto, PostDto, PostType } from "@nicestack/common"; @Injectable() export class RealtimeServer extends BaseWebSocketServer implements OnModuleInit { onModuleInit() { @@ -11,12 +11,10 @@ export class RealtimeServer extends BaseWebSocketServer implements OnModuleInit const receiverIds = (data as Partial).receivers.map(receiver => receiver.id) this.sendToUsers(receiverIds, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.MESSAGE } }) } - + if (type === ObjectType.POST) { const post = data as Partial - if (post.type === PostType.TROUBLE_INSTRUCTION || post.type === PostType.TROUBLE_PROGRESS) { - this.sendToRoom(post.referenceId, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.POST } }) - } + } }) diff --git a/apps/server/src/tasks/init/gendev.service.ts b/apps/server/src/tasks/init/gendev.service.ts index 5974d52..209feee 100644 --- a/apps/server/src/tasks/init/gendev.service.ts +++ b/apps/server/src/tasks/init/gendev.service.ts @@ -10,20 +10,18 @@ import { Staff, TaxonomySlug, Term, - TroubleType, } from '@nicestack/common'; import * as argon2 from 'argon2'; import EventBus from '@server/utils/event-bus'; import { - calculateTroubleAttributes, + capitalizeFirstLetter, - determineState, DevDataCounts, getCounts, - getRandomImageLinks } from './utils'; import { StaffService } from '@server/models/staff/staff.service'; +import { uuidv4 } from 'lib0/random'; @Injectable() export class GenDevService { private readonly logger = new Logger(GenDevService.name); @@ -53,7 +51,7 @@ export class GenDevService { await this.generateDepartments(3, 6); await this.generateTerms(2, 6); await this.generateStaffs(4); - + } catch (err) { this.logger.error(err); } @@ -94,7 +92,7 @@ export class GenDevService { } private async generateSubDepartments( - parentId: string, + parentId: string | null, currentDepth: number, maxDepth: number, count: number, @@ -103,7 +101,7 @@ export class GenDevService { if (currentDepth > maxDepth) return; for (let i = 0; i < count; i++) { - const deptName = `${parentId?.slice(0, 4) || '根'}公司${currentDepth}-${i}`; + const deptName = `${parentId?.slice(0, 6) || '根'}公司${currentDepth}-${i}`; const newDept = await this.createDepartment( deptName, parentId, @@ -164,10 +162,11 @@ export class GenDevService { this.deptStaffRecord[dept.id] = []; } for (let i = 0; i < countPerDept; i++) { + const username = `${dept.name}-S${staffsGenerated.toString().padStart(4, '0')}`; const staff = await this.staffService.create({ data: { - showname: `${dept.name}-user${i}`, - username: `${dept.name}-user${i}`, + showname: username, + username: username, deptId: dept.id, domainId: domain.id } @@ -184,10 +183,10 @@ export class GenDevService { } } } - + private async createDepartment( name: string, - parentId?: string, + parentId?: string | null, currentDepth: number = 1, ) { const department = await this.departmentService.create({ @@ -200,7 +199,7 @@ export class GenDevService { return department; } private async createTerms( - domain: Department, + domain: Department | null, taxonomySlug: TaxonomySlug, depth: number, nodesPerLevel: number, @@ -219,7 +218,7 @@ export class GenDevService { const newTerm = await this.termService.create({ data: { name, - taxonomyId: taxonomy.id, + taxonomyId: taxonomy!.id, domainId: domain?.id, parentId, } diff --git a/apps/server/src/tasks/init/utils.ts b/apps/server/src/tasks/init/utils.ts index 9ea0fe1..3fb5f97 100644 --- a/apps/server/src/tasks/init/utils.ts +++ b/apps/server/src/tasks/init/utils.ts @@ -1,4 +1,4 @@ -import { db, getRandomElement, getRandomIntInRange, getRandomTimeInterval, ObjectType, TroubleType } from '@nicestack/common'; +import { db, getRandomElement, getRandomIntInRange, getRandomTimeInterval, } from '@nicestack/common'; import dayjs from 'dayjs'; export interface DevDataCounts { deptCount: number; @@ -31,74 +31,4 @@ export function getRandomImageLinks(count: number = 5): string[] { return imageLinks; } -export function calculateTroubleAttributes(type: TroubleType) { - const probability = type === TroubleType.RISK ? getRandomIntInRange(1, 10) : getRandomIntInRange(25, 100); - const severity = type === TroubleType.RISK ? getRandomIntInRange(1, 10) : getRandomIntInRange(25, 100); - const impact = type === TroubleType.TROUBLE ? getRandomIntInRange(25, 100) : null; - const cost = type === TroubleType.TROUBLE ? getRandomIntInRange(25, 100) : null; - const deadline = type !== TroubleType.RISK ? getRandomTimeInterval(2024).endDate : null; - let level; - if (type === TroubleType.TROUBLE) { - level = getTroubleLevel(probability, severity, impact, cost, deadline); - } else if (type === TroubleType.RISK) { - level = getRiskLevel(probability, severity); - } else { - level = getRandomIntInRange(1, 4); - } - - return { probability, severity, impact, cost, deadline, level }; -} - -export function determineState(type: TroubleType): number { - if (type === TroubleType.TROUBLE) { - return getRandomElement([0, 1, 2, 3]); - } else { - return getRandomElement([0, 4, 5]); - } -} - -export function getTroubleLevel( - probability: number, - severity: number, - impact: number, - cost: number, - deadline: string | Date -) { - const deadlineDays = dayjs().diff(dayjs(deadline), "day"); - let deadlineScore = 25; - if (deadlineDays > 365) { - deadlineScore = 100; - } else if (deadlineDays > 90) { - deadlineScore = 75; - } else if (deadlineDays > 30) { - deadlineScore = 50; - } - let total = - 0.257 * probability + - 0.325 * severity + - 0.269 * impact + - 0.084 * deadlineScore + - 0.065 * cost; - if (total > 90) { - return 4; - } else if (total > 60) { - return 3; - } else if (total > 30) { - return 2; - } else if (probability * severity * impact * cost !== 1) { - return 1; - } else { - return 0; - } -} -export function getRiskLevel(probability: number, severity: number) { - if (probability * severity > 70) { - return 4; - } else if (probability * severity > 42) { - return 3; - } else if (probability * severity > 21) { - return 2; - } - return 1; -} \ No newline at end of file diff --git a/apps/server/src/trpc/trpc.module.ts b/apps/server/src/trpc/trpc.module.ts index 5a08555..fff1aa9 100755 --- a/apps/server/src/trpc/trpc.module.ts +++ b/apps/server/src/trpc/trpc.module.ts @@ -14,6 +14,9 @@ import { VisitModule } from '@server/models/visit/visit.module'; import { WebSocketModule } from '@server/socket/websocket.module'; import { RoleMapModule } from '@server/models/rbac/rbac.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({ imports: [ AuthModule, @@ -28,6 +31,9 @@ import { TransformModule } from '@server/models/transform/transform.module'; AppConfigModule, PostModule, VisitModule, + CourseModule, + LectureModule, + SectionModule, WebSocketModule ], controllers: [], diff --git a/apps/server/src/trpc/trpc.router.ts b/apps/server/src/trpc/trpc.router.ts index 96add0f..b8c2e3a 100755 --- a/apps/server/src/trpc/trpc.router.ts +++ b/apps/server/src/trpc/trpc.router.ts @@ -14,6 +14,9 @@ import { VisitRouter } from '@server/models/visit/visit.router'; import { RoleMapRouter } from '@server/models/rbac/rolemap.router'; import { TransformRouter } from '@server/models/transform/transform.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() export class TrpcRouter { logger = new Logger(TrpcRouter.name) @@ -31,6 +34,9 @@ export class TrpcRouter { private readonly app_config: AppConfigRouter, private readonly message: MessageRouter, private readonly visitor: VisitRouter, + private readonly course: CourseRouter, + private readonly lecture: LectureRouter, + private readonly section: SectionRouter // private readonly websocketService: WebSocketService ) { } appRouter = this.trpc.router({ @@ -45,7 +51,10 @@ export class TrpcRouter { rolemap: this.rolemap.router, message: this.message.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 diff --git a/apps/server/src/utils/event-bus.ts b/apps/server/src/utils/event-bus.ts index c52464b..f4bc904 100644 --- a/apps/server/src/utils/event-bus.ts +++ b/apps/server/src/utils/event-bus.ts @@ -1,5 +1,5 @@ import mitt from 'mitt'; -import { ObjectType, ChangedRows, UserProfile, MessageDto } from '@nicestack/common'; +import { ObjectType, UserProfile, MessageDto } from '@nicestack/common'; export enum CrudOperation { CREATED, UPDATED, diff --git a/apps/web/index.html b/apps/web/index.html index ee9e38a..db84405 100755 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -12,7 +12,7 @@ VITE_APP_VERSION: "$VITE_APP_VERSION", }; - 两道防线管理后台 + 烽火慕课 diff --git a/apps/web/package.json b/apps/web/package.json index ff4e432..6fc4320 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,9 +25,17 @@ "@ag-grid-enterprise/status-bar": "~32.3.2", "@ant-design/icons": "^5.4.0", "@floating-ui/react": "^0.26.25", - "@nicestack/common": "workspace:^", + "@heroicons/react": "^2.2.0", + "@hookform/resolvers": "^3.9.1", "@nicestack/client": "workspace:^", + "@nicestack/common": "workspace:^", "@nicestack/iconer": "workspace:^", + "@tanstack/query-async-storage-persister": "^5.51.9", + "@tanstack/react-query": "^5.51.21", + "@tanstack/react-query-persist-client": "^5.51.9", + "@trpc/client": "11.0.0-rc.456", + "@trpc/react-query": "11.0.0-rc.456", + "@trpc/server": "11.0.0-rc.456", "ag-grid-community": "~32.3.2", "ag-grid-enterprise": "~32.3.2", "ag-grid-react": "~32.3.2", @@ -35,22 +43,19 @@ "axios": "^1.7.2", "browser-image-compression": "^2.0.2", "dayjs": "^1.11.12", - "framer-motion": "^11.11.9", + "framer-motion": "^11.15.0", "idb-keyval": "^6.2.1", + "mitt": "^3.0.1", "react": "18.2.0", + "react-beautiful-dnd": "^13.1.1", "react-dom": "18.2.0", + "react-hook-form": "^7.54.2", + "react-hot-toast": "^2.4.1", "react-resizable": "^3.0.5", "react-router-dom": "^6.24.1", "superjson": "^2.2.1", - "@tanstack/query-async-storage-persister": "^5.51.9", - "@tanstack/react-query": "^5.51.21", - "@tanstack/react-query-persist-client": "^5.51.9", - "@trpc/client": "11.0.0-rc.456", - "@trpc/react-query": "11.0.0-rc.456", - "@trpc/server": "11.0.0-rc.456", - "zod": "^3.23.8", "yjs": "^13.6.20", - "mitt": "^3.0.1" + "zod": "^3.23.8" }, "devDependencies": { "@eslint/js": "^9.9.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b0525a9..54a93a3 100755 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -10,30 +10,34 @@ import locale from "antd/locale/zh_CN"; import dayjs from "dayjs"; import "dayjs/locale/zh-cn"; import { AuthProvider } from './providers/auth-provider'; +import { Toaster } from 'react-hot-toast'; dayjs.locale("zh-cn"); function App() { return ( - - - - - - - - - - - + <> + + + + + + + + + + + + + ); } diff --git a/apps/web/src/app/admin/base-setting/page.tsx b/apps/web/src/app/admin/base-setting/page.tsx index 9537650..0387e63 100644 --- a/apps/web/src/app/admin/base-setting/page.tsx +++ b/apps/web/src/app/admin/base-setting/page.tsx @@ -13,10 +13,11 @@ import { } from "antd"; import { useAppConfig } from "@nicestack/client"; import { useAuth } from "@web/src/providers/auth-provider"; -import { MainLayoutContext } from "../../layout"; + import FixedHeader from "@web/src/components/layout/fix-header"; import { useForm } from "antd/es/form/Form"; import { api } from "@nicestack/client" +import { MainLayoutContext } from "../layout"; export default function BaseSettingPage() { const { update, baseSetting } = useAppConfig(); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/admin/layout.tsx similarity index 100% rename from apps/web/src/app/layout.tsx rename to apps/web/src/app/admin/layout.tsx diff --git a/apps/web/src/app/main/course/editor/page.tsx b/apps/web/src/app/main/course/editor/page.tsx new file mode 100644 index 0000000..8ba41fe --- /dev/null +++ b/apps/web/src/app/main/course/editor/page.tsx @@ -0,0 +1,5 @@ +import CourseEditor from "@web/src/components/models/course/manage/CourseEditor"; + +export function CourseEditorPage() { + return +} \ No newline at end of file diff --git a/apps/web/src/app/main/course/page.tsx b/apps/web/src/app/main/course/page.tsx new file mode 100644 index 0000000..bdb9347 --- /dev/null +++ b/apps/web/src/app/main/course/page.tsx @@ -0,0 +1,34 @@ +import { CourseCard } from "@web/src/components/models/course/course-card" +import { CourseDetail } from "@web/src/components/models/course/course-detail" +import { CourseSyllabus } from "@web/src/components/models/course/course-syllabus" + +export const CoursePage = () => { + // 假设这些数据从API获取 + const course: any = { + /* course data */ + } + const sections: any = [ + /* sections data */ + ] + + return ( +
+
+ {/* 左侧课程详情 */} +
+ +
+ {/* 右侧课程大纲 */} +
+ + { + console.log('Clicked lecture:', lectureId) + }} + /> +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/app/main/courses/page.tsx b/apps/web/src/app/main/courses/page.tsx new file mode 100644 index 0000000..c3c2048 --- /dev/null +++ b/apps/web/src/app/main/courses/page.tsx @@ -0,0 +1,198 @@ +import { motion } from "framer-motion"; +import { useState } from "react"; +import { CourseDto } from "@nicestack/common"; +import { EmptyStateIllustration } from "@web/src/components/presentation/EmptyStateIllustration"; +import { useNavigate } from "react-router-dom"; + +interface CourseCardProps { + course: CourseDto; + type: "created" | "enrolled"; +} + +const CourseCard = ({ course, type }: CourseCardProps) => { + return ( + + {/* Course Thumbnail */} +
+ +
+
+ + {course.level} + +
+
+ + {/* Course Info */} +
+

+ {course.title} +

+ {course.subTitle && ( +

+ {course.subTitle} +

+ )} +
+ + {/* Course Stats */} +
+
+ {course.totalLectures} lectures + + {course.totalDuration} mins +
+
+ + + {course.averageRating.toFixed(1)} + +
+
+ + {/* Progress Bar (Only for enrolled courses) */} + {type === "enrolled" && course.enrollments[0] && ( +
+
+ +
+

+ {course.enrollments[0].completionRate}% Complete +

+
+ )} + + ); +}; +export default function CoursesPage() { + const [activeTab, setActiveTab] = useState<"enrolled" | "created">("enrolled"); + const [courses, setCourses] = useState([]); + const navigate = useNavigate() + const container = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + staggerChildren: 0.05, + duration: 0.3 + }, + }, + }; + + return ( +
+
+ {/* Header */} +
+
+

+ 我的课程 +

+
+ +
+
+ {activeTab === "created" && ( + { + navigate("/course/manage") + }} + transition={{ duration: 0.2 }} + className="flex items-center gap-2 rounded-lg bg-blue-500 px-6 py-3 text-sm font-medium text-white shadow-sm transition-all duration-200 hover:bg-blue-600 hover:shadow-md" + > + + + + + + 创建课程 + + )} +
+ + {/* Course Grid */} + + {courses.map((course) => ( + + ))} + + + {/* Empty State */} + {courses.length === 0 && ( + + +

+ No courses found +

+

+ {activeTab === "enrolled" + ? "You haven't enrolled in any courses yet." + : "You haven't created any courses yet."} +

+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/app/main/page.tsx b/apps/web/src/app/main/page.tsx deleted file mode 100755 index ca4cb4a..0000000 --- a/apps/web/src/app/main/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function MainPage() { - return
main
-} \ No newline at end of file diff --git a/apps/web/src/components/layout/main/MainLayout.tsx b/apps/web/src/components/layout/main/MainLayout.tsx new file mode 100644 index 0000000..887bb4c --- /dev/null +++ b/apps/web/src/components/layout/main/MainLayout.tsx @@ -0,0 +1,39 @@ +import { useState, useEffect, useRef, ReactNode } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { TopNavBar } from '@web/src/components/layout/main/top-nav-bar'; +import { navItems, notificationItems } from '@web/src/components/layout/main/nav-data'; +import { Sidebar } from '@web/src/components/layout/main/side-bar'; + + +export function MainLayout({ children }: { children: ReactNode }) { + const [sidebarOpen, setSidebarOpen] = useState(true); + const [notifications, setNotifications] = useState(3); + const [recentSearches] = useState([ + 'React Fundamentals', + 'TypeScript Advanced', + 'Tailwind CSS Projects', + ]); + + return ( +
+ + + + {sidebarOpen && } + + +
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/layout/main/nav-data.tsx b/apps/web/src/components/layout/main/nav-data.tsx new file mode 100644 index 0000000..fe7c7d0 --- /dev/null +++ b/apps/web/src/components/layout/main/nav-data.tsx @@ -0,0 +1,41 @@ +import { NavItem } from '@nicestack/client'; +import { + HomeIcon, + BookOpenIcon, + UserGroupIcon, + Cog6ToothIcon, + BellIcon, + HeartIcon, + AcademicCapIcon +} from '@heroicons/react/24/outline'; + +export const navItems: NavItem[] = [ + { icon: , label: '探索', path: '/' }, + { icon: , label: '我的课程', path: '/courses' }, + { icon: , label: '学习社区', path: '/community' }, + { icon: , label: '应用设置', path: '/settings' }, +]; + +export const notificationItems = [ + { + icon: , + title: "New Course Available", + description: "Advanced TypeScript Programming is now available", + time: "2 hours ago", + isUnread: true, + }, + { + icon: , + title: "Course Recommendation", + description: "Based on your interests: React Native Development", + time: "1 day ago", + isUnread: true, + }, + { + icon: , + title: "Certificate Ready", + description: "Your React Fundamentals certificate is ready to download", + time: "2 days ago", + isUnread: true, + }, +]; \ No newline at end of file diff --git a/apps/web/src/components/layout/main/notifications-dropdown.tsx b/apps/web/src/components/layout/main/notifications-dropdown.tsx new file mode 100644 index 0000000..439fa71 --- /dev/null +++ b/apps/web/src/components/layout/main/notifications-dropdown.tsx @@ -0,0 +1,40 @@ +import { useState, useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { NotificationsPanel } from './notifications-panel'; +import { BellIcon } from '@heroicons/react/24/outline'; +import { useClickOutside } from '@web/src/hooks/useClickOutside'; + +interface NotificationsDropdownProps { + notifications: number; + notificationItems: Array; +} + +export function NotificationsDropdown({ notifications, notificationItems }: NotificationsDropdownProps) { + const [showNotifications, setShowNotifications] = useState(false); + const notificationRef = useRef(null); + useClickOutside(notificationRef, () => setShowNotifications(false)); + return ( +
+ setShowNotifications(!showNotifications)} + > + + + {notifications > 0 && ( + + {notifications} + + )} + + + + {showNotifications && ( + + )} + +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/layout/main/notifications-panel.tsx b/apps/web/src/components/layout/main/notifications-panel.tsx new file mode 100644 index 0000000..43ef2b4 --- /dev/null +++ b/apps/web/src/components/layout/main/notifications-panel.tsx @@ -0,0 +1,64 @@ +import { ClockIcon } from '@heroicons/react/24/outline'; +import { motion } from 'framer-motion'; + +interface NotificationsPanelProps { + notificationItems: Array<{ + icon: React.ReactNode; + title: string; + description: string; + time: string; + isUnread: boolean; + }>; +} + +export function NotificationsPanel({ notificationItems }: NotificationsPanelProps) { + return ( + +
+
+

Notifications

+ + Mark all as read + +
+
+ +
+ {notificationItems.map((item, index) => ( + +
+
+ {item.icon} +
+
+

{item.title}

+

{item.description}

+
+ + {item.time} +
+
+
+
+ ))} +
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/layout/main/search-bar.tsx b/apps/web/src/components/layout/main/search-bar.tsx new file mode 100644 index 0000000..a965660 --- /dev/null +++ b/apps/web/src/components/layout/main/search-bar.tsx @@ -0,0 +1,55 @@ +import { useState, useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { SearchDropdown } from './search-dropdown'; +import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { useClickOutside } from '@web/src/hooks/useClickOutside'; + +interface SearchBarProps { + recentSearches: string[]; +} + +export function SearchBar({ recentSearches }: SearchBarProps) { + const [searchFocused, setSearchFocused] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const searchRef = useRef(null); + useClickOutside(searchRef, () => setSearchFocused(false)) + return ( +
+
+ + setSearchFocused(true)} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> + {searchQuery && ( + setSearchQuery('')} + > + + + )} +
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/layout/main/search-dropdown.tsx b/apps/web/src/components/layout/main/search-dropdown.tsx new file mode 100644 index 0000000..60ed49f --- /dev/null +++ b/apps/web/src/components/layout/main/search-dropdown.tsx @@ -0,0 +1,58 @@ +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; +import { motion } from 'framer-motion'; + +interface SearchDropdownProps { + searchFocused: boolean; + searchQuery: string; + recentSearches: string[]; + setSearchQuery: (query: string) => void; +} + +export function SearchDropdown({ + searchFocused, + searchQuery, + recentSearches, + setSearchQuery +}: SearchDropdownProps) { + if (!searchFocused) return null; + + return ( + +
+

Recent Searches

+
+ {recentSearches.map((search, index) => ( + setSearchQuery(search)} + > + + {search} + + ))} +
+
+ {searchQuery && ( +
+ + + + Search for "{searchQuery}" + + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/layout/main/side-bar.tsx b/apps/web/src/components/layout/main/side-bar.tsx new file mode 100644 index 0000000..a9f6e40 --- /dev/null +++ b/apps/web/src/components/layout/main/side-bar.tsx @@ -0,0 +1,44 @@ +import { motion } from 'framer-motion'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { NavItem } from '@nicestack/client'; + +interface SidebarProps { + navItems: Array; +} + +export function Sidebar({ navItems }: SidebarProps) { + const navigate = useNavigate(); + const location = useLocation(); + return ( + +
+ {navItems.map((item, index) => { + const isActive = location.pathname === item.path; + return ( + { + navigate(item.path) + }} + className={`flex items-center gap-3 w-full p-3 rounded-lg transition-colors + ${isActive + ? 'bg-blue-50 text-blue-600' + : 'text-gray-700 hover:bg-blue-50 hover:text-blue-600' + }`} + > + {item.icon} + {item.label} + + ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/layout/main/top-nav-bar.tsx b/apps/web/src/components/layout/main/top-nav-bar.tsx new file mode 100644 index 0000000..9ea15cd --- /dev/null +++ b/apps/web/src/components/layout/main/top-nav-bar.tsx @@ -0,0 +1,50 @@ +import { useState, useRef } from 'react'; + +import { NotificationsDropdown } from './notifications-dropdown'; +import { SearchBar } from './search-bar'; +import { UserMenuDropdown } from './usermenu-dropdown'; +import { Bars3Icon } from '@heroicons/react/24/outline'; + +interface TopNavBarProps { + sidebarOpen: boolean; + setSidebarOpen: (open: boolean) => void; + notifications: number; + notificationItems: Array; + recentSearches: string[]; +} + +export function TopNavBar({ + sidebarOpen, + setSidebarOpen, + notifications, + notificationItems, + recentSearches +}: TopNavBarProps) { + return ( + + ); +} \ No newline at end of file diff --git a/apps/web/src/components/layout/main/usermenu-dropdown.tsx b/apps/web/src/components/layout/main/usermenu-dropdown.tsx new file mode 100644 index 0000000..846a043 --- /dev/null +++ b/apps/web/src/components/layout/main/usermenu-dropdown.tsx @@ -0,0 +1,68 @@ +import { useState, useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ArrowLeftStartOnRectangleIcon, Cog6ToothIcon, QuestionMarkCircleIcon, UserCircleIcon } from '@heroicons/react/24/outline'; +import { useAuth } from '@web/src/providers/auth-provider'; +import { Avatar } from '../../presentation/user/Avatar'; +import { useClickOutside } from '@web/src/hooks/useClickOutside'; + +export function UserMenuDropdown() { + const [showMenu, setShowMenu] = useState(false); + const menuRef = useRef(null); + const { user, logout } = useAuth() + useClickOutside(menuRef, () => setShowMenu(false)); + const menuItems = [ + { icon: , label: '个人信息', action: () => { } }, + { icon: , label: '设置', action: () => { } }, + { icon: , label: '帮助', action: () => { } }, + { icon: , label: '注销', action: () => { logout() } }, + ]; + + return ( +
+ setShowMenu(!showMenu)} + className="w-10 h-10" // 移除了边框相关的类 + > + + + + + {showMenu && ( + +
+

{user?.showname}

+

{user?.username}

+
+ +
+ {menuItems.map((item, index) => ( + + {item.icon} + {item.label} + + ))} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/layout/sidebar-content.tsx b/apps/web/src/components/layout/sidebar-content.tsx index 3bc93a5..b85c341 100644 --- a/apps/web/src/components/layout/sidebar-content.tsx +++ b/apps/web/src/components/layout/sidebar-content.tsx @@ -20,12 +20,7 @@ export default function SidebarContent() { // icon: , // link: "/", // }, - { - key: "trouble", - label: "问题列表", - icon: , - link: "/troubles", - }, + hasSomePermissions( RolePerms.MANAGE_ANY_DEPT, RolePerms.MANAGE_ANY_STAFF, diff --git a/apps/web/src/components/models/course/card/CourseCard.tsx b/apps/web/src/components/models/course/card/CourseCard.tsx new file mode 100644 index 0000000..e66a268 --- /dev/null +++ b/apps/web/src/components/models/course/card/CourseCard.tsx @@ -0,0 +1,30 @@ +import { CourseDto } from '@nicestack/common'; +import { Card } from '@web/src/components/presentation/container/Card'; +import { CourseHeader } from './CourseHeader'; +import { CourseStats } from './CourseStats'; + +interface CourseCardProps { + course: CourseDto; + onClick?: () => void; +} + +export const CourseCard = ({ course, onClick }: CourseCardProps) => { + return ( + + + + + ); +}; \ No newline at end of file diff --git a/apps/web/src/components/models/course/card/CourseHeader.tsx b/apps/web/src/components/models/course/card/CourseHeader.tsx new file mode 100644 index 0000000..3444487 --- /dev/null +++ b/apps/web/src/components/models/course/card/CourseHeader.tsx @@ -0,0 +1,59 @@ +import { CalendarIcon, UserGroupIcon, AcademicCapIcon } from '@heroicons/react/24/outline'; + +interface CourseHeaderProps { + title: string; + subTitle?: string; + thumbnail?: string; + level?: string; + numberOfStudents?: number; + publishedAt?: Date; +} + +export const CourseHeader = ({ + title, + subTitle, + thumbnail, + level, + numberOfStudents, + publishedAt, +}: CourseHeaderProps) => { + return ( +
+ {thumbnail && ( +
+ {title} +
+ )} +
+

{title}

+ {subTitle && ( +

{subTitle}

+ )} +
+ {level && ( +
+ + {level} +
+ )} + {numberOfStudents !== undefined && ( +
+ + {numberOfStudents} students +
+ )} + {publishedAt && ( +
+ + {publishedAt.toLocaleDateString()} +
+ )} +
+
+
+ ); +}; \ No newline at end of file diff --git a/apps/web/src/components/models/course/card/CourseStats.tsx b/apps/web/src/components/models/course/card/CourseStats.tsx new file mode 100644 index 0000000..7d5fe02 --- /dev/null +++ b/apps/web/src/components/models/course/card/CourseStats.tsx @@ -0,0 +1,59 @@ +import { StarIcon, ChartBarIcon, ClockIcon } from '@heroicons/react/24/solid'; + +interface CourseStatsProps { + averageRating?: number; + numberOfReviews?: number; + completionRate?: number; + totalDuration?: number; +} + +export const CourseStats = ({ + averageRating, + numberOfReviews, + completionRate, + totalDuration, +}: CourseStatsProps) => { + return ( +
+ {averageRating !== undefined && ( +
+ +
+
+ {averageRating.toFixed(1)} +
+
+ {numberOfReviews} reviews +
+
+
+ )} + {completionRate !== undefined && ( +
+ +
+
+ {completionRate}% +
+
+ Completion +
+
+
+ )} + {totalDuration !== undefined && ( +
+ +
+
+ {Math.floor(totalDuration / 60)}h {totalDuration % 60}m +
+
+ Duration +
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/apps/web/src/components/models/course/detail/course-detail.tsx b/apps/web/src/components/models/course/detail/course-detail.tsx new file mode 100644 index 0000000..e4fa121 --- /dev/null +++ b/apps/web/src/components/models/course/detail/course-detail.tsx @@ -0,0 +1,75 @@ +import { CheckCircleIcon } from '@heroicons/react/24/outline' +import { Course } from "@nicestack/common" +interface CourseDetailProps { + course: Course +} + +export const CourseDetail: React.FC = ({ course }) => { + return ( +
+ {/* 课程标题区域 */} +
+

{course.title}

+ {course.subTitle && ( +

{course.subTitle}

+ )} +
+ + {/* 课程描述 */} +
+

{course.description}

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

学习目标

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

适合人群

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

课程要求

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

可获得技能

+
+ {course.skills.map((skill, index) => ( + + {skill} + + ))} +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/models/course/detail/course-objectives.tsx b/apps/web/src/components/models/course/detail/course-objectives.tsx new file mode 100644 index 0000000..b849eac --- /dev/null +++ b/apps/web/src/components/models/course/detail/course-objectives.tsx @@ -0,0 +1,29 @@ +import { CheckOutlined } from '@ant-design/icons'; +import React from 'react'; +interface CourseObjectivesProps { + objectives: string[]; + title?: string; +} +const CourseObjectives: React.FC = ({ + objectives, + title = "您将会学到" +}) => { + return ( +
+

{title}

+
+ {objectives.map((objective, index) => ( +
+ + {objective} +
+ ))} +
+
+ ); +}; + +export default CourseObjectives; \ No newline at end of file diff --git a/apps/web/src/components/models/course/detail/course-syllabus.tsx b/apps/web/src/components/models/course/detail/course-syllabus.tsx new file mode 100644 index 0000000..36af3d1 --- /dev/null +++ b/apps/web/src/components/models/course/detail/course-syllabus.tsx @@ -0,0 +1,78 @@ + +import { ChevronDownIcon, ClockIcon, PlayCircleIcon } from '@heroicons/react/24/outline' +import { useState } from 'react' +import { Section, SectionDto } from "@nicestack/common" +interface CourseSyllabusProps { + sections: SectionDto[] + onLectureClick?: (lectureId: string) => void +} + +export const CourseSyllabus: React.FC = ({ + sections, + onLectureClick +}) => { + const [expandedSections, setExpandedSections] = useState([]) + + const toggleSection = (sectionId: string) => { + setExpandedSections(prev => + prev.includes(sectionId) + ? prev.filter(id => id !== sectionId) + : [...prev, sectionId] + ) + } + + return ( +
+ {sections.map((section) => ( +
+ {/* 章节标题 */} + + + {/* 课时列表 */} + {expandedSections.includes(section.id) && ( +
+ {section.lectures.map((lecture) => ( + + ))} +
+ )} +
+ ))} +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/models/course/list/course-list.tsx b/apps/web/src/components/models/course/list/course-list.tsx new file mode 100644 index 0000000..61e55e9 --- /dev/null +++ b/apps/web/src/components/models/course/list/course-list.tsx @@ -0,0 +1,42 @@ +// CourseList.tsx +import { motion } from "framer-motion"; +import { Course, CourseDto } from "@nicestack/common"; +import { EmptyState } from "@web/src/components/presentation/space/Empty"; +interface CourseListProps { + courses: CourseDto[]; + activeTab: "enrolled" | "created"; +} +const container = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + staggerChildren: 0.05, + duration: 0.3 + }, + }, +}; +export const CourseList = ({ + courses, + renderItem, + emptyComponent: EmptyComponent, +}: CourseListProps & { + renderItem?: (course: CourseDto) => React.ReactNode; + emptyComponent?: React.ReactNode; +}) => { + if (courses.length === 0) { + return EmptyComponent || ( + + ); + } + return ( + + {courses.map((course) => renderItem(course))} + + ); +}; \ No newline at end of file diff --git a/apps/web/src/components/models/course/manage/CourseBasicForm.tsx b/apps/web/src/components/models/course/manage/CourseBasicForm.tsx new file mode 100644 index 0000000..f7cef6f --- /dev/null +++ b/apps/web/src/components/models/course/manage/CourseBasicForm.tsx @@ -0,0 +1,35 @@ +import { SubmitHandler, useFormContext } from 'react-hook-form'; + +import { CourseFormData, useCourseForm } from './CourseEditorContext'; +import { CourseLevel } from '@nicestack/common'; +import { FormInput } from '@web/src/components/presentation/form/FormInput'; +import { FormSelect } from '@web/src/components/presentation/form/FormSelect'; +import { FormArrayField } from '@web/src/components/presentation/form/FormArrayField'; + +export function CourseBasicForm() { + const { register, formState: { errors }, watch, handleSubmit } = useFormContext(); + + return ( +
+ + + + + {/* */} + + ); +} + diff --git a/apps/web/src/components/models/course/manage/CourseEditor.tsx b/apps/web/src/components/models/course/manage/CourseEditor.tsx new file mode 100644 index 0000000..9fbfc5a --- /dev/null +++ b/apps/web/src/components/models/course/manage/CourseEditor.tsx @@ -0,0 +1,12 @@ + +import { CourseBasicForm } from "./CourseBasicForm"; +import { CourseFormProvider } from "./CourseEditorContext"; +import CourseEditorLayout from "./CourseEditorLayout"; + +export default function CourseEditor() { + return + + + + +} \ No newline at end of file diff --git a/apps/web/src/components/models/course/manage/CourseEditorContext.tsx b/apps/web/src/components/models/course/manage/CourseEditorContext.tsx new file mode 100644 index 0000000..adb30c1 --- /dev/null +++ b/apps/web/src/components/models/course/manage/CourseEditorContext.tsx @@ -0,0 +1,71 @@ +import { createContext, useContext, ReactNode } from 'react'; +import { useForm, FormProvider, SubmitHandler } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { CourseLevel, CourseStatus } from '@nicestack/common'; +import { useCourse } from '@nicestack/client'; + +// 定义课程表单验证 Schema +const courseSchema = z.object({ + title: z.string().min(1, '课程标题不能为空'), + subTitle: z.string().optional(), + description: z.string().optional(), + thumbnail: z.string().url().optional(), + level: z.nativeEnum(CourseLevel), + requirements: z.array(z.string()).optional(), + objectives: z.array(z.string()).optional(), + skills: z.array(z.string()).optional(), + audiences: z.array(z.string()).optional(), + status: z.nativeEnum(CourseStatus), +}); +export type CourseFormData = z.infer; +interface CourseEditorContextType { + onSubmit: SubmitHandler; +} +const CourseEditorContext = createContext(null); +export function CourseFormProvider({ children }: { children: ReactNode }) { + const { create } = useCourse() + const methods = useForm({ + resolver: zodResolver(courseSchema), + defaultValues: { + status: CourseStatus.DRAFT, + level: CourseLevel.BEGINNER, + + requirements: [], + objectives: [], + skills: [], + audiences: [], + }, + }); + + const onSubmit: SubmitHandler = async (data: CourseFormData) => { + try { + // TODO: 实现API调用 + console.log('Form data:', data); + await create.mutateAsync({ + data: { + ...data + } + }) + + } catch (error) { + console.error('Error submitting form:', error); + } + }; + + return ( + + + {children} + + + ); +} + +export const useCourseForm = () => { + const context = useContext(CourseEditorContext); + if (!context) { + throw new Error('useCourseForm must be used within CourseFormProvider'); + } + return context; +}; \ No newline at end of file diff --git a/apps/web/src/components/models/course/manage/CourseEditorHeader.tsx b/apps/web/src/components/models/course/manage/CourseEditorHeader.tsx new file mode 100644 index 0000000..4326c9f --- /dev/null +++ b/apps/web/src/components/models/course/manage/CourseEditorHeader.tsx @@ -0,0 +1,35 @@ +import { ArrowLeftIcon, ClockIcon } from '@heroicons/react/24/outline'; +import { SubmitHandler, useFormContext } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; +import { CourseFormData, useCourseForm } from './CourseEditorContext'; + +export default function CourseEditorHeader() { + const navigate = useNavigate(); + const { handleSubmit} = useFormContext() + const { onSubmit } = useCourseForm() + return ( +
+
+
+ +
+

UI设计入门课程

+ 审核中 +
+ + 总时长 12:30:00 +
+
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx b/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx new file mode 100644 index 0000000..63948a5 --- /dev/null +++ b/apps/web/src/components/models/course/manage/CourseEditorLayout.tsx @@ -0,0 +1,50 @@ +import { ReactNode, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { DEFAULT_NAV_ITEMS } from "./navItems"; +import CourseEditorHeader from "./CourseEditorHeader"; +import { motion } from "framer-motion"; +import { NavItem } from "@nicestack/client" +import CourseEditorSidebar from "./CourseEditorSidebar"; +interface CourseEditorLayoutProps { + children: ReactNode; +} + + +export default function CourseEditorLayout({ children }: CourseEditorLayoutProps) { + const [isHovered, setIsHovered] = useState(false); + const [selectedSection, setSelectedSection] = useState(0); + const [navItems, setNavItems] = useState(DEFAULT_NAV_ITEMS); + const navigate = useNavigate(); + const handleNavigation = (item: NavItem, index: number) => { + setSelectedSection(index); + navigate(item.path); + }; + return ( +
+ +
+ + +
+
+

+ {navItems[selectedSection]?.label} +

+
+
{children}
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/models/course/manage/CourseEditorSidebar.tsx b/apps/web/src/components/models/course/manage/CourseEditorSidebar.tsx new file mode 100644 index 0000000..071f63e --- /dev/null +++ b/apps/web/src/components/models/course/manage/CourseEditorSidebar.tsx @@ -0,0 +1,55 @@ +import { motion } from 'framer-motion'; +import { NavItem } from "@nicestack/client" +interface CourseSidebarProps { + isHovered: boolean; + setIsHovered: (value: boolean) => void; + navItems: NavItem[]; + selectedSection: number; + onNavigate: (item: NavItem, index: number) => void; +} + +export default function CourseEditorSidebar({ + isHovered, + setIsHovered, + navItems, + selectedSection, + onNavigate +}: CourseSidebarProps) { + return ( + setIsHovered(true)} + onHoverEnd={() => setIsHovered(false)} + className="fixed left-0 top-16 h-[calc(100vh-4rem)] bg-white border-r border-gray-200 shadow-lg overflow-x-hidden" + > +
+ {navItems.map((item, index) => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/models/course/manage/navItems.tsx b/apps/web/src/components/models/course/manage/navItems.tsx new file mode 100644 index 0000000..ca88bef --- /dev/null +++ b/apps/web/src/components/models/course/manage/navItems.tsx @@ -0,0 +1,28 @@ +import { AcademicCapIcon, BookOpenIcon, Cog6ToothIcon, VideoCameraIcon } from '@heroicons/react/24/outline'; +import { NavItem } from '@nicestack/client'; + +export const DEFAULT_NAV_ITEMS: (NavItem & { isCompleted?: boolean })[] = [ + { + + label: "课程概述", + icon: , + path: "/manage/overview" + }, + { + + label: "目标学员", + icon: , + path: "/manage/overview" + }, + { + + label: "课程内容", + icon: , + path: "/manage/content" + }, + { + label: "课程设置", + icon: , + path: "/manage/settings" + }, +]; \ No newline at end of file diff --git a/apps/web/src/components/models/role/role-editor/role-form.tsx b/apps/web/src/components/models/role/role-editor/role-form.tsx index 1d7b5fb..1896caf 100644 --- a/apps/web/src/components/models/role/role-editor/role-form.tsx +++ b/apps/web/src/components/models/role/role-editor/role-form.tsx @@ -1,19 +1,17 @@ -import { Button, Form, Input, message, Select, Spin } from "antd"; -import { useContext, useEffect} from "react"; -import { ObjectType, Role, RolePerms } from "@nicestack/common"; +import { Form, Input, message, Select, Spin } from "antd"; +import { useContext, useEffect } from "react"; +import { Role, RolePerms } from "@nicestack/common"; import { useRole } from "@nicestack/client"; import { api } from "@nicestack/client"; import { RoleEditorContext } from "./role-editor"; - const options: { value: string; label: string }[] = Object.values(RolePerms).map((permission) => ({ value: permission, label: permission, })); - export default function RoleForm() { const { editRoleId, roleForm, setRoleModalOpen } = useContext(RoleEditorContext) - const { data, isLoading }: { data: Role, isLoading: boolean } = api.role.findById.useQuery( - { id: editRoleId }, + const { data, isLoading }: { data: Role, isLoading: boolean } = api.role.findFirst.useQuery( + { where: { id: editRoleId } }, { enabled: !!editRoleId } ); useEffect(() => { @@ -33,10 +31,9 @@ export default function RoleForm() { layout="vertical" requiredMark="optional" onFinish={async (values) => { - if (data) { try { - await update.mutateAsync({ id: data.id, ...values }); + await update.mutateAsync({ where: { id: data.id }, data: { ...values } }); } catch (err: any) { message.error("更新失败"); } diff --git a/apps/web/src/components/models/role/role-editor/role-list.tsx b/apps/web/src/components/models/role/role-editor/role-list.tsx index ba806df..98a6166 100644 --- a/apps/web/src/components/models/role/role-editor/role-list.tsx +++ b/apps/web/src/components/models/role/role-editor/role-list.tsx @@ -1,22 +1,19 @@ -import { ObjectType, Role, RolePerms } from "@nicestack/common" -import { DeleteOutlined, EditFilled, EditOutlined, EllipsisOutlined, PlusOutlined, UserOutlined } from "@ant-design/icons"; +import { Role, RolePerms } from "@nicestack/common" +import { DeleteOutlined, EditOutlined, EllipsisOutlined, PlusOutlined, UserOutlined } from "@ant-design/icons"; import { useRole } from "@nicestack/client"; import { Button, theme } from "antd"; -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo } from "react"; import { RoleEditorContext } from "./role-editor"; import { api } from "@nicestack/client" import { useAuth } from "@web/src/providers/auth-provider"; import { Menu, MenuItem } from "@web/src/components/presentation/dropdown-menu"; - const OpreationRenderer = ({ data }: { data: Role }) => { - const { deleteMany } = useRole() - const { editRoleId, setEditRoleId, setRoleModalOpen } = useContext(RoleEditorContext) + const { softDeleteByIds } = useRole() + const { setEditRoleId, setRoleModalOpen } = useContext(RoleEditorContext) return (
- }> + node={}> { @@ -28,7 +25,7 @@ const OpreationRenderer = ({ data }: { data: Role }) => { label="移除" disabled={data?.system} onClick={() => { - deleteMany.mutateAsync({ + softDeleteByIds.mutateAsync({ ids: [data?.id], }); }} @@ -38,7 +35,7 @@ const OpreationRenderer = ({ data }: { data: Role }) => { ); }; export default function RoleList() { - const { editRoleId, setEditRoleId, setRoleModalOpen } = useContext(RoleEditorContext) + const { setRoleModalOpen } = useContext(RoleEditorContext) const { setRole, role } = useContext(RoleEditorContext) const { data: roles } = api.role.findMany.useQuery({}) const { token } = theme.useToken() diff --git a/apps/web/src/components/models/role/role-select.tsx b/apps/web/src/components/models/role/role-select.tsx index 22da903..b74f0b4 100644 --- a/apps/web/src/components/models/role/role-select.tsx +++ b/apps/web/src/components/models/role/role-select.tsx @@ -11,7 +11,13 @@ interface RoleSelectProps { export default function RoleSelect({ value, onChange, style, multiple }: RoleSelectProps) { const [keyword, setQuery] = useState(''); - const { data, isLoading } = api.role.findMany.useQuery({ keyword }); + const { data, isLoading } = api.role.findMany.useQuery({ + where: { + OR: [ + { name: { contains: keyword } } + ] + } + }); const handleSearch = (value: string) => { setQuery(value); diff --git a/apps/web/src/components/presentation/EmptyStateIllustration.tsx b/apps/web/src/components/presentation/EmptyStateIllustration.tsx new file mode 100644 index 0000000..8c58482 --- /dev/null +++ b/apps/web/src/components/presentation/EmptyStateIllustration.tsx @@ -0,0 +1,75 @@ +import { motion } from "framer-motion"; + +export const EmptyStateIllustration = () => { + return ( + + {/* Background Elements */} + + + {/* Books Stack */} + + {/* Bottom Book */} + + {/* Middle Book */} + + {/* Top Book */} + + + + {/* Floating Elements */} + + {/* Small Circles */} + + + + + + + {/* Decorative Lines */} + + + + + + + ); +}; diff --git a/apps/web/src/components/presentation/container/Card.tsx b/apps/web/src/components/presentation/container/Card.tsx new file mode 100644 index 0000000..cc21462 --- /dev/null +++ b/apps/web/src/components/presentation/container/Card.tsx @@ -0,0 +1,28 @@ +import { motion } from 'framer-motion'; +import { ReactNode } from 'react'; + +interface CardProps { + children: ReactNode; + className?: string; + hover?: boolean; + onClick?: () => void; +} + +export const Card = ({ children, className = '', hover = true, onClick }: CardProps) => { + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/apps/web/src/components/presentation/form/FormArrayField.tsx b/apps/web/src/components/presentation/form/FormArrayField.tsx new file mode 100644 index 0000000..07245bf --- /dev/null +++ b/apps/web/src/components/presentation/form/FormArrayField.tsx @@ -0,0 +1,89 @@ +import { useFormContext } from 'react-hook-form'; +import { PlusIcon, XMarkIcon, Bars3Icon } from '@heroicons/react/24/outline'; +import { Reorder } from 'framer-motion'; +import { v4 as uuidv4 } from 'uuid'; +import { useState } from 'react'; +import FormError from './FormError'; +interface ArrayFieldProps { + name: string; + label: string; + placeholder?: string; + addButtonText?: string; + inputProps?: React.InputHTMLAttributes; +} +type ItemType = { id: string; value: string }; +const inputStyles = "w-full rounded-md border border-gray-300 bg-white p-2 outline-none transition-all duration-300 ease-out focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:ring-opacity-50 placeholder:text-gray-400 shadow-sm"; +const buttonStyles = "rounded-md inline-flex items-center gap-1 px-4 py-2 text-sm font-medium transition-colors "; +export function FormArrayField({ name, + label, + placeholder, + addButtonText = "添加项目", + inputProps = {} }: ArrayFieldProps) { + const { register, watch, setValue, formState: { errors }, trigger } = useFormContext(); + const [items, setItems] = useState(() => + (watch(name) as string[])?.map(value => ({ id: uuidv4(), value })) || [] + ); + const error = errors[name]?.message as string; + + const updateItems = (newItems: ItemType[]) => { + setItems(newItems); + setValue(name, newItems.map(item => item.value)); + }; + return ( +
+ +
+ + {items.map((item, index) => ( + +
+
+ updateItems( + items.map(i => i.id === item.id ? { ...i, value: e.target.value } : i) + )} + onBlur={() => trigger(name)} + placeholder={placeholder} + className={inputStyles} + /> + {inputProps.maxLength && ( + + {inputProps.maxLength - (item.value?.length || 0)} + + )} +
+ +
+
+ ))} +
+ + +
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/presentation/form/FormError.tsx b/apps/web/src/components/presentation/form/FormError.tsx new file mode 100644 index 0000000..ac633c6 --- /dev/null +++ b/apps/web/src/components/presentation/form/FormError.tsx @@ -0,0 +1,22 @@ +import { AnimatePresence, motion } from "framer-motion"; + +const ANIMATIONS = { + + error: { + initial: { opacity: 0, y: -8 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -8 }, + transition: { duration: 0.2 } + } +}; +export default function FormError({ error }: { error: string }) { + return + {error && ( + + {error} + + )} + +} \ No newline at end of file diff --git a/apps/web/src/components/presentation/form/FormInput.tsx b/apps/web/src/components/presentation/form/FormInput.tsx new file mode 100644 index 0000000..85d273f --- /dev/null +++ b/apps/web/src/components/presentation/form/FormInput.tsx @@ -0,0 +1,88 @@ +import { useFormContext } from 'react-hook-form'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useState } from 'react'; +import { CheckIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import FormError from './FormError'; + +export interface FormInputProps extends Omit, 'type'> { + name: string; + label: string; + type?: 'text' | 'textarea' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local'; + rows?: number; +} + +export function FormInput({ + name, + label, + type = 'text', + rows = 4, + className, + ...restProps +}: FormInputProps) { + const [isFocused, setIsFocused] = useState(false); + const { + register, + formState: { errors }, + watch, + setValue, + trigger, // Add trigger from useFormContext + } = useFormContext(); + const handleBlur = async () => { + setIsFocused(false); + await trigger(name); // Trigger validation for this field + }; + const value = watch(name); + const error = errors[name]?.message as string; + const isValid = value && !error; + + const inputClasses = ` + w-full rounded-md border bg-white p-2 pr-8 outline-none shadow-sm + transition-all duration-300 ease-out placeholder:text-gray-400 + ${error ? 'border-red-500 focus:border-red-500 focus:ring-red-200' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-200'} + ${isFocused ? 'ring-2 ring-opacity-50' : ''} + ${className || ''} + `; + + const InputElement = type === 'textarea' ? 'textarea' : 'input'; + + return ( +
+
+ + {restProps.maxLength && ( + + {value?.length || 0}/{restProps.maxLength} + + )} +
+ +
+ setIsFocused(true)} + onBlur={handleBlur} + className={inputClasses} + /> + +
+ {value && isFocused && ( + + )} + {isValid && } +
+ +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/presentation/form/FormSelect.tsx b/apps/web/src/components/presentation/form/FormSelect.tsx new file mode 100644 index 0000000..77d2f7a --- /dev/null +++ b/apps/web/src/components/presentation/form/FormSelect.tsx @@ -0,0 +1,117 @@ +import { useFormContext } from 'react-hook-form'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useEffect, useState, useRef } from 'react'; +import { ChevronUpDownIcon, CheckIcon } from '@heroicons/react/24/outline'; +import FormError from './FormError'; + +interface Option { + value: string; + label: string; +} + +interface FormSelectProps { + name: string; + label: string; + options: Option[]; + placeholder?: string; +} + +const ANIMATIONS = { + dropdown: { + initial: { opacity: 0, y: -4 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -4 }, + transition: { duration: 0.15 } + }, + error: { + initial: { opacity: 0, y: -8 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -8 }, + transition: { duration: 0.2 } + } +}; + +export function FormSelect({ name, label, options, placeholder = '请选择' }: FormSelectProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const { + register, + formState: { errors }, + watch, + setValue, + } = useFormContext(); + + const value = watch(name); + const error = errors[name]?.message as string; + const selectedOption = options.find(opt => opt.value === value); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (!dropdownRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const getInputClasses = (hasError: boolean) => ` + w-full rounded-md border bg-white + transition-all duration-300 ease-out + p-2 pr-8 outline-none cursor-pointer + ${hasError ? 'border-red-500 focus:border-red-500 focus:ring-red-200' + : 'border-gray-300 focus:border-blue-500 focus:ring-blue-200'} + ${isOpen ? 'ring-2 ring-opacity-50' : ''} + placeholder:text-gray-400 shadow-sm + `; + + return ( +
+ +
+ +
setIsOpen(!isOpen)} + > + {selectedOption?.label || {placeholder}} +
+ + + + + {isOpen && ( + + {options.map((option) => ( +
{ + setValue(name, option.value); + setIsOpen(false); + }} + > + {option.label} + {value === option.value && } +
+ ))} +
+ )} +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/presentation/space/Empty.tsx b/apps/web/src/components/presentation/space/Empty.tsx new file mode 100644 index 0000000..0481a48 --- /dev/null +++ b/apps/web/src/components/presentation/space/Empty.tsx @@ -0,0 +1,25 @@ +import { EmptyStateIllustration } from "../EmptyStateIllustration"; + +interface EmptyStateProps { + title?: string; + description?: string; + illustration?: React.ReactNode; +} + +export const EmptyState = ({ + title = "暂无数据", + description = "当前列表为空,请稍后再试", + illustration: Illustration = +}: EmptyStateProps) => { + return ( +
+ {Illustration} +

+ {title} +

+

+ {description} +

+
+ ); +}; \ No newline at end of file diff --git a/apps/web/src/components/presentation/user/Avatar.tsx b/apps/web/src/components/presentation/user/Avatar.tsx new file mode 100644 index 0000000..0693169 --- /dev/null +++ b/apps/web/src/components/presentation/user/Avatar.tsx @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; + +interface AvatarProps { + src?: string; + name?: string; + size?: number; + className?: string; +} + +export function Avatar({ src, name = '', size = 40, className = '' }: AvatarProps) { + const initials = useMemo(() => { + return name + .split(/\s+|(?=[A-Z])/) + .map(word => word[0]) + .slice(0, 2) + .join('') + .toUpperCase(); + }, [name]); + + const backgroundColor = useMemo(() => { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + const hue = hash % 360; + return `hsl(${hue}, 70%, 50%)`; + }, [name]); + + return ( +
+ {src ? ( + {name} + ) : ( +
+ {initials} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/hooks/useClickOutside.ts b/apps/web/src/hooks/useClickOutside.ts new file mode 100644 index 0000000..bda224e --- /dev/null +++ b/apps/web/src/hooks/useClickOutside.ts @@ -0,0 +1,14 @@ +import { useEffect, RefObject } from 'react'; + +export function useClickOutside(ref: RefObject, handler: () => void) { + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (ref.current && !ref.current.contains(event.target as Node)) { + handler(); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [ref, handler]); +} \ No newline at end of file diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index f9eb2a8..16b36ec 100755 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -10,10 +10,15 @@ import DepartmentAdminPage from "../app/admin/department/page"; import TermAdminPage from "../app/admin/term/page"; import StaffAdminPage from "../app/admin/staff/page"; import RoleAdminPage from "../app/admin/role/page"; -import MainLayoutPage from "../app/layout"; import WithAuth from "../components/utils/with-auth"; import LoginPage from "../app/login"; import BaseSettingPage from "../app/admin/base-setting/page"; + +import CoursesPage from "../app/main/courses/page"; +import { CoursePage } from "../app/main/course/page"; +import { CourseEditorPage } from "../app/main/course/editor/page"; +import { MainLayout } from "../components/layout/main/MainLayout"; + interface CustomIndexRouteObject extends IndexRouteObject { name?: string; breadcrumb?: string; @@ -37,7 +42,6 @@ export type CustomRouteObject = export const routes: CustomRouteObject[] = [ { path: "/", - element: , errorElement: , handle: { crumb() { @@ -45,6 +49,18 @@ export const routes: CustomRouteObject[] = [ }, }, children: [ + { + path: "courses", + index: true, + element: + }, + { + path: "course", + children: [{ + path: "manage", + element: + }] + }, { path: "admin", children: [ diff --git a/docker-compose.example.yml b/docker-compose.example.yml index c0edd0f..edd65ec 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -6,7 +6,7 @@ services: ports: - "5432:5432" environment: - - POSTGRES_DB=defender_app + - POSTGRES_DB=app - POSTGRES_USER=root - POSTGRES_PASSWORD=Letusdoit000 volumes: @@ -81,14 +81,14 @@ services: # environment: # - VITE_APP_SERVER_IP=192.168.79.77 # - VITE_APP_VERSION=0.3.0 - # - VITE_APP_APP_NAME=两道防线管理后台 + # - VITE_APP_APP_NAME=烽火慕课 # server: # image: td-server:latest # ports: # - "3000:3000" # - "3001:3001" # environment: - # - DATABASE_URL=postgresql://root:Letusdoit000@db:5432/defender_app?schema=public + # - DATABASE_URL=postgresql://root:Letusdoit000@db:5432/app?schema=public # - REDIS_HOST=redis # - REDIS_PORT=6379 # - REDIS_PASSWORD=Letusdoit000 diff --git a/packages/client/src/api/hooks/index.ts b/packages/client/src/api/hooks/index.ts index aa38d70..d6f1d1d 100644 --- a/packages/client/src/api/hooks/index.ts +++ b/packages/client/src/api/hooks/index.ts @@ -8,4 +8,5 @@ export * from "./useTransform" export * from "./useTaxonomy" export * from "./useVisitor" export * from "./useMessage" -export * from "./usePost" \ No newline at end of file +export * from "./usePost" +export * from "./useCourse" \ No newline at end of file diff --git a/packages/client/src/api/hooks/useCourse.ts b/packages/client/src/api/hooks/useCourse.ts new file mode 100644 index 0000000..6718491 --- /dev/null +++ b/packages/client/src/api/hooks/useCourse.ts @@ -0,0 +1,49 @@ +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.findMany.invalidate(); + utils.course.findManyWithCursor.invalidate(); + }, + }), + update: api.course.update.useMutation({ + onSuccess: () => { + utils.course.findMany.invalidate(); + utils.course.findManyWithCursor.invalidate(); + }, + }), + createMany: api.course.createMany.useMutation({ + onSuccess: () => { + utils.course.findMany.invalidate(); + utils.course.findManyWithCursor.invalidate(); + }, + }), + deleteMany: api.course.deleteMany.useMutation({ + onSuccess: () => { + utils.course.findMany.invalidate(); + utils.course.findManyWithCursor.invalidate(); + }, + }), + softDeleteByIds: api.course.softDeleteByIds.useMutation({ + onSuccess: () => { + utils.course.findMany.invalidate(); + utils.course.findManyWithCursor.invalidate(); + }, + }), + updateOrder: api.course.updateOrder.useMutation({ + onSuccess: () => { + utils.course.findMany.invalidate(); + utils.course.findManyWithCursor.invalidate(); + }, + }) + }; +} \ No newline at end of file diff --git a/packages/client/src/api/hooks/useRole.ts b/packages/client/src/api/hooks/useRole.ts index e31870b..51523bd 100755 --- a/packages/client/src/api/hooks/useRole.ts +++ b/packages/client/src/api/hooks/useRole.ts @@ -1,37 +1,33 @@ -import { getQueryKey } from "@trpc/react-query"; -import { api } from "../trpc"; // Adjust path as necessary -import { useQueryClient } from "@tanstack/react-query"; +import { api } from "../trpc"; export function useRole() { - const queryClient = useQueryClient(); - const queryKey = getQueryKey(api.role); - - const create = api.role.create.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - - - const update = api.role.update.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - - const deleteMany = api.role.deleteMany.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }) - } - }) - const paginate = (page: number, pageSize: number) => { - return api.role.paginate.useQuery({ page, pageSize }); - }; + const utils = api.useUtils(); return { - create, - update, - paginate, - deleteMany + // Create mutations + create: api.role.create.useMutation({ + onSuccess: () => utils.role.findMany.invalidate(), + }), + createMany: api.role.createMany.useMutation({ + onSuccess: () => utils.role.findMany.invalidate(), + }), + update: api.role.update.useMutation({ + onSuccess: () => utils.role.findMany.invalidate(), + }), + // Delete mutation + softDeleteByIds: api.role.softDeleteByIds.useMutation({ + onSuccess: () => utils.role.findMany.invalidate(), + }), + + // Update mutation + updateOrder: api.role.updateOrder.useMutation({ + onSuccess: () => utils.role.findMany.invalidate(), + }), + + // Queries + findFirst: api.role.findFirst.useQuery, + findMany: api.role.findMany.useQuery, + findManyWithCursor: api.role.findManyWithCursor.useQuery, + findManyWithPagination: api.role.findManyWithPagination.useQuery, }; -} +} \ No newline at end of file diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 21a4c55..943499a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -5,4 +5,5 @@ export * from "./io" export * from "./providers" export * from "./hooks" export * from "./websocket" -export * from "./event" \ No newline at end of file +export * from "./event" +export * from "./types" \ No newline at end of file diff --git a/packages/client/src/types/index.ts b/packages/client/src/types/index.ts new file mode 100644 index 0000000..91d491b --- /dev/null +++ b/packages/client/src/types/index.ts @@ -0,0 +1,5 @@ +export interface NavItem { + icon?: React.ReactNode; + label: string; + path: string; +} \ No newline at end of file diff --git a/packages/common/.env.example b/packages/common/.env.example index 0d37787..8183f56 100644 --- a/packages/common/.env.example +++ b/packages/common/.env.example @@ -1 +1 @@ -DATABASE_URL="postgresql://root:Letusdoit000@localhost:5432/defender_app?schema=public" \ No newline at end of file +DATABASE_URL="postgresql://root:Letusdoit000@localhost:5432/app?schema=public" \ No newline at end of file diff --git a/packages/common/prisma/schema.prisma b/packages/common/prisma/schema.prisma index d64e5bb..72831eb 100644 --- a/packages/common/prisma/schema.prisma +++ b/packages/common/prisma/schema.prisma @@ -99,14 +99,13 @@ model Staff { deletedAt DateTime? @map("deleted_at") officerId String? @map("officer_id") - watchedPost Post[] @relation("post_watch_staff") - visits Visit[] - posts Post[] - sentMsgs Message[] @relation("message_sender") - receivedMsgs Message[] @relation("message_receiver") - + watchedPost Post[] @relation("post_watch_staff") + visits Visit[] + posts Post[] + sentMsgs Message[] @relation("message_sender") + receivedMsgs Message[] @relation("message_receiver") + registerToken String? enrollments Enrollment[] - courseReviews CourseReview[] teachedCourses CourseInstructor[] @@index([officerId]) @@ -197,32 +196,43 @@ model AppConfig { } model Post { - id String @id @default(cuid()) - type String? - title String? - content String? - author Staff? @relation(fields: [authorId], references: [id]) - authorId String? - domainId String? - referenceId String? - attachments String[] @default([]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - visits Visit[] - watchableStaffs Staff[] @relation("post_watch_staff") - watchableDepts Department[] @relation("post_watch_dept") + // 字符串类型字段 + id String @id @default(cuid()) // 帖子唯一标识,使用 cuid() 生成默认值 + type String? // 帖子类型,可为空 + title String? // 帖子标题,可为空 + content String? // 帖子内容,可为空 + domainId String? @map("domain_id") + // 日期时间类型字段 + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") // 删除时间,可为空 - parentId String? - parent Post? @relation("PostChildren", fields: [parentId], references: [id]) - children Post[] @relation("PostChildren") - deletedAt DateTime? @map("deleted_at") - Lecture Lecture? @relation(fields: [lectureId], references: [id]) - lectureId String? + // 整数类型字段 + rating Int // 评分(1-5星) + + // 关系类型字段 + authorId String? @map("author_id") + author Staff? @relation(fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型 + + visits Visit[] // 访问记录,关联 Visit 模型 + + courseId String @map("course_id") + course Course @relation(fields: [courseId], references: [id]) // 关联课程,关联 Course 模型 + + parentId String? @map("parent_id") + parent Post? @relation("PostChildren", fields: [parentId], references: [id]) // 父级帖子,关联 Post 模型 + children Post[] @relation("PostChildren") // 子级帖子列表,关联 Post 模型 + + lectureId String? @map("lecture_id") + lecture Lecture? @relation(fields: [lectureId], references: [id]) // 关联讲座,关联 Lecture 模型 + resources Resource[] // 附件列表 + + watchableStaffs Staff[] @relation("post_watch_staff") // 可观看的员工列表,关联 Staff 模型 + watchableDepts Department[] @relation("post_watch_dept") // 可观看的部门列表,关联 Department 模型 // 复合索引 @@index([type, domainId]) // 类型和域组合查询 @@index([authorId, type]) // 作者和类型组合查询 - @@index([referenceId, type]) // 引用ID和类型组合查询 @@index([parentId, type]) // 父级帖子和创建时间索引 // 时间相关索引 @@index([createdAt]) // 按创建时间倒序索引 @@ -230,284 +240,244 @@ model Post { } model Message { - id String @id @default(cuid()) - url String? - intent String? - option Json? - senderId String? @map("sender_id") - messageType String? - sender Staff? @relation(name: "message_sender", fields: [senderId], references: [id]) - title String? - content String? - receivers Staff[] @relation("message_receiver") - visits Visit[] - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime? @updatedAt @map("updated_at") + id String @id @default(cuid()) + url String? + intent String? + option Json? + senderId String? @map("sender_id") + type String? + sender Staff? @relation(name: "message_sender", fields: [senderId], references: [id]) + title String? + content String? + receivers Staff[] @relation("message_receiver") + visits Visit[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime? @updatedAt @map("updated_at") - @@index([messageType, createdAt]) + @@index([type, createdAt]) @@map("message") } model Visit { - id String @id @default(cuid()) - visitType String? @map("visit_type") - visitorId String @map("visitor_id") - visitor Staff @relation(fields: [visitorId], references: [id]) - postId String? @map("post_id") - post Post? @relation(fields: [postId], references: [id]) - message Message? @relation(fields: [messageId], references: [id]) - messageId String? @map("message_id") - views Int @default(1) - createdAt DateTime? @default(now()) @map("created_at") - updatedAt DateTime? @updatedAt @map("updated_at") - sourceIP String? @map("source_ip") + id String @id @default(cuid()) @map("id") + type String? + views Int @default(1) @map("views") + sourceIP String? @map("source_ip") + // 关联关系 + visitorId String @map("visitor_id") + visitor Staff @relation(fields: [visitorId], references: [id]) + postId String? @map("post_id") + post Post? @relation(fields: [postId], references: [id]) + message Message? @relation(fields: [messageId], references: [id]) + messageId String? @map("message_id") + enrollment Enrollment? @relation(fields: [enrollmentId], references: [id], onDelete: Cascade) + enrollmentId String? @map("enrollment_id") // 报名记录ID + lecture Lecture? @relation(fields: [lectureId], references: [id], onDelete: Cascade) + lectureId String? @map("lecture_id") // 课时ID - @@index([postId, visitType, visitorId]) - @@index([messageId, visitType, visitorId]) + // 学习数据 + progress Float? @default(0) @map("progress") // 完成进度(0-100%) + isCompleted Boolean? @default(false) @map("is_completed") // 是否完成 + lastPosition Int? @default(0) @map("last_position") // 视频播放位置(秒) + totalWatchTime Int? @default(0) @map("total_watch_time") // 总观看时长(秒) + // 时间记录 + lastWatchedAt DateTime? @map("last_watched_at") // 最后观看时间 + createdAt DateTime @default(now()) @map("created_at") // 创建时间 + updatedAt DateTime @updatedAt @map("updated_at") // 更新时间 + + @@unique([enrollmentId, lectureId]) // 确保每个报名只有一条课时进度 + @@index([isCompleted]) // 完成状态索引 + @@index([lastWatchedAt]) // 最后观看时间索引 + @@index([postId, type, visitorId]) + @@index([messageId, type, visitorId]) @@map("visit") } - model Course { - id String @id @default(cuid()) // 课程唯一标识符 - title String // 课程标题 - subTitle String? // 课程副标题(可选) - description String // 课程详细描述 - thumbnail String? // 课程封面图片URL(可选) - level String // 课程难度等级 + id String @id @default(cuid()) @map("id") // 课程唯一标识符 + title String? @map("title") // 课程标题 + subTitle String? @map("sub_title") // 课程副标题(可选) + description String? @map("description") // 课程详细描述 + thumbnail String? @map("thumbnail") // 课程封面图片URL(可选) + level String? @map("level") // 课程难度等级 // 课程内容组织结构 terms Term[] @relation("course_term") // 课程学期 instructors CourseInstructor[] // 课程讲师团队 sections Section[] // 课程章节结构 enrollments Enrollment[] // 学生报名记录 - reviews CourseReview[] // 学员课程评价 + reviews Post[] // 学员课程评价 // 课程规划与目标设定 - requirements String[] // 课程学习前置要求 - objectives String[] // 具体的学习目标 - skills String[] // 课程结束后可掌握的技能 - audiences String[] // 目标受众群体描述 + requirements String[] @map("requirements") // 课程学习前置要求 + objectives String[] @map("objectives") // 具体的学习目标 + skills String[] @map("skills") // 课程结束后可掌握的技能 + audiences String[] @map("audiences") // 目标受众群体描述 // 课程统计指标 - totalDuration Int @default(0) // 课程总时长(分钟) - totalLectures Int @default(0) // 总课时数 - averageRating Float @default(0) // 平均评分(1-5分) - numberOfReviews Int @default(0) // 评价总数 - numberOfStudents Int @default(0) // 学习人数 - completionRate Float @default(0) // 完课率(0-100%) + totalDuration Int? @default(0) @map("total_duration") // 课程总时长(分钟) + totalLectures Int? @default(0) @map("total_lectures") // 总课时数 + averageRating Float? @default(0) @map("average_rating") // 平均评分(1-5分) + numberOfReviews Int? @default(0) @map("number_of_reviews") // 评价总数 + numberOfStudents Int? @default(0) @map("number_of_students") // 学习人数 + completionRate Float? @default(0) @map("completion_rate") // 完课率(0-100%) // 课程状态管理 - status String // 课程状态(如:草稿/已发布/已归档) - isFeatured Boolean @default(false) // 是否为精选推荐课程 + status String? @map("status") // 课程状态(如:草稿/已发布/已归档) + isFeatured Boolean? @default(false) @map("is_featured") // 是否为精选推荐课程 // 生命周期时间戳 - createdAt DateTime @default(now()) // 创建时间 - updatedAt DateTime @updatedAt // 最后更新时间 - publishedAt DateTime? // 发布时间 - archivedAt DateTime? // 归档时间 - deletedAt DateTime? // 软删除时间 + createdAt DateTime? @default(now()) @map("created_at") // 创建时间 + updatedAt DateTime? @updatedAt @map("updated_at") // 最后更新时间 + publishedAt DateTime? @map("published_at") // 发布时间 + archivedAt DateTime? @map("archived_at") // 归档时间 + deletedAt DateTime? @map("deleted_at") // 软删除时间 // 数据库索引优化 @@index([status]) // 课程状态索引,用于快速筛选 @@index([level]) // 难度等级索引,用于分类查询 @@index([isFeatured]) // 精选标记索引,用于首页推荐 + @@map("course") } - model Section { - id String @id @default(cuid()) // 章节唯一标识符 - title String // 章节标题 - description String? // 章节描述(可选) - objectives String[] // 本章节的具体学习目标 - order Float? @default(0) // 章节排序权重 + id String @id @default(cuid()) @map("id") + title String @map("title") + description String? @map("description") + objectives String[] @map("objectives") + order Float? @default(0) @map("order") + totalDuration Int @default(0) @map("total_duration") + totalLectures Int @default(0) @map("total_lectures") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") // 关联关系 course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) - courseId String // 所属课程ID - lectures Lecture[] // 包含的所有课时 + courseId String @map("course_id") + lectures Lecture[] - // 章节统计数据 - totalDuration Int @default(0) // 本章节总时长(分钟) - totalLectures Int @default(0) // 本章节课时总数 - - // 时间管理 - createdAt DateTime @default(now()) // 创建时间 - updatedAt DateTime @updatedAt // 更新时间 - deletedAt DateTime? // 软删除时间 - - @@index([courseId, order]) // 复合索引:用于按课程ID和顺序快速查询 + @@index([courseId, order]) + @@map("section") } - model Lecture { - id String @id @default(cuid()) // 课时唯一标识符 - title String // 课时标题 - description String? // 课时描述(可选) - order Float? @default(0) // 课时排序权重 - duration Int // 学习时长(分钟) - type String // 课时类型(video/article) + id String @id @default(cuid()) @map("id") + title String @map("title") + description String? @map("description") + order Float? @default(0) @map("order") + duration Int @map("duration") + type String @map("type") + content String? @map("content") + videoUrl String? @map("video_url") + videoThumbnail String? @map("video_thumbnail") + publishedAt DateTime? @map("published_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") - // 课时内容 - content String? // Markdown格式文章内容 - videoUrl String? // 视频URL地址 - videoThumbnail String? // 视频封面图URL + // 关联关系 + resources Resource[] + section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade) + sectionId String @map("section_id") + comments Post[] + visits Visit[] - // 关联内容 - resources Resource[] // 课时附属资源 - section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade) - sectionId String // 所属章节ID - comments Post[] // 课时评论 - progress LectureProgress[] // 学习进度记录 - - // 时间管理 - publishedAt DateTime? // 发布时间 - createdAt DateTime @default(now()) // 创建时间 - updatedAt DateTime @updatedAt // 更新时间 - deletedAt DateTime? // 软删除时间 - - @@index([sectionId, order]) // 章节内课时排序索引 - @@index([type, publishedAt]) // 课时类型和发布时间复合索引 + @@index([sectionId, order]) + @@index([type, publishedAt]) + @@map("lecture") } - model Enrollment { - id String @id @default(cuid()) // 报名记录唯一标识符 - status String // 报名状态(如:进行中/已完成/已过期) + id String @id @default(cuid()) @map("id") + status String @map("status") + completionRate Float @default(0) @map("completion_rate") + + lastAccessedAt DateTime? @map("last_accessed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + completedAt DateTime? @map("completed_at") // 关联关系 - student Staff @relation(fields: [studentId], references: [id]) - studentId String // 学员ID - course Course @relation(fields: [courseId], references: [id]) - courseId String // 课程ID - progress LectureProgress[] // 课时学习进度记录 + student Staff @relation(fields: [studentId], references: [id]) + studentId String @map("student_id") + course Course @relation(fields: [courseId], references: [id]) + courseId String @map("course_id") + visits Visit[] - // 学习数据统计 - completionRate Float @default(0) // 课程完成度(0-100%) - lastAccessedAt DateTime? // 最后访问时间 - - // 时间管理 - createdAt DateTime @default(now()) // 报名时间 - updatedAt DateTime @updatedAt // 更新时间 - completedAt DateTime? // 完课时间 - - - @@unique([studentId, courseId]) // 确保学员不会重复报名同一课程 - @@index([status]) // 报名状态索引 - @@index([completedAt]) // 完课时间索引 -} - - -model LectureProgress { - id String @id @default(cuid()) // 进度记录唯一标识符 - progress Float @default(0) // 完成进度(0-100%) - isCompleted Boolean @default(false) // 是否完成 - - // 关联关系 - enrollment Enrollment @relation(fields: [enrollmentId], references: [id], onDelete: Cascade) - enrollmentId String // 报名记录ID - lecture Lecture @relation(fields: [lectureId], references: [id], onDelete: Cascade) - lectureId String // 课时ID - - // 学习数据 - lastPosition Int @default(0) // 视频播放位置(秒) - viewCount Int @default(0) // 观看次数 - readCount Int @default(0) // 阅读次数 - totalWatchTime Int @default(0) // 总观看时长(秒) - - // 时间记录 - lastWatchedAt DateTime? // 最后观看时间 - createdAt DateTime @default(now()) // 创建时间 - updatedAt DateTime @updatedAt // 更新时间 - - @@unique([enrollmentId, lectureId]) // 确保每个报名只有一条课时进度 - @@index([isCompleted]) // 完成状态索引 - @@index([lastWatchedAt]) // 最后观看时间索引 + @@unique([studentId, courseId]) + @@index([status]) + @@index([completedAt]) + @@map("enrollment") } model CourseInstructor { - course Course @relation(fields: [courseId], references: [id]) - courseId String // 课程ID - instructor Staff @relation(fields: [instructorId], references: [id]) - instructorId String // 讲师ID - role String // 讲师角色 - createdAt DateTime @default(now()) // 创建时间 - order Float? @default(0) // 讲师显示顺序 + courseId String @map("course_id") + instructorId String @map("instructor_id") + role String @map("role") + createdAt DateTime @default(now()) @map("created_at") + order Float? @default(0) @map("order") - @@id([courseId, instructorId]) // 联合主键 + course Course @relation(fields: [courseId], references: [id]) + instructor Staff @relation(fields: [instructorId], references: [id]) + + @@id([courseId, instructorId]) + @@map("course_instructor") } - -model CourseReview { - id String @id @default(cuid()) // 评价唯一标识符 - rating Int // 评分(1-5星) - content String? // 评价内容 - student Staff @relation(fields: [studentId], references: [id]) - studentId String // 评价学员ID - course Course @relation(fields: [courseId], references: [id]) - courseId String // 课程ID - helpfulCount Int @default(0) // 评价点赞数 - createdAt DateTime @default(now()) // 评价时间 - updatedAt DateTime @updatedAt // 更新时间 - - @@unique([studentId, courseId]) // 确保学员对同一课程只能评价一次 - @@index([rating]) // 评分索引 -} - - model Resource { - id String @id @default(cuid()) // 资源唯一标识符 - title String // 资源标题 - description String? // 资源描述 - type String // 资源类型 - url String // 资源URL + id String @id @default(cuid()) @map("id") + title String @map("title") + description String? @map("description") + type String @map("type") + url String @map("url") + fileType String? @map("file_type") + fileSize Int? @map("file_size") + downloadCount Int @default(0) @map("download_count") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - fileType String? // 文件MIME类型 - fileSize Int? // 文件大小(bytes) + lectures Lecture[] + posts Post[] - lecture Lecture @relation(fields: [lectureId], references: [id], onDelete: Cascade) - lectureId String // 所属课时ID - downloadCount Int @default(0) // 下载次数 - createdAt DateTime @default(now()) // 创建时间 - updatedAt DateTime @updatedAt // 更新时间 - - @@index([lectureId, type]) // 课时资源类型复合索引 + @@index([type]) + @@map("resource") } model Node { - id String @id @default(cuid()) - title String - description String? - type String - // 节点之间的关系 - sourceEdges NodeEdge[] @relation("from_node") - targetEdges NodeEdge[] @relation("to_node") - style Json? - position Json? // 存储节点在画布中的位置 {x: number, y: number} - data Json? + id String @id @default(cuid()) @map("id") + title String @map("title") + description String? @map("description") + type String @map("type") + style Json? @map("style") + position Json? @map("position") + data Json? @map("data") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + // 关联关系 + sourceEdges NodeEdge[] @relation("source_node") + targetEdges NodeEdge[] @relation("target_node") + + @@map("node") } -// 节点之间的关系 model NodeEdge { - id String @id @default(cuid()) - // 关系的起点和终点 - source Node @relation("from_node", fields: [sourceId], references: [id], onDelete: Cascade) - sourceId String - target Node @relation("to_node", fields: [targetId], references: [id], onDelete: Cascade) - targetId String - // 关系属性 - type String? - label String? - description String? - // 自定义边的样式(可选) - style Json? // 存储边的样式,如 {color: string, strokeWidth: number} - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) @map("id") + type String? @map("type") + label String? @map("label") + description String? @map("description") + style Json? @map("style") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + source Node @relation("source_node", fields: [sourceId], references: [id], onDelete: Cascade) + sourceId String @map("source_id") + target Node @relation("target_node", fields: [targetId], references: [id], onDelete: Cascade) + targetId String @map("target_id") @@unique([sourceId, targetId, type]) @@index([sourceId]) @@index([targetId]) + @@map("node_edge") } diff --git a/packages/common/src/db.ts b/packages/common/src/db.ts index edcdac0..7390d70 100755 --- a/packages/common/src/db.ts +++ b/packages/common/src/db.ts @@ -1,18 +1,19 @@ import { PrismaClient } from "@prisma/client"; + let prisma: PrismaClient | null = null; + const createPrismaClient = () => { return new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"], }); }; + export const db = (() => { if (typeof window === 'undefined') { if (!prisma) { prisma = createPrismaClient(); } return prisma; - } else { - // Optional: You can throw an error or return null to indicate that this should not be used on the client side. - return null } -})(); + throw new Error('PrismaClient is not available in browser environment'); +})() as PrismaClient; \ No newline at end of file diff --git a/packages/common/src/enum.ts b/packages/common/src/enum.ts index 8ec1b44..800dc46 100755 --- a/packages/common/src/enum.ts +++ b/packages/common/src/enum.ts @@ -27,6 +27,11 @@ export enum ObjectType { MESSAGE = "message", POST = "post", VISIT = "visit", + COURSE = "course", + SECTION = "section", + LECTURE = "lecture", + ENROLLMENT = "enrollment", + RESOURCE = "resource" } export enum RolePerms { // Create Permissions 创建权限 diff --git a/packages/common/src/schema.ts b/packages/common/src/schema.ts index 91d3a6e..1a8c67b 100755 --- a/packages/common/src/schema.ts +++ b/packages/common/src/schema.ts @@ -44,8 +44,8 @@ export const UpdateOrderSchema = z.object({ overId: z.string(), }); export const RowRequestSchema = z.object({ - startRow: z.number().nullish(), - endRow: z.number().nullish(), + startRow: z.number(), + endRow: z.number(), rowGroupCols: z.array( z.object({ id: z.string(), @@ -58,12 +58,12 @@ export const RowRequestSchema = z.object({ id: z.string().nullish(), displayName: z.string().nullish(), aggFunc: z.string().nullish(), - field: z.string().nullish(), + field: z.string(), }) ), pivotCols: z.array(z.any()).nullish(), pivotMode: z.boolean().nullish(), - groupKeys: z.array(z.any()).nullish(), + groupKeys: z.array(z.any()), filterModel: z.any().nullish(), sortModel: z.array(SortModel).nullish(), includeDeleted: z.boolean().nullish() @@ -186,7 +186,7 @@ export const TransformMethodSchema = { domainId: z.string().nullish(), parentId: z.string().nullish(), }), - + }; export const TermMethodSchema = { getRows: RowRequestSchema.extend({ diff --git a/packages/common/src/select.ts b/packages/common/src/select.ts index f0205e9..ae90c52 100644 --- a/packages/common/src/select.ts +++ b/packages/common/src/select.ts @@ -5,8 +5,7 @@ export const postDetailSelect: Prisma.PostSelect = { type: true, title: true, content: true, - attachments: true, - referenceId: true, + resources: true, watchableDepts: true, watchableStaffs: true, updatedAt: true, @@ -35,9 +34,8 @@ export const postUnDetailSelect: Prisma.PostSelect = { type: true, title: true, content: true, - attachments: true, + resources: true, updatedAt: true, - referenceId: true, author: { select: { id: true, diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 10bed8a..fdc4d3d 100755 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -5,6 +5,10 @@ import type { Message, Post, RoleMap, + Section, + Lecture, + Course, + Enrollment, } from "@prisma/client"; import { SocketMsgType, RolePerms } from "./enum"; import { RowRequestSchema } from "./schema"; @@ -51,10 +55,6 @@ export type UserProfile = Staff & { domain: Department; department: Department; }; -export interface ObjectWithId { - id: string; // Ensure the row contains at least an 'id' field - [key: string]: any; // Allow additional fields as needed -} export interface DataNode { title: any; @@ -116,7 +116,7 @@ export type PostComment = { authorId: string; domainId: string; referenceId: string; - attachments: string[]; + resources: string[]; createdAt: Date; updatedAt: Date; parentId: string; @@ -156,16 +156,17 @@ export type DepartmentDto = Department & { export type RoleMapDto = RoleMap & { staff: StaffDto } +export type SectionDto = Section & { + lectures: Lecture[] +} +export type CourseDto = Course & { + enrollments: Enrollment[] +} export interface BaseSetting { - // termAvatars?: Record; //termId - url appConfig?: { splashScreen?: string; devDept?: string; - // fakeError?: { - // title?: string | null; - // content?: string | null; - // time?: string | null; - // }; + }; } export type RowModelResult = { @@ -173,7 +174,3 @@ export type RowModelResult = { rowCount: number; }; export type RowModelRequest = z.infer; -export interface ChangedRows { - rows: any[]; - op: "add" | "update" | "remove"; -} diff --git a/packages/iconer/src/generated/icon-names.ts b/packages/iconer/src/generated/icon-names.ts index 46915e0..a77538b 100644 --- a/packages/iconer/src/generated/icon-names.ts +++ b/packages/iconer/src/generated/icon-names.ts @@ -1,2 +1,2 @@ -export type IconName = 'align-center' | 'align-justify' | 'align-left' | 'align-right' | 'arrow-drop-down' | 'bold' | 'check' | 'content' | 'copy' | 'edit' | 'get-text' | 'home' | 'horizontal-rule' | 'image' | 'italic' | 'link-off' | 'link' | 'logout' | 'react' | 'redo' | 'share' | 'strike' | 'text-indent' | 'text-outdent' | 'underline' | 'undo' | 'zoomin' | 'zoomout' - \ No newline at end of file +export type IconName = 'account-location' | 'add' | 'admin-outlined' | 'airport' | 'align-center' | 'align-justify' | 'align-left' | 'align-right' | 'approve' | 'arrow-drop-down' | 'blocks-group' | 'bold' | 'caret-right' | 'category-outline' | 'check-one' | 'check' | 'config' | 'content' | 'copy' | 'cube-duotone' | 'date-time' | 'delete' | 'edit' | 'error-duotone' | 'error-outline' | 'exit' | 'filter' | 'fluent-person' | 'get-text' | 'group-work' | 'health-circle' | 'history' | 'home' | 'horizontal-rule' | 'image' | 'inbox' | 'italic' | 'link-off' | 'link' | 'list' | 'logout' | 'loop' | 'more' | 'note' | 'number-symbol' | 'org' | 'people-32' | 'people-group' | 'people-plus' | 'people' | 'person-board' | 'person-hair' | 'person-home' | 'plane-takeoff' | 'plane' | 'progress' | 'radar-chart' | 'react' | 'redo' | 'right-line' | 'seal-check' | 'search' | 'setting' | 'share' | 'strike' | 'subject-rounded' | 'sum' | 'target' | 'text-indent' | 'text-outdent' | 'time' | 'underline' | 'undo' | 'user-id' | 'work' | 'zoomin' | 'zoomout' + \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1de57e8..c888aee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,12 @@ importers: '@floating-ui/react': specifier: ^0.26.25 version: 0.26.28(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@heroicons/react': + specifier: ^2.2.0 + version: 2.2.0(react@18.2.0) + '@hookform/resolvers': + specifier: ^3.9.1 + version: 3.9.1(react-hook-form@7.54.2(react@18.2.0)) '@nicestack/client': specifier: workspace:^ version: link:../../packages/client @@ -285,7 +291,7 @@ importers: specifier: ^1.11.12 version: 1.11.13 framer-motion: - specifier: ^11.11.9 + specifier: ^11.15.0 version: 11.15.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) idb-keyval: specifier: ^6.2.1 @@ -296,9 +302,18 @@ importers: react: specifier: 18.2.0 version: 18.2.0 + react-beautiful-dnd: + specifier: ^13.1.1 + version: 13.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.54.2 + version: 7.54.2(react@18.2.0) + react-hot-toast: + specifier: ^2.4.1 + version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react-resizable: specifier: ^3.0.5 version: 3.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1174,6 +1189,16 @@ packages: '@floating-ui/utils@0.2.8': resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + '@heroicons/react@2.2.0': + resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} + peerDependencies: + react: '>= 16 || ^19.0.0-rc' + + '@hookform/resolvers@3.9.1': + resolution: {integrity: sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==} + peerDependencies: + react-hook-form: ^7.0.0 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -2046,6 +2071,9 @@ packages: '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + '@types/hoist-non-react-statics@3.3.6': + resolution: {integrity: sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==} + '@types/http-errors@2.0.4': resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} @@ -2097,6 +2125,9 @@ packages: '@types/react-dom@18.3.0': resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} + '@types/react-redux@7.1.34': + resolution: {integrity: sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==} + '@types/react@18.3.3': resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==} @@ -2956,6 +2987,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-box-model@1.2.1: + resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3603,6 +3637,11 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + goober@2.1.16: + resolution: {integrity: sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==} + peerDependencies: + csstype: ^3.0.10 + gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} @@ -3647,6 +3686,9 @@ packages: resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} engines: {node: '>=8'} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -4292,6 +4334,9 @@ packages: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + merge-descriptors@1.0.1: resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} @@ -4763,6 +4808,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + raf-schd@4.0.3: + resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -5002,6 +5050,13 @@ packages: react: '>=16.9.0' react-dom: '>=16.9.0' + react-beautiful-dnd@13.1.1: + resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==} + deprecated: 'react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672' + peerDependencies: + react: ^16.8.5 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0 + react-dom@18.2.0: resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -5018,12 +5073,40 @@ packages: react: '>= 16.3.0' react-dom: '>= 16.3.0' + react-hook-form@7.54.2: + resolution: {integrity: sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-hot-toast@2.4.1: + resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-redux@7.2.9: + resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==} + peerDependencies: + react: ^16.8.3 || ^17 || ^18 + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -5083,6 +5166,9 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redux@4.2.1: + resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -5509,6 +5595,9 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} @@ -5727,6 +5816,11 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-memo-one@1.1.3: + resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -6671,6 +6765,14 @@ snapshots: '@floating-ui/utils@0.2.8': {} + '@heroicons/react@2.2.0(react@18.2.0)': + dependencies: + react: 18.2.0 + + '@hookform/resolvers@3.9.1(react-hook-form@7.54.2(react@18.2.0))': + dependencies: + react-hook-form: 7.54.2(react@18.2.0) + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -7585,6 +7687,11 @@ snapshots: dependencies: '@types/node': 20.14.10 + '@types/hoist-non-react-statics@3.3.6': + dependencies: + '@types/react': 18.3.3 + hoist-non-react-statics: 3.3.2 + '@types/http-errors@2.0.4': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -7634,6 +7741,13 @@ snapshots: dependencies: '@types/react': 18.3.3 + '@types/react-redux@7.1.34': + dependencies: + '@types/hoist-non-react-statics': 3.3.6 + '@types/react': 18.3.3 + hoist-non-react-statics: 3.3.2 + redux: 4.2.1 + '@types/react@18.3.3': dependencies: '@types/prop-types': 15.7.12 @@ -8745,6 +8859,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-box-model@1.2.1: + dependencies: + tiny-invariant: 1.3.3 + cssesc@3.0.0: {} csstype@3.1.3: {} @@ -9494,6 +9612,10 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + goober@2.1.16(csstype@3.1.3): + dependencies: + csstype: 3.1.3 + gopd@1.0.1: dependencies: get-intrinsic: 1.2.4 @@ -9526,6 +9648,10 @@ snapshots: hexoid@1.0.0: {} + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + html-escaper@2.0.2: {} http-errors@2.0.0: @@ -10352,6 +10478,8 @@ snapshots: dependencies: fs-monkey: 1.0.6 + memoize-one@5.2.1: {} + merge-descriptors@1.0.1: {} merge-stream@2.0.0: {} @@ -10768,6 +10896,8 @@ snapshots: queue-microtask@1.2.3: {} + raf-schd@4.0.3: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -11102,6 +11232,20 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + react-beautiful-dnd@13.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + '@babel/runtime': 7.25.0 + css-box-model: 1.2.1 + memoize-one: 5.2.1 + raf-schd: 4.0.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-redux: 7.2.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + redux: 4.2.1 + use-memo-one: 1.1.3(react@18.2.0) + transitivePeerDependencies: + - react-native + react-dom@18.2.0(react@18.2.0): dependencies: loose-envify: 1.4.0 @@ -11127,10 +11271,36 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + react-hook-form@7.54.2(react@18.2.0): + dependencies: + react: 18.2.0 + + react-hot-toast@2.4.1(csstype@3.1.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + goober: 2.1.16(csstype@3.1.3) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - csstype + react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} + react-redux@7.2.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + '@babel/runtime': 7.25.0 + '@types/react-redux': 7.1.34 + hoist-non-react-statics: 3.3.2 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-is: 17.0.2 + optionalDependencies: + react-dom: 18.2.0(react@18.2.0) + react-refresh@0.14.2: {} react-resizable@3.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0): @@ -11197,6 +11367,10 @@ snapshots: dependencies: redis-errors: 1.2.0 + redux@4.2.1: + dependencies: + '@babel/runtime': 7.25.0 + reflect-metadata@0.2.2: {} regenerator-runtime@0.14.1: {} @@ -11689,6 +11863,8 @@ snapshots: through@2.3.8: {} + tiny-invariant@1.3.3: {} + tinycolor2@1.6.0: {} tinyexec@0.3.2: {} @@ -11914,6 +12090,10 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + use-memo-one@1.1.3(react@18.2.0): + dependencies: + react: 18.2.0 + util-deprecate@1.0.2: {} util@0.12.5: