Compare commits
10 Commits
55e5412f5e
...
7042f3ef48
Author | SHA1 | Date |
---|---|---|
|
7042f3ef48 | |
|
1a4b112599 | |
|
e5f3954e67 | |
|
6c26d26c2a | |
|
b9426cd0ea | |
|
bb91a86bb8 | |
|
348c6780ff | |
|
3d1470f742 | |
|
240f122edb | |
|
946969b503 |
|
@ -1,3 +1,4 @@
|
|||
export const env: { JWT_SECRET: string } = {
|
||||
JWT_SECRET: process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA='
|
||||
}
|
||||
JWT_SECRET:
|
||||
process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA=',
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@ import { db } from '@nice/common';
|
|||
|
||||
@Controller('dept')
|
||||
export class DepartmentController {
|
||||
constructor(private readonly deptService: DepartmentService) { }
|
||||
constructor(private readonly deptService: DepartmentService) {}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('get-detail')
|
||||
async getDepartmentDetails(@Query('dept-id') deptId: string) {
|
||||
|
|
|
@ -6,8 +6,13 @@ import { DepartmentController } from './department.controller';
|
|||
import { DepartmentRowService } from './department.row.service';
|
||||
|
||||
@Module({
|
||||
providers: [DepartmentService, DepartmentRouter, DepartmentRowService, TrpcService],
|
||||
providers: [
|
||||
DepartmentService,
|
||||
DepartmentRouter,
|
||||
DepartmentRowService,
|
||||
TrpcService,
|
||||
],
|
||||
exports: [DepartmentService, DepartmentRouter],
|
||||
controllers: [DepartmentController],
|
||||
})
|
||||
export class DepartmentModule { }
|
||||
export class DepartmentModule {}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
// apps/server/src/models/device/device.controller.ts
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { DeviceService } from './device.service';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Controller('device')
|
||||
export class DeviceController {
|
||||
constructor(private readonly deviceService: DeviceService) {}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DeviceService } from './device.service';
|
||||
import { DeviceRouter } from './device.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentModule } from '../department/department.module';
|
||||
import { DeviceController } from './device.controller';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentModule],
|
||||
providers: [DeviceService, DeviceRouter, TrpcService],
|
||||
exports: [DeviceService, DeviceRouter],
|
||||
controllers: [DeviceController],
|
||||
})
|
||||
export class DeviceModule {}
|
|
@ -0,0 +1,50 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DeviceService } from './device.service';
|
||||
import { Prisma } from '@nice/common';
|
||||
import { z, ZodType } from 'zod';
|
||||
|
||||
const DeviceUncheckedCreateInputSchema: ZodType<Prisma.DeviceUncheckedCreateInput> =
|
||||
z.any();
|
||||
const DeviceWhereInputSchema: ZodType<Prisma.DeviceWhereInput> = z.any();
|
||||
const DeviceSelectSchema: ZodType<Prisma.DeviceSelect> = z.any();
|
||||
const DeviceUpdateArgsSchema: ZodType<Prisma.DeviceUpdateArgs> = z.any();
|
||||
const DeviceFindFirstArgsSchema: ZodType<Prisma.DeviceFindFirstArgs> = z.any();
|
||||
const DeviceFindManyArgsSchema: ZodType<Prisma.DeviceFindManyArgs> = z.any();
|
||||
|
||||
@Injectable()
|
||||
export class DeviceRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly deviceService: DeviceService,
|
||||
) {}
|
||||
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.procedure
|
||||
.input(DeviceUncheckedCreateInputSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
console.log(input);
|
||||
return this.deviceService.create({ data: input });
|
||||
}),
|
||||
update: this.trpc.procedure
|
||||
.input(DeviceUpdateArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.deviceService.update(input);
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(DeviceFindManyArgsSchema)
|
||||
.query(async ({ input }) => {
|
||||
return this.deviceService.findMany(input);
|
||||
}),
|
||||
softDeleteByIds: this.trpc.procedure
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.mutation(async ({ input }) => {
|
||||
return this.deviceService.softDeleteByIds(input.ids);
|
||||
}),
|
||||
findFirst: this.trpc.procedure
|
||||
.input(DeviceFindFirstArgsSchema)
|
||||
.query(async ({ input }) => {
|
||||
return this.deviceService.findFirst(input);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import { db, ObjectType, Prisma, UserProfile } from '@nice/common';
|
||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
import { DefaultArgs } from '@prisma/client/runtime/library';
|
||||
|
||||
@Injectable()
|
||||
export class DeviceService extends BaseService<Prisma.DeviceDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.DEVICE, false);
|
||||
}
|
||||
async create(args: Prisma.DeviceCreateArgs) {
|
||||
const result = await super.create(args);
|
||||
this.emitDataChanged(CrudOperation.CREATED, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async update(args: Prisma.DeviceUpdateArgs) {
|
||||
const result = await super.update(args);
|
||||
this.emitDataChanged(CrudOperation.UPDATED, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async findMany(args: Prisma.DeviceFindManyArgs) {
|
||||
const result = await super.findMany(args);
|
||||
return result;
|
||||
}
|
||||
async findFirst(args: Prisma.DeviceFindFirstArgs) {
|
||||
const result = await super.findFirst(args);
|
||||
return result;
|
||||
}
|
||||
async softDeleteByIds(ids: string[]) {
|
||||
const result = await super.softDeleteByIds(ids);
|
||||
this.emitDataChanged(CrudOperation.DELETED, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private emitDataChanged(operation: CrudOperation, data: any) {
|
||||
EventBus.emit('dataChanged', {
|
||||
type: ObjectType.DEVICE,
|
||||
operation,
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,125 +0,0 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { MessageService } from './message.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db, VisitType } from '@nice/common';
|
||||
|
||||
@Controller('message')
|
||||
export class MessageController {
|
||||
constructor(private readonly messageService: MessageService) { }
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('find-last-one')
|
||||
async findLastOne(@Query('staff-id') staffId: string) {
|
||||
try {
|
||||
const result = await db.message.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
receivers: {
|
||||
some: {
|
||||
id: staffId,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
title: true,
|
||||
content: true,
|
||||
url: true
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('find-unreaded')
|
||||
async findUnreaded(@Query('staff-id') staffId: string) {
|
||||
try {
|
||||
const result = await db.message.findMany({
|
||||
where: {
|
||||
visits: {
|
||||
none: {
|
||||
id: staffId,
|
||||
type: VisitType.READED
|
||||
},
|
||||
},
|
||||
receivers: {
|
||||
some: {
|
||||
id: staffId,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
title: true,
|
||||
content: true,
|
||||
url: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('count-unreaded')
|
||||
async countUnreaded(@Query('staff-id') staffId: string) {
|
||||
try {
|
||||
const result = await db.message.findMany({
|
||||
where: {
|
||||
visits: {
|
||||
none: {
|
||||
id: staffId,
|
||||
type: VisitType.READED
|
||||
},
|
||||
},
|
||||
receivers: {
|
||||
some: {
|
||||
id: staffId,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
title: true,
|
||||
content: true,
|
||||
url: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MessageService } from './message.service';
|
||||
import { MessageRouter } from './message.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentModule } from '../department/department.module';
|
||||
import { MessageController } from './message.controller';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentModule],
|
||||
providers: [MessageService, MessageRouter, TrpcService],
|
||||
exports: [MessageService, MessageRouter],
|
||||
controllers: [MessageController],
|
||||
})
|
||||
export class MessageModule { }
|
|
@ -1,39 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { MessageService } from './message.service';
|
||||
import { Prisma } from '@nice/common';
|
||||
import { z, ZodType } from 'zod';
|
||||
const MessageUncheckedCreateInputSchema: ZodType<Prisma.MessageUncheckedCreateInput> = z.any()
|
||||
const MessageWhereInputSchema: ZodType<Prisma.MessageWhereInput> = z.any()
|
||||
const MessageSelectSchema: ZodType<Prisma.MessageSelect> = z.any()
|
||||
@Injectable()
|
||||
export class MessageRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly messageService: MessageService,
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.procedure
|
||||
.input(MessageUncheckedCreateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.messageService.create({ data: input }, { staff });
|
||||
}),
|
||||
findManyWithCursor: this.trpc.protectProcedure
|
||||
.input(z.object({
|
||||
cursor: z.any().nullish(),
|
||||
take: z.number().nullish(),
|
||||
where: MessageWhereInputSchema.nullish(),
|
||||
select: MessageSelectSchema.nullish()
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.messageService.findManyWithCursor(input, staff);
|
||||
}),
|
||||
getUnreadCount: this.trpc.protectProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.messageService.getUnreadCount(staff);
|
||||
})
|
||||
})
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { UserProfile, db, Prisma, VisitType, ObjectType } from '@nice/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
import { setMessageRelation } from './utils';
|
||||
@Injectable()
|
||||
export class MessageService extends BaseService<Prisma.MessageDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.MESSAGE);
|
||||
}
|
||||
async create(args: Prisma.MessageCreateArgs, params?: { tx?: Prisma.MessageDelegate, staff?: UserProfile }) {
|
||||
args.data!.senderId = params?.staff?.id;
|
||||
args.include = {
|
||||
receivers: {
|
||||
select: { id: true, registerToken: true, username: true }
|
||||
}
|
||||
}
|
||||
const result = await super.create(args);
|
||||
EventBus.emit("dataChanged", {
|
||||
type: ObjectType.MESSAGE,
|
||||
operation: CrudOperation.CREATED,
|
||||
data: result
|
||||
})
|
||||
return result
|
||||
}
|
||||
async findManyWithCursor(
|
||||
args: Prisma.MessageFindManyArgs,
|
||||
staff?: UserProfile,
|
||||
) {
|
||||
|
||||
return this.wrapResult(super.findManyWithCursor(args), async (result) => {
|
||||
let { items } = result;
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
await setMessageRelation(item, staff);
|
||||
}),
|
||||
);
|
||||
|
||||
return { ...result, items };
|
||||
});
|
||||
}
|
||||
async getUnreadCount(staff?: UserProfile) {
|
||||
const count = await db.message.count({
|
||||
where: {
|
||||
receivers: { some: { id: staff?.id } },
|
||||
visits: {
|
||||
none: {
|
||||
visitorId: staff?.id,
|
||||
type: VisitType.READED
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return count
|
||||
}
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
import { Message, UserProfile, VisitType, db } from "@nice/common"
|
||||
export async function setMessageRelation(
|
||||
data: Message,
|
||||
staff?: UserProfile,
|
||||
): Promise<any> {
|
||||
|
||||
const readed =
|
||||
(await db.visit.count({
|
||||
where: {
|
||||
messageId: data.id,
|
||||
type: VisitType.READED,
|
||||
visitorId: staff?.id,
|
||||
},
|
||||
})) > 0;
|
||||
|
||||
|
||||
Object.assign(data, {
|
||||
readed
|
||||
})
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { PostService } from './post.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db } from '@nice/common';
|
||||
|
||||
@Controller('post')
|
||||
export class PostController {
|
||||
constructor(private readonly postService: PostService) {}
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentService } from '@server/models/department/department.service';
|
||||
|
||||
import { QueueModule } from '@server/queue/queue.module';
|
||||
import { MessageModule } from '../message/message.module';
|
||||
import { PostRouter } from './post.router';
|
||||
import { PostController } from './post.controller';
|
||||
import { PostService } from './post.service';
|
||||
import { RoleMapModule } from '../rbac/rbac.module';
|
||||
|
||||
@Module({
|
||||
imports: [QueueModule, RoleMapModule, MessageModule],
|
||||
providers: [PostService, PostRouter, TrpcService, DepartmentService],
|
||||
exports: [PostRouter, PostService],
|
||||
controllers: [PostController],
|
||||
})
|
||||
export class PostModule {}
|
|
@ -1,138 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { CourseMethodSchema, Prisma } from '@nice/common';
|
||||
import { PostService } from './post.service';
|
||||
import { z, ZodType } from 'zod';
|
||||
import { UpdateOrderArgs } from '../base/base.type';
|
||||
const PostCreateArgsSchema: ZodType<Prisma.PostCreateArgs> = z.any();
|
||||
const PostUpdateArgsSchema: ZodType<Prisma.PostUpdateArgs> = z.any();
|
||||
const PostUpdateOrderArgsSchema: ZodType<UpdateOrderArgs> = z.any();
|
||||
const PostFindFirstArgsSchema: ZodType<Prisma.PostFindFirstArgs> = z.any();
|
||||
const PostFindManyArgsSchema: ZodType<Prisma.PostFindManyArgs> = z.any();
|
||||
const PostDeleteManyArgsSchema: ZodType<Prisma.PostDeleteManyArgs> = z.any();
|
||||
const PostWhereInputSchema: ZodType<Prisma.PostWhereInput> = z.any();
|
||||
const PostSelectSchema: ZodType<Prisma.PostSelect> = z.any();
|
||||
const PostUpdateInputSchema: ZodType<Prisma.PostUpdateInput> = z.any();
|
||||
@Injectable()
|
||||
export class PostRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly postService: PostService,
|
||||
) {}
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(PostCreateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.create(input, { staff });
|
||||
}),
|
||||
createCourse: this.trpc.protectProcedure
|
||||
.input(CourseMethodSchema.createCourse)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.createCourse(input, { staff });
|
||||
}),
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()),
|
||||
data: PostUpdateInputSchema.nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.postService.softDeleteByIds(input.ids, input.data);
|
||||
}),
|
||||
restoreByIds: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()),
|
||||
args: PostUpdateInputSchema.nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.postService.restoreByIds(input.ids, input.args);
|
||||
}),
|
||||
update: this.trpc.protectProcedure
|
||||
.input(PostUpdateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.update(input, staff);
|
||||
}),
|
||||
findById: this.trpc.procedure
|
||||
.input(z.object({ id: z.string(), args: PostFindFirstArgsSchema }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.findById(input.id, input.args);
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(PostFindManyArgsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.findMany(input);
|
||||
}),
|
||||
|
||||
findFirst: this.trpc.procedure
|
||||
.input(PostFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { staff, ip } = ctx;
|
||||
// 从请求中获取 IP
|
||||
|
||||
return await this.postService.findFirst(input, staff, ip);
|
||||
}),
|
||||
deleteMany: this.trpc.protectProcedure
|
||||
.input(PostDeleteManyArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.postService.deleteMany(input);
|
||||
}),
|
||||
findManyWithCursor: this.trpc.procedure
|
||||
.input(
|
||||
z.object({
|
||||
cursor: z.any().nullish(),
|
||||
take: z.number().nullish(),
|
||||
where: PostWhereInputSchema.nullish(),
|
||||
select: PostSelectSchema.nullish(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.findManyWithCursor(input, staff);
|
||||
}),
|
||||
findManyWithPagination: this.trpc.procedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().optional(),
|
||||
pageSize: z.number().optional(),
|
||||
where: PostWhereInputSchema.optional(),
|
||||
select: PostSelectSchema.optional(),
|
||||
}),
|
||||
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.postService.findManyWithPagination(input);
|
||||
}),
|
||||
updateOrder: this.trpc.protectProcedure
|
||||
.input(PostUpdateOrderArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.updateOrder(input);
|
||||
}),
|
||||
updateOrderByIds: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.updateOrderByIds(input.ids);
|
||||
}),
|
||||
softDeletePostDescendant:this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ancestorId:z.string()
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input })=>{
|
||||
return await this.postService.softDeletePostDescendant(input)
|
||||
})
|
||||
});
|
||||
}
|
|
@ -1,328 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
Prisma,
|
||||
UserProfile,
|
||||
VisitType,
|
||||
Post,
|
||||
PostType,
|
||||
RolePerms,
|
||||
ResPerm,
|
||||
ObjectType,
|
||||
LectureType,
|
||||
CourseMethodSchema,
|
||||
} from '@nice/common';
|
||||
import { MessageService } from '../message/message.service';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import { DepartmentService } from '../department/department.service';
|
||||
import { setPostInfo, setPostRelation } from './utils';
|
||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
import { BaseTreeService } from '../base/base.tree.service';
|
||||
import { z } from 'zod';
|
||||
import dayjs from 'dayjs';
|
||||
import { OrderByArgs } from '../base/base.type';
|
||||
|
||||
@Injectable()
|
||||
export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||
constructor(
|
||||
private readonly messageService: MessageService,
|
||||
private readonly departmentService: DepartmentService,
|
||||
) {
|
||||
super(db, ObjectType.POST, 'postAncestry', true);
|
||||
}
|
||||
async createLecture(
|
||||
lecture: z.infer<typeof CourseMethodSchema.createLecture>,
|
||||
params: { staff?: UserProfile; tx?: Prisma.TransactionClient },
|
||||
) {
|
||||
const { sectionId, type, title, content, resourceIds = [] } = lecture;
|
||||
const { staff, tx } = params;
|
||||
return await this.create(
|
||||
{
|
||||
data: {
|
||||
type: PostType.LECTURE,
|
||||
parentId: sectionId,
|
||||
content: content,
|
||||
title: title,
|
||||
authorId: params?.staff?.id,
|
||||
updatedAt: dayjs().toDate(),
|
||||
resources: {
|
||||
connect: resourceIds.map((fileId) => ({ fileId })),
|
||||
},
|
||||
meta: {
|
||||
type: type,
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
{ tx },
|
||||
);
|
||||
}
|
||||
async createSection(
|
||||
section: z.infer<typeof CourseMethodSchema.createSection>,
|
||||
params: {
|
||||
staff?: UserProfile;
|
||||
tx?: Prisma.TransactionClient;
|
||||
},
|
||||
) {
|
||||
const { title, courseId, lectures } = section;
|
||||
const { staff, tx } = params;
|
||||
// Create section first
|
||||
const createdSection = await this.create(
|
||||
{
|
||||
data: {
|
||||
type: PostType.SECTION,
|
||||
parentId: courseId,
|
||||
title: title,
|
||||
authorId: staff?.id,
|
||||
updatedAt: dayjs().toDate(),
|
||||
} as any,
|
||||
},
|
||||
{ tx },
|
||||
);
|
||||
// If lectures are provided, create them
|
||||
if (lectures && lectures.length > 0) {
|
||||
const lecturePromises = lectures.map((lecture) =>
|
||||
this.createLecture(
|
||||
{
|
||||
sectionId: createdSection.id,
|
||||
...lecture,
|
||||
},
|
||||
params,
|
||||
),
|
||||
);
|
||||
|
||||
// Create all lectures in parallel
|
||||
await Promise.all(lecturePromises);
|
||||
}
|
||||
return createdSection;
|
||||
}
|
||||
async createCourse(
|
||||
args: {
|
||||
courseDetail: Prisma.PostCreateArgs;
|
||||
},
|
||||
params: { staff?: UserProfile; tx?: Prisma.TransactionClient },
|
||||
) {
|
||||
|
||||
const { courseDetail } = args;
|
||||
// If no transaction is provided, create a new one
|
||||
if (!params.tx) {
|
||||
return await db.$transaction(async (tx) => {
|
||||
const courseParams = { ...params, tx };
|
||||
// Create the course first
|
||||
const createdCourse = await this.create(courseDetail, courseParams);
|
||||
// If sections are provided, create them
|
||||
return createdCourse;
|
||||
});
|
||||
}
|
||||
// If transaction is provided, use it directly
|
||||
const createdCourse = await this.create(courseDetail, params);
|
||||
// If sections are provided, create them
|
||||
return createdCourse;
|
||||
}
|
||||
async create(
|
||||
args: Prisma.PostCreateArgs,
|
||||
params?: { staff?: UserProfile; tx?: Prisma.TransactionClient },
|
||||
) {
|
||||
args.data.authorId = params?.staff?.id;
|
||||
args.data.updatedAt = dayjs().toDate();
|
||||
|
||||
const result = await super.create(args);
|
||||
EventBus.emit('dataChanged', {
|
||||
type: ObjectType.POST,
|
||||
operation: CrudOperation.CREATED,
|
||||
data: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) {
|
||||
//args.data.authorId = staff?.id;
|
||||
args.data.updatedAt = dayjs().toDate();
|
||||
const result = await super.update(args);
|
||||
EventBus.emit('dataChanged', {
|
||||
type: ObjectType.POST,
|
||||
operation: CrudOperation.UPDATED,
|
||||
data: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
async findFirst(
|
||||
args?: Prisma.PostFindFirstArgs,
|
||||
staff?: UserProfile,
|
||||
clientIp?: string,
|
||||
) {
|
||||
const transDto = await this.wrapResult(
|
||||
super.findFirst(args),
|
||||
async (result) => {
|
||||
if (result) {
|
||||
await setPostRelation({ data: result, staff });
|
||||
await this.setPerms(result, staff);
|
||||
await setPostInfo({ data: result });
|
||||
}
|
||||
// console.log(result);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
return transDto;
|
||||
}
|
||||
|
||||
async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
|
||||
if (!args.where) args.where = {};
|
||||
args.where.OR = await this.preFilter(args.where.OR, staff);
|
||||
return this.wrapResult(super.findManyWithCursor(args), async (result) => {
|
||||
const { items } = result;
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
await setPostRelation({ data: item, staff });
|
||||
await this.setPerms(item, staff);
|
||||
}),
|
||||
);
|
||||
return { ...result, items };
|
||||
});
|
||||
}
|
||||
async findManyWithPagination(args: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
where?: Prisma.PostWhereInput;
|
||||
orderBy?: OrderByArgs<(typeof db.post)['findMany']>;
|
||||
select?: Prisma.PostSelect;
|
||||
}): Promise<{
|
||||
items: {
|
||||
id: string;
|
||||
type: string | null;
|
||||
level: string | null;
|
||||
state: string | null;
|
||||
title: string | null;
|
||||
subTitle: string | null;
|
||||
content: string | null;
|
||||
important: boolean | null;
|
||||
domainId: string | null;
|
||||
order: number | null;
|
||||
duration: number | null;
|
||||
rating: number | null;
|
||||
createdAt: Date;
|
||||
views: number;
|
||||
hates: number;
|
||||
likes: number;
|
||||
publishedAt: Date | null;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date | null;
|
||||
authorId: string | null;
|
||||
parentId: string | null;
|
||||
hasChildren: boolean | null;
|
||||
meta: Prisma.JsonValue | null;
|
||||
}[];
|
||||
totalPages: number;
|
||||
}> {
|
||||
// super.updateOrder;
|
||||
return super.findManyWithPagination(args);
|
||||
}
|
||||
|
||||
async updateOrderByIds(ids: string[]) {
|
||||
const posts = await db.post.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, order: true },
|
||||
});
|
||||
const postMap = new Map(posts.map((post) => [post.id, post]));
|
||||
const orderedPosts = ids
|
||||
.map((id) => postMap.get(id))
|
||||
.filter((post): post is { id: string; order: number } => !!post);
|
||||
|
||||
// 生成仅需更新的操作
|
||||
const updates = orderedPosts
|
||||
.map((post, index) => ({
|
||||
id: post.id,
|
||||
newOrder: index, // 按数组索引设置新顺序
|
||||
currentOrder: post.order,
|
||||
}))
|
||||
.filter(({ newOrder, currentOrder }) => newOrder !== currentOrder)
|
||||
.map(({ id, newOrder }) =>
|
||||
db.post.update({
|
||||
where: { id },
|
||||
data: { order: newOrder },
|
||||
}),
|
||||
);
|
||||
|
||||
// 批量执行更新
|
||||
return updates.length > 0 ? await db.$transaction(updates) : [];
|
||||
}
|
||||
|
||||
protected async setPerms(data: Post, staff?: UserProfile) {
|
||||
if (!staff) return;
|
||||
const perms: ResPerm = {
|
||||
delete: false,
|
||||
};
|
||||
const isMySelf = data?.authorId === staff?.id;
|
||||
const isDomain = staff.domainId === data.domainId;
|
||||
const setManagePermissions = (perms: ResPerm) => {
|
||||
Object.assign(perms, {
|
||||
delete: true,
|
||||
// edit: true,
|
||||
});
|
||||
};
|
||||
if (isMySelf) {
|
||||
perms.delete = true;
|
||||
// perms.edit = true;
|
||||
}
|
||||
staff.permissions.forEach((permission) => {
|
||||
switch (permission) {
|
||||
case RolePerms.MANAGE_ANY_POST:
|
||||
setManagePermissions(perms);
|
||||
break;
|
||||
case RolePerms.MANAGE_DOM_POST:
|
||||
if (isDomain) {
|
||||
setManagePermissions(perms);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
Object.assign(data, { perms });
|
||||
}
|
||||
async preFilter(OR?: Prisma.PostWhereInput[], staff?: UserProfile) {
|
||||
const preFilter = (await this.getPostPreFilter(staff)) || [];
|
||||
const outOR = OR ? [...OR, ...preFilter].filter(Boolean) : preFilter;
|
||||
return outOR?.length > 0 ? outOR : undefined;
|
||||
}
|
||||
async getPostPreFilter(staff?: UserProfile) {
|
||||
if (!staff) return;
|
||||
const { deptId, domainId } = staff;
|
||||
if (
|
||||
staff.permissions.includes(RolePerms.READ_ANY_POST) ||
|
||||
staff.permissions.includes(RolePerms.MANAGE_ANY_POST)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const parentDeptIds =
|
||||
(await this.departmentService.getAncestorIds(staff.deptId)) || [];
|
||||
const orCondition: Prisma.PostWhereInput[] = [
|
||||
staff?.id && {
|
||||
authorId: staff.id,
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
if (orCondition?.length > 0) return orCondition;
|
||||
return undefined;
|
||||
}
|
||||
async softDeletePostDescendant(args:{ancestorId?:string}){
|
||||
const { ancestorId } = args
|
||||
const descendantIds = []
|
||||
await db.postAncestry.findMany({
|
||||
where:{
|
||||
ancestorId,
|
||||
},
|
||||
select:{
|
||||
descendantId:true
|
||||
}
|
||||
}).then(res=>{
|
||||
res.forEach(item=>{
|
||||
descendantIds.push(item.descendantId)
|
||||
})
|
||||
})
|
||||
console.log(descendantIds)
|
||||
const result = super.softDeleteByIds([...descendantIds,ancestorId])
|
||||
EventBus.emit('dataChanged', {
|
||||
type: ObjectType.POST,
|
||||
operation: CrudOperation.DELETED,
|
||||
data: result,
|
||||
});
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -1,204 +0,0 @@
|
|||
import {
|
||||
db,
|
||||
EnrollmentStatus,
|
||||
Lecture,
|
||||
Post,
|
||||
PostType,
|
||||
SectionDto,
|
||||
UserProfile,
|
||||
VisitType,
|
||||
} from '@nice/common';
|
||||
|
||||
export async function setPostRelation(params: {
|
||||
data: Post;
|
||||
staff?: UserProfile;
|
||||
}) {
|
||||
const { data, staff } = params;
|
||||
const limitedComments = await db.post.findMany({
|
||||
where: {
|
||||
parentId: data.id,
|
||||
type: PostType.POST_COMMENT,
|
||||
},
|
||||
include: {
|
||||
author: true,
|
||||
},
|
||||
take: 5,
|
||||
});
|
||||
const commentsCount = await db.post.count({
|
||||
where: {
|
||||
parentId: data.id,
|
||||
type: PostType.POST_COMMENT,
|
||||
},
|
||||
});
|
||||
const readed =
|
||||
(await db.visit.count({
|
||||
where: {
|
||||
postId: data.id,
|
||||
type: VisitType.READED,
|
||||
visitorId: staff?.id,
|
||||
},
|
||||
})) > 0;
|
||||
const readedCount = await db.visit.count({
|
||||
where: {
|
||||
postId: data.id,
|
||||
type: VisitType.READED,
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(data, {
|
||||
readed,
|
||||
readedCount,
|
||||
limitedComments,
|
||||
commentsCount,
|
||||
// trouble
|
||||
});
|
||||
}
|
||||
export async function updateParentLectureStats(parentId: string) {
|
||||
const ParentStats = await db.post.aggregate({
|
||||
where: {
|
||||
ancestors: {
|
||||
some: {
|
||||
ancestorId: parentId,
|
||||
descendant: {
|
||||
type: PostType.LECTURE,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
_sum: {
|
||||
duration: true,
|
||||
},
|
||||
});
|
||||
await db.post.update({
|
||||
where: { id: parentId },
|
||||
data: {
|
||||
//totalLectures: courseStats._count._all,
|
||||
//totalDuration: courseStats._sum.duration || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 更新课程评价统计
|
||||
export async function updateCourseReviewStats(courseId: string) {
|
||||
const reviews = await db.visit.findMany({
|
||||
where: {
|
||||
postId: courseId,
|
||||
type: PostType.COURSE_REVIEW,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { views: true },
|
||||
});
|
||||
const numberOfReviews = reviews.length;
|
||||
const averageRating =
|
||||
numberOfReviews > 0
|
||||
? reviews.reduce((sum, review) => sum + review.views, 0) / numberOfReviews
|
||||
: 0;
|
||||
|
||||
return db.post.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
// numberOfReviews,
|
||||
//averageRating,
|
||||
},
|
||||
});
|
||||
}
|
||||
// 更新课程注册统计
|
||||
export async function updateCourseEnrollmentStats(courseId: string) {
|
||||
const completedEnrollments = await db.enrollment.count({
|
||||
where: {
|
||||
postId: courseId,
|
||||
status: EnrollmentStatus.COMPLETED,
|
||||
},
|
||||
});
|
||||
const totalEnrollments = await db.enrollment.count({
|
||||
where: { postId: courseId },
|
||||
});
|
||||
const completionRate =
|
||||
totalEnrollments > 0 ? (completedEnrollments / totalEnrollments) * 100 : 0;
|
||||
return db.post.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
// numberOfStudents: totalEnrollments,
|
||||
// completionRate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function setPostInfo({ data }: { data: Post }) {
|
||||
// await db.term
|
||||
if (data?.type === PostType.COURSE) {
|
||||
const ancestries = await db.postAncestry.findMany({
|
||||
where: {
|
||||
ancestorId: data.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
descendant: true,
|
||||
},
|
||||
orderBy: {
|
||||
descendant: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
});
|
||||
const descendants = ancestries.map((ancestry) => ancestry.descendant);
|
||||
const sections: SectionDto[] = (
|
||||
descendants.filter((descendant) => {
|
||||
return (
|
||||
descendant.type === PostType.SECTION &&
|
||||
descendant.parentId === data.id
|
||||
);
|
||||
}) as any
|
||||
).map((section) => ({
|
||||
...section,
|
||||
lectures: [],
|
||||
}));
|
||||
const lectures = descendants.filter((descendant) => {
|
||||
return (
|
||||
descendant.type === PostType.LECTURE &&
|
||||
sections.map((section) => section.id).includes(descendant.parentId)
|
||||
);
|
||||
});
|
||||
|
||||
const lectureCount = lectures?.length || 0;
|
||||
sections.forEach((section) => {
|
||||
section.lectures = lectures.filter(
|
||||
(lecture) => lecture.parentId === section.id,
|
||||
) as any as Lecture[];
|
||||
});
|
||||
|
||||
Object.assign(data, { sections, lectureCount });
|
||||
}
|
||||
if (data?.type === PostType.LECTURE || data?.type === PostType.SECTION) {
|
||||
const ancestry = await db.postAncestry.findFirst({
|
||||
where: {
|
||||
descendantId: data?.id,
|
||||
ancestor: {
|
||||
type: PostType.COURSE,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
ancestor: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
const courseId = ancestry.ancestor.id;
|
||||
Object.assign(data, { courseId });
|
||||
}
|
||||
const students = await db.staff.findMany({
|
||||
where: {
|
||||
learningPosts: {
|
||||
some: {
|
||||
id: data.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const studentIds = (students || []).map((student) => student?.id);
|
||||
Object.assign(data, { studentIds });
|
||||
}
|
|
@ -35,53 +35,4 @@ export class ResourceService extends BaseService<Prisma.ResourceDelegate> {
|
|||
},
|
||||
});
|
||||
}
|
||||
// 添加保存文件名的方法
|
||||
async saveFileName(fileId: string, fileName: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`尝试保存文件名 "${fileName}" 到文件 ${fileId}`);
|
||||
|
||||
// 首先检查是否已存在 ShareCode 记录
|
||||
const existingShareCode = await db.shareCode.findUnique({
|
||||
where: { fileId },
|
||||
});
|
||||
|
||||
if (existingShareCode) {
|
||||
// 如果记录存在,更新文件名
|
||||
await db.shareCode.update({
|
||||
where: { fileId },
|
||||
data: { fileName },
|
||||
});
|
||||
this.logger.log(`更新了现有记录的文件名 "${fileName}" 到文件 ${fileId}`);
|
||||
} else {
|
||||
// 如果记录不存在,创建新记录
|
||||
await db.shareCode.create({
|
||||
data: {
|
||||
fileId,
|
||||
fileName,
|
||||
code: null, // 这里可以设置为 null 或生成一个临时码
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24小时后过期
|
||||
isUsed: false,
|
||||
},
|
||||
});
|
||||
this.logger.log(`创建了新记录并保存文件名 "${fileName}" 到文件 ${fileId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`保存文件名失败,文件ID: ${fileId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加获取文件名的方法
|
||||
async getFileName(fileId: string): Promise<string | null> {
|
||||
try {
|
||||
const shareCode = await db.shareCode.findUnique({
|
||||
where: { fileId },
|
||||
select: { fileName: true },
|
||||
});
|
||||
return shareCode?.fileName || null;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get filename for ${fileId}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { ResourceModule } from '../resource/resource.module';
|
||||
import { ShareCodeService } from './share-code.service';
|
||||
import { ShareCodeRouter } from './share-code.router';
|
||||
|
||||
@Module({
|
||||
imports: [ResourceModule],
|
||||
providers: [TrpcService, ShareCodeService, ShareCodeRouter],
|
||||
exports: [ShareCodeService, ShareCodeRouter],
|
||||
controllers: [],
|
||||
})
|
||||
export class ShareCodeModule { }
|
|
@ -1,70 +0,0 @@
|
|||
import { z, ZodType } from "zod";
|
||||
import { ShareCodeService } from "./share-code.service";
|
||||
import { TrpcService } from "@server/trpc/trpc.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@nice/common";
|
||||
const ShareCodeWhereInputSchema: ZodType<Prisma.ShareCodeWhereInput> = z.any();
|
||||
const ShareCodeFindManyArgsSchema: ZodType<Prisma.ShareCodeFindManyArgs> = z.any();
|
||||
@Injectable()
|
||||
export class ShareCodeRouter {
|
||||
constructor(
|
||||
private readonly shareCodeService: ShareCodeService,
|
||||
private readonly trpc: TrpcService
|
||||
) { }
|
||||
|
||||
router = this.trpc.router({
|
||||
getFileByShareCode: this.trpc.procedure
|
||||
.input(z.object({ code: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return this.shareCodeService.getFileByShareCode(input.code);
|
||||
}),
|
||||
generateShareCodeByFileId: this.trpc.procedure
|
||||
.input(z.object({ fileId: z.string(), expiresAt: z.date(), canUseTimes: z.number() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return this.shareCodeService.generateShareCodeByFileId(input.fileId, input.expiresAt, input.canUseTimes, ctx.ip);
|
||||
}),
|
||||
getShareCodesWithResources: this.trpc.procedure
|
||||
.input(z.object({ page: z.number(), pageSize: z.number(), where: ShareCodeWhereInputSchema.optional() }))
|
||||
.query(async ({ input }) => {
|
||||
return this.shareCodeService.getShareCodesWithResources(input);
|
||||
}),
|
||||
softDeleteShareCodes: this.trpc.procedure
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.mutation(async ({ input }) => {
|
||||
return this.shareCodeService.softDeleteShareCodes(input.ids);
|
||||
}),
|
||||
updateShareCode: this.trpc.procedure
|
||||
.input(z.object({
|
||||
id: z.string(),
|
||||
data: z.object({
|
||||
expiresAt: z.date().optional(),
|
||||
canUseTimes: z.number().optional(),
|
||||
})
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
return this.shareCodeService.updateShareCode(input.id, input.data);
|
||||
}),
|
||||
getShareCodeResourcesTotalSize: this.trpc.procedure
|
||||
.query(async () => {
|
||||
return this.shareCodeService.getShareCodeResourcesTotalSize();
|
||||
}),
|
||||
getShareCodeResourcesSizeByDateRange: this.trpc.procedure
|
||||
.input(z.object({ dateType: z.enum(['today', 'yesterday']) }))
|
||||
.query(async ({ input }) => {
|
||||
return this.shareCodeService.getShareCodeResourcesSizeByDateRange(input.dateType);
|
||||
}),
|
||||
countDistinctUploadIPs: this.trpc.procedure
|
||||
.query(async () => {
|
||||
return this.shareCodeService.countDistinctUploadIPs();
|
||||
}),
|
||||
findShareCodes: this.trpc.procedure
|
||||
.input(ShareCodeFindManyArgsSchema)
|
||||
.query(async ({ input }) => {
|
||||
return this.shareCodeService.findShareCodes(input);
|
||||
}),
|
||||
getAllreadlyDeletedShareCodes:this.trpc.procedure
|
||||
.query(async () => {
|
||||
return this.shareCodeService.getAllreadlyDeletedShareCodes();
|
||||
})
|
||||
});
|
||||
}
|
|
@ -1,607 +0,0 @@
|
|||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { customAlphabet } from 'nanoid-cjs';
|
||||
import { db, ObjectType, Prisma, Resource } from '@nice/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ResourceService } from '@server/models/resource/resource.service';
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import { BaseService } from '../base/base.service';
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
export interface ShareCode {
|
||||
id: string;
|
||||
code: string;
|
||||
fileId: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
isUsed: boolean;
|
||||
fileName?: string | null;
|
||||
canUseTimes: number | null;
|
||||
uploadIp?: string;
|
||||
}
|
||||
export interface GenerateShareCodeResponse {
|
||||
id?: string;
|
||||
code: string;
|
||||
expiresAt: Date;
|
||||
canUseTimes: number;
|
||||
fileName?: string;
|
||||
resource: {
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
meta: ResourceMeta
|
||||
}
|
||||
uploadIp?: string;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
interface ResourceMeta {
|
||||
filename: string;
|
||||
filetype: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
const ShareCodeSelect = {
|
||||
id: true,
|
||||
code: true,
|
||||
fileId: true,
|
||||
expiresAt: true,
|
||||
fileName: true,
|
||||
canUseTimes: true,
|
||||
resource: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
url: true,
|
||||
meta: true,
|
||||
}
|
||||
},
|
||||
uploadIp: true,
|
||||
createdAt: true,
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ShareCodeService extends BaseService<Prisma.ShareCodeDelegate> {
|
||||
private readonly logger = new Logger(ShareCodeService.name);
|
||||
// 生成8位分享码,使用易读的字符
|
||||
private readonly generateCode = customAlphabet(
|
||||
'23456789ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||||
8,
|
||||
);
|
||||
|
||||
constructor(private readonly resourceService: ResourceService) {
|
||||
super(db, ObjectType.SHARE_CODE, false);
|
||||
}
|
||||
// 根据文件ID生成分享码
|
||||
async generateShareCodeByFileId(fileId: string, expiresAt: Date, canUseTimes: number, ip: string) {
|
||||
try {
|
||||
this.logger.log('收到生成分享码请求,fileId:', fileId);
|
||||
this.logger.log('客户端IP:', ip);
|
||||
const result = await this.generateShareCode(fileId, expiresAt, canUseTimes, undefined, ip);
|
||||
this.logger.log('生成分享码结果:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error('生成分享码错误:', error);
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
async generateShareCode(
|
||||
fileId: string,
|
||||
expiresAt: Date,
|
||||
canUseTimes: number,
|
||||
fileName?: string,
|
||||
uploadIp?: string,
|
||||
): Promise<GenerateShareCodeResponse> {
|
||||
try {
|
||||
// 检查文件是否存在
|
||||
const resource = await this.resourceService.findUnique({
|
||||
where: { fileId },
|
||||
});
|
||||
this.logger.log('完整 resource:', resource);
|
||||
if (!resource) {
|
||||
throw new NotFoundException('文件不存在');
|
||||
}
|
||||
const { filename, filetype, size } = resource.meta as any as ResourceMeta
|
||||
// 生成分享码(修改的逻辑保证分享码的唯一性)
|
||||
let code = this.generateCode();
|
||||
let existingShareCode;
|
||||
do {
|
||||
// 查找是否已有相同 shareCode 或者相同 FileID 的分享码记录
|
||||
existingShareCode = await super.findUnique({
|
||||
where: {
|
||||
OR: [
|
||||
{ code },
|
||||
{ fileId }
|
||||
]
|
||||
},
|
||||
});
|
||||
// 如果找到的是已经被删除的码,则可以使用并更新其他信息,否则重新生成
|
||||
if (existingShareCode.deleteAt !== null) {
|
||||
break
|
||||
}
|
||||
if (existingShareCode && existingShareCode.code === code) {
|
||||
code = this.generateCode();
|
||||
}
|
||||
} while (existingShareCode && existingShareCode.code === code);
|
||||
|
||||
if (existingShareCode) {
|
||||
// 更新现有记录,但保留原有文件名
|
||||
await super.update({
|
||||
where: { id: existingShareCode.id },
|
||||
data: {
|
||||
code,
|
||||
expiresAt,
|
||||
canUseTimes,
|
||||
isUsed: false,
|
||||
fileId,
|
||||
fileName: filename || "downloaded_file",
|
||||
uploadIp,
|
||||
createdAt: new Date(),
|
||||
deleteAt: null
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 创建新记录
|
||||
await super.create({
|
||||
data: {
|
||||
code,
|
||||
fileId,
|
||||
expiresAt,
|
||||
canUseTimes,
|
||||
isUsed: false,
|
||||
fileName: filename || "downloaded_file",
|
||||
uploadIp,
|
||||
},
|
||||
});
|
||||
}
|
||||
this.logger.log(`Generated share code ${code} for file ${fileId} canUseTimes: ${canUseTimes}`);
|
||||
return {
|
||||
code,
|
||||
expiresAt,
|
||||
canUseTimes,
|
||||
fileName: filename || "downloaded_file",
|
||||
resource: {
|
||||
id: resource.id,
|
||||
type: resource.type,
|
||||
url: resource.url,
|
||||
meta: {
|
||||
filename,
|
||||
filetype,
|
||||
size,
|
||||
}
|
||||
},
|
||||
uploadIp,
|
||||
createdAt: new Date()
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to generate share code', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async validateAndUseCode(code: string): Promise<ShareCode | null> {
|
||||
try {
|
||||
this.logger.log(`尝试验证分享码: ${code}`);
|
||||
|
||||
// 查找有效的分享码
|
||||
const shareCode = await super.findFirst({
|
||||
where: {
|
||||
code,
|
||||
isUsed: false,
|
||||
expiresAt: { gt: dayjs().tz('Asia/Shanghai').toDate() },
|
||||
deletedAt: null
|
||||
},
|
||||
});
|
||||
if (shareCode.canUseTimes <= 0) {
|
||||
this.logger.log('分享码已使用次数超过限制');
|
||||
return null;
|
||||
}
|
||||
//更新已使用次数
|
||||
await super.update({
|
||||
where: { id: shareCode.id },
|
||||
data: { canUseTimes: shareCode.canUseTimes - 1 },
|
||||
});
|
||||
this.logger.log('查询结果:', shareCode);
|
||||
|
||||
if (!shareCode) {
|
||||
this.logger.log('分享码无效或已过期');
|
||||
return null;
|
||||
}
|
||||
// 记录使用日志
|
||||
this.logger.log(`Share code ${code} used for file ${shareCode.fileId}`);
|
||||
// 返回完整的分享码信息,包括文件名
|
||||
return shareCode;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to validate share code', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 每天清理过期的分享码
|
||||
//@Cron('*/30 * * * * *')
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async cleanupExpiredShareCodes() {
|
||||
try {
|
||||
const shareCodes = await super.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ expiresAt: { lt: dayjs().tz('Asia/Shanghai').toDate() } },
|
||||
{ isUsed: true },
|
||||
{ canUseTimes: { lte: 0 } }
|
||||
],
|
||||
}
|
||||
})
|
||||
this.logger.log('需要清理的分享码:', shareCodes);
|
||||
//文件资源硬删除
|
||||
shareCodes.forEach(code => {
|
||||
this.cleanupUploadFolder(code.fileId);
|
||||
})
|
||||
//数据库资源软删除
|
||||
const result = await super.softDeleteByIds(
|
||||
[...shareCodes.map(code => code.id)]
|
||||
);
|
||||
const deleteResource = await this.resourceService.updateMany({
|
||||
where: {
|
||||
fileId: {
|
||||
in: shareCodes.map(code => code.fileId)
|
||||
}
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date()
|
||||
}
|
||||
})
|
||||
this.logger.log(`Cleaned up ${result} ${deleteResource.count} expired share codes`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to cleanup expired share codes', error);
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupUploadFolder(file?: string) {
|
||||
//const uploadDir = path.join(__dirname, '../../../uploads');
|
||||
const uploadDir = path.join('/data/uploads/', file || '');
|
||||
this.logger.log('uploadDir:', uploadDir);
|
||||
try {
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
this.logger.warn(`Upload directory does not exist: ${uploadDir}`);
|
||||
return;
|
||||
}
|
||||
// 递归删除文件夹及其内容
|
||||
this.deleteFolderRecursive(uploadDir);
|
||||
this.logger.log(`Cleaned up upload folder: ${uploadDir}`);
|
||||
} catch (error) {
|
||||
this.logger.error('读取上传目录失败:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
private deleteFolderRecursive(dirPath: string) {
|
||||
if (fs.existsSync(dirPath)) {
|
||||
fs.readdirSync(dirPath).forEach((file) => {
|
||||
const filePath = path.join(dirPath, file);
|
||||
if (fs.statSync(filePath).isDirectory()) {
|
||||
// 递归删除子目录
|
||||
this.deleteFolderRecursive(filePath);
|
||||
} else {
|
||||
// 删除文件
|
||||
fs.unlinkSync(filePath);
|
||||
this.logger.log(`Deleted file: ${filePath}`);
|
||||
}
|
||||
});
|
||||
// 删除空文件夹
|
||||
fs.rmdirSync(dirPath);
|
||||
this.logger.log(`Deleted folder: ${dirPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 根据分享码获取文件
|
||||
async getFileByShareCode(code: string) {
|
||||
this.logger.log('收到验证分享码请求,code:', code);
|
||||
const shareCode = await this.validateAndUseCode(code);
|
||||
this.logger.log('验证分享码结果:', shareCode);
|
||||
|
||||
if (!shareCode) {
|
||||
this.logger.log('分享码无效或已过期');
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
const resource = await this.resourceService.findUnique({
|
||||
where: { fileId: shareCode.fileId },
|
||||
});
|
||||
this.logger.log('获取到的资源信息:', resource);
|
||||
const { filename, filetype, size } = resource.meta as any as ResourceMeta
|
||||
const fileUrl = resource?.url
|
||||
if (!resource) {
|
||||
throw new NotFoundException('文件不存在');
|
||||
}
|
||||
|
||||
// 直接返回正确的数据结构
|
||||
const response = {
|
||||
id: shareCode.id,
|
||||
code: shareCode.code,
|
||||
fileName: filename || 'downloaded_file',
|
||||
expiresAt: shareCode.expiresAt,
|
||||
canUseTimes: shareCode.canUseTimes - 1,
|
||||
resource: {
|
||||
id: resource.id,
|
||||
type: resource.type,
|
||||
url: resource.url,
|
||||
meta: {
|
||||
filename, filetype, size
|
||||
}
|
||||
},
|
||||
uploadIp: shareCode.uploadIp,
|
||||
createdAt: shareCode.createdAt
|
||||
};
|
||||
|
||||
this.logger.log('返回给前端的数据:', response); // 添加日志
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
async getShareCodesWithResources(args: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
where?: Prisma.ShareCodeWhereInput;
|
||||
}): Promise<{
|
||||
items: Array<ShareCode & { resource?: Resource }>;
|
||||
totalPages: number;
|
||||
}> {
|
||||
try {
|
||||
// 使用include直接关联查询Resource
|
||||
const { items, totalPages } = await super.findManyWithPagination({
|
||||
...args,
|
||||
select: ShareCodeSelect
|
||||
});
|
||||
return {
|
||||
items,
|
||||
totalPages
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get share codes with resources', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async softDeleteShareCodes(ids: string[]): Promise<any> {
|
||||
try {
|
||||
this.logger.log(`尝试软删除分享码,IDs: ${ids.join(', ')}`);
|
||||
const result = await super.softDeleteByIds(ids);
|
||||
this.logger.log(`软删除分享码成功,数量: ${result.length}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error('软删除分享码失败', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async updateShareCode(id: string, data: Partial<ShareCode>): Promise<any> {
|
||||
try {
|
||||
this.logger.log(`尝试更新分享码,ID: ${id},数据:`, data);
|
||||
const result = await super.updateById(id, data);
|
||||
this.logger.log(`更新分享码成功:`, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error('更新分享码失败', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分享码关联资源的总大小
|
||||
* @returns 返回所有资源的总大小和资源数量
|
||||
*/
|
||||
async getShareCodeResourcesTotalSize(): Promise<{ totalSize: number; resourceCount: number }> {
|
||||
try {
|
||||
this.logger.log('获取所有分享码关联资源的总大小');
|
||||
|
||||
// 查询所有有效的分享码及其关联的资源
|
||||
const shareCodes = await super.findMany({
|
||||
where: {
|
||||
deletedAt: null
|
||||
},
|
||||
select: { ...ShareCodeSelect }
|
||||
});
|
||||
const {totalSize, resourceCount} = this.calculateTotalSize(shareCodes);
|
||||
|
||||
this.logger.log(`资源总大小: ${totalSize}, 资源数量: ${resourceCount}`);
|
||||
return { totalSize, resourceCount };
|
||||
} catch (error) {
|
||||
this.logger.error('获取分享码资源总大小失败', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 根据日期范围获取分享码关联资源的大小
|
||||
* @param dateType 日期类型: 'today' 或 'yesterday'
|
||||
* @returns 返回特定日期范围内的资源总大小和资源数量
|
||||
*/
|
||||
async getShareCodeResourcesSizeByDateRange(dateType: 'today' | 'yesterday'): Promise<{ totalSize: number; resourceCount: number }> {
|
||||
try {
|
||||
let startDate: Date;
|
||||
let endDate: Date;
|
||||
const now = dayjs().tz('Asia/Shanghai');
|
||||
|
||||
if (dateType === 'today') {
|
||||
startDate = now.startOf('day').toDate();
|
||||
endDate = now.endOf('day').toDate();
|
||||
this.logger.log(`获取今天创建的分享码资源大小, 日期范围: ${startDate} 到 ${endDate}`);
|
||||
} else {
|
||||
startDate = now.subtract(1, 'day').startOf('day').toDate();
|
||||
endDate = now.subtract(1, 'day').endOf('day').toDate();
|
||||
this.logger.log(`获取昨天创建的分享码资源大小, 日期范围: ${startDate} 到 ${endDate}`);
|
||||
}
|
||||
|
||||
// 查询特定日期范围内创建的分享码及其关联的资源
|
||||
const shareCodes = await super.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
},
|
||||
deletedAt: null
|
||||
},
|
||||
select: {
|
||||
...ShareCodeSelect
|
||||
}
|
||||
});
|
||||
const {totalSize, resourceCount} = this.calculateTotalSize(shareCodes);
|
||||
this.logger.log(`${dateType}资源总大小: ${totalSize}, 资源数量: ${resourceCount}`);
|
||||
return { totalSize, resourceCount };
|
||||
} catch (error) {
|
||||
this.logger.error(`获取${dateType === 'today' ? '今天' : '昨天'}的分享码资源大小失败`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 统计不同时间段内独立uploadIp的数量
|
||||
* @returns 返回本周和上周的不同uploadIp数量
|
||||
*/
|
||||
async countDistinctUploadIPs(): Promise<{ thisWeek: number; lastWeek: number; all: number }> {
|
||||
try {
|
||||
const now = dayjs().tz('Asia/Shanghai');
|
||||
|
||||
// 本周的开始和结束
|
||||
const thisWeekStart = now.startOf('week').toDate();
|
||||
const thisWeekEnd = now.endOf('week').toDate();
|
||||
|
||||
// 上周的开始和结束
|
||||
const lastWeekStart = now.subtract(1, 'week').startOf('week').toDate();
|
||||
const lastWeekEnd = now.subtract(1, 'week').endOf('week').toDate();
|
||||
|
||||
this.logger.log(`统计本周IP数量, 日期范围: ${thisWeekStart} 到 ${thisWeekEnd}`);
|
||||
this.logger.log(`统计上周IP数量, 日期范围: ${lastWeekStart} 到 ${lastWeekEnd}`);
|
||||
// 查询所有不同IP
|
||||
const allIPs = await super.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
uploadIp: {
|
||||
not: null
|
||||
}
|
||||
},
|
||||
select: {
|
||||
uploadIp: true
|
||||
},
|
||||
distinct: ['uploadIp']
|
||||
});
|
||||
// 查询本周的不同IP
|
||||
const thisWeekIPs = await super.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: thisWeekStart,
|
||||
lte: thisWeekEnd
|
||||
},
|
||||
uploadIp: {
|
||||
not: null
|
||||
},
|
||||
deletedAt: null
|
||||
},
|
||||
select: {
|
||||
uploadIp: true
|
||||
},
|
||||
distinct: ['uploadIp']
|
||||
});
|
||||
|
||||
// 查询上周的不同IP
|
||||
const lastWeekIPs = await super.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: lastWeekStart,
|
||||
lte: lastWeekEnd
|
||||
},
|
||||
uploadIp: {
|
||||
not: null
|
||||
},
|
||||
deletedAt: null
|
||||
},
|
||||
select: {
|
||||
uploadIp: true
|
||||
},
|
||||
distinct: ['uploadIp']
|
||||
});
|
||||
|
||||
const thisWeekCount = thisWeekIPs.length;
|
||||
const lastWeekCount = lastWeekIPs.length;
|
||||
const allCount = allIPs.length;
|
||||
|
||||
this.logger.log(`本周不同IP数量: ${thisWeekCount}, 上周不同IP数量: ${lastWeekCount}, 所有不同IP数量: ${allCount}`);
|
||||
|
||||
return { thisWeek: thisWeekCount, lastWeek: lastWeekCount, all: allCount };
|
||||
} catch (error) {
|
||||
this.logger.error('统计不同uploadIp数量失败', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享码列表,使用ShareCodeSelect并按创建时间倒序排序
|
||||
* @param args 查询参数
|
||||
* @returns 返回分享码列表
|
||||
*/
|
||||
async findShareCodes(args?: Omit<Prisma.ShareCodeFindManyArgs, 'select' | 'orderBy'>): Promise<GenerateShareCodeResponse[]> {
|
||||
try {
|
||||
const result = await super.findMany({
|
||||
...args,
|
||||
select: ShareCodeSelect,
|
||||
});
|
||||
this.logger.log(`获取分享码列表成功, 数量: ${result.length}`);
|
||||
return result as unknown as GenerateShareCodeResponse[];
|
||||
} catch (error) {
|
||||
this.logger.error('获取分享码列表失败', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async getAllreadlyDeletedShareCodes(args?: Omit<Prisma.ShareCodeFindManyArgs, 'select' | 'orderBy'>):Promise<{ totalSize: number; resourceCount: number; }> {
|
||||
try {
|
||||
const result = await super.findMany({
|
||||
...args,
|
||||
where: {
|
||||
deletedAt: {
|
||||
not: null
|
||||
}
|
||||
},
|
||||
select: ShareCodeSelect,
|
||||
});
|
||||
// 计算总大小和资源数量
|
||||
const { totalSize, resourceCount } = this.calculateTotalSize(result as unknown as GenerateShareCodeResponse[]);
|
||||
this.logger.log(`获取已删除分享码列表成功, 数量: ${resourceCount}, 总大小: ${totalSize}`);
|
||||
return {totalSize,resourceCount}
|
||||
} catch (err) {
|
||||
this.logger.error('获取已删除分享码列表失败', err)
|
||||
throw err
|
||||
}
|
||||
|
||||
}
|
||||
calculateTotalSize(shareCodes: GenerateShareCodeResponse[]): { totalSize: number; resourceCount: number } {
|
||||
let totalSize = 0;
|
||||
let resourceCount = 0;
|
||||
shareCodes.forEach(shareCode => {
|
||||
if ((shareCode as any as GenerateShareCodeResponse).resource && (shareCode as any as GenerateShareCodeResponse).resource.meta) {
|
||||
const meta = (shareCode as any as GenerateShareCodeResponse).resource.meta as any;
|
||||
if (meta.size) {
|
||||
// 如果size是字符串格式(如 "1024"或"1 MB"),需要转换
|
||||
let sizeValue: number;
|
||||
if (typeof meta.size === 'string') {
|
||||
// 尝试直接解析数字
|
||||
sizeValue = parseInt(meta.size, 10);
|
||||
// 如果解析失败,可能需要更复杂的处理
|
||||
if (isNaN(sizeValue)) {
|
||||
// 简单处理,实际应用中可能需要更复杂的单位转换
|
||||
this.logger.warn(`无法解析资源大小: ${meta.size}`);
|
||||
sizeValue = 0;
|
||||
}
|
||||
} else if (typeof meta.size === 'number') {
|
||||
sizeValue = meta.size;
|
||||
} else {
|
||||
sizeValue = 0;
|
||||
}
|
||||
|
||||
totalSize += sizeValue;
|
||||
resourceCount++;
|
||||
}
|
||||
}
|
||||
})
|
||||
return { totalSize, resourceCount }
|
||||
}
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { VisitService } from './visit.service';
|
||||
import { VisitRouter } from './visit.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
|
||||
@Module({
|
||||
providers: [VisitService, VisitRouter, TrpcService],
|
||||
exports: [VisitRouter]
|
||||
})
|
||||
export class VisitModule { }
|
|
@ -1,37 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { Prisma } from '@nice/common';
|
||||
|
||||
import { VisitService } from './visit.service';
|
||||
import { z, ZodType } from 'zod';
|
||||
const VisitCreateArgsSchema: ZodType<Prisma.VisitCreateArgs> = z.any();
|
||||
const VisitCreateManyInputSchema: ZodType<Prisma.VisitCreateManyInput> =
|
||||
z.any();
|
||||
const VisitDeleteManyArgsSchema: ZodType<Prisma.VisitDeleteManyArgs> = z.any();
|
||||
@Injectable()
|
||||
export class VisitRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly visitService: VisitService,
|
||||
) {}
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.procedure
|
||||
.input(VisitCreateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.visitService.create(input, staff);
|
||||
}),
|
||||
createMany: this.trpc.procedure
|
||||
.input(z.array(VisitCreateManyInputSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
|
||||
return await this.visitService.createMany({ data: input }, staff);
|
||||
}),
|
||||
deleteMany: this.trpc.procedure
|
||||
.input(VisitDeleteManyArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.visitService.deleteMany(input);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -1,152 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import { UserProfile, db, ObjectType, Prisma, VisitType } from '@nice/common';
|
||||
import EventBus from '@server/utils/event-bus';
|
||||
@Injectable()
|
||||
export class VisitService extends BaseService<Prisma.VisitDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.VISIT);
|
||||
}
|
||||
async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) {
|
||||
const { postId, lectureId, messageId } = args.data;
|
||||
const visitorId = args.data?.visitorId || staff?.id;
|
||||
let result;
|
||||
const existingVisit = await db.visit.findFirst({
|
||||
where: {
|
||||
type: args.data.type,
|
||||
// visitorId: visitorId ? visitorId : null,
|
||||
OR: [{ postId }, { messageId }],
|
||||
},
|
||||
});
|
||||
if (!existingVisit) {
|
||||
result = await super.create(args);
|
||||
} else if (args.data.type === VisitType.READED) {
|
||||
result = await super.update({
|
||||
where: { id: existingVisit.id },
|
||||
data: {
|
||||
...args.data,
|
||||
views: existingVisit.views + 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
[VisitType.READED, VisitType.LIKE, VisitType.HATE].includes(
|
||||
args.data.type as VisitType,
|
||||
)
|
||||
) {
|
||||
EventBus.emit('updateVisitCount', {
|
||||
objectType: ObjectType.POST,
|
||||
id: postId,
|
||||
visitType: args.data.type, // 直接复用传入的类型
|
||||
});
|
||||
EventBus.emit('updateTotalCourseViewCount', {
|
||||
visitType: args.data.type, // 直接复用传入的类型
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) {
|
||||
const data = Array.isArray(args.data) ? args.data : [args.data];
|
||||
const updatePromises: any[] = [];
|
||||
const createData: Prisma.VisitCreateManyInput[] = [];
|
||||
await Promise.all(
|
||||
data.map(async (item) => {
|
||||
if (staff && !item.visitorId) item.visitorId = staff.id;
|
||||
const { postId, lectureId, messageId, visitorId } = item;
|
||||
const existingVisit = await db.visit.findFirst({
|
||||
where: {
|
||||
visitorId,
|
||||
OR: [{ postId }, { lectureId }, { messageId }],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingVisit) {
|
||||
updatePromises.push(
|
||||
super.update({
|
||||
where: { id: existingVisit.id },
|
||||
data: {
|
||||
...item,
|
||||
views: existingVisit.views + 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
createData.push(item);
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Execute all updates in parallel
|
||||
await Promise.all(updatePromises);
|
||||
// Create new visits for those not existing
|
||||
if (createData.length > 0) {
|
||||
return super.createMany({
|
||||
...args,
|
||||
data: createData,
|
||||
});
|
||||
}
|
||||
|
||||
return { count: updatePromises.length }; // Return the number of updates if no new creates
|
||||
}
|
||||
async deleteMany(args: Prisma.VisitDeleteManyArgs, staff?: UserProfile) {
|
||||
// const where = Array.isArray(args.where) ? args.where : [args.where];
|
||||
// const updatePromises: any[] = [];
|
||||
// const createData: Prisma.VisitCreateManyInput[] = [];
|
||||
// super
|
||||
// await Promise.all(
|
||||
// data.map(async (item) => {
|
||||
// if (staff && !item.visitorId) item.visitorId = staff.id;
|
||||
// const { postId, messageId, visitorId } = item;
|
||||
// const existingVisit = await db.visit.findFirst({
|
||||
// where: {
|
||||
// visitorId,
|
||||
// OR: [{ postId }, { messageId }],
|
||||
// },
|
||||
// });
|
||||
|
||||
// if (existingVisit) {
|
||||
// updatePromises.push(
|
||||
// super.update({
|
||||
// where: { id: existingVisit.id },
|
||||
// data: {
|
||||
// ...item,
|
||||
// views: existingVisit.views + 1,
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
// } else {
|
||||
// createData.push(item);
|
||||
// }
|
||||
// }),
|
||||
// );
|
||||
// // Execute all updates in parallel
|
||||
// await Promise.all(updatePromises);
|
||||
// // Create new visits for those not existing
|
||||
// if (createData.length > 0) {
|
||||
// return super.createMany({
|
||||
// ...args,
|
||||
// data: createData,
|
||||
// });
|
||||
// }
|
||||
// return { count: updatePromises.length }; // Return the number of updates if no new creates
|
||||
const superDetele = super.deleteMany(args, staff);
|
||||
if (args?.where?.postId) {
|
||||
if (
|
||||
[VisitType.READED, VisitType.LIKE, VisitType.HATE].includes(
|
||||
args.where.type as any,
|
||||
)
|
||||
) {
|
||||
EventBus.emit('updateVisitCount', {
|
||||
objectType: ObjectType.POST,
|
||||
id: args?.where?.postId as string,
|
||||
visitType: args.where.type as any, // 直接复用传入的类型
|
||||
});
|
||||
EventBus.emit('updateTotalCourseViewCount', {
|
||||
visitType: args.where.type as any, // 直接复用传入的类型
|
||||
});
|
||||
}
|
||||
}
|
||||
return superDetele;
|
||||
}
|
||||
}
|
|
@ -5,132 +5,132 @@ import {
|
|||
PostType,
|
||||
VisitType,
|
||||
} from '@nice/common';
|
||||
export async function updateTotalCourseViewCount(type: VisitType) {
|
||||
const posts = await db.post.findMany({
|
||||
where: {
|
||||
// type: { in: [PostType.COURSE, PostType.LECTURE,] },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true, type: true },
|
||||
});
|
||||
// export async function updateTotalCourseViewCount(type: VisitType) {
|
||||
// const posts = await db.post.findMany({
|
||||
// where: {
|
||||
// // type: { in: [PostType.COURSE, PostType.LECTURE,] },
|
||||
// deletedAt: null,
|
||||
// },
|
||||
// select: { id: true, type: true },
|
||||
// });
|
||||
|
||||
const courseIds = posts
|
||||
.filter((post) => post.type === PostType.COURSE)
|
||||
.map((course) => course.id);
|
||||
const lectures = posts.filter((post) => post.type === PostType.LECTURE);
|
||||
const totalViews = await db.visit.aggregate({
|
||||
_sum: {
|
||||
views: true,
|
||||
},
|
||||
where: {
|
||||
postId: { in: posts.map((post) => post.id) },
|
||||
type: type,
|
||||
},
|
||||
});
|
||||
const appConfig = await db.appConfig.findFirst({
|
||||
where: {
|
||||
slug: AppConfigSlug.BASE_SETTING,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
meta: true,
|
||||
},
|
||||
});
|
||||
const staffs = await db.staff.count({
|
||||
where: { deletedAt: null },
|
||||
});
|
||||
// const courseIds = posts
|
||||
// .filter((post) => post.type === PostType.COURSE)
|
||||
// .map((course) => course.id);
|
||||
// const lectures = posts.filter((post) => post.type === PostType.LECTURE);
|
||||
// const totalViews = await db.visit.aggregate({
|
||||
// _sum: {
|
||||
// views: true,
|
||||
// },
|
||||
// where: {
|
||||
// postId: { in: posts.map((post) => post.id) },
|
||||
// type: type,
|
||||
// },
|
||||
// });
|
||||
// const appConfig = await db.appConfig.findFirst({
|
||||
// where: {
|
||||
// slug: AppConfigSlug.BASE_SETTING,
|
||||
// },
|
||||
// select: {
|
||||
// id: true,
|
||||
// meta: true,
|
||||
// },
|
||||
// });
|
||||
// const staffs = await db.staff.count({
|
||||
// where: { deletedAt: null },
|
||||
// });
|
||||
|
||||
const baseSeting = appConfig.meta as BaseSetting;
|
||||
await db.appConfig.update({
|
||||
where: {
|
||||
slug: AppConfigSlug.BASE_SETTING,
|
||||
},
|
||||
data: {
|
||||
meta: {
|
||||
...baseSeting,
|
||||
appConfig: {
|
||||
...(baseSeting?.appConfig || {}),
|
||||
statistics: {
|
||||
reads: totalViews._sum.views || 0,
|
||||
courses: courseIds?.length || 0,
|
||||
staffs: staffs || 0,
|
||||
lectures: lectures?.length || 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
export async function updatePostViewCount(id: string, type: VisitType) {
|
||||
const post = await db.post.findFirst({
|
||||
where: { id },
|
||||
select: { id: true, meta: true, type: true },
|
||||
});
|
||||
const metaFieldMap = {
|
||||
[VisitType.READED]: 'views',
|
||||
[VisitType.LIKE]: 'likes',
|
||||
[VisitType.HATE]: 'hates',
|
||||
};
|
||||
if (post?.type === PostType.LECTURE) {
|
||||
const courseAncestry = await db.postAncestry.findFirst({
|
||||
where: {
|
||||
descendantId: post?.id,
|
||||
ancestor: {
|
||||
type: PostType.COURSE,
|
||||
},
|
||||
},
|
||||
select: { id: true, ancestorId: true },
|
||||
});
|
||||
const course = { id: courseAncestry.ancestorId };
|
||||
const lecturesAncestry = await db.postAncestry.findMany({
|
||||
where: { ancestorId: course.id, descendant: { type: PostType.LECTURE } },
|
||||
select: {
|
||||
id: true,
|
||||
descendantId: true,
|
||||
},
|
||||
});
|
||||
const lectures = lecturesAncestry.map((ancestry) => ({
|
||||
id: ancestry.descendantId,
|
||||
}));
|
||||
const courseViews = await db.visit.aggregate({
|
||||
_sum: {
|
||||
views: true,
|
||||
},
|
||||
where: {
|
||||
postId: {
|
||||
in: [course.id, ...lectures.map((lecture) => lecture.id)],
|
||||
},
|
||||
type: type,
|
||||
},
|
||||
});
|
||||
await db.post.update({
|
||||
where: { id: course.id },
|
||||
data: {
|
||||
[metaFieldMap[type]]: courseViews._sum.views || 0,
|
||||
// meta: {
|
||||
// ...((post?.meta as any) || {}),
|
||||
// [metaFieldMap[type]]: courseViews._sum.views || 0,
|
||||
// },
|
||||
},
|
||||
});
|
||||
}
|
||||
const totalViews = await db.visit.aggregate({
|
||||
_sum: {
|
||||
views: true,
|
||||
},
|
||||
where: {
|
||||
postId: id,
|
||||
type: type,
|
||||
},
|
||||
});
|
||||
await db.post.update({
|
||||
where: { id },
|
||||
data: {
|
||||
[metaFieldMap[type]]: totalViews._sum.views || 0,
|
||||
// meta: {
|
||||
// ...((post?.meta as any) || {}),
|
||||
// [metaFieldMap[type]]: totalViews._sum.views || 0,
|
||||
// },
|
||||
},
|
||||
});
|
||||
}
|
||||
// const baseSeting = appConfig.meta as BaseSetting;
|
||||
// await db.appConfig.update({
|
||||
// where: {
|
||||
// slug: AppConfigSlug.BASE_SETTING,
|
||||
// },
|
||||
// data: {
|
||||
// meta: {
|
||||
// ...baseSeting,
|
||||
// appConfig: {
|
||||
// ...(baseSeting?.appConfig || {}),
|
||||
// statistics: {
|
||||
// reads: totalViews._sum.views || 0,
|
||||
// courses: courseIds?.length || 0,
|
||||
// staffs: staffs || 0,
|
||||
// lectures: lectures?.length || 0,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// export async function updatePostViewCount(id: string, type: VisitType) {
|
||||
// const post = await db.post.findFirst({
|
||||
// where: { id },
|
||||
// select: { id: true, meta: true, type: true },
|
||||
// });
|
||||
// const metaFieldMap = {
|
||||
// [VisitType.READED]: 'views',
|
||||
// [VisitType.LIKE]: 'likes',
|
||||
// [VisitType.HATE]: 'hates',
|
||||
// };
|
||||
// if (post?.type === PostType.LECTURE) {
|
||||
// const courseAncestry = await db.postAncestry.findFirst({
|
||||
// where: {
|
||||
// descendantId: post?.id,
|
||||
// ancestor: {
|
||||
// type: PostType.COURSE,
|
||||
// },
|
||||
// },
|
||||
// select: { id: true, ancestorId: true },
|
||||
// });
|
||||
// const course = { id: courseAncestry.ancestorId };
|
||||
// const lecturesAncestry = await db.postAncestry.findMany({
|
||||
// where: { ancestorId: course.id, descendant: { type: PostType.LECTURE } },
|
||||
// select: {
|
||||
// id: true,
|
||||
// descendantId: true,
|
||||
// },
|
||||
// });
|
||||
// const lectures = lecturesAncestry.map((ancestry) => ({
|
||||
// id: ancestry.descendantId,
|
||||
// }));
|
||||
// const courseViews = await db.visit.aggregate({
|
||||
// _sum: {
|
||||
// views: true,
|
||||
// },
|
||||
// where: {
|
||||
// postId: {
|
||||
// in: [course.id, ...lectures.map((lecture) => lecture.id)],
|
||||
// },
|
||||
// type: type,
|
||||
// },
|
||||
// });
|
||||
// await db.post.update({
|
||||
// where: { id: course.id },
|
||||
// data: {
|
||||
// [metaFieldMap[type]]: courseViews._sum.views || 0,
|
||||
// // meta: {
|
||||
// // ...((post?.meta as any) || {}),
|
||||
// // [metaFieldMap[type]]: courseViews._sum.views || 0,
|
||||
// // },
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// const totalViews = await db.visit.aggregate({
|
||||
// _sum: {
|
||||
// views: true,
|
||||
// },
|
||||
// where: {
|
||||
// postId: id,
|
||||
// type: type,
|
||||
// },
|
||||
// });
|
||||
// await db.post.update({
|
||||
// where: { id },
|
||||
// data: {
|
||||
// [metaFieldMap[type]]: totalViews._sum.views || 0,
|
||||
// // meta: {
|
||||
// // ...((post?.meta as any) || {}),
|
||||
// // [metaFieldMap[type]]: totalViews._sum.views || 0,
|
||||
// // },
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
|
|
@ -6,61 +6,61 @@ import { ObjectType } from '@nice/common';
|
|||
// updateCourseReviewStats,
|
||||
// } from '@server/models/course/utils';
|
||||
import { QueueJobType } from '../types';
|
||||
import {
|
||||
updateCourseEnrollmentStats,
|
||||
updateCourseReviewStats,
|
||||
updateParentLectureStats,
|
||||
} from '@server/models/post/utils';
|
||||
import {
|
||||
updatePostViewCount,
|
||||
updateTotalCourseViewCount,
|
||||
} from '../models/post/utils';
|
||||
const logger = new Logger('QueueWorker');
|
||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||
try {
|
||||
if (job.name === QueueJobType.UPDATE_STATS) {
|
||||
const { sectionId, courseId, type } = job.data;
|
||||
// 处理 section 统计
|
||||
if (sectionId) {
|
||||
await updateParentLectureStats(sectionId);
|
||||
logger.debug(`Updated section stats for sectionId: ${sectionId}`);
|
||||
}
|
||||
// 如果没有 courseId,提前返回
|
||||
if (!courseId) {
|
||||
return;
|
||||
}
|
||||
// 处理 course 相关统计
|
||||
switch (type) {
|
||||
case ObjectType.LECTURE:
|
||||
await updateParentLectureStats(courseId);
|
||||
break;
|
||||
case ObjectType.ENROLLMENT:
|
||||
await updateCourseEnrollmentStats(courseId);
|
||||
break;
|
||||
case ObjectType.POST:
|
||||
await updateCourseReviewStats(courseId);
|
||||
break;
|
||||
default:
|
||||
logger.warn(`Unknown update stats type: ${type}`);
|
||||
}
|
||||
// import {
|
||||
// updateCourseEnrollmentStats,
|
||||
// updateCourseReviewStats,
|
||||
// updateParentLectureStats,
|
||||
// } from '@server/models/post/utils';
|
||||
// import {
|
||||
// updatePostViewCount,
|
||||
// updateTotalCourseViewCount,
|
||||
// } from '../models/post/utils';
|
||||
// const logger = new Logger('QueueWorker');
|
||||
// export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||
// try {
|
||||
// if (job.name === QueueJobType.UPDATE_STATS) {
|
||||
// const { sectionId, courseId, type } = job.data;
|
||||
// // 处理 section 统计
|
||||
// if (sectionId) {
|
||||
// await updateParentLectureStats(sectionId);
|
||||
// logger.debug(`Updated section stats for sectionId: ${sectionId}`);
|
||||
// }
|
||||
// // 如果没有 courseId,提前返回
|
||||
// if (!courseId) {
|
||||
// return;
|
||||
// }
|
||||
// // 处理 course 相关统计
|
||||
// switch (type) {
|
||||
// case ObjectType.LECTURE:
|
||||
// await updateParentLectureStats(courseId);
|
||||
// break;
|
||||
// case ObjectType.ENROLLMENT:
|
||||
// await updateCourseEnrollmentStats(courseId);
|
||||
// break;
|
||||
// case ObjectType.POST:
|
||||
// await updateCourseReviewStats(courseId);
|
||||
// break;
|
||||
// default:
|
||||
// logger.warn(`Unknown update stats type: ${type}`);
|
||||
// }
|
||||
|
||||
logger.debug(
|
||||
`Updated course stats for courseId: ${courseId}, type: ${type}`,
|
||||
);
|
||||
}
|
||||
if (job.name === QueueJobType.UPDATE_POST_VISIT_COUNT) {
|
||||
await updatePostViewCount(job.data.id, job.data.type);
|
||||
}
|
||||
if (job.name === QueueJobType.UPDATE_POST_STATE) {
|
||||
await updatePostViewCount(job.data.id, job.data.type);
|
||||
}
|
||||
if (job.name === QueueJobType.UPDATE_TOTAL_COURSE_VIEW_COUNT) {
|
||||
await updateTotalCourseViewCount(job.data.type);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`Error processing stats update job: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
// logger.debug(
|
||||
// `Updated course stats for courseId: ${courseId}, type: ${type}`,
|
||||
// );
|
||||
// }
|
||||
// if (job.name === QueueJobType.UPDATE_POST_VISIT_COUNT) {
|
||||
// await updatePostViewCount(job.data.id, job.data.type);
|
||||
// }
|
||||
// if (job.name === QueueJobType.UPDATE_POST_STATE) {
|
||||
// await updatePostViewCount(job.data.id, job.data.type);
|
||||
// }
|
||||
// if (job.name === QueueJobType.UPDATE_TOTAL_COURSE_VIEW_COUNT) {
|
||||
// await updateTotalCourseViewCount(job.data.type);
|
||||
// }
|
||||
// } catch (error: any) {
|
||||
// logger.error(
|
||||
// `Error processing stats update job: ${error.message}`,
|
||||
// error.stack,
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
|
|
@ -2,20 +2,20 @@ import { Injectable, OnModuleInit } from "@nestjs/common";
|
|||
import { WebSocketType } from "../types";
|
||||
import { BaseWebSocketServer } from "../base/base-websocket-server";
|
||||
import EventBus, { CrudOperation } from "@server/utils/event-bus";
|
||||
import { ObjectType, SocketMsgType, MessageDto, PostDto, PostType } from "@nice/common";
|
||||
import { ObjectType, SocketMsgType } from "@nice/common";
|
||||
@Injectable()
|
||||
export class RealtimeServer extends BaseWebSocketServer implements OnModuleInit {
|
||||
onModuleInit() {
|
||||
EventBus.on("dataChanged", ({ data, type, operation }) => {
|
||||
if (type === ObjectType.MESSAGE && operation === CrudOperation.CREATED) {
|
||||
const receiverIds = (data as Partial<MessageDto>).receivers.map(receiver => receiver.id)
|
||||
this.sendToUsers(receiverIds, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.MESSAGE } })
|
||||
}
|
||||
// if (type === ObjectType.MESSAGE && operation === CrudOperation.CREATED) {
|
||||
// const receiverIds = (data as Partial<MessageDto>).receivers.map(receiver => receiver.id)
|
||||
// this.sendToUsers(receiverIds, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.MESSAGE } })
|
||||
// }
|
||||
|
||||
if (type === ObjectType.POST) {
|
||||
const post = data as Partial<PostDto>
|
||||
// if (type === ObjectType.POST) {
|
||||
// const post = data as Partial<PostDto>
|
||||
|
||||
}
|
||||
// }
|
||||
})
|
||||
|
||||
}
|
||||
|
|
|
@ -38,20 +38,131 @@ export class GenDevService {
|
|||
private readonly departmentService: DepartmentService,
|
||||
private readonly staffService: StaffService,
|
||||
private readonly termService: TermService,
|
||||
) {}
|
||||
) { }
|
||||
async genDataEvent() {
|
||||
EventBus.emit('genDataEvent', { type: 'start' });
|
||||
try {
|
||||
await this.calculateCounts();
|
||||
await this.initializeDeviceSystemType();
|
||||
await this.generateDepartments(3, 6);
|
||||
await this.generateTerms(2, 6);
|
||||
await this.generateStaffs(4);
|
||||
await this.generateCourses(8);
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
EventBus.emit('genDataEvent', { type: 'end' });
|
||||
}
|
||||
|
||||
private async initializeDeviceSystemType() {
|
||||
this.logger.log('初始化设备分类');
|
||||
// 创建网系类别分类
|
||||
let systemTypeTaxonomy = await db.taxonomy.findFirst({
|
||||
where: { slug: 'system_type' },
|
||||
});
|
||||
if (!systemTypeTaxonomy) {
|
||||
systemTypeTaxonomy = await db.taxonomy.create({
|
||||
data: {
|
||||
name: '网系类别',
|
||||
slug: 'system_type',
|
||||
objectType: ['device'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 创建设备类型分类
|
||||
let deviceTypeTaxonomy = await db.taxonomy.findFirst({
|
||||
where: { slug: 'device_type' },
|
||||
});
|
||||
if (!deviceTypeTaxonomy) {
|
||||
deviceTypeTaxonomy = await db.taxonomy.create({
|
||||
data: {
|
||||
name: '故障类型',
|
||||
slug: 'device_type',
|
||||
objectType: ['device'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log('创建网系类别记录');
|
||||
|
||||
// 定义网系类别
|
||||
const systemTypes = [
|
||||
{ name: '文印系统', children: ['电源故障', '主板故障', '内存故障', '硬盘故障', '显示器故障', '键盘故障', '鼠标故障'] },
|
||||
{ name: '内网系统', children: ['系统崩溃', '应用程序错误', '病毒感染', '驱动问题', '系统更新失败'] },
|
||||
{ name: 'Windows系统', children: ['系统响应慢', '资源占用过高', '过热', '电池寿命短', '存储空间不足'] },
|
||||
{ name: 'Linux系统', children: ['未知错误', '用户操作错误', '环境因素', '设备老化'] },
|
||||
{ name: '移动设备系统', children: ['参数设置错误', '配置文件损坏', '兼容性问题', '初始化失败'] },
|
||||
];
|
||||
|
||||
// 定义安防设备的子类型
|
||||
const securityDevices = {
|
||||
未知错误: ['未授权访问', '数据泄露', '密码重置', '权限异常', '安全策略冲突'] ,
|
||||
};
|
||||
|
||||
// 创建网系类别及其关联的设备类型
|
||||
for (const systemTypeData of systemTypes) {
|
||||
const systemType = await db.term.findFirst({
|
||||
where: {
|
||||
name: systemTypeData.name,
|
||||
taxonomyId: systemTypeTaxonomy.id,
|
||||
},
|
||||
});
|
||||
|
||||
let systemTypeId;
|
||||
if (!systemType) {
|
||||
const newSystemType = await db.term.create({
|
||||
data: {
|
||||
name: systemTypeData.name,
|
||||
taxonomyId: systemTypeTaxonomy.id,
|
||||
hasChildren: true,
|
||||
},
|
||||
});
|
||||
systemTypeId = newSystemType.id;
|
||||
} else {
|
||||
systemTypeId = systemType.id;
|
||||
}
|
||||
|
||||
// 为每个网系类别创建关联的设备类型
|
||||
for (const deviceTypeName of systemTypeData.children) {
|
||||
const deviceType = await db.term.findFirst({
|
||||
where: {
|
||||
name: deviceTypeName,
|
||||
taxonomyId: deviceTypeTaxonomy.id,
|
||||
parentId: systemTypeId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!deviceType) {
|
||||
// 判断是否需要添加子设备
|
||||
const hasSubTypes = securityDevices[deviceTypeName] !== undefined;
|
||||
|
||||
const newDeviceType = await db.term.create({
|
||||
data: {
|
||||
name: deviceTypeName,
|
||||
taxonomyId: deviceTypeTaxonomy.id,
|
||||
parentId: systemTypeId,
|
||||
hasChildren: hasSubTypes,
|
||||
},
|
||||
});
|
||||
|
||||
// 如果该设备类型有子类型,创建子类型
|
||||
if (hasSubTypes) {
|
||||
for (const subTypeName of securityDevices[deviceTypeName]) {
|
||||
await db.term.create({
|
||||
data: {
|
||||
name: subTypeName,
|
||||
taxonomyId: deviceTypeTaxonomy.id,
|
||||
parentId: newDeviceType.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('初始化设备分类完成');
|
||||
}
|
||||
private async calculateCounts() {
|
||||
this.counts = await getCounts();
|
||||
Object.entries(this.counts).forEach(([key, value]) => {
|
||||
|
@ -144,65 +255,7 @@ export class GenDevService {
|
|||
collectChildren(domainId);
|
||||
return children;
|
||||
}
|
||||
private async generateCourses(countPerCate: number = 3) {
|
||||
const titleList = [
|
||||
'计算机科学导论',
|
||||
'数据结构与算法',
|
||||
'网络安全',
|
||||
'机器学习',
|
||||
'数据库管理系统',
|
||||
'Web开发',
|
||||
'移动应用开发',
|
||||
'人工智能',
|
||||
'计算机网络',
|
||||
'操作系统',
|
||||
'数字信号处理',
|
||||
'无线通信',
|
||||
'信息论',
|
||||
'密码学',
|
||||
'计算机图形学',
|
||||
];
|
||||
|
||||
if (!this.counts.courseCount) {
|
||||
this.logger.log('Generating courses...');
|
||||
const depts = await db.department.findMany({
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const cates = await db.term.findMany({
|
||||
where: {
|
||||
taxonomy: { slug: TaxonomySlug.CATEGORY },
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const total = cates.length * countPerCate;
|
||||
const levels = await db.term.findMany({
|
||||
where: {
|
||||
taxonomy: { slug: TaxonomySlug.LEVEL },
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
for (const cate of cates) {
|
||||
for (let i = 0; i < countPerCate; i++) {
|
||||
const randomTitle = `${titleList[Math.floor(Math.random() * titleList.length)]} ${Math.random().toString(36).substring(7)}`;
|
||||
const randomLevelId =
|
||||
levels[Math.floor(Math.random() * levels.length)].id;
|
||||
const randomDeptId =
|
||||
depts[Math.floor(Math.random() * depts.length)].id;
|
||||
|
||||
await this.createCourse(
|
||||
randomTitle,
|
||||
randomDeptId,
|
||||
cate.id,
|
||||
randomLevelId,
|
||||
);
|
||||
this.courseGeneratedCount++;
|
||||
this.logger.log(
|
||||
`Generated ${this.courseGeneratedCount}/${total} course`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private async generateStaffs(countPerDept: number = 3) {
|
||||
if (this.counts.staffCount === 1) {
|
||||
this.logger.log('Generating staffs...');
|
||||
|
@ -266,31 +319,6 @@ export class GenDevService {
|
|||
throw error; // 向上抛出错误供上层处理
|
||||
}
|
||||
}
|
||||
private async createCourse(
|
||||
title: string,
|
||||
deptId: string,
|
||||
cateId: string,
|
||||
levelId: string,
|
||||
) {
|
||||
const course = await db.post.create({
|
||||
data: {
|
||||
type: PostType.COURSE,
|
||||
title: title,
|
||||
updatedAt: dayjs().toDate(),
|
||||
depts: {
|
||||
connect: {
|
||||
id: deptId,
|
||||
},
|
||||
},
|
||||
terms: {
|
||||
connect: [cateId, levelId].map((id) => ({
|
||||
id: id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
return course;
|
||||
}
|
||||
private async createDepartment(
|
||||
name: string,
|
||||
parentId?: string | null,
|
||||
|
|
|
@ -9,8 +9,15 @@ import { DepartmentModule } from '@server/models/department/department.module';
|
|||
import { TermModule } from '@server/models/term/term.module';
|
||||
|
||||
@Module({
|
||||
imports: [MinioModule, AuthModule, AppConfigModule, StaffModule, DepartmentModule, TermModule],
|
||||
imports: [
|
||||
MinioModule,
|
||||
AuthModule,
|
||||
AppConfigModule,
|
||||
StaffModule,
|
||||
DepartmentModule,
|
||||
TermModule,
|
||||
],
|
||||
providers: [InitService, GenDevService],
|
||||
exports: [InitService]
|
||||
exports: [InitService],
|
||||
})
|
||||
export class InitModule { }
|
||||
export class InitModule {}
|
||||
|
|
|
@ -54,7 +54,7 @@ export class InitService {
|
|||
},
|
||||
});
|
||||
this.logger.log(`Created new taxonomy: ${taxonomy.name}`);
|
||||
} else if(process.env.NODE_ENV === 'development'){
|
||||
} else if (process.env.NODE_ENV === 'development') {
|
||||
// Check for differences and update if necessary
|
||||
const differences = Object.keys(taxonomy).filter(
|
||||
(key) => taxonomy[key] !== existingTaxonomy[key],
|
||||
|
|
|
@ -8,10 +8,8 @@ import {
|
|||
import dayjs from 'dayjs';
|
||||
export interface DevDataCounts {
|
||||
deptCount: number;
|
||||
|
||||
staffCount: number;
|
||||
termCount: number;
|
||||
courseCount: number;
|
||||
}
|
||||
export async function getCounts(): Promise<DevDataCounts> {
|
||||
const counts = {
|
||||
|
@ -19,11 +17,6 @@ export async function getCounts(): Promise<DevDataCounts> {
|
|||
|
||||
staffCount: await db.staff.count(),
|
||||
termCount: await db.term.count(),
|
||||
courseCount: await db.post.count({
|
||||
where: {
|
||||
type: PostType.COURSE,
|
||||
},
|
||||
}),
|
||||
};
|
||||
return counts;
|
||||
}
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ReminderService } from './reminder.service';
|
||||
import { MessageModule } from '@server/models/message/message.module';
|
||||
|
||||
@Module({
|
||||
imports: [ MessageModule],
|
||||
imports: [],
|
||||
providers: [ReminderService],
|
||||
exports: [ReminderService]
|
||||
})
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { MessageService } from '@server/models/message/message.service';
|
||||
|
||||
/**
|
||||
* 提醒服务类
|
||||
|
@ -25,7 +24,7 @@ export class ReminderService {
|
|||
* 构造函数
|
||||
* @param messageService 消息服务实例
|
||||
*/
|
||||
constructor(private readonly messageService: MessageService) { }
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* 生成提醒时间点
|
||||
|
|
|
@ -8,15 +8,11 @@ import { TermModule } from '@server/models/term/term.module';
|
|||
import { TaxonomyModule } from '@server/models/taxonomy/taxonomy.module';
|
||||
import { AuthModule } from '@server/auth/auth.module';
|
||||
import { AppConfigModule } from '@server/models/app-config/app-config.module';
|
||||
import { MessageModule } from '@server/models/message/message.module';
|
||||
import { PostModule } from '@server/models/post/post.module';
|
||||
import { VisitModule } from '@server/models/visit/visit.module';
|
||||
import { WebSocketModule } from '@server/socket/websocket.module';
|
||||
import { RoleMapModule } from '@server/models/rbac/rbac.module';
|
||||
import { TransformModule } from '@server/models/transform/transform.module';
|
||||
import { ShareCodeModule } from '@server/models/share-code/share-code.module';
|
||||
import { ResourceModule } from '@server/models/resource/resource.module';
|
||||
|
||||
import { DeviceModule } from '@server/models/device/device.module';
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
|
@ -27,13 +23,10 @@ import { ResourceModule } from '@server/models/resource/resource.module';
|
|||
TaxonomyModule,
|
||||
RoleMapModule,
|
||||
TransformModule,
|
||||
MessageModule,
|
||||
AppConfigModule,
|
||||
PostModule,
|
||||
VisitModule,
|
||||
WebSocketModule,
|
||||
ResourceModule,
|
||||
ShareCodeModule,
|
||||
DeviceModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [TrpcService, TrpcRouter, Logger],
|
||||
|
|
|
@ -7,20 +7,16 @@ import { TrpcService } from '@server/trpc/trpc.service';
|
|||
import * as trpcExpress from '@trpc/server/adapters/express';
|
||||
import ws, { WebSocketServer } from 'ws';
|
||||
import { AppConfigRouter } from '@server/models/app-config/app-config.router';
|
||||
import { MessageRouter } from '@server/models/message/message.router';
|
||||
import { PostRouter } from '@server/models/post/post.router';
|
||||
import { VisitRouter } from '@server/models/visit/visit.router';
|
||||
import { RoleMapRouter } from '@server/models/rbac/rolemap.router';
|
||||
import { TransformRouter } from '@server/models/transform/transform.router';
|
||||
import { RoleRouter } from '@server/models/rbac/role.router';
|
||||
import { ResourceRouter } from '../models/resource/resource.router';
|
||||
import { ShareCodeRouter } from '@server/models/share-code/share-code.router';
|
||||
import { DeviceRouter } from '@server/models/device/device.router';
|
||||
@Injectable()
|
||||
export class TrpcRouter {
|
||||
logger = new Logger(TrpcRouter.name);
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly post: PostRouter,
|
||||
private readonly department: DepartmentRouter,
|
||||
private readonly staff: StaffRouter,
|
||||
private readonly term: TermRouter,
|
||||
|
@ -29,28 +25,23 @@ export class TrpcRouter {
|
|||
private readonly rolemap: RoleMapRouter,
|
||||
private readonly transform: TransformRouter,
|
||||
private readonly app_config: AppConfigRouter,
|
||||
private readonly message: MessageRouter,
|
||||
private readonly visitor: VisitRouter,
|
||||
private readonly resource: ResourceRouter,
|
||||
private readonly shareCode: ShareCodeRouter,
|
||||
private readonly device: DeviceRouter,
|
||||
) {}
|
||||
getRouter() {
|
||||
return;
|
||||
}
|
||||
appRouter = this.trpc.router({
|
||||
transform: this.transform.router,
|
||||
post: this.post.router,
|
||||
department: this.department.router,
|
||||
staff: this.staff.router,
|
||||
term: this.term.router,
|
||||
taxonomy: this.taxonomy.router,
|
||||
role: this.role.router,
|
||||
rolemap: this.rolemap.router,
|
||||
message: this.message.router,
|
||||
app_config: this.app_config.router,
|
||||
visitor: this.visitor.router,
|
||||
resource: this.resource.router,
|
||||
shareCode: this.shareCode.router,
|
||||
device: this.device.router,
|
||||
});
|
||||
wss: WebSocketServer = undefined;
|
||||
|
||||
|
|
|
@ -1,173 +0,0 @@
|
|||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { customAlphabet } from 'nanoid-cjs';
|
||||
import { db } from '@nice/common';
|
||||
import { ShareCode, GenerateShareCodeResponse } from './types';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ResourceService } from '@server/models/resource/resource.service';
|
||||
|
||||
@Injectable()
|
||||
export class ShareCodeService {
|
||||
private readonly logger = new Logger(ShareCodeService.name);
|
||||
// 生成8位分享码,使用易读的字符
|
||||
private readonly generateCode = customAlphabet(
|
||||
'23456789ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||||
8,
|
||||
);
|
||||
|
||||
constructor(private readonly resourceService: ResourceService) {}
|
||||
|
||||
async generateShareCode(
|
||||
fileId: string,
|
||||
fileName?: string,
|
||||
): Promise<GenerateShareCodeResponse> {
|
||||
try {
|
||||
// 检查文件是否存在
|
||||
const resource = await this.resourceService.findUnique({
|
||||
where: { fileId },
|
||||
});
|
||||
console.log('完整 fileId:', fileId); // 确保与前端一致
|
||||
|
||||
if (!resource) {
|
||||
throw new NotFoundException('文件不存在');
|
||||
}
|
||||
|
||||
// 生成分享码
|
||||
const code = this.generateCode();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24小时后过期
|
||||
|
||||
// 查找是否已有分享码记录
|
||||
const existingShareCode = await db.shareCode.findUnique({
|
||||
where: { fileId },
|
||||
});
|
||||
|
||||
if (existingShareCode) {
|
||||
// 更新现有记录,但保留原有文件名
|
||||
await db.shareCode.update({
|
||||
where: { fileId },
|
||||
data: {
|
||||
code,
|
||||
expiresAt,
|
||||
isUsed: false,
|
||||
// 只在没有现有文件名且提供了新文件名时才更新文件名
|
||||
...(fileName && !existingShareCode.fileName ? { fileName } : {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 创建新记录
|
||||
await db.shareCode.create({
|
||||
data: {
|
||||
code,
|
||||
fileId,
|
||||
expiresAt,
|
||||
isUsed: false,
|
||||
fileName: fileName || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
this.logger.log(`Generated share code ${code} for file ${fileId}`);
|
||||
return {
|
||||
code,
|
||||
expiresAt,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to generate share code', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async validateAndUseCode(code: string): Promise<ShareCode | null> {
|
||||
try {
|
||||
console.log(`尝试验证分享码: ${code}`);
|
||||
|
||||
// 查找有效的分享码
|
||||
const shareCode = await db.shareCode.findFirst({
|
||||
where: {
|
||||
code,
|
||||
isUsed: false,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
console.log('查询结果:', shareCode);
|
||||
|
||||
if (!shareCode) {
|
||||
console.log('分享码无效或已过期');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 标记分享码为已使用
|
||||
// await db.shareCode.update({
|
||||
// where: { id: shareCode.id },
|
||||
// data: { isUsed: true },
|
||||
// });
|
||||
|
||||
// 记录使用日志
|
||||
this.logger.log(`Share code ${code} used for file ${shareCode.fileId}`);
|
||||
|
||||
// 返回完整的分享码信息,包括文件名
|
||||
return shareCode;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to validate share code', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 每天清理过期的分享码
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async cleanupExpiredShareCodes() {
|
||||
try {
|
||||
const result = await db.shareCode.deleteMany({
|
||||
where: {
|
||||
OR: [{ expiresAt: { lt: new Date() } }, { isUsed: true }],
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Cleaned up ${result.count} expired share codes`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to cleanup expired share codes', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分享码信息
|
||||
async getShareCodeInfo(code: string): Promise<ShareCode | null> {
|
||||
try {
|
||||
return await db.shareCode.findFirst({
|
||||
where: { code },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get share code info', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文件是否已经生成过分享码
|
||||
async hasActiveShareCode(fileId: string): Promise<boolean> {
|
||||
try {
|
||||
const activeCode = await db.shareCode.findFirst({
|
||||
where: {
|
||||
fileId,
|
||||
isUsed: false,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
return !!activeCode;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to check active share code', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件的所有分享记录
|
||||
async getFileShareHistory(fileId: string) {
|
||||
try {
|
||||
return await db.shareCode.findMany({
|
||||
where: { fileId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get file share history', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,7 +17,6 @@ import {
|
|||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { TusService } from './tus.service';
|
||||
import { ShareCodeService } from './share-code.service';
|
||||
import { ResourceService } from '@server/models/resource/resource.service';
|
||||
|
||||
interface ResourceMeta {
|
||||
|
@ -30,7 +29,6 @@ interface ResourceMeta {
|
|||
export class UploadController {
|
||||
constructor(
|
||||
private readonly tusService: TusService,
|
||||
private readonly shareCodeService: ShareCodeService,
|
||||
private readonly resourceService: ResourceService,
|
||||
) {}
|
||||
// @Post()
|
||||
|
@ -52,52 +50,7 @@ export class UploadController {
|
|||
async handlePost(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
@Get('share/:code')
|
||||
async validateShareCode(@Param('code') code: string) {
|
||||
console.log('收到验证分享码请求,code:', code);
|
||||
|
||||
const shareCode = await this.shareCodeService.validateAndUseCode(code);
|
||||
console.log('验证分享码结果:', shareCode);
|
||||
|
||||
if (!shareCode) {
|
||||
throw new NotFoundException('分享码无效或已过期');
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
const resource = await this.resourceService.findUnique({
|
||||
where: { fileId: shareCode.fileId },
|
||||
});
|
||||
console.log('获取到的资源信息:', resource);
|
||||
const {filename} = resource.meta as any as ResourceMeta
|
||||
if (!resource) {
|
||||
throw new NotFoundException('文件不存在');
|
||||
}
|
||||
|
||||
// 直接返回正确的数据结构
|
||||
const response = {
|
||||
fileId: shareCode.fileId,
|
||||
fileName:filename || 'downloaded_file',
|
||||
code: shareCode.code,
|
||||
expiresAt: shareCode.expiresAt
|
||||
};
|
||||
|
||||
console.log('返回给前端的数据:', response); // 添加日志
|
||||
return response;
|
||||
}
|
||||
|
||||
@Get('share/info/:code')
|
||||
async getShareCodeInfo(@Param('code') code: string) {
|
||||
const info = await this.shareCodeService.getShareCodeInfo(code);
|
||||
if (!info) {
|
||||
throw new NotFoundException('分享码不存在');
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
@Get('share/history/:fileId')
|
||||
async getFileShareHistory(@Param('fileId') fileId: string) {
|
||||
return this.shareCodeService.getFileShareHistory(fileId);
|
||||
}
|
||||
@Get('/*')
|
||||
async handleGet(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
|
@ -113,80 +66,5 @@ export class UploadController {
|
|||
async handleUpload(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
@Post('share/:fileId(*)')
|
||||
async generateShareCode(@Param('fileId') fileId: string) {
|
||||
try {
|
||||
console.log('收到生成分享码请求,fileId:', fileId);
|
||||
const result = await this.shareCodeService.generateShareCode(fileId);
|
||||
console.log('生成分享码结果:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('生成分享码错误:', error);
|
||||
throw new HttpException(
|
||||
{
|
||||
message: (error as Error).message || '生成分享码失败',
|
||||
error: 'SHARE_CODE_GENERATION_FAILED'
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('filename')
|
||||
async saveFileName(@Body() data: { fileId: string; fileName: string }) {
|
||||
try {
|
||||
console.log('收到保存文件名请求:', data);
|
||||
|
||||
// 检查参数
|
||||
if (!data.fileId || !data.fileName) {
|
||||
throw new HttpException(
|
||||
{ message: '缺少必要参数' },
|
||||
HttpStatus.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
// 保存文件名
|
||||
await this.resourceService.saveFileName(data.fileId, data.fileName);
|
||||
console.log('文件名保存成功:', data.fileName, '对应文件ID:', data.fileId);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('保存文件名失败:', error);
|
||||
throw new HttpException(
|
||||
{
|
||||
message: '保存文件名失败',
|
||||
error: (error instanceof Error) ? error.message : String(error)
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('download/:fileId')
|
||||
async downloadFile(@Param('fileId') fileId: string, @Res() res: Response) {
|
||||
try {
|
||||
// 获取文件信息
|
||||
const resource = await this.resourceService.findUnique({
|
||||
where: { fileId },
|
||||
});
|
||||
|
||||
if (!resource) {
|
||||
throw new NotFoundException('文件不存在');
|
||||
}
|
||||
|
||||
// 获取原始文件名
|
||||
const fileName = await this.resourceService.getFileName(fileId) || 'downloaded-file';
|
||||
|
||||
// 设置响应头,包含原始文件名
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(fileName)}"`
|
||||
);
|
||||
|
||||
// 其他下载逻辑...
|
||||
|
||||
} catch (error) {
|
||||
// 错误处理...
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ import { UploadController } from './upload.controller';
|
|||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { TusService } from './tus.service';
|
||||
import { ResourceModule } from '@server/models/resource/resource.module';
|
||||
import { ShareCodeService } from './share-code.service';
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
|
@ -12,6 +11,6 @@ import { ShareCodeService } from './share-code.service';
|
|||
ResourceModule,
|
||||
],
|
||||
controllers: [UploadController],
|
||||
providers: [TusService, ShareCodeService],
|
||||
providers: [TusService],
|
||||
})
|
||||
export class UploadModule {}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import mitt from 'mitt';
|
||||
import { ObjectType, UserProfile, MessageDto, VisitType } from '@nice/common';
|
||||
import { ObjectType, UserProfile, VisitType } from '@nice/common';
|
||||
export enum CrudOperation {
|
||||
CREATED,
|
||||
UPDATED,
|
||||
|
@ -24,7 +24,6 @@ type Events = {
|
|||
updateTotalCourseViewCount: {
|
||||
visitType: VisitType | string;
|
||||
};
|
||||
onMessageCreated: { data: Partial<MessageDto> };
|
||||
dataChanged: { type: string; operation: CrudOperation; data: any };
|
||||
};
|
||||
const EventBus = mitt<Events>();
|
||||
|
|
|
@ -57,7 +57,6 @@
|
|||
"framer-motion": "^11.15.0",
|
||||
"hls.js": "^1.5.18",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"mind-elixir": "workspace:^",
|
||||
"mitt": "^3.0.1",
|
||||
"quill": "2.0.3",
|
||||
"react": "18.2.0",
|
||||
|
@ -72,6 +71,7 @@
|
|||
"tailwind-merge": "^2.6.0",
|
||||
"use-debounce": "^10.0.4",
|
||||
"uuid": "^10.0.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"yjs": "^13.6.20",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
|
Before Width: | Height: | Size: 737 B After Width: | Height: | Size: 737 B |
Before Width: | Height: | Size: 737 B After Width: | Height: | Size: 737 B |
|
@ -1,15 +1,24 @@
|
|||
import { AppConfigSlug, BaseSetting, RolePerms } from "@nice/common";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Button, Form, Input, message, theme } from "antd";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
theme,
|
||||
Card,
|
||||
Divider,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import { useAppConfig } from "@nice/client";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import MultiAvatarUploader from "@web/src/components/common/uploader/MultiAvatarUploader";
|
||||
|
||||
import FixedHeader from "@web/src/components/layout/fix-header";
|
||||
import { useForm } from "antd/es/form/Form";
|
||||
import { api } from "@nice/client";
|
||||
import { MainLayoutContext } from "../layout";
|
||||
import CarouselUrlInput from "@web/src/components/common/input/CarouselUrlInput";
|
||||
import DeviceManager from "../../main/devicepage/select/Device-manager";
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
export default function BaseSettingPage() {
|
||||
const { update, baseSetting } = useAppConfig();
|
||||
|
@ -28,10 +37,11 @@ export default function BaseSettingPage() {
|
|||
const { user, hasSomePermissions } = useAuth();
|
||||
const context = useContext(MainLayoutContext);
|
||||
const pageWidth = context?.pageWidth;
|
||||
// const [meta,setMeta ] = useState<>(baseSetting);
|
||||
|
||||
function handleFieldsChange() {
|
||||
setIsFormChanged(true);
|
||||
}
|
||||
|
||||
function onResetClick() {
|
||||
if (!form) return;
|
||||
if (!baseSetting) {
|
||||
|
@ -42,9 +52,11 @@ export default function BaseSettingPage() {
|
|||
}
|
||||
setIsFormChanged(false);
|
||||
}
|
||||
|
||||
function onSaveClick() {
|
||||
if (form) form.submit();
|
||||
}
|
||||
|
||||
async function onSubmit(values: BaseSetting) {
|
||||
setLoading(true);
|
||||
const appConfig = values?.appConfig || {};
|
||||
|
@ -71,11 +83,13 @@ export default function BaseSettingPage() {
|
|||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (baseSetting && form) {
|
||||
form.setFieldsValue(baseSetting);
|
||||
}
|
||||
}, [baseSetting, form]);
|
||||
|
||||
return (
|
||||
<div style={{ width: pageWidth }}>
|
||||
<FixedHeader>
|
||||
|
@ -84,123 +98,32 @@ export default function BaseSettingPage() {
|
|||
hasSomePermissions(RolePerms.MANAGE_BASE_SETTING) && (
|
||||
<>
|
||||
<Button onClick={onResetClick}>重置</Button>
|
||||
<Button
|
||||
loading={loading}
|
||||
type="primary"
|
||||
onClick={onSaveClick}>
|
||||
<Button loading={loading} type="primary" onClick={onSaveClick}>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FixedHeader>
|
||||
<div
|
||||
className="flex flex-col overflow-auto "
|
||||
style={{ height: "calc(100vh - 48px - 49px)" }}>
|
||||
<Form
|
||||
form={form}
|
||||
disabled={
|
||||
!hasSomePermissions(RolePerms.MANAGE_BASE_SETTING)
|
||||
}
|
||||
onFinish={onSubmit}
|
||||
onFieldsChange={handleFieldsChange}
|
||||
layout="vertical">
|
||||
{/* <div
|
||||
className="p-2 border-b"
|
||||
style={{
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: "bold",
|
||||
}}>
|
||||
头像配置
|
||||
</div> */}
|
||||
<div
|
||||
className="p-2 border-b"
|
||||
style={{
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: "bold",
|
||||
}}>
|
||||
全局参数配置
|
||||
</div>
|
||||
<div className="p-2 grid grid-cols-8 gap-2 border-b">
|
||||
<Form.Item
|
||||
label="运维单位"
|
||||
name={["appConfig", "devDept"]}>
|
||||
<Input></Input>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="p-2 grid grid-cols-8 gap-2 border-b">
|
||||
<Form.Item
|
||||
label="首页轮播图"
|
||||
name={["appConfig", "slides"]}>
|
||||
<MultiAvatarUploader></MultiAvatarUploader>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="p-2 grid grid-cols-4 gap-2 border-b">
|
||||
<Form.Item
|
||||
label="首页轮播图链接"
|
||||
name={["appConfig", "slideLinks"]}>
|
||||
<CarouselUrlInput ></CarouselUrlInput>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{/* <div
|
||||
className="p-2 border-b flex items-center justify-between"
|
||||
style={{
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: "bold",
|
||||
|
||||
}}>
|
||||
登录页面配置
|
||||
<Button onClick={() => {
|
||||
form?.setFieldValue(["appConfig", "splashScreen"], undefined)
|
||||
setIsFormChanged(true)
|
||||
}}>重置</Button>
|
||||
</div>
|
||||
<div className="p-2 grid grid-cols-8 gap-2 border-b">
|
||||
<Form.Item
|
||||
label="首屏图片"
|
||||
name={["appConfig", "splashScreen"]}>
|
||||
<ImageUploader className="w-40" style={{ aspectRatio: "9/16" }} ></ImageUploader>
|
||||
</Form.Item>
|
||||
</div> */}
|
||||
</Form>
|
||||
<div
|
||||
className="p-2 border-b text-primary flex justify-between items-center"
|
||||
style={{
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: "bold",
|
||||
}}>
|
||||
<span>服务端全局命令</span>
|
||||
</div>
|
||||
<div className=" p-2 grid grid-cols-8 gap-4 border-b">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await utils.client.app_config.clearRowCache.mutate();
|
||||
message.success("操作成功"); // Displays a success message
|
||||
} catch (error) {
|
||||
message.error("操作失败,请重试"); // Displays an error message
|
||||
}
|
||||
}}
|
||||
type="primary"
|
||||
ghost>
|
||||
清除行模型缓存
|
||||
</Button>
|
||||
</div>
|
||||
{
|
||||
<div
|
||||
className="p-2 border-b text-primary flex justify-between items-center"
|
||||
style={{
|
||||
fontSize: token.fontSize,
|
||||
fontWeight: "bold",
|
||||
}}>
|
||||
<span>app在线人数</span>
|
||||
<div>
|
||||
{clientCount && clientCount > 0
|
||||
? `${clientCount}人在线`
|
||||
: "无人在线"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className="flex flex-col overflow-auto p-4"
|
||||
style={{ height: "calc(100vh - 48px - 49px)" }}
|
||||
>
|
||||
<Card bordered={false}>
|
||||
<Typography>
|
||||
<Title level={4}>网系故障分类管理</Title>
|
||||
<Paragraph>
|
||||
在此页面您可以管理网系故障的分类体系。系统会自动建立三层关系:网系类别
|
||||
→ 故障类型 →
|
||||
具体故障名称。使用树形结构可以清晰地查看和管理这些分类关系。
|
||||
</Paragraph>
|
||||
</Typography>
|
||||
|
||||
<Divider />
|
||||
|
||||
<DeviceManager title="网系故障分类" />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,116 +0,0 @@
|
|||
import { Form, FormInstance, message } from "antd";
|
||||
import { api } from "@nice/client";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import { ShareCodeResponse } from "../quick-file/quickFileContext";
|
||||
|
||||
interface CodeManageContextType {
|
||||
editForm: FormInstance<any>;
|
||||
isLoading: boolean;
|
||||
currentShareCodes: ShareCodeWithResource;
|
||||
currentPage: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
pageSize: number;
|
||||
deletShareCode: (id: string) => void;
|
||||
updateCode: (expiresAt: Date, canUseTimes: number) => void
|
||||
setCurrentCodeId: (id: string) => void,
|
||||
currentCodeId: string | null,
|
||||
searchRefetch: () => void,
|
||||
setSearchKeyword: (keyword: string) => void,
|
||||
currentCode: string | null,
|
||||
setCurrentCode: (code: string) => void,
|
||||
searchKeyword: string
|
||||
}
|
||||
|
||||
interface ShareCodeWithResource {
|
||||
items: ShareCodeResponse[],
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export const CodeManageContext = createContext<CodeManageContextType | null>(null);
|
||||
|
||||
export const CodeManageProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [editForm] = Form.useForm();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [currentCodeId, setCurrentCodeId] = useState<string | null>()
|
||||
const [currentCode, setCurrentCode] = useState<string | null>()
|
||||
const queryClient = useQueryClient();
|
||||
const pageSize = 8;
|
||||
// 在组件顶部添加
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
// 构建查询条件
|
||||
const whereCondition = {
|
||||
deletedAt: null,
|
||||
...(searchKeyword ? {
|
||||
OR: [
|
||||
{ fileName: { contains: searchKeyword } },
|
||||
{ code: { contains: searchKeyword } },
|
||||
{ uploadIp: { contains: searchKeyword } }
|
||||
]
|
||||
} : {})
|
||||
};
|
||||
const { data: currentShareCodes, refetch: searchRefetch }: { data: ShareCodeWithResource, refetch: () => void } = api.shareCode.getShareCodesWithResources.useQuery(
|
||||
{
|
||||
page: currentPage,
|
||||
pageSize: pageSize,
|
||||
where: whereCondition,
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
)
|
||||
const { mutate: softDeleteShareCode } = api.shareCode.softDeleteShareCodes.useMutation({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getQueryKey(api.shareCode) });
|
||||
},
|
||||
onError: () => {
|
||||
message.error('删除失败')
|
||||
}
|
||||
})
|
||||
const { mutate: updateShareCode } = api.shareCode.updateShareCode.useMutation({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getQueryKey(api.shareCode) });
|
||||
},
|
||||
onError: () => {
|
||||
message.error('更新失败')
|
||||
}
|
||||
})
|
||||
const deletShareCode = (id: string) => {
|
||||
softDeleteShareCode({ ids: [id] })
|
||||
}
|
||||
const updateCode = (expiresAt: Date, canUseTimes: number) => {
|
||||
if (currentCodeId) updateShareCode({ id: currentCodeId, data: { expiresAt, canUseTimes } })
|
||||
}
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
return <>
|
||||
<CodeManageContext.Provider value={{
|
||||
editForm,
|
||||
isLoading,
|
||||
currentShareCodes,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
pageSize,
|
||||
deletShareCode,
|
||||
updateCode,
|
||||
currentCodeId,
|
||||
setCurrentCodeId,
|
||||
searchRefetch,
|
||||
setSearchKeyword,
|
||||
currentCode,
|
||||
setCurrentCode,
|
||||
searchKeyword
|
||||
}}>
|
||||
{children}
|
||||
</CodeManageContext.Provider>
|
||||
</>
|
||||
};
|
||||
|
||||
export const useCodeManageContext = () => {
|
||||
const context = useContext(CodeManageContext);
|
||||
if (!context) {
|
||||
throw new Error("useCodeManageContext must be used within a CodeManageProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
|
@ -1,26 +0,0 @@
|
|||
import { useSearchParams } from "react-router-dom";
|
||||
import CodeManageSearchBase from "./components/CodeManageSearchBase";
|
||||
import CodeManageDisplay from "./components/CodeMangeDisplay";
|
||||
import { useCodeManageContext } from "./CodeManageContext";
|
||||
import { useEffect, useState } from "react";
|
||||
export default function CodeManageLayout() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setSearchKeyword, searchRefetch, setCurrentPage } = useCodeManageContext();
|
||||
const [localKeyword, setLocalKeyword] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const keyword = searchParams.get('keyword');
|
||||
if (keyword) {
|
||||
setSearchKeyword(keyword);
|
||||
setLocalKeyword(keyword);
|
||||
setCurrentPage(1);
|
||||
searchRefetch();
|
||||
}
|
||||
}, [searchParams, setSearchKeyword, setCurrentPage, searchRefetch]);
|
||||
return (
|
||||
<div className="max-w-[1100px] mx-auto h-[100vh]">
|
||||
<CodeManageSearchBase keyword={localKeyword}></CodeManageSearchBase>
|
||||
<CodeManageDisplay></CodeManageDisplay>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
import { useCodeManageContext } from "../CodeManageContext";
|
||||
import { Form, DatePicker, Input, Button } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function CodeManageEdit() {
|
||||
const { editForm } = useCodeManageContext();
|
||||
// 验证数字输入只能是大于等于0的整数
|
||||
const validatePositiveInteger = (_: any, value: string) => {
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < 0 || num !== parseFloat(value)) {
|
||||
return Promise.reject("请输入大于等于0的整数");
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto bg-white p-6 rounded-lg">
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
className="space-y-4"
|
||||
>
|
||||
<Form.Item
|
||||
label={<span className="text-gray-700 font-medium">分享码有效期</span>}
|
||||
name="expiresAt"
|
||||
rules={[{ required: true, message: "请选择有效期" }]}
|
||||
className="mb-5"
|
||||
>
|
||||
<DatePicker
|
||||
className="w-full"
|
||||
showTime
|
||||
placeholder="选择日期和时间"
|
||||
disabledDate={(current) => current && current < dayjs().startOf('day')}
|
||||
disabledTime={(current) => {
|
||||
if (current && current.isSame(dayjs(), 'day')) {
|
||||
return {
|
||||
disabledHours: () => [...Array(dayjs().hour()).keys()],
|
||||
disabledMinutes: (selectedHour) => {
|
||||
if (selectedHour === dayjs().hour()) {
|
||||
return [...Array(dayjs().minute()).keys()];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-gray-700 font-medium">使用次数</span>}
|
||||
name="canUseTimes"
|
||||
rules={[
|
||||
{ required: true, message: "请输入使用次数" },
|
||||
{ validator: validatePositiveInteger }
|
||||
]}
|
||||
className="mb-5"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="请输入使用次数"
|
||||
className="w-full"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,68 +0,0 @@
|
|||
import { Button, Form, Input } from 'antd';
|
||||
import { useCodeManageContext } from '../CodeManageContext';
|
||||
import { ChangeEvent, useEffect, useRef } from 'react';
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
export default function CodeManageSearchBase({ keyword }: { keyword?: string }) {
|
||||
const { setCurrentPage, searchRefetch, setSearchKeyword, searchKeyword } = useCodeManageContext();
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const formRef = Form.useForm()[0]; // 获取表单实例
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// 当 keyword 属性变化时更新表单值
|
||||
useEffect(() => {
|
||||
if (keyword) {
|
||||
formRef.setFieldsValue({ search: keyword });
|
||||
}
|
||||
}, [keyword, formRef]);
|
||||
|
||||
// 监听 searchKeyword 变化,如果 URL 参数中有 keyword 且 searchKeyword 发生变化,则清空 URL 参数
|
||||
useEffect(() => {
|
||||
const urlKeyword = searchParams.get('keyword');
|
||||
if (urlKeyword && searchKeyword !== urlKeyword) {
|
||||
// 创建一个新的 URLSearchParams 对象,不包含 keyword 参数
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('keyword');
|
||||
setSearchParams(newParams);
|
||||
}
|
||||
}, [searchKeyword, searchParams, setSearchParams]);
|
||||
|
||||
const onSearch = (value: string) => {
|
||||
console.log(value);
|
||||
setSearchKeyword(value);
|
||||
setCurrentPage(1)
|
||||
searchRefetch()
|
||||
};
|
||||
|
||||
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// 设置表单值
|
||||
setSearchKeyword(e.target.value);
|
||||
// 设置页码为1,确保从第一页开始显示搜索结果
|
||||
setCurrentPage(1);
|
||||
// 使用防抖处理,避免频繁发送请求
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
// 触发查询
|
||||
searchRefetch();
|
||||
}, 300); // 300毫秒的防抖延迟
|
||||
};
|
||||
|
||||
return <>
|
||||
<div className="py-4 mt-2 w-2/3 mx-auto">
|
||||
<Form form={formRef}>
|
||||
<Form.Item name="search" label="关键字搜索">
|
||||
<Input.Search
|
||||
placeholder="输入分享码、文件名或用户IP"
|
||||
enterButton
|
||||
value={searchKeyword}
|
||||
allowClear
|
||||
onSearch={onSearch}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
import { message, Modal, Pagination } from "antd";
|
||||
import ShareCodeList from "./ShareCodeList";
|
||||
import { useCodeManageContext } from "../CodeManageContext";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ExclamationCircleFilled } from "@ant-design/icons";
|
||||
import CodeManageEdit from "./CodeManageEdit";
|
||||
import dayjs from "dayjs";
|
||||
export default function CodeMangeDisplay() {
|
||||
const { isLoading, currentShareCodes, pageSize, currentPage,
|
||||
setCurrentPage, deletShareCode, editForm,
|
||||
updateCode, setCurrentCodeId, setCurrentCode, currentCode
|
||||
} = useCodeManageContext();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const { confirm } = Modal;
|
||||
const handleEdit = (id: string, expiresAt: Date, canUseTimes: number, code: string) => {
|
||||
console.log('编辑分享码:', id);
|
||||
setCurrentCodeId(id)
|
||||
setModalOpen(true);
|
||||
setCurrentCode(code)
|
||||
editForm.setFieldsValue({
|
||||
expiresAt: dayjs(expiresAt),
|
||||
canUseTimes: canUseTimes
|
||||
});
|
||||
};
|
||||
const handleEditOk = () => {
|
||||
const expiresAt = editForm.getFieldsValue().expiresAt.tz('Asia/Shanghai').toDate()
|
||||
const canUseTimes = Number(editForm.getFieldsValue().canUseTimes)
|
||||
updateCode(expiresAt, canUseTimes)
|
||||
message.success('分享码已更新')
|
||||
setModalOpen(false)
|
||||
}
|
||||
const handleDelete = (id: string) => {
|
||||
console.log('删除分享码:', id);
|
||||
confirm({
|
||||
title: '确定删除该分享码吗',
|
||||
icon: <ExclamationCircleFilled />,
|
||||
content: '',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
deletShareCode(id)
|
||||
message.success('分享码已删除')
|
||||
},
|
||||
onCancel() {
|
||||
},
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
console.log('currentShareCodes:', currentShareCodes);
|
||||
}, [currentShareCodes]);
|
||||
return <>
|
||||
<div className="w-full min-h-[720px] mx-auto">
|
||||
<ShareCodeList
|
||||
data={currentShareCodes?.items}
|
||||
loading={isLoading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-4 mt-10 w-2/3 mx-auto flex justify-center">
|
||||
<Pagination
|
||||
defaultCurrent={currentPage}
|
||||
total={currentShareCodes?.totalPages * pageSize}
|
||||
pageSize={pageSize}
|
||||
onChange={(page, pageSize) => {
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Modal
|
||||
width={550}
|
||||
onOk={() => {
|
||||
handleEditOk()
|
||||
}}
|
||||
centered
|
||||
open={modalOpen}
|
||||
confirmLoading={formLoading}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
}}
|
||||
title={`编辑分享码:${currentCode}`}>
|
||||
<CodeManageEdit></CodeManageEdit>
|
||||
</Modal>
|
||||
</>
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
import React from 'react';
|
||||
import { List,} from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import ShareCodeListCard from './ShareCodeListCard';
|
||||
import { ShareCodeResponse } from '../../quick-file/quickFileContext';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
|
||||
interface ShareCodeListProps {
|
||||
data: ShareCodeResponse[];
|
||||
loading?: boolean;
|
||||
onEdit?: (id: string,expiresAt:Date,canUseTimes:number,code:string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
const ShareCodeList: React.FC<ShareCodeListProps> = ({
|
||||
data,
|
||||
loading,
|
||||
onEdit,
|
||||
onDelete
|
||||
}) => {
|
||||
|
||||
return (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 2, md: 4, lg: 4 }}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<ShareCodeListCard item={item} onEdit={onEdit} onDelete={onDelete} styles='w-[262px]' />
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareCodeList;
|
|
@ -1,79 +0,0 @@
|
|||
import { DeleteOutlined, DownloadOutlined, EditOutlined } from "@ant-design/icons";
|
||||
import { Button, Card, Typography } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect } from "react";
|
||||
import { ShareCodeResponse } from "../../quick-file/quickFileContext";
|
||||
|
||||
export default function ShareCodeListCard({ item, onEdit, onDelete, styles, onDownload }:
|
||||
{
|
||||
item: ShareCodeResponse,
|
||||
styles?: string,
|
||||
onDelete: (id: string) => void,
|
||||
onEdit?: (id: string, expiresAt: Date, canUseTimes: number, code: string) => void,
|
||||
onDownload?: (id: string) => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.log('item:', item);
|
||||
}, [item]);
|
||||
return <div className={`${styles}`}>
|
||||
<Card
|
||||
className="shadow-md hover:shadow-lg h-[344px] transition-shadow duration-300 space-x-4"
|
||||
title={
|
||||
<Typography.Text
|
||||
strong
|
||||
className="text-lg font-semibold text-blue-600"
|
||||
>
|
||||
{item.code}
|
||||
</Typography.Text>
|
||||
}
|
||||
hoverable
|
||||
actions={[
|
||||
onEdit && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => onEdit?.(item.id, dayjs(item.expiresAt).toDate(), item.canUseTimes, item.code)}
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
/>
|
||||
),
|
||||
onDownload && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => onDownload?.(item.code)}
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
/>
|
||||
),
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => onDelete?.(item.id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
/>
|
||||
].filter(Boolean)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-gray-700 whitespace-nowrap overflow-hidden text-overflow-ellipsis hover:overflow-auto">
|
||||
<span className="font-medium">文件名:</span> {item.fileName}
|
||||
</p>
|
||||
<p className="text-gray-700 whitespace-nowrap overflow-hidden text-overflow-ellipsis hover:overflow-auto">
|
||||
<span className="font-medium">文件大小:</span> {Math.max(0.01, (Number(item?.resource?.meta?.size) / 1024 / 1024)).toFixed(2)} MB
|
||||
</p>
|
||||
|
||||
<p className="text-gray-700 whitespace-nowrap overflow-hidden text-overflow-ellipsis hover:overflow-auto">
|
||||
<span className="font-medium">剩余使用次数:</span> {item.canUseTimes}
|
||||
</p>
|
||||
<p className="text-gray-700 whitespace-nowrap overflow-hidden text-overflow-ellipsis hover:overflow-auto">
|
||||
<span className="font-medium">上传IP:</span> {item.uploadIp}
|
||||
</p>
|
||||
<p className="text-gray-700 whitespace-nowrap overflow-hidden text-overflow-ellipsis hover:overflow-auto">
|
||||
<span className="font-medium">过期时间:</span> {dayjs(item.expiresAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</p>
|
||||
<p className="text-gray-700 whitespace-nowrap overflow-hidden text-overflow-ellipsis hover:overflow-auto">
|
||||
<span className="font-medium">上传时间:</span> {dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
import { api } from "@nice/client";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { ShareCodeResponse } from "../quick-file/quickFileContext";
|
||||
interface DashboardContextType {
|
||||
shareCodeAll: ShareCodeResourcesSizeByDateRange;
|
||||
shareCodeToday: ShareCodeResourcesSizeByDateRange;
|
||||
shareCodeYesterday: ShareCodeResourcesSizeByDateRange;
|
||||
isShareCodeAllLoading: boolean;
|
||||
isShareCodeTodayLoading: boolean;
|
||||
isShareCodeYesterdayLoading: boolean;
|
||||
distinctUploadIPs: { thisWeek: number; lastWeek: number; all: number };
|
||||
isDistinctUploadIPsLoading: boolean;
|
||||
shareCodeList: ShareCodeResponse[];
|
||||
isShareCodeListLoading: boolean;
|
||||
}
|
||||
interface ShareCodeResourcesSizeByDateRange {
|
||||
totalSize: number;
|
||||
resourceCount: number;
|
||||
}
|
||||
export const DashboardContext = createContext<DashboardContextType | null>(null);
|
||||
|
||||
export const DashboardProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const { data: shareCodeAll, isLoading: isShareCodeAllLoading }:
|
||||
{ data: ShareCodeResourcesSizeByDateRange, isLoading: boolean }
|
||||
= api.shareCode.getShareCodeResourcesTotalSize.useQuery()
|
||||
const { data: shareCodeToday, isLoading: isShareCodeTodayLoading }:
|
||||
{ data: ShareCodeResourcesSizeByDateRange, isLoading: boolean }
|
||||
= api.shareCode.getShareCodeResourcesSizeByDateRange.useQuery(
|
||||
{ dateType: "today" })
|
||||
const { data: shareCodeYesterday, isLoading: isShareCodeYesterdayLoading }:
|
||||
{ data: ShareCodeResourcesSizeByDateRange, isLoading: boolean }
|
||||
= api.shareCode.getShareCodeResourcesSizeByDateRange.useQuery(
|
||||
{ dateType: "yesterday" })
|
||||
const { data: distinctUploadIPs, isLoading: isDistinctUploadIPsLoading }:
|
||||
{ data: { thisWeek: number; lastWeek: number; all: number }, isLoading: boolean }
|
||||
= api.shareCode.countDistinctUploadIPs.useQuery()
|
||||
const { data: shareCodeList, isLoading: isShareCodeListLoading }:
|
||||
{ data: ShareCodeResponse[], isLoading: boolean }
|
||||
= api.shareCode.findShareCodes.useQuery({
|
||||
where: {
|
||||
deletedAt: null
|
||||
},
|
||||
take: 8,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
})
|
||||
return <>
|
||||
<DashboardContext.Provider value={{
|
||||
shareCodeAll,
|
||||
shareCodeToday,
|
||||
shareCodeYesterday,
|
||||
isShareCodeAllLoading,
|
||||
isShareCodeTodayLoading,
|
||||
isShareCodeYesterdayLoading,
|
||||
distinctUploadIPs,
|
||||
isDistinctUploadIPsLoading,
|
||||
shareCodeList,
|
||||
isShareCodeListLoading
|
||||
}}>
|
||||
{children}
|
||||
</DashboardContext.Provider>
|
||||
</>
|
||||
};
|
||||
|
||||
export const useDashboardContext = () => {
|
||||
const context = useContext(DashboardContext);
|
||||
if (!context) {
|
||||
throw new Error("useDashboardContext must be used within a DashboardProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
|
@ -1,13 +0,0 @@
|
|||
import Board from './components/Board';
|
||||
import Activate from './components/Activate';
|
||||
|
||||
export default function DashboardLayout() {
|
||||
return (
|
||||
<div className="bg-gray-100 p-5">
|
||||
<Board />
|
||||
{/* 最近活动 */}
|
||||
<Activate />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { Typography, Space, Skeleton } from 'antd';
|
||||
import { FileOutlined } from '@ant-design/icons';
|
||||
import DashboardCard from '@web/src/components/presentation/dashboard-card';
|
||||
import ActivityItem from './ActivityItem';
|
||||
import { useDashboardContext } from '../DashboardContext';
|
||||
import { ShareCodeResponse } from '../../quick-file/quickFileContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
const { Title, Text } = Typography;
|
||||
export default function Activate() {
|
||||
const { shareCodeList, isShareCodeListLoading } = useDashboardContext();
|
||||
const navigate = useNavigate()
|
||||
const handleClick = (item: ShareCodeResponse) => {
|
||||
navigate(`/manage/share-code?keyword=${encodeURIComponent(item.code)}`)
|
||||
}
|
||||
return <>
|
||||
<div className="mt-6">
|
||||
<Title level={4} className="mb-4">最新上传活动</Title>
|
||||
<DashboardCard className="w-full">
|
||||
<Space direction="vertical" className="w-full" size="middle">
|
||||
{isShareCodeListLoading ?
|
||||
<Skeleton active />
|
||||
:
|
||||
shareCodeList.map((item) => (
|
||||
<div className='my-1 cursor-pointer' onClick={() => handleClick(item)}>
|
||||
<ActivityItem
|
||||
key={item.id}
|
||||
icon={<FileOutlined className="text-blue-500" />}
|
||||
time={item.createdAt?.toLocaleString()}
|
||||
ip={item.uploadIp}
|
||||
filename={item.fileName}
|
||||
filesize={Math.max(Number(item.resource.meta.size) / 1024 / 1024, 0.01).toFixed(2)}
|
||||
code={item.code}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</DashboardCard>
|
||||
</div>
|
||||
</>
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
import { motion } from 'framer-motion';
|
||||
// 活动项组件
|
||||
interface ActivityItemProps {
|
||||
icon: React.ReactNode;
|
||||
time: string;
|
||||
ip: string;
|
||||
filename: string;
|
||||
filesize: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export default function ActivityItem({ icon, time, ip ,filename, filesize, code }: ActivityItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="flex items-center py-2"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center mr-3">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<div className='text-gray-500'>
|
||||
来自<span className='text-red-500'>{ip}</span>的用户
|
||||
上传了文件:<span className='text-blue-500'>"{filename}"</span>,
|
||||
文件大小:<span className='text-gray-500'>{filesize}MB</span>,
|
||||
分享码:<span className='text-primary-900'>{code}</span>
|
||||
</div>
|
||||
<div className="text-gray-400 text-xs">{time}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
|
@ -1,138 +0,0 @@
|
|||
import { Row, Col, Typography, Space, Tooltip, Statistic, Spin } from 'antd';
|
||||
import { FileOutlined, HddOutlined, UserOutlined, CheckCircleOutlined, HistoryOutlined } from '@ant-design/icons';
|
||||
import DashboardCard from '@web/src/components/presentation/dashboard-card';
|
||||
import { useDashboardContext } from '../DashboardContext';
|
||||
import { useEffect, useState } from 'react';
|
||||
const { Title, Text } = Typography;
|
||||
export default function Board() {
|
||||
const { shareCodeAll, shareCodeToday, shareCodeYesterday,
|
||||
isShareCodeAllLoading, isShareCodeTodayLoading, isShareCodeYesterdayLoading,
|
||||
distinctUploadIPs, isDistinctUploadIPsLoading } = useDashboardContext();
|
||||
const [serverUptime, setServerUptime] = useState('');
|
||||
useEffect(() => {
|
||||
const calculateTimeDifference = () => {
|
||||
const now = new Date();
|
||||
const targetDate = new Date('2025-04-09T15:00:00');
|
||||
const diffMs = now.getTime()- targetDate.getTime();
|
||||
|
||||
// 如果是负数,表示目标日期已过
|
||||
if (diffMs < 0) {
|
||||
setServerUptime('0天0小时0分钟');
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算天数、小时数和分钟数
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
setServerUptime(`${days}天${hours}小时${minutes}分钟`);
|
||||
};
|
||||
|
||||
calculateTimeDifference();
|
||||
// 每分钟更新一次
|
||||
const timer = setInterval(calculateTimeDifference, 60000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
return <>
|
||||
<Title level={3} className="mb-4">仪表盘</Title>
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* 总文件数卡片 */}
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<DashboardCard
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<FileOutlined style={{ marginRight: 8, fontSize: 16 }} />
|
||||
总文件数
|
||||
</div>
|
||||
}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="flex flex-col h-full justify-between py-2">
|
||||
<Statistic value={isShareCodeAllLoading ? 0 : shareCodeAll?.resourceCount} />
|
||||
{
|
||||
isShareCodeTodayLoading || isShareCodeYesterdayLoading
|
||||
? <Spin />
|
||||
: (<div className="text-gray-500 text-sm mt-2">
|
||||
昨天: {shareCodeYesterday?.resourceCount} 今天: {shareCodeToday?.resourceCount}
|
||||
</div>)
|
||||
}
|
||||
</div>
|
||||
</DashboardCard>
|
||||
</Col>
|
||||
|
||||
{/* 存储空间卡片 */}
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<DashboardCard
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<HddOutlined style={{ marginRight: 8, fontSize: 16 }} />
|
||||
已使用的存储空间
|
||||
</div>
|
||||
}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="flex flex-col h-full justify-between py-2">
|
||||
<Statistic value={isShareCodeAllLoading ? 0 : `${(shareCodeAll?.totalSize / 1024 / 1024 / 1024).toFixed(2)}GB`} />
|
||||
{
|
||||
isShareCodeTodayLoading || isShareCodeYesterdayLoading
|
||||
? <Spin />
|
||||
: (<div className="text-gray-500 text-sm mt-2">
|
||||
昨天: {(shareCodeYesterday?.totalSize / 1024 / 1024).toFixed(2)}MB 今天: {(shareCodeToday?.totalSize / 1024 / 1024).toFixed(2)}MB
|
||||
</div>)
|
||||
}
|
||||
</div>
|
||||
</DashboardCard>
|
||||
</Col>
|
||||
|
||||
{/* 活跃用户卡片 */}
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<DashboardCard
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<UserOutlined style={{ marginRight: 8, fontSize: 16 }} />
|
||||
全部用户
|
||||
</div>
|
||||
}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="flex flex-col h-full justify-between py-2">
|
||||
<Statistic value={isDistinctUploadIPsLoading ? 0 : distinctUploadIPs?.all} />
|
||||
|
||||
<div className="text-gray-500 text-sm mt-2">
|
||||
上周使用用户: {distinctUploadIPs?.lastWeek} 本周使用用户: {distinctUploadIPs?.thisWeek}
|
||||
{
|
||||
distinctUploadIPs?.lastWeek ? (
|
||||
distinctUploadIPs?.lastWeek > distinctUploadIPs?.thisWeek ?
|
||||
<span className="text-red-500">↓{((distinctUploadIPs?.lastWeek - distinctUploadIPs?.thisWeek) / distinctUploadIPs?.lastWeek * 100).toFixed(2)}%</span>
|
||||
: <span className="text-green-500">↑ {((distinctUploadIPs?.thisWeek - distinctUploadIPs?.lastWeek) / distinctUploadIPs?.lastWeek * 100).toFixed(2)}%</span>
|
||||
) : null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardCard>
|
||||
</Col>
|
||||
|
||||
{/* 系统状态卡片 */}
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<DashboardCard
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<CheckCircleOutlined style={{ marginRight: 8, fontSize: 16, color: "#52c41a" }} />
|
||||
系统状态
|
||||
</div>
|
||||
}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="flex flex-col h-full justify-between py-2">
|
||||
<Statistic value="正常" valueStyle={{ color: '#52c41a' }} />
|
||||
<div className="text-gray-500 text-sm mt-2">
|
||||
服务器运行时间: {serverUptime}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardCard>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { Button } from "antd";
|
||||
import { RolePerms } from "@nice/common";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface HeaderProps {
|
||||
showLoginButton?: boolean;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ showLoginButton = true }) => {
|
||||
const navigate = useNavigate();
|
||||
const {isAuthenticated , hasEveryPermissions} = useAuth();
|
||||
const isAdmin = hasEveryPermissions(RolePerms.MANAGE_ANY_POST) && isAuthenticated;
|
||||
|
||||
return (
|
||||
<div className="w-[1000px] mx-auto flex justify-between items-center pt-3">
|
||||
<div className="flex items-center">
|
||||
<img src="/logo.svg" className="h-16 w-16" />
|
||||
<span className="text-4xl py-4 mx-4 font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-blue-700 drop-shadow-sm">烽火快传</span>
|
||||
</div>
|
||||
|
||||
{showLoginButton && (
|
||||
<Button
|
||||
type="primary"
|
||||
className="bg-gradient-to-r from-blue-500 to-blue-700"
|
||||
onClick={() => {
|
||||
if(isAdmin){
|
||||
navigate('/manage');
|
||||
}else{
|
||||
navigate('/login');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isAdmin ? '管理后台' : '登录'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
|
@ -1,86 +0,0 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Layout, Menu, Button, Space } from "antd";
|
||||
import { FileOutlined, DashboardOutlined, HomeOutlined, LogoutOutlined } from "@ant-design/icons";
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
export default function QuickFileManage() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [currentKey, setCurrentKey] = useState("1");
|
||||
const { logout } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
// 根据当前URL路径设置currentKey
|
||||
if (location.pathname.includes("/manage/dashboard")) {
|
||||
setCurrentKey("1");
|
||||
} else if (location.pathname.includes("/manage/share-code")) {
|
||||
setCurrentKey("2");
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={(value) => setCollapsed(value)}
|
||||
theme="light"
|
||||
style={{
|
||||
borderRight: "1px solid #f0f0f0",
|
||||
display: "flex",
|
||||
flexDirection: "column"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center cursor-pointer w-full">
|
||||
<img src="/logo.svg" className="h-10 w-10" />
|
||||
{!collapsed && <span className="text-2xl py-4 mx-4 font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-blue-700 drop-shadow-sm">烽火快传</span>}
|
||||
</div>
|
||||
<Menu
|
||||
theme="light"
|
||||
mode="inline"
|
||||
selectedKeys={[currentKey]}
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
icon: <DashboardOutlined />,
|
||||
label: <NavLink to="/manage/dashboard">数据看板</NavLink>,
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
icon: <FileOutlined />,
|
||||
label: <NavLink to="/manage/share-code">所有文件</NavLink>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="mt-auto p-2 flex justify-between" style={{marginTop: "auto"}}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<HomeOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
style={{flex: 1}}
|
||||
>
|
||||
{!collapsed && "首页"}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={async () => {
|
||||
navigate('/');
|
||||
await logout();
|
||||
}}
|
||||
style={{flex: 1}}
|
||||
>
|
||||
{!collapsed && "登出"}
|
||||
</Button>
|
||||
</div>
|
||||
</Sider>
|
||||
<Content style={{ padding: "24px" }}>
|
||||
<Outlet></Outlet>
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
|
||||
import { ShareCodeGenerator } from "../sharecode/sharecodegenerator";
|
||||
import { ShareCodeValidator } from "../sharecode/sharecodevalidator";
|
||||
import { Form } from "antd";
|
||||
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
|
||||
import CodeRecord from "../sharecode/components/CodeRecord";
|
||||
import Header from "./components/header";
|
||||
import { useState } from "react";
|
||||
import { MainFooter } from "../../main/layout/MainFooter";
|
||||
export default function QuickUploadPage() {
|
||||
const [form] = Form.useForm();
|
||||
const uploadFileId = Form.useWatch(["file"], form)?.[0]
|
||||
const [fileMsg, setFileMsg] = useState<File|null>(null)
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<div className="max-w-[1000px] mx-auto px-4 py-6">
|
||||
<div className="my-1 p-6 bg-white border border-gray-100 rounded-xl shadow-md hover:shadow-lg transition-shadow duration-300">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-5 pb-4 border-b border-gray-100">
|
||||
<span className="text-xl font-medium text-gray-800">使用分享码下载文件</span>
|
||||
<CodeRecord title="我使用的分享码" btnContent="使用记录" recordName="shareCodeDownloadRecords" />
|
||||
</div>
|
||||
<div className="bg-gray-50 bg-opacity-70 rounded-lg p-4">
|
||||
<ShareCodeValidator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-6 p-6 bg-white border border-gray-100 rounded-xl shadow-md hover:shadow-lg transition-shadow duration-300">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-5 pb-4 border-b border-gray-100">
|
||||
<span className="text-xl font-medium text-gray-800">上传文件并生成分享码</span>
|
||||
<CodeRecord title="我生成的分享码" btnContent="上传记录" recordName="shareCodeGeneratorRecords" />
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
<ShareCodeGenerator fileId={uploadFileId} fileMsg={fileMsg} />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Form form={form}>
|
||||
<Form.Item name="file">
|
||||
<TusUploader
|
||||
multiple={false}
|
||||
style="w-full py-8 px-4 mb-0 h-72 border-2 border-dashed border-gray-200 hover:border-blue-400 bg-gray-50 bg-opacity-50 rounded-lg transition-colors"
|
||||
getFileMsg={(file) => {
|
||||
setFileMsg(file)
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MainFooter />
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
|
@ -1,127 +0,0 @@
|
|||
import { message } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { env } from "@web/src/env";
|
||||
import { api } from "@nice/client";
|
||||
interface QuickFileContextType {
|
||||
saveCodeRecord: (data: ShareCodeResponse, recordName: string) => void;
|
||||
handleValidSuccess: (fileUrl: string, fileName: string) => void;
|
||||
isGetingFileId: boolean;
|
||||
downloadCode: string | null;
|
||||
setDownloadCode: (code: string | null) => void;
|
||||
refetchShareCodeWithResource: () => Promise<{ data: any }>;
|
||||
isLoading: boolean;
|
||||
downloadResult: ShareCodeResponse | null;
|
||||
}
|
||||
export interface ShareCodeResponse {
|
||||
id?: string;
|
||||
code?: string;
|
||||
fileName?: string;
|
||||
expiresAt?: Date;
|
||||
canUseTimes?: number;
|
||||
resource?: {
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
meta: ResourceMeta
|
||||
}
|
||||
uploadIp?: string;
|
||||
createdAt?: Date;
|
||||
}
|
||||
interface ResourceMeta {
|
||||
filename: string;
|
||||
filetype: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
export const QuickFileContext = createContext<QuickFileContextType | null>(null);
|
||||
|
||||
export const QuickFileProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [isGetingFileId, setIsGetingFileId] = useState(false);
|
||||
const [downloadCode, setDownloadCode] = useState<string | null>(null);
|
||||
const saveCodeRecord = (data: ShareCodeResponse, recordName: string) => {
|
||||
if (data.canUseTimes == 0) {
|
||||
const existingGeneratorRecords = localStorage.getItem(recordName);
|
||||
if (existingGeneratorRecords && data.code) {
|
||||
const generatorRecords = JSON.parse(existingGeneratorRecords);
|
||||
const filteredRecords = generatorRecords.filter((item: ShareCodeResponse) => item.code !== data.code);
|
||||
localStorage.setItem(recordName, JSON.stringify(filteredRecords));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const newRecord = {
|
||||
id: `${Date.now()}`, // 生成唯一ID
|
||||
code: data.code,
|
||||
expiresAt: dayjs(data.expiresAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||
fileName: data.fileName || `文件_${data.code}`,
|
||||
canUseTimes: data.canUseTimes,
|
||||
resource: {
|
||||
id: data.resource.id,
|
||||
type: data.resource.type,
|
||||
url: data.resource.url,
|
||||
meta: {
|
||||
size: data.resource.meta.size,
|
||||
filename: data.resource.meta.filename,
|
||||
filetype: data.resource.meta.filetype
|
||||
}
|
||||
},
|
||||
uploadIp: data.uploadIp,
|
||||
createdAt: data.createdAt
|
||||
};
|
||||
// 获取已有记录并添加新记录
|
||||
const existingGeneratorRecords = localStorage.getItem(recordName);
|
||||
let generatorRecords = existingGeneratorRecords ? JSON.parse(existingGeneratorRecords) : [];
|
||||
if (data.code) {
|
||||
generatorRecords = generatorRecords.filter((item: ShareCodeResponse) => item.code !== data.code)
|
||||
}
|
||||
generatorRecords.unshift(newRecord); // 添加到最前面
|
||||
localStorage.setItem(recordName, JSON.stringify(generatorRecords));
|
||||
}
|
||||
const handleValidSuccess = async (fileUrl: string, fileName: string) => {
|
||||
setIsGetingFileId(true);
|
||||
try {
|
||||
const downloadUrl = `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${fileUrl}`;
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = fileName;
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
message.success('文件下载开始');
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error);
|
||||
message.error('文件下载失败');
|
||||
} finally {
|
||||
setIsGetingFileId(false);
|
||||
}
|
||||
};
|
||||
const { data: downloadResult, isLoading, refetch: refetchShareCodeWithResource } = api.shareCode.getFileByShareCode.useQuery(
|
||||
{ code: downloadCode?.trim() },
|
||||
{
|
||||
enabled: false
|
||||
}
|
||||
)
|
||||
return <>
|
||||
<QuickFileContext.Provider value={{
|
||||
saveCodeRecord,
|
||||
handleValidSuccess,
|
||||
isGetingFileId,
|
||||
downloadCode,
|
||||
setDownloadCode,
|
||||
refetchShareCodeWithResource,
|
||||
isLoading,
|
||||
downloadResult: downloadResult as any as ShareCodeResponse
|
||||
}}>
|
||||
{children}
|
||||
</QuickFileContext.Provider>
|
||||
</>
|
||||
};
|
||||
|
||||
export const useQuickFileContext = () => {
|
||||
const context = useContext(QuickFileContext);
|
||||
if (!context) {
|
||||
throw new Error("useQuickFileContext must be used within a QuickFileProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
|
@ -1,68 +0,0 @@
|
|||
.container {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.generateButton {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.generateButton:hover {
|
||||
background-color: #40a9ff;
|
||||
}
|
||||
|
||||
.generateButton:disabled {
|
||||
background-color: #d9d9d9;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.codeDisplay {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.codeWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
color: #1890ff;
|
||||
padding: 8px 16px;
|
||||
background-color: #e6f7ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: #1890ff;
|
||||
font-size: 18px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.expireInfo {
|
||||
color: #666;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #ff4d4f;
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
.container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 16px;
|
||||
text-transform: uppercase;
|
||||
}
|
|
@ -1,89 +0,0 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Button, Drawer, Empty } from "antd";
|
||||
import { HistoryOutlined } from "@ant-design/icons";
|
||||
import ShareCodeListCard from "../../code-manage/components/ShareCodeListCard";
|
||||
import { ShareCodeResponse, useQuickFileContext } from "../../quick-file/quickFileContext";
|
||||
|
||||
export default function CodeRecord({ title, btnContent, recordName ,styles,isDownload}:
|
||||
{ title: string, btnContent: string , recordName: string, styles?:string,isDownload?:boolean}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [records, setRecords] = useState<ShareCodeResponse[]>([]);
|
||||
const {handleValidSuccess,saveCodeRecord,refetchShareCodeWithResource,setDownloadCode} = useQuickFileContext();
|
||||
|
||||
const loadRecordsFromStorage = () => {
|
||||
const storedRecords = localStorage.getItem(recordName);
|
||||
if (storedRecords) {
|
||||
setRecords(JSON.parse(storedRecords));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRecordsFromStorage();
|
||||
}, [recordName]);
|
||||
|
||||
const saveAndUpdateRecord = (data: ShareCodeResponse, name: string) => {
|
||||
saveCodeRecord(data, name);
|
||||
if (name === recordName) {
|
||||
loadRecordsFromStorage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
loadRecordsFromStorage();
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
const updatedRecords = records.filter(record => record.id !== id);
|
||||
setRecords(updatedRecords);
|
||||
localStorage.setItem(recordName, JSON.stringify(updatedRecords));
|
||||
};
|
||||
|
||||
const handleDownload = async (code:string,recordName:string) => {
|
||||
await setDownloadCode(code)
|
||||
const {data:result} = await refetchShareCodeWithResource()
|
||||
console.log('下载', result);
|
||||
handleValidSuccess(result.resource.url, result.fileName);
|
||||
saveAndUpdateRecord(result as any as ShareCodeResponse, 'shareCodeUploadRecords');
|
||||
saveAndUpdateRecord(result as any as ShareCodeResponse, 'shareCodeDownloadRecords');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<HistoryOutlined />}
|
||||
onClick={handleOpen}
|
||||
style={{ marginBottom: '16px' }}
|
||||
>
|
||||
{btnContent}
|
||||
</Button>
|
||||
<Drawer
|
||||
title={title}
|
||||
placement="right"
|
||||
width={420}
|
||||
onClose={handleClose}
|
||||
open={open}
|
||||
>
|
||||
{records.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{records.map(item => (
|
||||
<ShareCodeListCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onDelete={handleDelete}
|
||||
onDownload={() => handleDownload(item.code, recordName)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="暂无分享码记录" />
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,203 +0,0 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, DatePicker, Form, InputNumber, message } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getQueryKey } from '@trpc/react-query';
|
||||
import { api } from '@nice/client';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import { ShareCodeResponse, useQuickFileContext } from '../quick-file/quickFileContext';
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
interface ShareCodeGeneratorProps {
|
||||
fileId: string;
|
||||
onSuccess?: (code: string) => void;
|
||||
fileName?: string;
|
||||
fileMsg?: File;
|
||||
}
|
||||
|
||||
|
||||
export function copyToClipboard(text) {
|
||||
if (navigator.clipboard) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
export const ShareCodeGenerator: React.FC<ShareCodeGeneratorProps> = ({
|
||||
fileId,
|
||||
fileName,
|
||||
fileMsg
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shareCode, setShareCode] = useState<string>('');
|
||||
const [expiresAt, setExpiresAt] = useState<string | null>(null);
|
||||
const [canUseTimes, setCanUseTimes] = useState<number>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = getQueryKey(api.shareCode);
|
||||
const [isGenerate, setIsGenerate] = useState(false);
|
||||
const [currentFileId, setCurrentFileId] = useState<string>('');
|
||||
const [form] = Form.useForm();
|
||||
const { saveCodeRecord } = useQuickFileContext();
|
||||
const generateShareCode = api.shareCode.generateShareCodeByFileId.useMutation({
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
if (fileId !== currentFileId || !fileId) {
|
||||
setIsGenerate(false);
|
||||
}
|
||||
setCurrentFileId(fileId);
|
||||
}, [fileId])
|
||||
const generateCode = async () => {
|
||||
if (!fileId) {
|
||||
message.error('请先上传文件');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
console.log('开始生成分享码,fileId:', fileId, 'fileName:', fileName);
|
||||
try {
|
||||
let expiresAt = form.getFieldsValue()?.expiresAt ? form.getFieldsValue().expiresAt.tz('Asia/Shanghai').toDate() : dayjs().add(1, 'day').tz('Asia/Shanghai').toDate();
|
||||
if (fileMsg && fileMsg.size > 1024 * 1024 * 1024 * 5) {
|
||||
message.info('文件大小超过5GB,系统将设置过期时间为1天', 6);
|
||||
expiresAt = dayjs().add(1, 'day').tz('Asia/Shanghai').toDate();
|
||||
}
|
||||
const data: ShareCodeResponse = await generateShareCode.mutateAsync({
|
||||
fileId,
|
||||
expiresAt: expiresAt,
|
||||
canUseTimes: form.getFieldsValue()?.canUseTimes ? form.getFieldsValue().canUseTimes : 10,
|
||||
});
|
||||
console.log('data', data)
|
||||
setShareCode(data.code);
|
||||
setIsGenerate(true);
|
||||
setExpiresAt(dayjs(data.expiresAt).format('YYYY-MM-DD HH:mm:ss'));
|
||||
setCanUseTimes(data.canUseTimes);
|
||||
saveCodeRecord(data, 'shareCodeGeneratorRecords');
|
||||
message.success('分享码生成成功' + data.code);
|
||||
} catch (error) {
|
||||
console.error('生成分享码错误:', error);
|
||||
message.error('生成分享码失败: ' + (error instanceof Error ? error.message : '未知错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (code) => {
|
||||
copyToClipboard(code)
|
||||
.then(() => console.log('复制成功'))
|
||||
.catch(() => console.error('复制失败'));
|
||||
};
|
||||
useEffect(() => {
|
||||
const date = dayjs().add(1, 'day').tz('Asia/Shanghai');
|
||||
form.setFieldsValue({
|
||||
expiresAt: date,
|
||||
canUseTimes: 5
|
||||
});
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileId) {
|
||||
generateCode()
|
||||
}
|
||||
}, [fileId])
|
||||
return (
|
||||
<div style={{ padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
||||
<div style={{ marginBottom: '3px' }}>
|
||||
<small style={{ color: '#666' }}>文件ID: {fileId ? fileId : '暂未上传文件'}</small>
|
||||
</div>
|
||||
{!isGenerate ? (
|
||||
<>
|
||||
<Form form={form}>
|
||||
<div className='w-4/5 h-16 flex flex-row justify-between items-center'>
|
||||
<small style={{ color: '#666' }}>
|
||||
{"分享码的有效期"}
|
||||
</small>
|
||||
<Form.Item name="expiresAt" className='mt-5'>
|
||||
<DatePicker
|
||||
showTime
|
||||
disabledDate={(current) =>
|
||||
(current && current < dayjs().startOf('day')) ||
|
||||
(current && current > dayjs().add(7, 'day').endOf('day'))
|
||||
}
|
||||
disabledTime={(current) => {
|
||||
if (current && current.isSame(dayjs(), 'day')) {
|
||||
return {
|
||||
disabledHours: () => [...Array(dayjs().hour() + 1).keys()],
|
||||
disabledMinutes: (selectedHour) => {
|
||||
if (selectedHour === dayjs().hour()) {
|
||||
return [...Array(dayjs().minute() + 1).keys()];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<small style={{ color: '#666' }}>
|
||||
{"分享码的使用次数"}
|
||||
</small>
|
||||
<Form.Item name="canUseTimes" className='mt-5'>
|
||||
<InputNumber
|
||||
style={{ width: 120 }}
|
||||
min={1}
|
||||
max={10}
|
||||
defaultValue={5}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
|
||||
) : (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
margin: '16px 0'
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: '2px',
|
||||
color: '#1890ff',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#e6f7ff',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
{shareCode}
|
||||
</span>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
handleCopy(shareCode)
|
||||
//navigator.clipboard.writeText(shareCode);
|
||||
message.success('分享码已复制');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isGenerate && expiresAt ? (
|
||||
<div style={{ color: '#666' }}>
|
||||
有效期至: {expiresAt} 可使用次数: {canUseTimes}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'red' }}>
|
||||
未获取到有效期信息
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,102 +0,0 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Input, Button, message, Spin } from 'antd';
|
||||
import styles from './ShareCodeValidator.module.css';
|
||||
import { api } from '@nice/client';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import { env } from '@web/src/env';
|
||||
import { copyToClipboard } from './sharecodegenerator';
|
||||
import { ShareCodeResponse, useQuickFileContext } from '../quick-file/quickFileContext';
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
export const ShareCodeValidator: React.FC = ({ }) => {
|
||||
const { saveCodeRecord,handleValidSuccess,isGetingFileId,downloadCode,setDownloadCode,refetchShareCodeWithResource,isLoading,downloadResult } = useQuickFileContext();
|
||||
|
||||
const validateCode = useCallback(async () => {
|
||||
|
||||
if (!downloadCode?.trim()) {
|
||||
message.warning('请输入正确的分享码');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data: latestResult } = await refetchShareCodeWithResource();
|
||||
console.log('验证分享码返回数据:', latestResult);
|
||||
if (latestResult) {
|
||||
saveCodeRecord(latestResult as any as ShareCodeResponse,'shareCodeDownloadRecords')
|
||||
handleValidSuccess(latestResult.resource.url, latestResult.fileName);
|
||||
} else {
|
||||
message.error('分享码无效或已过期');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('验证分享码失败:', error);
|
||||
message.error('分享码无效或已过期');
|
||||
}
|
||||
}, [refetchShareCodeWithResource, downloadCode, handleValidSuccess]);
|
||||
const getDownloadUrl = useCallback(async () => {
|
||||
try {
|
||||
const { data: latestResult } = await refetchShareCodeWithResource();
|
||||
saveCodeRecord(latestResult as any as ShareCodeResponse,'shareCodeDownloadRecords')
|
||||
console.log('验证分享码返回数据:', latestResult);
|
||||
const downloadUrl = `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${latestResult.resource.url}`;
|
||||
copyToClipboard(downloadUrl)
|
||||
.then(() => {
|
||||
message.success(`${latestResult.fileName}的下载链接已复制`, 6)
|
||||
})
|
||||
.catch(() => {
|
||||
message.error('复制失败')
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('验证分享码失败:', error);
|
||||
message.error('分享码无效或已过期');
|
||||
}
|
||||
}, [refetchShareCodeWithResource, downloadCode, handleValidSuccess]);
|
||||
return (
|
||||
<>
|
||||
{
|
||||
isGetingFileId ?
|
||||
(<Spin spinning={isGetingFileId} fullscreen />) :
|
||||
(
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={downloadCode}
|
||||
onChange={(e) => setDownloadCode(e.target.value.toUpperCase())}
|
||||
placeholder="请输入分享码"
|
||||
maxLength={8}
|
||||
onPressEnter={validateCode}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={getDownloadUrl}
|
||||
loading={isLoading}
|
||||
disabled={!downloadCode?.trim()}
|
||||
>
|
||||
获取下载链接
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={validateCode}
|
||||
loading={isLoading}
|
||||
disabled={!downloadCode?.trim()}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
{
|
||||
!isLoading && downloadResult && (
|
||||
<div className='w-full flex justify-between my-2 p-1 antialiased text-secondary-600'>
|
||||
<span >{`分享码:${downloadResult?.code ? downloadResult.code : ''}`}</span>
|
||||
<span >{`文件名:${downloadResult?.fileName ? downloadResult.fileName : ''}`}</span>
|
||||
<span >{`过期时间:${downloadResult?.expiresAt ? dayjs(downloadResult.expiresAt).format('YYYY-MM-DD HH:mm:ss') : ''}`}</span>
|
||||
<span >{`剩余使用次数:${downloadResult?.canUseTimes ? downloadResult.canUseTimes : 0}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,117 @@
|
|||
// import { api, useStaff, useDevice } from "@nice/client";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import toast from "react-hot-toast";
|
||||
import { Button, Form, Input, Modal, Select, Row, Col } from "antd";
|
||||
import { useDevice } from "@nice/client";
|
||||
import { useEffect } from "react";
|
||||
import DepartmentChildrenSelect from "@web/src/components/models/department/department-children-select";
|
||||
import DepartmentSelect from "@web/src/components/models/department/department-select";
|
||||
import SystemTypeSelect from "@web/src/app/main/devicepage/select/System-select";
|
||||
import DeviceTypeSelect from "../select/Device-select";
|
||||
export default function DeviceModal() {
|
||||
const {
|
||||
form,
|
||||
formValue,
|
||||
setVisible,
|
||||
visible,
|
||||
editingRecord,
|
||||
setEditingRecord,
|
||||
} = useMainContext();
|
||||
const { create, update } = useDevice();
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = form.getFieldsValue();
|
||||
if (editingRecord?.id) {
|
||||
// 编辑现有记录
|
||||
await update.mutateAsync({
|
||||
where: { id: editingRecord.id },
|
||||
data: values
|
||||
});
|
||||
toast.success("更新故障成功");
|
||||
} else {
|
||||
// 创建新记录
|
||||
await create.mutateAsync(values);
|
||||
toast.success("创建故障成功");
|
||||
}
|
||||
|
||||
// 关闭模态框并重置
|
||||
setVisible(false);
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
console.error("保存故障信息失败:", error);
|
||||
toast.error("操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 当模态框关闭时清空编辑状态
|
||||
const handleCancel = () => {
|
||||
setVisible(false);
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
|
||||
// 模态框标题根据是否编辑而变化
|
||||
const modalTitle = editingRecord?.id ? "编辑故障信息" : "新增故障";
|
||||
|
||||
return (
|
||||
|
||||
<>
|
||||
<Modal
|
||||
title={modalTitle}
|
||||
open={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
width={1000}
|
||||
style={{ top: 20 }}
|
||||
bodyStyle={{ maxHeight: "calc(100vh - 200px)", overflowY: "auto" }}
|
||||
>
|
||||
<Form form={form} initialValues={formValue} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="systemType" label="网系类别">
|
||||
<SystemTypeSelect className="rounded-lg" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="deviceType" label="故障类型">
|
||||
<DeviceTypeSelect className="rounded-lg" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="showname"
|
||||
label="故障名称 "
|
||||
rules={[{ required: true, message: "请输入故障名称" }]}
|
||||
>
|
||||
<Input className="rounded-lg" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="deptId" label="单位">
|
||||
<DepartmentSelect />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Form.Item name="deviceStatus" label="故障状态">
|
||||
<Select className="rounded-lg">
|
||||
<Select.Option value="normal">正常</Select.Option>
|
||||
<Select.Option value="maintenance">维修中</Select.Option>
|
||||
<Select.Option value="broken">损坏</Select.Option>
|
||||
<Select.Option value="idle">闲置</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="notes" label="描述">
|
||||
<Input.TextArea rows={4} className="rounded-lg" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,558 @@
|
|||
import { Button, Modal, Table, Upload } from "antd";
|
||||
import { ColumnsType } from "antd/es/table";
|
||||
import { api, useDevice, useStaff } from "@nice/client";
|
||||
import { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import React from "react";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import { ExclamationCircleOutlined, ImportOutlined, ExportOutlined } from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import { utils, writeFile, read } from 'xlsx';
|
||||
|
||||
// 提取处理嵌套字段的函数
|
||||
const getNestedValue = (record: any, dataIndex: string | string[]) => {
|
||||
if (Array.isArray(dataIndex)) {
|
||||
return dataIndex.reduce((obj, key) => obj?.[key], record);
|
||||
}
|
||||
return record[dataIndex];
|
||||
};
|
||||
|
||||
// 添加props接口
|
||||
interface DeviceTableProps {
|
||||
onSelectedChange?: (keys: React.Key[], data: any[]) => void;
|
||||
}
|
||||
|
||||
// 使用forwardRef包裹组件
|
||||
const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) => {
|
||||
const { form, setVisible, searchValue, formValue, editingRecord, setEditingRecord } = useMainContext();
|
||||
|
||||
const { create } = useDevice();
|
||||
const [descModalVisible, setDescModalVisible] = useState(false);
|
||||
const [currentDesc, setCurrentDesc] = useState({ title: "", desc: "" });
|
||||
|
||||
// 处理点击故障名称显示描述信息
|
||||
const handleShowDesc = (record) => {
|
||||
setCurrentDesc({
|
||||
title: record.showname || "未知故障",
|
||||
desc: record.notes || "无故障详情"
|
||||
});
|
||||
setDescModalVisible(true);
|
||||
};
|
||||
|
||||
const { data: devices, isLoading, refetch } = api.device.findMany.useQuery(
|
||||
{
|
||||
where: (searchValue as any) || { deletedAt: null },
|
||||
include: {
|
||||
department: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
}
|
||||
);
|
||||
const { data: systemTypeTerms, refetch: refetchSystemType } =
|
||||
api.term.findMany.useQuery({
|
||||
where: {
|
||||
taxonomy: { slug: "system_type" },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
children: true,
|
||||
},
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
const { data: deviceTypeTerms, refetch: refetchDeviceType } = api.term.findMany.useQuery({
|
||||
where: {
|
||||
taxonomy: { slug: 'device_type' },
|
||||
deletedAt: null,
|
||||
}
|
||||
});
|
||||
const { mutate: softDeleteByIds } = api.device.softDeleteByIds.useMutation();
|
||||
useEffect(() => {
|
||||
// console.log(devices);
|
||||
// refetch();
|
||||
refetchSystemType();
|
||||
refetchDeviceType();
|
||||
}, [devices]);
|
||||
// const { softDeleteByIds } = useStaff()
|
||||
|
||||
|
||||
const getTermNameById = (termId, termType) => {
|
||||
if (!termId) return "未知";
|
||||
|
||||
const terms = termType === 'system_type' ? systemTypeTerms : deviceTypeTerms;
|
||||
const term = terms?.find(t => t.id === termId);
|
||||
return term?.name || "未知";
|
||||
};
|
||||
const handleDelete = (record) => {
|
||||
Modal.confirm({
|
||||
title: "确认删除",
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `确定要删除故障 "${record.showname || "未命名故障"}" 吗?`,
|
||||
okText: "删除",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
onOk() {
|
||||
softDeleteByIds(
|
||||
{
|
||||
ids: [record.id],
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("删除成功");
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("删除故障时出错:", error);
|
||||
toast.error("删除失败");
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<any> = [
|
||||
{
|
||||
title: "网系类别",
|
||||
dataIndex: "systemType",
|
||||
key: "systemType",
|
||||
align: "center",
|
||||
render: (text, record) => {
|
||||
return getTermNameById(record.systemType, 'system_type');
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "故障类型",
|
||||
dataIndex: "deviceType",
|
||||
key: "deviceType",
|
||||
align: "center",
|
||||
render: (text, record) => {
|
||||
return getTermNameById(record.deviceType, 'device_type');
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "单位",
|
||||
dataIndex: "deptId",
|
||||
key: "deptId",
|
||||
align: "center",
|
||||
render: (text, record) => {
|
||||
return (record as any)?.department?.name || "未知";
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "故障名称",
|
||||
dataIndex: "showname",
|
||||
key: "showname",
|
||||
align: "center",
|
||||
render: (text, record) => (
|
||||
<div onClick={() => handleShowDesc(record)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '8px 0',
|
||||
// backgroundColor: 'black',
|
||||
fontWeight: 'bold',
|
||||
}}>
|
||||
{text || "未命名故障"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "故障状态",
|
||||
dataIndex: "deviceStatus",
|
||||
key: "deviceStatus",
|
||||
align: "center",
|
||||
render: (status) => {
|
||||
const statusMap = {
|
||||
normal: "正常",
|
||||
maintenance: "维修中",
|
||||
broken: "损坏",
|
||||
idle: "闲置"
|
||||
};
|
||||
return statusMap[status] || "未知";
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
align: "center",
|
||||
render: (text, record) => record.createdAt ? dayjs(record.createdAt).format('YYYY-MM-DD') : "未知",
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
align: "center",
|
||||
render: (_, record) => (
|
||||
<div className="flex space-x-2 justify-center">
|
||||
<Button
|
||||
type="primary"
|
||||
key={record.id}
|
||||
onClick={() => handleEdit(record)}
|
||||
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
onClick={() => handleDelete(record)}
|
||||
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRecord) {
|
||||
form.setFieldsValue(editingRecord);
|
||||
console.log(editingRecord);
|
||||
}
|
||||
}, [editingRecord]);
|
||||
|
||||
const handleEdit = (record) => {
|
||||
setEditingRecord(record);
|
||||
form.setFieldsValue(record);
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
|
||||
const onSelectChange = (newSelectedRowKeys: React.Key[]) => {
|
||||
setSelectedRowKeys(newSelectedRowKeys);
|
||||
};
|
||||
//导出函数
|
||||
const handleExportSelected = () => {
|
||||
// 获取选中的行数据
|
||||
const selectedData = devices?.filter(item =>
|
||||
selectedRowKeys.includes(item.id)
|
||||
);
|
||||
|
||||
if (!selectedData || selectedData.length === 0) {
|
||||
toast.error("没有选中任何数据");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 格式化导出数据
|
||||
const exportData = selectedData.map(item => ({
|
||||
'网系类别': getTermNameById(item.systemType, 'system_type'),
|
||||
'故障类型': getTermNameById(item.deviceType, 'device_type'),
|
||||
'单位': (item as any)?.department?.name || "未知",
|
||||
'故障名称': item?.showname || "未命名故障",
|
||||
'故障状态': (() => {
|
||||
const statusMap = {
|
||||
normal: "正常",
|
||||
maintenance: "维修中",
|
||||
broken: "损坏",
|
||||
idle: "闲置"
|
||||
};
|
||||
return statusMap[item.deviceStatus] || "未知";
|
||||
})(),
|
||||
'时间': item.createdAt ? dayjs(item.createdAt).format('YYYY-MM-DD') : "未知"
|
||||
}));
|
||||
|
||||
// 创建工作簿
|
||||
const wb = utils.book_new();
|
||||
const ws = utils.json_to_sheet(exportData);
|
||||
utils.book_append_sheet(wb, ws, "故障数据");
|
||||
|
||||
// 导出Excel文件
|
||||
writeFile(wb, `故障数据_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`);
|
||||
|
||||
toast.success(`成功导出 ${selectedData.length} 条数据`);
|
||||
} catch (error) {
|
||||
console.error("导出数据错误:", error);
|
||||
toast.error("导出失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 添加用于文件上传的ref
|
||||
const uploadRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
try {
|
||||
// 读取Excel/CSV文件
|
||||
const data = await file.arrayBuffer();
|
||||
const workbook = read(data);
|
||||
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const jsonData = utils.sheet_to_json(worksheet);
|
||||
|
||||
// 转换为符合系统格式的数据
|
||||
const records = jsonData.map((row: any) => {
|
||||
// 转换字段名和值映射
|
||||
return {
|
||||
systemType: findTermIdByName(row['网系类别'], 'system_type'),
|
||||
deviceType: findTermIdByName(row['故障类型'], 'device_type'),
|
||||
showname: row['故障名称'] ? String(row['故障名称']) : "",
|
||||
deptId: findDeptIdByName(row['单位']),
|
||||
deviceStatus: getStatusKeyByValue(row['故障状态']),
|
||||
notes: row['描述'] ? String(row['描述']) : ""
|
||||
};
|
||||
});
|
||||
// 确认是否有有效数据
|
||||
if (records.length === 0) {
|
||||
toast.error("未找到有效数据");
|
||||
return;
|
||||
}
|
||||
// 批量创建记录
|
||||
await batchImportRecords(records);
|
||||
refetch(); // 刷新表格数据
|
||||
} catch (error) {
|
||||
console.error("导入失败:", error);
|
||||
toast.error("导入失败,请检查文件格式");
|
||||
} finally {
|
||||
// 清空文件输入,允许再次选择同一文件
|
||||
if (uploadRef.current) uploadRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// 查找术语ID
|
||||
const findTermIdByName = (name: string, type: 'system_type' | 'device_type') => {
|
||||
if (!name) return null;
|
||||
|
||||
const terms = type === 'system_type' ? systemTypeTerms : deviceTypeTerms;
|
||||
const term = terms?.find(t => t.name === name);
|
||||
return term?.id || null;
|
||||
};
|
||||
|
||||
// 修改查找部门ID函数,从设备数据中获取部门信息
|
||||
const findDeptIdByName = (name: string) => {
|
||||
if (!name || name === "未知") return null;
|
||||
|
||||
// 从已有设备数据中查找匹配的部门名称和ID
|
||||
const matchedDevice = devices?.find(device =>
|
||||
(device as any)?.department?.name === name
|
||||
);
|
||||
|
||||
return matchedDevice?.deptId || null;
|
||||
};
|
||||
|
||||
// 获取状态键
|
||||
const getStatusKeyByValue = (value: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
"正常": "normal",
|
||||
"维修中": "maintenance",
|
||||
"损坏": "broken",
|
||||
"闲置": "idle"
|
||||
};
|
||||
return statusMap[value] || "normal";
|
||||
};
|
||||
|
||||
// 改进批量导入记录函数
|
||||
// 改进批量导入记录函数,使用分批处理
|
||||
const batchImportRecords = async (records: any[]) => {
|
||||
if (!records || records.length === 0) return;
|
||||
try {
|
||||
// 过滤出有效记录
|
||||
const validRecords = records.filter(record =>
|
||||
record.systemType || record.deviceType || record.showname
|
||||
);
|
||||
|
||||
if (validRecords.length === 0) {
|
||||
toast.error("没有找到有效的记录数据");
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置批处理大小
|
||||
const batchSize = 5;
|
||||
let successCount = 0;
|
||||
let totalProcessed = 0;
|
||||
|
||||
// 显示进度提示
|
||||
const loadingToast = toast.loading(`正在导入数据...`);
|
||||
|
||||
// 分批处理数据
|
||||
for (let i = 0; i < validRecords.length; i += batchSize) {
|
||||
const batch = validRecords.slice(i, i + batchSize);
|
||||
|
||||
// 串行处理每一批数据
|
||||
for (const record of batch) {
|
||||
try {
|
||||
await create.mutateAsync(record);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`导入记录失败: ${record.showname || "未命名"}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
totalProcessed += batch.length;
|
||||
// 更新导入进度
|
||||
toast.loading(`已处理 ${totalProcessed}/${validRecords.length} 条数据...`,
|
||||
{ id: loadingToast });
|
||||
}
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
// 显示结果
|
||||
if (successCount === validRecords.length) {
|
||||
toast.success(`成功导入 ${successCount} 条数据`);
|
||||
} else {
|
||||
toast.success(`成功导入 ${successCount}/${validRecords.length} 条数据,部分记录导入失败`);
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error("批量导入失败:", error);
|
||||
toast.error("导入过程中发生错误");
|
||||
}
|
||||
};
|
||||
|
||||
// 添加导出模板功能
|
||||
const handleExportTemplate = () => {
|
||||
try {
|
||||
// 创建一个示例记录
|
||||
const templateData = [
|
||||
{
|
||||
'网系类别': systemTypeTerms?.[0]?.name || '网系类别1',
|
||||
'故障类型': deviceTypeTerms?.[0]?.name || '故障类型1',
|
||||
'单位': '单位名称',
|
||||
'故障名称': '示例故障名称',
|
||||
'故障状态': '正常', // 可选值: 正常、维修中、损坏、闲置
|
||||
'描述': '这是一个示例描述'
|
||||
}
|
||||
];
|
||||
|
||||
// 创建工作簿
|
||||
const wb = utils.book_new();
|
||||
const ws = utils.json_to_sheet(templateData);
|
||||
utils.book_append_sheet(wb, ws, "导入模板");
|
||||
|
||||
// 导出Excel文件
|
||||
writeFile(wb, `故障导入模板.xlsx`);
|
||||
|
||||
toast.success("已下载导入模板");
|
||||
} catch (error) {
|
||||
console.error("导出模板失败:", error);
|
||||
toast.error("下载模板失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 触发文件选择
|
||||
const handleImportClick = () => {
|
||||
if (uploadRef.current) {
|
||||
uploadRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: onSelectChange,
|
||||
columnWidth: 60,
|
||||
preserveSelectedRowKeys: true // 这个属性保证翻页时选中状态不丢失
|
||||
}
|
||||
const TableHeader = () => (
|
||||
<div className="w-full flex justify-between mb-2">
|
||||
<span>
|
||||
</span>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={uploadRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
accept=".xlsx,.xls,.csv"
|
||||
/>
|
||||
<Button
|
||||
icon={<ImportOutlined />}
|
||||
type="primary"
|
||||
onClick={handleExportTemplate}
|
||||
>
|
||||
下载模板文件
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ImportOutlined />}
|
||||
type="primary"
|
||||
onClick={handleImportClick}
|
||||
>
|
||||
导入
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleExportSelected}
|
||||
disabled={!selectedRowKeys || selectedRowKeys.length === 0}
|
||||
icon={<ExportOutlined />}
|
||||
>
|
||||
{selectedRowKeys?.length > 0 ? `导出 (${selectedRowKeys.length})项数据` : '导出选中数据'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableHeader />
|
||||
<Table
|
||||
rowSelection={rowSelection}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={devices}
|
||||
tableLayout="fixed"
|
||||
rowClassName={(record, index) =>
|
||||
index % 2 === 0 ? "bg-white" : "bg-gray-100"
|
||||
}
|
||||
onHeaderRow={() => {
|
||||
return {
|
||||
style: {
|
||||
backgroundColor: '#d6e4ff',
|
||||
},
|
||||
};
|
||||
}}
|
||||
scroll={{ x: "max-content" }}
|
||||
pagination={{
|
||||
position: ["bottomCenter"],
|
||||
className: "flex justify-center mt-4",
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["10", "20", "30"],
|
||||
}}
|
||||
components={{
|
||||
header: {
|
||||
cell: (props) => (
|
||||
<th
|
||||
{...props}
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
textAlign: "center",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}}
|
||||
bordered
|
||||
className="device-table-no-wrap"
|
||||
/>
|
||||
<Modal
|
||||
title={currentDesc.title}
|
||||
open={descModalVisible}
|
||||
onCancel={() => setDescModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setDescModalVisible(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<div className="py-4">
|
||||
<div className="text-lg font-medium mb-2">故障详情</div>
|
||||
<div className="bg-gray-50 p-4 rounded-md whitespace-pre-wrap">
|
||||
{currentDesc.desc}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default DeviceTable;
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { Button, DatePicker, Form, Input, Modal, Select } from "antd";
|
||||
import { useCallback, useEffect, useState, useRef } from "react";
|
||||
import _ from "lodash";
|
||||
import { useMainContext } from "../layout/MainProvider";
|
||||
import DeviceTable from "./devicetable/page";
|
||||
import DeviceModal from "./devicemodal/page";
|
||||
import React from "react";
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
PlusOutlined,
|
||||
ImportOutlined,
|
||||
ExportOutlined,
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import DepartmentSelect from "@web/src/components/models/department/department-select";
|
||||
|
||||
import SystemTypeSelect from "@web/src/app/main/devicepage/select/System-select";
|
||||
import DeviceTypeSelect from "@web/src/app/main/devicepage/select/Device-select";
|
||||
import dayjs from "dayjs";
|
||||
// 添加筛选条件类型
|
||||
type SearchCondition = {
|
||||
deletedAt: null;
|
||||
systemType?: string;
|
||||
deviceType?: string;
|
||||
deptId?: string;
|
||||
responsiblePerson?: { contains: string };
|
||||
createdAt?: {
|
||||
gte: string;
|
||||
lte: string;
|
||||
}
|
||||
};
|
||||
|
||||
export default function DeviceMessage() {
|
||||
const {
|
||||
form,
|
||||
formValue,
|
||||
setFormValue,
|
||||
setVisible,
|
||||
setSearchValue,
|
||||
editingRecord,
|
||||
} = useMainContext();
|
||||
// 控制展开/收起状态
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// 添加所有筛选条件的状态
|
||||
const [selectedSystem, setSelectedSystem] = useState<string | null>(null);
|
||||
const [selectedDeviceType, setSelectedDeviceType] = useState<string[] | null>(
|
||||
null
|
||||
);
|
||||
const [selectedDept, setSelectedDept] = useState<string | null>(null)
|
||||
const [time, setTime] = useState<string>("");
|
||||
const [ipAddress, setIpAddress] = useState<string>("");
|
||||
const [macAddress, setMacAddress] = useState<string>("");
|
||||
const [serialNumber, setSerialNumber] = useState<string>("");
|
||||
const [assetNumber, setAssetNumber] = useState<string>("");
|
||||
const [manufacturer, setManufacturer] = useState<string>("");
|
||||
const [model, setModel] = useState<string>("");
|
||||
const [location, setLocation] = useState<string>("");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [selectedSystemTypeId, setSelectedSystemTypeId] = useState<string>("");
|
||||
|
||||
// 创建ref以访问DeviceTable内部方法
|
||||
const tableRef = useRef(null);
|
||||
// 存储选中行的状态
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const [selectedData, setSelectedData] = useState<any[]>([]);
|
||||
|
||||
const handleNew = () => {
|
||||
form.setFieldsValue(formValue);
|
||||
console.log(editingRecord);
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
// 查询按钮点击处理
|
||||
const handleSearch = () => {
|
||||
// 构建查询条件
|
||||
const whereCondition: SearchCondition = {
|
||||
deletedAt: null,
|
||||
...(selectedSystem && { systemType: selectedSystem }),
|
||||
...(selectedDeviceType && { deviceType: selectedDeviceType }),
|
||||
...(selectedDept && { deptId: selectedDept }),
|
||||
...(time && {
|
||||
createdAt: {
|
||||
gte: dayjs(time).startOf('day').toISOString(),
|
||||
lte: dayjs(time).endOf('day').toISOString()
|
||||
}
|
||||
}),
|
||||
|
||||
// ...(status && { status: { contains: status } }),
|
||||
};
|
||||
|
||||
// 更新查询条件到全局上下文
|
||||
setSearchValue(whereCondition as any);
|
||||
|
||||
// 也可以直接使用refetch方法进行刷新,确保查询条件生效
|
||||
// 如果DeviceTable组件暴露了refetch方法,可以通过ref调用
|
||||
};
|
||||
|
||||
// 重置按钮处理
|
||||
const handleReset = () => {
|
||||
setSelectedSystem(null);
|
||||
setSelectedDeviceType(null);
|
||||
setSelectedDept(null);
|
||||
setTime("");
|
||||
setIpAddress("");
|
||||
setMacAddress("");
|
||||
setSerialNumber("");
|
||||
setAssetNumber("");
|
||||
setManufacturer("");
|
||||
setModel("");
|
||||
setLocation("");
|
||||
|
||||
// 重置为只查询未删除的记录
|
||||
setSearchValue({ deletedAt: null } as any);
|
||||
};
|
||||
|
||||
// 修复DepartmentSelect的onChange类型
|
||||
const handleDeptChange = (value: string | string[]) => {
|
||||
setSelectedDept(value as string);
|
||||
};
|
||||
|
||||
// 处理选择变更的回调
|
||||
const handleSelectedChange = (keys: React.Key[], data: any[]) => {
|
||||
console.log("选中状态变化:", keys.length);
|
||||
setSelectedKeys(keys);
|
||||
setSelectedData(data);
|
||||
};
|
||||
|
||||
// 处理导出按钮点击
|
||||
const handleExport = () => {
|
||||
if (tableRef.current) {
|
||||
tableRef.current.handleExportSelected();
|
||||
}
|
||||
};
|
||||
|
||||
// 系统类别变化处理
|
||||
const handleSystemTypeChange = (value: string) => {
|
||||
setSelectedSystemTypeId(value);
|
||||
form.setFieldValue('deviceType', undefined); // 清空已选故障类型
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-2 min-h-screen bg-gradient-to-br">
|
||||
<div className="pl-4 pr-4 pt-2 text-2xl flex items-center justify-between">
|
||||
<span>故障收录检索</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleNew}>
|
||||
新建
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<Form>
|
||||
<div className="p-2 h-full flex">
|
||||
<div className="max-w-full mx-auto flex-1 flex flex-col">
|
||||
{/* 搜索区域 - 分为左右两块布局 */}
|
||||
<div className="mb-2">
|
||||
<div className="flex w-full">
|
||||
{/* 左侧搜索框区域 */}
|
||||
<div className="flex-1 flex flex-col space-y-3">
|
||||
{/* 第一行搜索框 - 始终显示 */}
|
||||
<div className="flex w-full space-x-3">
|
||||
<SystemTypeSelect
|
||||
value={selectedSystem}
|
||||
onChange={setSelectedSystem}
|
||||
className="w-1/5"
|
||||
/>
|
||||
<DeviceTypeSelect
|
||||
value={selectedDeviceType}
|
||||
onChange={setSelectedDeviceType}
|
||||
className="w-1/5"
|
||||
systemTypeId={selectedSystemTypeId}
|
||||
/>
|
||||
<DepartmentSelect
|
||||
placeholder="单位"
|
||||
className="w-1/5"
|
||||
value={selectedDept}
|
||||
onChange={handleDeptChange}
|
||||
/>
|
||||
{/* <Select
|
||||
placeholder="状态"
|
||||
className="w-1/5"
|
||||
options={[
|
||||
{ label: "正常", value: "normal" },
|
||||
{ label: "故障", value: "fault" },
|
||||
{ label: "维修", value: "repair" },
|
||||
]}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
allowClear
|
||||
/> */}
|
||||
<DatePicker
|
||||
placeholder="选择日期"
|
||||
className="w-1/5"
|
||||
value={time ? dayjs(time) : null}
|
||||
onChange={(date, dateString) => setTime(dateString as string)}
|
||||
format="YYYY-MM-DD"
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧按钮区域 */}
|
||||
<div className="flex flex-row justify-between ml-4 space-x-2">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleSearch}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto justify-between">
|
||||
<DeviceTable
|
||||
ref={tableRef}
|
||||
onSelectedChange={handleSelectedChange}
|
||||
></DeviceTable>
|
||||
<DeviceModal></DeviceModal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,482 @@
|
|||
// apps/web/src/components/models/term/term-manager.tsx
|
||||
import { Button, Input, Modal, Space, Table, TreeSelect, Select } from "antd";
|
||||
import { api } from "@nice/client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { ObjectType } from "@nice/common";
|
||||
|
||||
interface TermManagerProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function DeviceManager({ title }: TermManagerProps) {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [editingTerm, setEditingTerm] = useState<any>(null);
|
||||
const [termName, setTermName] = useState("");
|
||||
const [parentId, setParentId] = useState<string | null>(null);
|
||||
const [taxonomyId, setTaxonomyId] = useState<string | null>(null);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [treeData, setTreeData] = useState<any[]>([]);
|
||||
const [taxonomySelectDisabled, setTaxonomySelectDisabled] = useState(false);
|
||||
|
||||
// 获取所有taxonomy
|
||||
const { data: taxonomies } = api.taxonomy.getAll.useQuery({
|
||||
type: ObjectType.DEVICE,
|
||||
});
|
||||
|
||||
// 获取所有故障类型taxonomy
|
||||
// 故障网系类别taxonomy
|
||||
const { data: systemTypeTaxonomy } = api.taxonomy.findBySlug.useQuery({
|
||||
slug: "system_type",
|
||||
});
|
||||
|
||||
// 故障类型taxonomy
|
||||
const { data: deviceTypeTaxonomy } = api.taxonomy.findBySlug.useQuery({
|
||||
slug: "device_type",
|
||||
});
|
||||
|
||||
// 获取所有网系类别条目
|
||||
const { data: systemTypeTerms, refetch: refetchSystemType } =
|
||||
api.term.findMany.useQuery({
|
||||
where: {
|
||||
taxonomy: { slug: "system_type" },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
children: true,
|
||||
},
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
// 获取所有故障类型条目
|
||||
const { data: deviceTypeTerms, refetch: refetchDeviceType } =
|
||||
api.term.findMany.useQuery({
|
||||
where: {
|
||||
taxonomy: { slug: "device_type" },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
children: true,
|
||||
},
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
// 构建包含两种分类的树形数据
|
||||
useEffect(() => {
|
||||
if (systemTypeTerms && deviceTypeTerms) {
|
||||
// 先获取顶级网系类别
|
||||
const rootTerms = systemTypeTerms.filter((term) => !term.parentId);
|
||||
|
||||
// 构建树形数据
|
||||
const buildTreeData = (items: any[]): any[] => {
|
||||
return items.map((item) => {
|
||||
// 找到与此网系类别关联的故障类型
|
||||
const deviceChildren = deviceTypeTerms.filter(
|
||||
(t) => t.parentId === item.id
|
||||
);
|
||||
|
||||
// 为每个故障类型找到其子故障
|
||||
const processedDeviceChildren = deviceChildren.map((deviceType) => {
|
||||
const deviceItems = deviceTypeTerms.filter(
|
||||
(t) => t.parentId === deviceType.id
|
||||
);
|
||||
|
||||
return {
|
||||
...deviceType,
|
||||
key: deviceType.id,
|
||||
children: deviceItems.map((device) => ({
|
||||
...device,
|
||||
key: device.id,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...item,
|
||||
key: item.id,
|
||||
children: processedDeviceChildren,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
setTreeData(buildTreeData(rootTerms));
|
||||
}
|
||||
}, [systemTypeTerms, deviceTypeTerms]);
|
||||
|
||||
// 搜索过滤逻辑
|
||||
useEffect(() => {
|
||||
if (systemTypeTerms && deviceTypeTerms) {
|
||||
if (!searchValue) {
|
||||
// 重新构建完整的树形结构
|
||||
const rootTerms = systemTypeTerms.filter((term) => !term.parentId);
|
||||
const buildTreeData = (items: any[]): any[] => {
|
||||
return items.map((item) => {
|
||||
const deviceChildren = deviceTypeTerms.filter(
|
||||
(t) => t.parentId === item.id
|
||||
);
|
||||
|
||||
const processedDeviceChildren = deviceChildren.map((deviceType) => {
|
||||
const deviceItems = deviceTypeTerms.filter(
|
||||
(t) => t.parentId === deviceType.id
|
||||
);
|
||||
|
||||
return {
|
||||
...deviceType,
|
||||
key: deviceType.id,
|
||||
children: deviceItems.map((device) => ({
|
||||
...device,
|
||||
key: device.id,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...item,
|
||||
key: item.id,
|
||||
children: processedDeviceChildren,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
setTreeData(buildTreeData(rootTerms));
|
||||
} else {
|
||||
// 搜索匹配所有项
|
||||
const allTerms = [...systemTypeTerms, ...deviceTypeTerms];
|
||||
const filtered = allTerms.filter((term) =>
|
||||
term.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
setTreeData(filtered.map((item) => ({ ...item, key: item.id })));
|
||||
}
|
||||
}
|
||||
}, [systemTypeTerms, deviceTypeTerms, searchValue]);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchValue(value);
|
||||
};
|
||||
|
||||
// API调用
|
||||
const { mutate: createTerm } = api.term.create.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchSystemType();
|
||||
refetchDeviceType();
|
||||
setIsModalVisible(false);
|
||||
setTermName("");
|
||||
setParentId(null);
|
||||
setTaxonomyId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: updateTerm } = api.term.update.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchSystemType();
|
||||
refetchDeviceType();
|
||||
setIsModalVisible(false);
|
||||
setEditingTerm(null);
|
||||
setTermName("");
|
||||
setParentId(null);
|
||||
setTaxonomyId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: softDeleteByIds } = api.term.softDeleteByIds.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchSystemType();
|
||||
refetchDeviceType();
|
||||
},
|
||||
});
|
||||
|
||||
// 操作处理函数
|
||||
// 修改handleAdd函数
|
||||
const handleAdd = (parentRecord?: any) => {
|
||||
setEditingTerm(null);
|
||||
setTermName("");
|
||||
setParentId(parentRecord?.id || null);
|
||||
refetchSystemType();
|
||||
refetchDeviceType();
|
||||
// 根据父记录类型自动选择taxonomy
|
||||
if (parentRecord) {
|
||||
// 如果父类是网系类别,则自动设置为故障类型
|
||||
if (parentRecord.taxonomyId === systemTypeTaxonomy?.id) {
|
||||
setTaxonomyId(deviceTypeTaxonomy?.id);
|
||||
// 可以设置状态来禁用Select组件
|
||||
setTaxonomySelectDisabled(true);
|
||||
} else {
|
||||
setTaxonomyId(parentRecord.taxonomyId);
|
||||
setTaxonomySelectDisabled(false);
|
||||
}
|
||||
} else {
|
||||
// 如果是顶级项,默认设为网系类别
|
||||
setTaxonomyId(systemTypeTaxonomy?.id || null);
|
||||
setTaxonomySelectDisabled(false);
|
||||
}
|
||||
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const handleEdit = (term: any) => {
|
||||
setEditingTerm(term);
|
||||
setTermName(term.name);
|
||||
setParentId(term.parentId);
|
||||
setTaxonomyId(term.taxonomyId);
|
||||
setIsModalVisible(true);
|
||||
refetchSystemType();
|
||||
refetchDeviceType();
|
||||
};
|
||||
|
||||
const handleDelete = (term: any) => {
|
||||
Modal.confirm({
|
||||
title: "确认删除",
|
||||
content: `确定要删除"${term.name}"吗?这将同时删除其下所有子项!`,
|
||||
onOk: () => softDeleteByIds({ ids: [term.id] }),
|
||||
});
|
||||
refetchSystemType();
|
||||
refetchDeviceType();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!termName.trim() || !taxonomyId) return;
|
||||
|
||||
if (editingTerm) {
|
||||
updateTerm({
|
||||
where: { id: editingTerm.id },
|
||||
data: {
|
||||
name: termName,
|
||||
parentId: parentId,
|
||||
hasChildren: editingTerm.hasChildren,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createTerm({
|
||||
data: {
|
||||
name: termName,
|
||||
taxonomyId: taxonomyId,
|
||||
parentId: parentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
refetchSystemType();
|
||||
refetchDeviceType();
|
||||
};
|
||||
|
||||
// 构建父级选择器的选项
|
||||
const getParentOptions = () => {
|
||||
if (!systemTypeTerms || !deviceTypeTerms) return [];
|
||||
|
||||
const allTerms = [...systemTypeTerms, ...deviceTypeTerms];
|
||||
|
||||
// 根据编辑对象和当前选择的taxonomy过滤有效的父级选项
|
||||
let validParents = allTerms;
|
||||
|
||||
// 如果是编辑现有项
|
||||
if (editingTerm) {
|
||||
// 递归查找所有子孙节点ID,避免循环引用
|
||||
const findAllDescendantIds = (itemId: string): string[] => {
|
||||
const directChildren = allTerms.filter((t) => t.parentId === itemId);
|
||||
const descendantIds = directChildren.map((c) => c.id);
|
||||
|
||||
directChildren.forEach((child) => {
|
||||
const childDescendants = findAllDescendantIds(child.id);
|
||||
descendantIds.push(...childDescendants);
|
||||
});
|
||||
|
||||
return descendantIds;
|
||||
};
|
||||
|
||||
const invalidIds = [
|
||||
editingTerm.id,
|
||||
...findAllDescendantIds(editingTerm.id),
|
||||
];
|
||||
validParents = allTerms.filter((t) => !invalidIds.includes(t.id));
|
||||
}
|
||||
|
||||
// 如果是添加故障类型,只能选择网系类别作为父级
|
||||
if (!editingTerm && taxonomyId === deviceTypeTaxonomy?.id) {
|
||||
validParents = systemTypeTerms;
|
||||
}
|
||||
|
||||
// 如果是添加具体故障,只能选择故障类型作为父级
|
||||
if (
|
||||
!editingTerm &&
|
||||
taxonomyId &&
|
||||
taxonomyId !== systemTypeTaxonomy?.id &&
|
||||
taxonomyId !== deviceTypeTaxonomy?.id
|
||||
) {
|
||||
validParents = deviceTypeTerms;
|
||||
}
|
||||
|
||||
// 转换为TreeSelect需要的格式
|
||||
const buildTreeOptions = (items: any[], depth = 0): any[] => {
|
||||
return items
|
||||
.filter((item) => (depth === 0 ? !item.parentId : true))
|
||||
.map((item) => {
|
||||
const children = allTerms.filter((t) => t.parentId === item.id);
|
||||
return {
|
||||
title: "—".repeat(depth) + (depth > 0 ? " " : "") + item.name,
|
||||
value: item.id,
|
||||
children:
|
||||
children.length > 0
|
||||
? buildTreeOptions(children, depth + 1)
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return buildTreeOptions(validParents);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginBottom: 16,
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<Input.Search
|
||||
placeholder={`根据${title}搜索`}
|
||||
onSearch={handleSearch}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
value={searchValue}
|
||||
style={{ width: 400 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleAdd()}
|
||||
>
|
||||
添加顶级{title}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
dataSource={treeData}
|
||||
expandable={{
|
||||
defaultExpandAllRows: true,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: "名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
},
|
||||
{
|
||||
title: "分类类型",
|
||||
key: "taxonomyType",
|
||||
render: (_, record) => {
|
||||
if (record.taxonomyId === systemTypeTaxonomy?.id) {
|
||||
return "网系类别";
|
||||
} else if (record.taxonomyId === deviceTypeTaxonomy?.id) {
|
||||
return "故障类型";
|
||||
} else {
|
||||
return "具体故障";
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 200,
|
||||
render: (_, record: any) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleAdd(record)}
|
||||
style={{ color: "green" }}
|
||||
>
|
||||
添加子项
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
style={{ color: "#1890ff" }}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
rowClassName={(record, index) =>
|
||||
index % 2 === 0 ? "bg-white" : "bg-gray-100"
|
||||
}
|
||||
onHeaderRow={() => {
|
||||
return {
|
||||
style: {
|
||||
backgroundColor: "#d6e4ff",
|
||||
},
|
||||
};
|
||||
}}
|
||||
bordered
|
||||
size="middle"
|
||||
locale={{ emptyText: "暂无数据" }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingTerm ? `编辑${title}` : `添加${title}`}
|
||||
open={isModalVisible}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: "block", marginBottom: 8 }}>名称:</label>
|
||||
<Input
|
||||
placeholder={`请输入${title}名称`}
|
||||
value={termName}
|
||||
onChange={(e) => setTermName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!editingTerm && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: "block", marginBottom: 8 }}>
|
||||
分类类型:
|
||||
</label>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
placeholder="请选择分类类型"
|
||||
value={taxonomyId}
|
||||
onChange={setTaxonomyId}
|
||||
disabled={taxonomySelectDisabled}
|
||||
options={taxonomies?.map((tax) => ({
|
||||
label: tax.name,
|
||||
value: tax.id,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label style={{ display: "block", marginBottom: 8 }}>
|
||||
上级分类:
|
||||
</label>
|
||||
<TreeSelect
|
||||
style={{ width: "100%" }}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
|
||||
placeholder="请选择上级分类"
|
||||
allowClear
|
||||
treeDefaultExpandAll
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
treeData={getParentOptions()}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,169 @@
|
|||
import { Select, Spin } from "antd";
|
||||
import { api } from "@nice/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
interface DeviceTypeSelectProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
systemTypeId?: string; // 添加网系类别ID作为过滤条件
|
||||
}
|
||||
|
||||
export default function DeviceTypeSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "选择故障类型",
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
systemTypeId,
|
||||
}: DeviceTypeSelectProps) {
|
||||
const [options, setOptions] = useState<{ label: string; value: string }[]>(
|
||||
[]
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取所有故障类型数据(包括子项)
|
||||
const { data: allDeviceTypes, isLoading, refetch: refetchDeviceType } = api.term.findMany.useQuery({
|
||||
where: {
|
||||
taxonomy: { slug: "device_type" },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
children: true,
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
taxonomyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
// 获取网系类别下的所有故障
|
||||
const { data: systemTypeTerms, refetch: refetchSystemType } = api.term.findMany.useQuery(
|
||||
{
|
||||
where: {
|
||||
taxonomy: { slug: "system_type" },
|
||||
id: systemTypeId,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
children: true, // 获取网系类别的直接子项(故障类型)
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!systemTypeId,
|
||||
}
|
||||
);
|
||||
|
||||
// 处理数据显示
|
||||
useEffect(() => {
|
||||
|
||||
setLoading(isLoading);
|
||||
if (allDeviceTypes) {
|
||||
// 有选中的网系类别
|
||||
if (systemTypeId && systemTypeTerms && systemTypeTerms.length > 0) {
|
||||
const systemTerm = systemTypeTerms[0];
|
||||
|
||||
// 查找直接关联到此网系类别的故障类型ID
|
||||
const childrenIds = (systemTerm as any).children?.map((child) => child.id) || [];
|
||||
|
||||
// 查找具有此网系类别parentId的故障类型
|
||||
const relatedByParentId = allDeviceTypes.filter(
|
||||
(device) => device.parentId === systemTypeId
|
||||
);
|
||||
|
||||
// 查找所有与该网系类别直接相关的故障类型
|
||||
const relatedDeviceTypes = allDeviceTypes.filter(
|
||||
(device) =>
|
||||
childrenIds.includes(device.id) || device.parentId === systemTypeId
|
||||
);
|
||||
|
||||
console.log("已选系统:", systemTerm.name);
|
||||
console.log("相关故障类型数量:", relatedDeviceTypes.length);
|
||||
|
||||
setOptions(
|
||||
relatedDeviceTypes.map((term) => ({
|
||||
label: term.name,
|
||||
value: term.id,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
// 没有选中网系类别,显示所有故障类型(按系统分组)
|
||||
const deviceTypesWithParent = allDeviceTypes.map((term) => {
|
||||
// 只处理没有子项的故障类型(最底层的故障类型)
|
||||
const parentName = (term as any).parent?.name || "";
|
||||
const displayName = parentName
|
||||
? `${parentName} - ${term.name}`
|
||||
: term.name;
|
||||
|
||||
return {
|
||||
label: displayName,
|
||||
value: term.id,
|
||||
};
|
||||
});
|
||||
|
||||
setOptions(deviceTypesWithParent);
|
||||
}
|
||||
}
|
||||
}, [systemTypeId, allDeviceTypes, systemTypeTerms, isLoading]);
|
||||
|
||||
// 当systemTypeId改变时,如果当前选中的故障类型不在新的网系类别下,则清空选择
|
||||
useEffect(() => {
|
||||
if (
|
||||
systemTypeId &&
|
||||
value &&
|
||||
allDeviceTypes &&
|
||||
systemTypeTerms &&
|
||||
systemTypeTerms.length > 0
|
||||
) {
|
||||
const selectedDeviceType = allDeviceTypes.find((t) => t.id === value);
|
||||
const systemTerm = systemTypeTerms[0];
|
||||
const childrenIds = (systemTerm as any).children?.map((child) => child.id) || [];
|
||||
|
||||
// 检查所选故障类型是否与当前网系类别相关
|
||||
if (
|
||||
selectedDeviceType &&
|
||||
selectedDeviceType.parentId !== systemTypeId &&
|
||||
!childrenIds.includes(selectedDeviceType.id)
|
||||
) {
|
||||
onChange?.("");
|
||||
}
|
||||
}
|
||||
}, [systemTypeId, value, allDeviceTypes, systemTypeTerms, onChange]);
|
||||
|
||||
// 如果真的需要在组件初始加载时获取一次数据,可以添加这个useEffect
|
||||
useEffect(() => {
|
||||
// 组件挂载时获取一次数据
|
||||
refetchDeviceType();
|
||||
refetchSystemType();
|
||||
// 空依赖数组,只在组件挂载时执行一次
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Select
|
||||
loading={loading}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
style={style}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label?.toString() || "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
allowClear
|
||||
notFoundContent={loading ? <Spin size="small" /> : null}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
// apps/web/src/components/models/term/system-type-select.tsx
|
||||
import { Select } from "antd";
|
||||
import { api } from "@nice/client";
|
||||
import React from "react";
|
||||
|
||||
interface SystemTypeSelectProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function SystemTypeSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "选择网系类别",
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
}: SystemTypeSelectProps) {
|
||||
const { data: terms, isLoading } = api.term.findMany.useQuery({
|
||||
where: {
|
||||
taxonomy: { slug: "system_type" },
|
||||
deletedAt: null,
|
||||
parentId: null, // 只查询顶级网系类别
|
||||
},
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
const options =
|
||||
terms?.map((term) => ({
|
||||
label: term.name,
|
||||
value: term.id,
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<Select
|
||||
loading={isLoading}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
style={style}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label?.toString() || "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
allowClear
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -16,18 +16,14 @@ export function MainFooter() {
|
|||
<h3 className="text-white font-semibold text-sm flex items-center justify-center md:justify-start">
|
||||
软件与数据小组
|
||||
</h3>
|
||||
<p className="text-gray-400 text-xs italic">
|
||||
提供技术支持
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs italic">提供技术支持</p>
|
||||
</div>
|
||||
|
||||
{/* 联系方式 */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<PhoneOutlined className="text-gray-400" />
|
||||
<span className="text-gray-300 text-xs">
|
||||
628532
|
||||
</span>
|
||||
<span className="text-gray-300 text-xs">628532</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<MailOutlined className="text-gray-400" />
|
||||
|
@ -43,26 +39,20 @@ export function MainFooter() {
|
|||
<a
|
||||
href="https://27.57.72.21"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
title="访问门户网站">
|
||||
title="访问门户网站"
|
||||
>
|
||||
<HomeOutlined className="text-lg" />
|
||||
</a>
|
||||
<a
|
||||
href="https://27.57.72.14"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
title="访问烽火青云">
|
||||
title="访问烽火青云"
|
||||
>
|
||||
<CloudOutlined className="text-lg" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 版权信息 */}
|
||||
<div className="border-t border-gray-700/50 mt-4 pt-4 text-center">
|
||||
<p className="text-gray-400 text-xs">
|
||||
© {new Date().getFullYear()} 烽火快传. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
|
|
@ -1,107 +1,67 @@
|
|||
import { Input, Button } from "antd";
|
||||
import { PlusOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { Button, Avatar, Dropdown, Space } from "antd";
|
||||
import { UserOutlined, LogoutOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { UserMenu } from "./UserMenu/UserMenu";
|
||||
import { NavigationMenu } from "./NavigationMenu";
|
||||
import { useMainContext } from "./MainProvider";
|
||||
import { env } from "@web/src/env";
|
||||
|
||||
export function MainHeader() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { isAuthenticated, user, logout } = useAuth();
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { searchValue, setSearchValue } = useMainContext();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: "logout",
|
||||
icon: <LogoutOutlined />,
|
||||
label: "退出登录",
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="select-none w-full flex items-center justify-between bg-white shadow-md border-b border-gray-100 fixed z-30 py-2 px-4 md:px-6">
|
||||
{/* 左侧区域 - 设置为不收缩 */}
|
||||
<div className="flex items-center justify-start space-x-4 flex-shrink-0">
|
||||
<img src="/logo.svg" className="h-12 w-12" />
|
||||
<div
|
||||
onClick={() => navigate("/")}
|
||||
className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer whitespace-nowrap">
|
||||
{env.APP_NAME}
|
||||
</div>
|
||||
<NavigationMenu />
|
||||
<div className="select-none w-full flex items-center justify-between bg-white shadow-md border-b border-gray-100 fixed z-30 py-2 px-4 md:px-6 h-16">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl font-bold text-primary">故障收录检索系统</span>
|
||||
</div>
|
||||
|
||||
{/* 右侧区域 - 可以灵活收缩 */}
|
||||
<div className="flex justify-end gap-2 md:gap-4 flex-shrink">
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<Input
|
||||
size="large"
|
||||
prefix={
|
||||
<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />
|
||||
}
|
||||
placeholder="搜索课程"
|
||||
className="w-full md:w-96 rounded-full"
|
||||
value={searchValue}
|
||||
onClick={(e) => {
|
||||
if (
|
||||
!window.location.pathname.startsWith("/search")
|
||||
) {
|
||||
navigate(`/search`);
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onPressEnter={(e) => {
|
||||
if (
|
||||
!window.location.pathname.startsWith("/search")
|
||||
) {
|
||||
navigate(`/search`);
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
{isAuthenticated && user ? (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items,
|
||||
onClick: ({ key }) => key === "logout" && handleLogout(),
|
||||
}}
|
||||
placement="bottomRight"
|
||||
arrow
|
||||
>
|
||||
<div className="flex items-center cursor-pointer transition-all hover:bg-gray-100 rounded-full py-1 px-3">
|
||||
<Avatar
|
||||
className="bg-blue-500"
|
||||
icon={<UserOutlined />}
|
||||
size="default"
|
||||
/>
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Button
|
||||
size="large"
|
||||
shape="round"
|
||||
icon={<PlusOutlined></PlusOutlined>}
|
||||
onClick={() => {
|
||||
const url = "/course/editor";
|
||||
navigate(url);
|
||||
}}
|
||||
type="primary">
|
||||
{"创建课程"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
size="large"
|
||||
shape="round"
|
||||
onClick={() => {
|
||||
window.location.href = "/path/editor";
|
||||
}}
|
||||
ghost
|
||||
type="primary"
|
||||
icon={<PlusOutlined></PlusOutlined>}>
|
||||
创建思维导图
|
||||
</Button>
|
||||
)}
|
||||
{isAuthenticated ? (
|
||||
<UserMenu />
|
||||
<span className="mx-2 font-medium">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
<DownOutlined className="text-xs text-gray-500" />
|
||||
</div>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
shape="round"
|
||||
onClick={() => navigate("/login")}
|
||||
icon={<UserOutlined />}>
|
||||
icon={<UserOutlined />}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,17 +3,21 @@ import { Outlet } from "react-router-dom";
|
|||
import { MainHeader } from "./MainHeader";
|
||||
import { MainFooter } from "./MainFooter";
|
||||
import { MainProvider } from "./MainProvider";
|
||||
import NavigationMenu from "./NavigationMenu";
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
export function MainLayout() {
|
||||
return (
|
||||
<MainProvider>
|
||||
<div className=" min-h-screen bg-gray-100">
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<MainHeader />
|
||||
<Content className=" flex-grow pt-16 bg-gray-50 ">
|
||||
<div className="flex pt-16">
|
||||
<NavigationMenu />
|
||||
<Content className="flex-grow bg-gray-50">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</div>
|
||||
<MainFooter />
|
||||
</div>
|
||||
</MainProvider>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { PostType, Prisma } from "@nice/common";
|
||||
import { Prisma, StaffDto } from "@nice/common";
|
||||
import React, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
|
@ -6,24 +6,19 @@ import React, {
|
|||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
interface SelectedTerms {
|
||||
[key: string]: string[]; // 每个 slug 对应一个 string 数组
|
||||
}
|
||||
// import { useDebounce } from "use-debounce";
|
||||
import { Form, FormInstance } from "antd";
|
||||
|
||||
interface MainContextType {
|
||||
searchValue?: string;
|
||||
selectedTerms?: SelectedTerms;
|
||||
setSearchValue?: React.Dispatch<React.SetStateAction<string>>;
|
||||
setSelectedTerms?: React.Dispatch<React.SetStateAction<SelectedTerms>>;
|
||||
searchCondition?: Prisma.PostWhereInput;
|
||||
termsCondition?: Prisma.PostWhereInput;
|
||||
searchMode?: PostType.COURSE | PostType.PATH | "both";
|
||||
setSearchMode?: React.Dispatch<
|
||||
React.SetStateAction<PostType.COURSE | PostType.PATH | "both">
|
||||
>;
|
||||
showSearchMode?: boolean;
|
||||
setShowSearchMode?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSearchValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
formValue?: { [key: string]: any };
|
||||
setFormValue: React.Dispatch<React.SetStateAction<{ [key: string]: any }>>;
|
||||
form: FormInstance; // 新增表单实例
|
||||
visible: boolean;
|
||||
setVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
editingRecord?: StaffDto | null;
|
||||
setEditingRecord: React.Dispatch<React.SetStateAction<StaffDto | null>>;
|
||||
}
|
||||
|
||||
const MainContext = createContext<MainContextType | null>(null);
|
||||
|
@ -32,71 +27,32 @@ interface MainProviderProps {
|
|||
}
|
||||
|
||||
export function MainProvider({ children }: MainProviderProps) {
|
||||
const [searchMode, setSearchMode] = useState<
|
||||
PostType.COURSE | PostType.PATH | "both"
|
||||
>("both");
|
||||
const [showSearchMode, setShowSearchMode] = useState<boolean>(false);
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const [debouncedValue] = useDebounce<string>(searchValue, 500);
|
||||
const [selectedTerms, setSelectedTerms] = useState<SelectedTerms>({}); // 初始化状态
|
||||
const termFilters = useMemo(() => {
|
||||
return Object.entries(selectedTerms)
|
||||
.filter(([, terms]) => terms.length > 0)
|
||||
?.map(([, terms]) => terms);
|
||||
}, [selectedTerms]);
|
||||
|
||||
const termsCondition: Prisma.PostWhereInput = useMemo(() => {
|
||||
return termFilters && termFilters?.length > 0
|
||||
? {
|
||||
AND: termFilters.map((termFilter) => ({
|
||||
terms: {
|
||||
some: {
|
||||
id: {
|
||||
in: termFilter, // 确保至少有一个 term.id 在当前 termFilter 中
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
}
|
||||
: {};
|
||||
}, [termFilters]);
|
||||
const searchCondition: Prisma.PostWhereInput = useMemo(() => {
|
||||
const containTextCondition: Prisma.StringNullableFilter = {
|
||||
contains: debouncedValue,
|
||||
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
|
||||
};
|
||||
return debouncedValue
|
||||
? {
|
||||
OR: [
|
||||
{ title: containTextCondition },
|
||||
{ subTitle: containTextCondition },
|
||||
{ content: containTextCondition },
|
||||
{
|
||||
terms: {
|
||||
some: {
|
||||
name: containTextCondition,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {};
|
||||
}, [debouncedValue]);
|
||||
const [formValue, setFormValue] = useState<{ [key: string]: any }>({});
|
||||
const [form] = Form.useForm(); // 添加AntD表单实例
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
const [editingRecord, setEditingRecord] = useState<StaffDto | null>(null);
|
||||
return (
|
||||
<MainContext.Provider
|
||||
value={{
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
selectedTerms,
|
||||
setSelectedTerms,
|
||||
searchCondition,
|
||||
termsCondition,
|
||||
searchMode,
|
||||
setSearchMode,
|
||||
showSearchMode,
|
||||
setShowSearchMode,
|
||||
}}>
|
||||
formValue,
|
||||
setFormValue,
|
||||
form,
|
||||
visible,
|
||||
setVisible,
|
||||
editingRecord,
|
||||
setEditingRecord,
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
onValuesChange={(changed, all) => setFormValue(all)}
|
||||
initialValues={formValue}
|
||||
>
|
||||
{children}
|
||||
</Form>
|
||||
</MainContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,59 +1,129 @@
|
|||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Menu } from "antd";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
BookOutlined,
|
||||
SettingOutlined,
|
||||
FormOutlined,
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
KeyOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
export const NavigationMenu = () => {
|
||||
function getItem(
|
||||
label: any,
|
||||
key: any,
|
||||
icon: any,
|
||||
children: any,
|
||||
type: any
|
||||
// permission: any
|
||||
) {
|
||||
return {
|
||||
key,
|
||||
icon,
|
||||
children,
|
||||
label,
|
||||
type,
|
||||
// permission,
|
||||
};
|
||||
}
|
||||
const items = [
|
||||
getItem("故障收录检索", "/device", <BookOutlined />, null, null),
|
||||
getItem(
|
||||
"系统设置",
|
||||
"/admin",
|
||||
<SettingOutlined />,
|
||||
[
|
||||
getItem("基本设置", "/admin/base-setting", <FormOutlined />, null, null),
|
||||
getItem("用户管理", "/admin/user", <UserOutlined />, null, null),
|
||||
getItem("组织架构", "/admin/department", <TeamOutlined />, null, null),
|
||||
getItem("角色管理", "/admin/role", <KeyOutlined />, null, null),
|
||||
// getItem("考核标准管理", "/admin/assessment-standard", null, null, null),
|
||||
],
|
||||
null
|
||||
),
|
||||
];
|
||||
|
||||
const NavigationMenu: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const baseItems = [
|
||||
{ key: "home", path: "/", label: "首页" },
|
||||
{ key: "path", path: "/path", label: "全部思维导图" },
|
||||
{ key: "courses", path: "/courses", label: "所有课程" },
|
||||
];
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return baseItems;
|
||||
} else {
|
||||
return [
|
||||
...baseItems,
|
||||
{ key: "my-duty", path: "/my-duty", label: "我创建的课程" },
|
||||
{ key: "my-learning", path: "/my-learning", label: "我学习的课程" },
|
||||
{ key: "my-duty-path", path: "/my-duty-path", label: "我创建的思维导图" },
|
||||
{ key: "my-path", path: "/my-path", label: "我学习的思维导图" },
|
||||
];
|
||||
// 定义路径匹配规则
|
||||
const getParentPath = (pathname: string): string[] => {
|
||||
if (pathname.startsWith("/assessment/") || pathname.startsWith("/admin/")) {
|
||||
return [pathname.split("/").slice(0, 2).join("/")];
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const selectedKey = useMemo(() => {
|
||||
const normalizePath = (path: string): string => path.replace(/\/$/, "");
|
||||
return pathname === '/' ? "home" : menuItems.find((item) => normalizePath(pathname) === item.path)?.key || "";
|
||||
}, [pathname]);
|
||||
if (pathname.indexOf("/staff") !== -1) {
|
||||
return ["/staff"];
|
||||
}
|
||||
|
||||
return [pathname];
|
||||
};
|
||||
|
||||
// 选中的菜单
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([
|
||||
location.pathname,
|
||||
]);
|
||||
// 展开菜单
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(
|
||||
getParentPath(location.pathname)
|
||||
);
|
||||
|
||||
// 路径变化时更新菜单状态
|
||||
useEffect(() => {
|
||||
setSelectedKeys([location.pathname]);
|
||||
setOpenKeys(getParentPath(location.pathname));
|
||||
}, [location.pathname]);
|
||||
|
||||
const onClick = (e: any) => {
|
||||
navigate(e.key);
|
||||
};
|
||||
// console.log(items)
|
||||
useEffect(() => {
|
||||
setSelectedKeys(selectedKeys);
|
||||
// console.log(selectedKeys)
|
||||
}, [selectedKeys]);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
className="border-none font-medium"
|
||||
disabledOverflow={true}
|
||||
selectedKeys={[selectedKey]}
|
||||
onClick={({ key }) => {
|
||||
const selectedItem = menuItems.find((item) => item.key === key);
|
||||
if (selectedItem) navigate(selectedItem.path);
|
||||
window.scrollTo({
|
||||
<div className="w-[200px] h-full bg-#fff">
|
||||
<div
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
cursor: "pointer",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}>
|
||||
{menuItems.map(({ key, label }) => (
|
||||
<Menu.Item
|
||||
key={key}
|
||||
className="text-gray-600 hover:text-blue-600">
|
||||
{label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
zIndex: 10,
|
||||
background: "#fff",
|
||||
}}
|
||||
onClick={() => {
|
||||
window.location.href = "/";
|
||||
}}
|
||||
>
|
||||
{/* 此处为版权标识,严禁删改
|
||||
<img src={logo} className="w-[124px] h-[40px]"/> */}
|
||||
</div>
|
||||
<div className="w-[200px] h-[calc(100%-74px)] overflow-y-auto overflow-x-hidden">
|
||||
<Menu
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: 200,
|
||||
background: "#ffffff",
|
||||
}}
|
||||
selectedKeys={selectedKeys}
|
||||
openKeys={openKeys}
|
||||
mode="inline"
|
||||
items={items}
|
||||
onSelect={(data: any) => {
|
||||
setSelectedKeys(data.selectedKeys);
|
||||
}}
|
||||
onOpenChange={(keys: any) => {
|
||||
setOpenKeys(keys);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationMenu;
|
||||
|
|
|
@ -16,10 +16,11 @@ export function PathListContainer() {
|
|||
type: PostType.PATH,
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
deletedAt:null
|
||||
deletedAt: null,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
cols={4}
|
||||
></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ const { Content } = Layout;
|
|||
export default function AdminLayout() {
|
||||
return (
|
||||
<Layout className="min-h-screen">
|
||||
<AdminSidebar routes={adminRoute.children || []} />
|
||||
<Layout>
|
||||
<Content>
|
||||
<Outlet />
|
||||
|
|
0
apps/web/src/components/models/course/detail/CourseOperationBtns/CourseOperationBtns.tsx
Normal file → Executable file
0
apps/web/src/components/models/course/detail/CourseOperationBtns/CourseOperationBtns.tsx
Normal file → Executable file
|
@ -0,0 +1,35 @@
|
|||
import { useAuth } from "@web/src/providers/auth-provider"
|
||||
import { api } from "@nice/client"
|
||||
import { Select } from "antd"
|
||||
import { Department } from "packages/common/dist"
|
||||
import { useEffect } from "react"
|
||||
|
||||
interface Props {
|
||||
value?: string | null
|
||||
onChange?: (value: string | null) => void
|
||||
}
|
||||
export default function DepartmentChildrenSelect({value, onChange}: Props) {
|
||||
const { user, isAuthenticated } = useAuth()
|
||||
|
||||
// const { data: depts, isLoading: deptsLoading }
|
||||
// const { user, isAuthenticated } = useAuth()
|
||||
|
||||
const { data: depts, isLoading: deptsLoading }
|
||||
= isAuthenticated ? api.department.getChildSimpleTree.useQuery({
|
||||
rootId: user.deptId
|
||||
}) : { data: null, isLoading: false }
|
||||
|
||||
const deptSelectOptions = depts?.map((dept) => ({
|
||||
label: dept.title,
|
||||
value: dept.id
|
||||
}))
|
||||
return (
|
||||
<Select
|
||||
placeholder="请选择单位"
|
||||
optionFilterProp="label"
|
||||
options={deptSelectOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
import { createContext, useMemo, useState } from "react";
|
||||
import React, { createContext, useMemo, useState } from "react";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { RolePerms } from "@nice/common";
|
||||
|
||||
import { Button, FormInstance } from "antd";
|
||||
import { useForm } from "antd/es/form/Form";
|
||||
import DepartmentList from "./department-list";
|
||||
|
@ -57,7 +56,8 @@ export default function DeptEditor() {
|
|||
|
||||
setImportModalOpen,
|
||||
importModalOpen,
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<FixedHeader roomId="dept-editor">
|
||||
<div className=" flex items-center gap-4 ">
|
||||
{canManageDept && (
|
||||
|
@ -67,20 +67,21 @@ export default function DeptEditor() {
|
|||
type="primary"
|
||||
onClick={() => {
|
||||
setImportModalOpen(true);
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
导入单位
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setModalOpen(true);
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
新建单位
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</FixedHeader>
|
||||
<DepartmentList></DepartmentList>
|
||||
<DeptModal />
|
||||
|
|
|
@ -58,7 +58,7 @@ export const adminRoute: CustomRouteObject = {
|
|||
},
|
||||
},
|
||||
{
|
||||
path: "staff",
|
||||
path: "user",
|
||||
name: "用户管理",
|
||||
icon: <UserOutlined />,
|
||||
element: (
|
||||
|
@ -74,7 +74,7 @@ export const adminRoute: CustomRouteObject = {
|
|||
),
|
||||
handle: {
|
||||
crumb() {
|
||||
return <Link to={"/admin/staff"}>用户管理</Link>;
|
||||
return <Link to={"/admin/user"}>用户管理</Link>;
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -1,21 +1,14 @@
|
|||
import {
|
||||
createBrowserRouter,
|
||||
IndexRouteObject,
|
||||
Link,
|
||||
Navigate,
|
||||
NonIndexRouteObject,
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
import ErrorPage from "../app/error";
|
||||
import LoginPage from "../app/login";
|
||||
import WithAuth from "../components/utils/with-auth";
|
||||
import { CodeManageProvider } from "../app/admin/code-manage/CodeManageContext";
|
||||
import CodeManageLayout from "../app/admin/code-manage/CodeManageLayout";
|
||||
import QuickUploadPage from "../app/admin/quick-file/page";
|
||||
import { QuickFileProvider } from "../app/admin/quick-file/quickFileContext";
|
||||
import QuickFileManage from "../app/admin/quick-file/manage";
|
||||
import { DashboardProvider } from "../app/admin/dashboard/DashboardContext";
|
||||
import DashboardLayout from "../app/admin/dashboard/DashboardLayout";
|
||||
import { MainLayout } from "../app/main/layout/MainLayout";
|
||||
import DeviceMessage from "../app/main/devicepage/page";
|
||||
import AdminLayout from "../components/layout/admin/AdminLayout";
|
||||
import { adminRoute } from "./admin-route";
|
||||
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||
name?: string;
|
||||
breadcrumb?: string;
|
||||
|
@ -38,59 +31,34 @@ export type CustomRouteObject =
|
|||
| CustomNonIndexRouteObject;
|
||||
export const routes: CustomRouteObject[] = [
|
||||
{
|
||||
path: "/",
|
||||
element: <>
|
||||
<QuickFileProvider>
|
||||
<QuickUploadPage></QuickUploadPage>
|
||||
</QuickFileProvider>
|
||||
</>,
|
||||
errorElement: <ErrorPage />,
|
||||
path: "/",
|
||||
element: <MainLayout/>,
|
||||
children: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <DeviceMessage></DeviceMessage>,
|
||||
},
|
||||
{
|
||||
path: "/device",
|
||||
element: <DeviceMessage></DeviceMessage>,
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
element: <AdminLayout></AdminLayout>,
|
||||
children: adminRoute.children,
|
||||
}
|
||||
],
|
||||
},]
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
breadcrumb: "登录",
|
||||
element: <LoginPage></LoginPage>,
|
||||
},
|
||||
{
|
||||
path: "/code-manage",
|
||||
element: <>
|
||||
<WithAuth>
|
||||
<CodeManageProvider>
|
||||
<CodeManageLayout></CodeManageLayout>
|
||||
</CodeManageProvider>
|
||||
</WithAuth>
|
||||
</>
|
||||
},
|
||||
{
|
||||
path: "/manage",
|
||||
element: <>
|
||||
<WithAuth>
|
||||
<QuickFileManage></QuickFileManage>
|
||||
</WithAuth>
|
||||
</>,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/manage/dashboard" replace />
|
||||
},
|
||||
{
|
||||
path: "/manage/share-code",
|
||||
element: <>
|
||||
<CodeManageProvider>
|
||||
<CodeManageLayout></CodeManageLayout>
|
||||
</CodeManageProvider>
|
||||
</>
|
||||
},
|
||||
{
|
||||
path: "/manage/dashboard",
|
||||
element: <>
|
||||
<DashboardProvider>
|
||||
<DashboardLayout></DashboardLayout>
|
||||
</DashboardProvider>
|
||||
</>
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
];
|
||||
|
||||
export const router = createBrowserRouter(routes);
|
||||
|
|
|
@ -2,7 +2,7 @@ server {
|
|||
# 监听80端口
|
||||
listen 80;
|
||||
# 服务器域名/IP地址,使用环境变量
|
||||
server_name 192.168.43.206;
|
||||
server_name 192.168.119.194;
|
||||
|
||||
# 基础性能优化配置
|
||||
# 启用tcp_nopush以优化数据发送
|
||||
|
@ -100,7 +100,7 @@ server {
|
|||
# 仅供内部使用
|
||||
internal;
|
||||
# 代理到认证服务
|
||||
proxy_pass http://192.168.43.206:3006/auth/file;
|
||||
proxy_pass http://192.168.119.194:3000/auth/file;
|
||||
|
||||
# 请求优化:不传递请求体
|
||||
proxy_pass_request_body off;
|
||||
|
|
|
@ -6,7 +6,4 @@ export * from "./useRole"
|
|||
export * from "./useRoleMap"
|
||||
export * from "./useTransform"
|
||||
export * from "./useTaxonomy"
|
||||
export * from "./useVisitor"
|
||||
export * from "./useMessage"
|
||||
export * from "./usePost"
|
||||
export * from "./useEntity"
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import { useEntity } from "./useEntity";
|
||||
|
||||
export function useMessage() {
|
||||
return useEntity("message");
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
|
||||
import { MutationResult, useEntity } from "./useEntity";
|
||||
export function usePost() {
|
||||
return useEntity("post");
|
||||
}
|
||||
// export function usePost() {
|
||||
// return useEntity("post");
|
||||
// }
|
||||
|
|
|
@ -26,7 +26,6 @@ export function useStaff() {
|
|||
const update = api.staff.update.useMutation({
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
queryClient.invalidateQueries({ queryKey: getQueryKey(api.post) });
|
||||
emitDataChange(
|
||||
ObjectType.STAFF,
|
||||
result as any,
|
||||
|
|
|
@ -1,178 +0,0 @@
|
|||
import { api } from "../trpc";
|
||||
import { PostParams } from "../../singleton/DataHolder";
|
||||
|
||||
export function useVisitor() {
|
||||
const utils = api.useUtils();
|
||||
const postParams = PostParams.getInstance();
|
||||
|
||||
const create = api.visitor.create.useMutation({
|
||||
onSuccess() {
|
||||
utils.visitor.invalidate();
|
||||
|
||||
// utils.post.invalidate();
|
||||
},
|
||||
});
|
||||
/**
|
||||
* 通用的乐观更新mutation工厂函数
|
||||
* @param updateFn 更新数据的具体逻辑函数
|
||||
* @returns 封装后的mutation配置对象
|
||||
*/
|
||||
const createOptimisticMutation = (
|
||||
updateFn: (item: any, variables: any) => any
|
||||
) => ({
|
||||
//在请求发送前执行本地数据预更新
|
||||
onMutate: async (variables: any) => {
|
||||
const previousDataList: any[] = [];
|
||||
const previousDetailDataList: any[] = [];
|
||||
|
||||
// 处理列表数据
|
||||
const paramsList = postParams.getItems();
|
||||
for (const params of paramsList) {
|
||||
await utils.post.findManyWithCursor.cancel();
|
||||
const previousData =
|
||||
utils.post.findManyWithCursor.getInfiniteData({
|
||||
...params,
|
||||
});
|
||||
previousDataList.push(previousData);
|
||||
utils.post.findManyWithCursor.setInfiniteData(
|
||||
{
|
||||
...params,
|
||||
},
|
||||
(oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
items: (page.items as any).map((item) =>
|
||||
item.id === variables?.postId
|
||||
? updateFn(item, variables)
|
||||
: item
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 处理详情数据
|
||||
const detailParamsList = postParams.getDetailItems();
|
||||
for (const params of detailParamsList) {
|
||||
await utils.post.findFirst.cancel();
|
||||
const previousDetailData = utils.post.findFirst.getData(params);
|
||||
previousDetailDataList.push(previousDetailData);
|
||||
utils.post.findFirst.setData(params, (oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.id === variables?.postId
|
||||
? updateFn(oldData, variables)
|
||||
: oldData;
|
||||
});
|
||||
}
|
||||
|
||||
return { previousDataList, previousDetailDataList };
|
||||
},
|
||||
// 错误处理:数据回滚
|
||||
onError: (_err: any, _variables: any, context: any) => {
|
||||
const paramsList = postParams.getItems();
|
||||
paramsList.forEach((params, index) => {
|
||||
if (context?.previousDataList?.[index]) {
|
||||
utils.post.findManyWithCursor.setInfiniteData(
|
||||
{ ...params },
|
||||
context.previousDataList[index]
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
// 成功后的缓存失效
|
||||
onSuccess: async (_: any, variables: any) => {
|
||||
await Promise.all([
|
||||
utils.visitor.invalidate(),
|
||||
utils.post.findFirst.invalidate({
|
||||
where: {
|
||||
id: (variables as any)?.postId,
|
||||
},
|
||||
}),
|
||||
utils.post.findManyWithCursor.invalidate(),
|
||||
]);
|
||||
},
|
||||
});
|
||||
// 定义具体的mutation
|
||||
const read = api.visitor.create.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
...item,
|
||||
views: (item.views || 0) + 1,
|
||||
readed: true,
|
||||
}))
|
||||
);
|
||||
const like = api.visitor.create.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
...item,
|
||||
likes: (item.likes || 0) + 1,
|
||||
liked: true,
|
||||
}))
|
||||
);
|
||||
const unLike = api.visitor.deleteMany.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
...item,
|
||||
likes: item.likes - 1 || 0,
|
||||
liked: false,
|
||||
}))
|
||||
);
|
||||
|
||||
const hate = api.visitor.create.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
...item,
|
||||
hates: (item.hates || 0) + 1,
|
||||
hated: true,
|
||||
}))
|
||||
);
|
||||
const unHate = api.visitor.deleteMany.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
...item,
|
||||
hates: item.hates - 1 || 0,
|
||||
hated: false,
|
||||
}))
|
||||
);
|
||||
|
||||
const addStar = api.visitor.create.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
...item,
|
||||
star: true,
|
||||
}))
|
||||
);
|
||||
|
||||
const deleteStar = api.visitor.deleteMany.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
...item,
|
||||
star: false,
|
||||
}))
|
||||
);
|
||||
|
||||
const deleteMany = api.visitor.deleteMany.useMutation({
|
||||
onSuccess() {
|
||||
utils.visitor.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const createMany = api.visitor.createMany.useMutation({
|
||||
onSuccess() {
|
||||
utils.visitor.invalidate();
|
||||
utils.message.invalidate();
|
||||
utils.post.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
postParams,
|
||||
create,
|
||||
createMany,
|
||||
deleteMany,
|
||||
read,
|
||||
addStar,
|
||||
deleteStar,
|
||||
like,
|
||||
unLike,
|
||||
hate,
|
||||
unHate,
|
||||
};
|
||||
}
|
|
@ -3,8 +3,14 @@
|
|||
* 它使用 `mitt` 库进行事件管理,并为不同类型的对象定义了特定的事件处理器。
|
||||
*/
|
||||
|
||||
import mitt from 'mitt';
|
||||
import { DepartmentDto, ObjectType, RoleMapDto, StaffDto, TermDto, } from '@nice/common';
|
||||
import mitt from "mitt";
|
||||
import {
|
||||
DepartmentDto,
|
||||
ObjectType,
|
||||
RoleMapDto,
|
||||
StaffDto,
|
||||
TermDto,
|
||||
} from "@nice/common";
|
||||
|
||||
/**
|
||||
* 枚举类型,表示可以对数据执行的CRUD操作。
|
||||
|
@ -12,14 +18,14 @@ import { DepartmentDto, ObjectType, RoleMapDto, StaffDto, TermDto, } from '@nice
|
|||
export enum CrudOperation {
|
||||
CREATED, // 创建操作
|
||||
UPDATED, // 更新操作
|
||||
DELETED // 删除操作
|
||||
DELETED, // 删除操作
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型定义,表示事件总线可以发出的事件类型。
|
||||
*/
|
||||
type Events = {
|
||||
dataChanged: { type: ObjectType, operation: CrudOperation, data: any } // 数据变更事件
|
||||
dataChanged: { type: ObjectType; operation: CrudOperation; data: any }; // 数据变更事件
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -31,7 +37,10 @@ export const EventBus = mitt<Events>();
|
|||
* 类型定义,表示一个用于发出数据变更事件的函数。
|
||||
* @template T - 要发出的数据的类型。
|
||||
*/
|
||||
type EmitChangeFunction<T> = (data: Partial<T>, operation: CrudOperation) => void;
|
||||
type EmitChangeFunction<T> = (
|
||||
data: Partial<T>,
|
||||
operation: CrudOperation
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* 接口定义,表示不同类型对象的数据变更事件处理器。
|
||||
|
@ -40,7 +49,7 @@ interface EmitChangeHandlers {
|
|||
[ObjectType.STAFF]: EmitChangeFunction<StaffDto>; // 员工数据变更处理器
|
||||
[ObjectType.ROLE_MAP]: EmitChangeFunction<RoleMapDto>; // 角色映射数据变更处理器
|
||||
[ObjectType.DEPARTMENT]: EmitChangeFunction<DepartmentDto>; // 部门数据变更处理器
|
||||
[ObjectType.TERM]: EmitChangeFunction<TermDto> // 术语数据变更处理器
|
||||
[ObjectType.TERM]: EmitChangeFunction<TermDto>; // 术语数据变更处理器
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -54,14 +63,14 @@ const emitChangeHandlers: EmitChangeHandlers = {
|
|||
officer_id: data.officerId,
|
||||
phone_number: data.phoneNumber,
|
||||
dept_name: data.department?.name,
|
||||
domain_name: data.domain?.name
|
||||
domain_name: data.domain?.name,
|
||||
};
|
||||
|
||||
// 发出员工数据变更事件
|
||||
EventBus.emit("dataChanged", {
|
||||
type: ObjectType.STAFF,
|
||||
operation,
|
||||
data: [rowData]
|
||||
data: [rowData],
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -78,7 +87,7 @@ const emitChangeHandlers: EmitChangeHandlers = {
|
|||
EventBus.emit("dataChanged", {
|
||||
type: ObjectType.ROLE_MAP,
|
||||
operation,
|
||||
data: [rowData]
|
||||
data: [rowData],
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -94,7 +103,7 @@ const emitChangeHandlers: EmitChangeHandlers = {
|
|||
EventBus.emit("dataChanged", {
|
||||
type: ObjectType.DEPARTMENT,
|
||||
operation,
|
||||
data: [rowData]
|
||||
data: [rowData],
|
||||
});
|
||||
},
|
||||
[ObjectType.TERM]: (data, operation) => {
|
||||
|
@ -109,9 +118,9 @@ const emitChangeHandlers: EmitChangeHandlers = {
|
|||
EventBus.emit("dataChanged", {
|
||||
type: ObjectType.TERM,
|
||||
operation,
|
||||
data: [rowData]
|
||||
data: [rowData],
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -120,7 +129,11 @@ const emitChangeHandlers: EmitChangeHandlers = {
|
|||
* @param data - 与变更相关的数据。
|
||||
* @param operation - 执行的CRUD操作。
|
||||
*/
|
||||
export function emitDataChange(type: ObjectType, data: any, operation: CrudOperation) {
|
||||
export function emitDataChange(
|
||||
type: ObjectType,
|
||||
data: any,
|
||||
operation: CrudOperation
|
||||
) {
|
||||
// 获取指定对象类型的事件处理器
|
||||
const handler = emitChangeHandlers[type];
|
||||
if (handler) {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export * from "./useCheckBox"
|
||||
export * from "./useStack"
|
||||
export * from "./useTimeout"
|
||||
export * from "./useDevice"
|
||||
export * from "./useAwaitState"
|
|
@ -0,0 +1,32 @@
|
|||
import { getQueryKey } from "@trpc/react-query";
|
||||
import { api } from "../api/trpc"; // 修复导入路径
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ObjectType } from "@nice/common";
|
||||
import { CrudOperation, emitDataChange } from "../event"; // 修复导入路径
|
||||
export function useDevice() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = getQueryKey(api.device);
|
||||
|
||||
const create = api.device.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
emitDataChange(ObjectType.DEVICE, result as any, CrudOperation.CREATED);
|
||||
},
|
||||
});
|
||||
const update = api.device.update.useMutation({
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
emitDataChange(ObjectType.DEVICE, result as any, CrudOperation.UPDATED);
|
||||
},
|
||||
});
|
||||
const softDeleteByIds = api.device.softDeleteByIds.useMutation({
|
||||
onSuccess: (result, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
return {
|
||||
create,
|
||||
update,
|
||||
softDeleteByIds,
|
||||
};
|
||||
}
|
|
@ -44,7 +44,6 @@ model Term {
|
|||
createdBy String? @map("created_by")
|
||||
depts Department[] @relation("department_term")
|
||||
hasChildren Boolean? @default(false) @map("has_children")
|
||||
posts Post[] @relation("post_term")
|
||||
|
||||
@@index([name]) // 对name字段建立索引,以加快基于name的查找速度
|
||||
@@index([parentId]) // 对parentId字段建立索引,以加快基于parentId的查找速度
|
||||
|
@ -88,16 +87,7 @@ model Staff {
|
|||
deletedAt DateTime? @map("deleted_at")
|
||||
officerId String? @map("officer_id")
|
||||
|
||||
// watchedPost Post[] @relation("post_watch_staff")
|
||||
visits Visit[]
|
||||
posts Post[]
|
||||
|
||||
learningPosts Post[] @relation("post_student")
|
||||
sentMsgs Message[] @relation("message_sender")
|
||||
receivedMsgs Message[] @relation("message_receiver")
|
||||
registerToken String?
|
||||
enrollments Enrollment[]
|
||||
teachedPosts PostInstructor[]
|
||||
ownedResources Resource[]
|
||||
|
||||
@@index([officerId])
|
||||
|
@ -112,7 +102,7 @@ model Department {
|
|||
id String @id @default(cuid())
|
||||
name String
|
||||
order Float?
|
||||
posts Post[] @relation("post_dept")
|
||||
deptDevices Device[] @relation("DeptDevice")
|
||||
ancestors DeptAncestry[] @relation("DescendantToAncestor")
|
||||
descendants DeptAncestry[] @relation("AncestorToDescendant")
|
||||
parentId String? @map("parent_id")
|
||||
|
@ -188,156 +178,7 @@ model AppConfig {
|
|||
@@map("app_config")
|
||||
}
|
||||
|
||||
model Post {
|
||||
// 字符串类型字段
|
||||
id String @id @default(cuid()) // 帖子唯一标识,使用 cuid() 生成默认值
|
||||
type String? // Post类型,课程、章节、小节、讨论都用Post实现
|
||||
level String?
|
||||
state String?
|
||||
title String? // 帖子标题,可为空
|
||||
subTitle String?
|
||||
content String? // 帖子内容,可为空
|
||||
important Boolean? //是否重要/精选/突出
|
||||
domainId String? @map("domain_id")
|
||||
terms Term[] @relation("post_term")
|
||||
order Float? @default(0) @map("order")
|
||||
duration Int?
|
||||
rating Int? @default(0)
|
||||
students Staff[] @relation("post_student")
|
||||
depts Department[] @relation("post_dept")
|
||||
views Int @default(0) @map("views")
|
||||
hates Int @default(0) @map("hates")
|
||||
likes Int @default(0) @map("likes")
|
||||
// 索引
|
||||
// 日期时间类型字段
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
publishedAt DateTime? @map("published_at") // 发布时间
|
||||
updatedAt DateTime @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
|
||||
instructors PostInstructor[]
|
||||
// 关系类型字段
|
||||
authorId String? @map("author_id")
|
||||
author Staff? @relation(fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型
|
||||
enrollments Enrollment[] // 学生报名记录
|
||||
visits Visit[] // 访问记录,关联 Visit 模型
|
||||
parentId String? @map("parent_id")
|
||||
parent Post? @relation("PostChildren", fields: [parentId], references: [id]) // 父级帖子,关联 Post 模型
|
||||
children Post[] @relation("PostChildren") // 子级帖子列表,关联 Post 模型
|
||||
hasChildren Boolean? @default(false) @map("has_children")
|
||||
// 闭包表关系
|
||||
ancestors PostAncestry[] @relation("DescendantPosts")
|
||||
descendants PostAncestry[] @relation("AncestorPosts")
|
||||
resources Resource[] // 附件列表
|
||||
meta Json? // 封面url 视频url objectives具体的学习目标 rating评分Int
|
||||
|
||||
// 索引
|
||||
@@index([type, domainId])
|
||||
@@index([authorId, type])
|
||||
@@index([parentId, type])
|
||||
@@index([parentId, order])
|
||||
@@index([createdAt])
|
||||
@@index([updatedAt])
|
||||
@@index([type, publishedAt])
|
||||
@@index([state])
|
||||
@@index([level])
|
||||
@@index([views])
|
||||
@@index([important])
|
||||
@@map("post")
|
||||
}
|
||||
|
||||
model PostAncestry {
|
||||
id String @id @default(cuid())
|
||||
ancestorId String? @map("ancestor_id")
|
||||
descendantId String @map("descendant_id")
|
||||
relDepth Int @map("rel_depth")
|
||||
ancestor Post? @relation("AncestorPosts", fields: [ancestorId], references: [id])
|
||||
descendant Post @relation("DescendantPosts", fields: [descendantId], references: [id])
|
||||
|
||||
// 复合索引优化
|
||||
// 索引建议
|
||||
@@index([ancestorId]) // 针对祖先的查询
|
||||
@@index([descendantId]) // 针对后代的查询
|
||||
@@index([ancestorId, descendantId]) // 组合索引,用于查询特定的祖先-后代关系
|
||||
@@index([relDepth]) // 根据关系深度的查询
|
||||
@@map("post_ancestry")
|
||||
}
|
||||
|
||||
model Message {
|
||||
id String @id @default(cuid())
|
||||
url String?
|
||||
intent String?
|
||||
option Json?
|
||||
senderId String? @map("sender_id")
|
||||
type String?
|
||||
sender Staff? @relation(name: "message_sender", fields: [senderId], references: [id])
|
||||
title String?
|
||||
content String?
|
||||
receivers Staff[] @relation("message_receiver")
|
||||
visits Visit[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
|
||||
@@index([type, createdAt])
|
||||
@@map("message")
|
||||
}
|
||||
|
||||
model Visit {
|
||||
id String @id @default(cuid()) @map("id")
|
||||
type String?
|
||||
views Int @default(1) @map("views")
|
||||
// sourceIP String? @map("source_ip")
|
||||
// 关联关系
|
||||
visitorId String? @map("visitor_id")
|
||||
visitor Staff? @relation(fields: [visitorId], references: [id])
|
||||
postId String? @map("post_id")
|
||||
post Post? @relation(fields: [postId], references: [id])
|
||||
message Message? @relation(fields: [messageId], references: [id])
|
||||
messageId String? @map("message_id")
|
||||
lectureId String? @map("lecture_id") // 课时ID
|
||||
createdAt DateTime @default(now()) @map("created_at") // 创建时间
|
||||
updatedAt DateTime @updatedAt @map("updated_at") // 更新时间
|
||||
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
|
||||
meta Json?
|
||||
|
||||
@@index([postId, type, visitorId])
|
||||
@@index([messageId, type, visitorId])
|
||||
@@map("visit")
|
||||
}
|
||||
|
||||
model Enrollment {
|
||||
id String @id @default(cuid()) @map("id")
|
||||
status String @map("status")
|
||||
completionRate Float @default(0) @map("completion_rate")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
// 关联关系
|
||||
student Staff @relation(fields: [studentId], references: [id])
|
||||
studentId String @map("student_id")
|
||||
post Post @relation(fields: [postId], references: [id])
|
||||
postId String @map("post_id")
|
||||
|
||||
@@unique([studentId, postId])
|
||||
@@index([status])
|
||||
@@index([completedAt])
|
||||
@@map("enrollment")
|
||||
}
|
||||
|
||||
model PostInstructor {
|
||||
postId String @map("post_id")
|
||||
instructorId String @map("instructor_id")
|
||||
role String @map("role")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
order Float? @default(0) @map("order")
|
||||
|
||||
post Post @relation(fields: [postId], references: [id])
|
||||
instructor Staff @relation(fields: [instructorId], references: [id])
|
||||
|
||||
@@id([postId, instructorId])
|
||||
@@map("post_instructor")
|
||||
}
|
||||
|
||||
model Resource {
|
||||
id String @id @default(cuid()) @map("id")
|
||||
|
@ -358,9 +199,6 @@ model Resource {
|
|||
isPublic Boolean? @default(true) @map("is_public")
|
||||
owner Staff? @relation(fields: [ownerId], references: [id])
|
||||
ownerId String? @map("owner_id")
|
||||
post Post? @relation(fields: [postId], references: [id])
|
||||
postId String? @map("post_id")
|
||||
shareCode ShareCode?
|
||||
|
||||
// 索引
|
||||
@@index([type])
|
||||
|
@ -368,80 +206,33 @@ model Resource {
|
|||
@@map("resource")
|
||||
}
|
||||
|
||||
model Node {
|
||||
id String @id @default(cuid()) @map("id")
|
||||
title String @map("title")
|
||||
description String? @map("description")
|
||||
type String @map("type")
|
||||
style Json? @map("style")
|
||||
position Json? @map("position")
|
||||
data Json? @map("data")
|
||||
model Device {
|
||||
id String @id @default(cuid())
|
||||
deptId String? @map("dept_id")
|
||||
department Department? @relation("DeptDevice", fields: [deptId], references: [id])
|
||||
showname String? @map("showname")
|
||||
productType String? @map("product_type")
|
||||
serialNumber String? @map("serial_number")
|
||||
assetId String? @map("asset_id")
|
||||
deviceStatus String? @map("device_status")
|
||||
confidentialLabelId String? @map("confidential_label_id")
|
||||
confidentialityLevel String? @map("confidentiality_level")
|
||||
ipAddress String? @map("ip_address")
|
||||
macAddress String? @map("mac_address")
|
||||
diskSerialNumber String? @map("disk_serial_number")
|
||||
storageLocation String? @map("storage_location")
|
||||
responsiblePerson String? @map("responsible_person")
|
||||
notes String? @map("notes")
|
||||
systemType String? @map("system_type")
|
||||
deviceType String? @map("device_type")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// 关联关系
|
||||
sourceEdges NodeEdge[] @relation("source_node")
|
||||
targetEdges NodeEdge[] @relation("target_node")
|
||||
|
||||
@@map("node")
|
||||
}
|
||||
|
||||
model NodeEdge {
|
||||
id String @id @default(cuid()) @map("id")
|
||||
type String? @map("type")
|
||||
label String? @map("label")
|
||||
description String? @map("description")
|
||||
style Json? @map("style")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
source Node @relation("source_node", fields: [sourceId], references: [id], onDelete: Cascade)
|
||||
sourceId String @map("source_id")
|
||||
target Node @relation("target_node", fields: [targetId], references: [id], onDelete: Cascade)
|
||||
targetId String @map("target_id")
|
||||
|
||||
@@unique([sourceId, targetId, type])
|
||||
@@index([sourceId])
|
||||
@@index([targetId])
|
||||
@@map("node_edge")
|
||||
}
|
||||
|
||||
model Animal {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
age Int
|
||||
gender Boolean
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id])
|
||||
}
|
||||
|
||||
model Person {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
age Int
|
||||
gender Boolean
|
||||
animals Animal[]
|
||||
}
|
||||
|
||||
model ShareCode {
|
||||
id String @id @default(cuid())
|
||||
code String? @unique
|
||||
|
||||
fileId String? @unique
|
||||
fileName String? @map("file_name")
|
||||
resource Resource? @relation(fields: [fileId], references: [fileId])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
isUsed Boolean? @default(false)
|
||||
canUseTimes Int?
|
||||
|
||||
uploadIp String? @map("upload_ip")
|
||||
|
||||
@@index([code])
|
||||
@@index([fileId])
|
||||
@@index([expiresAt])
|
||||
@@map("share_code")
|
||||
@@index([deptId])
|
||||
@@index([systemType])
|
||||
@@index([deviceType])
|
||||
@@index([responsiblePerson])
|
||||
@@map("device")
|
||||
}
|
||||
|
||||
|
|
|
@ -60,6 +60,7 @@ export enum ObjectType {
|
|||
ENROLLMENT = "enrollment",
|
||||
RESOURCE = "resource",
|
||||
SHARE_CODE = "shareCode",
|
||||
DEVICE = "device",
|
||||
}
|
||||
export enum RolePerms {
|
||||
// Create Permissions 创建权限
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
export * from "./department";
|
||||
export * from "./message";
|
||||
export * from "./staff";
|
||||
export * from "./term";
|
||||
export * from "./post";
|
||||
export * from "./rbac";
|
||||
export * from "./select";
|
||||
export * from "./resource";
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import { Message, Staff } from "@prisma/client";
|
||||
|
||||
export type MessageDto = Message & {
|
||||
readed: boolean;
|
||||
receivers: Staff[];
|
||||
sender: Staff;
|
||||
};
|
|
@ -1,136 +0,0 @@
|
|||
import {
|
||||
Post,
|
||||
Department,
|
||||
Staff,
|
||||
Enrollment,
|
||||
Taxonomy,
|
||||
Term,
|
||||
} from "@prisma/client";
|
||||
import { StaffDto } from "./staff";
|
||||
import { TermDto } from "./term";
|
||||
import { ResourceDto } from "./resource";
|
||||
import { DepartmentDto } from "./department";
|
||||
|
||||
export type PostComment = {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
domainId: string;
|
||||
referenceId: string;
|
||||
resources: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
parentId: string;
|
||||
author: {
|
||||
id: string;
|
||||
showname: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
};
|
||||
};
|
||||
export type PostDto = Post & {
|
||||
readed: boolean;
|
||||
readedCount: number;
|
||||
author: StaffDto;
|
||||
limitedComments: PostComment[];
|
||||
commentsCount: number;
|
||||
perms?: {
|
||||
delete: boolean;
|
||||
// edit: boolean;
|
||||
};
|
||||
meta?: PostMeta;
|
||||
watchableDepts: Department[];
|
||||
watchableStaffs: Staff[];
|
||||
terms: TermDto[];
|
||||
depts: DepartmentDto[];
|
||||
studentIds?: string[];
|
||||
};
|
||||
export type PostMeta = {
|
||||
thumbnail?: string;
|
||||
views?: number;
|
||||
likes?: number;
|
||||
hates?: number;
|
||||
};
|
||||
export type LectureMeta = PostMeta & {
|
||||
type?: string;
|
||||
|
||||
videoUrl?: string;
|
||||
videoThumbnail?: string;
|
||||
videoIds?: string[];
|
||||
videoThumbnailIds?: string[];
|
||||
};
|
||||
|
||||
export type Lecture = Post & {
|
||||
courseId?: string;
|
||||
resources?: ResourceDto[];
|
||||
meta?: LectureMeta;
|
||||
};
|
||||
|
||||
export type SectionMeta = PostMeta & {
|
||||
objectives?: string[];
|
||||
};
|
||||
export type Section = Post & {
|
||||
courseId?: string;
|
||||
meta?: SectionMeta;
|
||||
};
|
||||
export type SectionDto = Section & {
|
||||
lectures: Lecture[];
|
||||
};
|
||||
export type CourseMeta = PostMeta & {
|
||||
objectives?: string[];
|
||||
};
|
||||
export type Course = PostDto & {
|
||||
meta?: CourseMeta;
|
||||
};
|
||||
export type CourseDto = Course & {
|
||||
enrollments?: Enrollment[];
|
||||
sections?: SectionDto[];
|
||||
terms: TermDto[];
|
||||
lectureCount?: number;
|
||||
depts: Department[];
|
||||
studentIds: string[];
|
||||
};
|
||||
|
||||
export type Summary = {
|
||||
id: string;
|
||||
text: string;
|
||||
parent: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
export type NodeObj = {
|
||||
topic: string;
|
||||
id: string;
|
||||
style?: {
|
||||
fontSize?: string;
|
||||
color?: string;
|
||||
background?: string;
|
||||
fontWeight?: string;
|
||||
};
|
||||
children?: NodeObj[];
|
||||
};
|
||||
export type Arrow = {
|
||||
id: string;
|
||||
label: string;
|
||||
from: string;
|
||||
to: string;
|
||||
delta1: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
delta2: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
};
|
||||
export type PathMeta = PostMeta & {
|
||||
nodeData: NodeObj;
|
||||
arrows?: Arrow[];
|
||||
summaries?: Summary[];
|
||||
direction?: number;
|
||||
};
|
||||
export type PathDto = PostDto & {
|
||||
meta: PathMeta;
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue