This commit is contained in:
longdayi 2024-09-10 10:31:24 +08:00
parent b8c73030d8
commit 5630af88ba
84 changed files with 5445 additions and 120 deletions

View File

@ -9,9 +9,10 @@ import { TransformModule } from './transform/transform.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { TasksModule } from './tasks/tasks.module';
@Module({ @Module({
imports: [ScheduleModule.forRoot(), TrpcModule, RedisModule, QueueModule, TransformModule, AuthModule], imports: [ScheduleModule.forRoot(), TrpcModule, RedisModule, QueueModule, TransformModule, AuthModule, TasksModule],
providers: [RedisService, SocketGateway, ConfigService], providers: [RedisService, SocketGateway, ConfigService],
}) })
export class AppModule { } export class AppModule { }

View File

@ -8,21 +8,22 @@ import { AuthGuard } from './auth.guard';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) { } constructor(private readonly authService: AuthService) { }
@UseGuards(AuthGuard)
@Get("user-profile") @Get("user-profile")
async getUserProfile(@Req() request: Request) { async getUserProfile(@Req() request: Request) {
const user: JwtPayload = (request as any).user const user: JwtPayload = (request as any).user
console.log(user)
// console.log(request)
return this.authService.getUserProfile(user) return this.authService.getUserProfile(user)
} }
@Post('login') @Post('login')
async login(@Body() body: z.infer<typeof AuthSchema.signInRequset>) { async login(@Body() body: z.infer<typeof AuthSchema.signInRequset>) {
return this.authService.signIn(body); return this.authService.signIn(body);
} }
@Post('signup') @Post('signup')
async signup(@Body() body: z.infer<typeof AuthSchema.signUpRequest>) { async signup(@Body() body: z.infer<typeof AuthSchema.signUpRequest>) {
return this.authService.signUp(body); return this.authService.signUp(body);
} }
@UseGuards(AuthGuard) // Protecting the refreshToken endpoint with AuthGuard @UseGuards(AuthGuard) // Protecting the refreshToken endpoint with AuthGuard
@Post('refresh-token') @Post('refresh-token')
async refreshToken(@Body() body: z.infer<typeof AuthSchema.refreshTokenRequest>) { async refreshToken(@Body() body: z.infer<typeof AuthSchema.refreshTokenRequest>) {

View File

@ -15,6 +15,7 @@ export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request); const token = this.extractTokenFromHeader(request);
console.log(token)
if (!token) { if (!token) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
@ -25,6 +26,7 @@ export class AuthGuard implements CanActivate {
secret: env.JWT_SECRET secret: env.JWT_SECRET
} }
); );
// 💡 We're assigning the payload to the request object here // 💡 We're assigning the payload to the request object here
// so that we can access it in our route handlers // so that we can access it in our route handlers
request['user'] = payload; request['user'] = payload;

View File

@ -8,12 +8,13 @@ import { RoleMapService } from '@server/rbac/rolemap.service';
import { DepartmentService } from '@server/models/department/department.service'; import { DepartmentService } from '@server/models/department/department.service';
@Module({ @Module({
providers: [AuthService, StaffService, RoleMapService,DepartmentService], providers: [AuthService, StaffService, RoleMapService, DepartmentService],
imports: [JwtModule.register({ imports: [JwtModule.register({
global: true, global: true,
secret: env.JWT_SECRET, secret: env.JWT_SECRET,
signOptions: { expiresIn: '60s' }, signOptions: { expiresIn: '60s' },
}),], }),],
controllers: [AuthController] controllers: [AuthController],
exports: [AuthService]
}) })
export class AuthModule { } export class AuthModule { }

View File

@ -20,15 +20,24 @@ export class AuthService {
) { } ) { }
async signIn(data: z.infer<typeof AuthSchema.signInRequset>) { async signIn(data: z.infer<typeof AuthSchema.signInRequset>) {
const { username, password } = data; const { username, password, phoneNumber } = data;
const staff = await db.staff.findUnique({ where: { username } }); // Find the staff by either username or phoneNumber
const staff = await db.staff.findFirst({
where: {
OR: [
{ username },
{ phoneNumber }
]
}
});
if (!staff) { 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); const isPasswordMatch = await bcrypt.compare(password, staff.password);
if (!isPasswordMatch) { 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 }; const payload: JwtPayload = { sub: staff.id, username: staff.username };
@ -99,22 +108,27 @@ export class AuthService {
} }
async signUp(data: z.infer<typeof AuthSchema.signUpRequest>) { async signUp(data: z.infer<typeof AuthSchema.signUpRequest>) {
const { username, password } = data; const { username, password, phoneNumber } = data;
const existingUser = await db.staff.findUnique({ where: { username } });
if (existingUser) { const existingUserByUsername = await db.staff.findUnique({ where: { username } });
if (existingUserByUsername) {
throw new BadRequestException('Username is already taken'); 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 hashedPassword = await bcrypt.hash(password, 10);
const staff = await this.staffService.create({ const staff = await this.staffService.create({
username, username,
phoneNumber,
password: hashedPassword, password: hashedPassword,
}); });
return staff; return staff;
} }
async logout(data: z.infer<typeof AuthSchema.logoutRequest>) { async logout(data: z.infer<typeof AuthSchema.logoutRequest>) {
const { refreshToken } = data; const { refreshToken } = data;
await db.refreshToken.deleteMany({ where: { token: refreshToken } }); await db.refreshToken.deleteMany({ where: { token: refreshToken } });

View File

@ -1,3 +1,4 @@
export const env: { JWT_SECRET: string } = { export const env: { JWT_SECRET: string, APP_URL: string } = {
JWT_SECRET: process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA=' JWT_SECRET: process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA=',
APP_URL: process.env.APP_URL || 'http://localhost:5173'
} }

View File

@ -1,7 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { InitService } from './init.service'; import { InitService } from './init.service';
import { AuthModule } from '@server/auth/auth.module';
@Module({ @Module({
imports: [AuthModule],
providers: [InitService], providers: [InitService],
exports: [InitService] exports: [InitService]
}) })

View File

@ -44,19 +44,27 @@ export class InitService {
private async createRoot() { private async createRoot() {
this.logger.log('Checking for root account'); this.logger.log('Checking for root account');
const rootAccountExists = await db.staff.findUnique({ const rootAccountExists = await db.staff.findFirst({
where: { phoneNumber: process.env.ADMIN_PHONE_NUMBER || '000000' }, where: {
OR: [
{
phoneNumber: process.env.ADMIN_PHONE_NUMBER || '000000'
},
{
username: 'root'
}
]
},
}); });
if (!rootAccountExists) { if (!rootAccountExists) {
this.logger.log('Creating root account'); this.logger.log('Creating root account');
const rootStaff =await this.authService.signUp({ const rootStaff = await this.authService.signUp({
username: 'root', username: 'root',
password: 'admin' password: 'root'
}) })
const rootRole = await db.role.findUnique({ const rootRole = await db.role.findUnique({
where: { name: '根管理员' }, where: { name: '根管理员' },
}); });
if (rootRole) { if (rootRole) {
this.logger.log('Assigning root role to root account'); this.logger.log('Assigning root role to root account');
await db.roleMap.create({ await db.roleMap.create({

View File

@ -1,11 +1,13 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { TrpcRouter } from './trpc/trpc.router'; import { TrpcRouter } from './trpc/trpc.router';
import { env } from './env';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.enableCors({ app.enableCors({
origin: [process.env.APP_URL!], origin: [env.APP_URL],
credentials: true
}); });
const trpc = app.get(TrpcRouter); const trpc = app.get(TrpcRouter);
trpc.applyMiddleware(app); trpc.applyMiddleware(app);

View File

@ -9,7 +9,6 @@ export class DepartmentRouter {
private readonly trpc: TrpcService, private readonly trpc: TrpcService,
private readonly departmentService: DepartmentService, // inject DepartmentService private readonly departmentService: DepartmentService, // inject DepartmentService
) {} ) {}
router = this.trpc.router({ router = this.trpc.router({
create: this.trpc.protectProcedure create: this.trpc.protectProcedure
.input(DepartmentSchema.create) // expect input according to the schema .input(DepartmentSchema.create) // expect input according to the schema

View File

@ -125,7 +125,6 @@ export class DepartmentService {
} }
async paginate(data: z.infer<typeof DepartmentSchema.paginate>) { async paginate(data: z.infer<typeof DepartmentSchema.paginate>) {
const { page, pageSize, ids } = data; const { page, pageSize, ids } = data;
const [items, totalCount] = await Promise.all([ const [items, totalCount] = await Promise.all([
db.department.findMany({ db.department.findMany({
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,

View File

@ -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 { }

View File

@ -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();
})
});
}

View File

@ -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<typeof TaxonomySchema.create>) {
// 获取当前分类数量设置新分类的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<typeof TaxonomySchema.findByName>) {
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<typeof TaxonomySchema.findById>) {
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' },
});
}
}

View File

@ -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 { }

View File

@ -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);
}),
});
}

View File

@ -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<TermDto> {
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<RelationResult>[]));
return { ...others, ...mergedResults, permissions, hasChildren: term.children.length > 0 };
}
/**
*
*
* @param parentId IDnull
* @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<typeof TermSchema.create>) {
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<typeof TermSchema.update>) {
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<typeof TermSchema.delete>) {
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<typeof TermSchema.getChildren>) {
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<typeof TermSchema.getChildren>) {
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<typeof TermSchema.findMany>) {
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
});
}
}

View File

@ -5,10 +5,12 @@ import { TrpcService } from '@server/trpc/trpc.service';
import { RoleService } from './role.service'; import { RoleService } from './role.service';
import { RoleMapRouter } from './rolemap.router'; import { RoleMapRouter } from './rolemap.router';
import { DepartmentModule } from '@server/models/department/department.module'; import { DepartmentModule } from '@server/models/department/department.module';
import { RolePermsService } from './roleperms.service';
import { RelationService } from '@server/relation/relation.service';
@Module({ @Module({
imports: [DepartmentModule], imports: [DepartmentModule],
providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter], providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter, RolePermsService, RelationService],
exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter] exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter, RolePermsService]
}) })
export class RoleMapModule { } export class RbacModule { }

View File

@ -2,30 +2,24 @@ import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service'; import { TrpcService } from '@server/trpc/trpc.service';
import { RoleMapSchema } from '@nicestack/common'; import { RoleMapSchema } from '@nicestack/common';
import { RoleMapService } from './rolemap.service'; import { RoleMapService } from './rolemap.service';
@Injectable() @Injectable()
export class RoleMapRouter { export class RoleMapRouter {
constructor( constructor(
private readonly trpc: TrpcService, private readonly trpc: TrpcService,
private readonly roleMapService: RoleMapService, private readonly roleMapService: RoleMapService,
) {} ) { }
router = this.trpc.router({ router = this.trpc.router({
deleteAllRolesForObject: this.trpc.protectProcedure deleteAllRolesForObject: this.trpc.protectProcedure
.input(RoleMapSchema.deleteWithObject) .input(RoleMapSchema.deleteWithObject)
.mutation(({ input }) => .mutation(({ input }) =>
this.roleMapService.deleteAllRolesForObject(input), this.roleMapService.deleteAllRolesForObject(input),
), ),
setRoleForObject: this.trpc.protectProcedure setRoleForObject: this.trpc.protectProcedure
.input(RoleMapSchema.create) .input(RoleMapSchema.create)
.mutation(({ input }) => this.roleMapService.setRoleForObject(input)), .mutation(({ input }) => this.roleMapService.setRoleForObject(input)),
createManyObjects: this.trpc.protectProcedure createManyObjects: this.trpc.protectProcedure
.input(RoleMapSchema.createManyObjects) .input(RoleMapSchema.createManyObjects)
.mutation(({ input }) => this.roleMapService.createManyObjects(input)), .mutation(({ input }) => this.roleMapService.createManyObjects(input)),
setRolesForObject: this.trpc.protectProcedure setRolesForObject: this.trpc.protectProcedure
.input(RoleMapSchema.createManyRoles) .input(RoleMapSchema.createManyRoles)
.mutation(({ input }) => this.roleMapService.setRolesForObject(input)), .mutation(({ input }) => this.roleMapService.setRolesForObject(input)),

View File

@ -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<string> {
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;
}
}

View File

@ -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<Array>} A promise that resolves to an array of relation objects.
*/
async getERO(aType: ObjectType, relationType: RelationType, bType: ObjectType, aId: string, limit?: number): Promise<Array<Relation>> {
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<number>} A promise that resolves to an array of relation objects.
*/
async getEROCount(aType: ObjectType, relationType: RelationType, bType: ObjectType, aId: string): Promise<number> {
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<Array<string>>} A promise that resolves to an array of object IDs.
*/
async getEROBids(aType: ObjectType, relationType: RelationType, bType: ObjectType, aId: string, limit?: number): Promise<Array<string>> {
const res = await this.getERO(aType, relationType, bType, aId, limit);
return res.map(relation => relation.bId);
}
}

View File

@ -2,8 +2,13 @@ import { Module } from '@nestjs/common';
import { TransformService } from './transform.service'; import { TransformService } from './transform.service';
import { TransformRouter } from './transform.router'; import { TransformRouter } from './transform.router';
import { TrpcService } from '@server/trpc/trpc.service'; 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({ @Module({
imports: [DepartmentModule, StaffModule, TaxonomyModule, TermModule],
providers: [TransformService, TransformRouter, TrpcService], providers: [TransformService, TransformRouter, TrpcService],
exports: [TransformRouter] exports: [TransformRouter]
}) })

View File

@ -1,13 +1,32 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { TransformService } from './transform.service'; import { TransformService } from './transform.service';
import { TransformSchema } from '@nicestack/common';
import { TrpcService } from '../trpc/trpc.service';
@Injectable() @Injectable()
export class TransformRouter { export class TransformRouter {
constructor(private readonly trpc: TrpcService, private readonly transformService: TransformService) { } 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);
}),
router = this.trpc.router({ });
});
} }

View File

@ -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() @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<T extends Record<string, string>>(
input: T,
): { [K in T[keyof T]]: Extract<keyof T, string> } {
const result: Partial<{ [K in T[keyof T]]: Extract<keyof T, string> }> = {};
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<keyof T, string> };
}
isEmptyRow(row: any) {
return row.every((cell: any) => {
return !cell || cell.toString().trim() === '';
});
}
async importStaffs(data: z.infer<typeof TransformSchema.importStaffs>) {
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<typeof TransformSchema.importTerms>,
) {
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<typeof TransformSchema.importDepts>,
) {
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('更新单位信息或者创建单位闭包表失败');
}
}
}

View File

@ -7,9 +7,12 @@ import { StaffRouter } from '@server/models/staff/staff.router';
import { StaffModule } from '../models/staff/staff.module'; import { StaffModule } from '../models/staff/staff.module';
import { DepartmentModule } from '@server/models/department/department.module'; import { DepartmentModule } from '@server/models/department/department.module';
import { TransformModule } from '@server/transform/transform.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({ @Module({
imports: [StaffModule, DepartmentModule, TransformModule], imports: [StaffModule, DepartmentModule, TransformModule, TermModule, TaxonomyModule, RbacModule],
controllers: [], controllers: [],
providers: [TrpcService, TrpcRouter], providers: [TrpcService, TrpcRouter],
}) })

View File

@ -1,20 +1,34 @@
import { INestApplication, Injectable } from '@nestjs/common'; import { INestApplication, Injectable } from '@nestjs/common';
import { TransformRouter } from '@server/transform/transform.router';
import { DepartmentRouter } from '@server/models/department/department.router'; import { DepartmentRouter } from '@server/models/department/department.router';
import { StaffRouter } from '@server/models/staff/staff.router'; import { StaffRouter } from '@server/models/staff/staff.router';
import { TrpcService } from '@server/trpc/trpc.service'; import { TrpcService } from '@server/trpc/trpc.service';
import * as trpcExpress from '@trpc/server/adapters/express'; 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() @Injectable()
export class TrpcRouter { export class TrpcRouter {
constructor(private readonly trpc: TrpcService, constructor(
private readonly staff: StaffRouter, private readonly trpc: TrpcService,
private readonly department: DepartmentRouter, 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({ appRouter = this.trpc.router({
staff: this.staff.router, transform: this.transform.router,
department: this.department.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) { async applyMiddleware(app: INestApplication) {
app.use( app.use(
@ -28,4 +42,3 @@ export class TrpcRouter {
} }
export type AppRouter = TrpcRouter[`appRouter`]; export type AppRouter = TrpcRouter[`appRouter`];

10
apps/web/nginx.conf Normal file
View File

@ -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;
}
}

View File

@ -10,6 +10,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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:^", "@nicestack/common": "workspace:^",
"@tanstack/query-async-storage-persister": "^5.51.9", "@tanstack/query-async-storage-persister": "^5.51.9",
"@tanstack/react-query": "^5.51.1", "@tanstack/react-query": "^5.51.1",
@ -19,12 +24,15 @@
"@trpc/client": "11.0.0-rc.456", "@trpc/client": "11.0.0-rc.456",
"@trpc/react-query": "11.0.0-rc.456", "@trpc/react-query": "11.0.0-rc.456",
"@trpc/server": "11.0.0-rc.456", "@trpc/server": "11.0.0-rc.456",
"antd": "^5.20.6",
"axios": "^1.7.3", "axios": "^1.7.3",
"browser-image-compression": "^2.0.2",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.24.1", "react-router-dom": "^6.24.1",
"superjson": "^2.2.1", "superjson": "^2.2.1",
"tus-js-client": "^4.1.0",
"zod": "^3.23.8", "zod": "^3.23.8",
"zustand": "^4.5.5" "zustand": "^4.5.5"
}, },

View File

@ -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;
}

View File

@ -5,12 +5,15 @@ import {
import QueryProvider from './providers/query-provider' import QueryProvider from './providers/query-provider'
import { router } from './routes'; import { router } from './routes';
import { AuthProvider } from './providers/auth-provider'; import { AuthProvider } from './providers/auth-provider';
import ThemeProvider from './providers/theme-provider';
function App() { function App() {
return ( return (
<AuthProvider> <AuthProvider>
<QueryProvider> <QueryProvider>
<RouterProvider router={router}></RouterProvider> <ThemeProvider>
<RouterProvider router={router}></RouterProvider>
</ThemeProvider>
</QueryProvider> </QueryProvider>
</AuthProvider> </AuthProvider>
) )

View File

@ -0,0 +1,7 @@
import DepartmentList from "@web/src/components/models/department/department-list";
export default function DepartmentAdminPage() {
return <div className=" flex-grow p-2 bg-white rounded-xl flex">
<DepartmentList></DepartmentList>
</div>
}

View File

@ -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<string | undefined>(undefined);
const [roleName, setRoleName] = useState<string | undefined>(undefined);
return (
<div className="flex-grow p-2 bg-white rounded-xl flex">
<div className="w-1/4">
<RoleList
onChange={(id, name) => {
console.log(id);
setRoleId(id);
setRoleName(name);
}}></RoleList>
</div>
<Divider className="h-full" type="vertical"></Divider>
<div className="flex-1">
{roleId && (
<RoleMapTable
roleName={roleName}
roleId={roleId}></RoleMapTable>
)}
{!roleId && <Empty description="暂无角色"></Empty>}
</div>
</div>
);
}

View File

@ -0,0 +1,6 @@
import StaffTable from "@web/src/components/models/staff/staff-table";
export default function StaffAdminPage() {
return <div className="p-2 bg-white rounded-xl flex-grow">
<StaffTable></StaffTable>
</div>
}

View File

@ -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 <div className="p-2 rounded-xl bg-white shadow flex flex-grow">
<div className=" border-r p-2">
<TaxonomyTable></TaxonomyTable>
</div>
<div className="p-2 flex-1"> <TermList></TermList></div>
</div>
}

View File

@ -1,3 +1,162 @@
export default function LoginPage() { import React, { useState, useRef, useEffect } from 'react';
return 'LoginPage' 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<any>(null);
const registerFormRef = useRef<any>(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 (
<div className="flex justify-center items-center h-screen w-full bg-layout">
<div className="flex items-center transition-all overflow-hidden bg-container rounded-xl" style={{ width: 800, height: 600, padding: 0 }}>
<div className="flex-1 py-10 px-10">
{showLogin ? (
<>
<Link to="/" className="text-gray-400 text-sm hover:text-primary hover:cursor-pointer">
</Link>
<div className="text-center text-2xl text-primary select-none">
<span className="px-2"></span>
</div>
<Form ref={loginFormRef} onFinish={onFinishLogin} layout="vertical" requiredMark="optional" size="large">
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password />
</Form.Item>
<div className="flex items-center justify-center">
<Button type="primary" htmlType="submit">
</Button>
</div>
</Form>
</>
) : (
<>
<div
onClick={() => setShowLogin(true)}
className="text-sm text-gray-400 hover:cursor-pointer hover:text-primary"
>
</div>
<div className="text-center text-2xl text-primary"></div>
<Form requiredMark="optional" ref={registerFormRef} onFinish={onFinishRegister} layout="vertical" size="large">
<Form.Item name="username" label="用户名" rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, max: 15, message: '用户名长度在 3 到 15 个字符' }
]}>
<Input placeholder="输入用户名" />
</Form.Item>
<Form.Item name="deptId" label="单位" rules={[{ required: true, message: '请选择单位' }]}>
<DepartmentSelect />
</Form.Item>
<Form.Item name="password" label="密码" rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度不能小于 6 位' }
]}>
<Input.Password placeholder="输入密码" />
</Form.Item>
<Form.Item name="repeatPass" label="确认密码" dependencies={['password']} hasFeedback rules={[
{ required: true, message: '请再次输入密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
}
})
]}>
<Input.Password placeholder="确认密码" />
</Form.Item>
<div className="flex items-center justify-center">
<Button loading={registerLoading} type="primary" htmlType="submit">
</Button>
</div>
</Form>
</>
)}
</div>
<div className={`transition-all h-full flex-1 text-white p-10 flex items-center justify-center bg-primary`}>
{showLogin ? (
<div className="flex flex-col">
<SineWave width={300} height={200} />
<div className="text-2xl my-4"></div>
<div className="my-4 font-thin text-sm"></div>
<div
onClick={() => 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"
>
</div>
</div>
) : (
<div className="flex flex-col">
<div className="text-2xl my-4"></div>
<div className="my-4 font-thin text-sm"></div>
<div
onClick={() => 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"
>
</div>
<SineWave width={300} height={200} />
</div>
)}
</div>
</div>
</div>
);
};
export default LoginPage;

View File

@ -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<Department>;
parentId?: string;
}
export default function DepartmentDrawer({
data,
parentId,
title,
...buttonProps
}: DepartmentDrawerProps) {
const [open, setOpen] = useState(false);
const handleTrigger = () => {
setOpen(true);
};
return (
<>
<Button {...buttonProps} onClick={handleTrigger}>{title}</Button>
<Drawer
open={open}
onClose={() => {
setOpen(false);
}}
title={title}
width={400}
>
<DepartmentForm data={data} parentId={parentId}></DepartmentForm>
</Drawer>
</>
);
}

View File

@ -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<Department>;
parentId?: string;
}) {
const { create, update, addFetchParentId } = useDepartment();
const [loading, setLoading] = useState(false);
const formRef = useRef<FormInstance>(null);
useEffect(() => {
if (parentId) formRef.current?.setFieldValue("parentId", parentId);
}, [parentId]);
return (
<Form
initialValues={data}
ref={formRef}
layout="vertical"
requiredMark="optional"
onFinish={async (values) => {
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);
}}
>
<Form.Item rules={[{ required: true }]} name={"name"} label="名称">
<Input></Input>
</Form.Item>
<Form.Item name={"parentId"} label="父单位">
<DepartmentSelect></DepartmentSelect>
</Form.Item>
<Form.Item name={"order"} label="顺序">
<InputNumber></InputNumber>
</Form.Item>
<Form.Item name={"isDomain"} valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<div className="flex justify-center items-center p-2">
<Button loading={loading} htmlType="submit" type="primary">
{" "}
</Button>
</div>
</Form>
);
}

View File

@ -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<Department>;
parentId?: string;
}
export default function DepartmentImportDrawer({
data,
parentId,
title,
...buttonProps
}: DepartmentDrawerProps) {
const [open, setOpen] = useState(false);
const [deptParentId, setDeptParentId] = useState<string | undefined>(
parentId ? parentId : undefined
);
const formRef = useRef<FormInstance>(null);
const handleTrigger = () => {
setOpen(true);
};
return (
<>
<Button {...buttonProps} onClick={handleTrigger}>
{title}
</Button>
<Drawer
open={open}
onClose={() => {
setOpen(false);
}}
title={title}
width={400}>
<Form ref={formRef} layout="vertical" requiredMark="optional">
<Form.Item
name={"parentId"}
initialValue={parentId}
label="所属父单位">
<DepartmentSelect
placeholder="选择父单位"
rootId={parentId}
onChange={(value) =>
setDeptParentId(value as string)
}></DepartmentSelect>
</Form.Item>
<div className="flex justify-center">
<ExcelImporter
type="dept"
parentId={deptParentId}></ExcelImporter>
</div>
</Form>
</Drawer>
</>
);
}

View File

@ -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<DataNode[]>([]);
const { treeData, addFetchParentId, update, deleteDepartment } =
useDepartment();
useEffect(() => {
if (treeData) {
const processedTreeData = processTreeData(treeData);
setCustomTreeData(processedTreeData);
}
}, [treeData]);
const renderTitle = (node: DataNode) => (
<div className="flex items-center justify-between w-full">
<span
className={`font-semibold mr-2 ${node.data.isDomain ? "text-blue-500" : ""}`}>
{node.data.isDomain && <BranchesOutlined className="mr-2" />}
{node.title}
</span>
<div className="flex items-center gap-2">
<DepartmentImportDrawer
title="导入子节点"
type="primary"
size="small"
parentId={node.data.id}
ghost></DepartmentImportDrawer>
<DepartmentDrawer
ghost
type="primary"
size="small"
icon={<PlusOutlined />}
title="子节点"
parentId={node.key}
/>
<DepartmentDrawer data={node.data} title="编辑" size="small" />
<Button
size="small"
danger
onClick={async () => {
await deleteDepartment.mutateAsync({
id: node.data.id,
});
}}>
</Button>
</div>
</div>
);
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 (
<div className="flex flex-col gap-4 flex-grow">
<div className="flex items-center gap-4">
<DepartmentDrawer title="新建单位" type="primary" />
<DepartmentImportDrawer
ghost
title="导入单位"
type="primary"></DepartmentImportDrawer>
</div>
{customTreeData.length > 0 ? (
<Tree
style={{ minWidth: 400 }}
loadData={onLoadData}
treeData={customTreeData}
draggable
blockNode
onDragEnter={onDragEnter}
onDrop={onDrop}
showLine={{ showLeafIcon: false }}
switcherIcon={<DownOutlined />}
onExpand={onExpand}
/>
) : (
<Empty></Empty>
)}
</div>
);
}

View File

@ -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<DataNode[]>([]);
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 (
<>
<TreeSelect
allowClear
value={selectedValue}
style={{ minWidth: 200, width }}
placeholder={placeholder}
onChange={handleChange}
loadData={onLoadData}
treeData={filteredTreeData}
treeCheckable={multiple}
showCheckedStrategy={TreeSelect.SHOW_ALL}
treeCheckStrictly={multiple}
onClear={() => handleChange(multiple ? [] : undefined)}
onTreeExpand={handleExpand}
/>
</>
);
}

View File

@ -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<string>('');
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 (
<Select
allowClear
showSearch
placeholder="请选择域"
notFoundContent={isLoading ? <Spin size="small" /> : null}
filterOption={false}
onSearch={handleSearch}
options={options}
value={value}
onChange={handleChange}
style={{ minWidth: 200, ...style }}
/>
);
}

View File

@ -0,0 +1,39 @@
import { Button, Drawer } from "antd";
import React, { useState } from "react";
import type { ButtonProps } from "antd";
import { Role } from "@nicestack/common";
import RoleForm from "./role-form";
interface RoleDrawerProps extends ButtonProps {
title: string;
data?: Partial<Role>;
}
export default function RoleDrawer({
data,
title,
...buttonProps
}: RoleDrawerProps) {
const [open, setOpen] = useState(false);
const handleTrigger = () => {
setOpen(true);
};
return (
<>
<Button {...buttonProps} onClick={handleTrigger}>{title}</Button>
<Drawer
open={open}
onClose={() => {
setOpen(false);
}}
title={title}
width={400}
>
<RoleForm data={data} ></RoleForm>
</Drawer>
</>
);
}

View File

@ -0,0 +1,64 @@
import { Button, Form, Input, message, Select } from "antd";
import { FormInstance } from "antd";
import { useEffect, useRef, useState } from "react";
import { Role, RolePerms } from "@nicestack/common";
import { useRole } from "@web/src/hooks/useRole";
const options: { value: string; label: string }[] = Object.values(RolePerms).map((permission) => ({
value: permission,
label: permission,
}));
export default function RoleForm({
data,
}: {
data?: Partial<Role>;
}) {
const { create, update } = useRole(); // Ensure you have these methods in your hooks
const [loading, setLoading] = useState(false);
const formRef = useRef<FormInstance>(null);
return (
<Form
initialValues={data}
ref={formRef}
layout="vertical"
requiredMark="optional"
onFinish={async (values) => {
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();
} catch (err) {
message.error("创建失败");
}
}
setLoading(false);
}}
>
<Form.Item rules={[{ required: true }]} name={"name"} label="名称">
<Input />
</Form.Item>
<Form.Item rules={[{ required: true }]} name="permissions" label="权限">
<Select mode="multiple" placeholder="选择权限" options={options} />
</Form.Item>
<div className="flex justify-center items-center p-2">
<Button loading={loading} htmlType="submit" type="primary">
</Button>
</div>
</Form>
);
}

View File

@ -0,0 +1,101 @@
import { useRole } from "@web/src/hooks/useRole";
import { api } from "@web/src/utils/trpc";
import { TableColumnsType, Space, Button, Table } from "antd";
import { Role } from "packages/common/dist/cjs";
import { useState, useEffect } from "react";
import RoleDrawer from "./role-drawer";
interface RoleListProps {
onChange?: (roleId: string, name?: string) => void;
}
const RoleList: React.FC<RoleListProps> = ({ onChange }) => {
const [dataSource, setDataSource] = useState<any[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const { data, isLoading } = api.role.paginate.useQuery({
page: currentPage,
pageSize,
});
const [selectedRowId, setSelectedRowId] = useState(null);
const { batchDelete } = useRole();
useEffect(() => {
if (data && data.items.length > 0) {
console.log(data.items);
setDataSource(data.items);
if (!selectedRowId) {
setSelectedRowId(data.items[0]?.id);
if (onChange) {
onChange(data.items[0].id, data.items[0].name);
}
}
}
}, [data]);
const columns: TableColumnsType<Role> = [
// { title: 'ID', dataIndex: 'id', width: 300, render: (text) => text },
{ title: "名称", dataIndex: "name", render: (text) => text },
{
title: "操作",
render: (_, record) => (
<Space size="middle">
<RoleDrawer title="编辑" data={record}></RoleDrawer>
<Button
danger
onClick={async () => {
await batchDelete.mutateAsync({ ids: [record.id] });
}}>
</Button>
</Space>
),
},
];
return (
<div className="flex flex-col space-y-4">
<div>
<RoleDrawer type="primary" title="新建角色"></RoleDrawer>
</div>
<Table
rowKey="id"
columns={columns}
dataSource={dataSource}
loading={isLoading}
pagination={{
current: currentPage,
pageSize,
total: data?.totalCount,
onChange: (page, pageSize) => {
setCurrentPage(page);
setPageSize(pageSize);
},
hideOnSinglePage: true,
}}
onRow={(record) => ({
onClick: () => {
setSelectedRowId(record.id);
if (onChange) {
onChange(record.id, record.name);
}
// console.log("Selected Row ID:", record.id);
},
style: {
backgroundColor:
selectedRowId === record.id ? "#e6f7ff" : "",
cursor: "pointer",
},
})}
rowClassName={(record) =>
record.id === selectedRowId ? "ant-table-row-selected" : ""
}
locale={{
emptyText: "暂无角色", // 自定义数据为空时的显示内容
}}
/>
</div>
);
};
export default RoleList;

View File

@ -0,0 +1,256 @@
import React, { useMemo, useEffect, useState } from "react";
import { Button, Table, Space, Divider, Segmented } from "antd";
import type { TableColumnsType } from "antd";
import { ObjectType, Staff } from "@nicestack/common";
import { TableRowSelection } from "antd/es/table/interface";
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 RoleMapDrawer from "./rolemap-drawer";
interface RoleMapTableProps {
roleId?: string | undefined;
roleName?: string | undefined;
}
const RoleMapTable: React.FC<RoleMapTableProps> = ({ roleId, roleName }) => {
const [type, setType] = useState<"dept" | "staff">("dept");
const [domainId, setDomainId] = useState<string>();
const [deptId, setDeptId] = useState<string>();
const { data: roleDetail } = api.rolemap.getRoleMapDetail.useQuery({
roleId,
domainId: domainId ? domainId : null,
});
const staffIdsList = useMemo(() => {
if (roleDetail) {
return roleDetail.staffIds;
}
return [];
}, [roleDetail]);
const deptIdsList = useMemo(() => {
if (roleDetail) {
return roleDetail.deptIds;
}
return [];
}, [roleDetail]);
api.useQueries((t) => {
return deptIdsList?.map((id) =>
t.department.getDepartmentDetails({ deptId: id })
);
});
const [dataSource, setDataSource] = useState<any[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const { data: staffData, isLoading: isStaffLoading } =
api.staff.paginate.useQuery({
page: currentPage,
pageSize,
ids: staffIdsList,
// domainId,
deptId,
});
const { data: deptData, isLoading: isDeptLoading } =
api.department.paginate.useQuery({
page: currentPage,
pageSize,
ids: deptIdsList,
});
useEffect(() => {
if (staffData && type === "staff") {
console.log(staffData.items);
setDataSource(staffData.items);
}
}, [staffData, type]);
useEffect(() => {
if (deptData && type === "dept") {
console.log(deptData.items);
setDataSource(
deptData.items.map((item) => {
return {
id: item?.id,
name: item?.name,
isDomain: item?.isDomain,
parentName: item?.parent?.name,
staffCount: item?.deptStaffs?.length,
};
})
);
}
}, [deptData, type]);
const deptColumns: TableColumnsType = [
{ title: "名称", dataIndex: "name", render: (text) => text },
{
title: "父单位",
key: "parent",
render: (_, record) => record.parentName,
},
{
title: "单位人数",
key: "parent",
render: (_, record) => record.staffCount,
},
{
title: "是否域",
key: "isDomain",
render: (_, record) => (record.isDomain ? "是" : "否"),
},
{
title: "操作",
render: (_, record) => (
<Space size="middle">
<Button
danger
onClick={async () => {
console.log(domainId);
await createManyObjects.mutateAsync({
domainId: domainId ? domainId : null,
objectType: ObjectType.DEPARTMENT,
objectIds: deptIdsList.filter(
(id) => id !== record.id
),
roleId,
});
}}>
</Button>
</Space>
),
},
];
const { createManyObjects } = useRoleMap();
const staffColumns: TableColumnsType<Staff> = [
{ title: "名称", dataIndex: "name", render: (text) => text },
{
title: "手机号",
dataIndex: "phoneNumber",
key: "phoneNumber",
},
{
title: "所属域",
key: "domain.name",
render: (_, record: any) => record.domain?.name,
},
{
title: "单位",
key: "department.name",
render: (_, record: any) => record.department?.name,
},
{
title: "操作",
render: (_, record) => (
<Space size="middle">
<Button
danger
onClick={async () => {
console.log(domainId);
console.log(record);
await createManyObjects.mutateAsync({
domainId: domainId ? domainId : null,
objectType: ObjectType.STAFF,
objectIds: staffIdsList.filter(
(id) => id !== record.id
),
roleId,
});
}}>
</Button>
</Space>
),
},
];
const [selectedIds, setSelectedRowKeys] = useState<string[]>([]);
const onSelectChange = (newSelectedRowKeys: React.Key[]) => {
setSelectedRowKeys(newSelectedRowKeys as string[]);
};
const rowSelection: TableRowSelection<Staff> = {
selectedRowKeys: selectedIds,
onChange: onSelectChange,
};
return (
<div className="flex flex-col space-y-4">
<div className="font-bold text-xl">{roleName}</div>
<div>
<Segmented
options={[
{ label: "单位", value: "dept" },
{ label: "人员", value: "staff" },
]}
onChange={setType}
/>
<Divider type="vertical"></Divider>
<DomainSelect onChange={setDomainId}></DomainSelect>
{type === "staff" && (
<>
<Divider type="vertical"></Divider>
<DepartmentSelect
width={"auto"}
rootId={domainId}
onChange={setDeptId as any}></DepartmentSelect>
</>
)}
<Divider type="vertical"></Divider>
{roleId && (
<RoleMapDrawer
roleType={type}
domainId={domainId}
roleId={roleId}
title="分配权限"></RoleMapDrawer>
)}
</div>
<div className="flex flex-col space-y-4">
{type === "staff" && (
<Table
rowKey="id"
columns={staffColumns}
dataSource={dataSource}
loading={isStaffLoading}
rowSelection={rowSelection}
pagination={{
current: currentPage,
pageSize,
total: staffData?.totalCount,
onChange: (page, pageSize) => {
setCurrentPage(page);
setPageSize(pageSize);
},
hideOnSinglePage: true,
}}
locale={{
emptyText: "暂无人员", // 自定义数据为空时的显示内容
}}
/>
)}
{type === "dept" && (
<Table
rowKey="id"
columns={deptColumns}
dataSource={dataSource}
loading={isDeptLoading}
// rowSelection={rowSelection}
pagination={{
current: currentPage,
pageSize,
total: deptData?.totalCount,
onChange: (page, pageSize) => {
setCurrentPage(page);
setPageSize(pageSize);
},
hideOnSinglePage: true,
}}
locale={{
emptyText: "暂无单位", // 自定义数据为空时的显示内容
}}
/>
)}
</div>
</div>
);
};
export default RoleMapTable;

View File

@ -0,0 +1,41 @@
import { useState } from 'react';
import { Select, Spin } from 'antd';
import type { SelectProps } from 'antd';
import { api } from '@admin/src/utils/trpc';
interface RoleSelectProps {
value?: string | string[];
onChange?: (value: string | string[]) => void;
style?: React.CSSProperties;
multiple?: boolean;
}
export default function RoleSelect({ value, onChange, style, multiple }: RoleSelectProps) {
const [keyword, setQuery] = useState<string>('');
const { data, isLoading } = api.role.findMany.useQuery({ keyword });
const handleSearch = (value: string) => {
setQuery(value);
};
const options: SelectProps['options'] = data?.map((role: any) => ({
value: role.id,
label: role.name,
})) || [];
return (
<Select
allowClear
showSearch
mode={multiple ? 'multiple' : undefined}
placeholder="请选择角色"
notFoundContent={isLoading ? <Spin size="small" /> : null}
filterOption={false}
onSearch={handleSearch}
options={options}
value={value}
onChange={onChange}
style={{ minWidth: 200, ...style }}
/>
);
}

View File

@ -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 (
<>
<Button type="primary" {...buttonProps} onClick={handleTrigger}>
{title}
</Button>
<Drawer
open={open}
onClose={() => {
setOpen(false);
}}
title={title}
width={400}>
<RoleMapForm roleType={roleType} roleId={roleId} domainId={domainId}></RoleMapForm>
</Drawer>
</>
);
}

View File

@ -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<FormInstance>(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 (
<Form
initialValues={data}
ref={formRef}
layout="vertical"
requiredMark="optional"
onFinish={async (values) => {
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);
}}>
<Form.Item name={"domainId"} label="所属域">
<DomainSelect
onChange={(value) => {
setSelectedDomainId(value);
formRef.current?.setFieldValue("domainId", value);
}}
/>
</Form.Item>
{(roleType === "staff" || roleType === "both") && (
<Form.Item name={"staffIds"} label="分配给人员">
<StaffSelect
domainId={selectedDomainId}
multiple></StaffSelect>
</Form.Item>
)}
{(roleType === "dept" || roleType === "both") && (
<Form.Item name={"deptIds"} label="分配给单位">
<DepartmentSelect
rootId={selectedDomainId}
multiple></DepartmentSelect>
</Form.Item>
)}
<div className="flex justify-center items-center p-2">
<Button loading={loading} htmlType="submit" type="primary">
</Button>
</div>
</Form>
);
}

