From 5630af88bab8bc0c610cab820ec56c63ea7e4df9 Mon Sep 17 00:00:00 2001 From: longdayi <13477510+longdayilongdayi@user.noreply.gitee.com> Date: Tue, 10 Sep 2024 10:31:24 +0800 Subject: [PATCH] 09101031 --- apps/server/src/app.module.ts | 3 +- apps/server/src/auth/auth.controller.ts | 5 +- apps/server/src/auth/auth.guard.ts | 2 + apps/server/src/auth/auth.module.ts | 5 +- apps/server/src/auth/auth.service.ts | 32 +- apps/server/src/env.ts | 5 +- apps/server/src/init/init.module.ts | 2 + apps/server/src/init/init.service.ts | 18 +- apps/server/src/main.ts | 4 +- .../models/department/department.router.ts | 1 - .../models/department/department.service.ts | 1 - .../src/models/taxonomy/taxonomy.module.ts | 12 + .../src/models/taxonomy/taxonomy.router.ts | 42 ++ .../src/models/taxonomy/taxonomy.service.ts | 176 +++++++ apps/server/src/models/term/term.module.ts | 15 + apps/server/src/models/term/term.router.ts | 55 ++ apps/server/src/models/term/term.service.ts | 367 +++++++++++++ apps/server/src/rbac/rbac.module.ts | 8 +- apps/server/src/rbac/rolemap.router.ts | 10 +- apps/server/src/rbac/roleperms.service.ts | 189 +++++++ apps/server/src/relation/relation.service.ts | 85 +++ apps/server/src/transform/transform.module.ts | 5 + apps/server/src/transform/transform.router.ts | 35 +- .../server/src/transform/transform.service.ts | 496 +++++++++++++++++- apps/server/src/trpc/trpc.module.ts | 5 +- apps/server/src/trpc/trpc.router.ts | 27 +- apps/web/nginx.conf | 10 + apps/web/package.json | 8 + apps/web/src/App.css | 42 -- apps/web/src/App.tsx | 5 +- apps/web/src/app/admin/department/page.tsx | 7 + apps/web/src/app/admin/role/page.tsx | 30 ++ apps/web/src/app/admin/staff/page.tsx | 6 + apps/web/src/app/admin/term/page.tsx | 11 + apps/web/src/app/login.tsx | 165 +++++- .../models/department/department-drawer.tsx | 40 ++ .../models/department/department-form.tsx | 62 +++ .../department/department-import-drawer.tsx | 63 +++ .../models/department/department-list.tsx | 211 ++++++++ .../models/department/department-select.tsx | 144 +++++ .../models/domain/domain-select.tsx | 53 ++ .../components/models/role/role-drawer.tsx | 39 ++ .../src/components/models/role/role-form.tsx | 64 +++ .../src/components/models/role/role-list.tsx | 101 ++++ .../components/models/role/role-map-table.tsx | 256 +++++++++ .../components/models/role/role-select.tsx | 41 ++ .../components/models/role/rolemap-drawer.tsx | 41 ++ .../components/models/role/rolemap-form.tsx | 104 ++++ .../components/models/staff/staff-drawer.tsx | 41 ++ .../components/models/staff/staff-form.tsx | 93 ++++ .../models/staff/staff-import-drawer.tsx | 73 +++ .../components/models/staff/staff-select.tsx | 47 ++ .../components/models/staff/staff-table.tsx | 242 +++++++++ .../models/taxonomy/taxonomy-drawer.tsx | 41 ++ .../models/taxonomy/taxonomy-form.tsx | 32 ++ .../models/taxonomy/taxonomy-select.tsx | 63 +++ .../models/taxonomy/taxonomy-table.tsx | 175 ++++++ .../components/models/term/term-drawer.tsx | 43 ++ .../src/components/models/term/term-form.tsx | 102 ++++ .../models/term/term-import-drawer.tsx | 109 ++++ .../src/components/models/term/term-list.tsx | 254 +++++++++ .../components/models/term/term-select.tsx | 72 +++ .../presentation/animation/sine-wave.tsx | 110 ++++ .../components/utilities/excel-importer.tsx | 142 +++++ .../{auth => utilities}/with-auth.tsx | 5 +- apps/web/src/hooks/useAwaitState.ts | 31 ++ apps/web/src/hooks/useDepartment.ts | 119 +++++ apps/web/src/hooks/useRole.ts | 37 ++ apps/web/src/hooks/useRoleMap.ts | 37 ++ apps/web/src/hooks/useStaff.ts | 31 ++ apps/web/src/hooks/useTaxonomy.ts | 47 ++ apps/web/src/hooks/useTerm.ts | 101 ++++ apps/web/src/hooks/useTransform.ts | 29 + apps/web/src/providers/auth-provider.tsx | 23 +- apps/web/src/providers/theme-provider.tsx | 45 ++ apps/web/src/routes/index.tsx | 69 ++- apps/web/src/utils/tusd.ts | 95 ++++ apps/web/tailwind.config.js | 24 +- apps/web/tsconfig.app.json | 1 + packages/common/.env.example | 2 +- packages/common/prisma/schema.prisma | 1 + packages/common/src/schema.ts | 103 ++++ packages/common/src/type.ts | 15 +- tsconfig.json | 3 + 84 files changed, 5445 insertions(+), 120 deletions(-) create mode 100644 apps/server/src/models/taxonomy/taxonomy.module.ts create mode 100644 apps/server/src/models/taxonomy/taxonomy.router.ts create mode 100755 apps/server/src/models/taxonomy/taxonomy.service.ts create mode 100755 apps/server/src/models/term/term.module.ts create mode 100755 apps/server/src/models/term/term.router.ts create mode 100755 apps/server/src/models/term/term.service.ts create mode 100755 apps/server/src/rbac/roleperms.service.ts create mode 100644 apps/server/src/relation/relation.service.ts create mode 100644 apps/web/nginx.conf create mode 100644 apps/web/src/app/admin/department/page.tsx create mode 100644 apps/web/src/app/admin/role/page.tsx create mode 100644 apps/web/src/app/admin/staff/page.tsx create mode 100644 apps/web/src/app/admin/term/page.tsx create mode 100644 apps/web/src/components/models/department/department-drawer.tsx create mode 100644 apps/web/src/components/models/department/department-form.tsx create mode 100644 apps/web/src/components/models/department/department-import-drawer.tsx create mode 100644 apps/web/src/components/models/department/department-list.tsx create mode 100644 apps/web/src/components/models/department/department-select.tsx create mode 100644 apps/web/src/components/models/domain/domain-select.tsx create mode 100644 apps/web/src/components/models/role/role-drawer.tsx create mode 100644 apps/web/src/components/models/role/role-form.tsx create mode 100644 apps/web/src/components/models/role/role-list.tsx create mode 100644 apps/web/src/components/models/role/role-map-table.tsx create mode 100644 apps/web/src/components/models/role/role-select.tsx create mode 100644 apps/web/src/components/models/role/rolemap-drawer.tsx create mode 100644 apps/web/src/components/models/role/rolemap-form.tsx create mode 100644 apps/web/src/components/models/staff/staff-drawer.tsx create mode 100644 apps/web/src/components/models/staff/staff-form.tsx create mode 100644 apps/web/src/components/models/staff/staff-import-drawer.tsx create mode 100644 apps/web/src/components/models/staff/staff-select.tsx create mode 100644 apps/web/src/components/models/staff/staff-table.tsx create mode 100644 apps/web/src/components/models/taxonomy/taxonomy-drawer.tsx create mode 100644 apps/web/src/components/models/taxonomy/taxonomy-form.tsx create mode 100644 apps/web/src/components/models/taxonomy/taxonomy-select.tsx create mode 100644 apps/web/src/components/models/taxonomy/taxonomy-table.tsx create mode 100644 apps/web/src/components/models/term/term-drawer.tsx create mode 100644 apps/web/src/components/models/term/term-form.tsx create mode 100644 apps/web/src/components/models/term/term-import-drawer.tsx create mode 100644 apps/web/src/components/models/term/term-list.tsx create mode 100644 apps/web/src/components/models/term/term-select.tsx create mode 100644 apps/web/src/components/presentation/animation/sine-wave.tsx create mode 100644 apps/web/src/components/utilities/excel-importer.tsx rename apps/web/src/components/{auth => utilities}/with-auth.tsx (84%) create mode 100644 apps/web/src/hooks/useAwaitState.ts create mode 100644 apps/web/src/hooks/useDepartment.ts create mode 100644 apps/web/src/hooks/useRole.ts create mode 100644 apps/web/src/hooks/useRoleMap.ts create mode 100644 apps/web/src/hooks/useStaff.ts create mode 100644 apps/web/src/hooks/useTaxonomy.ts create mode 100644 apps/web/src/hooks/useTerm.ts create mode 100644 apps/web/src/hooks/useTransform.ts create mode 100644 apps/web/src/providers/theme-provider.tsx create mode 100644 apps/web/src/utils/tusd.ts diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 5814881..0204d1b 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -9,9 +9,10 @@ import { TransformModule } from './transform/transform.module'; import { AuthModule } from './auth/auth.module'; import { ScheduleModule } from '@nestjs/schedule'; import { ConfigService } from '@nestjs/config'; +import { TasksModule } from './tasks/tasks.module'; @Module({ - imports: [ScheduleModule.forRoot(), TrpcModule, RedisModule, QueueModule, TransformModule, AuthModule], + imports: [ScheduleModule.forRoot(), TrpcModule, RedisModule, QueueModule, TransformModule, AuthModule, TasksModule], providers: [RedisService, SocketGateway, ConfigService], }) export class AppModule { } diff --git a/apps/server/src/auth/auth.controller.ts b/apps/server/src/auth/auth.controller.ts index 4ec6a74..00eaf3c 100644 --- a/apps/server/src/auth/auth.controller.ts +++ b/apps/server/src/auth/auth.controller.ts @@ -8,21 +8,22 @@ import { AuthGuard } from './auth.guard'; @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) { } + @UseGuards(AuthGuard) @Get("user-profile") async getUserProfile(@Req() request: Request) { const user: JwtPayload = (request as any).user + console.log(user) + // console.log(request) return this.authService.getUserProfile(user) } @Post('login') async login(@Body() body: z.infer) { return this.authService.signIn(body); } - @Post('signup') async signup(@Body() body: z.infer) { return this.authService.signUp(body); } - @UseGuards(AuthGuard) // Protecting the refreshToken endpoint with AuthGuard @Post('refresh-token') async refreshToken(@Body() body: z.infer) { diff --git a/apps/server/src/auth/auth.guard.ts b/apps/server/src/auth/auth.guard.ts index 22c2383..def8472 100644 --- a/apps/server/src/auth/auth.guard.ts +++ b/apps/server/src/auth/auth.guard.ts @@ -15,6 +15,7 @@ export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const token = this.extractTokenFromHeader(request); + console.log(token) if (!token) { throw new UnauthorizedException(); } @@ -25,6 +26,7 @@ export class AuthGuard implements CanActivate { secret: env.JWT_SECRET } ); + // 💡 We're assigning the payload to the request object here // so that we can access it in our route handlers request['user'] = payload; diff --git a/apps/server/src/auth/auth.module.ts b/apps/server/src/auth/auth.module.ts index e4593f0..a4f7c4f 100644 --- a/apps/server/src/auth/auth.module.ts +++ b/apps/server/src/auth/auth.module.ts @@ -8,12 +8,13 @@ import { RoleMapService } from '@server/rbac/rolemap.service'; import { DepartmentService } from '@server/models/department/department.service'; @Module({ - providers: [AuthService, StaffService, RoleMapService,DepartmentService], + providers: [AuthService, StaffService, RoleMapService, DepartmentService], imports: [JwtModule.register({ global: true, secret: env.JWT_SECRET, signOptions: { expiresIn: '60s' }, }),], - controllers: [AuthController] + controllers: [AuthController], + exports: [AuthService] }) export class AuthModule { } diff --git a/apps/server/src/auth/auth.service.ts b/apps/server/src/auth/auth.service.ts index 7b6d3f7..9009b7d 100644 --- a/apps/server/src/auth/auth.service.ts +++ b/apps/server/src/auth/auth.service.ts @@ -20,15 +20,24 @@ export class AuthService { ) { } async signIn(data: z.infer) { - const { username, password } = data; - const staff = await db.staff.findUnique({ where: { username } }); + const { username, password, phoneNumber } = data; + // Find the staff by either username or phoneNumber + const staff = await db.staff.findFirst({ + where: { + OR: [ + { username }, + { phoneNumber } + ] + } + }); + if (!staff) { - throw new UnauthorizedException('Invalid username or password'); + throw new UnauthorizedException('Invalid username/phone number or password'); } const isPasswordMatch = await bcrypt.compare(password, staff.password); if (!isPasswordMatch) { - throw new UnauthorizedException('Invalid username or password'); + throw new UnauthorizedException('Invalid username/phone number or password'); } const payload: JwtPayload = { sub: staff.id, username: staff.username }; @@ -99,22 +108,27 @@ export class AuthService { } async signUp(data: z.infer) { - const { username, password } = data; - const existingUser = await db.staff.findUnique({ where: { username } }); + const { username, password, phoneNumber } = data; - if (existingUser) { + const existingUserByUsername = await db.staff.findUnique({ where: { username } }); + if (existingUserByUsername) { throw new BadRequestException('Username is already taken'); } - + if (phoneNumber) { + const existingUserByPhoneNumber = await db.staff.findUnique({ where: { phoneNumber } }); + if (existingUserByPhoneNumber) { + throw new BadRequestException('Phone number is already taken'); + } + } const hashedPassword = await bcrypt.hash(password, 10); const staff = await this.staffService.create({ username, + phoneNumber, password: hashedPassword, }); return staff; } - async logout(data: z.infer) { const { refreshToken } = data; await db.refreshToken.deleteMany({ where: { token: refreshToken } }); diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index 74aecae..8c648d7 100755 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -1,3 +1,4 @@ -export const env: { JWT_SECRET: string } = { - JWT_SECRET: process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA=' +export const env: { JWT_SECRET: string, APP_URL: string } = { + JWT_SECRET: process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA=', + APP_URL: process.env.APP_URL || 'http://localhost:5173' } \ No newline at end of file diff --git a/apps/server/src/init/init.module.ts b/apps/server/src/init/init.module.ts index 8113776..ad6d316 100644 --- a/apps/server/src/init/init.module.ts +++ b/apps/server/src/init/init.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { InitService } from './init.service'; +import { AuthModule } from '@server/auth/auth.module'; @Module({ + imports: [AuthModule], providers: [InitService], exports: [InitService] }) diff --git a/apps/server/src/init/init.service.ts b/apps/server/src/init/init.service.ts index 405f913..2009c1c 100644 --- a/apps/server/src/init/init.service.ts +++ b/apps/server/src/init/init.service.ts @@ -44,19 +44,27 @@ export class InitService { private async createRoot() { this.logger.log('Checking for root account'); - const rootAccountExists = await db.staff.findUnique({ - where: { phoneNumber: process.env.ADMIN_PHONE_NUMBER || '000000' }, + const rootAccountExists = await db.staff.findFirst({ + where: { + OR: [ + { + phoneNumber: process.env.ADMIN_PHONE_NUMBER || '000000' + }, + { + username: 'root' + } + ] + }, }); if (!rootAccountExists) { this.logger.log('Creating root account'); - const rootStaff =await this.authService.signUp({ + const rootStaff = await this.authService.signUp({ username: 'root', - password: 'admin' + password: 'root' }) const rootRole = await db.role.findUnique({ where: { name: '根管理员' }, }); - if (rootRole) { this.logger.log('Assigning root role to root account'); await db.roleMap.create({ diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index f1f0f2a..10abb60 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,11 +1,13 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { TrpcRouter } from './trpc/trpc.router'; +import { env } from './env'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableCors({ - origin: [process.env.APP_URL!], + origin: [env.APP_URL], + credentials: true }); const trpc = app.get(TrpcRouter); trpc.applyMiddleware(app); diff --git a/apps/server/src/models/department/department.router.ts b/apps/server/src/models/department/department.router.ts index a7293b2..2bced54 100755 --- a/apps/server/src/models/department/department.router.ts +++ b/apps/server/src/models/department/department.router.ts @@ -9,7 +9,6 @@ export class DepartmentRouter { private readonly trpc: TrpcService, private readonly departmentService: DepartmentService, // inject DepartmentService ) {} - router = this.trpc.router({ create: this.trpc.protectProcedure .input(DepartmentSchema.create) // expect input according to the schema diff --git a/apps/server/src/models/department/department.service.ts b/apps/server/src/models/department/department.service.ts index 63cbe4a..bcff592 100755 --- a/apps/server/src/models/department/department.service.ts +++ b/apps/server/src/models/department/department.service.ts @@ -125,7 +125,6 @@ export class DepartmentService { } async paginate(data: z.infer) { const { page, pageSize, ids } = data; - const [items, totalCount] = await Promise.all([ db.department.findMany({ skip: (page - 1) * pageSize, diff --git a/apps/server/src/models/taxonomy/taxonomy.module.ts b/apps/server/src/models/taxonomy/taxonomy.module.ts new file mode 100644 index 0000000..a8aa9ab --- /dev/null +++ b/apps/server/src/models/taxonomy/taxonomy.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TaxonomyRouter } from './taxonomy.router'; +import { TaxonomyService } from './taxonomy.service'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { RedisModule } from '@server/redis/redis.module'; + +@Module({ + imports: [RedisModule], + providers: [TaxonomyRouter, TaxonomyService, TrpcService], + exports: [TaxonomyRouter, TaxonomyService] +}) +export class TaxonomyModule { } diff --git a/apps/server/src/models/taxonomy/taxonomy.router.ts b/apps/server/src/models/taxonomy/taxonomy.router.ts new file mode 100644 index 0000000..c575bb3 --- /dev/null +++ b/apps/server/src/models/taxonomy/taxonomy.router.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { TaxonomyService } from './taxonomy.service'; +import { TaxonomySchema } from '@nicestack/common'; + +@Injectable() +export class TaxonomyRouter { + constructor( + private readonly trpc: TrpcService, + private readonly taxonomyService: TaxonomyService + ) { } + + router = this.trpc.router({ + create: this.trpc.procedure.input(TaxonomySchema.create).mutation(async ({ input }) => { + return this.taxonomyService.create(input); + }), + + findById: this.trpc.procedure.input(TaxonomySchema.findById).query(async ({ input }) => { + return this.taxonomyService.findById(input); + }), + + update: this.trpc.procedure.input(TaxonomySchema.update).mutation(async ({ input }) => { + return this.taxonomyService.update(input); + }), + + delete: this.trpc.procedure.input(TaxonomySchema.delete).mutation(async ({ input }) => { + return this.taxonomyService.delete(input); + }), + + batchDelete: this.trpc.procedure.input(TaxonomySchema.batchDelete).mutation(async ({ input }) => { + return this.taxonomyService.batchDelete(input); + }), + + paginate: this.trpc.procedure.input(TaxonomySchema.paginate!).query(async ({ input }) => { + return this.taxonomyService.paginate(input); + }), + + getAll: this.trpc.procedure.query(() => { + return this.taxonomyService.getAll(); + }) + }); +} diff --git a/apps/server/src/models/taxonomy/taxonomy.service.ts b/apps/server/src/models/taxonomy/taxonomy.service.ts new file mode 100755 index 0000000..9655a1d --- /dev/null +++ b/apps/server/src/models/taxonomy/taxonomy.service.ts @@ -0,0 +1,176 @@ +import { Injectable } from '@nestjs/common'; +import { db, TaxonomySchema, z } from '@nicestack/common'; +import { RedisService } from '@server/redis/redis.service'; +import { TRPCError } from '@trpc/server'; + +@Injectable() +export class TaxonomyService { + constructor(private readonly redis: RedisService) {} + + /** + * 清除分页缓存,删除所有以'taxonomies:page:'开头的键 + */ + private async invalidatePaginationCache() { + const keys = await this.redis.keys('taxonomies:page:*'); + await Promise.all(keys.map((key) => this.redis.deleteKey(key))); + } + + /** + * 创建新的分类记录 + * @param input 分类创建信息 + * @returns 新创建的分类记录 + */ + async create(input: z.infer) { + // 获取当前分类数量,设置新分类的order值为count + 1 + const count = await db.taxonomy.count(); + const taxonomy = await db.taxonomy.create({ + data: { ...input, order: count + 1 }, + }); + + // 删除该分类的缓存及分页缓存 + await this.redis.deleteKey(`taxonomy:${taxonomy.id}`); + await this.invalidatePaginationCache(); + return taxonomy; + } + + /** + * 根据name查找分类记录 + * @param input 包含分类name的对象 + * @returns 查找到的分类记录 + */ + async findByName(input: z.infer) { + const { name } = input; + const cacheKey = `taxonomy:${name}`; + let cachedTaxonomy = await this.redis.getValue(cacheKey); + if (cachedTaxonomy) { + return JSON.parse(cachedTaxonomy); + } + const taxonomy = await db.taxonomy.findUnique({ where: { name: name } }); + if (taxonomy) { + await this.redis.setWithExpiry(cacheKey, JSON.stringify(taxonomy), 60); + } + return taxonomy; + } + /** + * 根据ID查找分类记录 + * @param input 包含分类ID的对象 + * @returns 查找到的分类记录 + */ + async findById(input: z.infer) { + const cacheKey = `taxonomy:${input.id}`; + let cachedTaxonomy = await this.redis.getValue(cacheKey); + if (cachedTaxonomy) { + return JSON.parse(cachedTaxonomy); + } + const taxonomy = await db.taxonomy.findUnique({ where: { id: input.id } }); + if (taxonomy) { + await this.redis.setWithExpiry(cacheKey, JSON.stringify(taxonomy), 60); + } + return taxonomy; + } + + /** + * 更新分类记录 + * @param input 包含ID和其他更新字段的对象 + * @returns 更新后的分类记录 + */ + async update(input: any) { + const { id, ...data } = input; + const updatedTaxonomy = await db.taxonomy.update({ where: { id }, data }); + + // 删除该分类的缓存及分页缓存 + await this.redis.deleteKey(`taxonomy:${updatedTaxonomy.id}`); + await this.invalidatePaginationCache(); + return updatedTaxonomy; + } + + /** + * 删除分类记录(软删除) + * @param input 包含分类ID的对象 + * @returns 删除后的分类记录 + */ + async delete(input: any) { + const deletedTaxonomy = await db.taxonomy.update({ + where: { id: input.id }, + data: { deletedAt: new Date() }, + }); + + // 删除该分类的缓存及分页缓存 + await this.redis.deleteKey(`taxonomy:${deletedTaxonomy.id}`); + await this.invalidatePaginationCache(); + return deletedTaxonomy; + } + + /** + * 批量删除分类记录(软删除) + * @param input 包含要删除的分类ID数组的对象 + * @returns 删除操作结果,包括删除的记录数 + */ + async batchDelete(input: any) { + const { ids } = input; + if (!ids || ids.length === 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'No IDs provided for deletion.', + }); + } + const deletedTaxonomies = await db.taxonomy.updateMany({ + where: { + id: { in: ids }, + }, + data: { deletedAt: new Date() }, + }); + if (!deletedTaxonomies.count) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'No taxonomies were found with the provided IDs.', + }); + } + + // 删除每个分类的缓存及分页缓存 + await Promise.all( + ids.map(async (id: string) => this.redis.deleteKey(`taxonomy:${id}`)), + ); + await this.invalidatePaginationCache(); + return { success: true, count: deletedTaxonomies.count }; + } + + /** + * 分页查询分类记录 + * @param input 包含分页参数的对象 + * @returns 分类列表及总记录数 + */ + async paginate(input: any) { + const cacheKey = `taxonomies:page:${input.page}:size:${input.pageSize}`; + let cachedData = await this.redis.getValue(cacheKey); + if (cachedData) { + return JSON.parse(cachedData); + } + const { page, pageSize } = input; + const [items, totalCount] = await Promise.all([ + db.taxonomy.findMany({ + skip: (page - 1) * pageSize, + take: pageSize, + orderBy: { order: 'asc' }, + where: { deletedAt: null }, + }), + db.taxonomy.count({ where: { deletedAt: null } }), + ]); + const result = { items, totalCount }; + + // 缓存结果并设置过期时间 + await this.redis.setWithExpiry(cacheKey, JSON.stringify(result), 60); + return result; + } + + /** + * 获取所有未删除的分类记录 + * @returns 分类记录列表 + */ + async getAll() { + return db.taxonomy.findMany({ + where: { deletedAt: null }, + orderBy: { order: 'asc' }, + }); + } +} diff --git a/apps/server/src/models/term/term.module.ts b/apps/server/src/models/term/term.module.ts new file mode 100755 index 0000000..3017928 --- /dev/null +++ b/apps/server/src/models/term/term.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TermService } from './term.service'; +import { TermRouter } from './term.router'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { DepartmentModule } from '../department/department.module'; +import { RbacModule } from '@server/rbac/rbac.module'; +import { RelationService } from '@server/relation/relation.service'; + + +@Module({ + imports: [DepartmentModule, RbacModule], + providers: [TermService, TermRouter, TrpcService, RelationService], + exports: [TermService, TermRouter] +}) +export class TermModule { } diff --git a/apps/server/src/models/term/term.router.ts b/apps/server/src/models/term/term.router.ts new file mode 100755 index 0000000..48eb10d --- /dev/null +++ b/apps/server/src/models/term/term.router.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { TermService } from './term.service'; // Adjust the import path as necessary +import { z, TermSchema } from '@nicestack/common'; + +@Injectable() +export class TermRouter { + constructor( + private readonly trpc: TrpcService, + private readonly termService: TermService, + ) { } + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(TermSchema.create) + .mutation(async ({ input, ctx }) => { + const { staff } = ctx + return this.termService.create(staff, input); + }), + update: this.trpc.protectProcedure + .input(TermSchema.update) + .mutation(async ({ input }) => { + return this.termService.update(input); + }), + delete: this.trpc.protectProcedure + .input(TermSchema.delete) + .mutation(async ({ input }) => { + return this.termService.delete(input); + }), + findById: this.trpc.procedure.input(z.object({ + id: z.string() + })).query(async ({ input, ctx }) => { + const { staff } = ctx + return this.termService.findUnique(staff, input.id) + }), + batchDelete: this.trpc.protectProcedure.input(z.object({ + ids: z.array(z.string()) + })).mutation(async ({ input }) => { + const { ids } = input + return this.termService.batchDelete(ids) + }), + getChildren: this.trpc.procedure.input(TermSchema.getChildren).query(async ({ input, ctx }) => { + const { staff } = ctx + return this.termService.getChildren(staff, input) + }), + getAllChildren: this.trpc.procedure.input(TermSchema.getChildren).query(async ({ input, ctx }) => { + const { staff } = ctx + return this.termService.getAllChildren(staff, input) + }), + findMany: this.trpc.procedure + .input(TermSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.termService.findMany(input); + }), + }); +} diff --git a/apps/server/src/models/term/term.service.ts b/apps/server/src/models/term/term.service.ts new file mode 100755 index 0000000..96d6719 --- /dev/null +++ b/apps/server/src/models/term/term.service.ts @@ -0,0 +1,367 @@ +import { Injectable } from '@nestjs/common'; +import { z, TermSchema, db, Staff, Term, RelationType, ObjectType, Prisma, TermDto } from '@nicestack/common'; +import { RolePermsService } from '@server/rbac/roleperms.service'; +import { RelationService } from '@server/relation/relation.service'; + +/** + * Service for managing terms and their ancestries. + */ +@Injectable() +export class TermService { + constructor(private readonly permissionService: RolePermsService, private readonly relations: RelationService) { } + + /** + * 生成TermDto对象,包含权限和关系信息 + * @param staff 当前操作的工作人员 + * @param term 当前处理的术语对象,包含其子节点 + * @returns 完整的TermDto对象 + */ + async genTermDto(staff: Staff, term: Term & { children: Term[] }): Promise { + const { children, ...others } = term as any; + const permissions = this.permissionService.getTermPerms(staff, term); + const relationTypes = [ + { type: RelationType.WATCH, object: ObjectType.DEPARTMENT, key: 'watchDeptIds', limit: undefined }, + { type: RelationType.WATCH, object: ObjectType.STAFF, key: 'watchStaffIds', limit: undefined } + ] as const; + + type RelationResult = { + [key in typeof relationTypes[number]['key']]: string[]; + }; + + const promises = relationTypes.map(async ({ type, object, key, limit }) => ({ + [key]: await this.relations.getEROBids(ObjectType.TERM, type, object, term.id, limit) + })); + + const results = await Promise.all(promises); + const mergedResults = Object.assign({}, ...(results as Partial[])); + + return { ...others, ...mergedResults, permissions, hasChildren: term.children.length > 0 }; + } + + /** + * 获取特定父节点下新的排序值。 + * + * @param parentId 父节点ID,如果是根节点则为null + * @returns 下一个排序值 + */ + private async getNextOrder(parentId?: string) { + let newOrder = 0; + + if (parentId) { + const siblingTerms = await db.term.findMany({ + where: { parentId }, + orderBy: { order: 'desc' }, + take: 1, + }); + + if (siblingTerms.length > 0) { + newOrder = siblingTerms[0].order + 1; + } + } else { + const rootTerms = await db.term.findMany({ + where: { parentId: null }, + orderBy: { order: 'desc' }, + take: 1, + }); + + if (rootTerms.length > 0) { + newOrder = rootTerms[0].order + 1; + } + } + + return newOrder; + } + + /** + * 创建关系数据用于批量插入。 + * + * @param termId 术语ID + * @param watchDeptIds 监控部门ID数组 + * @param watchStaffIds 监控员工ID数组 + * @returns 关系数据数组 + */ + private createRelations( + termId: string, + watchDeptIds: string[], + watchStaffIds: string[] + ) { + const relationsData = [ + ...watchDeptIds.map(bId => this.relations.buildRelation(termId, bId, ObjectType.TERM, ObjectType.DEPARTMENT, RelationType.WATCH)), + ...watchStaffIds.map(bId => this.relations.buildRelation(termId, bId, ObjectType.TERM, ObjectType.STAFF, RelationType.WATCH)), + ]; + return relationsData; + } + + /** + * 创建一个新的术语并根据需要创建祖先关系。 + * + * @param data 创建新术语的数据 + * @returns 新创建的术语 + */ + async create(staff: Staff, data: z.infer) { + const { parentId, watchDeptIds = [], watchStaffIds = [], ...others } = data; + + return await db.$transaction(async (trx) => { + const order = await this.getNextOrder(parentId); + + const newTerm = await trx.term.create({ + data: { + ...others, + parentId, + order, + createdBy: staff.id + }, + }); + + if (parentId) { + const parentTerm = await trx.term.findUnique({ + where: { id: parentId }, + include: { ancestors: true }, + }); + + const ancestries = parentTerm.ancestors.map((ancestor) => ({ + ancestorId: ancestor.ancestorId, + descendantId: newTerm.id, + relDepth: ancestor.relDepth + 1, + })); + + ancestries.push({ + ancestorId: parentTerm.id, + descendantId: newTerm.id, + relDepth: 1, + }); + + await trx.termAncestry.createMany({ data: ancestries }); + } + + const relations = this.createRelations(newTerm.id, watchDeptIds, watchStaffIds); + await trx.relation.createMany({ data: relations }); + return newTerm; + }); + } + + /** + * 更新现有术语的数据,并在parentId改变时管理术语祖先关系。 + * + * @param data 更新术语的数据 + * @returns 更新后的术语 + */ + async update(data: z.infer) { + return await db.$transaction(async (prisma) => { + const currentTerm = await prisma.term.findUnique({ + where: { id: data.id }, + }); + if (!currentTerm) throw new Error('Term not found'); + console.log(data) + const updatedTerm = await prisma.term.update({ + where: { id: data.id }, + data, + }); + + if (data.parentId !== currentTerm.parentId) { + await prisma.termAncestry.deleteMany({ + where: { descendantId: data.id }, + }); + + if (data.parentId) { + const parentAncestries = await prisma.termAncestry.findMany({ + where: { descendantId: data.parentId }, + }); + + const newAncestries = parentAncestries.map(ancestry => ({ + ancestorId: ancestry.ancestorId, + descendantId: data.id, + relDepth: ancestry.relDepth + 1, + })); + + newAncestries.push({ + ancestorId: data.parentId, + descendantId: data.id, + relDepth: 1, + }); + + await prisma.termAncestry.createMany({ + data: newAncestries, + }); + + const order = await this.getNextOrder(data.parentId); + await prisma.term.update({ + where: { id: data.id }, + data: { order }, + }); + } + } + + if (data.watchDeptIds || data.watchStaffIds) { + await prisma.relation.deleteMany({ where: { aId: data.id, relationType: { in: [RelationType.WATCH] } } }); + + const relations = this.createRelations( + data.id, + data.watchDeptIds ?? [], + data.watchStaffIds ?? [] + ); + + await prisma.relation.createMany({ data: relations }); + } + + return updatedTerm; + }); + } + + /** + * 根据ID删除现有术语。 + * + * @param data 删除术语的数据 + * @returns 被删除的术语 + */ + async delete(data: z.infer) { + const { id } = data; + + await db.termAncestry.deleteMany({ + where: { OR: [{ ancestorId: id }, { descendantId: id }] }, + }); + + const deletedTerm = await db.term.update({ + where: { id }, + data: { + deletedAt: new Date(), + }, + }); + + return deletedTerm; + } + + /** + * 批量删除术语。 + * + * @param ids 要删除的术语ID数组 + * @returns 已删除的术语列表 + */ + async batchDelete(ids: string[]) { + await db.termAncestry.deleteMany({ + where: { OR: [{ ancestorId: { in: ids } }, { descendantId: { in: ids } }] }, + }); + + const deletedTerms = await db.term.updateMany({ + where: { id: { in: ids } }, + data: { + deletedAt: new Date(), + } + }); + + return deletedTerms; + } + + /** + * 查找唯一术语并生成TermDto对象。 + * + * @param staff 当前操作的工作人员 + * @param id 术语ID + * @returns 包含详细信息的术语对象 + */ + async findUnique(staff: Staff, id: string) { + const term = await db.term.findUnique({ + where: { + id, + }, + include: { + domain: true, + children: true, + }, + }); + return await this.genTermDto(staff, term); + } + + /** + * 获取指定条件下的术语子节点。 + * + * @param staff 当前操作的工作人员 + * @param data 查询条件 + * @returns 子节点术语列表 + */ + async getChildren(staff: Staff, data: z.infer) { + const { parentId, domainId, taxonomyId, cursor, limit = 10 } = data; + const extraCondition = await this.permissionService.getTermExtraConditions(staff); + let queryCondition: Prisma.TermWhereInput = { taxonomyId, parentId: parentId === undefined ? null : parentId, domainId, deletedAt: null } + const whereCondition: Prisma.TermWhereInput = { + AND: [extraCondition, queryCondition], + }; + console.log(JSON.stringify(whereCondition)) + const terms = await db.term.findMany({ + where: whereCondition, + include: { + children: { + where: { + deletedAt: null, + }, + + } + }, + take: limit + 1, + cursor: cursor ? { createdAt: cursor.split('_')[0], id: cursor.split('_')[1] } : undefined, + }); + let nextCursor: typeof cursor | undefined = undefined; + if (terms.length > limit) { + const nextItem = terms.pop(); + nextCursor = `${nextItem.createdAt.toISOString()}_${nextItem!.id}`; + } + const termDtos = await Promise.all(terms.map((item) => this.genTermDto(staff, item))); + return { + items: termDtos, + nextCursor, + }; + } + /** + * 获取指定条件下的所有术语子节点。 + * + * @param staff 当前操作的工作人员 + * @param data 查询条件 + * @returns 子节点术语列表 + */ + async getAllChildren(staff: Staff, data: z.infer) { + const { parentId, domainId, taxonomyId } = data; + const extraCondition = await this.permissionService.getTermExtraConditions(staff); + let queryCondition: Prisma.TermWhereInput = { taxonomyId, parentId: parentId === undefined ? null : parentId, domainId, deletedAt: null } + + const whereCondition: Prisma.TermWhereInput = { + AND: [extraCondition, queryCondition], + }; + console.log(JSON.stringify(whereCondition)) + const terms = await db.term.findMany({ + where: whereCondition, + include: { + children: { + where: { + deletedAt: null, + }, + }, + }, + }); + return await Promise.all(terms.map((item) => this.genTermDto(staff, item))); + + } + + /** + * 根据关键词或ID集合查找术语 + * @param data 包含关键词、域ID和ID集合的对象 + * @returns 匹配的术语记录列表 + */ + async findMany(data: z.infer) { + const { keyword, taxonomyId, ids } = data; + + return await db.term.findMany({ + where: { + deletedAt: null, + taxonomyId, + OR: [ + { name: { contains: keyword } }, + { + id: { in: ids } + } + ] + }, + orderBy: { order: "asc" }, + take: 20 + }); + } +} diff --git a/apps/server/src/rbac/rbac.module.ts b/apps/server/src/rbac/rbac.module.ts index 210b5b3..9174e0b 100644 --- a/apps/server/src/rbac/rbac.module.ts +++ b/apps/server/src/rbac/rbac.module.ts @@ -5,10 +5,12 @@ import { TrpcService } from '@server/trpc/trpc.service'; import { RoleService } from './role.service'; import { RoleMapRouter } from './rolemap.router'; import { DepartmentModule } from '@server/models/department/department.module'; +import { RolePermsService } from './roleperms.service'; +import { RelationService } from '@server/relation/relation.service'; @Module({ imports: [DepartmentModule], - providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter], - exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter] + providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter, RolePermsService, RelationService], + exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter, RolePermsService] }) -export class RoleMapModule { } +export class RbacModule { } diff --git a/apps/server/src/rbac/rolemap.router.ts b/apps/server/src/rbac/rolemap.router.ts index 1362e28..ac335b4 100644 --- a/apps/server/src/rbac/rolemap.router.ts +++ b/apps/server/src/rbac/rolemap.router.ts @@ -2,34 +2,28 @@ import { Injectable } from '@nestjs/common'; import { TrpcService } from '@server/trpc/trpc.service'; import { RoleMapSchema } from '@nicestack/common'; import { RoleMapService } from './rolemap.service'; - @Injectable() export class RoleMapRouter { constructor( private readonly trpc: TrpcService, private readonly roleMapService: RoleMapService, - ) {} - + ) { } router = this.trpc.router({ - deleteAllRolesForObject: this.trpc.protectProcedure .input(RoleMapSchema.deleteWithObject) .mutation(({ input }) => this.roleMapService.deleteAllRolesForObject(input), ), - setRoleForObject: this.trpc.protectProcedure .input(RoleMapSchema.create) .mutation(({ input }) => this.roleMapService.setRoleForObject(input)), - createManyObjects: this.trpc.protectProcedure .input(RoleMapSchema.createManyObjects) .mutation(({ input }) => this.roleMapService.createManyObjects(input)), - setRolesForObject: this.trpc.protectProcedure .input(RoleMapSchema.createManyRoles) .mutation(({ input }) => this.roleMapService.setRolesForObject(input)), - + getPermsForObject: this.trpc.procedure .input(RoleMapSchema.getPermsForObject) .query(({ input }) => this.roleMapService.getPermsForObject(input)), diff --git a/apps/server/src/rbac/roleperms.service.ts b/apps/server/src/rbac/roleperms.service.ts new file mode 100755 index 0000000..8a93a8f --- /dev/null +++ b/apps/server/src/rbac/roleperms.service.ts @@ -0,0 +1,189 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + Prisma, + ObjectType, + RolePerms, + RelationType, + db, + Staff, + Term, + GenPerms, +} from '@nicestack/common'; +import { DepartmentService } from '@server/models/department/department.service'; +import { RelationService } from '@server/relation/relation.service'; +import { RoleMapService } from './rolemap.service'; +type RolePermsHandlers = { + [key in RolePerms]?: (permissions: GenPerms) => void; +}; + +@Injectable() +export class RolePermsService { + constructor( + private readonly relations: RelationService, + private readonly departments: DepartmentService, + private readonly rbac: RoleMapService, + ) { } + private readonly logger = new Logger(RolePermsService.name); + async getStaffPerms(staff: Staff) { + const staffPerms = await this.rbac.getPermsForObject({ + domainId: staff.domainId, + staffId: staff.id, + deptId: staff.deptId, + }); + return staffPerms; + } + async getTermPerms(staff: Staff, term: Term) { + const termPerms: GenPerms = { + delete: false, + edit: false, + read: false, + }; + const staffPerms = await this.getStaffPerms(staff) + const isInDomain = staff.domainId === term.domainId; + const watchDeptIds = await this.relations.getEROBids( + ObjectType.TERM, + RelationType.WATCH, + ObjectType.DEPARTMENT, + term.id, + ); + const watchStaffIds = await this.relations.getEROBids( + ObjectType.TERM, + RelationType.WATCH, + ObjectType.STAFF, + term.id, + ); + const canWatch = + watchDeptIds.includes(staff.deptId) || watchStaffIds.includes(staff.id); + if (canWatch) { + Object.assign(termPerms, { read: true }); + } + const applyRolePerms = (perm: RolePerms) => { + const handlers: RolePermsHandlers = { + [RolePerms.EDIT_ANY_TERM]: (p) => Object.assign(p, { edit: true }), + [RolePerms.EDIT_DOM_TERM]: (p) => + isInDomain && Object.assign(p, { edit: true }), + [RolePerms.READ_DOM_TERM]: (p) => + isInDomain && Object.assign(p, { read: true }), + [RolePerms.READ_ANY_TERM]: (p) => Object.assign(p, { read: true }), + [RolePerms.DELETE_ANY_TERM]: (p) => Object.assign(p, { delete: true }), + [RolePerms.DELETE_DOM_TERM]: (p) => + isInDomain && Object.assign(p, { delete: true }), + }; + handlers[perm]?.(termPerms); + }; + staffPerms.forEach(applyRolePerms); + return termPerms; + } + + /** + * Build conditions for querying message comments. + * @param staff - The staff details to build conditions. + * @returns A string representing the SQL condition for message comments. + */ + async buildCommentExtraQuery( + staff: Staff, + aId: string, + aType: ObjectType, + relationType: RelationType, + ): Promise { + const { id: staffId, deptId } = staff; + const ancestorDeptIds = await this.departments.getAllParentDeptIds(deptId); + let queryString = ''; + if (relationType === RelationType.MESSAGE) { + queryString = ` + c.id IN ( + SELECT "aId" + FROM relations + WHERE ( + "bId" = '${staffId}' AND + "bType" = '${ObjectType.STAFF}' AND + "aType" = '${ObjectType.COMMENT}' AND + "relationType" = '${RelationType.MESSAGE}' + ) + `; + + if (ancestorDeptIds.length > 0) { + queryString += ` + OR ( + "bId" IN (${[...ancestorDeptIds, deptId].map((id) => `'${id}'`).join(', ')}) AND + "bType" = '${ObjectType.DEPARTMENT}' AND + "aType" = '${ObjectType.COMMENT}' AND + "relationType" = '${RelationType.MESSAGE}' + ) + `; + } + + queryString += `)`; + } else { + queryString = ` + c.id IN ( + SELECT "bId" + FROM relations + WHERE ( + "aId" = '${aId}' AND + "aType" = '${aType}' AND + "bType" = '${ObjectType.COMMENT}' AND + "relationType" = '${relationType}' + ) + `; + queryString += `)`; + } + + return queryString; + } + async getTermExtraConditions(staff: Staff) { + const { domainId, id: staffId, deptId } = staff; + const staffPerms = await this.getStaffPerms(staff) + + const ancestorDeptIds = await this.departments.getAllParentDeptIds(deptId); + + if (staffPerms.includes(RolePerms.READ_ANY_TERM)) { + return {}; + } + const relevantRelations = await db.relation.findMany({ + where: { + OR: [ + { + bId: staffId, + bType: ObjectType.STAFF, + aType: ObjectType.TERM, + relationType: RelationType.WATCH, + }, + { + bId: { in: ancestorDeptIds }, + bType: ObjectType.DEPARTMENT, + aType: ObjectType.TERM, + relationType: RelationType.WATCH, + }, + ], + }, + select: { aId: true }, + }); + + const termIds = relevantRelations.map((relation) => relation.aId); + const ownedTermIds = await db.term.findMany({ + select: { + id: true, + }, + where: { + createdBy: staffId, + }, + }); + const conditions: Prisma.TermWhereInput = { + OR: [ + { + id: { + in: [...termIds, ...ownedTermIds.map((item) => item.id)], + }, + }, + ], + }; + + if (domainId && staffPerms.includes(RolePerms.READ_DOM_TERM)) { + conditions.OR.push({ + OR: [{ domainId: null }, { domainId: domainId }], + }); + } + return conditions; + } +} diff --git a/apps/server/src/relation/relation.service.ts b/apps/server/src/relation/relation.service.ts new file mode 100644 index 0000000..1ca9bbf --- /dev/null +++ b/apps/server/src/relation/relation.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@nestjs/common'; +import { ObjectType, RelationType, db, Relation } from "@nicestack/common"; + +/** + * Service dealing with relation entities. + */ +@Injectable() +export class RelationService { + + /** + * Create a new relation object. + * + * @param {string} aId - The ID of the related entity. + * @param {string} bId - The ID of the target object. + * @param {ObjectType} bType - The type of the target object. + * @param {RelationType} relationType - The type of the relation. + * @returns {{aId: string, bId: string, aType:ObjectType, bType: ObjectType, relationType: RelationType}} An object representing the created relation. + */ + buildRelation(aId: string, bId: string, aType: ObjectType, bType: ObjectType, relationType: RelationType): { aId: string; bId: string; aType: ObjectType; bType: ObjectType; relationType: RelationType; } { + return { + aId, + bId, + aType, + bType, + relationType + }; + } + + /** + * Find relations based on entity type, relation type, object type, and entity ID. + * + * @param {ObjectType} aType - The type of the entity. + * @param {RelationType} relationType - The type of the relation. + * @param {ObjectType} bType - The type of the object. + * @param {string} aId - The ID of the entity to find relations for. + * @param {number} [limit] - Optional limit on the number of results. + * @returns {Promise} A promise that resolves to an array of relation objects. + */ + async getERO(aType: ObjectType, relationType: RelationType, bType: ObjectType, aId: string, limit?: number): Promise> { + return await db.relation.findMany({ + where: { + aType, + relationType, + bType, + aId + }, + take: limit // Add the limit if provided + }); + } + /** + * Find relations based on entity type, relation type, object type, and entity ID. + * + * @param {ObjectType} aType - The type of the entity. + * @param {RelationType} relationType - The type of the relation. + * @param {ObjectType} bType - The type of the object. + * @param {string} aId - The ID of the entity to find relations for. + * @param {number} [limit] - Optional limit on the number of results. + * @returns {Promise} A promise that resolves to an array of relation objects. + */ + async getEROCount(aType: ObjectType, relationType: RelationType, bType: ObjectType, aId: string): Promise { + return await db.relation.count({ + where: { + aType, + relationType, + bType, + aId + } + }); + } + + /** + * Get the IDs of objects related to a specific entity. + * + * @param {ObjectType} aType - The type of the entity. + * @param {RelationType} relationType - The type of the relation. + * @param {ObjectType} bType - The type of the object. + * @param {string} aId - The ID of the entity to get related object IDs for. + * @param {number} [limit] - Optional limit on the number of results. + * @returns {Promise>} A promise that resolves to an array of object IDs. + */ + async getEROBids(aType: ObjectType, relationType: RelationType, bType: ObjectType, aId: string, limit?: number): Promise> { + const res = await this.getERO(aType, relationType, bType, aId, limit); + return res.map(relation => relation.bId); + } +} diff --git a/apps/server/src/transform/transform.module.ts b/apps/server/src/transform/transform.module.ts index dc64a77..20e5e3b 100644 --- a/apps/server/src/transform/transform.module.ts +++ b/apps/server/src/transform/transform.module.ts @@ -2,8 +2,13 @@ import { Module } from '@nestjs/common'; import { TransformService } from './transform.service'; import { TransformRouter } from './transform.router'; import { TrpcService } from '@server/trpc/trpc.service'; +import { DepartmentModule } from '@server/models/department/department.module'; +import { StaffModule } from '@server/models/staff/staff.module'; +import { TaxonomyModule } from '@server/models/taxonomy/taxonomy.module'; +import { TermModule } from '@server/models/term/term.module'; @Module({ + imports: [DepartmentModule, StaffModule, TaxonomyModule, TermModule], providers: [TransformService, TransformRouter, TrpcService], exports: [TransformRouter] }) diff --git a/apps/server/src/transform/transform.router.ts b/apps/server/src/transform/transform.router.ts index 9a9dade..6183933 100644 --- a/apps/server/src/transform/transform.router.ts +++ b/apps/server/src/transform/transform.router.ts @@ -1,13 +1,32 @@ import { Injectable } from '@nestjs/common'; -import { TrpcService } from '@server/trpc/trpc.service'; import { TransformService } from './transform.service'; - - +import { TransformSchema } from '@nicestack/common'; +import { TrpcService } from '../trpc/trpc.service'; @Injectable() export class TransformRouter { - constructor(private readonly trpc: TrpcService, private readonly transformService: TransformService) { } - - router = this.trpc.router({ - - }); + constructor( + private readonly trpc: TrpcService, + private readonly transformService: TransformService, + ) {} + router = this.trpc.router({ + importTerms: this.trpc.protectProcedure + .input(TransformSchema.importTerms) // expect input according to the schema + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return this.transformService.importTerms(staff, input); + }), + importDepts: this.trpc.protectProcedure + .input(TransformSchema.importDepts) // expect input according to the schema + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return this.transformService.importDepts(staff, input); + }), + importStaffs: this.trpc.protectProcedure + .input(TransformSchema.importStaffs) // expect input according to the schema + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return this.transformService.importStaffs(input); + }), + + }); } diff --git a/apps/server/src/transform/transform.service.ts b/apps/server/src/transform/transform.service.ts index 8df4247..5df1cb2 100644 --- a/apps/server/src/transform/transform.service.ts +++ b/apps/server/src/transform/transform.service.ts @@ -1,4 +1,496 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import * as ExcelJS from 'exceljs'; +import { + z, + db, + Prisma, + Staff, + TransformSchema +} from '@nicestack/common'; +import * as dayjs from 'dayjs'; +import { StaffService } from '../models/staff/staff.service'; +import { DepartmentService } from '../models/department/department.service'; +import { TaxonomyService } from '@server/models/taxonomy/taxonomy.service'; + +class TreeNode { + value: string; + children: TreeNode[]; + + constructor(value: string) { + this.value = value; + this.children = []; + } + + addChild(childValue: string): TreeNode { + let newChild = undefined; + if (this.children.findIndex((child) => child.value === childValue) === -1) { + newChild = new TreeNode(childValue); + this.children.push(newChild); + } + return this.children.find((child) => child.value === childValue); + } +} @Injectable() -export class TransformService {} +export class TransformService { + constructor( + private readonly departmentService: DepartmentService, + private readonly staffService: StaffService, + private readonly taxonomyService: TaxonomyService, + ) { } + private readonly logger = new Logger(TransformService.name); + + excelDateToISO(excelDate: number) { + // 设置 Excel 序列号的起点 + const startDate = dayjs('1899-12-31'); + // 加上 Excel 中的天数(注意必须减去2,因为 Excel 错误地把1900年当作闰年) + const date = startDate.add(excelDate, 'day'); + // 转换为 ISO 字符串 + return date.toDate(); + } + async getDepts(domainId: string, cellStr: string) { + const pattern = /[\s、,,;.。;\n]+/; + const depts: string[] = []; + if (pattern.test(cellStr)) { + const deptNames = cellStr.split(pattern); + for (const name of deptNames) { + const dept = await this.departmentService.findByNameInDom( + domainId, + name, + ); + if (dept) depts.push(dept.id); + } + } else { + const dept = await this.departmentService.findByNameInDom( + domainId, + cellStr, + ); + if (dept) depts.push(dept.id); + } + + if (depts.length === 0) { + this.logger.error(`未找到单位:${cellStr}`); + } + return depts; + } + async getStaffs(deptIds: string[], cellStr: string) { + const staffs: string[] = []; + const pattern = /[\s、,,;.。;\n]+/; + const allStaffsArrays = await Promise.all( + deptIds.map((deptId) => this.staffService.findByDept({ deptId })), + ); + const combinedStaffs = allStaffsArrays.reduce( + (acc, curr) => acc.concat(curr), + [], + ); + if (pattern.test(cellStr)) { + const staffNames = cellStr.split(pattern); + + for (const name of staffNames) { + if (combinedStaffs.map((staff, index) => staff.showname).includes(name)) { + const staffWithName = combinedStaffs.find( + (staff) => staff.showname === name, + ); + if (staffWithName) { + // 将该员工的 ID 添加到 staffIds 数组中 + staffs.push(staffWithName.id); + } + } + // if (staff) staffs.push(staff.staffId); + } + } else { + // const staff = await this.lanxin.getStaffsByDepartment(deptIds); + // if (staff) staffs.push(staff.staffId); + if (combinedStaffs.map((staff, index) => staff.showname).includes(cellStr)) { + const staffWithName = combinedStaffs.find( + (staff) => staff.showname === cellStr, + ); + if (staffWithName) { + // 将该员工的 ID 添加到 staffIds 数组中 + staffs.push(staffWithName.id); + } + } + } + if (staffs.length === 0) { + this.logger.error(`未找到人员:${cellStr}`); + } + return staffs; + } + + buildTree(data: string[][]): TreeNode { + const root = new TreeNode('root'); + try { + for (const path of data) { + let currentNode = root; + for (const value of path) { + currentNode = currentNode.addChild(value); + } + } + return root; + } catch (error) { + console.error(error); + } + } + async generateTreeFromFile(file: Buffer): Promise<{ tree: TreeNode }> { + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(file); + const worksheet = workbook.getWorksheet(1); + + const data: string[][] = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { + // Skip header row if any + try { + const rowData: string[] = (row.values as string[]) + .slice(2) + .map((cell) => (cell || '').toString()); + data.push(rowData.map((value) => value.trim())); + } catch (error) { + throw new Error(`发生报错!位于第${rowNumber}行,错误: ${error}`); + } + } + }); + // Fill forward values + for (let i = 1; i < data.length; i++) { + for (let j = 0; j < data[i].length; j++) { + if (!data[i][j]) data[i][j] = data[i - 1][j]; + } + } + return { tree: this.buildTree(data) }; + } + printTree(node: TreeNode, level: number = 0): void { + const indent = ' '.repeat(level); + console.log(`${indent}${node.value}`); + for (const child of node.children) { + this.printTree(child, level + 1); + } + } + swapKeyValue>( + input: T, + ): { [K in T[keyof T]]: Extract } { + const result: Partial<{ [K in T[keyof T]]: Extract }> = {}; + for (const key in input) { + if (Object.prototype.hasOwnProperty.call(input, key)) { + const value = input[key]; + result[value] = key; + } + } + return result as { [K in T[keyof T]]: Extract }; + } + isEmptyRow(row: any) { + return row.every((cell: any) => { + return !cell || cell.toString().trim() === ''; + }); + } + + async importStaffs(data: z.infer) { + const { base64, domainId } = data; + this.logger.log('开始'); + const buffer = Buffer.from(base64, 'base64'); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(buffer); + const importsStaffSchema = z.object({ + name: z.string(), + phoneNumber: z.string().regex(/^\d+$/), // Assuming phone numbers should be numeric + deptName: z.string(), + }); + const worksheet = workbook.getWorksheet(1); // Assuming the data is in the first sheet + const staffs: { name: string; phoneNumber: string; deptName: string }[] = + []; + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { + // Assuming the first row is headers + const name = row.getCell(1).value as string; + const phoneNumber = row.getCell(2).value as string; + const deptName = row.getCell(3).value as string; + try { + importsStaffSchema.parse({ name, phoneNumber, deptName }); + staffs.push({ name, phoneNumber, deptName }); + } catch (error) { + throw new Error(`发生报错!位于第${rowNumber}行,错误: ${error}`); + } + } + }); + // 获取所有唯一的部门名称 + const uniqueDeptNames = [...new Set(staffs.map((staff) => staff.deptName))]; + // 获取所有部门名称对应的部门ID + const deptIdsMap = await this.departmentService.getDeptIdsByNames( + uniqueDeptNames, + domainId, + ); + const count = await db.staff.count(); + // 为员工数据添加部门ID + const staffsToCreate = staffs.map((staff, index) => ({ + showname: staff.name, + username: staff.phoneNumber, + phoneNumber: staff.phoneNumber, + password: "123456", + deptId: deptIdsMap[staff.deptName], + domainId, + order: index + count, + })); + // 批量创建员工数据 + const createdStaffs = await db.staff.createMany({ + data: staffsToCreate, + }); + return createdStaffs; + } + async importTerms( + staff: Staff, + data: z.infer, + ) { + const { base64, domainId, taxonomyId, parentId } = data; + this.logger.log('开始'); + const buffer = Buffer.from(base64, 'base64'); + const { tree: root } = await this.generateTreeFromFile(buffer); + + this.printTree(root); + + const termsData: Prisma.TermCreateManyInput[] = []; + const termAncestriesData: Prisma.TermAncestryCreateManyInput[] = []; + if (!taxonomyId) { + throw new Error('未指定分类!'); + } + this.logger.log('存在taxonomyId'); + const taxonomy = await db.taxonomy.findUnique({ + where: { id: taxonomyId }, + }); + if (!taxonomy) { + throw new Error('未找到对应分类'); + } + const count = await db.term.count({ where: { taxonomyId: taxonomyId } }); + let termIndex = 0; + this.logger.log(count); + + const gatherTermsData = async (nodes: TreeNode[], depth = 0) => { + let currentIndex = 0; + console.log(`depth:${depth}`); + for (const node of nodes) { + const termData = { + name: node.value, + taxonomyId: taxonomyId, + domainId: domainId, + createdBy: staff.id, + order: count + termIndex + 1, + }; + termsData.push(termData); + termIndex++; + // Debug: Log term data preparation + console.log(`Prepared Term Data:`, termData); + + await gatherTermsData(node.children, depth + 1); + currentIndex++; + } + }; + await gatherTermsData(root.children); + console.log('最后准备的数据 Terms Data:', termsData); + let createdTerms: { id: string; name: string }[] = []; + try { + createdTerms = await db.term.createManyAndReturn({ + data: termsData, + select: { id: true, name: true }, + }); + // Debug: Log created terms + console.log('创建的Terms:', createdTerms); + } catch (error) { + console.error('创建Terms报错:', error); + throw new Error('创建失败'); + } + const termsUpdate = []; + + const gatherAncestryData = ( + nodes: TreeNode[], + ancestors: string[] = parentId ? [parentId] : [], + depth = 0, + ) => { + let currentIndex = 0; + + console.log(`depth:${depth}`); + for (const node of nodes) { + // if (depth !== 0) { + const dept = createdTerms.find((dept) => dept.name === node.value); + if (dept) { + termsUpdate.push({ + where: { id: dept.id }, + data: { parentId: ancestors[ancestors.length - 1] }, + }); + for (let i = 0; i < ancestors.length; i++) { + const ancestryData = { + ancestorId: ancestors[i], + descendantId: dept.id, + relDepth: depth - i, + }; + termAncestriesData.push(ancestryData); + console.log(`准备好的闭包表数据DeptAncestryData:`, ancestryData); + } + const newAncestors = [...ancestors, dept.id]; + gatherAncestryData(node.children, newAncestors, depth + 1); + } + currentIndex++; + } + + // console.log(`depth:${depth}`); + // for (const node of nodes) { + // if (depth !== 0) { + // const term = createdTerms.find((term) => term.name === node.value); + // if (term) { + // termsUpdate.push({ + // where: { id: term.id }, + // data: { parentId: ancestors[ancestors.length - 1] }, + // }); + // for (let i = 0; i < ancestors.length; i++) { + // const ancestryData = { + // ancestorId: ancestors[i], + // descendantId: term.id, + // relDepth: depth - i, + // }; + // termAncestriesData.push(ancestryData); + // console.log(`准备好的闭包表数据ATermAncestryData:`, ancestryData); + // } + // const newAncestors = [...ancestors, term.id]; + // gatherAncestryData(node.children, newAncestors, depth + 1); + // } + // } else { + // gatherAncestryData( + // node.children, + // [createdTerms.find((term) => term.name === node.value).id], + // depth + 1, + // ); + // } + // currentIndex++; + // } + }; + gatherAncestryData(root.children); + + this.logger.log('准备好闭包表数据 Ancestries Data:', termAncestriesData); + try { + const updatePromises = termsUpdate.map((update) => + db.term.update(update), + ); + await Promise.all(updatePromises); + await db.termAncestry.createMany({ data: termAncestriesData }); + + console.log('Term闭包表 已创建:', termAncestriesData.length); + return { count: createdTerms.length }; + } catch (error) { + console.error('Error 更新Term或者创建Terms闭包表失败:', error); + throw new Error('更新术语信息或者创建术语闭包表失败'); + } + } + async importDepts( + staff: Staff, + data: z.infer, + ) { + const { base64, domainId, parentId } = data; + + this.logger.log('开始', parentId); + const buffer = Buffer.from(base64, 'base64'); + const { tree: root } = await this.generateTreeFromFile(buffer); + + this.printTree(root); + + const deptsData: Prisma.DepartmentCreateManyInput[] = []; + const deptAncestriesData: Prisma.DeptAncestryCreateManyInput[] = []; + const count = await db.department.count({ where: {} }); + let deptIndex = 0; + // this.logger.log(count); + + const gatherDeptsData = async ( + nodes: TreeNode[], + depth = 0, + dept?: string, + ) => { + let currentIndex = 0; + // console.log(`depth:${depth}`); + for (const node of nodes) { + const deptData = { + name: node.value, + // taxonomyId: taxonomyId, + // domainId: domainId, + // createdBy: staff.id, + + order: count + deptIndex + 1, + }; + deptsData.push(deptData); + deptIndex++; + // Debug: Log term data preparation + console.log(`Prepared Dept Data:`, deptData); + + await gatherDeptsData(node.children, depth + 1); + currentIndex++; + } + }; + await gatherDeptsData(root.children); + console.log('最后准备的数据 Depts Data:', deptsData); + let createdDepts: { id: string; name: string }[] = []; + try { + createdDepts = await db.department.createManyAndReturn({ + data: deptsData, + select: { id: true, name: true }, + }); + // Debug: Log created terms + console.log('创建的Depts:', createdDepts); + } catch (error) { + console.error('创建Depts报错:', error); + throw new Error('创建失败'); + } + const deptsUpdate = []; + const gatherAncestryData = ( + nodes: TreeNode[], + ancestors: string[] = parentId ? [parentId] : [], + depth = 0, + ) => { + let currentIndex = 0; + console.log(`depth:${depth}`); + for (const node of nodes) { + // if (depth !== 0) { + const dept = createdDepts.find((dept) => dept.name === node.value); + if (dept) { + deptsUpdate.push({ + where: { id: dept.id }, + data: { parentId: ancestors[ancestors.length - 1] }, + }); + for (let i = 0; i < ancestors.length; i++) { + const ancestryData = { + ancestorId: ancestors[i], + descendantId: dept.id, + relDepth: depth - i, + }; + deptAncestriesData.push(ancestryData); + console.log(`准备好的闭包表数据DeptAncestryData:`, ancestryData); + } + const newAncestors = [...ancestors, dept.id]; + gatherAncestryData(node.children, newAncestors, depth + 1); + } + // } + // else { + // const dept = createdDepts.find((dept) => dept.name === node.value); + + // gatherAncestryData( + // node.children, + // [createdDepts.find((dept) => dept.name === node.value).id], + // depth + 1, + // ); + // } + currentIndex++; + } + }; + gatherAncestryData(root.children); + + this.logger.log('准备好闭包表数据 Ancestries Data:', deptAncestriesData); + try { + const updatePromises = deptsUpdate.map((update) => + db.department.update(update), + ); + await Promise.all(updatePromises); + await db.deptAncestry.createMany({ data: deptAncestriesData }); + console.log('Dept闭包表 已创建:', deptAncestriesData.length); + return { count: createdDepts.length }; + } catch (error) { + console.error('Error 更新Dept或者创建Depts闭包表失败:', error); + throw new Error('更新单位信息或者创建单位闭包表失败'); + } + } + +} diff --git a/apps/server/src/trpc/trpc.module.ts b/apps/server/src/trpc/trpc.module.ts index c974d3e..8113006 100644 --- a/apps/server/src/trpc/trpc.module.ts +++ b/apps/server/src/trpc/trpc.module.ts @@ -7,9 +7,12 @@ import { StaffRouter } from '@server/models/staff/staff.router'; import { StaffModule } from '../models/staff/staff.module'; import { DepartmentModule } from '@server/models/department/department.module'; import { TransformModule } from '@server/transform/transform.module'; +import { TermModule } from '@server/models/term/term.module'; +import { TaxonomyModule } from '@server/models/taxonomy/taxonomy.module'; +import { RbacModule } from '@server/rbac/rbac.module'; @Module({ - imports: [StaffModule, DepartmentModule, TransformModule], + imports: [StaffModule, DepartmentModule, TransformModule, TermModule, TaxonomyModule, RbacModule], controllers: [], providers: [TrpcService, TrpcRouter], }) diff --git a/apps/server/src/trpc/trpc.router.ts b/apps/server/src/trpc/trpc.router.ts index f06997b..1219b86 100644 --- a/apps/server/src/trpc/trpc.router.ts +++ b/apps/server/src/trpc/trpc.router.ts @@ -1,20 +1,34 @@ import { INestApplication, Injectable } from '@nestjs/common'; +import { TransformRouter } from '@server/transform/transform.router'; import { DepartmentRouter } from '@server/models/department/department.router'; import { StaffRouter } from '@server/models/staff/staff.router'; import { TrpcService } from '@server/trpc/trpc.service'; import * as trpcExpress from '@trpc/server/adapters/express'; -import { TransformRouter } from '../transform/transform.router'; +import { TaxonomyRouter } from '@server/models/taxonomy/taxonomy.router'; +import { TermRouter } from '@server/models/term/term.router'; +import { RoleRouter } from '@server/rbac/role.router'; +import { RoleMapRouter } from '@server/rbac/rolemap.router'; + @Injectable() export class TrpcRouter { - constructor(private readonly trpc: TrpcService, - private readonly staff: StaffRouter, + constructor( + private readonly trpc: TrpcService, private readonly department: DepartmentRouter, - private readonly transform: TransformRouter + private readonly staff: StaffRouter, + private readonly term: TermRouter, + private readonly taxonomy: TaxonomyRouter, + private readonly role: RoleRouter, + private readonly rolemap: RoleMapRouter, + private readonly transform: TransformRouter, ) { } appRouter = this.trpc.router({ - staff: this.staff.router, + transform: this.transform.router, department: this.department.router, - transform: this.transform.router + staff: this.staff.router, + term: this.term.router, + taxonomy: this.taxonomy.router, + role: this.role.router, + rolemap: this.rolemap.router, }); async applyMiddleware(app: INestApplication) { app.use( @@ -28,4 +42,3 @@ export class TrpcRouter { } export type AppRouter = TrpcRouter[`appRouter`]; - diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf new file mode 100644 index 0000000..ac02f6a --- /dev/null +++ b/apps/web/nginx.conf @@ -0,0 +1,10 @@ +server { + listen 80; + server_name localhost; + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } +} + diff --git a/apps/web/package.json b/apps/web/package.json index 9555266..0be0234 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@ant-design/icons": "^5.4.0", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@nicestack/common": "workspace:^", "@tanstack/query-async-storage-persister": "^5.51.9", "@tanstack/react-query": "^5.51.1", @@ -19,12 +24,15 @@ "@trpc/client": "11.0.0-rc.456", "@trpc/react-query": "11.0.0-rc.456", "@trpc/server": "11.0.0-rc.456", + "antd": "^5.20.6", "axios": "^1.7.3", + "browser-image-compression": "^2.0.2", "idb-keyval": "^6.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.1", "superjson": "^2.2.1", + "tus-js-client": "^4.1.0", "zod": "^3.23.8", "zustand": "^4.5.5" }, diff --git a/apps/web/src/App.css b/apps/web/src/App.css index b9d355d..e69de29 100644 --- a/apps/web/src/App.css +++ b/apps/web/src/App.css @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index edfd265..0b2a473 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,12 +5,15 @@ import { import QueryProvider from './providers/query-provider' import { router } from './routes'; import { AuthProvider } from './providers/auth-provider'; +import ThemeProvider from './providers/theme-provider'; function App() { return ( - + + + ) diff --git a/apps/web/src/app/admin/department/page.tsx b/apps/web/src/app/admin/department/page.tsx new file mode 100644 index 0000000..3fc7751 --- /dev/null +++ b/apps/web/src/app/admin/department/page.tsx @@ -0,0 +1,7 @@ +import DepartmentList from "@web/src/components/models/department/department-list"; + +export default function DepartmentAdminPage() { + return
+ +
+} \ No newline at end of file diff --git a/apps/web/src/app/admin/role/page.tsx b/apps/web/src/app/admin/role/page.tsx new file mode 100644 index 0000000..d63cbda --- /dev/null +++ b/apps/web/src/app/admin/role/page.tsx @@ -0,0 +1,30 @@ + +import RoleList from "@web/src/components/models/role/role-list"; +import RoleMapTable from "@web/src/components/models/role/role-map-table"; +import { Divider, Empty } from "antd"; +import { useState } from "react"; +export default function RoleAdminPage() { + const [roleId, setRoleId] = useState(undefined); + const [roleName, setRoleName] = useState(undefined); + return ( +
+
+ { + console.log(id); + setRoleId(id); + setRoleName(name); + }}> +
+ +
+ {roleId && ( + + )} + {!roleId && } +
+
+ ); +} diff --git a/apps/web/src/app/admin/staff/page.tsx b/apps/web/src/app/admin/staff/page.tsx new file mode 100644 index 0000000..c3e712f --- /dev/null +++ b/apps/web/src/app/admin/staff/page.tsx @@ -0,0 +1,6 @@ +import StaffTable from "@web/src/components/models/staff/staff-table"; +export default function StaffAdminPage() { + return
+ +
+} \ No newline at end of file diff --git a/apps/web/src/app/admin/term/page.tsx b/apps/web/src/app/admin/term/page.tsx new file mode 100644 index 0000000..416119c --- /dev/null +++ b/apps/web/src/app/admin/term/page.tsx @@ -0,0 +1,11 @@ +import TaxonomyTable from "@web/src/components/models/taxonomy/taxonomy-table"; +import TermList from "@web/src/components/models/term/term-list"; + +export default function TermAdminPage() { + return
+
+ +
+
+
+} \ No newline at end of file diff --git a/apps/web/src/app/login.tsx b/apps/web/src/app/login.tsx index f9e1c16..bf3560d 100644 --- a/apps/web/src/app/login.tsx +++ b/apps/web/src/app/login.tsx @@ -1,3 +1,162 @@ -export default function LoginPage() { - return 'LoginPage' -} \ No newline at end of file +import React, { useState, useRef, useEffect } from 'react'; +import { Form, Input, Button, message } from 'antd'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import DepartmentSelect from '../components/models/department/department-select'; +import { useAuth } from '../providers/auth-provider'; +import SineWave from '../components/presentation/animation/sine-wave'; + +const LoginPage: React.FC = () => { + const [showLogin, setShowLogin] = useState(true); + const [registerLoading, setRegisterLoading] = useState(false); + const { login, isAuthenticated } = useAuth(); + const loginFormRef = useRef(null); + const registerFormRef = useRef(null); + const location = useLocation(); + const navigate = useNavigate() + const onFinishLogin = async (values: any) => { + try { + const { username, password } = values; + await login(username, password); + message.success('登录成功!'); + } catch (err) { + message.warning('用户名或密码错误!'); + console.error(err); + } + }; + + const onFinishRegister = async (values: any) => { + setRegisterLoading(true); + try { + // await wp.RegisterUser().create({ + // ...values, + // custom_data: { org_unit: values.org_unit }, + // }); + message.success('注册成功!'); + setShowLogin(true); + loginFormRef.current.submit(); + } catch (err) { + console.error(err); + } finally { + setRegisterLoading(false); + } + }; + + useEffect(() => { + if (isAuthenticated) { + const params = new URLSearchParams(location.search); + const redirectUrl = params.get('redirect_url') || '/'; + navigate(redirectUrl, { replace: true }); + } + }, [isAuthenticated, location]); + + return ( +
+
+
+ {showLogin ? ( + <> + + 返回首页 + +
+ 登录 +
+
+ + + + + + + +
+ +
+
+ + ) : ( + <> +
setShowLogin(true)} + className="text-sm text-gray-400 hover:cursor-pointer hover:text-primary" + > + 返回登录 +
+
注册
+
+ + + + + + + + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('两次输入的密码不一致')); + } + }) + ]}> + + + +
+ +
+
+ + )} +
+
+ {showLogin ? ( +
+ +
没有账号?
+
点击注册一个属于你自己的账号吧!
+
setShowLogin(false)} + className="hover:translate-y-1 my-8 p-2 text-center rounded-xl border-white border hover:bg-white hover:text-primary hover:shadow-xl hover:cursor-pointer transition-all" + > + 注册 +
+
+ ) : ( +
+
注册小贴士
+
请认真填写用户信息哦!
+
setShowLogin(true)} + className="hover:translate-y-1 my-8 rounded-xl text-center border-white border p-2 hover:bg-white hover:text-primary hover:shadow-xl hover:cursor-pointer transition-all" + > + 返回登录 +
+ +
+ )} +
+
+
+ ); +}; + +export default LoginPage; diff --git a/apps/web/src/components/models/department/department-drawer.tsx b/apps/web/src/components/models/department/department-drawer.tsx new file mode 100644 index 0000000..f4c3c04 --- /dev/null +++ b/apps/web/src/components/models/department/department-drawer.tsx @@ -0,0 +1,40 @@ +import { Button, Drawer } from "antd"; +import React, { useState } from "react"; +import type { ButtonProps } from "antd"; +import { Department } from "@nicestack/common"; +import DepartmentForm from "./department-form"; + +interface DepartmentDrawerProps extends ButtonProps { + title: string; + data?: Partial; + parentId?: string; +} + +export default function DepartmentDrawer({ + data, + parentId, + title, + ...buttonProps +}: DepartmentDrawerProps) { + const [open, setOpen] = useState(false); + + const handleTrigger = () => { + setOpen(true); + }; + + return ( + <> + + { + setOpen(false); + }} + title={title} + width={400} + > + + + + ); +} diff --git a/apps/web/src/components/models/department/department-form.tsx b/apps/web/src/components/models/department/department-form.tsx new file mode 100644 index 0000000..8a99842 --- /dev/null +++ b/apps/web/src/components/models/department/department-form.tsx @@ -0,0 +1,62 @@ +import { Button, Form, Input, InputNumber, Checkbox } from "antd"; +import { FormInstance } from "antd"; +import { useEffect, useRef, useState } from "react"; +import { Department } from "@nicestack/common"; +import { useDepartment } from "@web/src/hooks/useDepartment"; +import DepartmentSelect from "./department-select"; + +export default function DepartmentForm({ + data = undefined, + parentId, +}: { + data?: Partial; + parentId?: string; +}) { + const { create, update, addFetchParentId } = useDepartment(); + const [loading, setLoading] = useState(false); + const formRef = useRef(null); + useEffect(() => { + if (parentId) formRef.current?.setFieldValue("parentId", parentId); + }, [parentId]); + return ( +
{ + setLoading(true); + addFetchParentId(values.parentId); + console.log(values) + if (data) { + console.log(values); + await update.mutateAsync({ id: data.id, ...values }); + } else { + await create.mutateAsync(values); + formRef.current?.resetFields(); + if (parentId) formRef.current?.setFieldValue("parentId", parentId); + } + setLoading(false); + }} + > + + + + + + + + + + + 是否为域 + +
+ +
+
+ ); +} diff --git a/apps/web/src/components/models/department/department-import-drawer.tsx b/apps/web/src/components/models/department/department-import-drawer.tsx new file mode 100644 index 0000000..42a08c9 --- /dev/null +++ b/apps/web/src/components/models/department/department-import-drawer.tsx @@ -0,0 +1,63 @@ +import { Button, Drawer, Form } from "antd"; +import React, { useRef, useState } from "react"; +import type { ButtonProps, FormInstance } from "antd"; +import { Department } from "@nicestack/common"; +import { ExcelImporter } from "../../utilities/excel-importer"; +import DepartmentSelect from "./department-select"; + + +interface DepartmentDrawerProps extends ButtonProps { + title: string; + data?: Partial; + parentId?: string; +} + +export default function DepartmentImportDrawer({ + data, + parentId, + title, + ...buttonProps +}: DepartmentDrawerProps) { + const [open, setOpen] = useState(false); + const [deptParentId, setDeptParentId] = useState( + parentId ? parentId : undefined + ); + const formRef = useRef(null); + const handleTrigger = () => { + setOpen(true); + }; + + return ( + <> + + { + setOpen(false); + }} + title={title} + width={400}> +
+ + + setDeptParentId(value as string) + }> + +
+ +
+
+
+ + ); +} diff --git a/apps/web/src/components/models/department/department-list.tsx b/apps/web/src/components/models/department/department-list.tsx new file mode 100644 index 0000000..ec633a2 --- /dev/null +++ b/apps/web/src/components/models/department/department-list.tsx @@ -0,0 +1,211 @@ +import React, { useEffect, useState } from "react"; +import { Button, Empty, Tree } from "antd"; + +import { + BranchesOutlined, + DownOutlined, + NodeIndexOutlined, + PlusOutlined, +} from "@ant-design/icons"; +import { DataNode } from "@nicestack/common"; +import { useDepartment } from "@web/src/hooks/useDepartment"; +import DepartmentDrawer from "./department-drawer"; +import DepartmentImportDrawer from "./department-import-drawer"; + +export default function DepartmentList() { + const [customTreeData, setCustomTreeData] = useState([]); + const { treeData, addFetchParentId, update, deleteDepartment } = + useDepartment(); + + useEffect(() => { + if (treeData) { + const processedTreeData = processTreeData(treeData); + setCustomTreeData(processedTreeData); + } + }, [treeData]); + + const renderTitle = (node: DataNode) => ( +
+ + {node.data.isDomain && } + {node.title} + +
+ + + } + title="子节点" + parentId={node.key} + /> + + + +
+
+ ); + + const processTreeData = (nodes: DataNode[]): DataNode[] => { + return nodes.map((node) => ({ + ...node, + title: renderTitle(node), + children: + node.children && node.children.length > 0 + ? processTreeData(node.children) + : [], + })); + }; + + const onLoadData = async ({ key }: any) => { + console.log(key); + addFetchParentId(key); + }; + + const onExpand = ( + expandedKeys: React.Key[], + { expanded, node }: { expanded: boolean; node: any } + ) => { + if (expanded) { + addFetchParentId(node.key); + } + }; + + const onDrop = async (info: any) => { + console.log(info); + + const dropKey = info.node.key; + const dragKey = info.dragNode.key; + + const dropPos = info.node.pos.split("-"); + const dropPosition = + info.dropPosition - Number(dropPos[dropPos.length - 1]); + console.log(dropPosition); + + const loop = ( + data: DataNode[], + key: React.Key, + callback: (node: DataNode, i: number, data: DataNode[]) => void + ) => { + for (let i = 0; i < data.length; i++) { + if (data[i].key === key) { + return callback(data[i], i, data); + } + if (data[i].children) { + loop(data[i].children!, key, callback); + } + } + }; + + const data = [...customTreeData]; + let dragObj: DataNode | undefined; + loop(data, dragKey, (item, index, arr) => { + arr.splice(index, 1); + dragObj = item; + }); + + let parentNodeId: any = null; + let siblings: DataNode[] = []; + + if (!info.dropToGap) { + loop(data, dropKey, (item) => { + item.children = item.children || []; + item.children.unshift(dragObj!); + parentNodeId = item.key; + siblings = item.children; + }); + } else if ( + (info.node.children || []).length > 0 && + info.node.expanded && + dropPosition === 1 + ) { + loop(data, dropKey, (item) => { + item.children = item.children || []; + item.children.unshift(dragObj!); + parentNodeId = item.key; + siblings = item.children; + }); + } else { + let ar: DataNode[] = []; + let i: number = 0; + loop(data, dropKey, (item, index, arr) => { + ar = arr; + i = index; + }); + + if (dropPosition === -1) { + ar.splice(i, 0, dragObj!); + } else { + ar.splice(i + 1, 0, dragObj!); + } + + parentNodeId = ar[0].data.parentId || null; + siblings = ar; + } + + setCustomTreeData(data); + + const { id } = dragObj!.data; + console.log(JSON.parse(JSON.stringify(siblings))); + + const updatePromises = siblings.map((sibling, idx) => { + return update.mutateAsync({ + id: sibling.data.id, + order: idx, + parentId: parentNodeId, + }); + }); + + await Promise.all(updatePromises); + console.log( + `Updated node ${id} and its siblings with new order and parentId ${parentNodeId}` + ); + }; + + const onDragEnter = () => { }; + + return ( +
+
+ + +
+ {customTreeData.length > 0 ? ( + } + onExpand={onExpand} + /> + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/models/department/department-select.tsx b/apps/web/src/components/models/department/department-select.tsx new file mode 100644 index 0000000..09ec3d0 --- /dev/null +++ b/apps/web/src/components/models/department/department-select.tsx @@ -0,0 +1,144 @@ +import { Button, TreeSelect, TreeSelectProps } from "antd"; +import { useEffect, useState } from "react"; +import { DataNode, findNodeByKey } from "@nicestack/common"; +import { useDepartment } from "@web/src/hooks/useDepartment"; +import { api } from "@web/src/utils/trpc"; + +interface DepartmentSelectProps { + defaultValue?: string | string[]; + value?: string | string[]; + onChange?: (value: string | string[]) => void; + width?: number | string; + placeholder?: string; + multiple?: boolean; + rootId?: string; + extraOptions?: { value: string | undefined; label: string }[]; +} + +export default function DepartmentSelect({ + defaultValue, + value, + onChange, + width = "100%", + placeholder = "选择单位", + multiple = false, + rootId, +}: DepartmentSelectProps) { + const { treeData, addFetchParentId } = useDepartment(); + api.useQueries((t) => { + if (Array.isArray(defaultValue)) { + return defaultValue?.map((id) => + t.department.getDepartmentDetails({ deptId: id }) + ); + } else { + return []; + } + }); + const [filteredTreeData, setFilteredTreeData] = useState([]); + const [selectedValue, setSelectedValue] = useState(() => { + if (value) { + if (Array.isArray(value)) { + return value.map((item) => ({ + label: item, + value: item, + })); + } else { + return { label: value, value: value }; + } + } + return undefined; // 如果没有提供defaultValue,返回null或者合适的初始值 + }); + + const findNodeByKey = (data: DataNode[], key: string): DataNode | null => { + for (let node of data) { + if (node.key === key) return node; + if (node.children) { + const found = findNodeByKey(node.children, key); + if (found) return found; + } + } + return null; + }; + + useEffect(() => { + if (rootId && treeData.length > 0) { + const rootNode = findNodeByKey(treeData, rootId); + if (rootNode) { + setFilteredTreeData([rootNode]); + } else { + setFilteredTreeData([]); + } + } else { + setFilteredTreeData(treeData); + } + }, [rootId, treeData]); + + useEffect(() => { + if (rootId) { + setSelectedValue(undefined); + addFetchParentId(rootId); + } + }, [rootId]); + + useEffect(() => { + if (defaultValue) { + if (Array.isArray(defaultValue)) { + setSelectedValue( + defaultValue.map((item) => ({ label: item, value: item })) + ); + } else { + setSelectedValue({ label: defaultValue, value: defaultValue }); + } + } + if (value) { + if (Array.isArray(value)) { + setSelectedValue( + value.map((item) => ({ label: item, value: item })) + ); + } else { + setSelectedValue({ label: value, value: value }); + } + } + }, [defaultValue, value]); + + const handleChange = (newValue: any) => { + setSelectedValue(newValue); + if (onChange) { + if (multiple && Array.isArray(newValue)) { + onChange(newValue.map((item) => item.value)); + } else { + onChange(newValue); + } + } + }; + + const onLoadData: TreeSelectProps["loadData"] = async ({ id }) => { + addFetchParentId(id); + }; + + const handleExpand = (expandedKeys: React.Key[]) => { + (expandedKeys as string[]).forEach((id: string) => + addFetchParentId(id) + ); + }; + + return ( + <> + handleChange(multiple ? [] : undefined)} + onTreeExpand={handleExpand} + /> + + ); +} diff --git a/apps/web/src/components/models/domain/domain-select.tsx b/apps/web/src/components/models/domain/domain-select.tsx new file mode 100644 index 0000000..15ad781 --- /dev/null +++ b/apps/web/src/components/models/domain/domain-select.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { Select, Spin } from 'antd'; +import type { SelectProps } from 'antd'; +import { api } from '@web/src/utils/trpc'; + +interface DomainSelectProps { + value?: string; + onChange?: (value: string | undefined) => void; + style?: React.CSSProperties; + showAll?: boolean; // New prop to control inclusion of '全部' +} + +export default function DomainSelect({ value, onChange, style, showAll = false }: DomainSelectProps) { + const [query, setQuery] = useState(''); + const { data, isLoading } = api.department.getDomainDepartments.useQuery({ query }); + + const handleSearch = (value: string) => { + setQuery(value); + }; + + const handleChange = (value: string | undefined) => { + if (onChange) { + if (value === 'all') { + onChange(undefined) + } else { + onChange(value === undefined ? null : value); + } + } + }; + + const options: SelectProps['options'] = [ + ...(showAll ? [{ value: 'all', label: '全部' }] : []), + ...(data?.map((domain: any) => ({ + value: domain.id, + label: domain.name, + })) || []), + ]; + + return ( + + + + : null} + filterOption={false} + onSearch={handleSearch} + options={options} + value={value} + onChange={onChange} + style={{ minWidth: 200, ...style }} + /> + ); +} diff --git a/apps/web/src/components/models/role/rolemap-drawer.tsx b/apps/web/src/components/models/role/rolemap-drawer.tsx new file mode 100644 index 0000000..8d9d627 --- /dev/null +++ b/apps/web/src/components/models/role/rolemap-drawer.tsx @@ -0,0 +1,41 @@ +import { Button, Drawer } from "antd"; +import React, { useState } from "react"; +import type { ButtonProps } from "antd"; +import RoleMapForm from "./rolemap-form"; + +interface RoleMapDrawerProps extends ButtonProps { + title: string; + roleId: string; + domainId?: string; + roleType?: "dept" | "staff" | "both"; +} + +export default function RoleMapDrawer({ + roleId, + title, + domainId, + roleType = "both", + ...buttonProps +}: RoleMapDrawerProps) { + const [open, setOpen] = useState(false); + const handleTrigger = () => { + setOpen(true); + }; + + return ( + <> + + { + setOpen(false); + }} + title={title} + width={400}> + + + + ); +} diff --git a/apps/web/src/components/models/role/rolemap-form.tsx b/apps/web/src/components/models/role/rolemap-form.tsx new file mode 100644 index 0000000..8ff7c5f --- /dev/null +++ b/apps/web/src/components/models/role/rolemap-form.tsx @@ -0,0 +1,104 @@ +import { Button, Form, Input, message } from "antd"; +import { FormInstance } from "antd"; +import { useEffect, useRef, useState } from "react"; +import { ObjectType, Staff } from "@nicestack/common"; // Adjust the import path if necessary +import { useRoleMap } from "@web/src/hooks/useRoleMap"; +import { api } from "@web/src/utils/trpc"; +import DepartmentSelect from "../department/department-select"; +import DomainSelect from "../domain/domain-select"; +import StaffSelect from "../staff/staff-select"; + +export default function RoleMapForm({ + roleId, + domainId, + roleType = "both", +}: { + roleId: string; + domainId?: string; + roleType?: "dept" | "staff" | "both"; +}) { + const { createManyObjects } = useRoleMap(); + const [loading, setLoading] = useState(false); + const [selectedDomainId, setSelectedDomainId] = useState(domainId || null); + const { data } = api.rolemap.getRoleMapDetail.useQuery({ + roleId, + domainId: selectedDomainId ? selectedDomainId : null, + }); + const formRef = useRef(null); + useEffect(() => { + if (domainId) { + setSelectedDomainId(domainId); + formRef.current?.setFieldValue("domainId", domainId); + } + }, [domainId]); + useEffect(() => { + if (data) { + console.log("data.deptIds", data.deptIds); + formRef.current?.setFieldValue("deptIds", data.deptIds); + formRef.current?.setFieldValue("staffIds", data.staffIds); + } + }, [data, domainId]); + return ( +
{ + console.log("Received values:", values); + const { deptIds, staffIds, domainId = null } = values; + setLoading(true); + try { + console.log(deptIds); + if (roleType === "dept" || roleType === "both") { + await createManyObjects.mutateAsync({ + domainId, + objectType: ObjectType.DEPARTMENT, + objectIds: deptIds, + roleId, + }); + } + if (roleType === "staff" || roleType === "both") { + await createManyObjects.mutateAsync({ + domainId, + objectType: ObjectType.STAFF, + objectIds: staffIds, + roleId, + }); + } + } catch (err) { + message.error("更新失败"); + } + setLoading(false); + }}> + + { + setSelectedDomainId(value); + formRef.current?.setFieldValue("domainId", value); + }} + /> + + {(roleType === "staff" || roleType === "both") && ( + + + + )} + {(roleType === "dept" || roleType === "both") && ( + + + + )} +
+ +
+
+ ); +} diff --git a/apps/web/src/components/models/staff/staff-drawer.tsx b/apps/web/src/components/models/staff/staff-drawer.tsx new file mode 100644 index 0000000..a09250d --- /dev/null +++ b/apps/web/src/components/models/staff/staff-drawer.tsx @@ -0,0 +1,41 @@ +import { Button, Drawer } from "antd"; +import React, { useState } from "react"; +import type { ButtonProps } from "antd"; +import { Staff } from "@nicestack/common"; +import StaffForm from "./staff-form"; + +interface StaffDrawerProps extends ButtonProps { + title: string; + data?: Partial; + deptId?: string; + domainId?: string +} + +export default function StaffDrawer({ + data, + deptId, + title, + domainId, + ...buttonProps +}: StaffDrawerProps) { + const [open, setOpen] = useState(false); + const handleTrigger = () => { + setOpen(true); + }; + + return ( + <> + + { + setOpen(false); + }} + title={title} + width={400} + > + + + + ); +} diff --git a/apps/web/src/components/models/staff/staff-form.tsx b/apps/web/src/components/models/staff/staff-form.tsx new file mode 100644 index 0000000..6c3c794 --- /dev/null +++ b/apps/web/src/components/models/staff/staff-form.tsx @@ -0,0 +1,93 @@ +import { Button, Form, Input, message } from "antd"; +import { FormInstance } from "antd"; +import { useEffect, useRef, useState } from "react"; +import { Staff } from "@nicestack/common"; // Adjust the import path if necessary +import DomainSelect from "../domain/domain-select"; +import { useStaff } from "@web/src/hooks/useStaff"; +import DepartmentSelect from "../department/department-select"; + +export default function StaffForm({ + data, + deptId, + domainId +}: { + data?: Partial; + deptId: string; + parentId?: string; + domainId?: string +}) { + const { create, update } = useStaff(); // Ensure you have these methods in your hooks + const [loading, setLoading] = useState(false); + const [selectedDomainId, setSelectedDomainId] = useState(domainId); + const formRef = useRef(null); + + useEffect(() => { + + if (deptId) formRef.current?.setFieldValue("deptId", deptId); + }, [deptId]); + + useEffect(() => { + if (domainId) { + formRef.current?.setFieldValue("domainId", domainId); + setSelectedDomainId(domainId) + } + }, [domainId]); + + return ( +
{ + console.log("Received values:", values); + setLoading(true); + if (data) { + try { + await update.mutateAsync({ id: data.id, ...values }); + } catch (err) { + message.error("更新失败"); + } + } else { + try { + await create.mutateAsync(values); + formRef.current?.resetFields(); + if (deptId) + formRef.current?.setFieldValue("deptId", deptId); + if (domainId) + formRef.current?.setFieldValue("domainId", domainId); + } catch (err) { + message.error("创建失败"); + } + } + setLoading(false); + }} + > + + + + + + + + + { + setSelectedDomainId(value); + formRef.current?.setFieldValue('domainId', value); + }} + /> + + + + + + +
+ +
+
+ ); +} diff --git a/apps/web/src/components/models/staff/staff-import-drawer.tsx b/apps/web/src/components/models/staff/staff-import-drawer.tsx new file mode 100644 index 0000000..f50f630 --- /dev/null +++ b/apps/web/src/components/models/staff/staff-import-drawer.tsx @@ -0,0 +1,73 @@ +import { Button, Drawer, Form } from "antd"; +import React, { useEffect, useRef, useState } from "react"; +import type { ButtonProps, FormInstance } from "antd"; +import { Term } from "@nicestack/common"; +import DomainSelect from "../domain/domain-select"; +import { ExcelImporter } from "../../utilities/excel-importer"; + + +interface TermDrawerProps extends ButtonProps { + title: string; + data?: Partial; + parentId?: string; + + domainId?: string; +} + +export default function StaffImportDrawer({ + data, + + title, + + domainId, + ...buttonProps +}: TermDrawerProps) { + const [open, setOpen] = useState(false); + const handleTrigger = () => { + setOpen(true); + }; + + const [staffDomainId, setStaffDomainId] = useState( + domainId + ); + + const formRef = useRef(null); + useEffect(() => { + if (domainId) { + formRef.current?.setFieldValue("domainId", domainId); + setStaffDomainId(domainId); + } + }, [domainId]); + return ( + <> + + { + setOpen(false); + }} + title={title} + width={400}> +
+ + { + setStaffDomainId(value); + }}> + +
+
+ +
+
+ + ); +} diff --git a/apps/web/src/components/models/staff/staff-select.tsx b/apps/web/src/components/models/staff/staff-select.tsx new file mode 100644 index 0000000..96763fe --- /dev/null +++ b/apps/web/src/components/models/staff/staff-select.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import { Select, Spin } from 'antd'; +import type { SelectProps } from 'antd'; +import { api } from '@web/src/utils/trpc'; + +interface StaffSelectProps { + value?: string | string[]; + onChange?: (value: string | string[]) => void; + style?: React.CSSProperties; + multiple?: boolean; + domainId?: string +} + +export default function StaffSelect({ value, onChange, style, multiple, domainId }: StaffSelectProps) { + const [keyword, setQuery] = useState(''); + + // Determine ids based on whether value is an array or not + const ids = Array.isArray(value) ? value : undefined; + + // Adjust the query to include ids when they are present + const { data, isLoading } = api.staff.findMany.useQuery({ keyword, domainId, ids }); + + const handleSearch = (value: string) => { + setQuery(value); + }; + + const options: SelectProps['options'] = data?.map((staff: any) => ({ + value: staff.id, + label: staff.showname, + })) || []; + + return ( + +
+ {/* + + */} +
+ +
+ +} \ No newline at end of file diff --git a/apps/web/src/components/models/taxonomy/taxonomy-select.tsx b/apps/web/src/components/models/taxonomy/taxonomy-select.tsx new file mode 100644 index 0000000..c726a7a --- /dev/null +++ b/apps/web/src/components/models/taxonomy/taxonomy-select.tsx @@ -0,0 +1,63 @@ +import { api } from "@web/src/utils/trpc"; +import { Select } from "antd"; +import React from "react"; +import { useEffect, useState } from "react"; + +// 定义组件的 props 类型 +interface TaxonomySelectProps { + defaultValue?: string; + value?: string; + onChange?: (value: string) => void; + width?: number | string; // 修改类型,支持百分比 + placeholder?: string; + extraOptions?: { value: string | undefined, label: string }[]; // 新增 extraOptions 属性 +} + +export default function TaxonomySelect({ + defaultValue, + value, + onChange, + width = '100%', // 默认设置为 100% + placeholder = "选择分类", + extraOptions = [] // 默认值为空数组 +}: TaxonomySelectProps) { + const { data: taxonomies, isLoading: isTaxLoading } = api.taxonomy.getAll.useQuery(); + + const [selectedValue, setSelectedValue] = useState(defaultValue); + + // 当 defaultValue 或 value 改变时,将其设置为 selectedValue + useEffect(() => { + if (value !== undefined) { + setSelectedValue(value); + } else if (defaultValue !== undefined) { + setSelectedValue(defaultValue); + } + }, [defaultValue, value]); + + // 内部处理选择变化,并调用外部传入的 onChange 回调(如果有的话) + const handleChange = (newValue: string) => { + setSelectedValue(newValue); + if (onChange) { + onChange(newValue); + } + }; + + return ( + + + {/* + + */} + + + + + + + + + +
+ +
+ + ); +} diff --git a/apps/web/src/components/models/term/term-import-drawer.tsx b/apps/web/src/components/models/term/term-import-drawer.tsx new file mode 100644 index 0000000..2c62a70 --- /dev/null +++ b/apps/web/src/components/models/term/term-import-drawer.tsx @@ -0,0 +1,109 @@ +import { Button, Drawer, Form } from "antd"; +import React, { useEffect, useRef, useState } from "react"; +import type { ButtonProps, FormInstance } from "antd"; +import { Term } from "@nicestack/common"; +import DomainSelect from "../domain/domain-select"; +import TaxonomySelect from "../taxonomy/taxonomy-select"; +import TermSelect from "./term-select"; +import { ExcelImporter } from "../../utilities/excel-importer"; + +interface TermDrawerProps extends ButtonProps { + title: string; + data?: Partial; + parentId?: string; + taxonomyId: string; + domainId?: string; +} + +export default function TermImportDrawer({ + data, + parentId, + title, + taxonomyId, + domainId, + ...buttonProps +}: TermDrawerProps) { + const [open, setOpen] = useState(false); + const handleTrigger = () => { + setOpen(true); + }; + + const [termDomainId, setTermDomainId] = useState( + domainId + ); + const [termTaxonomyId, setTermTaxonomyId] = useState( + taxonomyId + ); + + const [termId, setTermId] = useState(parentId); + const formRef = useRef(null); + useEffect(() => { + if (parentId) { + formRef.current?.setFieldValue("termId", taxonomyId); + setTermId(parentId); + } + }, [parentId]); + useEffect(() => { + if (taxonomyId) { + formRef.current?.setFieldValue("taxonomyId", taxonomyId); + setTermTaxonomyId(taxonomyId); + } + }, [taxonomyId]); + useEffect(() => { + if (domainId) { + formRef.current?.setFieldValue("domainId", domainId); + setTermDomainId(domainId); + } + }, [domainId]); + return ( + <> + + { + setOpen(false); + }} + title={title} + width={400}> +
+ + { + setTermDomainId(value); + }}> + + + { + setTermTaxonomyId(value); + }}> + + + { + setTermId(value); + }} + taxonomyId={termTaxonomyId}> + +
+
+ +
+
+ + ); +} diff --git a/apps/web/src/components/models/term/term-list.tsx b/apps/web/src/components/models/term/term-list.tsx new file mode 100644 index 0000000..f29c816 --- /dev/null +++ b/apps/web/src/components/models/term/term-list.tsx @@ -0,0 +1,254 @@ +import React, { useEffect, useState } from "react"; +import { Empty, Tree, Button, message, TreeProps } from "antd"; +import { PlusOutlined, DownOutlined } from "@ant-design/icons"; +import { useTerm } from "@web/src/hooks/useTerm"; +import { api } from "@web/src/utils/trpc"; + +import DomainSelect from "../domain/domain-select"; +import TaxonomySelect from "../taxonomy/taxonomy-select"; +import TermDrawer from "./term-drawer"; +import TermImportDrawer from "./term-import-drawer"; +import { DataNode } from "@nicestack/common"; + +export default function TermList() { + const [customTreeData, setCustomTreeData] = useState([]); + const [checkedTermIds, setCheckedTermIds] = useState([]); + const { + treeData, + update, + batchDelete, + taxonomyId, + setTaxonomyId, + domainId, + setDomainId, + addFetchParentId, + } = useTerm(); + const { data: taxonomies } = api.taxonomy.getAll.useQuery(); + useEffect(() => { + if (treeData && taxonomyId) { + const processedTreeData = processTreeData(treeData).filter( + (node) => node.data.taxonomyId === taxonomyId + ); + console.log(treeData); + console.log(processedTreeData); + setCustomTreeData(processedTreeData); + } + }, [treeData, taxonomyId]); + + useEffect(() => { + if (taxonomies && taxonomies.length > 0) { + setTaxonomyId(taxonomies[0].id); + } + }, [taxonomies]); + + const renderTitle = (node: DataNode) => ( +
+ {node.title} +
+ } + title="子节点" + parentId={node.key} + /> + + +
+
+ ); + + const processTreeData = (nodes: DataNode[]): DataNode[] => { + return nodes.map((node) => ({ + ...node, + title: renderTitle(node), + children: + node.children && node.children.length > 0 + ? processTreeData(node.children) + : [], + })); + }; + + const onLoadData = async ({ key }: any) => { + console.log(key); + addFetchParentId(key); + }; + + const onDragEnter = () => { }; + + const onDrop = async (info: any) => { + console.log(info); + + const dropKey = info.node.key; + const dragKey = info.dragNode.key; + + const dropPos = info.node.pos.split("-"); + const dropPosition = + info.dropPosition - Number(dropPos[dropPos.length - 1]); + console.log(dropPosition); + + const loop = ( + data: DataNode[], + key: React.Key, + callback: (node: DataNode, i: number, data: DataNode[]) => void + ) => { + for (let i = 0; i < data.length; i++) { + if (data[i].key === key) { + return callback(data[i], i, data); + } + if (data[i].children) { + loop(data[i].children!, key, callback); + } + } + }; + + const data = [...customTreeData]; + let dragObj: DataNode | undefined; + loop(data, dragKey, (item, index, arr) => { + arr.splice(index, 1); + dragObj = item; + }); + + let parentNodeId: any = null; + let siblings: DataNode[] = []; + + if (!info.dropToGap) { + loop(data, dropKey, (item) => { + item.children = item.children || []; + item.children.unshift(dragObj!); + parentNodeId = item.key; + siblings = item.children; + }); + } else if ( + (info.node.children || []).length > 0 && + info.node.expanded && + dropPosition === 1 + ) { + loop(data, dropKey, (item) => { + item.children = item.children || []; + item.children.unshift(dragObj!); + parentNodeId = item.key; + siblings = item.children; + }); + } else { + let ar: DataNode[] = []; + let i: number = 0; + loop(data, dropKey, (item, index, arr) => { + ar = arr; + i = index; + }); + + if (dropPosition === -1) { + ar.splice(i, 0, dragObj!); + } else { + ar.splice(i + 1, 0, dragObj!); + } + + parentNodeId = ar[0].data.parentId || null; + siblings = ar; + } + + setCustomTreeData(data); + + const { id } = dragObj!.data; + console.log(JSON.parse(JSON.stringify(siblings))); + + const updatePromises = siblings.map((sibling, idx) => { + return update.mutateAsync({ + id: sibling.data.id, + order: idx, + parentId: parentNodeId, + }); + }); + + await Promise.all(updatePromises); + console.log( + `Updated node ${id} and its siblings with new order and parentId ${parentNodeId}` + ); + }; + + const onExpand = ( + expandedKeys: React.Key[], + { expanded, node }: { expanded: boolean; node: any } + ) => { + if (expanded) { + addFetchParentId(node.key); + } + }; + + const onCheck: TreeProps["onCheck"] = (checkedKeysValue: any) => { + console.log("onCheck", checkedKeysValue); + setCheckedTermIds(checkedKeysValue.checked); + }; + + const handleBatchDelete = async () => { + try { + await batchDelete.mutateAsync({ ids: checkedTermIds }); + setCheckedTermIds([]); + message.success("成功删除所选术语"); + } catch (error) { + message.error("删除失败"); + } + }; + + return ( +
+
+ + setTaxonomyId(value)} + defaultValue={taxonomyId} + width={200} + /> + + + + +
+ {customTreeData.length > 0 ? ( + } + /> + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/models/term/term-select.tsx b/apps/web/src/components/models/term/term-select.tsx new file mode 100644 index 0000000..496efb7 --- /dev/null +++ b/apps/web/src/components/models/term/term-select.tsx @@ -0,0 +1,72 @@ +import { TreeSelect, TreeSelectProps } from "antd"; +import { useEffect, useState } from "react"; +import { DataNode } from "@nicestack/common"; +import { useTerm } from "@web/src/hooks/useTerm"; + +interface TermSelectProps { + defaultValue?: string; + value?: string; + onChange?: (value: string) => void; + width?: number | string; + placeholder?: string; + taxonomyId: string; + extraOptions?: { value: string | undefined, label: string }[]; +} + +export default function TermSelect({ + defaultValue, + value, + onChange, + width = '100%', + taxonomyId, + placeholder = "选择术语" +}: TermSelectProps) { + const [customTreeData, setCustomTreeData] = useState([]); + const { treeData, addFetchParentId } = useTerm(); + + useEffect(() => { + if (treeData && taxonomyId) { + const processedTreeData = treeData.filter(node => node.data.taxonomyId === taxonomyId); + setCustomTreeData(processedTreeData); + } + }, [treeData, taxonomyId]); + + const [selectedValue, setSelectedValue] = useState(defaultValue); + + useEffect(() => { + if (value) { + setSelectedValue(value); + } else if (defaultValue) { + setSelectedValue(defaultValue); + } + }, [defaultValue, value]); + + const handleChange = (newValue: string) => { + setSelectedValue(newValue); + if (onChange) { + onChange(newValue); + } + }; + + const onLoadData: TreeSelectProps['loadData'] = async ({ id }) => { + addFetchParentId(id); + }; + + const handleExpand = (expandedKeys: React.Key[]) => { + console.log(expandedKeys) + // addFetchParentId(node.key as string); + }; + + return ( + + ); +} diff --git a/apps/web/src/components/presentation/animation/sine-wave.tsx b/apps/web/src/components/presentation/animation/sine-wave.tsx new file mode 100644 index 0000000..2bf368f --- /dev/null +++ b/apps/web/src/components/presentation/animation/sine-wave.tsx @@ -0,0 +1,110 @@ +import React, { useRef, useEffect } from 'react'; + +interface CanvasProps { + width: number; + height: number; +} + +const SineWavesCanvas: React.FC = ({ width, height }) => { + const canvasRef = useRef(null); + + useEffect(() => { + if (canvasRef.current) { + const context = canvasRef.current.getContext('2d'); + if (context) { + drawSineWaves(context); + } + } + }, [width, height]); + + function drawSineWaves(ctx: CanvasRenderingContext2D) { + let startAngle = 0; + const waveParams = [ + { + baseAmplitude: height * 0.13, + amplitudeModifier: (x: number) => Math.sin((Math.PI * x) / width), + phase: Math.PI / 2, + lineWidth: 3, + cycle: width * Math.random() * 0.0001, + opacityModifier: (x: number) => { + const distanceFromCenter = Math.abs(x - width / 2); + const maxDistance = width / 2; + return 1 - Math.pow(distanceFromCenter / maxDistance, 2); + } + }, + { + baseAmplitude: height * 0.12, + amplitudeModifier: (x: number) => Math.sin((Math.PI * x) / width), + phase: 0, + lineWidth: 1.5, + cycle: width * Math.random() * 0.001, + opacityModifier: (x: number) => { + const distanceFromCenter = Math.abs(x - width / 2); + const maxDistance = width / 2; + return 1 - Math.pow(distanceFromCenter / maxDistance, 2); + } + }, + { + baseAmplitude: height * 0.1, + amplitudeModifier: (x: number) => Math.sin((Math.PI * x) / width), + phase: Math.PI, + lineWidth: 0.5, + cycle: width * Math.random() * 0.01, + opacityModifier: (x: number) => { + const distanceFromCenter = Math.abs(x - width / 2); + const maxDistance = width / 2; + return 1 - Math.pow(distanceFromCenter / maxDistance, 2); + } + }, + { + baseAmplitude: height * 0.11, + amplitudeModifier: (x: number) => Math.sin((Math.PI * x) / width), + phase: Math.random() * Math.PI * 2, + lineWidth: 1.3, + cycle: width * Math.random() * 0.1, + opacityModifier: (x: number) => { + const distanceFromCenter = Math.abs(x - width / 2); + const maxDistance = width / 2; + return 1 - Math.pow(distanceFromCenter / maxDistance, 2); + } + } + ]; + + const gradient = ctx.createLinearGradient(0, 0, width, 0); + gradient.addColorStop(0, 'rgba(255, 255, 255, 0)'); + gradient.addColorStop(0.5, 'rgba(255, 255, 255, 1)'); + gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); + + function draw() { + ctx.clearRect(0, 0, width, height); + + startAngle += 0.1; + + waveParams.forEach(param => { + ctx.beginPath(); + + for (let x = 0; x < width; x++) { + let y = + height / 2 + + param.baseAmplitude * + param.amplitudeModifier(x) * + Math.sin(x * param.cycle + startAngle + param.phase); + + ctx.strokeStyle = gradient; + ctx.lineTo(x, y); + } + + ctx.lineWidth = param.lineWidth; + ctx.stroke(); + }); + + requestAnimationFrame(draw); + } + + draw(); + } + + return ; +}; + +export default SineWavesCanvas; diff --git a/apps/web/src/components/utilities/excel-importer.tsx b/apps/web/src/components/utilities/excel-importer.tsx new file mode 100644 index 0000000..aa927d5 --- /dev/null +++ b/apps/web/src/components/utilities/excel-importer.tsx @@ -0,0 +1,142 @@ +// import { api } from "@/trpc/react"; +import { useQueryClient } from "@tanstack/react-query"; +import { getQueryKey } from "@trpc/react-query"; +import { Button, message } from "antd"; +import { useMemo, useRef, useState } from "react"; + +import { SizeType } from "antd/es/config-provider/SizeContext"; +import { useTransform } from "@web/src/hooks/useTransform"; +import { api } from "@web/src/utils/trpc"; + +export function ExcelImporter({ + type = "trouble", + className, + name = "导入", + taxonomyId, + parentId, + size = "small", + domainId, + refresh = true, + disabled = false, + ghost = true, +}: { + disabled?: boolean; + type?: "trouble" | "term" | "dept" | "staff"; + className?: string; + name?: string; + domainId?: string; + taxonomyId?: string; + parentId?: string; + size?: SizeType; + refresh?: boolean; + ghost?: boolean; +}) { + const fileInput = useRef(null); + const [loading, setLoading] = useState(false); + const { importTerms, importDepts, importStaffs } = + useTransform(); + + return ( +
+ + { + const files = Array.from(e.target.files || []); + if (!files.length) return; // 如果没有文件被选中, 直接返回 + + const file = files[0]; + if (file) { + const isExcel = + file.type === "application/vnd.ms-excel" || + file.type === + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || + file.type === "application/wps-office.xlsx"; + + if (!isExcel) { + message.warning("请选择Excel文件"); + return; + } + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const bufferToBase64 = buffer.toString("base64"); + + try { + setLoading(true); + let data = undefined; + // if (type === "trouble") { + // data = await importTrouble.mutateAsync({ + // base64: bufferToBase64, + // domainId, + // }); + // } + if (type === "term") { + data = await importTerms.mutateAsync({ + base64: bufferToBase64, + domainId, + taxonomyId, + parentId, + }); + } + if (type === "dept") { + data = await importDepts.mutateAsync({ + base64: bufferToBase64, + domainId, + parentId, + }); + } + if (type === "staff") { + data = await importStaffs.mutateAsync({ + base64: bufferToBase64, + domainId, + }); + } + // const data = res.data; + console.log(`%cdata:${data}`, "color:red"); + if (!data?.error) { + // message.success("导入成功"); + // if (type === "trouble") { + message.success(`已经导入${data.count}条数据`); + // } + // queryClient.invalidateQueries({ queryKey }); + if (refresh && type !== "trouble") { + setTimeout(() => { + window.location.reload(); + }, 700); + } + } else { + console.log( + `%cerror:${JSON.stringify(data.error)}`, + "color:red" + ); + console.log(JSON.stringify(data.error)); + message.error(JSON.stringify(data.error)); + } + } catch (error) { + console.error(`${error}`); + message.error(`${error}`); + } finally { + if (fileInput.current) { + fileInput.current.value = ""; // 清空文件输入 + } + setLoading(false); + } + } + }} + style={{ display: "none" }} + /> +
+ ); +} diff --git a/apps/web/src/components/auth/with-auth.tsx b/apps/web/src/components/utilities/with-auth.tsx similarity index 84% rename from apps/web/src/components/auth/with-auth.tsx rename to apps/web/src/components/utilities/with-auth.tsx index 92667c8..3abad9d 100644 --- a/apps/web/src/components/auth/with-auth.tsx +++ b/apps/web/src/components/utilities/with-auth.tsx @@ -1,7 +1,7 @@ import { useAuth } from '@web/src/providers/auth-provider'; import { RolePerms } from '@nicestack/common'; import { ReactNode } from 'react'; -import { Navigate } from "react-router-dom"; +import { Navigate, useLocation } from "react-router-dom"; // Define a type for the props that the HOC will accept. interface WithAuthProps { permissions?: RolePerms[]; @@ -10,12 +10,13 @@ interface WithAuthProps { // Create the HOC function. export default function WithAuth({ options = {}, children }: { children: ReactNode, options?: WithAuthProps }) { const { isAuthenticated, user, isLoading } = useAuth(); + const location = useLocation() if (isLoading) { return
Loading...
; } // If the user is not authenticated, redirect them to the login page. if (!isAuthenticated) { - return + return } if (options.permissions && user) { diff --git a/apps/web/src/hooks/useAwaitState.ts b/apps/web/src/hooks/useAwaitState.ts new file mode 100644 index 0000000..782e749 --- /dev/null +++ b/apps/web/src/hooks/useAwaitState.ts @@ -0,0 +1,31 @@ +import { useState, useEffect, useRef } from 'react'; + +/** + * 自定义 Hook: 等待 state 变为指定值 + * @param {T} value - 要监听的 state 值。 + * @param {T} targetValue - 目标值,当 state 变为该值时 promise 结束。 + * @return {Promise} - 返回一个 Promise,当 state 变为 targetValue 时 resolve。 + */ +function useAwaitState(value: T, targetValue: T): Promise { + const [resolved, setResolved] = useState(false); + const resolveRef = useRef<() => void>(); + + useEffect(() => { + if (value === targetValue) { + setResolved(true); + if (resolveRef.current) { + resolveRef.current(); + } + } + }, [value, targetValue]); + + return new Promise((resolve) => { + if (resolved) { + resolve(); + } else { + resolveRef.current = resolve; + } + }); +} + +export default useAwaitState; diff --git a/apps/web/src/hooks/useDepartment.ts b/apps/web/src/hooks/useDepartment.ts new file mode 100644 index 0000000..ab495b0 --- /dev/null +++ b/apps/web/src/hooks/useDepartment.ts @@ -0,0 +1,119 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { getQueryKey } from "@trpc/react-query"; +import { api } from "../utils/trpc"; +import { DataNode, DepartmentDto } from "@nicestack/common"; +import { ReactNode, useEffect, useMemo, useState } from "react"; +import { findQueryData } from "../utils/general"; + +export function useDepartment() { + const queryClient = useQueryClient(); + const queryKey = getQueryKey(api.department); + const [fetchParentIds, setFetchParentIds] = useState([null]); + const queries = api.useQueries((t) => { + return fetchParentIds.map((id) => + t.department.getChildren({ parentId: id }) + ); + }); + const addFetchParentId = (newId: string) => { + setFetchParentIds((prevIds) => { + // Check if the newId already exists in the array + if (!prevIds.includes(newId)) { + // If not, add it to the array + return [...prevIds, newId]; + } + // Otherwise, return the array as is + return prevIds; + }); + }; + const [treeData, setTreeData] = useState([]); + const queriesFetched = useMemo(() => { + return queries.every((query) => query.isFetched); + }, [queries]); + const queriesFetching = useMemo(() => { + return queries.some((query) => query.isFetching); + }, [queries]); + useEffect(() => { + if (queriesFetched) { + const rawTreeData = getTreeData(); + setTreeData(rawTreeData); + } + }, [queriesFetching]); + + const create = api.department.create.useMutation({ + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const findById = (id: string) => { + return api.department.getDepartmentDetails.useQuery({ deptId: id }); + }; + + const update = api.department.update.useMutation({ + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const deleteDepartment = api.department.delete.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const buildTree = ( + data: DepartmentDto[], + parentId: string | null = null + ): DataNode[] => { + return data + .filter((department) => department.parentId === parentId) + .sort((a, b) => a.order - b.order) + .map((department) => { + const node: DataNode = { + title: department.name, + key: department.id, + value: department.id, + isLeaf: !department.hasChildren, + children: department.hasChildren + ? buildTree(data, department.id) + : undefined, + data: department, + }; + return node; + }); + }; + + const getTreeData = () => { + const cacheArray = queryClient.getQueriesData({ + queryKey: getQueryKey(api.department.getChildren), + }); + const data: DepartmentDto[] = cacheArray + .flatMap((cache) => cache.slice(1)) + .flat() + .filter((item) => item !== undefined) as any; + const uniqueDataMap = new Map(); + + data.forEach((item) => { + if (item && item.id) { + uniqueDataMap.set(item.id, item); + } + }); + // Convert the Map back to an array + const uniqueData: DepartmentDto[] = Array.from(uniqueDataMap.values()); + const treeData: DataNode[] = buildTree(uniqueData); + return treeData; + }; + const getDept = (key: string) => { + return findQueryData(queryClient, api.department, key); + }; + return { + deleteDepartment, + update, + findById, + create, + getTreeData, + addFetchParentId, + fetchParentIds, + treeData, + getDept, + }; +} diff --git a/apps/web/src/hooks/useRole.ts b/apps/web/src/hooks/useRole.ts new file mode 100644 index 0000000..30e9b59 --- /dev/null +++ b/apps/web/src/hooks/useRole.ts @@ -0,0 +1,37 @@ +import { getQueryKey } from "@trpc/react-query"; +import { api } from "../utils/trpc"; // Adjust path as necessary +import { useQueryClient } from "@tanstack/react-query"; + +export function useRole() { + const queryClient = useQueryClient(); + const queryKey = getQueryKey(api.role); + + const create = api.role.create.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + + const update = api.role.update.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const batchDelete = api.role.batchDelete.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }) + } + }) + const paginate = (page: number, pageSize: number) => { + return api.role.paginate.useQuery({ page, pageSize }); + }; + + return { + create, + update, + paginate, + batchDelete + }; +} diff --git a/apps/web/src/hooks/useRoleMap.ts b/apps/web/src/hooks/useRoleMap.ts new file mode 100644 index 0000000..0cbefa4 --- /dev/null +++ b/apps/web/src/hooks/useRoleMap.ts @@ -0,0 +1,37 @@ +import { getQueryKey } from "@trpc/react-query"; +import { api } from "../utils/trpc"; // Adjust path as necessary +import { useQueryClient } from "@tanstack/react-query"; +import { RoleMapSchema, z } from "@nicestack/common"; +export function useRoleMap() { + const queryClient = useQueryClient(); + const queryKey = getQueryKey(api.rolemap); + + const create = api.rolemap.setRoleForObject.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const createManyObjects = api.rolemap.createManyObjects.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const update = api.rolemap.update.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const batchDelete = api.rolemap.batchDelete.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + return { + create, + update, + createManyObjects, + batchDelete, + + }; +} diff --git a/apps/web/src/hooks/useStaff.ts b/apps/web/src/hooks/useStaff.ts new file mode 100644 index 0000000..a2b4098 --- /dev/null +++ b/apps/web/src/hooks/useStaff.ts @@ -0,0 +1,31 @@ +import { getQueryKey } from "@trpc/react-query"; +import { api } from "../utils/trpc"; // Adjust path as necessary +import { useQueryClient } from "@tanstack/react-query"; +export function useStaff() { + const queryClient = useQueryClient(); + const queryKey = getQueryKey(api.staff); + + const create = api.staff.create.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + + const update = api.staff.update.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const batchDelete = api.staff.batchDelete.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }) + } + }) + + return { + create, + update, + batchDelete + }; +} diff --git a/apps/web/src/hooks/useTaxonomy.ts b/apps/web/src/hooks/useTaxonomy.ts new file mode 100644 index 0000000..21cf220 --- /dev/null +++ b/apps/web/src/hooks/useTaxonomy.ts @@ -0,0 +1,47 @@ +import { getQueryKey } from "@trpc/react-query"; +import { api } from "../utils/trpc"; // Adjust path as necessary +import { useQueryClient } from "@tanstack/react-query"; + +export function useTaxonomy() { + const queryClient = useQueryClient(); + const queryKey = getQueryKey(api.taxonomy); + + const create = api.taxonomy.create.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const findById = (id: string) => { + return api.taxonomy.findById.useQuery({ id }); + }; + + const update = api.taxonomy.update.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const deleteItem = api.taxonomy.delete.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const batchDelete = api.taxonomy.batchDelete.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }) + } + }) + const paginate = (page: number, pageSize: number) => { + return api.taxonomy.paginate.useQuery({ page, pageSize }); + }; + + return { + create, + findById, + update, + deleteItem, + paginate, + batchDelete + }; +} diff --git a/apps/web/src/hooks/useTerm.ts b/apps/web/src/hooks/useTerm.ts new file mode 100644 index 0000000..80da979 --- /dev/null +++ b/apps/web/src/hooks/useTerm.ts @@ -0,0 +1,101 @@ +import { getQueryKey } from "@trpc/react-query"; +import { api } from "../utils/trpc"; // Adjust path as necessary +import { useQueryClient } from "@tanstack/react-query"; +import { DataNode, TermDto } from "@nicestack/common" +import { useEffect, useMemo, useState } from "react"; +import { getCacheDataFromQuery } from "../utils/general"; +export function useTerm() { + const queryClient = useQueryClient(); + const queryKey = getQueryKey(api.term); + const [fetchParentIds, setFetchParentIds] = useState([null]); + const [domainId, setDomainId] = useState(undefined) + const [taxonomyId, setTaxonomyId] = useState(undefined) + const queries = api.useQueries(t => { + return fetchParentIds.map(id => t.term.getAllChildren({ parentId: id, domainId, taxonomyId })) + }) + const addFetchParentId = (newId: string) => { + setFetchParentIds((prevIds) => { + // Check if the newId already exists in the array + if (!prevIds.includes(newId)) { + // If not, add it to the array + return [...prevIds, newId]; + } + // Otherwise, return the array as is + return prevIds; + }); + + }; + const [treeData, setTreeData] = useState([]); + const queriesFetching = useMemo(() => { + return queries.some(query => query.isFetching) + }, [queries]) + useEffect(() => { + if (!queriesFetching) { + const rawTreeData = getTreeData(); + setTreeData(rawTreeData); + } + }, [queriesFetching]); + + const create = api.term.create.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const findById = (id: string) => { + return api.term.findById.useQuery({ id }); + }; + + const update = api.term.update.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const deleteTerm = api.term.delete.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const batchDelete = api.term.batchDelete.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }) + } + }) + const buildTree = (data: TermDto[], parentId: string | null = null): DataNode[] => { + return data + .filter(term => term.parentId === parentId).sort((a, b) => a.order - b.order) + .map(term => { + const node: DataNode = { + title: term.name, + key: term.id, + value: term.id, + isLeaf: !term.hasChildren, + children: term.hasChildren ? buildTree(data, term.id) : undefined, + data: term + }; + return node; + }); + }; + + const getTreeData = () => { + const uniqueData: any = getCacheDataFromQuery(queryClient, api.term, "id") + console.log(uniqueData) + const treeData: DataNode[] = buildTree(uniqueData); + return treeData; + }; + + return { + create, + findById, + update, + deleteTerm, + batchDelete, + treeData, + addFetchParentId, + setDomainId, + domainId, + taxonomyId, setTaxonomyId + }; +} diff --git a/apps/web/src/hooks/useTransform.ts b/apps/web/src/hooks/useTransform.ts new file mode 100644 index 0000000..aceeab1 --- /dev/null +++ b/apps/web/src/hooks/useTransform.ts @@ -0,0 +1,29 @@ +import { getQueryKey } from "@trpc/react-query"; +import { api } from "../utils/trpc"; // Adjust path as necessary +import { useQueryClient } from "@tanstack/react-query"; +export function useTransform() { + const queryClient = useQueryClient(); + const queryKey = getQueryKey(api.transform); + const importTerms = api.transform.importTerms.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const importDepts = api.transform.importDepts.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const importStaffs = api.transform.importStaffs.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + return { + importTerms, + importDepts, + importStaffs, + + }; +} diff --git a/apps/web/src/providers/auth-provider.tsx b/apps/web/src/providers/auth-provider.tsx index b0f0902..08a21d5 100644 --- a/apps/web/src/providers/auth-provider.tsx +++ b/apps/web/src/providers/auth-provider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import apiClient from '../utils/axios-client'; import { UserProfile } from '@nicestack/common'; @@ -7,7 +7,7 @@ interface AuthContextProps { refreshToken: string | null; isAuthenticated: boolean; isLoading: boolean; - user: any; + user: UserProfile | null; login: (username: string, password: string) => Promise; logout: () => Promise; refreshAccessToken: () => Promise; @@ -17,23 +17,27 @@ interface AuthContextProps { } const AuthContext = createContext(undefined); -export const useAuth = (): AuthContextProps => { + +export function useAuth(): AuthContextProps { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within an AuthProvider'); } return context; }; + interface AuthProviderProps { children: ReactNode; } -export const AuthProvider: React.FC = ({ children }) => { + +export function AuthProvider({ children }: AuthProviderProps) { const [accessToken, setAccessToken] = useState(localStorage.getItem('access_token')); const [refreshToken, setRefreshToken] = useState(localStorage.getItem('refresh_token')); const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem('access_token')); const [isLoading, setIsLoading] = useState(false); const [intervalId, setIntervalId] = useState(null); - const [user, setUser] = useState(null); + const [user, setUser] = useState(JSON.parse(localStorage.getItem('user_profile') || 'null')); + const initializeAuth = useCallback(() => { const storedAccessToken = localStorage.getItem('access_token'); const storedRefreshToken = localStorage.getItem('refresh_token'); @@ -47,6 +51,7 @@ export const AuthProvider: React.FC = ({ children }) => { fetchUserProfile(); } }, []); + const refreshAccessToken = useCallback(async () => { if (!refreshToken) return; try { @@ -70,11 +75,8 @@ export const AuthProvider: React.FC = ({ children }) => { if (intervalId) { clearInterval(intervalId); } - await refreshAccessToken(); - const newIntervalId = setInterval(refreshAccessToken, 10 * 60 * 1000); // 10 minutes - setIntervalId(newIntervalId); }, [intervalId, refreshAccessToken]); @@ -110,6 +112,7 @@ export const AuthProvider: React.FC = ({ children }) => { localStorage.removeItem('refresh_token'); localStorage.removeItem('access_token_expires_at'); localStorage.removeItem('refresh_token_expires_at'); + localStorage.removeItem('user_profile'); setAccessToken(null); setRefreshToken(null); @@ -131,7 +134,9 @@ export const AuthProvider: React.FC = ({ children }) => { const fetchUserProfile = useCallback(async () => { try { const response = await apiClient.get(`/auth/user-profile`); - setUser(response.data); + const userProfile = response.data; + setUser(userProfile); + localStorage.setItem('user_profile', JSON.stringify(userProfile)); } catch (err) { console.error("Fetching user profile failed", err); } diff --git a/apps/web/src/providers/theme-provider.tsx b/apps/web/src/providers/theme-provider.tsx new file mode 100644 index 0000000..282a883 --- /dev/null +++ b/apps/web/src/providers/theme-provider.tsx @@ -0,0 +1,45 @@ +import { ConfigProvider, theme } from "antd"; +import { ReactNode, useEffect, useMemo } from "react"; + +export interface TailwindTheme { + [key: string]: string; +} + +export default function ThemeProvider({ children }: { children: ReactNode }) { + const token = theme.getDesignToken(); + + const applyTheme = (tailwindTheme: TailwindTheme) => { + for (let key in tailwindTheme) { + document.documentElement.style.setProperty(key, tailwindTheme[key]); + } + }; + + const tailwindTheme: TailwindTheme = useMemo(() => ({ + '--color-primary': token.colorPrimary, + '--color-text-secondary': token.colorTextSecondary, + '--color-text-tertiary': token.colorTextTertiary, + '--bg-container': token.colorBgContainer, + '--bg-layout': token.colorBgLayout, + '--bg-mask': token.colorBgMask, + '--primary-bg': token.colorPrimaryBg, + '--color-text': token.colorText, + '--color-text-quaternary': token.colorTextQuaternary, + '--color-text-placeholder': token.colorTextPlaceholder, + '--color-text-description': token.colorTextDescription, + '--color-border': token.colorBorder, + '--primary-text': token.colorPrimaryText + }), [token]); + + useEffect(() => { + applyTheme(tailwindTheme); + }, [tailwindTheme]); + + return ( + + {children} + + ); +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 0e626b5..6ac429f 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,13 +1,29 @@ import { - createBrowserRouter + createBrowserRouter, + IndexRouteObject, + NonIndexRouteObject } from "react-router-dom"; import MainPage from "../app/main/page"; import ErrorPage from "../app/error"; import LayoutPage from "../app/layout"; import LoginPage from "../app/login"; -import WithAuth from "../components/auth/with-auth"; +import DepartmentAdminPage from "../app/admin/department/page"; +import RoleAdminPage from "../app/admin/role/page"; +import StaffAdminPage from "../app/admin/staff/page"; +import TermAdminPage from "../app/admin/term/page"; +import WithAuth from "../components/utilities/with-auth"; +interface CustomIndexRouteObject extends IndexRouteObject { + name?: string; + breadcrumb?: string; +} -export const router = createBrowserRouter([ +interface CustomNonIndexRouteObject extends NonIndexRouteObject { + name?: string; + children?: CustomRouteObject[]; + breadcrumb?: string; +} +type CustomRouteObject = CustomIndexRouteObject | CustomNonIndexRouteObject; +export const routes = [ { path: "/", element: , @@ -16,7 +32,49 @@ export const router = createBrowserRouter([ { index: true, element: - } + }, + { + path: "admin", + children: [ + { + path: "department", + breadcrumb: "单位管理", + element: ( + + + + ), + }, + { + path: "staff", + breadcrumb: "人员管理", + element: ( + + + + ), + }, + { + path: "term", + breadcrumb: "术语管理", + element: ( + + + + ), + }, + + { + path: "role", + breadcrumb: "角色管理", + element: ( + + + + ), + } + ], + }, ], }, { @@ -24,4 +82,5 @@ export const router = createBrowserRouter([ element: } -]); +] +export const router = createBrowserRouter(routes); diff --git a/apps/web/src/utils/tusd.ts b/apps/web/src/utils/tusd.ts new file mode 100644 index 0000000..8b3ffb1 --- /dev/null +++ b/apps/web/src/utils/tusd.ts @@ -0,0 +1,95 @@ +import * as tus from "tus-js-client"; +import imageCompression from "browser-image-compression"; +export const uploader = async ( + endpoint: string, + file: File, + onProgress?: (percentage: number, speed: number) => void, + onSuccess?: (url: string) => void, + onError?: (error: Error) => void +) => { + let previousUploadedSize = 0; + let previousTimestamp = Date.now(); + + // 压缩图像为WebP格式 + const compressImage = async (file: File): Promise => { + const options = { + maxSizeMB: 0.6, // 最大文件大小(MB) + maxWidthOrHeight: 2560, // 最大宽高 + useWebWorker: true, + fileType: "image/webp", // 输出文件格式 + }; + const compressedFile = await imageCompression(file, options); + return new File([compressedFile], `${file.name.split(".")[0]}.webp`, { + type: "image/webp", + }); + }; + + let fileToUpload: File; + + // 检查并压缩图片文件 + if (file.type.startsWith("image/")) { + try { + fileToUpload = await compressImage(file); + } catch (error: any) { + console.error("图像压缩失败: " + error.message); + if (onError) onError(error); + throw error; // 如果压缩失败,抛出错误并终止上传 + } + } else { + fileToUpload = file; // 非图片文件,不进行压缩 + } + + const upload = new tus.Upload(fileToUpload, { + // Replace this with tusd's upload creation URL + endpoint: `${endpoint}/files/`, + retryDelays: [0, 3000, 5000, 10000, 20000], + metadata: { + filename: fileToUpload.name, + filetype: fileToUpload.type, + }, + onError: function (error) { + console.error("上传失败: " + error.message); + if (onError) onError(error); + }, + onProgress: function (bytesUploaded: number, bytesTotal: number) { + const currentTimestamp = Date.now(); + const timeElapsed = (currentTimestamp - previousTimestamp) / 1000; // in seconds + const bytesUploadedSinceLastTime = bytesUploaded - previousUploadedSize; + const speed = bytesUploadedSinceLastTime / timeElapsed; // bytes per second + previousUploadedSize = bytesUploaded; + previousTimestamp = currentTimestamp; + const percentage = (bytesUploaded / bytesTotal) * 100; + if (onProgress) onProgress(percentage, speed); + }, + onSuccess: function () { + console.log("上传文件类型", fileToUpload.type); + console.log("上传文件名称", fileToUpload.name); + if (onSuccess) onSuccess(upload.url!); + console.log("Download %s from %s", fileToUpload.name, upload.url); + }, + }); + + // Check if there are any previous uploads to continue. + upload.findPreviousUploads().then(function (previousUploads) { + // Found previous uploads so we select the first one. + if (previousUploads && previousUploads.length > 0) { + upload.resumeFromPreviousUpload(previousUploads[0]!); + } + }); + + return upload; +}; + +export const uploaderPromise = ( + endpoint: string, + file: File, + onProgress?: (percentage: number, speed: number) => void +): Promise => { + return new Promise((resolve, reject) => { + uploader(endpoint, file, onProgress, resolve, reject) + .then((upload) => { + upload.start(); + }) + .catch(reject); + }); +}; \ No newline at end of file diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index 89a305e..5303830 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -5,7 +5,27 @@ export default { "./src/**/*.{js,ts,jsx,tsx}", ], theme: { - extend: {}, + extend: { + colors: { + primary: "var(--color-primary)", + }, + backgroundColor: { + layout: "var(--bg-layout)", + mask: "var(--bg-mask)", + container: "var(--bg-container)", + }, + textColor: { + default: "var(--color-text)", + quaternary: "var(--color-text-quaternary)", + placeholder: "var(--color-text-placeholder)", + description: "var(--color-text-description)", + secondary: "var(--color-text-secondary)", + tertiary: "var(--color-text-tertiary)", + }, + borderColor: { + colorDefault: "var(--color-border)", + } + }, }, plugins: [], -} \ No newline at end of file +} diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index ae24b1e..17da72b 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -21,6 +21,7 @@ "jsx": "react-jsx", /* Linting */ "strict": true, + "strictNullChecks": false, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, diff --git a/packages/common/.env.example b/packages/common/.env.example index d780ca4..ce62d58 100755 --- a/packages/common/.env.example +++ b/packages/common/.env.example @@ -1 +1 @@ -DATABASE_URL=postgresql://root:Letusdoit000@192.168.197.77:5432/lxminiapp \ No newline at end of file +DATABASE_URL=postgresql://root:Letusdoit000@192.168.116.77:5432/lxminiapp \ No newline at end of file diff --git a/packages/common/prisma/schema.prisma b/packages/common/prisma/schema.prisma index fd936a4..768412a 100755 --- a/packages/common/prisma/schema.prisma +++ b/packages/common/prisma/schema.prisma @@ -88,6 +88,7 @@ model Comment { model Staff { id String @id @default(uuid()) + showname String? username String @unique password String phoneNumber String? @unique diff --git a/packages/common/src/schema.ts b/packages/common/src/schema.ts index b10a296..8969397 100755 --- a/packages/common/src/schema.ts +++ b/packages/common/src/schema.ts @@ -4,13 +4,16 @@ export const AuthSchema = { signInRequset: z.object({ username: z.string(), password: z.string(), + phoneNumber: z.string().nullish() }), signUpRequest: z.object({ username: z.string(), password: z.string(), + phoneNumber: z.string().nullish() }), changePassword: z.object({ username: z.string(), + phoneNumber: z.string().nullish(), oldPassword: z.string(), newPassword: z.string(), }), @@ -26,6 +29,7 @@ export const StaffSchema = { username: z.string(), password: z.string(), domainId: z.string().nullish(), + phoneNumber: z.string().nullish() }), update: z.object({ id: z.string(), @@ -161,3 +165,102 @@ export const RoleSchema = { keyword: z.string().nullish(), }), }; +export const TaxonomySchema = { + create: z.object({ + name: z.string(), + // slug: z.string().min(1), // Assuming slug cannot be empty + }), + delete: z.object({ + id: z.string(), + }), + findByName: z.object({ + name: z.string(), + }), + findById: z.object({ + id: z.string(), + }), + batchDelete: z.object({ + ids: z.array(z.string()), + }), + update: z.object({ + id: z.string(), + name: z.string().nullish(), + // slug: z.string().nullish(), + order: z.number().nullish(), + }), + paginate: z.object({ + page: z.number().min(1), + pageSize: z.number().min(1), + }), +}; +export const TermSchema = { + create: z.object({ + name: z.string(), + description: z.string().nullish(), + domainId: z.string().nullish(), + // slug: z.string().min(1), // Assuming slug cannot be empty + parentId: z.string().nullish(), // Optional field + taxonomyId: z.string(), // Optional field + watchStaffIds: z.array(z.string()).nullish(), + watchDeptIds: z.array(z.string()).nullish(), + }), + update: z.object({ + id: z.string(), + description: z.string().nullish(), + parentId: z.string().nullish(), + domainId: z.string().nullish(), + name: z.string().nullish(), + // slug: z.string().nullish(), + taxonomyId: z.string().nullish(), + order: z.number().nullish(), + watchStaffIds: z.array(z.string()).nullish(), + watchDeptIds: z.array(z.string()).nullish(), + }), + delete: z.object({ + id: z.string(), + }), + paginate: z.object({ + page: z.number().min(1), + pageSize: z.number().min(1), + }), + batchDelete: z.object({ + ids: z.array(z.string()), + }), + cursorList: z.object({ + cursor: z.string().nullish(), + search: z.string().nullish(), + limit: z.number().min(1).max(100).nullish(), + taxonomyId: z.string(), + id: z.string(), + }), + getChildren: z.object({ + parentId: z.string().nullish(), + domainId: z.string().nullish(), + taxonomyId: z.string().nullish(), + cursor: z.string().nullish(), + limit: z.number().min(1).max(100).nullish(), + }), + findMany: z.object({ + keyword: z.string().nullish(), + ids: z.array(z.string()).nullish(), + taxonomyId: z.string().nullish(), + }), +}; +export const TransformSchema = { + importStaffs: z.object({ + base64: z.string(), + domainId: z.string().nullish(), + }), + importTerms: z.object({ + base64: z.string(), + domainId: z.string().nullish(), + taxonomyId: z.string().nullish(), + parentId: z.string().nullish(), + }), + importDepts: z.object({ + base64: z.string(), + domainId: z.string().nullish(), + parentId: z.string().nullish(), + }), + +}; diff --git a/packages/common/src/type.ts b/packages/common/src/type.ts index 8271fb4..9f0d95a 100644 --- a/packages/common/src/type.ts +++ b/packages/common/src/type.ts @@ -1,4 +1,4 @@ -import { Department, Staff } from "@prisma/client"; +import { Department, Staff, Term } from "@prisma/client"; export interface DataNode { title: any; @@ -38,4 +38,15 @@ export interface GenPerms { edit?: boolean; delete?: boolean; read?: boolean; -} \ No newline at end of file +} +export type TermDto = Term & { + permissions: GenPerms; + children: TermDto[]; + hasChildren: boolean; +}; +export type DepartmentDto = Department & { + parent: DepartmentDto; + children: DepartmentDto[]; + hasChildren: boolean; + staffs: StaffDto[]; +}; diff --git a/tsconfig.json b/tsconfig.json index c5894dd..d2c16d7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,9 @@ ], "@web/*": [ "apps/web/*" + ], + "@admin/*": [ + "apps/admin/*" ] } }