diff --git a/apps/server/.env.example b/apps/server/.env.example index 30fc108..88068be 100755 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -11,4 +11,4 @@ DEADLINE_CRON="0 0 8 * * *" SERVER_PORT=3000 ADMIN_PHONE_NUMBER=13258117304 NODE_ENV=development -UPLOAD_DIR=/opt/projects/remooc/uploads \ No newline at end of file +UPLOAD_DIR=/opt/projects/re-mooc/uploads \ No newline at end of file diff --git a/apps/server/src/filters/exceptions.filter.ts b/apps/server/src/filters/exceptions.filter.ts index 9c82fdd..b08dc1a 100644 --- a/apps/server/src/filters/exceptions.filter.ts +++ b/apps/server/src/filters/exceptions.filter.ts @@ -1,4 +1,10 @@ -import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, +} from '@nestjs/common'; import { Response } from 'express'; @Catch() export class ExceptionsFilter implements ExceptionFilter { @@ -7,13 +13,15 @@ export class ExceptionsFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); - const status = exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; - const message = exception instanceof HttpException - ? exception.message - : 'Internal server error'; + const message = + exception instanceof HttpException + ? exception.message + : 'Internal server error'; response.status(status).json({ statusCode: status, diff --git a/apps/server/src/models/base/base.service.ts b/apps/server/src/models/base/base.service.ts index ffbcfc3..d1b8b16 100644 --- a/apps/server/src/models/base/base.service.ts +++ b/apps/server/src/models/base/base.service.ts @@ -37,10 +37,8 @@ export class BaseService< constructor( protected prisma: PrismaClient, protected objectType: string, - protected enableOrder: boolean = false - ) { - - } + protected enableOrder: boolean = false, + ) {} /** * Retrieves the name of the model dynamically. @@ -51,7 +49,7 @@ export class BaseService< return modelName; } private getModel(tx?: TransactionType): D { - return tx?.[this.objectType] || this.prisma[this.objectType] as D; + return tx?.[this.objectType] || (this.prisma[this.objectType] as D); } /** * Error handling helper function @@ -85,7 +83,9 @@ export class BaseService< */ async findUnique(args: A['findUnique']): Promise { try { - return this.getModel().findUnique(args as any) as Promise; + return this.getModel().findUnique(args as any) as Promise< + R['findUnique'] + >; } catch (error) { this.handleError(error, 'read'); } @@ -152,26 +152,27 @@ export class BaseService< * const newUser = await service.create({ data: { name: 'John Doe' } }); */ 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({ - orderBy: { order: 'desc' } - }) as any; + const maxOrderItem = (await this.getModel(params?.tx).findFirst({ + orderBy: { order: 'desc' }, + })) as any; // 设置新记录的 order 值 - const newOrder = maxOrderItem ? maxOrderItem.order + this.ORDER_INTERVAL : 1; + const newOrder = maxOrderItem + ? maxOrderItem.order + this.ORDER_INTERVAL + : 1; // 将 order 添加到创建参数中 (args as any).data.order = newOrder; } - return this.getModel(params?.tx).create(args as any) as Promise; + return this.getModel(params?.tx).create(args as any) as Promise< + R['create'] + >; } catch (error) { this.handleError(error, 'create'); } } - /** * Creates multiple new records with the given data. * @param args - Arguments to create multiple records. @@ -179,9 +180,14 @@ export class BaseService< * @example * const newUsers = await service.createMany({ data: [{ name: 'John' }, { name: 'Jane' }] }); */ - async createMany(args: A['createMany'], params?: any): Promise { + async createMany( + args: A['createMany'], + params?: any, + ): Promise { try { - return this.getModel(params?.tx).createMany(args as any) as Promise; + return this.getModel(params?.tx).createMany(args as any) as Promise< + R['createMany'] + >; } catch (error) { this.handleError(error, 'create'); } @@ -196,8 +202,9 @@ export class BaseService< */ async update(args: A['update'], params?: any): Promise { try { - - return this.getModel(params?.tx).update(args as any) as Promise; + return this.getModel(params?.tx).update(args as any) as Promise< + R['update'] + >; } catch (error) { this.handleError(error, 'update'); } @@ -251,7 +258,9 @@ export class BaseService< */ async delete(args: A['delete'], params?: any): Promise { try { - return this.getModel(params?.tx).delete(args as any) as Promise; + return this.getModel(params?.tx).delete(args as any) as Promise< + R['delete'] + >; } catch (error) { this.handleError(error, 'delete'); } @@ -309,10 +318,14 @@ export class BaseService< * @example * const deleteResult = await service.deleteMany({ where: { isActive: false } }); */ - async deleteMany(args: A['deleteMany'], params?: any): Promise { + async deleteMany( + args: A['deleteMany'], + params?: any, + ): Promise { try { - - return this.getModel(params?.tx).deleteMany(args as any) as Promise; + return this.getModel(params?.tx).deleteMany(args as any) as Promise< + R['deleteMany'] + >; } catch (error) { this.handleError(error, 'delete'); } @@ -327,7 +340,9 @@ export class BaseService< */ async updateMany(args: A['updateMany']): Promise { try { - return this.getModel().updateMany(args as any) as Promise; + return this.getModel().updateMany(args as any) as Promise< + R['updateMany'] + >; } catch (error) { this.handleError(error, 'update'); } @@ -420,8 +435,7 @@ export class BaseService< data: { ...data, deletedAt: null } as any, }) as Promise; } catch (error) { - this.handleError(error, "update"); - + this.handleError(error, 'update'); } } @@ -436,25 +450,25 @@ export class BaseService< page?: number; pageSize?: number; where?: WhereArgs; - select?: SelectArgs + select?: SelectArgs; }): Promise<{ items: R['findMany']; totalPages: number }> { const { page = 1, pageSize = 10, where, select } = args; try { // 获取总记录数 - const total = await this.getModel().count({ where }) as number; + const total = (await this.getModel().count({ where })) as number; // 获取分页数据 - const items = await this.getModel().findMany({ + const items = (await this.getModel().findMany({ where, select, skip: (page - 1) * pageSize, take: pageSize, - } as any) as R['findMany']; + } as any)) as R['findMany']; // 计算总页数 const totalPages = Math.ceil(total / pageSize); return { items, - totalPages + totalPages, }; } catch (error) { this.handleError(error, 'read'); @@ -483,10 +497,9 @@ export class BaseService< : undefined, } as any)) as any[]; - /** * 处理下一页游标 - * @description + * @description * 1. 如果查到的记录数超过take,说明还有下一页 * 2. 将最后一条记录弹出,用其updatedAt和id构造下一页游标 * 3. 游标格式为: updatedAt_id @@ -502,7 +515,7 @@ export class BaseService< /** * 返回查询结果 - * @returns {Object} + * @returns {Object} * - items: 当前页记录 * - totalCount: 总记录数 * - nextCursor: 下一页游标 @@ -530,7 +543,7 @@ export class BaseService< order: { gt: targetObject.order }, deletedAt: null, }, - orderBy: { order: 'asc' } + orderBy: { order: 'asc' }, } as any)) as any; const newOrder = nextObject diff --git a/apps/server/src/models/base/base.tree.service.ts b/apps/server/src/models/base/base.tree.service.ts index 45ad185..f62aecc 100644 --- a/apps/server/src/models/base/base.tree.service.ts +++ b/apps/server/src/models/base/base.tree.service.ts @@ -1,6 +1,12 @@ import { Prisma, PrismaClient } from '@nice/common'; -import { BaseService } from "./base.service"; -import { DataArgs, DelegateArgs, DelegateFuncs, DelegateReturnTypes, UpdateOrderArgs } from "./base.type"; +import { BaseService } from './base.service'; +import { + DataArgs, + DelegateArgs, + DelegateFuncs, + DelegateReturnTypes, + UpdateOrderArgs, +} from './base.type'; /** * BaseTreeService provides a generic CRUD interface for a tree prisma model. @@ -11,379 +17,400 @@ import { DataArgs, DelegateArgs, DelegateFuncs, DelegateReturnTypes, UpdateOrder * @template R - Return types for the model delegate's operations. */ export class BaseTreeService< - D extends DelegateFuncs, - A extends DelegateArgs = DelegateArgs, - R extends DelegateReturnTypes = DelegateReturnTypes, + D extends DelegateFuncs, + A extends DelegateArgs = DelegateArgs, + R extends DelegateReturnTypes = DelegateReturnTypes, > extends BaseService { + constructor( + protected prisma: PrismaClient, + protected objectType: string, + protected ancestryType: string = objectType + 'Ancestry', + protected enableOrder: boolean = false, + ) { + super(prisma, objectType, enableOrder); + } + async getNextOrder( + transaction: any, + parentId: string | null, + parentOrder?: number, + ): Promise { + // 查找同层级最后一个节点的 order + const lastOrder = await transaction[this.objectType].findFirst({ + where: { + parentId: parentId ?? null, + }, + select: { order: true }, + orderBy: { order: 'desc' }, + } as any); - constructor( - protected prisma: PrismaClient, - protected objectType: string, - protected ancestryType: string = objectType + 'Ancestry', - protected enableOrder: boolean = false - ) { - super(prisma, objectType, enableOrder) - } - async getNextOrder( - transaction: any, - parentId: string | null, - parentOrder?: number - ): Promise { - // 查找同层级最后一个节点的 order - const lastOrder = await transaction[this.objectType].findFirst({ - where: { - parentId: parentId ?? null - }, + // 如果有父节点 + if (parentId) { + // 获取父节点的 order(如果未提供) + const parentNodeOrder = + parentOrder ?? + ( + await transaction[this.objectType].findUnique({ + where: { id: parentId }, select: { order: true }, - orderBy: { order: 'desc' }, - } as any); + }) + )?.order ?? + 0; - // 如果有父节点 - if (parentId) { - // 获取父节点的 order(如果未提供) - const parentNodeOrder = parentOrder ?? ( - await transaction[this.objectType].findUnique({ - where: { id: parentId }, - select: { order: true } - }) - )?.order ?? 0; - - // 如果存在最后一个同层级节点,确保新节点 order 大于最后一个节点 - // 否则,新节点 order 设置为父节点 order + 1 - return lastOrder - ? Math.max(lastOrder.order + this.ORDER_INTERVAL, - parentNodeOrder + this.ORDER_INTERVAL) - : parentNodeOrder + this.ORDER_INTERVAL; - } - - // 对于根节点,直接使用最后一个节点的 order + 1 - return lastOrder?.order ? lastOrder?.order + this.ORDER_INTERVAL : 1; + // 如果存在最后一个同层级节点,确保新节点 order 大于最后一个节点 + // 否则,新节点 order 设置为父节点 order + 1 + return lastOrder + ? Math.max( + lastOrder.order + this.ORDER_INTERVAL, + parentNodeOrder + this.ORDER_INTERVAL, + ) + : parentNodeOrder + this.ORDER_INTERVAL; } - async create(args: A['create']) { - const anyArgs = args as any - return this.prisma.$transaction(async (transaction) => { - if (this.enableOrder) { - // 获取新节点的 order - anyArgs.data.order = await this.getNextOrder( - transaction, - anyArgs?.data.parentId ?? null - ); - } - // 创建节点 - const result: any = await super.create(anyArgs, { tx: transaction }); + // 对于根节点,直接使用最后一个节点的 order + 1 + return lastOrder?.order ? lastOrder?.order + this.ORDER_INTERVAL : 1; + } - // 更新父节点的 hasChildren 状态 - if (anyArgs.data.parentId) { - await transaction[this.objectType].update({ - where: { id: anyArgs.data.parentId }, - data: { hasChildren: true } - }); - } + async create(args: A['create'], params?: any) { + const anyArgs = args as any; + // 如果传入了外部事务,直接使用该事务执行所有操作 + // 如果没有外部事务,则创建新事务 + const executor = async (transaction: any) => { + if (this.enableOrder) { + anyArgs.data.order = await this.getNextOrder( + transaction, + anyArgs?.data.parentId ?? null, + ); + } - // 创建祖先关系 - const newAncestries = anyArgs.data.parentId - ? [ - ...( - await transaction[this.ancestryType].findMany({ - where: { descendantId: anyArgs.data.parentId }, - select: { ancestorId: true, relDepth: true }, - }) - ).map(({ ancestorId, relDepth }) => ({ - ancestorId, - descendantId: result.id, - relDepth: relDepth + 1, - })), - { - ancestorId: result.parentId, - descendantId: result.id, - relDepth: 1, - }, - ] - : [{ ancestorId: null, descendantId: result.id, relDepth: 1 }]; + const result: any = await super.create(anyArgs, { tx: transaction }); - await transaction[this.ancestryType].createMany({ data: newAncestries }); + if (anyArgs.data.parentId) { + await transaction[this.objectType].update({ + where: { id: anyArgs.data.parentId }, + data: { hasChildren: true }, + }); + } - return result; - }) as Promise; - } - - - /** - * 更新现有单位,并在parentId更改时管理DeptAncestry关系。 - * @param data - 用于更新现有单位的数据。 - * @returns 更新后的单位对象。 - */ - async update(args: A['update']) { - const anyArgs = args as any - return this.prisma.$transaction(async (transaction) => { - const current = await transaction[this.objectType].findUnique({ - where: { id: anyArgs.where.id }, - }); - - if (!current) throw new Error('object not found'); - - const result: any = await super.update(anyArgs, { tx: transaction }); - - if (anyArgs.data.parentId !== current.parentId) { - await transaction[this.ancestryType].deleteMany({ - where: { descendantId: result.id }, - }); - // 更新原父级的 hasChildren 状态 - if (current.parentId) { - const childrenCount = await transaction[this.objectType].count({ - where: { parentId: current.parentId, deletedAt: null } - }); - - if (childrenCount === 0) { - await transaction[this.objectType].update({ - where: { id: current.parentId }, - data: { hasChildren: false } - }); - } - } - if (anyArgs.data.parentId) { - await transaction[this.objectType].update({ - where: { id: anyArgs.data.parentId }, - data: { hasChildren: true } - }); - const parentAncestries = await transaction[this.ancestryType].findMany({ - where: { descendantId: anyArgs.data.parentId }, - }); - - const newAncestries = parentAncestries.map( - ({ ancestorId, relDepth }) => ({ - ancestorId, - descendantId: result.id, - relDepth: relDepth + 1, - }), - ); - - newAncestries.push({ - ancestorId: anyArgs.data.parentId, - descendantId: result.id, - relDepth: 1, - }); - - await transaction[this.ancestryType].createMany({ data: newAncestries }); - } else { - await transaction[this.ancestryType].create({ - data: { ancestorId: null, descendantId: result.id, relDepth: 0 }, - }); - } - } - - return result; - }) as Promise; - } - /** - * Soft deletes records by setting `isDeleted` to true for the given IDs. - * @param ids - An array of IDs of the records to soft delete. - * @param data - Additional data to update on soft delete. (Optional) - * @returns {Promise} - A promise resolving to an array of updated records. - * @example - * const softDeletedUsers = await service.softDeleteByIds(['user_id1', 'user_id2'], { reason: 'Bulk deletion' }); - */ - async softDeleteByIds( - ids: string[], - data: Partial> = {}, // Default to empty object - ): Promise { - return this.prisma.$transaction(async tx => { - // 首先找出所有需要软删除的记录的父级ID - const parentIds = await tx[this.objectType].findMany({ - where: { - id: { in: ids }, - parentId: { not: null } - }, - select: { parentId: true } - }); - - const uniqueParentIds = [...new Set(parentIds.map(p => p.parentId))]; - - // 执行软删除 - const result = await super.softDeleteByIds(ids, data); - - // 删除相关的祖先关系 - await tx[this.ancestryType].deleteMany({ - where: { - OR: [ - { ancestorId: { in: ids } }, - { descendantId: { in: ids } }, - ], - } - }); - // 更新父级的 hasChildren 状态 - if (uniqueParentIds.length > 0) { - for (const parentId of uniqueParentIds) { - const remainingChildrenCount = await tx[this.objectType].count({ - where: { - parentId: parentId, - deletedAt: null - } - }); - if (remainingChildrenCount === 0) { - await tx[this.objectType].update({ - where: { id: parentId }, - data: { hasChildren: false } - }); - } - } - } - - return result; - }) as Promise; - } - - - getAncestors(ids: string[]) { - if (!ids || ids.length === 0) return []; - const validIds = ids.filter(id => id != null); - const hasNull = ids.includes(null) - return this.prisma[this.ancestryType].findMany({ - where: { - OR: [ - { ancestorId: { in: validIds } }, - { ancestorId: hasNull ? null : undefined }, - ] + const newAncestries = anyArgs.data.parentId + ? [ + ...( + await transaction[this.ancestryType].findMany({ + where: { descendantId: anyArgs.data.parentId }, + select: { ancestorId: true, relDepth: true }, + }) + ).map(({ ancestorId, relDepth }) => ({ + ancestorId, + descendantId: result.id, + relDepth: relDepth + 1, + })), + { + ancestorId: result.parentId, + descendantId: result.id, + relDepth: 1, }, - }); - } + ] + : [{ ancestorId: null, descendantId: result.id, relDepth: 1 }]; - getDescendants(ids: string[]) { - if (!ids || ids.length === 0) return []; - const validIds = ids.filter(id => id != null); - const hasNull = ids.includes(null) - return this.prisma[this.ancestryType].findMany({ + await transaction[this.ancestryType].createMany({ data: newAncestries }); + + return result; + }; + // 根据是否有外部事务决定执行方式 + if (params?.tx) { + return executor(params.tx) as Promise; + } else { + return this.prisma.$transaction(executor) as Promise; + } + } + + /** + * 更新现有单位,并在parentId更改时管理DeptAncestry关系。 + * @param data - 用于更新现有单位的数据。 + * @returns 更新后的单位对象。 + */ + async update(args: A['update'], params?: any) { + const anyArgs = args as any; + return this.prisma.$transaction(async (transaction) => { + const current = await transaction[this.objectType].findUnique({ + where: { id: anyArgs.where.id }, + }); + + if (!current) throw new Error('object not found'); + + const result: any = await super.update(anyArgs, { tx: transaction }); + + if (anyArgs.data.parentId !== current.parentId) { + await transaction[this.ancestryType].deleteMany({ + where: { descendantId: result.id }, + }); + // 更新原父级的 hasChildren 状态 + if (current.parentId) { + const childrenCount = await transaction[this.objectType].count({ + where: { parentId: current.parentId, deletedAt: null }, + }); + + if (childrenCount === 0) { + await transaction[this.objectType].update({ + where: { id: current.parentId }, + data: { hasChildren: false }, + }); + } + } + if (anyArgs.data.parentId) { + await transaction[this.objectType].update({ + where: { id: anyArgs.data.parentId }, + data: { hasChildren: true }, + }); + const parentAncestries = await transaction[ + this.ancestryType + ].findMany({ + where: { descendantId: anyArgs.data.parentId }, + }); + + const newAncestries = parentAncestries.map( + ({ ancestorId, relDepth }) => ({ + ancestorId, + descendantId: result.id, + relDepth: relDepth + 1, + }), + ); + + newAncestries.push({ + ancestorId: anyArgs.data.parentId, + descendantId: result.id, + relDepth: 1, + }); + + await transaction[this.ancestryType].createMany({ + data: newAncestries, + }); + } else { + await transaction[this.ancestryType].create({ + data: { ancestorId: null, descendantId: result.id, relDepth: 0 }, + }); + } + } + + return result; + }) as Promise; + } + /** + * Soft deletes records by setting `isDeleted` to true for the given IDs. + * @param ids - An array of IDs of the records to soft delete. + * @param data - Additional data to update on soft delete. (Optional) + * @returns {Promise} - A promise resolving to an array of updated records. + * @example + * const softDeletedUsers = await service.softDeleteByIds(['user_id1', 'user_id2'], { reason: 'Bulk deletion' }); + */ + async softDeleteByIds( + ids: string[], + data: Partial> = {}, // Default to empty object + ): Promise { + return this.prisma.$transaction(async (tx) => { + // 首先找出所有需要软删除的记录的父级ID + const parentIds = await tx[this.objectType].findMany({ + where: { + id: { in: ids }, + parentId: { not: null }, + }, + select: { parentId: true }, + }); + + const uniqueParentIds = [...new Set(parentIds.map((p) => p.parentId))]; + + // 执行软删除 + const result = await super.softDeleteByIds(ids, data); + + // 删除相关的祖先关系 + await tx[this.ancestryType].deleteMany({ + where: { + OR: [{ ancestorId: { in: ids } }, { descendantId: { in: ids } }], + }, + }); + // 更新父级的 hasChildren 状态 + if (uniqueParentIds.length > 0) { + for (const parentId of uniqueParentIds) { + const remainingChildrenCount = await tx[this.objectType].count({ where: { - OR: [ - { ancestorId: { in: validIds } }, - { ancestorId: hasNull ? null : undefined }, - ] + parentId: parentId, + deletedAt: null, }, - }); - } - - async getDescendantIds(ids: string | string[], includeOriginalIds: boolean = false): Promise { - // 将单个 ID 转换为数组 - const idArray = Array.isArray(ids) ? ids : [ids]; - - const res = await this.getDescendants(idArray); - const descendantSet = new Set(res?.map((item) => item.descendantId) || []); - - if (includeOriginalIds) { - idArray.forEach(id => descendantSet.add(id)); + }); + if (remainingChildrenCount === 0) { + await tx[this.objectType].update({ + where: { id: parentId }, + data: { hasChildren: false }, + }); + } } + } - return Array.from(descendantSet).filter(Boolean) as string[]; + return result; + }) as Promise; + } + + getAncestors(ids: string[]) { + if (!ids || ids.length === 0) return []; + const validIds = ids.filter((id) => id != null); + const hasNull = ids.includes(null); + return this.prisma[this.ancestryType].findMany({ + where: { + OR: [ + { ancestorId: { in: validIds } }, + { ancestorId: hasNull ? null : undefined }, + ], + }, + }); + } + + getDescendants(ids: string[]) { + if (!ids || ids.length === 0) return []; + const validIds = ids.filter((id) => id != null); + const hasNull = ids.includes(null); + return this.prisma[this.ancestryType].findMany({ + where: { + OR: [ + { ancestorId: { in: validIds } }, + { ancestorId: hasNull ? null : undefined }, + ], + }, + }); + } + + async getDescendantIds( + ids: string | string[], + includeOriginalIds: boolean = false, + ): Promise { + // 将单个 ID 转换为数组 + const idArray = Array.isArray(ids) ? ids : [ids]; + + const res = await this.getDescendants(idArray); + const descendantSet = new Set(res?.map((item) => item.descendantId) || []); + + if (includeOriginalIds) { + idArray.forEach((id) => descendantSet.add(id)); } - async getAncestorIds(ids: string | string[], includeOriginalIds: boolean = false): Promise { - // 将单个 ID 转换为数组 - const idArray = Array.isArray(ids) ? ids : [ids]; + return Array.from(descendantSet).filter(Boolean) as string[]; + } - const res = await this.getDescendants(idArray); - const ancestorSet = new Set(); + async getAncestorIds( + ids: string | string[], + includeOriginalIds: boolean = false, + ): Promise { + // 将单个 ID 转换为数组 + const idArray = Array.isArray(ids) ? ids : [ids]; - // 按深度排序并添加祖先ID - res - ?.sort((a, b) => b.relDepth - a.relDepth) - ?.forEach((item) => ancestorSet.add(item.ancestorId)); + const res = await this.getDescendants(idArray); + const ancestorSet = new Set(); - // 根据参数决定是否添加原始ID - if (includeOriginalIds) { - idArray.forEach((id) => ancestorSet.add(id)); - } + // 按深度排序并添加祖先ID + res + ?.sort((a, b) => b.relDepth - a.relDepth) + ?.forEach((item) => ancestorSet.add(item.ancestorId)); - return Array.from(ancestorSet).filter(Boolean) as string[]; - } - async updateOrder(args: UpdateOrderArgs) { - const { id, overId } = args; - - return this.prisma.$transaction(async (transaction) => { - // 查找当前节点和目标节点 - const currentObject = await transaction[this.objectType].findUnique({ - where: { id }, - select: { id: true, parentId: true, order: true } - }); - - const targetObject = await transaction[this.objectType].findUnique({ - where: { id: overId }, - select: { id: true, parentId: true, order: true } - }); - - // 验证节点 - if (!currentObject || !targetObject) { - throw new Error('Invalid object or target object'); - } - - // 查找父节点 - const parentObject = currentObject.parentId - ? await transaction[this.objectType].findUnique({ - where: { id: currentObject.parentId }, - select: { id: true, order: true } - }) - : null; - - // 确保在同一父节点下移动 - if (currentObject.parentId !== targetObject.parentId) { - throw new Error('Cannot move between different parent nodes'); - } - - // 查找同层级的所有节点,按 order 排序 - const siblingNodes = await transaction[this.objectType].findMany({ - where: { - parentId: targetObject.parentId - }, - select: { id: true, order: true }, - orderBy: { order: 'asc' } - }); - - // 找到目标节点和当前节点在兄弟节点中的索引 - const targetIndex = siblingNodes.findIndex(node => node.id === targetObject.id); - const currentIndex = siblingNodes.findIndex(node => node.id === currentObject.id); - - // 移除当前节点 - siblingNodes.splice(currentIndex, 1); - - // 在目标位置插入当前节点 - const insertIndex = currentIndex > targetIndex ? targetIndex + 1 : targetIndex; - siblingNodes.splice(insertIndex, 0, currentObject); - - - // 重新分配 order - const newOrders = this.redistributeOrder(siblingNodes, parentObject?.order || 0); - - // 批量更新节点的 order - const updatePromises = newOrders.map((nodeOrder, index) => - transaction[this.objectType].update({ - where: { id: siblingNodes[index].id }, - data: { order: nodeOrder } - }) - ); - - await Promise.all(updatePromises); - - // 返回更新后的当前节点 - return transaction[this.objectType].findUnique({ - where: { id: currentObject.id } - }); - }); + // 根据参数决定是否添加原始ID + if (includeOriginalIds) { + idArray.forEach((id) => ancestorSet.add(id)); } - // 重新分配 order 的方法 - private redistributeOrder(nodes: Array<{ id: string, order: number }>, parentOrder: number): number[] { - const MIN_CHILD_ORDER = parentOrder + this.ORDER_INTERVAL; // 子节点 order 必须大于父节点 - const newOrders: number[] = []; + return Array.from(ancestorSet).filter(Boolean) as string[]; + } + async updateOrder(args: UpdateOrderArgs) { + const { id, overId } = args; - nodes.forEach((_, index) => { - // 使用等差数列分配 order,确保大于父节点 - const nodeOrder = MIN_CHILD_ORDER + (index + 1) * this.ORDER_INTERVAL; - newOrders.push(nodeOrder); - }); + return this.prisma.$transaction(async (transaction) => { + // 查找当前节点和目标节点 + const currentObject = await transaction[this.objectType].findUnique({ + where: { id }, + select: { id: true, parentId: true, order: true }, + }); - return newOrders; - } + const targetObject = await transaction[this.objectType].findUnique({ + where: { id: overId }, + select: { id: true, parentId: true, order: true }, + }); + // 验证节点 + if (!currentObject || !targetObject) { + throw new Error('Invalid object or target object'); + } + // 查找父节点 + const parentObject = currentObject.parentId + ? await transaction[this.objectType].findUnique({ + where: { id: currentObject.parentId }, + select: { id: true, order: true }, + }) + : null; -} \ No newline at end of file + // 确保在同一父节点下移动 + if (currentObject.parentId !== targetObject.parentId) { + throw new Error('Cannot move between different parent nodes'); + } + + // 查找同层级的所有节点,按 order 排序 + const siblingNodes = await transaction[this.objectType].findMany({ + where: { + parentId: targetObject.parentId, + }, + select: { id: true, order: true }, + orderBy: { order: 'asc' }, + }); + + // 找到目标节点和当前节点在兄弟节点中的索引 + const targetIndex = siblingNodes.findIndex( + (node) => node.id === targetObject.id, + ); + const currentIndex = siblingNodes.findIndex( + (node) => node.id === currentObject.id, + ); + + // 移除当前节点 + siblingNodes.splice(currentIndex, 1); + + // 在目标位置插入当前节点 + const insertIndex = + currentIndex > targetIndex ? targetIndex + 1 : targetIndex; + siblingNodes.splice(insertIndex, 0, currentObject); + + // 重新分配 order + const newOrders = this.redistributeOrder( + siblingNodes, + parentObject?.order || 0, + ); + + // 批量更新节点的 order + const updatePromises = newOrders.map((nodeOrder, index) => + transaction[this.objectType].update({ + where: { id: siblingNodes[index].id }, + data: { order: nodeOrder }, + }), + ); + + await Promise.all(updatePromises); + + // 返回更新后的当前节点 + return transaction[this.objectType].findUnique({ + where: { id: currentObject.id }, + }); + }); + } + + // 重新分配 order 的方法 + private redistributeOrder( + nodes: Array<{ id: string; order: number }>, + parentOrder: number, + ): number[] { + const MIN_CHILD_ORDER = parentOrder + this.ORDER_INTERVAL; // 子节点 order 必须大于父节点 + const newOrders: number[] = []; + + nodes.forEach((_, index) => { + // 使用等差数列分配 order,确保大于父节点 + const nodeOrder = MIN_CHILD_ORDER + (index + 1) * this.ORDER_INTERVAL; + newOrders.push(nodeOrder); + }); + + return newOrders; + } +} diff --git a/apps/server/src/models/course/course.module.ts b/apps/server/src/models/course/course.module.ts index c776075..8438e80 100644 --- a/apps/server/src/models/course/course.module.ts +++ b/apps/server/src/models/course/course.module.ts @@ -1,10 +1,10 @@ -import { Module } from '@nestjs/common'; -import { CourseRouter } from './course.router'; -import { CourseService } from './course.service'; -import { TrpcService } from '@server/trpc/trpc.service'; +// 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 { } +// @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 index 4ed98c4..fe9047f 100644 --- a/apps/server/src/models/course/course.router.ts +++ b/apps/server/src/models/course/course.router.ts @@ -1,94 +1,92 @@ -import { Injectable } from '@nestjs/common'; -import { TrpcService } from '@server/trpc/trpc.service'; -import { Prisma, UpdateOrderSchema } from '@nice/common'; -import { CourseService } from './course.service'; -import { z, ZodType } from 'zod'; -const CourseCreateArgsSchema: ZodType = z.any(); -const CourseUpdateArgsSchema: ZodType = z.any(); -const CourseCreateManyInputSchema: ZodType = - z.any(); -const CourseDeleteManyArgsSchema: ZodType = - z.any(); -const CourseFindManyArgsSchema: ZodType = z.any(); -const CourseFindFirstArgsSchema: ZodType = z.any(); -const CourseWhereInputSchema: ZodType = z.any(); -const CourseSelectSchema: ZodType = z.any(); +// import { Injectable } from '@nestjs/common'; +// import { TrpcService } from '@server/trpc/trpc.service'; +// import { Prisma, UpdateOrderSchema } from '@nice/common'; +// import { CourseService } from './course.service'; +// import { z, ZodType } from 'zod'; +// // const CourseCreateArgsSchema: ZodType = z.any(); +// // const CourseUpdateArgsSchema: ZodType = z.any(); +// // const CourseCreateManyInputSchema: ZodType = +// // z.any(); +// // const CourseDeleteManyArgsSchema: ZodType = +// // z.any(); +// // const CourseFindManyArgsSchema: ZodType = z.any(); +// // const CourseFindFirstArgsSchema: ZodType = z.any(); +// // const CourseWhereInputSchema: ZodType = z.any(); +// // const CourseSelectSchema: ZodType = z.any(); -@Injectable() -export class CourseRouter { - constructor( - private readonly trpc: TrpcService, - private readonly courseService: CourseService, - ) {} - router = this.trpc.router({ - create: this.trpc.protectProcedure - .input(CourseCreateArgsSchema) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; - - return await this.courseService.create(input, { staff }); - }), - update: this.trpc.protectProcedure - .input(CourseUpdateArgsSchema) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.courseService.update(input, { staff }); - }), - createMany: this.trpc.protectProcedure - .input(z.array(CourseCreateManyInputSchema)) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; - - return await this.courseService.createMany({ data: input }, staff); - }), - deleteMany: this.trpc.procedure - .input(CourseDeleteManyArgsSchema) - .mutation(async ({ input }) => { - return await this.courseService.deleteMany(input); - }), - findFirst: this.trpc.procedure - .input(CourseFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword - .query(async ({ input }) => { - return await this.courseService.findFirst(input); - }), - softDeleteByIds: this.trpc.protectProcedure - .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema - .mutation(async ({ input }) => { - return this.courseService.softDeleteByIds(input.ids); - }), - updateOrder: this.trpc.protectProcedure - .input(UpdateOrderSchema) - .mutation(async ({ input }) => { - return this.courseService.updateOrder(input); - }), - findMany: this.trpc.procedure - .input(CourseFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword - .query(async ({ input }) => { - return await this.courseService.findMany(input); - }), - findManyWithCursor: this.trpc.protectProcedure - .input( - z.object({ - cursor: z.any().nullish(), - take: z.number().optional(), - where: CourseWhereInputSchema.optional(), - select: CourseSelectSchema.optional(), - }), - ) - .query(async ({ ctx, input }) => { - return await this.courseService.findManyWithCursor(input); - }), - findManyWithPagination: this.trpc.procedure - .input( - z.object({ - page: z.number().optional(), - pageSize: z.number().optional(), - where: CourseWhereInputSchema.optional(), - select: CourseSelectSchema.optional(), - }), - ) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword - .query(async ({ input }) => { - return await this.courseService.findManyWithPagination(input); - }), - }); -} +// @Injectable() +// export class CourseRouter { +// constructor( +// private readonly trpc: TrpcService, +// private readonly courseService: CourseService, +// ) {} +// router = this.trpc.router({ +// // create: this.trpc.protectProcedure +// // .input(CourseCreateArgsSchema) +// // .mutation(async ({ ctx, input }) => { +// // const { staff } = ctx; +// // return await this.courseService.create(input, { staff }); +// // }), +// // update: this.trpc.protectProcedure +// // .input(CourseUpdateArgsSchema) +// // .mutation(async ({ ctx, input }) => { +// // const { staff } = ctx; +// // return await this.courseService.update(input, { staff }); +// // }), +// // createMany: this.trpc.protectProcedure +// // .input(z.array(CourseCreateManyInputSchema)) +// // .mutation(async ({ ctx, input }) => { +// // const { staff } = ctx; +// // return await this.courseService.createMany({ data: input }, staff); +// // }), +// // deleteMany: this.trpc.procedure +// // .input(CourseDeleteManyArgsSchema) +// // .mutation(async ({ input }) => { +// // return await this.courseService.deleteMany(input); +// // }), +// // findFirst: this.trpc.procedure +// // .input(CourseFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword +// // .query(async ({ input }) => { +// // return await this.courseService.findFirst(input); +// // }), +// // softDeleteByIds: this.trpc.protectProcedure +// // .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema +// // .mutation(async ({ input }) => { +// // return this.courseService.softDeleteByIds(input.ids); +// // }), +// // updateOrder: this.trpc.protectProcedure +// // .input(UpdateOrderSchema) +// // .mutation(async ({ input }) => { +// // return this.courseService.updateOrder(input); +// // }), +// // findMany: this.trpc.procedure +// // .input(CourseFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword +// // .query(async ({ input }) => { +// // return await this.courseService.findMany(input); +// // }), +// // findManyWithCursor: this.trpc.protectProcedure +// // .input( +// // z.object({ +// // cursor: z.any().nullish(), +// // take: z.number().optional(), +// // where: CourseWhereInputSchema.optional(), +// // select: CourseSelectSchema.optional(), +// // }), +// // ) +// // .query(async ({ ctx, input }) => { +// // return await this.courseService.findManyWithCursor(input); +// // }), +// // findManyWithPagination: this.trpc.procedure +// // .input( +// // z.object({ +// // page: z.number().optional(), +// // pageSize: z.number().optional(), +// // where: CourseWhereInputSchema.optional(), +// // select: CourseSelectSchema.optional(), +// // }), +// // ) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword +// // .query(async ({ input }) => { +// // return await this.courseService.findManyWithPagination(input); +// // }), +// }); +// } diff --git a/apps/server/src/models/course/course.service.ts b/apps/server/src/models/course/course.service.ts index f21a249..55b1038 100644 --- a/apps/server/src/models/course/course.service.ts +++ b/apps/server/src/models/course/course.service.ts @@ -1,78 +1,78 @@ -import { Injectable } from '@nestjs/common'; -import { BaseService } from '../base/base.service'; -import { - UserProfile, - db, - ObjectType, - Prisma, - InstructorRole, -} from '@nice/common'; -@Injectable() -export class CourseService extends BaseService { - constructor() { - super(db, ObjectType.COURSE); - } - async create( - args: Prisma.CourseCreateArgs, - params?: { staff?: UserProfile } - ) { - return await db.$transaction(async tx => { - const result = await super.create(args, { tx }); - if (params?.staff?.id) { - await tx.courseInstructor.create({ - data: { - instructorId: params.staff.id, - courseId: result.id, - role: InstructorRole.MAIN, - } - }); - } - return result; - }, { - timeout: 10000 // 10 seconds - }); - } - async update( - args: Prisma.CourseUpdateArgs, - params?: { staff?: UserProfile } - ) { - return await db.$transaction(async tx => { - const result = await super.update(args, { tx }); - return result; - }, { - timeout: 10000 // 10 seconds - }); - } - async removeInstructor(courseId: string, instructorId: string) { - return await db.courseInstructor.delete({ - where: { - courseId_instructorId: { - courseId, - instructorId, - }, - }, - }); - } - async addInstructor(params: { - courseId: string; - instructorId: string; - role?: string; - order?: number; - }) { - return await db.courseInstructor.create({ - data: { - courseId: params.courseId, - instructorId: params.instructorId, - role: params.role || InstructorRole.ASSISTANT, - order: params.order, - }, - }); - } - async getInstructors(courseId: string) { - return await db.courseInstructor.findMany({ - where: { courseId }, - include: { instructor: true }, - orderBy: { order: 'asc' }, - }); - } -} +// import { Injectable } from '@nestjs/common'; +// import { BaseService } from '../base/base.service'; +// import { +// UserProfile, +// db, +// ObjectType, +// Prisma, +// InstructorRole, +// } from '@nice/common'; +// @Injectable() +// export class CourseService extends BaseService { +// constructor() { +// super(db, ObjectType.COURSE); +// } +// async create( +// args: Prisma.CourseCreateArgs, +// params?: { staff?: UserProfile } +// ) { +// return await db.$transaction(async tx => { +// const result = await super.create(args, { tx }); +// if (params?.staff?.id) { +// await tx.courseInstructor.create({ +// data: { +// instructorId: params.staff.id, +// courseId: result.id, +// role: InstructorRole.MAIN, +// } +// }); +// } +// return result; +// }, { +// timeout: 10000 // 10 seconds +// }); +// } +// async update( +// args: Prisma.CourseUpdateArgs, +// params?: { staff?: UserProfile } +// ) { +// return await db.$transaction(async tx => { +// const result = await super.update(args, { tx }); +// return result; +// }, { +// timeout: 10000 // 10 seconds +// }); +// } +// async removeInstructor(courseId: string, instructorId: string) { +// return await db.courseInstructor.delete({ +// where: { +// courseId_instructorId: { +// courseId, +// instructorId, +// }, +// }, +// }); +// } +// async addInstructor(params: { +// courseId: string; +// instructorId: string; +// role?: string; +// order?: number; +// }) { +// return await db.courseInstructor.create({ +// data: { +// courseId: params.courseId, +// instructorId: params.instructorId, +// role: params.role || InstructorRole.ASSISTANT, +// order: params.order, +// }, +// }); +// } +// async getInstructors(courseId: string) { +// return await db.courseInstructor.findMany({ +// where: { courseId }, +// include: { instructor: true }, +// orderBy: { order: 'asc' }, +// }); +// } +// } diff --git a/apps/server/src/models/course/utils.ts b/apps/server/src/models/course/utils.ts index 9cd98c7..aef7966 100644 --- a/apps/server/src/models/course/utils.ts +++ b/apps/server/src/models/course/utils.ts @@ -1,49 +1,49 @@ -import { db, EnrollmentStatus, PostType } from '@nice/common'; +// import { db, EnrollmentStatus, PostType } from '@nice/common'; -// 更新课程评价统计 -export async function updateCourseReviewStats(courseId: string) { - const reviews = await db.post.findMany({ - where: { - courseId, - type: PostType.COURSE_REVIEW, - deletedAt: null, - }, - select: { rating: true }, - }); - const numberOfReviews = reviews.length; - const averageRating = - numberOfReviews > 0 - ? reviews.reduce((sum, review) => sum + review.rating, 0) / - numberOfReviews - : 0; +// // 更新课程评价统计 +// export async function updateCourseReviewStats(courseId: string) { +// const reviews = await db.post.findMany({ +// where: { +// courseId, +// type: PostType.COURSE_REVIEW, +// deletedAt: null, +// }, +// select: { rating: true }, +// }); +// const numberOfReviews = reviews.length; +// const averageRating = +// numberOfReviews > 0 +// ? reviews.reduce((sum, review) => sum + review.rating, 0) / +// numberOfReviews +// : 0; - return db.course.update({ - where: { id: courseId }, - data: { - // numberOfReviews, - //averageRating, - }, - }); -} +// return db.course.update({ +// where: { id: courseId }, +// data: { +// // numberOfReviews, +// //averageRating, +// }, +// }); +// } -// 更新课程注册统计 -export async function updateCourseEnrollmentStats(courseId: string) { - const completedEnrollments = await db.enrollment.count({ - where: { - courseId, - status: EnrollmentStatus.COMPLETED, - }, - }); - const totalEnrollments = await db.enrollment.count({ - where: { courseId }, - }); - const completionRate = - totalEnrollments > 0 ? (completedEnrollments / totalEnrollments) * 100 : 0; - return db.course.update({ - where: { id: courseId }, - data: { - // numberOfStudents: totalEnrollments, - // completionRate, - }, - }); -} +// // 更新课程注册统计 +// export async function updateCourseEnrollmentStats(courseId: string) { +// const completedEnrollments = await db.enrollment.count({ +// where: { +// courseId, +// status: EnrollmentStatus.COMPLETED, +// }, +// }); +// const totalEnrollments = await db.enrollment.count({ +// where: { courseId }, +// }); +// const completionRate = +// totalEnrollments > 0 ? (completedEnrollments / totalEnrollments) * 100 : 0; +// return db.course.update({ +// where: { id: courseId }, +// data: { +// // numberOfStudents: totalEnrollments, +// // completionRate, +// }, +// }); +// } diff --git a/apps/server/src/models/department/department.row.service.ts b/apps/server/src/models/department/department.row.service.ts index b97f459..bae18d8 100644 --- a/apps/server/src/models/department/department.row.service.ts +++ b/apps/server/src/models/department/department.row.service.ts @@ -37,7 +37,7 @@ export class DepartmentRowService extends RowCacheService { `${this.tableName}.is_domain AS is_domain`, // 是否为域 `${this.tableName}.order AS order`, // 排序 `${this.tableName}.has_children AS has_children`, // 是否有子部门 - `${this.tableName}.parent_id AS parent_id` // 父部门 ID + `${this.tableName}.parent_id AS parent_id`, // 父部门 ID ]); return result; } @@ -72,7 +72,7 @@ export class DepartmentRowService extends RowCacheService { } else if (parentId === null) { condition.AND.push({ field: `${this.tableName}.parent_id`, - op: "blank", // 空白操作符 + op: 'blank', // 空白操作符 }); } } diff --git a/apps/server/src/models/department/department.service.ts b/apps/server/src/models/department/department.service.ts index 68c02eb..3ec0e6e 100755 --- a/apps/server/src/models/department/department.service.ts +++ b/apps/server/src/models/department/department.service.ts @@ -40,7 +40,7 @@ export class DepartmentService extends BaseTreeService ancestry.descendantId, ); // 根据参数决定是否包含祖先部门ID @@ -65,7 +65,7 @@ export class DepartmentService extends BaseTreeService ancestry.descendantId, ); // 根据参数决定是否包含祖先域ID diff --git a/apps/server/src/models/department/utils.ts b/apps/server/src/models/department/utils.ts index 096a6cc..6694aa5 100644 --- a/apps/server/src/models/department/utils.ts +++ b/apps/server/src/models/department/utils.ts @@ -1,4 +1,9 @@ -import { UserProfile, db, DeptSimpleTreeNode, TreeDataNode } from "@nice/common"; +import { + UserProfile, + db, + DeptSimpleTreeNode, + TreeDataNode, +} from '@nice/common'; /** * 将部门数据映射为DeptSimpleTreeNode结构 @@ -15,16 +20,16 @@ import { UserProfile, db, DeptSimpleTreeNode, TreeDataNode } from "@nice/common" * - hasStaff: 该部门是否包含员工 */ export function mapToDeptSimpleTree(department: any): DeptSimpleTreeNode { - return { - id: department.id, - key: department.id, - value: department.id, - title: department.name, - order: department.order, - pId: department.parentId, - isLeaf: !Boolean(department.children?.length), - hasStaff: department?.deptStaffs?.length > 0 - }; + return { + id: department.id, + key: department.id, + value: department.id, + title: department.name, + order: department.order, + pId: department.parentId, + isLeaf: !Boolean(department.children?.length), + hasStaff: department?.deptStaffs?.length > 0, + }; } /** @@ -36,15 +41,15 @@ export function mapToDeptSimpleTree(department: any): DeptSimpleTreeNode { * - 使用flatMap将查询结果扁平化,提取所有员工ID */ export async function getStaffsByDeptIds(ids: string[]) { - const depts = await db.department.findMany({ - where: { id: { in: ids } }, - select: { - deptStaffs: { - select: { id: true } - } - }, - }); - return depts.flatMap((dept) => dept.deptStaffs); + const depts = await db.department.findMany({ + where: { id: { in: ids } }, + select: { + deptStaffs: { + select: { id: true }, + }, + }, + }); + return depts.flatMap((dept) => dept.deptStaffs); } /** @@ -57,12 +62,16 @@ export async function getStaffsByDeptIds(ids: string[]) { * - 如果传入了员工信息,则从结果中移除该员工的ID * - 最终返回去重后的员工ID列表 */ -export async function extractUniqueStaffIds(params: { deptIds?: string[], staffIds?: string[], staff?: UserProfile }): Promise { - const { deptIds, staff, staffIds } = params; - const deptStaffs = await getStaffsByDeptIds(deptIds); - const result = new Set(deptStaffs.map(item => item.id).concat(staffIds)); - if (staff) { - result.delete(staff.id); - } - return Array.from(result); -} \ No newline at end of file +export async function extractUniqueStaffIds(params: { + deptIds?: string[]; + staffIds?: string[]; + staff?: UserProfile; +}): Promise { + const { deptIds, staff, staffIds } = params; + const deptStaffs = await getStaffsByDeptIds(deptIds); + const result = new Set(deptStaffs.map((item) => item.id).concat(staffIds)); + if (staff) { + result.delete(staff.id); + } + return Array.from(result); +} diff --git a/apps/server/src/models/enrollment/enroll.schema.ts b/apps/server/src/models/enrollment/enroll.schema.ts index 41a9968..652f2c8 100644 --- a/apps/server/src/models/enrollment/enroll.schema.ts +++ b/apps/server/src/models/enrollment/enroll.schema.ts @@ -1,11 +1,11 @@ -import { z } from "zod"; +import { z } from 'zod'; export const EnrollSchema = z.object({ - studentId: z.string(), - courseId: z.string(), + studentId: z.string(), + postId: z.string(), }); export const UnenrollSchema = z.object({ - studentId: z.string(), - courseId: z.string(), -}); \ No newline at end of file + studentId: z.string(), + postId: z.string(), +}); diff --git a/apps/server/src/models/enrollment/enrollment.module.ts b/apps/server/src/models/enrollment/enrollment.module.ts index 71a7e35..dd9c554 100644 --- a/apps/server/src/models/enrollment/enrollment.module.ts +++ b/apps/server/src/models/enrollment/enrollment.module.ts @@ -3,7 +3,7 @@ import { EnrollmentRouter } from './enrollment.router'; import { EnrollmentService } from './enrollment.service'; @Module({ - exports: [EnrollmentRouter, EnrollmentService], - providers: [EnrollmentRouter, EnrollmentService] + exports: [EnrollmentRouter, EnrollmentService], + providers: [EnrollmentRouter, EnrollmentService], }) -export class EnrollmentModule { } +export class EnrollmentModule {} diff --git a/apps/server/src/models/enrollment/enrollment.service.ts b/apps/server/src/models/enrollment/enrollment.service.ts index 63f87ef..1b6ca8f 100644 --- a/apps/server/src/models/enrollment/enrollment.service.ts +++ b/apps/server/src/models/enrollment/enrollment.service.ts @@ -1,11 +1,15 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { BaseService } from '../base/base.service'; import { UserProfile, db, ObjectType, Prisma, - EnrollmentStatus + EnrollmentStatus, } from '@nice/common'; import { z } from 'zod'; import { EnrollSchema, UnenrollSchema } from './enroll.schema'; @@ -17,58 +21,58 @@ export class EnrollmentService extends BaseService { super(db, ObjectType.COURSE); } async enroll(params: z.infer) { - const { studentId, courseId } = params - const result = await db.$transaction(async tx => { + const { studentId, postId } = params; + const result = await db.$transaction(async (tx) => { // 检查是否已经报名 const existing = await tx.enrollment.findUnique({ where: { - studentId_courseId: { + studentId_postId: { studentId: studentId, - courseId: courseId, + postId: postId, }, }, }); if (existing) { - throw new ConflictException('Already enrolled in this course'); + throw new ConflictException('Already enrolled in this post'); } // 创建报名记录 const enrollment = await tx.enrollment.create({ data: { studentId: studentId, - courseId: courseId, + postId: postId, status: EnrollmentStatus.ACTIVE, }, }); return enrollment; }); - + EventBus.emit('dataChanged', { type: ObjectType.ENROLLMENT, operation: CrudOperation.CREATED, data: result, }); - return result + return result; } async unenroll(params: z.infer) { - const { studentId, courseId } = params + const { studentId, postId } = params; const result = await db.enrollment.update({ where: { - studentId_courseId: { + studentId_postId: { studentId, - courseId, + postId, }, }, data: { - status: EnrollmentStatus.CANCELLED - } + status: EnrollmentStatus.CANCELLED, + }, }); - + EventBus.emit('dataChanged', { type: ObjectType.ENROLLMENT, operation: CrudOperation.UPDATED, data: result, }); - return result + return result; } } diff --git a/apps/server/src/models/lecture/lecture.module.ts b/apps/server/src/models/lecture/lecture.module.ts index a7d7561..71f6573 100644 --- a/apps/server/src/models/lecture/lecture.module.ts +++ b/apps/server/src/models/lecture/lecture.module.ts @@ -1,10 +1,10 @@ -import { Module } from '@nestjs/common'; -import { LectureRouter } from './lecture.router'; -import { LectureService } from './lecture.service'; -import { TrpcService } from '@server/trpc/trpc.service'; +// 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 { } +// @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 index 39d609e..832c8a6 100644 --- a/apps/server/src/models/lecture/lecture.router.ts +++ b/apps/server/src/models/lecture/lecture.router.ts @@ -1,70 +1,70 @@ -import { Injectable } from '@nestjs/common'; -import { TrpcService } from '@server/trpc/trpc.service'; -import { Prisma, UpdateOrderSchema } from '@nice/common'; -import { LectureService } from './lecture.service'; -import { z, ZodType } from 'zod'; -const LectureCreateArgsSchema: ZodType = z.any() -const LectureCreateManyInputSchema: ZodType = z.any() -const LectureDeleteManyArgsSchema: ZodType = z.any() -const LectureFindManyArgsSchema: ZodType = z.any() -const LectureFindFirstArgsSchema: ZodType = z.any() -const LectureWhereInputSchema: ZodType = z.any() -const LectureSelectSchema: ZodType = z.any() +// import { Injectable } from '@nestjs/common'; +// import { TrpcService } from '@server/trpc/trpc.service'; +// import { Prisma, UpdateOrderSchema } from '@nice/common'; +// import { LectureService } from './lecture.service'; +// import { z, ZodType } from 'zod'; +// const LectureCreateArgsSchema: ZodType = z.any() +// const LectureCreateManyInputSchema: ZodType = z.any() +// const LectureDeleteManyArgsSchema: ZodType = z.any() +// const LectureFindManyArgsSchema: ZodType = z.any() +// const LectureFindFirstArgsSchema: ZodType = z.any() +// const LectureWhereInputSchema: ZodType = z.any() +// const LectureSelectSchema: ZodType = z.any() -@Injectable() -export class LectureRouter { - constructor( - private readonly trpc: TrpcService, - private readonly lectureService: LectureService, - ) { } - router = this.trpc.router({ - create: this.trpc.protectProcedure - .input(LectureCreateArgsSchema) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.lectureService.create(input, {staff}); - }), - createMany: this.trpc.protectProcedure.input(z.array(LectureCreateManyInputSchema)) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; +// @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); - }), - }); -} +// 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 index ea02d72..4eb7250 100644 --- a/apps/server/src/models/lecture/lecture.service.ts +++ b/apps/server/src/models/lecture/lecture.service.ts @@ -1,35 +1,35 @@ -import { Injectable } from '@nestjs/common'; -import { BaseService } from '../base/base.service'; -import { - UserProfile, - db, - ObjectType, - Prisma -} from '@nice/common'; -import EventBus, { CrudOperation } from '@server/utils/event-bus'; +// import { Injectable } from '@nestjs/common'; +// import { BaseService } from '../base/base.service'; +// import { +// UserProfile, +// db, +// ObjectType, +// Prisma +// } from '@nice/common'; +// import EventBus, { CrudOperation } from '@server/utils/event-bus'; -@Injectable() -export class LectureService extends BaseService { - constructor() { - super(db, ObjectType.COURSE); - } - async create(args: Prisma.LectureCreateArgs, params?: { staff?: UserProfile }) { - const result = await super.create(args) - EventBus.emit('dataChanged', { - type: ObjectType.LECTURE, - operation: CrudOperation.CREATED, - data: result, - }); - return result; - } - async update(args: Prisma.LectureUpdateArgs) { - const result = await super.update(args); - EventBus.emit('dataChanged', { - type: ObjectType.LECTURE, - operation: CrudOperation.UPDATED, - data: result, - }); - return result; - } +// @Injectable() +// export class LectureService extends BaseService { +// constructor() { +// super(db, ObjectType.COURSE); +// } +// async create(args: Prisma.LectureCreateArgs, params?: { staff?: UserProfile }) { +// const result = await super.create(args) +// EventBus.emit('dataChanged', { +// type: ObjectType.LECTURE, +// operation: CrudOperation.CREATED, +// data: result, +// }); +// return result; +// } +// async update(args: Prisma.LectureUpdateArgs) { +// const result = await super.update(args); +// EventBus.emit('dataChanged', { +// type: ObjectType.LECTURE, +// operation: CrudOperation.UPDATED, +// data: result, +// }); +// return result; +// } -} +// } diff --git a/apps/server/src/models/lecture/utils.ts b/apps/server/src/models/lecture/utils.ts index f5216f8..dc0c823 100644 --- a/apps/server/src/models/lecture/utils.ts +++ b/apps/server/src/models/lecture/utils.ts @@ -1,39 +1,48 @@ -import { db, Lecture } from '@nice/common'; +import { db, PostType } from '@nice/common'; -export async function updateSectionLectureStats(sectionId: string) { - const sectionStats = await db.lecture.aggregate({ - where: { - sectionId, - deletedAt: null, - }, - _count: { _all: true }, - _sum: { duration: true }, - }); +// export async function updateSectionLectureStats(sectionId: string) { +// const sectionStats = await db.post.aggregate({ +// where: { +// parentId: sectionId, +// deletedAt: null, +// type: PostType.LECTURE, +// }, +// _count: { _all: true }, +// _sum: { duration: true }, +// }); - await db.section.update({ - where: { id: sectionId }, - data: { - // totalLectures: sectionStats._count._all, - // totalDuration: sectionStats._sum.duration || 0, - }, - }); -} +// await db.post.update({ +// where: { id: sectionId }, +// data: { +// // totalLectures: sectionStats._count._all, +// // totalDuration: sectionStats._sum.duration || 0, +// }, +// }); +// } -export async function updateCourseLectureStats(courseId: string) { - const courseStats = await db.lecture.aggregate({ - where: { - courseId, - deletedAt: null, - }, - _count: { _all: true }, - _sum: { duration: true }, - }); - - await db.course.update({ - where: { id: courseId }, - data: { - //totalLectures: courseStats._count._all, - //totalDuration: courseStats._sum.duration || 0, - }, - }); -} +// export async function updateParentLectureStats(parentId: string) { +// const ParentStats = await db.post.aggregate({ +// where: { +// ancestors: { +// some: { +// ancestorId: parentId, +// descendant: { +// type: PostType.LECTURE, +// deletedAt: null, +// }, +// }, +// }, +// }, +// _count: { _all: true }, +// _sum: { +// duration: true, +// }, +// }); +// await db.post.update({ +// where: { id: parentId }, +// data: { +// //totalLectures: courseStats._count._all, +// //totalDuration: courseStats._sum.duration || 0, +// }, +// }); +// } diff --git a/apps/server/src/models/post/post.router.ts b/apps/server/src/models/post/post.router.ts index b4ca364..458564d 100755 --- a/apps/server/src/models/post/post.router.ts +++ b/apps/server/src/models/post/post.router.ts @@ -1,11 +1,12 @@ import { Injectable } from '@nestjs/common'; import { TrpcService } from '@server/trpc/trpc.service'; -import { Prisma } from '@nice/common'; +import { CourseMethodSchema, Prisma } from '@nice/common'; import { PostService } from './post.service'; import { z, ZodType } from 'zod'; const PostCreateArgsSchema: ZodType = z.any(); const PostUpdateArgsSchema: ZodType = z.any(); const PostFindFirstArgsSchema: ZodType = z.any(); +const PostFindManyArgsSchema: ZodType = z.any(); const PostDeleteManyArgsSchema: ZodType = z.any(); const PostWhereInputSchema: ZodType = z.any(); const PostSelectSchema: ZodType = z.any(); @@ -15,7 +16,7 @@ export class PostRouter { constructor( private readonly trpc: TrpcService, private readonly postService: PostService, - ) { } + ) {} router = this.trpc.router({ create: this.trpc.protectProcedure .input(PostCreateArgsSchema) @@ -23,6 +24,12 @@ export class PostRouter { const { staff } = ctx; return await this.postService.create(input, { staff }); }), + createCourse: this.trpc.protectProcedure + .input(CourseMethodSchema.createCourse) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.postService.createCourse(input, { staff }); + }), softDeleteByIds: this.trpc.protectProcedure .input( z.object({ @@ -55,6 +62,21 @@ export class PostRouter { const { staff } = ctx; return await this.postService.findById(input.id, input.args); }), + findMany: this.trpc.protectProcedure + .input(PostFindManyArgsSchema) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.postService.findMany(input); + }), + + findFirst: this.trpc.procedure + .input(PostFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input, ctx }) => { + const { staff, ip } = ctx; + // 从请求中获取 IP + + return await this.postService.findFirst(input, staff, ip); + }), deleteMany: this.trpc.protectProcedure .input(PostDeleteManyArgsSchema) .mutation(async ({ input }) => { @@ -73,5 +95,17 @@ export class PostRouter { const { staff } = ctx; return await this.postService.findManyWithCursor(input, staff); }), + findManyWithPagination: this.trpc.procedure + .input( + z.object({ + page: z.number().optional(), + pageSize: z.number().optional(), + where: PostWhereInputSchema.optional(), + select: PostSelectSchema.optional(), + }), + ) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.postService.findManyWithPagination(input); + }), }); } diff --git a/apps/server/src/models/post/post.service.ts b/apps/server/src/models/post/post.service.ts index a391ba6..c8cd7ba 100755 --- a/apps/server/src/models/post/post.service.ts +++ b/apps/server/src/models/post/post.service.ts @@ -9,24 +9,146 @@ import { RolePerms, ResPerm, ObjectType, + LectureType, + CourseMethodSchema, } from '@nice/common'; import { MessageService } from '../message/message.service'; import { BaseService } from '../base/base.service'; import { DepartmentService } from '../department/department.service'; import { setPostRelation } from './utils'; import EventBus, { CrudOperation } from '@server/utils/event-bus'; +import { BaseTreeService } from '../base/base.tree.service'; +import { z } from 'zod'; @Injectable() -export class PostService extends BaseService { +export class PostService extends BaseTreeService { constructor( private readonly messageService: MessageService, private readonly departmentService: DepartmentService, ) { - super(db, ObjectType.POST); + super(db, ObjectType.POST, 'postAncestry', true); + } + async createLecture( + lecture: z.infer, + params: { staff?: UserProfile; tx?: Prisma.TransactionClient }, + ) { + const { sectionId, type, title, content, resourceIds = [] } = lecture; + const { staff, tx } = params; + return await this.create( + { + data: { + type: PostType.LECTURE, + parentId: sectionId, + content: content, + title: title, + authorId: params?.staff?.id, + resources: { + connect: resourceIds.map((fileId) => ({ fileId })), + }, + meta: { + type: type, + }, + }, + }, + { tx }, + ); + } + async createSection( + section: z.infer, + params: { + staff?: UserProfile; + tx?: Prisma.TransactionClient; + }, + ) { + const { title, courseId, lectures } = section; + const { staff, tx } = params; + // Create section first + const createdSection = await this.create( + { + data: { + type: PostType.SECTION, + parentId: courseId, + title: title, + authorId: staff?.id, + }, + }, + { tx }, + ); + // If lectures are provided, create them + if (lectures && lectures.length > 0) { + const lecturePromises = lectures.map((lecture) => + this.createLecture( + { + sectionId: createdSection.id, + ...lecture, + }, + params, + ), + ); + + // Create all lectures in parallel + await Promise.all(lecturePromises); + } + return createdSection; + } + async createCourse( + args: { + courseDetail: Prisma.PostCreateArgs; + sections?: z.infer[]; + }, + params: { staff?: UserProfile; tx?: Prisma.TransactionClient }, + ) { + const { courseDetail, sections } = args; + // If no transaction is provided, create a new one + if (!params.tx) { + return await db.$transaction(async (tx) => { + const courseParams = { ...params, tx }; + // Create the course first + console.log(courseParams?.staff?.id); + console.log('courseDetail', courseDetail); + const createdCourse = await this.create(courseDetail, courseParams); + // If sections are provided, create them + if (sections && sections.length > 0) { + const sectionPromises = sections.map((section) => + this.createSection( + { + courseId: createdCourse.id, + title: section.title, + lectures: section.lectures, + }, + courseParams, + ), + ); + // Create all sections (and their lectures) in parallel + await Promise.all(sectionPromises); + } + return createdCourse; + }); + } + // If transaction is provided, use it directly + console.log('courseDetail', courseDetail); + const createdCourse = await this.create(courseDetail, params); + // If sections are provided, create them + if (sections && sections.length > 0) { + const sectionPromises = sections.map((section) => + this.createSection( + { + courseId: createdCourse.id, + title: section.title, + lectures: section.lectures, + }, + params, + ), + ); + // Create all sections (and their lectures) in parallel + await Promise.all(sectionPromises); + } + + return createdCourse; } async create( args: Prisma.PostCreateArgs, - params: { staff?: UserProfile; tx?: Prisma.PostDelegate }, + params?: { staff?: UserProfile; tx?: Prisma.TransactionClient }, ) { args.data.authorId = params?.staff?.id; const result = await super.create(args); @@ -45,13 +167,44 @@ export class PostService extends BaseService { operation: CrudOperation.UPDATED, data: result, }); - return result + return result; } + async findFirst( + args?: Prisma.PostFindFirstArgs, + staff?: UserProfile, + clientIp?: string, + ) { + const transDto = await this.wrapResult( + super.findFirst(args), + async (result) => { + if (result) { + await setPostRelation({ data: result, staff }); + await this.setPerms(result, staff); + } + + return result; + }, + ); + return transDto; + } + // async findMany(args: Prisma.PostFindManyArgs, staff?: UserProfile) { + // if (!args.where) args.where = {}; + // args.where.OR = await this.preFilter(args.where.OR, staff); + // return this.wrapResult(super.findMany(args), async (result) => { + // await Promise.all( + // result.map(async (item) => { + // await setPostRelation({ data: item, staff }); + // await this.setPerms(item, staff); + // }), + // ); + // return { ...result }; + // }); + // } async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) { - if (!args.where) args.where = {} + if (!args.where) args.where = {}; args.where.OR = await this.preFilter(args.where.OR, staff); return this.wrapResult(super.findManyWithCursor(args), async (result) => { - let { items } = result; + const { items } = result; await Promise.all( items.map(async (item) => { await setPostRelation({ data: item, staff }); @@ -99,7 +252,7 @@ export class PostService extends BaseService { return outOR?.length > 0 ? outOR : undefined; } async getPostPreFilter(staff?: UserProfile) { - if (!staff) return + 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 6029f81..91af13e 100644 --- a/apps/server/src/models/post/utils.ts +++ b/apps/server/src/models/post/utils.ts @@ -1,44 +1,125 @@ -import { db, Post, PostType, UserProfile, VisitType } from "@nice/common"; +import { + db, + EnrollmentStatus, + Post, + PostType, + UserProfile, + VisitType, +} from '@nice/common'; -export async function setPostRelation(params: { data: Post, staff?: UserProfile }) { - const { data, staff } = params - const limitedComments = await db.post.findMany({ - where: { - parentId: data.id, - type: PostType.POST_COMMENT, - }, - include: { - author: true, - }, - take: 5, - }); - const commentsCount = await db.post.count({ - where: { - parentId: data.id, - type: PostType.POST_COMMENT, - }, - }); - const readed = - (await db.visit.count({ - where: { - postId: data.id, - type: VisitType.READED, - visitorId: staff?.id, - }, - })) > 0; - const readedCount = await db.visit.count({ - where: { - postId: data.id, - type: VisitType.READED, - }, - }); +export async function setPostRelation(params: { + data: Post; + staff?: UserProfile; +}) { + const { data, staff } = params; + const limitedComments = await db.post.findMany({ + where: { + parentId: data.id, + type: PostType.POST_COMMENT, + }, + include: { + author: true, + }, + take: 5, + }); + const commentsCount = await db.post.count({ + where: { + parentId: data.id, + type: PostType.POST_COMMENT, + }, + }); + const readed = + (await db.visit.count({ + where: { + postId: data.id, + type: VisitType.READED, + visitorId: staff?.id, + }, + })) > 0; + const readedCount = await db.visit.count({ + where: { + postId: data.id, + type: VisitType.READED, + }, + }); - Object.assign(data, { - readed, - readedCount, - limitedComments, - commentsCount, - // trouble - }) + Object.assign(data, { + readed, + readedCount, + limitedComments, + commentsCount, + // trouble + }); +} +export async function updateParentLectureStats(parentId: string) { + const ParentStats = await db.post.aggregate({ + where: { + ancestors: { + some: { + ancestorId: parentId, + descendant: { + type: PostType.LECTURE, + deletedAt: null, + }, + }, + }, + }, + _count: { _all: true }, + _sum: { + duration: true, + }, + }); + await db.post.update({ + where: { id: parentId }, + data: { + //totalLectures: courseStats._count._all, + //totalDuration: courseStats._sum.duration || 0, + }, + }); +} -} \ No newline at end of file +// 更新课程评价统计 +export async function updateCourseReviewStats(courseId: string) { + const reviews = await db.visit.findMany({ + where: { + postId: courseId, + type: PostType.COURSE_REVIEW, + deletedAt: null, + }, + select: { views: true }, + }); + const numberOfReviews = reviews.length; + const averageRating = + numberOfReviews > 0 + ? reviews.reduce((sum, review) => sum + review.views, 0) / numberOfReviews + : 0; + + return db.post.update({ + where: { id: courseId }, + data: { + // numberOfReviews, + //averageRating, + }, + }); +} +// 更新课程注册统计 +export async function updateCourseEnrollmentStats(courseId: string) { + const completedEnrollments = await db.enrollment.count({ + where: { + postId: courseId, + status: EnrollmentStatus.COMPLETED, + }, + }); + const totalEnrollments = await db.enrollment.count({ + where: { postId: courseId }, + }); + const completionRate = + totalEnrollments > 0 ? (completedEnrollments / totalEnrollments) * 100 : 0; + return db.post.update({ + where: { id: courseId }, + data: { + // numberOfStudents: totalEnrollments, + // completionRate, + }, + }); +} diff --git a/apps/server/src/models/rbac/role.service.ts b/apps/server/src/models/rbac/role.service.ts index 210b5df..513482a 100755 --- a/apps/server/src/models/rbac/role.service.ts +++ b/apps/server/src/models/rbac/role.service.ts @@ -1,26 +1,25 @@ import { Injectable } from '@nestjs/common'; -import { db, RoleMethodSchema, ObjectType, Prisma } from "@nice/common"; +import { db, RoleMethodSchema, ObjectType, Prisma } from '@nice/common'; import { BaseService } from '../base/base.service'; @Injectable() export class RoleService extends BaseService { - constructor() { - super(db, ObjectType.ROLE) - } - /** - * 批量删除角色 - * @param data 包含要删除的角色ID列表的数据 - * @returns 删除结果 - * @throws 如果未提供ID,将抛出错误 - */ - async softDeleteByIds(ids: string[], data?: Prisma.RoleUpdateInput) { - await db.roleMap.deleteMany({ - where: { - roleId: { - in: ids - } - } - }); - return await super.softDeleteByIds(ids, data) - } - + constructor() { + super(db, ObjectType.ROLE); + } + /** + * 批量删除角色 + * @param data 包含要删除的角色ID列表的数据 + * @returns 删除结果 + * @throws 如果未提供ID,将抛出错误 + */ + async softDeleteByIds(ids: string[], data?: Prisma.RoleUpdateInput) { + await db.roleMap.deleteMany({ + where: { + roleId: { + in: ids, + }, + }, + }); + return await super.softDeleteByIds(ids, data); + } } diff --git a/apps/server/src/models/resource/pipe/resource.pipeline.ts b/apps/server/src/models/resource/pipe/resource.pipeline.ts index 2c5d98e..bd3c7fd 100644 --- a/apps/server/src/models/resource/pipe/resource.pipeline.ts +++ b/apps/server/src/models/resource/pipe/resource.pipeline.ts @@ -51,7 +51,7 @@ export class ResourceProcessingPipeline { ResourceStatus.PROCESSED, ); this.logger.log( - `资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.metadata)}`, + `资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.meta)}`, ); return { diff --git a/apps/server/src/models/resource/processor/ImageProcessor.ts b/apps/server/src/models/resource/processor/ImageProcessor.ts index ea01e75..8228c08 100644 --- a/apps/server/src/models/resource/processor/ImageProcessor.ts +++ b/apps/server/src/models/resource/processor/ImageProcessor.ts @@ -13,7 +13,7 @@ export class ImageProcessor extends BaseProcessor { async process(resource: Resource): Promise { const { url } = resource; const filepath = getUploadFilePath(url); - const originMeta = resource.metadata as unknown as FileMetadata; + const originMeta = resource.meta as unknown as FileMetadata; if (!originMeta.mimeType?.startsWith('image/')) { this.logger.log(`Skipping non-image resource: ${resource.id}`); return resource; @@ -47,7 +47,7 @@ export class ImageProcessor extends BaseProcessor { const updatedResource = await db.resource.update({ where: { id: resource.id }, data: { - metadata: { + meta: { ...originMeta, ...imageMeta, }, diff --git a/apps/server/src/models/resource/processor/VideoProcessor.ts b/apps/server/src/models/resource/processor/VideoProcessor.ts index 7853f5e..3600298 100644 --- a/apps/server/src/models/resource/processor/VideoProcessor.ts +++ b/apps/server/src/models/resource/processor/VideoProcessor.ts @@ -18,7 +18,7 @@ export class VideoProcessor extends BaseProcessor { `Processing video for resource ID: ${resource.id}, File ID: ${url}`, ); - const originMeta = resource.metadata as unknown as FileMetadata; + const originMeta = resource.meta as unknown as FileMetadata; if (!originMeta.mimeType?.startsWith('video/')) { this.logger.log(`Skipping non-video resource: ${resource.id}`); return resource; @@ -39,7 +39,7 @@ export class VideoProcessor extends BaseProcessor { const updatedResource = await db.resource.update({ where: { id: resource.id }, data: { - metadata: { + meta: { ...originMeta, ...videoMeta, }, diff --git a/apps/server/src/models/section/section.module.ts b/apps/server/src/models/section/section.module.ts index 0a44529..a337cb6 100644 --- a/apps/server/src/models/section/section.module.ts +++ b/apps/server/src/models/section/section.module.ts @@ -1,10 +1,10 @@ -import { Module } from '@nestjs/common'; -import { SectionRouter } from './section.router'; -import { SectionService } from './section.service'; -import { TrpcService } from '@server/trpc/trpc.service'; +// 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 { } +// @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 index fcdd6ba..2658567 100644 --- a/apps/server/src/models/section/section.router.ts +++ b/apps/server/src/models/section/section.router.ts @@ -1,70 +1,70 @@ -import { Injectable } from '@nestjs/common'; -import { TrpcService } from '@server/trpc/trpc.service'; -import { Prisma, UpdateOrderSchema } from '@nice/common'; -import { SectionService } from './section.service'; -import { z, ZodType } from 'zod'; -const SectionCreateArgsSchema: ZodType = z.any() -const SectionCreateManyInputSchema: ZodType = z.any() -const SectionDeleteManyArgsSchema: ZodType = z.any() -const SectionFindManyArgsSchema: ZodType = z.any() -const SectionFindFirstArgsSchema: ZodType = z.any() -const SectionWhereInputSchema: ZodType = z.any() -const SectionSelectSchema: ZodType = z.any() +// import { Injectable } from '@nestjs/common'; +// import { TrpcService } from '@server/trpc/trpc.service'; +// import { Prisma, UpdateOrderSchema } from '@nice/common'; +// import { SectionService } from './section.service'; +// import { z, ZodType } from 'zod'; +// const SectionCreateArgsSchema: ZodType = z.any() +// const SectionCreateManyInputSchema: ZodType = z.any() +// const SectionDeleteManyArgsSchema: ZodType = z.any() +// const SectionFindManyArgsSchema: ZodType = z.any() +// const SectionFindFirstArgsSchema: ZodType = z.any() +// const SectionWhereInputSchema: ZodType = z.any() +// const SectionSelectSchema: ZodType = z.any() -@Injectable() -export class SectionRouter { - constructor( - private readonly trpc: TrpcService, - private readonly sectionService: SectionService, - ) { } - router = this.trpc.router({ - create: this.trpc.protectProcedure - .input(SectionCreateArgsSchema) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.sectionService.create(input, { staff }); - }), - createMany: this.trpc.protectProcedure.input(z.array(SectionCreateManyInputSchema)) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; +// @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); - }), - }); -} +// 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 index 229e26b..177cf4d 100644 --- a/apps/server/src/models/section/section.service.ts +++ b/apps/server/src/models/section/section.service.ts @@ -1,24 +1,23 @@ -import { Injectable } from '@nestjs/common'; -import { BaseService } from '../base/base.service'; -import { - UserProfile, - db, - ObjectType, - Prisma, +// import { Injectable } from '@nestjs/common'; +// import { BaseService } from '../base/base.service'; +// import { +// UserProfile, +// db, +// ObjectType, +// Prisma, -} from '@nice/common'; -@Injectable() -export class SectionService extends BaseService { - constructor() { - super(db, ObjectType.SECTION); - } +// } from '@nice/common'; +// @Injectable() +// export class SectionService extends BaseService { +// constructor() { +// super(db, ObjectType.SECTION); +// } - create(args: Prisma.SectionCreateArgs, params?: { staff?: UserProfile }) { - return super.create(args) - } - async update(args: Prisma.SectionUpdateArgs) { - return super.update(args); - } +// create(args: Prisma.SectionCreateArgs, params?: { staff?: UserProfile }) { +// return super.create(args) +// } +// async update(args: Prisma.SectionUpdateArgs) { +// return super.update(args); +// } - -} +// } diff --git a/apps/server/src/models/staff/staff.router.ts b/apps/server/src/models/staff/staff.router.ts index 37fe6fa..4768b16 100755 --- a/apps/server/src/models/staff/staff.router.ts +++ b/apps/server/src/models/staff/staff.router.ts @@ -10,15 +10,15 @@ const StaffFindFirstArgsSchema: ZodType = z.any(); const StaffDeleteManyArgsSchema: ZodType = z.any(); const StaffWhereInputSchema: ZodType = z.any(); const StaffSelectSchema: ZodType = z.any(); -const StaffUpdateInputSchema: ZodType = z.any(); +const StaffUpdateInputSchema: ZodType = z.any(); const StaffFindManyArgsSchema: ZodType = z.any(); @Injectable() export class StaffRouter { constructor( private readonly trpc: TrpcService, private readonly staffService: StaffService, - private readonly staffRowService: StaffRowService - ) { } + private readonly staffRowService: StaffRowService, + ) {} router = this.trpc.router({ create: this.trpc.procedure @@ -35,7 +35,7 @@ export class StaffRouter { updateUserDomain: this.trpc.protectProcedure .input( z.object({ - domainId: z.string() + domainId: z.string(), }), ) .mutation(async ({ input, ctx }) => { @@ -72,8 +72,10 @@ export class StaffRouter { const { staff } = ctx; return await this.staffService.findFirst(input); }), - updateOrder: this.trpc.protectProcedure.input(UpdateOrderSchema).mutation(async ({ input }) => { - return this.staffService.updateOrder(input) - }), + updateOrder: this.trpc.protectProcedure + .input(UpdateOrderSchema) + .mutation(async ({ input }) => { + return this.staffService.updateOrder(input); + }), }); } diff --git a/apps/server/src/models/taxonomy/taxonomy.service.ts b/apps/server/src/models/taxonomy/taxonomy.service.ts index 8ca0d00..a8efe3e 100755 --- a/apps/server/src/models/taxonomy/taxonomy.service.ts +++ b/apps/server/src/models/taxonomy/taxonomy.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { db, TaxonomyMethodSchema, Prisma } from '@nice/common'; +import { db, TaxonomyMethodSchema, Prisma } from '@nice/common'; import { redis } from '@server/utils/redis/redis.service'; import { deleteByPattern } from '@server/utils/redis/utils'; import { TRPCError } from '@trpc/server'; @@ -7,13 +7,13 @@ import { z } from 'zod'; @Injectable() export class TaxonomyService { - constructor() { } + constructor() {} /** * 清除分页缓存,删除所有以'taxonomies:page:'开头的键 */ private async invalidatePaginationCache() { - deleteByPattern('taxonomies:page:*') + deleteByPattern('taxonomies:page:*'); } /** @@ -42,7 +42,7 @@ export class TaxonomyService { async findByName(input: z.infer) { const { name } = input; const cacheKey = `taxonomy:${name}`; - let cachedTaxonomy = await redis.get(cacheKey); + const cachedTaxonomy = await redis.get(cacheKey); if (cachedTaxonomy) { return JSON.parse(cachedTaxonomy); } @@ -55,7 +55,7 @@ export class TaxonomyService { async findBySlug(input: z.infer) { const { slug } = input; const cacheKey = `taxonomy-slug:${slug}`; - let cachedTaxonomy = await redis.get(cacheKey); + const cachedTaxonomy = await redis.get(cacheKey); if (cachedTaxonomy) { return JSON.parse(cachedTaxonomy); } @@ -72,7 +72,7 @@ export class TaxonomyService { */ async findById(input: z.infer) { const cacheKey = `taxonomy:${input.id}`; - let cachedTaxonomy = await redis.get(cacheKey); + const cachedTaxonomy = await redis.get(cacheKey); if (cachedTaxonomy) { return JSON.parse(cachedTaxonomy); } @@ -149,7 +149,7 @@ export class TaxonomyService { */ async paginate(input: any) { const cacheKey = `taxonomies:page:${input.page}:size:${input.pageSize}`; - let cachedData = await redis.get(cacheKey); + const cachedData = await redis.get(cacheKey); if (cachedData) { return JSON.parse(cachedData); } @@ -197,7 +197,7 @@ export class TaxonomyService { slug: true, objectType: true, order: true, - } + }, }); } } diff --git a/apps/server/src/models/transform/transform.router.ts b/apps/server/src/models/transform/transform.router.ts index 9a3d7ed..24cbd05 100755 --- a/apps/server/src/models/transform/transform.router.ts +++ b/apps/server/src/models/transform/transform.router.ts @@ -1,16 +1,14 @@ import { Injectable } from '@nestjs/common'; import { TransformService } from './transform.service'; -import { TransformMethodSchema} from '@nice/common'; +import { TransformMethodSchema } from '@nice/common'; import { TrpcService } from '@server/trpc/trpc.service'; @Injectable() export class TransformRouter { constructor( private readonly trpc: TrpcService, private readonly transformService: TransformService, - ) { } + ) {} router = this.trpc.router({ - - importTerms: this.trpc.protectProcedure .input(TransformMethodSchema.importTerms) // expect input according to the schema .mutation(async ({ ctx, input }) => { @@ -29,6 +27,5 @@ export class TransformRouter { const { staff } = ctx; return this.transformService.importStaffs(input); }), - }); } diff --git a/apps/server/src/models/transform/transform.service.ts b/apps/server/src/models/transform/transform.service.ts index 22a835a..a8dd88c 100755 --- a/apps/server/src/models/transform/transform.service.ts +++ b/apps/server/src/models/transform/transform.service.ts @@ -1,11 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import * as ExcelJS from 'exceljs'; -import { - TransformMethodSchema, - db, - Prisma, - Staff, -} from '@nice/common'; +import { TransformMethodSchema, db, Prisma, Staff } from '@nice/common'; import dayjs from 'dayjs'; import * as argon2 from 'argon2'; import { TaxonomyService } from '@server/models/taxonomy/taxonomy.service'; @@ -184,7 +179,7 @@ export class TransformService { return !cell || cell.toString().trim() === ''; }); } - + async importStaffs(data: z.infer) { const { base64, domainId } = data; this.logger.log('开始'); @@ -543,5 +538,4 @@ export class TransformService { // }, // }); } - } diff --git a/apps/server/src/models/visit/visit.service.ts b/apps/server/src/models/visit/visit.service.ts index 69ac47a..a7f2ada 100644 --- a/apps/server/src/models/visit/visit.service.ts +++ b/apps/server/src/models/visit/visit.service.ts @@ -1,12 +1,6 @@ import { Injectable } from '@nestjs/common'; import { BaseService } from '../base/base.service'; -import { - UserProfile, - db, - ObjectType, - Prisma, - VisitType, -} from '@nice/common'; +import { UserProfile, db, ObjectType, Prisma, VisitType } from '@nice/common'; import EventBus from '@server/utils/event-bus'; @Injectable() export class VisitService extends BaseService { @@ -50,7 +44,7 @@ export class VisitService extends BaseService { const createData: Prisma.VisitCreateManyInput[] = []; await Promise.all( data.map(async (item) => { - if (staff && !item.visitorId) item.visitorId = staff.id + if (staff && !item.visitorId) item.visitorId = staff.id; const { postId, lectureId, messageId, visitorId } = item; const existingVisit = await db.visit.findFirst({ where: { diff --git a/apps/server/src/queue/worker/processor.ts b/apps/server/src/queue/worker/processor.ts index e5b2052..7273ecf 100755 --- a/apps/server/src/queue/worker/processor.ts +++ b/apps/server/src/queue/worker/processor.ts @@ -1,15 +1,16 @@ import { Job } from 'bullmq'; import { Logger } from '@nestjs/common'; -import { - updateCourseLectureStats, - updateSectionLectureStats, -} from '@server/models/lecture/utils'; import { ObjectType } from '@nice/common'; +// import { +// updateCourseEnrollmentStats, +// updateCourseReviewStats, +// } from '@server/models/course/utils'; +import { QueueJobType } from '../types'; import { updateCourseEnrollmentStats, updateCourseReviewStats, -} from '@server/models/course/utils'; -import { QueueJobType } from '../types'; + updateParentLectureStats, +} from '@server/models/post/utils'; const logger = new Logger('QueueWorker'); export default async function processJob(job: Job) { try { @@ -17,7 +18,7 @@ export default async function processJob(job: Job) { const { sectionId, courseId, type } = job.data; // 处理 section 统计 if (sectionId) { - await updateSectionLectureStats(sectionId); + await updateParentLectureStats(sectionId); logger.debug(`Updated section stats for sectionId: ${sectionId}`); } // 如果没有 courseId,提前返回 @@ -27,7 +28,7 @@ export default async function processJob(job: Job) { // 处理 course 相关统计 switch (type) { case ObjectType.LECTURE: - await updateCourseLectureStats(courseId); + await updateParentLectureStats(courseId); break; case ObjectType.ENROLLMENT: await updateCourseEnrollmentStats(courseId); diff --git a/apps/server/src/tasks/init/gendev.service.ts b/apps/server/src/tasks/init/gendev.service.ts index d955a18..fca5ca3 100644 --- a/apps/server/src/tasks/init/gendev.service.ts +++ b/apps/server/src/tasks/init/gendev.service.ts @@ -12,12 +12,7 @@ import { Term, } from '@nice/common'; import EventBus from '@server/utils/event-bus'; -import { - - capitalizeFirstLetter, - DevDataCounts, - getCounts, -} from './utils'; +import { capitalizeFirstLetter, DevDataCounts, getCounts } from './utils'; import { StaffService } from '@server/models/staff/staff.service'; @Injectable() export class GenDevService { @@ -28,6 +23,7 @@ export class GenDevService { [TaxonomySlug.CATEGORY]: [], [TaxonomySlug.UNIT]: [], [TaxonomySlug.TAG]: [], + [TaxonomySlug.LEVEL]: [], }; depts: Department[] = []; domains: Department[] = []; @@ -40,7 +36,7 @@ export class GenDevService { private readonly departmentService: DepartmentService, private readonly staffService: StaffService, private readonly termService: TermService, - ) { } + ) {} async genDataEvent() { EventBus.emit('genDataEvent', { type: 'start' }); try { @@ -48,7 +44,6 @@ export class GenDevService { await this.generateDepartments(3, 6); await this.generateTerms(2, 6); await this.generateStaffs(4); - } catch (err) { this.logger.error(err); } @@ -165,8 +160,8 @@ export class GenDevService { showname: username, username: username, deptId: dept.id, - domainId: domain.id - } + domainId: domain.id, + }, }); // Update both deptStaffRecord and staffs array this.deptStaffRecord[dept.id].push(staff); @@ -191,7 +186,7 @@ export class GenDevService { name, isDomain: currentDepth === 1 ? true : false, parentId, - } + }, }); return department; } @@ -218,7 +213,7 @@ export class GenDevService { taxonomyId: taxonomy!.id, domainId: domain?.id, parentId, - } + }, }); this.terms[taxonomySlug].push(newTerm); await createTermTree(newTerm.id, currentDepth + 1); diff --git a/apps/server/src/trpc/trpc.module.ts b/apps/server/src/trpc/trpc.module.ts index fff1aa9..222c9c9 100755 --- a/apps/server/src/trpc/trpc.module.ts +++ b/apps/server/src/trpc/trpc.module.ts @@ -14,9 +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'; + +import { ResourceModule } from '@server/models/resource/resource.module'; + @Module({ imports: [ AuthModule, @@ -31,12 +31,10 @@ import { SectionModule } from '@server/models/section/section.module'; AppConfigModule, PostModule, VisitModule, - CourseModule, - LectureModule, - SectionModule, - WebSocketModule + WebSocketModule, + ResourceModule, ], controllers: [], providers: [TrpcService, TrpcRouter, Logger], }) -export class TrpcModule { } +export class TrpcModule {} diff --git a/apps/server/src/trpc/trpc.router.ts b/apps/server/src/trpc/trpc.router.ts index ac97d20..7550867 100755 --- a/apps/server/src/trpc/trpc.router.ts +++ b/apps/server/src/trpc/trpc.router.ts @@ -13,12 +13,11 @@ 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'; +import { ResourceRouter } from '../models/resource/resource.router'; + @Injectable() export class TrpcRouter { - logger = new Logger(TrpcRouter.name) + logger = new Logger(TrpcRouter.name); constructor( private readonly trpc: TrpcService, private readonly post: PostRouter, @@ -32,13 +31,12 @@ 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 - ) { } + private readonly resource: ResourceRouter, + ) {} + getRouter() { + return; + } appRouter = this.trpc.router({ - transform: this.transform.router, post: this.post.router, department: this.department.router, @@ -50,11 +48,9 @@ export class TrpcRouter { message: this.message.router, app_config: this.app_config.router, visitor: this.visitor.router, - course: this.course.router, - lecture: this.lecture.router, - section: this.section.router + resource: this.resource.router, }); - wss: WebSocketServer = undefined + wss: WebSocketServer = undefined; async applyMiddleware(app: INestApplication) { app.use( @@ -65,7 +61,7 @@ export class TrpcRouter { onError(opts) { const { error, type, path, input, ctx, req } = opts; // console.error('TRPC Error:', error); - } + }, }), ); // applyWSSHandler({ @@ -75,4 +71,3 @@ export class TrpcRouter { // }); } } -export type AppRouter = TrpcRouter[`appRouter`]; diff --git a/apps/server/src/trpc/trpc.service.ts b/apps/server/src/trpc/trpc.service.ts index 31f3072..38024c9 100755 --- a/apps/server/src/trpc/trpc.service.ts +++ b/apps/server/src/trpc/trpc.service.ts @@ -5,6 +5,7 @@ import * as trpcExpress from '@trpc/server/adapters/express'; import { db, JwtPayload, UserProfile, RolePerms } from '@nice/common'; import { CreateWSSContextFnOptions } from '@trpc/server/adapters/ws'; import { UserProfileService } from '@server/auth/utils'; +import { getClientIp } from './utils'; type Context = Awaited>; @Injectable() export class TrpcService { @@ -12,9 +13,18 @@ export class TrpcService { async createExpressContext( opts: trpcExpress.CreateExpressContextOptions, - ): Promise<{ staff: UserProfile | undefined }> { + ): Promise<{ + staff: UserProfile | undefined; + ip: string; + }> { const token = opts.req.headers.authorization?.split(' ')[1]; - return await UserProfileService.instance.getUserProfileByToken(token); + const staff = + await UserProfileService.instance.getUserProfileByToken(token); + const ip = getClientIp(opts.req); + return { + staff: staff.staff, + ip: ip, + }; } async createWSSContext( opts: CreateWSSContextFnOptions, @@ -45,6 +55,7 @@ export class TrpcService { ctx: { // User value is confirmed to be non-null at this point staff: ctx.staff, + ip: ctx.ip, }, }); }); diff --git a/apps/server/src/trpc/types.ts b/apps/server/src/trpc/types.ts new file mode 100644 index 0000000..fc6ce2c --- /dev/null +++ b/apps/server/src/trpc/types.ts @@ -0,0 +1,3 @@ +import { TrpcRouter } from './trpc.router'; + +export type AppRouter = TrpcRouter[`appRouter`]; diff --git a/apps/server/src/trpc/utils.ts b/apps/server/src/trpc/utils.ts new file mode 100644 index 0000000..0c285a3 --- /dev/null +++ b/apps/server/src/trpc/utils.ts @@ -0,0 +1,15 @@ +export function getClientIp(req: any): string { + let ip = + req.ip || + (Array.isArray(req.headers['x-forwarded-for']) + ? req.headers['x-forwarded-for'][0] + : req.headers['x-forwarded-for']) || + req.socket.remoteAddress; + + // 如果是 IPv4-mapped IPv6 地址,转换为 IPv4 + if (typeof ip === 'string' && ip.startsWith('::ffff:')) { + ip = ip.substring(7); + } + + return ip || ''; +} diff --git a/apps/server/src/upload/tus.service.ts b/apps/server/src/upload/tus.service.ts index 0173d25..8844b97 100644 --- a/apps/server/src/upload/tus.service.ts +++ b/apps/server/src/upload/tus.service.ts @@ -74,7 +74,7 @@ export class TusService implements OnModuleInit { title: getFilenameWithoutExt(upload.metadata.filename), fileId, // 移除最后的文件名 url: upload.id, - metadata: upload.metadata, + meta: upload.metadata, status: ResourceStatus.UPLOADING, }, }); diff --git a/apps/web/src/app/main/courses/instructor/page.tsx b/apps/web/src/app/main/courses/instructor/page.tsx index 032409a..a914925 100644 --- a/apps/web/src/app/main/courses/instructor/page.tsx +++ b/apps/web/src/app/main/courses/instructor/page.tsx @@ -13,7 +13,7 @@ export default function InstructorCoursesPage() { const { user } = useAuth(); const { data: paginationRes, refetch } = - api.course.findManyWithPagination.useQuery({ + api.post.findManyWithPagination.useQuery({ page: currentPage, pageSize: 8, where: { diff --git a/apps/web/src/app/main/courses/student/page.tsx b/apps/web/src/app/main/courses/student/page.tsx index 390d9dc..2508c50 100644 --- a/apps/web/src/app/main/courses/student/page.tsx +++ b/apps/web/src/app/main/courses/student/page.tsx @@ -5,37 +5,44 @@ import { CourseCard } from "@web/src/components/models/course/card/CourseCard"; import { useAuth } from "@web/src/providers/auth-provider"; export default function StudentCoursesPage() { - const [currentPage, setCurrentPage] = useState(1); - const { user } = useAuth() - const { data: paginationRes, refetch } = api.course.findManyWithPagination.useQuery({ - page: currentPage, - pageSize: 8, - where: { - enrollments: { - some: { - studentId: user?.id - } - } - } - }); - const handlePageChange = (page: number) => { - setCurrentPage(page); - refetch() - }; - return ( -
-
-
-

我参加的课程

-
- }> - -
-
- ); -} \ No newline at end of file + const [currentPage, setCurrentPage] = useState(1); + const { user } = useAuth(); + + const { data: paginationRes, refetch } = + api.post.findManyWithPagination.useQuery({ + page: currentPage, + pageSize: 8, + where: { + enrollments: { + some: { + studentId: user?.id, + }, + }, + }, + }); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + refetch(); + }; + + return ( +
+
+
+

+ 我参加的课程 +

+
+ ( + + )}> +
+
+ ); +} diff --git a/apps/web/src/app/main/layout/UserMenu.tsx b/apps/web/src/app/main/layout/UserMenu.tsx index 97396fd..2591253 100644 --- a/apps/web/src/app/main/layout/UserMenu.tsx +++ b/apps/web/src/app/main/layout/UserMenu.tsx @@ -1,49 +1,70 @@ -import { Avatar, Menu, Dropdown } from 'antd'; -import { LogoutOutlined, SettingOutlined } from '@ant-design/icons'; -import { useAuth } from '@web/src/providers/auth-provider'; -import { useNavigate } from 'react-router-dom'; +import { Avatar, Menu, Dropdown } from "antd"; +import { + LogoutOutlined, + SettingOutlined, + UserAddOutlined, + UserSwitchOutlined, +} from "@ant-design/icons"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { useNavigate } from "react-router-dom"; export const UserMenu = () => { - const { isAuthenticated, logout, user } = useAuth(); - const navigate = useNavigate(); + const { isAuthenticated, logout, user } = useAuth(); + const navigate = useNavigate(); - return ( - - {isAuthenticated ? ( - <> - -
- - {(user?.showname || user?.username || '')[0]?.toUpperCase()} - -
- {user?.showname || user?.username} - {user?.department?.name || user?.officerId} -
-
-
- - } className="px-4"> - 个人设置 - - } - onClick={async () => await logout()} - className="px-4 text-red-500 hover:text-red-600 hover:bg-red-50" - > - 退出登录 - - - ) : ( - navigate("/login")} - className="px-4 text-blue-500 hover:text-blue-600 hover:bg-blue-50" - > - 登录/注册 - - )} -
- ); -}; \ No newline at end of file + return ( + + {isAuthenticated ? ( + <> + +
+ + {(user?.showname || + user?.username || + "")[0]?.toUpperCase()} + +
+ + {user?.showname || user?.username} + + + {user?.department?.name || user?.officerId} + +
+
+
+ + } + className="px-4"> + 个人设置 + + } + onClick={() => { + navigate("/admin/staff"); + }} + className="px-4"> + 后台管理 + + } + onClick={async () => await logout()} + className="px-4 text-red-500 hover:text-red-600 hover:bg-red-50"> + 退出登录 + + + ) : ( + navigate("/login")} + className="px-4 text-blue-500 hover:text-blue-600 hover:bg-blue-50"> + 登录/注册 + + )} +
+ ); +}; diff --git a/apps/web/src/components/common/editor/quill/QuillEditor.tsx b/apps/web/src/components/common/editor/quill/QuillEditor.tsx index 15c94ad..78ca9ff 100644 --- a/apps/web/src/components/common/editor/quill/QuillEditor.tsx +++ b/apps/web/src/components/common/editor/quill/QuillEditor.tsx @@ -1,171 +1,198 @@ -import React, { useEffect, useRef, useState } from 'react'; -import Quill from 'quill'; -import 'quill/dist/quill.snow.css'; // 引入默认样式 -import QuillCharCounter from './QuillCharCounter'; -import { defaultModules } from './constants'; +import React, { useEffect, useRef, useState } from "react"; +import Quill from "quill"; +import "quill/dist/quill.snow.css"; // 引入默认样式 +import QuillCharCounter from "./QuillCharCounter"; +import { defaultModules } from "./constants"; interface QuillEditorProps { - value?: string; - onChange?: (content: string) => void; - placeholder?: string; - readOnly?: boolean; - theme?: 'snow' | 'bubble'; - modules?: any; - className?: string; - style?: React.CSSProperties; - onFocus?: () => void; - onBlur?: () => void; - onKeyDown?: (event: KeyboardEvent) => void; - onKeyUp?: (event: KeyboardEvent) => void; - maxLength?: number; - minLength?: number; - minRows?: number; - maxRows?: number; + value?: string; + onChange?: (content: string) => void; + placeholder?: string; + readOnly?: boolean; + theme?: "snow" | "bubble"; + modules?: any; + className?: string; + style?: React.CSSProperties; + onFocus?: () => void; + onBlur?: () => void; + onKeyDown?: (event: KeyboardEvent) => void; + onKeyUp?: (event: KeyboardEvent) => void; + maxLength?: number; + minLength?: number; + minRows?: number; + maxRows?: number; } const QuillEditor: React.FC = ({ - value = '', - onChange, - placeholder = '请输入内容...', - readOnly = false, - theme = 'snow', - modules = defaultModules, - className = '', - style = {}, - onFocus, - onBlur, - onKeyDown, - onKeyUp, - maxLength, - minLength = 0, - minRows = 1, - maxRows + value = "", + onChange, + placeholder = "请输入内容...", + readOnly = false, + theme = "snow", + modules = defaultModules, + className = "", + style = {}, + onFocus, + onBlur, + onKeyDown, + onKeyUp, + maxLength, + minLength = 0, + minRows = 1, + maxRows, }) => { - const editorRef = useRef(null); - const quillRef = useRef(null); - const isMounted = useRef(false); - const [charCount, setCharCount] = useState(0); // 添加字符计数状态 - const handleTextChange = () => { - if (!quillRef.current) return; - const editor = quillRef.current; - // 获取文本并处理换行符 - const text = editor.getText().replace(/\n$/, ''); - const textLength = text.length; + const editorRef = useRef(null); + const quillRef = useRef(null); + const isMounted = useRef(false); + const [charCount, setCharCount] = useState(0); // 添加字符计数状态 + const handleTextChange = () => { + if (!quillRef.current) return; + const editor = quillRef.current; + // 获取文本并处理换行符 + const text = editor.getText().replace(/\n$/, ""); + const textLength = text.length; - // 处理最大长度限制 - if (maxLength && textLength > maxLength) { - // 暂时移除事件监听器 - editor.off('text-change', handleTextChange); + // 处理最大长度限制 + if (maxLength && textLength > maxLength) { + // 暂时移除事件监听器 + editor.off("text-change", handleTextChange); - // 获取当前选区 - const selection = editor.getSelection(); - const delta = editor.getContents(); - let length = 0; - const newDelta = delta.ops?.reduce((acc: any, op: any) => { - if (typeof op.insert === 'string') { - const remainingLength = maxLength - length; - if (length < maxLength) { - const truncatedText = op.insert.slice(0, remainingLength); - length += truncatedText.length; - acc.push({ ...op, insert: truncatedText }); - } - } else { - acc.push(op); - } - return acc; - }, []); - // 更新内容 - editor.setContents({ ops: newDelta } as any); - // 恢复光标位置 - if (selection) { - editor.setSelection(Math.min(selection.index, maxLength)); - } - // 重新计算截断后的实际长度 - const finalText = editor.getText().replace(/\n$/, ''); - setCharCount(finalText.length); + // 获取当前选区 + const selection = editor.getSelection(); + const delta = editor.getContents(); + let length = 0; + const newDelta = delta.ops?.reduce((acc: any, op: any) => { + if (typeof op.insert === "string") { + const remainingLength = maxLength - length; + if (length < maxLength) { + const truncatedText = op.insert.slice( + 0, + remainingLength + ); + length += truncatedText.length; + acc.push({ ...op, insert: truncatedText }); + } + } else { + acc.push(op); + } + return acc; + }, []); + // 更新内容 + editor.setContents({ ops: newDelta } as any); + // 恢复光标位置 + if (selection) { + editor.setSelection(Math.min(selection.index, maxLength)); + } + // 重新计算截断后的实际长度 + const finalText = editor.getText().replace(/\n$/, ""); + setCharCount(finalText.length); - // 重新绑定事件监听器 - editor.on('text-change', handleTextChange); - } else { - // 如果没有超出最大长度,直接更新字符计数 - setCharCount(textLength); - } + // 重新绑定事件监听器 + editor.on("text-change", handleTextChange); + } else { + // 如果没有超出最大长度,直接更新字符计数 + setCharCount(textLength); + } - onChange?.(quillRef.current.root.innerHTML); - }; + onChange?.(quillRef.current.root.innerHTML); + }; - useEffect(() => { - if (!editorRef.current) return; - if (!isMounted.current) { - // 初始化 Quill 编辑器 - quillRef.current = new Quill(editorRef.current, { - theme, - modules, - placeholder, - readOnly, - }); - // 设置初始内容 - quillRef.current.root.innerHTML = value; - if (onFocus) { - quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => { - if (range) { - onFocus(); - } - }); - } - if (onBlur) { - quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => { - if (!range) { - onBlur(); - } - }); - } - quillRef.current.on(Quill.events.TEXT_CHANGE, handleTextChange); - if (onKeyDown) { - quillRef.current.root.addEventListener('keydown', onKeyDown); - } - if (onKeyUp) { - quillRef.current.root.addEventListener('keyup', onKeyUp); - } - isMounted.current = true; - } - }, [theme, modules, placeholder, readOnly, onFocus, onBlur, onKeyDown, onKeyUp, maxLength, minLength]); // 添加所有相关的依赖 - useEffect(() => { - if (quillRef.current) { - const editor = editorRef.current?.querySelector('.ql-editor') as HTMLElement; - if (editor) { - const lineHeight = parseInt(window.getComputedStyle(editor).lineHeight, 10); - const paddingTop = parseInt(window.getComputedStyle(editor).paddingTop, 10); - const paddingBottom = parseInt(window.getComputedStyle(editor).paddingBottom, 10); - const minHeight = lineHeight * minRows + paddingTop + paddingBottom; - editor.style.minHeight = `${minHeight}px`; - if (maxRows) { - const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom; - editor.style.maxHeight = `${maxHeight}px`; - editor.style.overflowY = 'auto'; - } - } - } - }, [minRows, maxRows, quillRef.current]); + useEffect(() => { + if (!editorRef.current) return; + if (!isMounted.current) { + // 初始化 Quill 编辑器 + quillRef.current = new Quill(editorRef.current, { + theme, + modules, + placeholder, + readOnly, + }); + // 设置初始内容 + quillRef.current.root.innerHTML = value; + if (onFocus) { + quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => { + if (range) { + onFocus(); + } + }); + } + if (onBlur) { + quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => { + if (!range) { + onBlur(); + } + }); + } + quillRef.current.on(Quill.events.TEXT_CHANGE, handleTextChange); + if (onKeyDown) { + quillRef.current.root.addEventListener("keydown", onKeyDown); + } + if (onKeyUp) { + quillRef.current.root.addEventListener("keyup", onKeyUp); + } + isMounted.current = true; + } + }, [ + theme, + modules, + placeholder, + readOnly, + onFocus, + onBlur, + onKeyDown, + onKeyUp, + maxLength, + minLength, + ]); // 添加所有相关的依赖 + useEffect(() => { + if (quillRef.current) { + const editor = editorRef.current?.querySelector( + ".ql-editor" + ) as HTMLElement; + if (editor) { + const lineHeight = parseInt( + window.getComputedStyle(editor).lineHeight, + 10 + ); + const paddingTop = parseInt( + window.getComputedStyle(editor).paddingTop, + 10 + ); + const paddingBottom = parseInt( + window.getComputedStyle(editor).paddingBottom, + 10 + ); + const minHeight = + lineHeight * minRows + paddingTop + paddingBottom; + editor.style.minHeight = `${minHeight}px`; + if (maxRows) { + const maxHeight = + lineHeight * maxRows + paddingTop + paddingBottom; + editor.style.maxHeight = `${maxHeight}px`; + editor.style.overflowY = "auto"; + } + } + } + }, [minRows, maxRows, quillRef.current]); - // 监听 value 属性变化 - useEffect(() => { - if (quillRef.current && value !== quillRef.current.root.innerHTML) { - quillRef.current.root.innerHTML = value; - } - }, [value]); + // 监听 value 属性变化 + useEffect(() => { + if (quillRef.current && value !== quillRef.current.root.innerHTML) { + quillRef.current.root.innerHTML = value; + } + }, [value]); - return ( -
-
- {(maxLength || minLength > 0) && ( - - )} -
- ); + return ( +
+
+ {(maxLength || minLength > 0) && ( + + )} +
+ ); }; -export default QuillEditor; \ No newline at end of file +export default QuillEditor; diff --git a/apps/web/src/components/common/element/Avatar.tsx b/apps/web/src/components/common/element/Avatar.tsx new file mode 100644 index 0000000..0693169 --- /dev/null +++ b/apps/web/src/components/common/element/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/components/common/form/FormArrayField.tsx b/apps/web/src/components/common/form/FormArrayField.tsx index 41d2a0d..76835e5 100644 --- a/apps/web/src/components/common/form/FormArrayField.tsx +++ b/apps/web/src/components/common/form/FormArrayField.tsx @@ -1,21 +1,20 @@ -import { useFormContext } from "react-hook-form"; -import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; import { Reorder } from "framer-motion"; -import React, { useEffect, useState } from "react"; -import FormError from "./FormError"; +import React, { useState } from "react"; +import { Input, Button, Form, Typography } from "antd"; +import type { InputProps } from "antd"; import { UUIDGenerator } from "@nice/common"; + interface ArrayFieldProps { name: string; label: string; placeholder?: string; addButtonText?: string; - inputProps?: React.InputHTMLAttributes; + inputProps?: Partial; } + 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, @@ -23,128 +22,123 @@ export function FormArrayField({ addButtonText = "添加项目", inputProps = {}, }: ArrayFieldProps) { - const { - register, - watch, - setValue, - formState: { errors }, - trigger, - } = useFormContext(); + const [items, setItems] = useState([]); - // 添加 watch 监听 - const watchedValues = watch(name); - const [items, setItems] = useState( - () => - (watchedValues as string[])?.map((value) => ({ - id: UUIDGenerator.generate(), - value, - })) || [] - ); - // 使用 useEffect 监听表单值变化 - useEffect(() => { - if (watchedValues) { - const newItems = watchedValues.map( - (value: string, index: number) => { - // 尽量保持现有的 id - return { - id: items[index]?.id || UUIDGenerator.generate(), - value, - }; - } - ); - setItems(newItems); - } - }, [watchedValues]); - const error = errors[name]?.message as string; const updateItems = (newItems: ItemType[]) => { setItems(newItems); - setValue( - name, - newItems.map((item) => item.value), - { shouldDirty: true } - ); }; + return ( -
- -
- - {items.map((item, index) => ( - -
-
- { - const newItems = items.map((i) => - i.id === item.id - ? { - ...i, + + {(fields, { add, remove }, { errors }) => ( + +
+ + {fields.map((field, index) => ( + +
+ + + // {inputProps.maxLength - + // ( + // Form.useWatch( + // [ + // name, + // field.name, + // ] + // ) || "" + // ).length} + // + // ) + // } + onChange={(e) => { + // 更新 items 状态 + const newItems = [...items]; + if (!newItems[index]) { + newItems[index] = { + id: field.key.toString(), value: e.target .value, - } - : i - ); - updateItems(newItems); - }} - onBlur={() => { - trigger(name); - }} - placeholder={placeholder} - className={inputStyles} - /> - {inputProps.maxLength && ( - - {inputProps.maxLength - - (item.value?.length || 0)} - - )} -
- -
- - ))} - + }; + } else { + newItems[index].value = + e.target.value; + } + setItems(newItems); + }} + /> +
+
+ + ))} + - -
- -
+ + + +
+ + )} + ); } diff --git a/apps/web/src/components/common/uploader/AvatarUploader.tsx b/apps/web/src/components/common/uploader/AvatarUploader.tsx new file mode 100644 index 0000000..d4077f3 --- /dev/null +++ b/apps/web/src/components/common/uploader/AvatarUploader.tsx @@ -0,0 +1,156 @@ +import { env } from "@web/src/env"; +import { message, Progress, Spin, theme } from "antd"; +import React, { useState, useEffect, useRef } from "react"; +import { useTusUpload } from "@web/src/hooks/useTusUpload"; +import { Avatar } from "antd/lib"; +import toast from "react-hot-toast"; + +export interface AvatarUploaderProps { + value?: string; + placeholder?: string; + className?: string; + onChange?: (value: string) => void; + compressed?: boolean; + style?: React.CSSProperties; // 添加style属性 +} + +interface UploadingFile { + name: string; + progress: number; + status: "uploading" | "done" | "error"; + fileId?: string; + url?: string; + compressedUrl?: string; + fileKey?: string; +} + +const AvatarUploader: React.FC = ({ + value, + onChange, + compressed = false, + className, + placeholder = "点击上传", + style, // 解构style属性 +}) => { + const { handleFileUpload, uploadProgress } = useTusUpload(); + const [file, setFile] = useState(null); + const avatarRef = useRef(null); + const [previewUrl, setPreviewUrl] = useState(value || ""); + + const [compressedUrl, setCompressedUrl] = useState(value || ""); + const [url, setUrl] = useState(value || ""); + const [uploading, setUploading] = useState(false); + const inputRef = useRef(null); + // 在组件中定义 key 状态 + const [avatarKey, setAvatarKey] = useState(0); + const { token } = theme.useToken(); + + const handleChange = async (event: React.ChangeEvent) => { + const selectedFile = event.target.files?.[0]; + if (!selectedFile) return; + // Create an object URL for the selected file + const objectUrl = URL.createObjectURL(selectedFile); + setPreviewUrl(objectUrl); + setFile({ + name: selectedFile.name, + progress: 0, + status: "uploading", + fileKey: `${selectedFile.name}-${Date.now()}`, + }); + setUploading(true); + + try { + const uploadedUrl = await new Promise((resolve, reject) => { + handleFileUpload( + selectedFile, + (result) => { + setFile((prev) => ({ + ...prev!, + progress: 100, + status: "done", + fileId: result.fileId, + url: result.url, + compressedUrl: result.compressedUrl, + })); + + setUrl(result.url); + setCompressedUrl(result.compressedUrl); + // 直接使用 result 中的最新值 + resolve(compressed ? result.compressedUrl : result.url); + }, + (error) => { + reject(error); + }, + file?.fileKey + ); + }); + // await new Promise((resolve) => setTimeout(resolve,4999)); // 方法1:使用 await 暂停执行 + // 使用 resolved 的最新值调用 onChange + // 强制刷新 Avatar 组件 + setAvatarKey((prev) => prev + 1); // 修改 key 强制重新挂载 + onChange?.(uploadedUrl); + console.log(uploadedUrl); + toast.success("头像上传成功"); + } catch (error) { + console.error("上传错误:", error); + toast.error("头像上传失败"); + setFile((prev) => ({ ...prev!, status: "error" })); + } finally { + setUploading(false); + } + }; + + const triggerUpload = () => { + inputRef.current?.click(); + }; + + return ( +
+ + {previewUrl ? ( + + ) : ( +
+ {placeholder} +
+ )} + {uploading && ( +
+ +
+ )} + {file && file.status === "uploading" && ( +
+ +
+ )} +
+ ); +}; + +export default AvatarUploader; diff --git a/apps/web/src/components/common/uploader/FileUploader.tsx b/apps/web/src/components/common/uploader/FileUploader.tsx deleted file mode 100644 index 3635063..0000000 --- a/apps/web/src/components/common/uploader/FileUploader.tsx +++ /dev/null @@ -1,237 +0,0 @@ -// FileUploader.tsx -import React, { useRef, memo, useState } from "react"; -import { - CloudArrowUpIcon, - XMarkIcon, - DocumentIcon, - ExclamationCircleIcon, - CheckCircleIcon, -} from "@heroicons/react/24/outline"; -import { motion, AnimatePresence } from "framer-motion"; -import { toast } from "react-hot-toast"; -import { useTusUpload } from "@web/src/hooks/useTusUpload"; - -interface FileUploaderProps { - endpoint?: string; - onSuccess?: (url: string) => void; - onError?: (error: Error) => void; - maxSize?: number; - allowedTypes?: string[]; - placeholder?: string; -} - -interface FileItemProps { - file: File; - progress?: number; - onRemove: (name: string) => void; - isUploaded: boolean; -} - -const FileItem: React.FC = memo( - ({ file, progress, onRemove, isUploaded }) => ( - - -
-
-

- {file.name} -

- -
- {!isUploaded && progress !== undefined && ( -
-
- -
- - {progress}% - -
- )} - {isUploaded && ( -
- - 上传完成 -
- )} -
-
- ) -); - -const FileUploader: React.FC = ({ - endpoint = "", - onSuccess, - onError, - maxSize = 100, - placeholder = "点击或拖拽文件到这里上传", - allowedTypes = ["*/*"], -}) => { - const [isDragging, setIsDragging] = useState(false); - const [files, setFiles] = useState< - Array<{ file: File; isUploaded: boolean }> - >([]); - const fileInputRef = useRef(null); - - const { progress, isUploading, uploadError, handleFileUpload } = - useTusUpload(); - - const handleError = (error: Error) => { - toast.error(error.message); - onError?.(error); - }; - - const handleDrag = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.type === "dragenter" || e.type === "dragover") { - setIsDragging(true); - } else if (e.type === "dragleave") { - setIsDragging(false); - } - }; - - const validateFile = (file: File) => { - if (file.size > maxSize * 1024 * 1024) { - throw new Error(`文件大小不能超过 ${maxSize}MB`); - } - if ( - !allowedTypes.includes("*/*") && - !allowedTypes.includes(file.type) - ) { - throw new Error( - `不支持的文件类型。支持的类型: ${allowedTypes.join(", ")}` - ); - } - }; - - const uploadFile = (file: File) => { - try { - validateFile(file); - handleFileUpload( - file, - (upload) => { - onSuccess?.(upload.url || ""); - setFiles((prev) => - prev.map((f) => - f.file.name === file.name - ? { ...f, isUploaded: true } - : f - ) - ); - }, - handleError - ); - } catch (error) { - handleError(error as Error); - } - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - - const droppedFiles = Array.from(e.dataTransfer.files); - setFiles((prev) => [ - ...prev, - ...droppedFiles.map((file) => ({ file, isUploaded: false })), - ]); - droppedFiles.forEach(uploadFile); - }; - - const handleFileSelect = (e: React.ChangeEvent) => { - if (e.target.files) { - const selectedFiles = Array.from(e.target.files); - setFiles((prev) => [ - ...prev, - ...selectedFiles.map((file) => ({ file, isUploaded: false })), - ]); - selectedFiles.forEach(uploadFile); - } - }; - - const removeFile = (fileName: string) => { - setFiles((prev) => prev.filter(({ file }) => file.name !== fileName)); - }; - - const handleClick = () => { - fileInputRef.current?.click(); - }; - - return ( -
-
- - -

{placeholder}

- {isDragging && ( -
-

- 释放文件以上传 -

-
- )} -
- - -
- {files.map(({ file, isUploaded }) => ( - - ))} -
-
- - {uploadError && ( -
- - {uploadError} -
- )} -
- ); -}; - -export default FileUploader; diff --git a/apps/web/src/components/common/uploader/TusUploader.tsx b/apps/web/src/components/common/uploader/TusUploader.tsx new file mode 100644 index 0000000..27b120c --- /dev/null +++ b/apps/web/src/components/common/uploader/TusUploader.tsx @@ -0,0 +1,234 @@ +import { useCallback, useState } from "react"; +import { + UploadOutlined, + CheckCircleOutlined, + DeleteOutlined, +} from "@ant-design/icons"; +import { Upload, Progress, Button } from "antd"; +import type { UploadFile } from "antd"; +import { useTusUpload } from "@web/src/hooks/useTusUpload"; +import toast from "react-hot-toast"; +import { getCompressedImageUrl } from "@nice/utils"; +import { api } from "@nice/client"; + +export interface TusUploaderProps { + value?: string[]; + onChange?: (value: string[]) => void; + multiple?: boolean; +} + +interface UploadingFile { + name: string; + progress: number; + status: "uploading" | "done" | "error"; + fileId?: string; + fileKey?: string; +} + +export const TusUploader = ({ + value = [], + onChange, + multiple = true, +}: TusUploaderProps) => { + const { data: files } = api.resource.findMany.useQuery({ + where: { + fileId: { in: value }, + }, + select: { + id: true, + fileId: true, + title: true, + }, + }); + const { handleFileUpload, uploadProgress } = useTusUpload(); + const [uploadingFiles, setUploadingFiles] = useState([]); + const [completedFiles, setCompletedFiles] = useState( + () => + value?.map((fileId) => ({ + name: `文件 ${fileId}`, + progress: 100, + status: "done" as const, + fileId, + })) || [] + ); + const [uploadResults, setUploadResults] = useState(value || []); + + const handleRemoveFile = useCallback( + (fileId: string) => { + setCompletedFiles((prev) => + prev.filter((f) => f.fileId !== fileId) + ); + setUploadResults((prev) => { + const newValue = prev.filter((id) => id !== fileId); + onChange?.(newValue); + return newValue; + }); + }, + [onChange] + ); + + // 新增:处理删除上传中的失败文件 + const handleRemoveUploadingFile = useCallback((fileKey: string) => { + setUploadingFiles((prev) => prev.filter((f) => f.fileKey !== fileKey)); + }, []); + + const handleBeforeUpload = useCallback( + (file: File) => { + const fileKey = `${file.name}-${Date.now()}`; + + setUploadingFiles((prev) => [ + ...prev, + { + name: file.name, + progress: 0, + status: "uploading", + fileKey, + }, + ]); + + handleFileUpload( + file, + (result) => { + setCompletedFiles((prev) => [ + ...prev, + { + name: file.name, + progress: 100, + status: "done", + fileId: result.fileId, + }, + ]); + + setUploadingFiles((prev) => + prev.filter((f) => f.fileKey !== fileKey) + ); + + setUploadResults((prev) => { + // 如果是单文件模式,则替换现有文件 + const newValue = multiple + ? [...prev, result.fileId] + : [result.fileId]; + onChange?.(newValue); + return newValue; + }); + + // 单文件模式下,清除之前的完成文件 + if (!multiple) { + setCompletedFiles([ + { + name: file.name, + progress: 100, + status: "done", + fileId: result.fileId, + }, + ]); + } + }, + (error) => { + console.error("上传错误:", error); + toast.error( + `上传失败: ${error instanceof Error ? error.message : "未知错误"}` + ); + setUploadingFiles((prev) => + prev.map((f) => + f.fileKey === fileKey + ? { ...f, status: "error" } + : f + ) + ); + }, + fileKey + ); + + return false; + }, + [handleFileUpload, onChange] + ); + + return ( +
+ +

+ +

+

+ 点击或拖拽文件到此区域进行上传 +

+

+ {multiple ? "支持单个或批量上传文件" : "仅支持上传单个文件"} +

+ +
+ {uploadingFiles.map((file) => ( +
+
+ {file.name} +
+
+ + {file.status === "error" && ( +
+
+ ))} + + {completedFiles.map((file) => ( +
+
+ + {file.name} +
+
+ ))} +
+
+
+ ); +}; diff --git a/apps/web/src/components/layout/admin/AdminHeader.tsx b/apps/web/src/components/layout/admin/AdminHeader.tsx new file mode 100644 index 0000000..7be4a0c --- /dev/null +++ b/apps/web/src/components/layout/admin/AdminHeader.tsx @@ -0,0 +1,198 @@ +import { useAuth } from "@web/src/providers/auth-provider"; +import { Avatar, Tag, theme, Tooltip } from "antd"; +import React, { + ReactNode, + useEffect, + useState, + useRef, + CSSProperties, +} from "react"; +import { SyncOutlined } from "@ant-design/icons"; +import * as Y from "yjs"; +import { stringToColor, YWsProvider } from "@nice/common"; +import { lightenColor } from "@nice/client"; +import { useLocalSettings } from "@web/src/hooks/useLocalSetting"; +import Breadcrumb from "../element/breadcrumb"; + +interface AdminHeaderProps { + children?: ReactNode; + roomId?: string; + awarePlaceholder?: string; + borderless?: boolean; + style?: CSSProperties; + className?: string; +} + +const AdminHeader: React.FC = ({ + className, + style, + borderless = false, + children, + roomId, + awarePlaceholder = "协作人员", +}) => { + const { user, sessionId, accessToken } = useAuth(); + const [userStates, setUserStates] = useState>(new Map()); + const { token } = theme.useToken(); + const providerRef = useRef(null); + const { websocketUrl } = useLocalSettings(); + + useEffect(() => { + let cleanup: (() => void) | undefined; + // 如果已经连接或缺少必要参数,则返回 + if (!user || !roomId || !websocketUrl) { + return; + } + // 设置延时,避免立即连接 + const connectTimeout = setTimeout(() => { + try { + const ydoc = new Y.Doc(); + const provider = new YWsProvider( + websocketUrl + "/yjs", + roomId, + ydoc, + { + params: { + userId: user?.id, + sessionId, + }, + } + ); + providerRef.current = provider; + const { awareness } = provider; + const updateAwarenessData = () => { + const uniqueStates = new Map(); + awareness.getStates().forEach((value, key) => { + const sessionId = value?.user?.sessionId; + if (sessionId) { + uniqueStates.set(sessionId, value); + } + }); + setUserStates(uniqueStates); + }; + + const localState = { + user: { + id: user.id, + showname: user?.showname || user.username, + deptName: user.department?.name, + sessionId, + }, + }; + + awareness.setLocalStateField("user", localState.user); + awareness.on("change", updateAwarenessData); + updateAwarenessData(); + + const handleBeforeUnload = () => { + awareness.setLocalState(null); + provider.disconnect(); + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + + // 定义清理函数 + cleanup = () => { + if (providerRef.current) { + awareness.off("change", updateAwarenessData); + awareness.setLocalState(null); + provider.disconnect(); + providerRef.current = null; + } + + setUserStates(new Map()); + window.removeEventListener( + "beforeunload", + handleBeforeUnload + ); + }; + } catch (error) { + console.error("WebSocket connection error:", error); + } + }, 100); + + // 返回清理函数 + return () => { + clearTimeout(connectTimeout); + if (cleanup) { + cleanup(); + } + }; + }, [roomId, user, websocketUrl, sessionId]); + + // 其余渲染代码保持不变... + const renderAvatars = () => + Array.from(userStates.entries()).map(([key, value]) => ( + + {value?.user.deptName && ( + + {value?.user?.deptName} + + )} + + {value?.user?.showname || "匿名用户"} + + + } + key={key}> + + {!value?.user?.avatarUrl && + (value?.user?.showname?.toUpperCase() || "匿名用户")} + + + )); + + return ( +
+
+ +
+ {roomId && ( + } + color={token.colorPrimaryHover}> + {awarePlaceholder} + + )} + + {renderAvatars()} + +
+
+ {children} +
+ ); +}; + +export default AdminHeader; diff --git a/apps/web/src/components/layout/admin/AdminLayout.tsx b/apps/web/src/components/layout/admin/AdminLayout.tsx new file mode 100644 index 0000000..ed2cd5a --- /dev/null +++ b/apps/web/src/components/layout/admin/AdminLayout.tsx @@ -0,0 +1,20 @@ +import { Outlet } from "react-router-dom"; +import { Layout } from "antd"; + +import { adminRoute } from "@web/src/routes/admin-route"; +import AdminSidebar from "./AdminSidebar"; + +const { Content } = Layout; + +export default function AdminLayout() { + return ( + + + + + + + + + ); +} diff --git a/apps/web/src/components/layout/admin/AdminSidebar.tsx b/apps/web/src/components/layout/admin/AdminSidebar.tsx new file mode 100644 index 0000000..005bac4 --- /dev/null +++ b/apps/web/src/components/layout/admin/AdminSidebar.tsx @@ -0,0 +1,58 @@ +import { useState, useMemo } from "react"; +import { NavLink, matchPath, useLocation, useMatches } from "react-router-dom"; +import { Layout, Menu, theme } from "antd"; +import type { MenuProps } from "antd"; +import { CustomRouteObject } from "@web/src/routes/types"; + +const { Sider } = Layout; +const { useToken } = theme; + +type SidebarProps = { + routes: CustomRouteObject[]; +}; + +export default function AdminSidebar({ routes }: SidebarProps) { + const [collapsed, setCollapsed] = useState(false); + const { token } = useToken(); + const matches = useMatches(); + console.log(matches); + const menuItems: MenuProps["items"] = useMemo( + () => + routes.map((route) => ({ + key: route.path, + icon: route.icon, + label: {route.name}, + })), + [routes] + ); + + return ( + setCollapsed(value)} + width={150} + className="h-screen sticky top-0" + style={{ + backgroundColor: token.colorBgContainer, + borderRight: `1px solid ${token.colorBorderSecondary}`, + }}> + + matches.some((match) => + match.pathname.includes(route.path) + ) + ) + .map((route) => route.path)} + items={menuItems} + className="border-r-0" + style={{ + borderRight: 0, + }} + /> + + ); +} diff --git a/apps/web/src/components/layout/element/breadcrumb.tsx b/apps/web/src/components/layout/element/breadcrumb.tsx new file mode 100644 index 0000000..c6908da --- /dev/null +++ b/apps/web/src/components/layout/element/breadcrumb.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { useLocation, Link, useMatches } from 'react-router-dom'; +import { theme } from 'antd'; +import { RightOutlined } from '@ant-design/icons'; + +export default function Breadcrumb() { + let matches = useMatches(); + const { token } = theme.useToken() + let crumbs = matches + // first get rid of any matches that don't have handle and crumb + .filter((match) => Boolean((match.handle as any)?.crumb)) + // now map them into an array of elements, passing the loader + // data to each one + .map((match) => (match.handle as any).crumb(match.data)); + + return ( +
    + {crumbs.map((crumb, index) => ( + +
  1. + {crumb} +
  2. + {index < crumbs.length - 1 && ( +
  3. + +
  4. + )} +
    + ))} +
+ ); +} diff --git a/apps/web/src/components/layout/element/types.ts b/apps/web/src/components/layout/element/types.ts new file mode 100644 index 0000000..03d24cb --- /dev/null +++ b/apps/web/src/components/layout/element/types.ts @@ -0,0 +1,5 @@ +export interface MenuItemType { + icon: JSX.Element; + label: string; + action: () => void; +} diff --git a/apps/web/src/components/layout/element/usermenu/user-edit-modal.tsx b/apps/web/src/components/layout/element/usermenu/user-edit-modal.tsx new file mode 100644 index 0000000..30214d8 --- /dev/null +++ b/apps/web/src/components/layout/element/usermenu/user-edit-modal.tsx @@ -0,0 +1,26 @@ +import { Button, Drawer, Modal } from "antd"; +import React, { useContext, useEffect, useState } from "react"; + +import { UserEditorContext } from "./usermenu"; +import UserForm from "./user-form"; + +export default function UserEditModal() { + const { formLoading, modalOpen, setModalOpen, form } = + useContext(UserEditorContext); + const handleOk = () => { + form.submit(); + }; + return ( + { + setModalOpen(false); + }} + title={"编辑个人信息"}> + + + ); +} diff --git a/apps/web/src/components/layout/element/usermenu/user-form.tsx b/apps/web/src/components/layout/element/usermenu/user-form.tsx new file mode 100644 index 0000000..6774028 --- /dev/null +++ b/apps/web/src/components/layout/element/usermenu/user-form.tsx @@ -0,0 +1,216 @@ +import { Button, Form, Input, Spin, Switch, message } from "antd"; +import { useContext, useEffect } from "react"; +import { useStaff } from "@nice/client"; +import DepartmentSelect from "@web/src/components/models/department/department-select"; +import { api } from "@nice/client"; + +import { useAuth } from "@web/src/providers/auth-provider"; +import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader"; +import { StaffDto } from "@nice/common"; +import { UserEditorContext } from "./usermenu"; +import toast from "react-hot-toast"; +export default function StaffForm() { + const { user } = useAuth(); + const { create, update } = useStaff(); // Ensure you have these methods in your hooks + const { + formLoading, + modalOpen, + setModalOpen, + domainId, + setDomainId, + form, + setFormLoading, + } = useContext(UserEditorContext); + const { + data, + isLoading, + }: { + data: StaffDto; + isLoading: boolean; + } = api.staff.findFirst.useQuery( + { where: { id: user?.id } }, + { enabled: !!user?.id } + ); + const { isRoot } = useAuth(); + async function handleFinish(values: any) { + const { + username, + showname, + deptId, + domainId, + password, + phoneNumber, + officerId, + avatar, + enabled, + } = values; + setFormLoading(true); + try { + if (data && user?.id) { + await update.mutateAsync({ + where: { id: data.id }, + data: { + username, + deptId, + avatar, + officerId, + showname, + domainId, + password, + phoneNumber, + enabled, + }, + }); + } + toast.success("提交成功"); + setModalOpen(false); + } catch (err: any) { + toast.error(err.message); + } finally { + setFormLoading(false); + } + } + useEffect(() => { + form.resetFields(); + console.log(data?.avatar); + if (data) { + form.setFieldValue("username", data.username); + form.setFieldValue("showname", data.showname); + form.setFieldValue("domainId", data.domainId); + form.setFieldValue("deptId", data.deptId); + form.setFieldValue("avatar", data.avatar); + form.setFieldValue("phoneNumber", data.phoneNumber); + form.setFieldValue("officerId", data.officerId); + form.setFieldValue("enabled", data.enabled); + } + }, [data]); + // useEffect(() => { + // if (!data && domainId) { + // form.setFieldValue("domainId", domainId); + // form.setFieldValue("deptId", domainId); + // } + // }, [domainId, data as any]); + return ( +
+ {isLoading && ( +
+ +
+ )} +
+
+
+ + + +
+ +
+ + + + + + + + { + setDomainId(value as string); + }} + domain={true} + /> + + + + +
+
+
+ + + + + + + + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/layout/element/usermenu/usermenu.tsx b/apps/web/src/components/layout/element/usermenu/usermenu.tsx new file mode 100644 index 0000000..e00965b --- /dev/null +++ b/apps/web/src/components/layout/element/usermenu/usermenu.tsx @@ -0,0 +1,259 @@ +import { useClickOutside } from "@web/src/hooks/useClickOutside"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { motion, AnimatePresence } from "framer-motion"; +import React, { + useState, + useRef, + useCallback, + useMemo, + createContext, +} from "react"; +import { Avatar } from "../../../common/element/Avatar"; +import { + UserOutlined, + SettingOutlined, + LogoutOutlined, +} from "@ant-design/icons"; +import { FormInstance, Spin } from "antd"; +import { useNavigate } from "react-router-dom"; +import { MenuItemType } from "../types"; +import { RolePerms } from "@nice/common"; +import { useForm } from "antd/es/form/Form"; +import UserEditModal from "./user-edit-modal"; +const menuVariants = { + hidden: { opacity: 0, scale: 0.95, y: -10 }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { + type: "spring", + stiffness: 300, + damping: 30, + }, + }, + exit: { + opacity: 0, + scale: 0.95, + y: -10, + transition: { + duration: 0.2, + }, + }, +}; + +export const UserEditorContext = createContext<{ + domainId: string; + setDomainId: React.Dispatch>; + modalOpen: boolean; + setModalOpen: React.Dispatch>; + form: FormInstance; + formLoading: boolean; + setFormLoading: React.Dispatch>; +}>({ + modalOpen: false, + domainId: undefined, + setDomainId: undefined, + setModalOpen: undefined, + form: undefined, + formLoading: undefined, + setFormLoading: undefined, +}); + +export function UserMenu() { + const [form] = useForm(); + const [formLoading, setFormLoading] = useState(); + const [showMenu, setShowMenu] = useState(false); + const menuRef = useRef(null); + const { user, logout, isLoading, hasSomePermissions } = useAuth(); + const navigate = useNavigate(); + useClickOutside(menuRef, () => setShowMenu(false)); + const [modalOpen, setModalOpen] = useState(false); + const [domainId, setDomainId] = useState(); + const toggleMenu = useCallback(() => { + setShowMenu((prev) => !prev); + }, []); + const canManageAnyStaff = useMemo(() => { + return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF); + }, [user]); + const menuItems: MenuItemType[] = useMemo( + () => + [ + { + icon: , + label: "个人信息", + action: () => { + setModalOpen(true); + }, + }, + canManageAnyStaff && { + icon: , + label: "设置", + action: () => { + navigate("/admin/staff"); + }, + }, + // { + // icon: , + // label: '帮助', + // action: () => { }, + // }, + { + icon: , + label: "注销", + action: () => logout(), + }, + ].filter(Boolean), + [logout] + ); + + const handleMenuItemClick = useCallback((action: () => void) => { + action(); + setShowMenu(false); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + +
+ + {/* Avatar 容器,相对定位 */} +
+ + {/* 小绿点 */} +
+ + {/* 用户信息,显示在 Avatar 右侧 */} +
+ + {user?.showname || user?.username} + + + {user?.department?.name} + +
+
+ + + {showMenu && ( + + {/* User Profile Section */} +
+
+ +
+ + {user?.showname || user?.username} + + + + 在线 + +
+
+
+ + {/* Menu Items */} +
+ {menuItems.map((item, index) => ( + + ))} +
+
+ )} +
+
+ +
+ ); +} diff --git a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx index 72507b5..3458a1f 100644 --- a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx @@ -1,7 +1,7 @@ -import { api, useCourse } from "@nice/client"; +import { api,} from "@nice/client"; import { courseDetailSelect, CourseDto } from "@nice/common"; import React, { createContext, ReactNode, useState } from "react"; -import { string } from "zod"; + interface CourseDetailContextType { editId?: string; // 添加 editId diff --git a/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx b/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx index 55c8104..26c9e09 100644 --- a/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx +++ b/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx @@ -1,97 +1,136 @@ import { createContext, useContext, ReactNode, useEffect } from "react"; -import { useForm, FormProvider, SubmitHandler } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { CourseDto, CourseLevel, CourseStatus } from "@nice/common"; -import { api, useCourse } from "@nice/client"; -import toast from "react-hot-toast"; +import { Form, FormInstance, message } from "antd"; +import { + CourseDto, + CourseStatus, + ObjectType, + PostType, + Taxonomy, +} from "@nice/common"; +import { api, usePost } from "@nice/client"; import { useNavigate } from "react-router-dom"; -// 定义课程表单验证 Schema -const courseSchema = z.object({ - title: z.string().min(1, '课程标题不能为空'), - subTitle: z.string().nullish(), - description: z.string().nullish(), - thumbnail: z.string().url().nullish(), - level: z.nativeEnum(CourseLevel), - requirements: z.array(z.string()).nullish(), - objectives: z.array(z.string()).nullish() -}); -export type CourseFormData = z.infer; +import { z } from "zod"; + +export type CourseFormData = { + title: string; + subTitle?: string; + content?: string; + thumbnail?: string; + requirements?: string[]; + objectives?: string[]; + sections: any; +}; + interface CourseEditorContextType { - onSubmit: SubmitHandler; - editId?: string; // 添加 editId - part?: string; + onSubmit: (values: CourseFormData) => Promise; + editId?: string; course?: CourseDto; + taxonomies?: Taxonomy[]; // 根据实际类型调整 + form: FormInstance; // 添加 form 到上下文 } + interface CourseFormProviderProps { children: ReactNode; - editId?: string; // 添加 editId 参数 - part?: string; + editId?: string; } + const CourseEditorContext = createContext(null); -export function CourseFormProvider({ children, editId }: CourseFormProviderProps) { - const { create, update } = useCourse() - const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery({ where: { id: editId } }, { enabled: Boolean(editId) }) - const navigate = useNavigate() - const methods = useForm({ - resolver: zodResolver(courseSchema), - defaultValues: { - level: CourseLevel.BEGINNER, - requirements: [], - objectives: [] - }, - }); - useEffect(() => { - if (course) { - const formData = { - title: course.title, - subTitle: course.subTitle, - description: course.description, - thumbnail: course.thumbnail, - level: course.level, - requirements: course.requirements, - objectives: course.objectives, - status: course.status, - }; - methods.reset(formData as any); - } - }, [course, methods]); - const onSubmit: SubmitHandler = async (data: CourseFormData) => { - try { - if (editId) { - await update.mutateAsync({ - where: { id: editId }, - data: { - ...data - } - }) - toast.success('课程更新成功!'); - } else { - const result = await create.mutateAsync({ - data: { - status: CourseStatus.DRAFT, - ...data - } - }) - navigate(`/course/${result.id}/editor`, { replace: true }) - toast.success('课程创建成功!'); - } - methods.reset(data); +export function CourseFormProvider({ + children, + editId, +}: CourseFormProviderProps) { + const [form] = Form.useForm(); + const { create, update, createCourse } = usePost(); + const { data: course }: { data: CourseDto } = api.post.findFirst.useQuery( + { where: { id: editId } }, + { enabled: Boolean(editId) } + ); + const { + data: taxonomies, + }: { + data: Taxonomy[]; + } = api.taxonomy.getAll.useQuery({ + // type: ObjectType.COURSE, + }); + const navigate = useNavigate(); + useEffect(() => { + if (course) { + const formData = { + title: course.title, + subTitle: course.subTitle, + content: course.content, + thumbnail: course?.meta?.thumbnail, + requirements: course?.meta?.requirements, + objectives: course?.meta?.objectives, + }; + form.setFieldsValue(formData); + } + }, [course, form]); - } catch (error) { - console.error('Error submitting form:', error); - toast.error('操作失败,请重试!'); - } - }; - return ( - - - {children} - - - ); + const onSubmit = async (values: CourseFormData) => { + console.log(values); + const sections = values?.sections || []; + const formattedValues = { + ...values, + meta: { + requirements: values.requirements, + objectives: values.objectives, + }, + }; + delete formattedValues.requirements; + delete formattedValues.objectives; + delete formattedValues.sections; + try { + if (editId) { + await update.mutateAsync({ + where: { id: editId }, + data: formattedValues, + }); + message.success("课程更新成功!"); + } else { + const result = await createCourse.mutateAsync({ + courseDetail: { + data: { + title: formattedValues.title || "12345", + state: CourseStatus.DRAFT, + type: PostType.COURSE, + ...formattedValues, + }, + }, + sections, + }); + navigate(`/course/${result.id}/editor`, { replace: true }); + message.success("课程创建成功!"); + } + form.resetFields(); + } catch (error) { + console.error("Error submitting form:", error); + message.error("操作失败,请重试!"); + } + }; + + return ( + +
+ {children} +
+
+ ); } export const useCourseEditor = () => { diff --git a/apps/web/src/components/models/course/editor/form/CourseBasicForm.tsx b/apps/web/src/components/models/course/editor/form/CourseBasicForm.tsx index dd83771..754004d 100644 --- a/apps/web/src/components/models/course/editor/form/CourseBasicForm.tsx +++ b/apps/web/src/components/models/course/editor/form/CourseBasicForm.tsx @@ -1,24 +1,63 @@ -import { SubmitHandler, useFormContext } from 'react-hook-form'; -import { CourseLevel, CourseLevelLabel } from '@nice/common'; -import { FormInput } from '@web/src/components/common/form/FormInput'; -import { FormSelect } from '@web/src/components/common/form/FormSelect'; -import { FormArrayField } from '@web/src/components/common/form/FormArrayField'; -import { convertToOptions } from '@nice/client'; -import { CourseFormData } from '../context/CourseEditorContext'; -import QuillEditor from '@web/src/components/common/editor/quill/QuillEditor'; -import { FormQuillInput } from '@web/src/components/common/form/FormQuillInput'; +import { Form, Input, Select } from "antd"; +import { CourseLevel, CourseLevelLabel } from "@nice/common"; +import { convertToOptions } from "@nice/client"; +import TermSelect from "../../../term/term-select"; +import { useCourseEditor } from "../context/CourseEditorContext"; + +const { TextArea } = Input; export function CourseBasicForm() { - const { register, formState: { errors }, watch, handleSubmit } = useFormContext(); - return ( -
- - - + // 将 CourseLevelLabel 转换为 Ant Design Select 需要的选项格式 + const levelOptions = Object.entries(CourseLevelLabel).map( + ([key, value]) => ({ + label: value, + value: key as CourseLevel, + }) + ); + const { form, taxonomies } = useCourseEditor(); + return ( +
+ + + - + + + - - ); + +