View File

@ -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<Staff>;
deptId?: string;
domainId?: string
}
export default function StaffDrawer({
data,
deptId,
title,
domainId,
...buttonProps
}: StaffDrawerProps) {
const [open, setOpen] = useState(false);
const handleTrigger = () => {
setOpen(true);
};
return (
<>
<Button {...buttonProps} onClick={handleTrigger}>{title}</Button>
<Drawer
open={open}
onClose={() => {
setOpen(false);
}}
title={title}
width={400}
>
<StaffForm domainId={domainId} deptId={deptId} data={data} ></StaffForm>
</Drawer>
</>
);
}

View File

@ -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<Staff>;
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<FormInstance>(null);
useEffect(() => {
if (deptId) formRef.current?.setFieldValue("deptId", deptId);
}, [deptId]);
useEffect(() => {
if (domainId) {
formRef.current?.setFieldValue("domainId", domainId);
setSelectedDomainId(domainId)
}
}, [domainId]);
return (
<Form
initialValues={data}
ref={formRef}
layout="vertical"
requiredMark="optional"
onFinish={async (values) => {
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);
}}
>
<Form.Item rules={[{ required: true }]} name={"phoneNumber"} label="手机号">
<Input />
</Form.Item>
<Form.Item rules={[{ required: true }]} name={"name"} label="名称">
<Input />
</Form.Item>
<Form.Item name={'domainId'} label='所属域'>
<DomainSelect
onChange={(value) => {
setSelectedDomainId(value);
formRef.current?.setFieldValue('domainId', value);
}}
/>
</Form.Item>
<Form.Item name={'deptId'} label='所属单位'>
<DepartmentSelect rootId={selectedDomainId} />
</Form.Item>
<div className="flex justify-center items-center p-2">
<Button loading={loading} htmlType="submit" type="primary">
</Button>
</div>
</Form>
);
}

View File

@ -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<Term>;
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<string | undefined>(
domainId
);
const formRef = useRef<FormInstance>(null);
useEffect(() => {
if (domainId) {
formRef.current?.setFieldValue("domainId", domainId);
setStaffDomainId(domainId);
}
}, [domainId]);
return (
<>
<Button ghost {...buttonProps} onClick={handleTrigger}>
{title}
</Button>
<Drawer
open={open}
onClose={() => {
setOpen(false);
}}
title={title}
width={400}>
<Form ref={formRef} layout="vertical" requiredMark="optional">
<Form.Item
name={"domainId"}
initialValue={domainId}
label="所属域">
<DomainSelect
onChange={(value) => {
setStaffDomainId(value);
}}></DomainSelect>
</Form.Item>
</Form>
<div className="flex justify-center">
<ExcelImporter
disabled={!staffDomainId}
domainId={staffDomainId}
type="staff"></ExcelImporter>
</div>
</Drawer>
</>
);
}

View File

@ -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<string>('');
// 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 (
<Select
allowClear
showSearch
mode={multiple ? 'multiple' : undefined}
placeholder="请选择人员"
notFoundContent={isLoading ? <Spin size="small" /> : null}
filterOption={false}
onSearch={handleSearch}
options={options}
value={value}
onChange={onChange}
style={{ minWidth: 200, ...style }}
/>
);
}

View File

@ -0,0 +1,242 @@
import React, { useContext, useMemo, useEffect, useState } from "react";
import { DeleteOutlined, HolderOutlined } from "@ant-design/icons";
import type { DragEndEvent } from "@dnd-kit/core";
import { DndContext } from "@dnd-kit/core";
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button, Table, Space, Divider, Typography } from "antd";
import type { TableColumnsType } from "antd";
import { Staff } from "@nicestack/common";
import { useStaff } from "@web/src/hooks/useStaff";
import { api } from "@web/src/utils/trpc";
import { TableRowSelection } from "antd/es/table/interface";
import DepartmentSelect from "../department/department-select";
import DomainSelect from "../domain/domain-select";
import StaffDrawer from "./staff-drawer";
import StaffImportDrawer from "./staff-import-drawer";
interface RowContextProps {
setActivatorNodeRef?: (element: HTMLElement | null) => void;
listeners?: SyntheticListenerMap;
}
const RowContext = React.createContext<RowContextProps>({});
const DragHandle: React.FC = () => {
const { setActivatorNodeRef, listeners } = useContext(RowContext);
return (
<Button
type="text"
size="small"
icon={<HolderOutlined />}
style={{ cursor: "move" }}
ref={setActivatorNodeRef}
{...listeners}
/>
);
};
interface RowProps extends React.HTMLAttributes<HTMLTableRowElement> {
"data-row-key": string;
}
const Row: React.FC<RowProps> = (props) => {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: props["data-row-key"] });
const style: React.CSSProperties = {
...props.style,
transform: CSS.Translate.toString(transform),
transition,
...(isDragging ? { position: "relative", zIndex: 10 } : {}),
};
const contextValue = useMemo<RowContextProps>(
() => ({ setActivatorNodeRef, listeners }),
[setActivatorNodeRef, listeners]
);
return (
<RowContext.Provider value={contextValue}>
<tr {...props} ref={setNodeRef} style={style} {...attributes} />
</RowContext.Provider>
);
};
const StaffTable: React.FC = () => {
const [dataSource, setDataSource] = useState<any[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [domainId, setDomainId] = useState<string>();
const [deptId, setDeptId] = useState<string>();
const { data, isLoading } = api.staff.paginate.useQuery({
page: currentPage,
pageSize,
domainId,
deptId,
});
const [selectedIds, setSelectedRowKeys] = useState<string[]>([]);
const onSelectChange = (newSelectedRowKeys: React.Key[]) => {
setSelectedRowKeys(newSelectedRowKeys as string[]);
};
const { batchDelete, update } = useStaff();
const rowSelection: TableRowSelection<Staff> = {
selectedRowKeys: selectedIds,
onChange: onSelectChange,
};
useEffect(() => {
if (data) {
console.log(data.items);
setDataSource(data.items);
}
}, [data]);
const onDragEnd = ({ active, over }: DragEndEvent) => {
if (active.id !== over?.id) {
setDataSource((prevState) => {
const activeIndex = prevState.findIndex(
(record) => record.id === active?.id
);
const overIndex = prevState.findIndex(
(record) => record.id === over?.id
);
const newItems = arrayMove(prevState, activeIndex, overIndex);
handleUpdateOrder(JSON.parse(JSON.stringify(newItems)));
return newItems;
});
}
};
const columns: TableColumnsType<Staff> = [
{
key: "sort",
align: "center",
width: 80,
render: () => <DragHandle />,
},
{ title: "名称", dataIndex: "name", render: (text) => text },
{ title: "手机号", dataIndex: "phoneNumber", key: "phoneNumber" },
{
title: "所属域",
key: "domain.name",
render: (_, record: any) => record.domain?.name,
},
{
title: "单位",
key: "department.name",
render: (_, record: any) => record.department?.name,
},
{
title: "操作",
render: (_, record) => (
<Space size="middle">
<StaffDrawer title="编辑" data={record}></StaffDrawer>
</Space>
),
},
];
const handleDelete = async () => {
if (selectedIds.length > 0) {
await batchDelete.mutateAsync({ ids: selectedIds });
}
};
const handleUpdateOrder = async (newItems: Staff[]) => {
// Create a deep copy of newItems
const itemsCopy = JSON.parse(JSON.stringify(newItems));
const orderedItems = itemsCopy.sort((a, b) => a.order - b.order);
await Promise.all(
orderedItems.map((item, index) => {
if (item.order !== newItems[index].order) {
return update.mutateAsync({
id: newItems[index].id,
order: item.order,
});
}
})
);
};
return (
<div className="flex flex-col space-y-4">
<div className="flex items-center ">
<DomainSelect onChange={setDomainId}></DomainSelect>
<Divider type="vertical"></Divider>
<DepartmentSelect
rootId={domainId}
onChange={setDeptId as any}></DepartmentSelect>
<Divider type="vertical"></Divider>
<StaffImportDrawer
className="mr-2"
title="导入人员"
ghost
domainId={domainId}
type="primary"></StaffImportDrawer>
<StaffDrawer
domainId={domainId}
deptId={deptId}
type="primary"
title="新建人员"></StaffDrawer>
<Divider type="vertical"></Divider>
<Button
onClick={handleDelete}
disabled={selectedIds.length === 0}
danger
ghost
icon={<DeleteOutlined></DeleteOutlined>}>
</Button>
{/* Display total number of staff */}
<Divider type="vertical"></Divider>
</div>
<Typography.Text type="secondary">
{data?.totalCount}
</Typography.Text>
<DndContext
modifiers={[restrictToVerticalAxis]}
onDragEnd={onDragEnd}>
<SortableContext
items={dataSource.map((i) => i.id)}
strategy={verticalListSortingStrategy}>
<Table
rowKey="id"
pagination={{
current: currentPage,
pageSize,
total: data?.totalCount,
onChange: (page, pageSize) => {
setCurrentPage(page);
setPageSize(pageSize);
},
}}
components={{ body: { row: Row } }}
columns={columns}
dataSource={dataSource}
loading={isLoading}
rowSelection={rowSelection}
/>
</SortableContext>
</DndContext>
</div>
);
};
export default StaffTable;

View File

@ -0,0 +1,41 @@
import { Button, ButtonProps, Drawer } from "antd";
import { useMemo, useState } from "react";
import { Taxonomy } from "@nicestack/common";
import TaxonomyForm from "./taxonomy-form";
interface TaxonomyDrawerProps extends ButtonProps {
data?: Partial<Taxonomy>;
title: string;
}
export default function TaxonomyDrawer({
data,
title,
...buttonProps
}: TaxonomyDrawerProps) {
const [open, setOpen] = useState(false);
const drawerTitle = useMemo(() => {
return data ? '编辑分类法' : '创建分类法';
}, [data]);
const handleTrigger = () => {
setOpen(true);
};
return (
<>
<Button {...buttonProps} onClick={handleTrigger}>
{title}
</Button>
<Drawer
open={open}
onClose={() => {
setOpen(false);
}}
title={drawerTitle}
width={400}
>
<TaxonomyForm data={data}></TaxonomyForm>
</Drawer>
</>
);
}

View File

@ -0,0 +1,32 @@
import { Button, Form, Input } from "antd";
import { FormInstance } from "antd";
import { useRef, useState } from "react";
import { Taxonomy } from "@nicestack/common"
import { useTaxonomy } from "@web/src/hooks/useTaxonomy";
export default function TaxonomyForm({ data = undefined }: { data?: Partial<Taxonomy> }) {
const { create, update } = useTaxonomy()
const [loading, setLoading] = useState(false)
const formRef = useRef<FormInstance>(null)
return <Form initialValues={data} ref={formRef} layout="vertical" requiredMark='optional' onFinish={async (values) => {
console.log(values)
setLoading(true)
if (data) {
await update.mutateAsync({ id: data.id, ...values })
} else {
await create.mutateAsync(values)
formRef.current?.resetFields()
}
setLoading(false)
}}>
<Form.Item rules={[{ required: true }]} name={'name'} label='名称'>
<Input></Input>
</Form.Item>
{/* <Form.Item rules={[{ required: true }]} name={'slug'} label=''>
<Input></Input>
</Form.Item> */}
<div className="flex justify-center items-center p-2">
<Button loading={loading} htmlType="submit" type="primary"> </Button>
</div>
</Form>
}

View File

@ -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<string | undefined>(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 (
<Select
allowClear
value={selectedValue}
style={{ width }}
options={[
...(taxonomies?.map(tax => ({
value: tax.id,
label: tax.name
})) || []),
...extraOptions, // 添加额外选项
]}
loading={isTaxLoading}
placeholder={placeholder}
onChange={handleChange}
/>
);
}

View File

@ -0,0 +1,175 @@
import React, { useContext, useMemo, useEffect, useState } from 'react';
import { DeleteOutlined, HolderOutlined, PlusOutlined } from '@ant-design/icons';
import type { DragEndEvent } from '@dnd-kit/core';
import { DndContext } from '@dnd-kit/core';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button, Table, Space, Divider } from 'antd';
import type { TableColumnsType } from 'antd';
import { Taxonomy } from "@nicestack/common"
import { TableRowSelection } from 'antd/es/table/interface';
import { useTaxonomy } from '@web/src/hooks/useTaxonomy';
import { api } from '@web/src/utils/trpc';
import TaxonomyDrawer from './taxonomy-drawer';
interface RowContextProps {
setActivatorNodeRef?: (element: HTMLElement | null) => void;
listeners?: SyntheticListenerMap;
}
const RowContext = React.createContext<RowContextProps>({});
const DragHandle: React.FC = () => {
const { setActivatorNodeRef, listeners } = useContext(RowContext);
return (
<Button
type="text"
size="small"
icon={<HolderOutlined />}
style={{ cursor: 'move' }}
ref={setActivatorNodeRef}
{...listeners}
/>
);
};
interface RowProps extends React.HTMLAttributes<HTMLTableRowElement> {
'data-row-key': string;
}
const Row: React.FC<RowProps> = (props) => {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: props['data-row-key'] });
const style: React.CSSProperties = {
...props.style,
transform: CSS.Translate.toString(transform),
transition,
...(isDragging ? { position: 'relative', zIndex: 10 } : {}),
};
const contextValue = useMemo<RowContextProps>(
() => ({ setActivatorNodeRef, listeners }),
[setActivatorNodeRef, listeners],
);
return (
<RowContext.Provider value={contextValue}>
<tr {...props} ref={setNodeRef} style={style} {...attributes} />
</RowContext.Provider>
);
};
const TaxonomyTable: React.FC = () => {
const [dataSource, setDataSource] = useState<Taxonomy[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const { data, isLoading } = api.taxonomy.paginate.useQuery({ page: currentPage, pageSize });
const [selectedIds, setSelectedRowKeys] = useState<string[]>([]);
const onSelectChange = (newSelectedRowKeys: React.Key[]) => {
setSelectedRowKeys(newSelectedRowKeys as string[]);
};
const { batchDelete, update } = useTaxonomy();
const rowSelection: TableRowSelection<Taxonomy> = {
selectedRowKeys: selectedIds,
onChange: onSelectChange,
};
useEffect(() => {
if (data) {
setDataSource(data.items);
}
}, [data]);
const onDragEnd = ({ active, over }: DragEndEvent) => {
if (active.id !== over?.id) {
setDataSource((prevState) => {
const activeIndex = prevState.findIndex((record) => record.id === active?.id);
const overIndex = prevState.findIndex((record) => record.id === over?.id);
const newItems = arrayMove(prevState, activeIndex, overIndex);
handleUpdateOrder(JSON.parse(JSON.stringify(newItems)));
return newItems;
});
}
};
const columns: TableColumnsType<Taxonomy> = [
{ key: 'sort', align: 'center', width: 80, render: () => <DragHandle /> },
{ title: '名称', dataIndex: 'name', render: (text) => text },
// { title: '别名', dataIndex: 'slug', key: 'slug' },
{
title: '操作',
render: (_, record) => (
<Space size="middle">
<TaxonomyDrawer title='编辑' data={record}></TaxonomyDrawer>
</Space>
),
},
];
const handleDelete = async () => {
if (selectedIds.length > 0) {
await batchDelete.mutateAsync({ ids: selectedIds });
}
};
const handleUpdateOrder = async (newItems: Taxonomy[]) => {
const orderedItems = newItems.sort((a, b) => a.order - b.order);
await Promise.all(
orderedItems.map((item, index) => {
if (item.order !== newItems[index].order) {
return update.mutateAsync({ id: newItems[index].id, order: item.order });
}
})
);
};
return (
<div className='flex flex-col space-y-4'>
<div>
<TaxonomyDrawer title='新建分类法' type='primary' ></TaxonomyDrawer>
<Divider type='vertical'></Divider>
<Button onClick={handleDelete} disabled={selectedIds.length === 0} danger ghost icon={<DeleteOutlined></DeleteOutlined>}></Button>
</div>
<DndContext modifiers={[restrictToVerticalAxis]} onDragEnd={onDragEnd}>
<SortableContext items={dataSource.map((i) => i.id)} strategy={verticalListSortingStrategy}>
<Table
rowKey="id"
pagination={{
current: currentPage,
pageSize,
total: data?.totalCount,
onChange: (page, pageSize) => {
setCurrentPage(page);
setPageSize(pageSize);
}
}}
components={{ body: { row: Row } }}
columns={columns}
dataSource={dataSource}
loading={isLoading}
rowSelection={rowSelection}
/>
</SortableContext>
</DndContext>
</div>
);
};
export default TaxonomyTable;

View File

@ -0,0 +1,43 @@
import { Button, Drawer } from "antd";
import React, { useState } from "react";
import type { ButtonProps } from "antd";
import { Term } from "@nicestack/common";
import TermForm from "./term-form";
interface TermDrawerProps extends ButtonProps {
title: string;
data?: Partial<Term>;
parentId?: string;
taxonomyId: string,
domainId?: string
}
export default function TermDrawer({
data,
parentId,
title,
taxonomyId,
domainId,
...buttonProps
}: TermDrawerProps) {
const [open, setOpen] = useState(false);
const handleTrigger = () => {
setOpen(true);
};
return (
<>
<Button {...buttonProps} onClick={handleTrigger}>{title}</Button>
<Drawer
open={open}
onClose={() => {
setOpen(false);
}}
title={title}
width={400}
>
<TermForm domainId={domainId} taxonomyId={taxonomyId} data={data} parentId={parentId}></TermForm>
</Drawer>
</>
);
}

View File

@ -0,0 +1,102 @@
import { Button, Form, Input, message, Checkbox } from "antd";
import { FormInstance } from "antd";
import { useEffect, useRef, useState } from "react";
import { Term } from "@nicestack/common"; // Adjust the import path if necessary
import { useTerm } from "@web/src/hooks/useTerm";
import DepartmentSelect from "../department/department-select";
import DomainSelect from "../domain/domain-select";
import StaffSelect from "../staff/staff-select";
import TaxonomySelect from "../taxonomy/taxonomy-select";
import TermSelect from "./term-select";
export default function TermForm({
data,
taxonomyId,
parentId,
domainId
}: {
data?: Partial<Term>;
taxonomyId: string;
parentId?: string;
domainId?: string
}) {
const { create, update, addFetchParentId } = useTerm(); // Ensure you have these methods in your hooks
const [loading, setLoading] = useState(false);
const formRef = useRef<FormInstance>(null);
const [selectedDomainId, setSelectedDomainId] = useState(domainId);
useEffect(() => {
if (taxonomyId) formRef.current?.setFieldValue("taxonomyId", taxonomyId);
}, [taxonomyId]);
useEffect(() => {
if (domainId) {
formRef.current?.setFieldValue("domainId", domainId);
setSelectedDomainId(domainId)
}
}, [domainId]);
return (
<Form
initialValues={data}
ref={formRef}
layout="vertical"
requiredMark="optional"
onFinish={async (values) => {
setLoading(true);
addFetchParentId(values.parentId)
if (data) {
try {
await update.mutateAsync({ id: data.id, ...values });
} catch (err) {
message.error("更新失败");
}
} else {
try {
await create.mutateAsync(values);
formRef.current?.resetFields();
if (taxonomyId)
formRef.current?.setFieldValue("taxonomyId", taxonomyId);
if (domainId)
formRef.current?.setFieldValue("domainId", domainId);
} catch (err) {
message.error("创建失败");
}
}
setLoading(false);
}}
>
<Form.Item name={'domainId'} label='所属域'>
<DomainSelect onChange={(value) => {
setSelectedDomainId(value);
formRef.current?.setFieldValue('domainId', value);
}}></DomainSelect>
</Form.Item>
<Form.Item
rules={[{ required: true }]}
name={"taxonomyId"}
label="所属分类法"
>
<TaxonomySelect></TaxonomySelect>
</Form.Item>
<Form.Item rules={[{ required: true }]} name={"name"} label="名称">
<Input />
</Form.Item>
{/* <Form.Item rules={[{ required: true }]} name={"slug"} label="">
<Input />
</Form.Item> */}
<Form.Item initialValue={parentId} name={"parentId"} label="父术语">
<TermSelect taxonomyId={taxonomyId}></TermSelect>
</Form.Item>
<Form.Item name={'watchStaffIds'} label='可见人员'>
<StaffSelect multiple></StaffSelect>
</Form.Item>
<Form.Item name={'watchDeptIds'} label='可见单位'>
<DepartmentSelect rootId={selectedDomainId} multiple />
</Form.Item>
<div className="flex justify-center items-center p-2">
<Button loading={loading} htmlType="submit" type="primary">
</Button>
</div>
</Form>
);
}

View File

@ -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<Term>;
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<string | undefined>(
domainId
);
const [termTaxonomyId, setTermTaxonomyId] = useState<string | undefined>(
taxonomyId
);
const [termId, setTermId] = useState<string | undefined>(parentId);
const formRef = useRef<FormInstance>(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 (
<>
<Button ghost {...buttonProps} onClick={handleTrigger}>
{title}
</Button>
<Drawer
open={open}
onClose={() => {
setOpen(false);
}}
title={title}
width={400}>
<Form ref={formRef} layout="vertical" requiredMark="optional">
<Form.Item
name={"domainId"}
initialValue={domainId}
label="所属域">
<DomainSelect
onChange={(value) => {
setTermDomainId(value);
}}></DomainSelect>
</Form.Item>
<Form.Item
name={"taxonomyId"}
initialValue={taxonomyId}
label="所属分类"
required>
<TaxonomySelect
onChange={(value) => {
setTermTaxonomyId(value);
}}></TaxonomySelect>
</Form.Item>
<Form.Item name={"termId"} label="所属父节点">
<TermSelect
onChange={(value) => {
setTermId(value);
}}
taxonomyId={termTaxonomyId}></TermSelect>
</Form.Item>
</Form>
<div className="flex justify-center">
<ExcelImporter
disabled={!termTaxonomyId}
domainId={termDomainId}
taxonomyId={termTaxonomyId}
parentId={termId}
type="term"></ExcelImporter>
</div>
</Drawer>
</>
);
}

View File

@ -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<DataNode[]>([]);
const [checkedTermIds, setCheckedTermIds] = useState<string[]>([]);
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) => (
<div className="flex items-center justify-between w-full">
<span className={`font-semibold mr-2 `}>{node.title}</span>
<div className="flex items-center gap-2">
<TermDrawer
domainId={domainId}
taxonomyId={taxonomyId}
ghost
type="primary"
size="small"
icon={<PlusOutlined />}
title="子节点"
parentId={node.key}
/>
<TermDrawer
taxonomyId={taxonomyId}
data={node.data}
title="编辑"
size="small"
/>
</div>
</div>
);
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 (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<DomainSelect showAll onChange={setDomainId}></DomainSelect>
<TaxonomySelect
onChange={(value) => setTaxonomyId(value)}
defaultValue={taxonomyId}
width={200}
/>
<TermImportDrawer
disabled={!taxonomyId}
domainId={domainId}
title="导入术语"
type="primary"
taxonomyId={taxonomyId}
/>
<TermDrawer
disabled={!taxonomyId}
domainId={domainId}
title="新建术语"
type="primary"
taxonomyId={taxonomyId}
/>
<Button
danger
disabled={checkedTermIds.length === 0}
onClick={handleBatchDelete}>
</Button>
</div>
{customTreeData.length > 0 ? (
<Tree
style={{ minWidth: 400 }}
loadData={onLoadData}
treeData={customTreeData}
draggable
blockNode
onDragEnter={onDragEnter}
onDrop={onDrop}
onExpand={onExpand}
onCheck={onCheck}
checkable
checkStrictly
showLine={{ showLeafIcon: false }}
switcherIcon={<DownOutlined />}
/>
) : (
<Empty />
)}
</div>
);
}

View File

@ -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<DataNode[]>([]);
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<string | undefined>(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 (
<TreeSelect
allowClear
value={selectedValue}
style={{ width }}
placeholder={placeholder}
onChange={handleChange}
loadData={onLoadData}
onTreeExpand={handleExpand}
treeData={customTreeData}
/>
);
}

View File

@ -0,0 +1,110 @@
import React, { useRef, useEffect } from 'react';
interface CanvasProps {
width: number;
height: number;
}
const SineWavesCanvas: React.FC<CanvasProps> = ({ width, height }) => {
const canvasRef = useRef<HTMLCanvasElement | null>(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 <canvas ref={canvasRef} width={width} height={height} className="block"></canvas>;
};
export default SineWavesCanvas;

View File

@ -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<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const { importTerms, importDepts, importStaffs } =
useTransform();
return (
<div className={className}>
<Button
size={size}
ghost={ghost}
type="primary"
loading={loading}
disabled={loading || disabled}
onClick={() => {
fileInput.current?.click();
}}>
{name}
</Button>
<input
ref={fileInput}
accept="application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
type="file"
onChange={async (e) => {
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" }}
/>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { useAuth } from '@web/src/providers/auth-provider'; import { useAuth } from '@web/src/providers/auth-provider';
import { RolePerms } from '@nicestack/common'; import { RolePerms } from '@nicestack/common';
import { ReactNode } from 'react'; 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. // Define a type for the props that the HOC will accept.
interface WithAuthProps { interface WithAuthProps {
permissions?: RolePerms[]; permissions?: RolePerms[];
@ -10,12 +10,13 @@ interface WithAuthProps {
// Create the HOC function. // Create the HOC function.
export default function WithAuth({ options = {}, children }: { children: ReactNode, options?: WithAuthProps }) { export default function WithAuth({ options = {}, children }: { children: ReactNode, options?: WithAuthProps }) {
const { isAuthenticated, user, isLoading } = useAuth(); const { isAuthenticated, user, isLoading } = useAuth();
const location = useLocation()
if (isLoading) { if (isLoading) {
return <div>Loading...</div>; return <div>Loading...</div>;
} }
// If the user is not authenticated, redirect them to the login page. // If the user is not authenticated, redirect them to the login page.
if (!isAuthenticated) { if (!isAuthenticated) {
return <Navigate to={'/login'}></Navigate> return <Navigate to={`/login?redirect_url=${location.pathname}`} ></Navigate>
} }
if (options.permissions && user) { if (options.permissions && user) {

View File

@ -0,0 +1,31 @@
import { useState, useEffect, useRef } from 'react';
/**
* Hook: 等待 state
* @param {T} value - state
* @param {T} targetValue - state promise
* @return {Promise<void>} - Promise state targetValue resolve
*/
function useAwaitState<T>(value: T, targetValue: T): Promise<void> {
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;

View File

@ -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<string[]>([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<DataNode[]>([]);
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<string, DepartmentDto>();
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<DepartmentDto>(queryClient, api.department, key);
};
return {
deleteDepartment,
update,
findById,
create,
getTreeData,
addFetchParentId,
fetchParentIds,
treeData,
getDept,
};
}

View File

@ -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
};
}

View File

@ -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,
};
}

View File

@ -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
};
}

View File

@ -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
};
}

View File

@ -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<string[]>([null]);
const [domainId, setDomainId] = useState<string>(undefined)
const [taxonomyId, setTaxonomyId] = useState<string>(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<DataNode[]>([]);
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
};
}

View File

@ -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,
};
}

View File

@ -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 apiClient from '../utils/axios-client';
import { UserProfile } from '@nicestack/common'; import { UserProfile } from '@nicestack/common';
@ -7,7 +7,7 @@ interface AuthContextProps {
refreshToken: string | null; refreshToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
user: any; user: UserProfile | null;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
refreshAccessToken: () => Promise<void>; refreshAccessToken: () => Promise<void>;
@ -17,23 +17,27 @@ interface AuthContextProps {
} }
const AuthContext = createContext<AuthContextProps | undefined>(undefined); const AuthContext = createContext<AuthContextProps | undefined>(undefined);
export const useAuth = (): AuthContextProps => {
export function useAuth(): AuthContextProps {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (!context) { if (!context) {
throw new Error('useAuth must be used within an AuthProvider'); throw new Error('useAuth must be used within an AuthProvider');
} }
return context; return context;
}; };
interface AuthProviderProps { interface AuthProviderProps {
children: ReactNode; children: ReactNode;
} }
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
export function AuthProvider({ children }: AuthProviderProps) {
const [accessToken, setAccessToken] = useState<string | null>(localStorage.getItem('access_token')); const [accessToken, setAccessToken] = useState<string | null>(localStorage.getItem('access_token'));
const [refreshToken, setRefreshToken] = useState<string | null>(localStorage.getItem('refresh_token')); const [refreshToken, setRefreshToken] = useState<string | null>(localStorage.getItem('refresh_token'));
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(!!localStorage.getItem('access_token')); const [isAuthenticated, setIsAuthenticated] = useState<boolean>(!!localStorage.getItem('access_token'));
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null); const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
const [user, setUser] = useState<UserProfile | null>(null); const [user, setUser] = useState<UserProfile | null>(JSON.parse(localStorage.getItem('user_profile') || 'null'));
const initializeAuth = useCallback(() => { const initializeAuth = useCallback(() => {
const storedAccessToken = localStorage.getItem('access_token'); const storedAccessToken = localStorage.getItem('access_token');
const storedRefreshToken = localStorage.getItem('refresh_token'); const storedRefreshToken = localStorage.getItem('refresh_token');
@ -47,6 +51,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
fetchUserProfile(); fetchUserProfile();
} }
}, []); }, []);
const refreshAccessToken = useCallback(async () => { const refreshAccessToken = useCallback(async () => {
if (!refreshToken) return; if (!refreshToken) return;
try { try {
@ -70,11 +75,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
if (intervalId) { if (intervalId) {
clearInterval(intervalId); clearInterval(intervalId);
} }
await refreshAccessToken(); await refreshAccessToken();
const newIntervalId = setInterval(refreshAccessToken, 10 * 60 * 1000); // 10 minutes const newIntervalId = setInterval(refreshAccessToken, 10 * 60 * 1000); // 10 minutes
setIntervalId(newIntervalId); setIntervalId(newIntervalId);
}, [intervalId, refreshAccessToken]); }, [intervalId, refreshAccessToken]);
@ -110,6 +112,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
localStorage.removeItem('refresh_token'); localStorage.removeItem('refresh_token');
localStorage.removeItem('access_token_expires_at'); localStorage.removeItem('access_token_expires_at');
localStorage.removeItem('refresh_token_expires_at'); localStorage.removeItem('refresh_token_expires_at');
localStorage.removeItem('user_profile');
setAccessToken(null); setAccessToken(null);
setRefreshToken(null); setRefreshToken(null);
@ -131,7 +134,9 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const fetchUserProfile = useCallback(async () => { const fetchUserProfile = useCallback(async () => {
try { try {
const response = await apiClient.get(`/auth/user-profile`); 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) { } catch (err) {
console.error("Fetching user profile failed", err); console.error("Fetching user profile failed", err);
} }

View File

@ -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 (
<ConfigProvider theme={{
algorithm: theme.defaultAlgorithm,
// algorithm: [theme.darkAlgorithm, theme.compactAlgorithm],
}}>
{children}
</ConfigProvider>
);
}

View File

@ -1,13 +1,29 @@
import { import {
createBrowserRouter createBrowserRouter,
IndexRouteObject,
NonIndexRouteObject
} from "react-router-dom"; } from "react-router-dom";
import MainPage from "../app/main/page"; import MainPage from "../app/main/page";
import ErrorPage from "../app/error"; import ErrorPage from "../app/error";
import LayoutPage from "../app/layout"; import LayoutPage from "../app/layout";
import LoginPage from "../app/login"; 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: "/", path: "/",
element: <LayoutPage></LayoutPage>, element: <LayoutPage></LayoutPage>,
@ -16,7 +32,49 @@ export const router = createBrowserRouter([
{ {
index: true, index: true,
element: <WithAuth><MainPage></MainPage></WithAuth> element: <WithAuth><MainPage></MainPage></WithAuth>
} },
{
path: "admin",
children: [
{
path: "department",
breadcrumb: "单位管理",
element: (
<WithAuth>
<DepartmentAdminPage></DepartmentAdminPage>
</WithAuth>
),
},
{
path: "staff",
breadcrumb: "人员管理",
element: (
<WithAuth>
<StaffAdminPage></StaffAdminPage>
</WithAuth>
),
},
{
path: "term",
breadcrumb: "术语管理",
element: (
<WithAuth>
<TermAdminPage></TermAdminPage>
</WithAuth>
),
},
{
path: "role",
breadcrumb: "角色管理",
element: (
<WithAuth>
<RoleAdminPage></RoleAdminPage>
</WithAuth>
),
}
],
},
], ],
}, },
{ {
@ -24,4 +82,5 @@ export const router = createBrowserRouter([
element: <LoginPage></LoginPage> element: <LoginPage></LoginPage>
} }
]); ]
export const router = createBrowserRouter(routes);

View File

@ -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<File> => {
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<string> => {
return new Promise((resolve, reject) => {
uploader(endpoint, file, onProgress, resolve, reject)
.then((upload) => {
upload.start();
})
.catch(reject);
});
};

View File

@ -5,7 +5,27 @@ export default {
"./src/**/*.{js,ts,jsx,tsx}", "./src/**/*.{js,ts,jsx,tsx}",
], ],
theme: { 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: [], plugins: [],
} }

View File

@ -21,6 +21,7 @@
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"strictNullChecks": false,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,

View File

@ -1 +1 @@
DATABASE_URL=postgresql://root:Letusdoit000@192.168.197.77:5432/lxminiapp DATABASE_URL=postgresql://root:Letusdoit000@192.168.116.77:5432/lxminiapp

View File

@ -88,6 +88,7 @@ model Comment {
model Staff { model Staff {
id String @id @default(uuid()) id String @id @default(uuid())
showname String?
username String @unique username String @unique
password String password String
phoneNumber String? @unique phoneNumber String? @unique

View File

@ -4,13 +4,16 @@ export const AuthSchema = {
signInRequset: z.object({ signInRequset: z.object({
username: z.string(), username: z.string(),
password: z.string(), password: z.string(),
phoneNumber: z.string().nullish()
}), }),
signUpRequest: z.object({ signUpRequest: z.object({
username: z.string(), username: z.string(),
password: z.string(), password: z.string(),
phoneNumber: z.string().nullish()
}), }),
changePassword: z.object({ changePassword: z.object({
username: z.string(), username: z.string(),
phoneNumber: z.string().nullish(),
oldPassword: z.string(), oldPassword: z.string(),
newPassword: z.string(), newPassword: z.string(),
}), }),
@ -26,6 +29,7 @@ export const StaffSchema = {
username: z.string(), username: z.string(),
password: z.string(), password: z.string(),
domainId: z.string().nullish(), domainId: z.string().nullish(),
phoneNumber: z.string().nullish()
}), }),
update: z.object({ update: z.object({
id: z.string(), id: z.string(),
@ -161,3 +165,102 @@ export const RoleSchema = {
keyword: z.string().nullish(), 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(),
}),
};

View File

@ -1,4 +1,4 @@
import { Department, Staff } from "@prisma/client"; import { Department, Staff, Term } from "@prisma/client";
export interface DataNode { export interface DataNode {
title: any; title: any;
@ -39,3 +39,14 @@ export interface GenPerms {
delete?: boolean; delete?: boolean;
read?: boolean; read?: boolean;
} }
export type TermDto = Term & {
permissions: GenPerms;
children: TermDto[];
hasChildren: boolean;
};
export type DepartmentDto = Department & {
parent: DepartmentDto;
children: DepartmentDto[];
hasChildren: boolean;
staffs: StaffDto[];
};

View File

@ -18,6 +18,9 @@
], ],
"@web/*": [ "@web/*": [
"apps/web/*" "apps/web/*"
],
"@admin/*": [
"apps/admin/*"
] ]
} }
} }