09101031
This commit is contained in:
parent
b8c73030d8
commit
5630af88ba
|
@ -9,9 +9,10 @@ import { TransformModule } from './transform/transform.module';
|
|||
import { AuthModule } from './auth/auth.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TasksModule } from './tasks/tasks.module';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), TrpcModule, RedisModule, QueueModule, TransformModule, AuthModule],
|
||||
imports: [ScheduleModule.forRoot(), TrpcModule, RedisModule, QueueModule, TransformModule, AuthModule, TasksModule],
|
||||
providers: [RedisService, SocketGateway, ConfigService],
|
||||
})
|
||||
export class AppModule { }
|
||||
|
|
|
@ -8,21 +8,22 @@ import { AuthGuard } from './auth.guard';
|
|||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) { }
|
||||
@UseGuards(AuthGuard)
|
||||
@Get("user-profile")
|
||||
async getUserProfile(@Req() request: Request) {
|
||||
const user: JwtPayload = (request as any).user
|
||||
console.log(user)
|
||||
// console.log(request)
|
||||
return this.authService.getUserProfile(user)
|
||||
}
|
||||
@Post('login')
|
||||
async login(@Body() body: z.infer<typeof AuthSchema.signInRequset>) {
|
||||
return this.authService.signIn(body);
|
||||
}
|
||||
|
||||
@Post('signup')
|
||||
async signup(@Body() body: z.infer<typeof AuthSchema.signUpRequest>) {
|
||||
return this.authService.signUp(body);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard) // Protecting the refreshToken endpoint with AuthGuard
|
||||
@Post('refresh-token')
|
||||
async refreshToken(@Body() body: z.infer<typeof AuthSchema.refreshTokenRequest>) {
|
||||
|
|
|
@ -15,6 +15,7 @@ export class AuthGuard implements CanActivate {
|
|||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
console.log(token)
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
@ -25,6 +26,7 @@ export class AuthGuard implements CanActivate {
|
|||
secret: env.JWT_SECRET
|
||||
}
|
||||
);
|
||||
|
||||
// 💡 We're assigning the payload to the request object here
|
||||
// so that we can access it in our route handlers
|
||||
request['user'] = payload;
|
||||
|
|
|
@ -8,12 +8,13 @@ import { RoleMapService } from '@server/rbac/rolemap.service';
|
|||
import { DepartmentService } from '@server/models/department/department.service';
|
||||
|
||||
@Module({
|
||||
providers: [AuthService, StaffService, RoleMapService,DepartmentService],
|
||||
providers: [AuthService, StaffService, RoleMapService, DepartmentService],
|
||||
imports: [JwtModule.register({
|
||||
global: true,
|
||||
secret: env.JWT_SECRET,
|
||||
signOptions: { expiresIn: '60s' },
|
||||
}),],
|
||||
controllers: [AuthController]
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService]
|
||||
})
|
||||
export class AuthModule { }
|
||||
|
|
|
@ -20,15 +20,24 @@ export class AuthService {
|
|||
) { }
|
||||
|
||||
async signIn(data: z.infer<typeof AuthSchema.signInRequset>) {
|
||||
const { username, password } = data;
|
||||
const staff = await db.staff.findUnique({ where: { username } });
|
||||
const { username, password, phoneNumber } = data;
|
||||
// Find the staff by either username or phoneNumber
|
||||
const staff = await db.staff.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username },
|
||||
{ phoneNumber }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (!staff) {
|
||||
throw new UnauthorizedException('Invalid username or password');
|
||||
throw new UnauthorizedException('Invalid username/phone number or password');
|
||||
}
|
||||
|
||||
const isPasswordMatch = await bcrypt.compare(password, staff.password);
|
||||
if (!isPasswordMatch) {
|
||||
throw new UnauthorizedException('Invalid username or password');
|
||||
throw new UnauthorizedException('Invalid username/phone number or password');
|
||||
}
|
||||
|
||||
const payload: JwtPayload = { sub: staff.id, username: staff.username };
|
||||
|
@ -99,22 +108,27 @@ export class AuthService {
|
|||
}
|
||||
|
||||
async signUp(data: z.infer<typeof AuthSchema.signUpRequest>) {
|
||||
const { username, password } = data;
|
||||
const existingUser = await db.staff.findUnique({ where: { username } });
|
||||
const { username, password, phoneNumber } = data;
|
||||
|
||||
if (existingUser) {
|
||||
const existingUserByUsername = await db.staff.findUnique({ where: { username } });
|
||||
if (existingUserByUsername) {
|
||||
throw new BadRequestException('Username is already taken');
|
||||
}
|
||||
|
||||
if (phoneNumber) {
|
||||
const existingUserByPhoneNumber = await db.staff.findUnique({ where: { phoneNumber } });
|
||||
if (existingUserByPhoneNumber) {
|
||||
throw new BadRequestException('Phone number is already taken');
|
||||
}
|
||||
}
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const staff = await this.staffService.create({
|
||||
username,
|
||||
phoneNumber,
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
return staff;
|
||||
}
|
||||
|
||||
async logout(data: z.infer<typeof AuthSchema.logoutRequest>) {
|
||||
const { refreshToken } = data;
|
||||
await db.refreshToken.deleteMany({ where: { token: refreshToken } });
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export const env: { JWT_SECRET: string } = {
|
||||
JWT_SECRET: process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA='
|
||||
export const env: { JWT_SECRET: string, APP_URL: string } = {
|
||||
JWT_SECRET: process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA=',
|
||||
APP_URL: process.env.APP_URL || 'http://localhost:5173'
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { InitService } from './init.service';
|
||||
import { AuthModule } from '@server/auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
providers: [InitService],
|
||||
exports: [InitService]
|
||||
})
|
||||
|
|
|
@ -44,19 +44,27 @@ export class InitService {
|
|||
|
||||
private async createRoot() {
|
||||
this.logger.log('Checking for root account');
|
||||
const rootAccountExists = await db.staff.findUnique({
|
||||
where: { phoneNumber: process.env.ADMIN_PHONE_NUMBER || '000000' },
|
||||
const rootAccountExists = await db.staff.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
phoneNumber: process.env.ADMIN_PHONE_NUMBER || '000000'
|
||||
},
|
||||
{
|
||||
username: 'root'
|
||||
}
|
||||
]
|
||||
},
|
||||
});
|
||||
if (!rootAccountExists) {
|
||||
this.logger.log('Creating root account');
|
||||
const rootStaff =await this.authService.signUp({
|
||||
const rootStaff = await this.authService.signUp({
|
||||
username: 'root',
|
||||
password: 'admin'
|
||||
password: 'root'
|
||||
})
|
||||
const rootRole = await db.role.findUnique({
|
||||
where: { name: '根管理员' },
|
||||
});
|
||||
|
||||
if (rootRole) {
|
||||
this.logger.log('Assigning root role to root account');
|
||||
await db.roleMap.create({
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { TrpcRouter } from './trpc/trpc.router';
|
||||
import { env } from './env';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors({
|
||||
origin: [process.env.APP_URL!],
|
||||
origin: [env.APP_URL],
|
||||
credentials: true
|
||||
});
|
||||
const trpc = app.get(TrpcRouter);
|
||||
trpc.applyMiddleware(app);
|
||||
|
|
|
@ -9,7 +9,6 @@ export class DepartmentRouter {
|
|||
private readonly trpc: TrpcService,
|
||||
private readonly departmentService: DepartmentService, // inject DepartmentService
|
||||
) {}
|
||||
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(DepartmentSchema.create) // expect input according to the schema
|
||||
|
|
|
@ -125,7 +125,6 @@ export class DepartmentService {
|
|||
}
|
||||
async paginate(data: z.infer<typeof DepartmentSchema.paginate>) {
|
||||
const { page, pageSize, ids } = data;
|
||||
|
||||
const [items, totalCount] = await Promise.all([
|
||||
db.department.findMany({
|
||||
skip: (page - 1) * pageSize,
|
||||
|
|
|
@ -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 { }
|
|
@ -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();
|
||||
})
|
||||
});
|
||||
}
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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 { }
|
|
@ -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);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -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 父节点ID,如果是根节点则为null
|
||||
* @returns 下一个排序值
|
||||
*/
|
||||
private async getNextOrder(parentId?: string) {
|
||||
let newOrder = 0;
|
||||
|
||||
if (parentId) {
|
||||
const siblingTerms = await db.term.findMany({
|
||||
where: { parentId },
|
||||
orderBy: { order: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (siblingTerms.length > 0) {
|
||||
newOrder = siblingTerms[0].order + 1;
|
||||
}
|
||||
} else {
|
||||
const rootTerms = await db.term.findMany({
|
||||
where: { parentId: null },
|
||||
orderBy: { order: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (rootTerms.length > 0) {
|
||||
newOrder = rootTerms[0].order + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return newOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建关系数据用于批量插入。
|
||||
*
|
||||
* @param termId 术语ID
|
||||
* @param watchDeptIds 监控部门ID数组
|
||||
* @param watchStaffIds 监控员工ID数组
|
||||
* @returns 关系数据数组
|
||||
*/
|
||||
private createRelations(
|
||||
termId: string,
|
||||
watchDeptIds: string[],
|
||||
watchStaffIds: string[]
|
||||
) {
|
||||
const relationsData = [
|
||||
...watchDeptIds.map(bId => this.relations.buildRelation(termId, bId, ObjectType.TERM, ObjectType.DEPARTMENT, RelationType.WATCH)),
|
||||
...watchStaffIds.map(bId => this.relations.buildRelation(termId, bId, ObjectType.TERM, ObjectType.STAFF, RelationType.WATCH)),
|
||||
];
|
||||
return relationsData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个新的术语并根据需要创建祖先关系。
|
||||
*
|
||||
* @param data 创建新术语的数据
|
||||
* @returns 新创建的术语
|
||||
*/
|
||||
async create(staff: Staff, data: z.infer<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
|
||||
});
|
||||
}
|
||||
}
|
|
@ -5,10 +5,12 @@ import { TrpcService } from '@server/trpc/trpc.service';
|
|||
import { RoleService } from './role.service';
|
||||
import { RoleMapRouter } from './rolemap.router';
|
||||
import { DepartmentModule } from '@server/models/department/department.module';
|
||||
import { RolePermsService } from './roleperms.service';
|
||||
import { RelationService } from '@server/relation/relation.service';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentModule],
|
||||
providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter],
|
||||
exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter]
|
||||
providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter, RolePermsService, RelationService],
|
||||
exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter, RolePermsService]
|
||||
})
|
||||
export class RoleMapModule { }
|
||||
export class RbacModule { }
|
||||
|
|
|
@ -2,30 +2,24 @@ import { Injectable } from '@nestjs/common';
|
|||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { RoleMapSchema } from '@nicestack/common';
|
||||
import { RoleMapService } from './rolemap.service';
|
||||
|
||||
@Injectable()
|
||||
export class RoleMapRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly roleMapService: RoleMapService,
|
||||
) {}
|
||||
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
|
||||
deleteAllRolesForObject: this.trpc.protectProcedure
|
||||
.input(RoleMapSchema.deleteWithObject)
|
||||
.mutation(({ input }) =>
|
||||
this.roleMapService.deleteAllRolesForObject(input),
|
||||
),
|
||||
|
||||
setRoleForObject: this.trpc.protectProcedure
|
||||
.input(RoleMapSchema.create)
|
||||
.mutation(({ input }) => this.roleMapService.setRoleForObject(input)),
|
||||
|
||||
createManyObjects: this.trpc.protectProcedure
|
||||
.input(RoleMapSchema.createManyObjects)
|
||||
.mutation(({ input }) => this.roleMapService.createManyObjects(input)),
|
||||
|
||||
setRolesForObject: this.trpc.protectProcedure
|
||||
.input(RoleMapSchema.createManyRoles)
|
||||
.mutation(({ input }) => this.roleMapService.setRolesForObject(input)),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -2,8 +2,13 @@ import { Module } from '@nestjs/common';
|
|||
import { TransformService } from './transform.service';
|
||||
import { TransformRouter } from './transform.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentModule } from '@server/models/department/department.module';
|
||||
import { StaffModule } from '@server/models/staff/staff.module';
|
||||
import { TaxonomyModule } from '@server/models/taxonomy/taxonomy.module';
|
||||
import { TermModule } from '@server/models/term/term.module';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentModule, StaffModule, TaxonomyModule, TermModule],
|
||||
providers: [TransformService, TransformRouter, TrpcService],
|
||||
exports: [TransformRouter]
|
||||
})
|
||||
|
|
|
@ -1,13 +1,32 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { TransformService } from './transform.service';
|
||||
|
||||
|
||||
import { TransformSchema } from '@nicestack/common';
|
||||
import { TrpcService } from '../trpc/trpc.service';
|
||||
@Injectable()
|
||||
export class TransformRouter {
|
||||
constructor(private readonly trpc: TrpcService, private readonly transformService: TransformService) { }
|
||||
|
||||
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);
|
||||
}),
|
||||
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,4 +1,496 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import {
|
||||
z,
|
||||
db,
|
||||
Prisma,
|
||||
Staff,
|
||||
TransformSchema
|
||||
} from '@nicestack/common';
|
||||
import * as dayjs from 'dayjs';
|
||||
import { StaffService } from '../models/staff/staff.service';
|
||||
import { DepartmentService } from '../models/department/department.service';
|
||||
import { TaxonomyService } from '@server/models/taxonomy/taxonomy.service';
|
||||
|
||||
class TreeNode {
|
||||
value: string;
|
||||
children: TreeNode[];
|
||||
|
||||
constructor(value: string) {
|
||||
this.value = value;
|
||||
this.children = [];
|
||||
}
|
||||
|
||||
addChild(childValue: string): TreeNode {
|
||||
let newChild = undefined;
|
||||
if (this.children.findIndex((child) => child.value === childValue) === -1) {
|
||||
newChild = new TreeNode(childValue);
|
||||
this.children.push(newChild);
|
||||
}
|
||||
return this.children.find((child) => child.value === childValue);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformService {}
|
||||
export class TransformService {
|
||||
constructor(
|
||||
private readonly departmentService: DepartmentService,
|
||||
private readonly staffService: StaffService,
|
||||
private readonly taxonomyService: TaxonomyService,
|
||||
) { }
|
||||
private readonly logger = new Logger(TransformService.name);
|
||||
|
||||
excelDateToISO(excelDate: number) {
|
||||
// 设置 Excel 序列号的起点
|
||||
const startDate = dayjs('1899-12-31');
|
||||
// 加上 Excel 中的天数(注意必须减去2,因为 Excel 错误地把1900年当作闰年)
|
||||
const date = startDate.add(excelDate, 'day');
|
||||
// 转换为 ISO 字符串
|
||||
return date.toDate();
|
||||
}
|
||||
async getDepts(domainId: string, cellStr: string) {
|
||||
const pattern = /[\s、,,;.。;\n]+/;
|
||||
const depts: string[] = [];
|
||||
if (pattern.test(cellStr)) {
|
||||
const deptNames = cellStr.split(pattern);
|
||||
for (const name of deptNames) {
|
||||
const dept = await this.departmentService.findByNameInDom(
|
||||
domainId,
|
||||
name,
|
||||
);
|
||||
if (dept) depts.push(dept.id);
|
||||
}
|
||||
} else {
|
||||
const dept = await this.departmentService.findByNameInDom(
|
||||
domainId,
|
||||
cellStr,
|
||||
);
|
||||
if (dept) depts.push(dept.id);
|
||||
}
|
||||
|
||||
if (depts.length === 0) {
|
||||
this.logger.error(`未找到单位:${cellStr}`);
|
||||
}
|
||||
return depts;
|
||||
}
|
||||
async getStaffs(deptIds: string[], cellStr: string) {
|
||||
const staffs: string[] = [];
|
||||
const pattern = /[\s、,,;.。;\n]+/;
|
||||
const allStaffsArrays = await Promise.all(
|
||||
deptIds.map((deptId) => this.staffService.findByDept({ deptId })),
|
||||
);
|
||||
const combinedStaffs = allStaffsArrays.reduce(
|
||||
(acc, curr) => acc.concat(curr),
|
||||
[],
|
||||
);
|
||||
if (pattern.test(cellStr)) {
|
||||
const staffNames = cellStr.split(pattern);
|
||||
|
||||
for (const name of staffNames) {
|
||||
if (combinedStaffs.map((staff, index) => staff.showname).includes(name)) {
|
||||
const staffWithName = combinedStaffs.find(
|
||||
(staff) => staff.showname === name,
|
||||
);
|
||||
if (staffWithName) {
|
||||
// 将该员工的 ID 添加到 staffIds 数组中
|
||||
staffs.push(staffWithName.id);
|
||||
}
|
||||
}
|
||||
// if (staff) staffs.push(staff.staffId);
|
||||
}
|
||||
} else {
|
||||
// const staff = await this.lanxin.getStaffsByDepartment(deptIds);
|
||||
// if (staff) staffs.push(staff.staffId);
|
||||
if (combinedStaffs.map((staff, index) => staff.showname).includes(cellStr)) {
|
||||
const staffWithName = combinedStaffs.find(
|
||||
(staff) => staff.showname === cellStr,
|
||||
);
|
||||
if (staffWithName) {
|
||||
// 将该员工的 ID 添加到 staffIds 数组中
|
||||
staffs.push(staffWithName.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (staffs.length === 0) {
|
||||
this.logger.error(`未找到人员:${cellStr}`);
|
||||
}
|
||||
return staffs;
|
||||
}
|
||||
|
||||
buildTree(data: string[][]): TreeNode {
|
||||
const root = new TreeNode('root');
|
||||
try {
|
||||
for (const path of data) {
|
||||
let currentNode = root;
|
||||
for (const value of path) {
|
||||
currentNode = currentNode.addChild(value);
|
||||
}
|
||||
}
|
||||
return root;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
async generateTreeFromFile(file: Buffer): Promise<{ tree: TreeNode }> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file);
|
||||
const worksheet = workbook.getWorksheet(1);
|
||||
|
||||
const data: string[][] = [];
|
||||
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber > 1) {
|
||||
// Skip header row if any
|
||||
try {
|
||||
const rowData: string[] = (row.values as string[])
|
||||
.slice(2)
|
||||
.map((cell) => (cell || '').toString());
|
||||
data.push(rowData.map((value) => value.trim()));
|
||||
} catch (error) {
|
||||
throw new Error(`发生报错!位于第${rowNumber}行,错误: ${error}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Fill forward values
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
for (let j = 0; j < data[i].length; j++) {
|
||||
if (!data[i][j]) data[i][j] = data[i - 1][j];
|
||||
}
|
||||
}
|
||||
return { tree: this.buildTree(data) };
|
||||
}
|
||||
printTree(node: TreeNode, level: number = 0): void {
|
||||
const indent = ' '.repeat(level);
|
||||
console.log(`${indent}${node.value}`);
|
||||
for (const child of node.children) {
|
||||
this.printTree(child, level + 1);
|
||||
}
|
||||
}
|
||||
swapKeyValue<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('更新单位信息或者创建单位闭包表失败');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -7,9 +7,12 @@ import { StaffRouter } from '@server/models/staff/staff.router';
|
|||
import { StaffModule } from '../models/staff/staff.module';
|
||||
import { DepartmentModule } from '@server/models/department/department.module';
|
||||
import { TransformModule } from '@server/transform/transform.module';
|
||||
import { TermModule } from '@server/models/term/term.module';
|
||||
import { TaxonomyModule } from '@server/models/taxonomy/taxonomy.module';
|
||||
import { RbacModule } from '@server/rbac/rbac.module';
|
||||
|
||||
@Module({
|
||||
imports: [StaffModule, DepartmentModule, TransformModule],
|
||||
imports: [StaffModule, DepartmentModule, TransformModule, TermModule, TaxonomyModule, RbacModule],
|
||||
controllers: [],
|
||||
providers: [TrpcService, TrpcRouter],
|
||||
})
|
||||
|
|
|
@ -1,20 +1,34 @@
|
|||
import { INestApplication, Injectable } from '@nestjs/common';
|
||||
import { TransformRouter } from '@server/transform/transform.router';
|
||||
import { DepartmentRouter } from '@server/models/department/department.router';
|
||||
import { StaffRouter } from '@server/models/staff/staff.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import * as trpcExpress from '@trpc/server/adapters/express';
|
||||
import { TransformRouter } from '../transform/transform.router';
|
||||
import { TaxonomyRouter } from '@server/models/taxonomy/taxonomy.router';
|
||||
import { TermRouter } from '@server/models/term/term.router';
|
||||
import { RoleRouter } from '@server/rbac/role.router';
|
||||
import { RoleMapRouter } from '@server/rbac/rolemap.router';
|
||||
|
||||
@Injectable()
|
||||
export class TrpcRouter {
|
||||
constructor(private readonly trpc: TrpcService,
|
||||
private readonly staff: StaffRouter,
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly department: DepartmentRouter,
|
||||
private readonly transform: TransformRouter
|
||||
private readonly staff: StaffRouter,
|
||||
private readonly term: TermRouter,
|
||||
private readonly taxonomy: TaxonomyRouter,
|
||||
private readonly role: RoleRouter,
|
||||
private readonly rolemap: RoleMapRouter,
|
||||
private readonly transform: TransformRouter,
|
||||
) { }
|
||||
appRouter = this.trpc.router({
|
||||
staff: this.staff.router,
|
||||
transform: this.transform.router,
|
||||
department: this.department.router,
|
||||
transform: this.transform.router
|
||||
staff: this.staff.router,
|
||||
term: this.term.router,
|
||||
taxonomy: this.taxonomy.router,
|
||||
role: this.role.router,
|
||||
rolemap: this.rolemap.router,
|
||||
});
|
||||
async applyMiddleware(app: INestApplication) {
|
||||
app.use(
|
||||
|
@ -28,4 +42,3 @@ export class TrpcRouter {
|
|||
}
|
||||
|
||||
export type AppRouter = TrpcRouter[`appRouter`];
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,11 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@nicestack/common": "workspace:^",
|
||||
"@tanstack/query-async-storage-persister": "^5.51.9",
|
||||
"@tanstack/react-query": "^5.51.1",
|
||||
|
@ -19,12 +24,15 @@
|
|||
"@trpc/client": "11.0.0-rc.456",
|
||||
"@trpc/react-query": "11.0.0-rc.456",
|
||||
"@trpc/server": "11.0.0-rc.456",
|
||||
"antd": "^5.20.6",
|
||||
"axios": "^1.7.3",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.24.1",
|
||||
"superjson": "^2.2.1",
|
||||
"tus-js-client": "^4.1.0",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -5,12 +5,15 @@ import {
|
|||
import QueryProvider from './providers/query-provider'
|
||||
import { router } from './routes';
|
||||
import { AuthProvider } from './providers/auth-provider';
|
||||
import ThemeProvider from './providers/theme-provider';
|
||||
function App() {
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<QueryProvider>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router}></RouterProvider>
|
||||
</ThemeProvider>
|
||||
</QueryProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -1,3 +1,162 @@
|
|||
export default function LoginPage() {
|
||||
return 'LoginPage'
|
||||
}
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Form, Input, Button, message } from 'antd';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import DepartmentSelect from '../components/models/department/department-select';
|
||||
import { useAuth } from '../providers/auth-provider';
|
||||
import SineWave from '../components/presentation/animation/sine-wave';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const [showLogin, setShowLogin] = useState(true);
|
||||
const [registerLoading, setRegisterLoading] = useState(false);
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const loginFormRef = useRef<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;
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { useAuth } from '@web/src/providers/auth-provider';
|
||||
import { RolePerms } from '@nicestack/common';
|
||||
import { ReactNode } from 'react';
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
// Define a type for the props that the HOC will accept.
|
||||
interface WithAuthProps {
|
||||
permissions?: RolePerms[];
|
||||
|
@ -10,12 +10,13 @@ interface WithAuthProps {
|
|||
// Create the HOC function.
|
||||
export default function WithAuth({ options = {}, children }: { children: ReactNode, options?: WithAuthProps }) {
|
||||
const { isAuthenticated, user, isLoading } = useAuth();
|
||||
const location = useLocation()
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
// If the user is not authenticated, redirect them to the login page.
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={'/login'}></Navigate>
|
||||
return <Navigate to={`/login?redirect_url=${location.pathname}`} ></Navigate>
|
||||
|
||||
}
|
||||
if (options.permissions && user) {
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
|
||||
};
|
||||
}
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
|
||||
};
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import apiClient from '../utils/axios-client';
|
||||
import { UserProfile } from '@nicestack/common';
|
||||
|
||||
|
@ -7,7 +7,7 @@ interface AuthContextProps {
|
|||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
user: any;
|
||||
user: UserProfile | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshAccessToken: () => Promise<void>;
|
||||
|
@ -17,23 +17,27 @@ interface AuthContextProps {
|
|||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextProps | undefined>(undefined);
|
||||
export const useAuth = (): AuthContextProps => {
|
||||
|
||||
export function useAuth(): AuthContextProps {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [accessToken, setAccessToken] = useState<string | null>(localStorage.getItem('access_token'));
|
||||
const [refreshToken, setRefreshToken] = useState<string | null>(localStorage.getItem('refresh_token'));
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(!!localStorage.getItem('access_token'));
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
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 storedAccessToken = localStorage.getItem('access_token');
|
||||
const storedRefreshToken = localStorage.getItem('refresh_token');
|
||||
|
@ -47,6 +51,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||
fetchUserProfile();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshAccessToken = useCallback(async () => {
|
||||
if (!refreshToken) return;
|
||||
try {
|
||||
|
@ -70,11 +75,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
|
||||
await refreshAccessToken();
|
||||
|
||||
const newIntervalId = setInterval(refreshAccessToken, 10 * 60 * 1000); // 10 minutes
|
||||
|
||||
setIntervalId(newIntervalId);
|
||||
}, [intervalId, refreshAccessToken]);
|
||||
|
||||
|
@ -110,6 +112,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('access_token_expires_at');
|
||||
localStorage.removeItem('refresh_token_expires_at');
|
||||
localStorage.removeItem('user_profile');
|
||||
|
||||
setAccessToken(null);
|
||||
setRefreshToken(null);
|
||||
|
@ -131,7 +134,9 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||
const fetchUserProfile = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiClient.get(`/auth/user-profile`);
|
||||
setUser(response.data);
|
||||
const userProfile = response.data;
|
||||
setUser(userProfile);
|
||||
localStorage.setItem('user_profile', JSON.stringify(userProfile));
|
||||
} catch (err) {
|
||||
console.error("Fetching user profile failed", err);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,13 +1,29 @@
|
|||
import {
|
||||
createBrowserRouter
|
||||
createBrowserRouter,
|
||||
IndexRouteObject,
|
||||
NonIndexRouteObject
|
||||
} from "react-router-dom";
|
||||
import MainPage from "../app/main/page";
|
||||
import ErrorPage from "../app/error";
|
||||
import LayoutPage from "../app/layout";
|
||||
import LoginPage from "../app/login";
|
||||
import WithAuth from "../components/auth/with-auth";
|
||||
import DepartmentAdminPage from "../app/admin/department/page";
|
||||
import RoleAdminPage from "../app/admin/role/page";
|
||||
import StaffAdminPage from "../app/admin/staff/page";
|
||||
import TermAdminPage from "../app/admin/term/page";
|
||||
import WithAuth from "../components/utilities/with-auth";
|
||||
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||
name?: string;
|
||||
breadcrumb?: string;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
interface CustomNonIndexRouteObject extends NonIndexRouteObject {
|
||||
name?: string;
|
||||
children?: CustomRouteObject[];
|
||||
breadcrumb?: string;
|
||||
}
|
||||
type CustomRouteObject = CustomIndexRouteObject | CustomNonIndexRouteObject;
|
||||
export const routes = [
|
||||
{
|
||||
path: "/",
|
||||
element: <LayoutPage></LayoutPage>,
|
||||
|
@ -16,12 +32,55 @@ export const router = createBrowserRouter([
|
|||
{
|
||||
index: true,
|
||||
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>
|
||||
),
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage></LoginPage>
|
||||
}
|
||||
|
||||
]);
|
||||
]
|
||||
export const router = createBrowserRouter(routes);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
};
|
|
@ -5,7 +5,27 @@ export default {
|
|||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "var(--color-primary)",
|
||||
},
|
||||
backgroundColor: {
|
||||
layout: "var(--bg-layout)",
|
||||
mask: "var(--bg-mask)",
|
||||
container: "var(--bg-container)",
|
||||
},
|
||||
textColor: {
|
||||
default: "var(--color-text)",
|
||||
quaternary: "var(--color-text-quaternary)",
|
||||
placeholder: "var(--color-text-placeholder)",
|
||||
description: "var(--color-text-description)",
|
||||
secondary: "var(--color-text-secondary)",
|
||||
tertiary: "var(--color-text-tertiary)",
|
||||
},
|
||||
borderColor: {
|
||||
colorDefault: "var(--color-border)",
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
|
@ -21,6 +21,7 @@
|
|||
"jsx": "react-jsx",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"strictNullChecks": false,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
|
|
@ -1 +1 @@
|
|||
DATABASE_URL=postgresql://root:Letusdoit000@192.168.197.77:5432/lxminiapp
|
||||
DATABASE_URL=postgresql://root:Letusdoit000@192.168.116.77:5432/lxminiapp
|
|
@ -88,6 +88,7 @@ model Comment {
|
|||
|
||||
model Staff {
|
||||
id String @id @default(uuid())
|
||||
showname String?
|
||||
username String @unique
|
||||
password String
|
||||
phoneNumber String? @unique
|
||||
|
|
|
@ -4,13 +4,16 @@ export const AuthSchema = {
|
|||
signInRequset: z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
phoneNumber: z.string().nullish()
|
||||
}),
|
||||
signUpRequest: z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
phoneNumber: z.string().nullish()
|
||||
}),
|
||||
changePassword: z.object({
|
||||
username: z.string(),
|
||||
phoneNumber: z.string().nullish(),
|
||||
oldPassword: z.string(),
|
||||
newPassword: z.string(),
|
||||
}),
|
||||
|
@ -26,6 +29,7 @@ export const StaffSchema = {
|
|||
username: z.string(),
|
||||
password: z.string(),
|
||||
domainId: z.string().nullish(),
|
||||
phoneNumber: z.string().nullish()
|
||||
}),
|
||||
update: z.object({
|
||||
id: z.string(),
|
||||
|
@ -161,3 +165,102 @@ export const RoleSchema = {
|
|||
keyword: z.string().nullish(),
|
||||
}),
|
||||
};
|
||||
export const TaxonomySchema = {
|
||||
create: z.object({
|
||||
name: z.string(),
|
||||
// slug: z.string().min(1), // Assuming slug cannot be empty
|
||||
}),
|
||||
delete: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
findByName: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
findById: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
batchDelete: z.object({
|
||||
ids: z.array(z.string()),
|
||||
}),
|
||||
update: z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullish(),
|
||||
// slug: z.string().nullish(),
|
||||
order: z.number().nullish(),
|
||||
}),
|
||||
paginate: z.object({
|
||||
page: z.number().min(1),
|
||||
pageSize: z.number().min(1),
|
||||
}),
|
||||
};
|
||||
export const TermSchema = {
|
||||
create: z.object({
|
||||
name: z.string(),
|
||||
description: z.string().nullish(),
|
||||
domainId: z.string().nullish(),
|
||||
// slug: z.string().min(1), // Assuming slug cannot be empty
|
||||
parentId: z.string().nullish(), // Optional field
|
||||
taxonomyId: z.string(), // Optional field
|
||||
watchStaffIds: z.array(z.string()).nullish(),
|
||||
watchDeptIds: z.array(z.string()).nullish(),
|
||||
}),
|
||||
update: z.object({
|
||||
id: z.string(),
|
||||
description: z.string().nullish(),
|
||||
parentId: z.string().nullish(),
|
||||
domainId: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
// slug: z.string().nullish(),
|
||||
taxonomyId: z.string().nullish(),
|
||||
order: z.number().nullish(),
|
||||
watchStaffIds: z.array(z.string()).nullish(),
|
||||
watchDeptIds: z.array(z.string()).nullish(),
|
||||
}),
|
||||
delete: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
paginate: z.object({
|
||||
page: z.number().min(1),
|
||||
pageSize: z.number().min(1),
|
||||
}),
|
||||
batchDelete: z.object({
|
||||
ids: z.array(z.string()),
|
||||
}),
|
||||
cursorList: z.object({
|
||||
cursor: z.string().nullish(),
|
||||
search: z.string().nullish(),
|
||||
limit: z.number().min(1).max(100).nullish(),
|
||||
taxonomyId: z.string(),
|
||||
id: z.string(),
|
||||
}),
|
||||
getChildren: z.object({
|
||||
parentId: z.string().nullish(),
|
||||
domainId: z.string().nullish(),
|
||||
taxonomyId: z.string().nullish(),
|
||||
cursor: z.string().nullish(),
|
||||
limit: z.number().min(1).max(100).nullish(),
|
||||
}),
|
||||
findMany: z.object({
|
||||
keyword: z.string().nullish(),
|
||||
ids: z.array(z.string()).nullish(),
|
||||
taxonomyId: z.string().nullish(),
|
||||
}),
|
||||
};
|
||||
export const TransformSchema = {
|
||||
importStaffs: z.object({
|
||||
base64: z.string(),
|
||||
domainId: z.string().nullish(),
|
||||
}),
|
||||
importTerms: z.object({
|
||||
base64: z.string(),
|
||||
domainId: z.string().nullish(),
|
||||
taxonomyId: z.string().nullish(),
|
||||
parentId: z.string().nullish(),
|
||||
}),
|
||||
importDepts: z.object({
|
||||
base64: z.string(),
|
||||
domainId: z.string().nullish(),
|
||||
parentId: z.string().nullish(),
|
||||
}),
|
||||
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Department, Staff } from "@prisma/client";
|
||||
import { Department, Staff, Term } from "@prisma/client";
|
||||
|
||||
export interface DataNode {
|
||||
title: any;
|
||||
|
@ -39,3 +39,14 @@ export interface GenPerms {
|
|||
delete?: 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[];
|
||||
};
|
||||
|
|
|
@ -18,6 +18,9 @@
|
|||
],
|
||||
"@web/*": [
|
||||
"apps/web/*"
|
||||
],
|
||||
"@admin/*": [
|
||||
"apps/admin/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue