Compare commits

...

10 Commits

Author SHA1 Message Date
Your Name 7042f3ef48 add 2025-05-20 10:29:13 +08:00
Rao 1a4b112599 rht 2025-04-22 15:29:39 +08:00
Rao e5f3954e67 rht 2025-04-22 12:31:59 +08:00
Rao 6c26d26c2a rht 2025-04-21 22:52:10 +08:00
Rao b9426cd0ea Merge branch 'main' of http://113.45.67.59:3003/raohaotian/quick-file 2025-04-21 19:09:05 +08:00
Rao bb91a86bb8 rht 2025-04-21 19:09:04 +08:00
linfeng 348c6780ff Merge branch 'main' of http://113.45.67.59:3003/raohaotian/quick-file 2025-04-14 11:25:31 +08:00
linfeng 3d1470f742 123 2025-04-14 11:25:29 +08:00
raohaotian 240f122edb rht 2025-04-13 00:32:30 +08:00
raohaotian 946969b503 rht 2025-04-12 18:31:08 +08:00
106 changed files with 2922 additions and 7112 deletions

View File

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

View File

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

View File

@ -6,8 +6,13 @@ import { DepartmentController } from './department.controller';
import { DepartmentRowService } from './department.row.service';
@Module({
providers: [DepartmentService, DepartmentRouter, DepartmentRowService, TrpcService],
exports: [DepartmentService, DepartmentRouter],
controllers: [DepartmentController],
providers: [
DepartmentService,
DepartmentRouter,
DepartmentRowService,
TrpcService,
],
exports: [DepartmentService, DepartmentRouter],
controllers: [DepartmentController],
})
export class DepartmentModule { }
export class DepartmentModule {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
}
// }
})
}

View File

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

View File

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

View File

@ -54,12 +54,12 @@ 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],
);
if (differences.length > 0) {
await db.taxonomy.update({
where: { id: existingTaxonomy.id },

View File

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

View File

@ -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]
})

View File

@ -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() { }
/**
*

View File

@ -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],

View File

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

View File

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

View File

@ -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) {
// 错误处理...
}
}
}

View File

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

View File

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

View File

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

0
apps/web/public/logo.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 737 B

After

Width:  |  Height:  |  Size: 737 B

0
apps/web/public/vite.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 737 B

After

Width:  |  Height:  |  Size: 737 B

View File

@ -1,207 +1,130 @@
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();
const utils = api.useUtils();
const [form] = useForm();
const { token } = theme.useToken();
const { data: clientCount } = api.app_config.getClientCount.useQuery(
undefined,
{
refetchInterval: 3000,
refetchIntervalInBackground: true,
}
);
const [isFormChanged, setIsFormChanged] = useState(false);
const [loading, setLoading] = useState(false);
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) {
form.resetFields();
} else {
form.resetFields();
form.setFieldsValue(baseSetting);
}
setIsFormChanged(false);
}
function onSaveClick() {
if (form) form.submit();
}
async function onSubmit(values: BaseSetting) {
setLoading(true);
const appConfig = values?.appConfig || {};
try {
await update.mutateAsync({
where: {
slug: AppConfigSlug.BASE_SETTING,
},
data: {
meta: {
...baseSetting,
appConfig: {
...(baseSetting?.appConfig || {}),
...appConfig,
},
},
},
});
setIsFormChanged(false);
message.success("已保存");
} catch (err: any) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => {
if (baseSetting && form) {
form.setFieldsValue(baseSetting);
}
}, [baseSetting, form]);
return (
<div style={{ width: pageWidth }}>
<FixedHeader>
<div className="flex items-center gap-2">
{isFormChanged &&
hasSomePermissions(RolePerms.MANAGE_BASE_SETTING) && (
<>
<Button onClick={onResetClick}></Button>
<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",
const { update, baseSetting } = useAppConfig();
const utils = api.useUtils();
const [form] = useForm();
const { token } = theme.useToken();
const { data: clientCount } = api.app_config.getClientCount.useQuery(
undefined,
{
refetchInterval: 3000,
refetchIntervalInBackground: true,
}
);
const [isFormChanged, setIsFormChanged] = useState(false);
const [loading, setLoading] = useState(false);
const { user, hasSomePermissions } = useAuth();
const context = useContext(MainLayoutContext);
const pageWidth = context?.pageWidth;
}}>
<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>
}
</div>
</div>
);
function handleFieldsChange() {
setIsFormChanged(true);
}
function onResetClick() {
if (!form) return;
if (!baseSetting) {
form.resetFields();
} else {
form.resetFields();
form.setFieldsValue(baseSetting);
}
setIsFormChanged(false);
}
function onSaveClick() {
if (form) form.submit();
}
async function onSubmit(values: BaseSetting) {
setLoading(true);
const appConfig = values?.appConfig || {};
try {
await update.mutateAsync({
where: {
slug: AppConfigSlug.BASE_SETTING,
},
data: {
meta: {
...baseSetting,
appConfig: {
...(baseSetting?.appConfig || {}),
...appConfig,
},
},
},
});
setIsFormChanged(false);
message.success("已保存");
} catch (err: any) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => {
if (baseSetting && form) {
form.setFieldsValue(baseSetting);
}
}, [baseSetting, form]);
return (
<div style={{ width: pageWidth }}>
<FixedHeader>
<div className="flex items-center gap-2">
{isFormChanged &&
hasSomePermissions(RolePerms.MANAGE_BASE_SETTING) && (
<>
<Button onClick={onResetClick}></Button>
<Button loading={loading} type="primary" onClick={onSaveClick}>
</Button>
</>
)}
</div>
</FixedHeader>
<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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +0,0 @@
.container {
display: flex;
gap: 12px;
padding: 20px;
border-radius: 8px;
background-color: #f8f9fa;
}
.input {
font-size: 16px;
text-transform: uppercase;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,69 +1,59 @@
import {
CloudOutlined,
FileSearchOutlined,
HomeOutlined,
MailOutlined,
PhoneOutlined,
CloudOutlined,
FileSearchOutlined,
HomeOutlined,
MailOutlined,
PhoneOutlined,
} from "@ant-design/icons";
export function MainFooter() {
return (
<footer className="bg-gradient-to-b from-slate-800 to-slate-900 relative z-10 text-secondary-200">
<div className="container mx-auto px-4 py-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 开发组织信息 */}
<div className="text-center md:text-left space-y-2">
<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>
</div>
return (
<footer className="bg-gradient-to-b from-slate-800 to-slate-900 relative z-10 text-secondary-200">
<div className="container mx-auto px-4 py-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 开发组织信息 */}
<div className="text-center md:text-left space-y-2">
<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>
</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>
</div>
<div className="flex items-center justify-center space-x-2">
<MailOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">
ruanjian1@tx3l.nb.kj
</span>
</div>
</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>
</div>
<div className="flex items-center justify-center space-x-2">
<MailOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">
ruanjian1@tx3l.nb.kj
</span>
</div>
</div>
{/* 系统链接 */}
<div className="text-center md:text-right space-y-2">
<div className="flex items-center justify-center md:justify-end space-x-4">
<a
href="https://27.57.72.21"
className="text-gray-400 hover:text-white transition-colors"
title="访问门户网站">
<HomeOutlined className="text-lg" />
</a>
<a
href="https://27.57.72.14"
className="text-gray-400 hover:text-white transition-colors"
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>
);
{/* 系统链接 */}
<div className="text-center md:text-right space-y-2">
<div className="flex items-center justify-center md:justify-end space-x-4">
<a
href="https://27.57.72.21"
className="text-gray-400 hover:text-white transition-colors"
title="访问门户网站"
>
<HomeOutlined className="text-lg" />
</a>
<a
href="https://27.57.72.14"
className="text-gray-400 hover:text-white transition-colors"
title="访问烽火青云"
>
<CloudOutlined className="text-lg" />
</a>
</div>
</div>
</div>
</div>
</footer>
);
}

View File

@ -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 { id } = useParams();
const navigate = useNavigate();
const { searchValue, setSearchValue } = useMainContext();
const { isAuthenticated, user, logout } = useAuth();
const { id } = useParams();
const navigate = useNavigate();
const { searchValue, setSearchValue } = useMainContext();
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>
const handleLogout = () => {
logout();
navigate("/login");
};
{/* 右侧区域 - 可以灵活收缩 */}
<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 && (
<>
<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 />
) : (
<Button
type="primary"
size="large"
shape="round"
onClick={() => navigate("/login")}
icon={<UserOutlined />}>
</Button>
)}
</div>
</div>
</div>
);
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 h-16">
<div className="flex items-center">
<span className="text-xl font-bold text-primary"></span>
</div>
{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"
/>
<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 />}
>
</Button>
)}
</div>
);
}

View File

@ -3,19 +3,23 @@ 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">
<MainHeader />
<Content className=" flex-grow pt-16 bg-gray-50 ">
<Outlet />
</Content>
<MainFooter />
</div>
</MainProvider>
);
return (
<MainProvider>
<div className="min-h-screen bg-gray-100">
<MainHeader />
<div className="flex pt-16">
<NavigationMenu />
<Content className="flex-grow bg-gray-50">
<Outlet />
</Content>
</div>
<MainFooter />
</div>
</MainProvider>
);
}

View File

@ -1,109 +1,65 @@
import { PostType, Prisma } from "@nice/common";
import { Prisma, StaffDto } from "@nice/common";
import React, {
createContext,
ReactNode,
useContext,
useMemo,
useState,
createContext,
ReactNode,
useContext,
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>>;
searchValue?: string;
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);
interface MainProviderProps {
children: ReactNode;
children: ReactNode;
}
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]);
return (
<MainContext.Provider
value={{
searchValue,
setSearchValue,
selectedTerms,
setSelectedTerms,
searchCondition,
termsCondition,
searchMode,
setSearchMode,
showSearchMode,
setShowSearchMode,
}}>
{children}
</MainContext.Provider>
);
const [searchValue, setSearchValue] = useState<string>("");
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,
formValue,
setFormValue,
form,
visible,
setVisible,
editingRecord,
setEditingRecord,
}}
>
<Form
form={form}
onValuesChange={(changed, all) => setFormValue(all)}
initialValues={formValue}
>
{children}
</Form>
</MainContext.Provider>
);
}
export const useMainContext = () => {
const context = useContext(MainContext);
if (!context) {
throw new Error("useMainContext must be used within MainProvider");
}
return context;
const context = useContext(MainContext);
if (!context) {
throw new Error("useMainContext must be used within MainProvider");
}
return context;
};

View File

@ -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 = () => {
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const { pathname } = useLocation();
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 menuItems = useMemo(() => {
const baseItems = [
{ key: "home", path: "/", label: "首页" },
{ key: "path", path: "/path", label: "全部思维导图" },
{ key: "courses", path: "/courses", label: "所有课程" },
];
const NavigationMenu: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
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: "我学习的思维导图" },
];
}
}, [isAuthenticated]);
// 定义路径匹配规则
const getParentPath = (pathname: string): string[] => {
if (pathname.startsWith("/assessment/") || pathname.startsWith("/admin/")) {
return [pathname.split("/").slice(0, 2).join("/")];
}
const selectedKey = useMemo(() => {
const normalizePath = (path: string): string => path.replace(/\/$/, "");
return pathname === '/' ? "home" : menuItems.find((item) => normalizePath(pathname) === item.path)?.key || "";
}, [pathname]);
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({
top: 0,
behavior: "smooth",
});
}}>
{menuItems.map(({ key, label }) => (
<Menu.Item
key={key}
className="text-gray-600 hover:text-blue-600">
{label}
</Menu.Item>
))}
</Menu>
);
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 (
<div className="w-[200px] h-full bg-#fff">
<div
style={{
textDecoration: "none",
cursor: "pointer",
position: "sticky",
top: 0,
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;

View File

@ -5,22 +5,23 @@ import PostCard from "@web/src/components/models/post/PostCard";
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
export function PathListContainer() {
const { searchCondition, termsCondition } = useMainContext();
return (
<>
<PostList
renderItem={(post) => <PathCard post={post}></PathCard>}
params={{
pageSize: 12,
where: {
type: PostType.PATH,
...termsCondition,
...searchCondition,
deletedAt:null
},
}}
cols={4}></PostList>
</>
);
const { searchCondition, termsCondition } = useMainContext();
return (
<>
<PostList
renderItem={(post) => <PathCard post={post}></PathCard>}
params={{
pageSize: 12,
where: {
type: PostType.PATH,
...termsCondition,
...searchCondition,
deletedAt: null,
},
}}
cols={4}
></PostList>
</>
);
}
export default PathListContainer;

View File

View File

@ -9,7 +9,6 @@ const { Content } = Layout;
export default function AdminLayout() {
return (
<Layout className="min-h-screen">
<AdminSidebar routes={adminRoute.children || []} />
<Layout>
<Content>
<Outlet />

View 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}
/>
)
}

View File

@ -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";
@ -9,82 +8,84 @@ import DeptModal from "./dept-modal";
import DeptImportModal from "./dept-import-modal";
import FixedHeader from "../../layout/fix-header";
export const DeptEditorContext = createContext<{
parentId: string;
domainId: string;
modalOpen: boolean;
setParentId: React.Dispatch<React.SetStateAction<string>>;
setDomainId: React.Dispatch<React.SetStateAction<string>>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
parentId: string;
domainId: string;
modalOpen: boolean;
setParentId: React.Dispatch<React.SetStateAction<string>>;
setDomainId: React.Dispatch<React.SetStateAction<string>>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
editId: string;
setEditId: React.Dispatch<React.SetStateAction<string>>;
form: FormInstance<any>;
canManageDept: boolean;
importModalOpen: boolean;
setImportModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
editId: string;
setEditId: React.Dispatch<React.SetStateAction<string>>;
form: FormInstance<any>;
canManageDept: boolean;
importModalOpen: boolean;
setImportModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
}>(undefined);
export default function DeptEditor() {
const [parentId, setParentId] = useState<string>();
const [domainId, setDomainId] = useState<string>();
const [modalOpen, setModalOpen] = useState<boolean>(false);
const [importModalOpen, setImportModalOpen] = useState(false);
const { user, hasSomePermissions } = useAuth();
const [editId, setEditId] = useState<string>();
const [form] = useForm();
const canManageDept = useMemo(() => {
return hasSomePermissions(
RolePerms.MANAGE_ANY_DEPT,
RolePerms.MANAGE_DOM_DEPT
);
}, [user]);
const [parentId, setParentId] = useState<string>();
const [domainId, setDomainId] = useState<string>();
const [modalOpen, setModalOpen] = useState<boolean>(false);
const [importModalOpen, setImportModalOpen] = useState(false);
const { user, hasSomePermissions } = useAuth();
const [editId, setEditId] = useState<string>();
const [form] = useForm();
const canManageDept = useMemo(() => {
return hasSomePermissions(
RolePerms.MANAGE_ANY_DEPT,
RolePerms.MANAGE_DOM_DEPT
);
}, [user]);
return (
<DeptEditorContext.Provider
value={{
canManageDept,
parentId,
domainId,
return (
<DeptEditorContext.Provider
value={{
canManageDept,
parentId,
domainId,
modalOpen,
setParentId,
setDomainId,
modalOpen,
setParentId,
setDomainId,
setModalOpen,
form,
editId,
setEditId,
setModalOpen,
form,
editId,
setEditId,
setImportModalOpen,
importModalOpen,
}}>
<FixedHeader roomId="dept-editor">
<div className=" flex items-center gap-4 ">
{canManageDept && (
<>
<Button
ghost
type="primary"
onClick={() => {
setImportModalOpen(true);
}}>
</Button>
<Button
type="primary"
onClick={() => {
setModalOpen(true);
}}>
</Button>
</>
)}
</div>
</FixedHeader>
<DepartmentList></DepartmentList>
<DeptModal />
<DeptImportModal />
</DeptEditorContext.Provider>
);
setImportModalOpen,
importModalOpen,
}}
>
<FixedHeader roomId="dept-editor">
<div className=" flex items-center gap-4 ">
{canManageDept && (
<>
<Button
ghost
type="primary"
onClick={() => {
setImportModalOpen(true);
}}
>
</Button>
<Button
type="primary"
onClick={() => {
setModalOpen(true);
}}
>
</Button>
</>
)}
</div>
</FixedHeader>
<DepartmentList></DepartmentList>
<DeptModal />
<DeptImportModal />
</DeptEditorContext.Provider>
);
}

View File

View File

View File

View File

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

View File

@ -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;
@ -37,60 +30,35 @@ export type CustomRouteObject =
| CustomIndexRouteObject
| 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);

View File

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

View File

@ -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"

View File

@ -1,5 +0,0 @@
import { useEntity } from "./useEntity";
export function useMessage() {
return useEntity("message");
}

View File

@ -1,5 +1,5 @@
import { MutationResult, useEntity } from "./useEntity";
export function usePost() {
return useEntity("post");
}
// export function usePost() {
// return useEntity("post");
// }

View File

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

View File

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

View File

@ -3,23 +3,29 @@
* 使 `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操作
*/
export enum CrudOperation {
CREATED, // 创建操作
UPDATED, // 更新操作
DELETED // 删除操作
CREATED, // 创建操作
UPDATED, // 更新操作
DELETED, // 删除操作
}
/**
* 线
*/
type Events = {
dataChanged: { type: ObjectType, operation: CrudOperation, data: any } // 数据变更事件
dataChanged: { type: ObjectType; operation: CrudOperation; data: any }; // 数据变更事件
};
/**
@ -31,87 +37,90 @@ 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;
/**
*
*/
interface EmitChangeHandlers {
[ObjectType.STAFF]: EmitChangeFunction<StaffDto>; // 员工数据变更处理器
[ObjectType.ROLE_MAP]: EmitChangeFunction<RoleMapDto>; // 角色映射数据变更处理器
[ObjectType.DEPARTMENT]: EmitChangeFunction<DepartmentDto>; // 部门数据变更处理器
[ObjectType.TERM]: EmitChangeFunction<TermDto> // 术语数据变更处理器
[ObjectType.STAFF]: EmitChangeFunction<StaffDto>; // 员工数据变更处理器
[ObjectType.ROLE_MAP]: EmitChangeFunction<RoleMapDto>; // 角色映射数据变更处理器
[ObjectType.DEPARTMENT]: EmitChangeFunction<DepartmentDto>; // 部门数据变更处理器
[ObjectType.TERM]: EmitChangeFunction<TermDto>; // 术语数据变更处理器
}
/**
*
*/
const emitChangeHandlers: EmitChangeHandlers = {
[ObjectType.STAFF]: (data, operation) => {
// 转换员工数据,包含额外字段
const rowData = {
...data,
officer_id: data.officerId,
phone_number: data.phoneNumber,
dept_name: data.department?.name,
domain_name: data.domain?.name
};
[ObjectType.STAFF]: (data, operation) => {
// 转换员工数据,包含额外字段
const rowData = {
...data,
officer_id: data.officerId,
phone_number: data.phoneNumber,
dept_name: data.department?.name,
domain_name: data.domain?.name,
};
// 发出员工数据变更事件
EventBus.emit("dataChanged", {
type: ObjectType.STAFF,
operation,
data: [rowData]
});
},
// 发出员工数据变更事件
EventBus.emit("dataChanged", {
type: ObjectType.STAFF,
operation,
data: [rowData],
});
},
[ObjectType.ROLE_MAP]: (data, operation) => {
// 转换角色映射数据,包含额外字段
const rowData = {
staff_username: data.staff?.username,
staff_showname: data.staff?.showname,
staff_officer_id: data.staff?.officerId,
department_name: data.staff?.department?.name,
...data,
};
// 发出角色映射数据变更事件
EventBus.emit("dataChanged", {
type: ObjectType.ROLE_MAP,
operation,
data: [rowData]
});
},
[ObjectType.ROLE_MAP]: (data, operation) => {
// 转换角色映射数据,包含额外字段
const rowData = {
staff_username: data.staff?.username,
staff_showname: data.staff?.showname,
staff_officer_id: data.staff?.officerId,
department_name: data.staff?.department?.name,
...data,
};
// 发出角色映射数据变更事件
EventBus.emit("dataChanged", {
type: ObjectType.ROLE_MAP,
operation,
data: [rowData],
});
},
[ObjectType.DEPARTMENT]: (data, operation) => {
// 转换部门数据,包含额外字段
const rowData = {
is_domain: data.isDomain,
parent_id: data.parentId,
has_children: data.hasChildren,
...data,
};
// 发出部门数据变更事件
EventBus.emit("dataChanged", {
type: ObjectType.DEPARTMENT,
operation,
data: [rowData]
});
},
[ObjectType.TERM]: (data, operation) => {
// 转换术语数据,包含额外字段
const rowData = {
taxonomy_id: data.taxonomyId,
parent_id: data.parentId,
has_children: data.hasChildren,
...data,
};
// 发出术语数据变更事件
EventBus.emit("dataChanged", {
type: ObjectType.TERM,
operation,
data: [rowData]
});
}
[ObjectType.DEPARTMENT]: (data, operation) => {
// 转换部门数据,包含额外字段
const rowData = {
is_domain: data.isDomain,
parent_id: data.parentId,
has_children: data.hasChildren,
...data,
};
// 发出部门数据变更事件
EventBus.emit("dataChanged", {
type: ObjectType.DEPARTMENT,
operation,
data: [rowData],
});
},
[ObjectType.TERM]: (data, operation) => {
// 转换术语数据,包含额外字段
const rowData = {
taxonomy_id: data.taxonomyId,
parent_id: data.parentId,
has_children: data.hasChildren,
...data,
};
// 发出术语数据变更事件
EventBus.emit("dataChanged", {
type: ObjectType.TERM,
operation,
data: [rowData],
});
},
};
/**
@ -120,14 +129,18 @@ const emitChangeHandlers: EmitChangeHandlers = {
* @param data -
* @param operation - CRUD操作
*/
export function emitDataChange(type: ObjectType, data: any, operation: CrudOperation) {
// 获取指定对象类型的事件处理器
const handler = emitChangeHandlers[type];
if (handler) {
// 调用处理器发出数据变更事件
handler(data, operation);
} else {
// 如果未找到指定对象类型的事件处理器,打印警告
console.warn(`No emit handler for type: ${type}`);
}
export function emitDataChange(
type: ObjectType,
data: any,
operation: CrudOperation
) {
// 获取指定对象类型的事件处理器
const handler = emitChangeHandlers[type];
if (handler) {
// 调用处理器发出数据变更事件
handler(data, operation);
} else {
// 如果未找到指定对象类型的事件处理器,打印警告
console.warn(`No emit handler for type: ${type}`);
}
}

View File

@ -1,4 +1,5 @@
export * from "./useCheckBox"
export * from "./useStack"
export * from "./useTimeout"
export * from "./useDevice"
export * from "./useAwaitState"

View File

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

View File

@ -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")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
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")
deletedAt DateTime? @map("deleted_at")
// 关联关系
sourceEdges NodeEdge[] @relation("source_node")
targetEdges NodeEdge[] @relation("target_node")
@@map("node")
@@index([deptId])
@@index([systemType])
@@index([deviceType])
@@index([responsiblePerson])
@@map("device")
}
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")
}

View File

@ -60,6 +60,7 @@ export enum ObjectType {
ENROLLMENT = "enrollment",
RESOURCE = "resource",
SHARE_CODE = "shareCode",
DEVICE = "device",
}
export enum RolePerms {
// Create Permissions 创建权限

View File

@ -8,4 +8,4 @@ export * from "./db"
export * from "@prisma/client"
export * from "./upload"
export * from "./tool"
export * from "./models"
export * from "./models"

View File

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

View File

@ -1,7 +0,0 @@
import { Message, Staff } from "@prisma/client";
export type MessageDto = Message & {
readed: boolean;
receivers: Staff[];
sender: Staff;
};

Some files were not shown because too many files have changed in this diff Show More