Compare commits

...

4 Commits

Author SHA1 Message Date
Your Name 40ce147949 add 2025-06-26 08:19:49 +08:00
Your Name fe542c59e4 add 2025-06-21 21:57:20 +08:00
Your Name ab4ba55721 add 2025-06-19 21:48:08 +08:00
Your Name 51341068da add 2025-06-15 22:37:47 +08:00
86 changed files with 6705 additions and 518 deletions

View File

@ -43,9 +43,8 @@ export class AuthController {
authorization,
};
const authResult = await this.authService.validateFileRequest(
fileRequest,
);
const authResult =
await this.authService.validateFileRequest(fileRequest);
if (!authResult.isValid) {
// 使用枚举类型进行错误处理
switch (authResult.error) {

View File

@ -0,0 +1,21 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UploadedFile,
UseInterceptors,
Res,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { BookService } from './book.service';
import { Response } from 'express';
@Controller('book')
export class BookController {
constructor(private readonly bookService: BookService) {}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { BookService } from './book.service';
import { BookRouter } from './book.router';
import { TrpcService } from '@server/trpc/trpc.service';
import { BookController } from './book.controller';
@Module({
providers: [BookService, BookRouter, TrpcService],
exports: [BookService, BookRouter],
controllers: [BookController],
})
export class BookModule {}

View File

@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { BookService } from './book.service';
import { Prisma } from '@nice/common';
import { z, ZodType } from 'zod';
const BookUncheckedCreateInputSchema: ZodType<Prisma.BookUncheckedCreateInput> =
z.any();
const BookWhereInputSchema: ZodType<Prisma.BookWhereInput> = z.any();
const BookSelectSchema: ZodType<Prisma.BookSelect> = z.any();
const BookUpdateArgsSchema: ZodType<Prisma.BookUpdateArgs> = z.any();
const BookFindFirstArgsSchema: ZodType<Prisma.BookFindFirstArgs> = z.any();
const BookFindManyArgsSchema: ZodType<Prisma.BookFindManyArgs> = z.any();
@Injectable()
export class BookRouter {
constructor(
private readonly trpc: TrpcService,
private readonly bookService: BookService,
) {}
router = this.trpc.router({
create: this.trpc.procedure
.input(BookUncheckedCreateInputSchema)
.mutation(async ({ input }) => {
return this.bookService.create({ data: input });
}),
update: this.trpc.procedure
.input(BookUpdateArgsSchema)
.mutation(async ({ input }) => {
return this.bookService.update(input);
}),
findMany: this.trpc.procedure
.input(BookFindManyArgsSchema)
.query(async ({ input }) => {
return this.bookService.findMany(input);
}),
softDeleteByIds: this.trpc.procedure
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
return this.bookService.softDeleteByIds(input.ids);
}),
findFirst: this.trpc.procedure
.input(BookFindFirstArgsSchema)
.query(async ({ input }) => {
return this.bookService.findFirst(input);
}),
});
}

View File

@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { BaseService } from '../base/base.service';
import { db, ObjectType, Prisma } from '@nice/common';
import EventBus, { CrudOperation } from '@server/utils/event-bus';
@Injectable()
export class BookService extends BaseService<Prisma.BookDelegate> {
constructor() {
super(db, ObjectType.BOOK, false);
}
async create(args: Prisma.BookCreateArgs) {
const result = await super.create(args);
this.emitDataChanged(CrudOperation.CREATED, result);
return result;
}
async update(args: Prisma.BookUpdateArgs) {
const result = await super.update(args);
this.emitDataChanged(CrudOperation.UPDATED, result);
return result;
}
async findMany(args: Prisma.BookFindManyArgs) {
const result = await super.findMany(args);
return result;
}
async findFirst(args: Prisma.BookFindFirstArgs) {
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.BOOK,
operation,
data,
});
}
}

View File

@ -0,0 +1,21 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UploadedFile,
UseInterceptors,
Res,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { BorrowRecordService } from './borrowRecord.service';
import { Response } from 'express';
@Controller('borrowRecord')
export class BorrowRecordController {
constructor(private readonly borrowRecordService: BorrowRecordService) {}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { BorrowRecordService } from './borrowRecord.service';
import { BorrowRecordRouter } from './borrowRecord.router';
import { TrpcService } from '@server/trpc/trpc.service';
import { BorrowRecordController } from './borrowRecord.controller';
@Module({
providers: [BorrowRecordService, BorrowRecordRouter, TrpcService],
exports: [BorrowRecordService, BorrowRecordRouter],
controllers: [BorrowRecordController],
})
export class BorrowRecordModule {}

View File

@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { BorrowRecordService } from './borrowRecord.service';
import { Prisma } from '@nice/common';
import { z, ZodType } from 'zod';
const BorrowRecordUncheckedCreateInputSchema: ZodType<Prisma.BorrowRecordUncheckedCreateInput> =
z.any();
const BorrowRecordWhereInputSchema: ZodType<Prisma.BorrowRecordWhereInput> =
z.any();
const BorrowRecordSelectSchema: ZodType<Prisma.BorrowRecordSelect> = z.any();
const BorrowRecordUpdateArgsSchema: ZodType<Prisma.BorrowRecordUpdateArgs> =
z.any();
const BorrowRecordFindFirstArgsSchema: ZodType<Prisma.BorrowRecordFindFirstArgs> =
z.any();
const BorrowRecordFindManyArgsSchema: ZodType<Prisma.BorrowRecordFindManyArgs> =
z.any();
@Injectable()
export class BorrowRecordRouter {
constructor(
private readonly trpc: TrpcService,
private readonly borrowRecordService: BorrowRecordService,
) {}
router = this.trpc.router({
create: this.trpc.procedure
.input(BorrowRecordUncheckedCreateInputSchema)
.mutation(async ({ input }) => {
return this.borrowRecordService.create({ data: input });
}),
update: this.trpc.procedure
.input(BorrowRecordUpdateArgsSchema)
.mutation(async ({ input }) => {
return this.borrowRecordService.update(input);
}),
findMany: this.trpc.procedure
.input(BorrowRecordFindManyArgsSchema)
.query(async ({ input }) => {
return this.borrowRecordService.findMany(input);
}),
softDeleteByIds: this.trpc.procedure
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
return this.borrowRecordService.softDeleteByIds(input.ids);
}),
findFirst: this.trpc.procedure
.input(BorrowRecordFindFirstArgsSchema)
.query(async ({ input }) => {
return this.borrowRecordService.findFirst(input);
}),
});
}

View File

@ -0,0 +1,96 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { BaseService } from '../base/base.service';
import { db, ObjectType, Prisma } from '@nice/common';
import EventBus, { CrudOperation } from '@server/utils/event-bus';
@Injectable()
export class BorrowRecordService extends BaseService<Prisma.BorrowRecordDelegate> {
constructor() {
super(db, ObjectType.BORROWRECORD, false);
}
async create(args: Prisma.BorrowRecordCreateArgs) {
// 验证读者和图书是否属于同一个图书馆
await this.validateLibraryMatch(
args.data.readerId as string,
args.data.bookId as string,
);
const result = await super.create(args);
this.emitDataChanged(CrudOperation.CREATED, result);
return result;
}
async update(args: Prisma.BorrowRecordUpdateArgs) {
// 如果更新了读者或图书,需要验证
if (args.data.readerId || args.data.bookId) {
const record = await db.borrowRecord.findUnique({
where: { id: args.where.id as string },
select: { readerId: true, bookId: true },
});
const readerId = args.data.readerId
? typeof args.data.readerId === 'string'
? args.data.readerId
: record.readerId
: record.readerId;
const bookId = args.data.bookId
? typeof args.data.bookId === 'string'
? args.data.bookId
: record.bookId
: record.bookId;
await this.validateLibraryMatch(readerId, bookId);
}
const result = await super.update(args);
this.emitDataChanged(CrudOperation.UPDATED, result);
return result;
}
async findMany(args: Prisma.BorrowRecordFindManyArgs) {
const result = await super.findMany(args);
return result;
}
async findFirst(args: Prisma.BorrowRecordFindFirstArgs) {
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.BORROWRECORD,
operation,
data,
});
}
// 验证读者和图书是否属于同一个图书馆
private async validateLibraryMatch(readerId: string, bookId: string) {
const reader = await db.reader.findUnique({
where: { id: readerId },
select: { libraryId: true },
});
const book = await db.book.findUnique({
where: { id: bookId },
select: { libraryId: true },
});
if (!reader || !book) {
throw new BadRequestException('读者或图书不存在');
}
if (reader.libraryId !== book.libraryId) {
throw new BadRequestException('读者只能借阅所属图书馆的书籍');
}
}
}

View File

@ -0,0 +1,22 @@
// apps/server/src/models/comment/comment.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UploadedFile,
UseInterceptors,
Res,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CommentService } from './comment.service';
import { Response } from 'express';
@Controller('comment')
export class CommentController {
constructor(private readonly commentService: CommentService) {}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { CommentService } from './comment.service';
import { CommentRouter } from './comment.router';
import { TrpcService } from '@server/trpc/trpc.service';
import { DepartmentModule } from '../department/department.module';
import { CommentController } from './comment.controller';
@Module({
imports: [DepartmentModule],
providers: [CommentService, CommentRouter, TrpcService],
exports: [CommentService, CommentRouter],
controllers: [CommentController],
})
export class CommentModule {}

View File

@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { CommentService } from './comment.service';
import { Prisma } from '@nice/common';
import { z, ZodType } from 'zod';
const CommentUncheckedCreateInputSchema: ZodType<Prisma.CommentUncheckedCreateInput> =
z.any();
const CommentWhereInputSchema: ZodType<Prisma.CommentWhereInput> = z.any();
const CommentSelectSchema: ZodType<Prisma.CommentSelect> = z.any();
const CommentUpdateArgsSchema: ZodType<Prisma.CommentUpdateArgs> = z.any();
const CommentFindFirstArgsSchema: ZodType<Prisma.CommentFindFirstArgs> =
z.any();
const CommentFindManyArgsSchema: ZodType<Prisma.CommentFindManyArgs> = z.any();
@Injectable()
export class CommentRouter {
constructor(
private readonly trpc: TrpcService,
private readonly commentService: CommentService,
) {}
router = this.trpc.router({
create: this.trpc.procedure
.input(CommentUncheckedCreateInputSchema)
.mutation(async ({ input }) => {
console.log(input);
return this.commentService.create({ data: input });
}),
update: this.trpc.procedure
.input(CommentUpdateArgsSchema)
.mutation(async ({ input }) => {
return this.commentService.update(input);
}),
findMany: this.trpc.procedure
.input(CommentFindManyArgsSchema)
.query(async ({ input }) => {
return this.commentService.findMany(input);
}),
softDeleteByIds: this.trpc.procedure
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
return this.commentService.softDeleteByIds(input.ids);
}),
findFirst: this.trpc.procedure
.input(CommentFindFirstArgsSchema)
.query(async ({ input }) => {
return this.commentService.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 CommentService extends BaseService<Prisma.CommentDelegate> {
constructor() {
super(db, ObjectType.COMMENT, false);
}
async create(args: Prisma.CommentCreateArgs) {
const result = await super.create(args);
this.emitDataChanged(CrudOperation.CREATED, result);
return result;
}
async update(args: Prisma.CommentUpdateArgs) {
const result = await super.update(args);
this.emitDataChanged(CrudOperation.UPDATED, result);
return result;
}
async findMany(args: Prisma.CommentFindManyArgs) {
const result = await super.findMany(args);
return result;
}
async findFirst(args: Prisma.CommentFindFirstArgs) {
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.COMMENT,
operation,
data,
});
}
}

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

@ -0,0 +1,21 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UploadedFile,
UseInterceptors,
Res,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { LibraryService } from './library.service';
import { Response } from 'express';
@Controller('library')
export class LibraryController {
constructor(private readonly libraryService: LibraryService) {}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { LibraryService } from './library.service';
import { LibraryRouter } from './library.router';
import { TrpcService } from '@server/trpc/trpc.service';
import { LibraryController } from './library.controller';
@Module({
providers: [LibraryService, LibraryRouter, TrpcService],
exports: [LibraryService, LibraryRouter],
controllers: [LibraryController],
})
export class LibraryModule {}

View File

@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { LibraryService } from './library.service';
import { Prisma } from '@nice/common';
import { z, ZodType } from 'zod';
const LibraryUncheckedCreateInputSchema: ZodType<Prisma.LibraryUncheckedCreateInput> =
z.any();
const LibraryWhereInputSchema: ZodType<Prisma.LibraryWhereInput> = z.any();
const LibrarySelectSchema: ZodType<Prisma.LibrarySelect> = z.any();
const LibraryUpdateArgsSchema: ZodType<Prisma.LibraryUpdateArgs> = z.any();
const LibraryFindFirstArgsSchema: ZodType<Prisma.LibraryFindFirstArgs> =
z.any();
const LibraryFindManyArgsSchema: ZodType<Prisma.LibraryFindManyArgs> = z.any();
@Injectable()
export class LibraryRouter {
constructor(
private readonly trpc: TrpcService,
private readonly libraryService: LibraryService,
) {}
router = this.trpc.router({
create: this.trpc.procedure
.input(LibraryUncheckedCreateInputSchema)
.mutation(async ({ input }) => {
return this.libraryService.create({ data: input });
}),
update: this.trpc.procedure
.input(LibraryUpdateArgsSchema)
.mutation(async ({ input }) => {
return this.libraryService.update(input);
}),
findMany: this.trpc.procedure
.input(LibraryFindManyArgsSchema)
.query(async ({ input }) => {
return this.libraryService.findMany(input);
}),
softDeleteByIds: this.trpc.procedure
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
return this.libraryService.softDeleteByIds(input.ids);
}),
findFirst: this.trpc.procedure
.input(LibraryFindFirstArgsSchema)
.query(async ({ input }) => {
return this.libraryService.findFirst(input);
}),
});
}

View File

@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { BaseService } from '../base/base.service';
import { db, ObjectType, Prisma } from '@nice/common';
import EventBus, { CrudOperation } from '@server/utils/event-bus';
@Injectable()
export class LibraryService extends BaseService<Prisma.LibraryDelegate> {
constructor() {
super(db, ObjectType.LIBRARY, false);
}
async create(args: Prisma.LibraryCreateArgs) {
const result = await super.create(args);
this.emitDataChanged(CrudOperation.CREATED, result);
return result;
}
async update(args: Prisma.LibraryUpdateArgs) {
const result = await super.update(args);
this.emitDataChanged(CrudOperation.UPDATED, result);
return result;
}
async findMany(args: Prisma.LibraryFindManyArgs) {
const result = await super.findMany(args);
return result;
}
async findFirst(args: Prisma.LibraryFindFirstArgs) {
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.LIBRARY,
operation,
data,
});
}
}

View File

@ -0,0 +1,22 @@
// apps/server/src/models/reader/reader.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UploadedFile,
UseInterceptors,
Res,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ReaderService } from './reader.service';
import { Response } from 'express';
@Controller('reader')
export class ReaderController {
constructor(private readonly readerService: ReaderService) {}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ReaderService } from './reader.service';
import { ReaderRouter } from './reader.router';
import { TrpcService } from '@server/trpc/trpc.service';
import { DepartmentModule } from '../department/department.module';
import { ReaderController } from './reader.controller';
@Module({
imports: [DepartmentModule],
providers: [ReaderService, ReaderRouter, TrpcService],
exports: [ReaderService, ReaderRouter],
controllers: [ReaderController],
})
export class ReaderModule {}

View File

@ -0,0 +1,85 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { ReaderService } from './reader.service';
import { Prisma } from '@nice/common';
import { z, ZodType } from 'zod';
const ReaderUncheckedCreateInputSchema: ZodType<Prisma.ReaderUncheckedCreateInput> =
z.any();
const ReaderWhereInputSchema: ZodType<Prisma.ReaderWhereInput> = z.any();
const ReaderSelectSchema: ZodType<Prisma.ReaderSelect> = z.any();
const ReaderUpdateArgsSchema: ZodType<Prisma.ReaderUpdateArgs> = z.any();
const ReaderFindFirstArgsSchema: ZodType<Prisma.ReaderFindFirstArgs> = z.any();
const ReaderFindManyArgsSchema: ZodType<Prisma.ReaderFindManyArgs> = z.any();
@Injectable()
export class ReaderRouter {
constructor(
private readonly trpc: TrpcService,
private readonly readerService: ReaderService,
// private readonly termService: TermService, // 添加 TermService
) {}
router = this.trpc.router({
create: this.trpc.procedure
.input(ReaderUncheckedCreateInputSchema)
.mutation(async ({ input }) => {
console.log(input);
return this.readerService.create({ data: input });
}),
update: this.trpc.procedure
.input(ReaderUpdateArgsSchema)
.mutation(async ({ input }) => {
return this.readerService.update(input);
}),
findMany: this.trpc.procedure
.input(ReaderFindManyArgsSchema)
.query(async ({ input }) => {
return this.readerService.findMany(input);
}),
softDeleteByIds: this.trpc.procedure
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
return this.readerService.softDeleteByIds(input.ids);
}),
findFirst: this.trpc.procedure
.input(ReaderFindFirstArgsSchema)
.query(async ({ input }) => {
return this.readerService.findFirst(input);
}),
// 获取读者分类
getReaderTypes: this.trpc.procedure.query(async () => {
return this.readerService.getReaderTypes();
}),
// 为读者分配分类
assignReaderType: this.trpc.procedure
.input(
z.object({
readerId: z.string(),
termId: z.string(),
}),
)
.mutation(async ({ input }) => {
return this.readerService.assignReaderType(
input.readerId,
input.termId,
);
}),
// 移除读者分类
removeReaderType: this.trpc.procedure
.input(
z.object({
readerId: z.string(),
termId: z.string(),
}),
)
.mutation(async ({ input }) => {
return this.readerService.removeReaderType(
input.readerId,
input.termId,
);
}),
});
}

View File

@ -0,0 +1,83 @@
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 ReaderService extends BaseService<Prisma.ReaderDelegate> {
constructor() {
super(db, ObjectType.READER, false);
}
async create(args: Prisma.ReaderCreateArgs) {
const result = await super.create(args);
this.emitDataChanged(CrudOperation.CREATED, result);
return result;
}
async update(args: Prisma.ReaderUpdateArgs) {
const result = await super.update(args);
this.emitDataChanged(CrudOperation.UPDATED, result);
return result;
}
async findMany(args: Prisma.ReaderFindManyArgs) {
const result = await super.findMany(args);
return result;
}
async findFirst(args: Prisma.ReaderFindFirstArgs) {
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.READER,
operation,
data,
});
}
// 获取读者分类
async getReaderTypes() {
const taxonomy = await db.taxonomy.findFirst({
where: { slug: 'reader_type' },
include: {
terms: {
where: { deletedAt: null },
orderBy: { order: 'asc' },
},
},
});
return taxonomy?.terms || [];
}
// 为读者分配分类
async assignReaderType(readerId: string, termId: string) {
return this.update({
where: { id: readerId },
data: {
terms: {
connect: { id: termId },
},
},
});
}
// 移除读者分类
async removeReaderType(readerId: string, termId: string) {
return this.update({
where: { id: readerId },
data: {
terms: {
disconnect: { id: termId },
},
},
});
}
}

View File

@ -0,0 +1,21 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UploadedFile,
UseInterceptors,
Res,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { UserService } from './user.service';
import { Response } from 'express';
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserRouter } from './user.router';
import { TrpcService } from '@server/trpc/trpc.service';
import { UserController } from './user.controller';
@Module({
providers: [UserService, UserRouter, TrpcService],
exports: [UserService, UserRouter],
controllers: [UserController],
})
export class UserModule {}

View File

@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { UserService } from './user.service';
import { Prisma } from '@nice/common';
import { z, ZodType } from 'zod';
const UserUncheckedCreateInputSchema: ZodType<Prisma.UserUncheckedCreateInput> =
z.any();
const UserWhereInputSchema: ZodType<Prisma.UserWhereInput> = z.any();
const UserSelectSchema: ZodType<Prisma.UserSelect> = z.any();
const UserUpdateArgsSchema: ZodType<Prisma.UserUpdateArgs> = z.any();
const UserFindFirstArgsSchema: ZodType<Prisma.UserFindFirstArgs> = z.any();
const UserFindManyArgsSchema: ZodType<Prisma.UserFindManyArgs> = z.any();
@Injectable()
export class UserRouter {
constructor(
private readonly trpc: TrpcService,
private readonly userService: UserService,
) {}
router = this.trpc.router({
create: this.trpc.procedure
.input(UserUncheckedCreateInputSchema)
.mutation(async ({ input }) => {
return this.userService.create({ data: input });
}),
update: this.trpc.procedure
.input(UserUpdateArgsSchema)
.mutation(async ({ input }) => {
return this.userService.update(input);
}),
findMany: this.trpc.procedure
.input(UserFindManyArgsSchema)
.query(async ({ input }) => {
return this.userService.findMany(input);
}),
softDeleteByIds: this.trpc.procedure
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
return this.userService.softDeleteByIds(input.ids);
}),
findFirst: this.trpc.procedure
.input(UserFindFirstArgsSchema)
.query(async ({ input }) => {
return this.userService.findFirst(input);
}),
});
}

View File

@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { BaseService } from '../base/base.service';
import { db, ObjectType, Prisma } from '@nice/common';
import EventBus, { CrudOperation } from '@server/utils/event-bus';
@Injectable()
export class UserService extends BaseService<Prisma.UserDelegate> {
constructor() {
super(db, ObjectType.USER, false);
}
async create(args: Prisma.UserCreateArgs) {
const result = await super.create(args);
this.emitDataChanged(CrudOperation.CREATED, result);
return result;
}
async update(args: Prisma.UserUpdateArgs) {
const result = await super.update(args);
this.emitDataChanged(CrudOperation.UPDATED, result);
return result;
}
async findMany(args: Prisma.UserFindManyArgs) {
const result = await super.findMany(args);
return result;
}
async findFirst(args: Prisma.UserFindFirstArgs) {
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.USER,
operation,
data,
});
}
}

View File

@ -1,3 +1,4 @@
import { Reader } from './../../../../../node_modules/.prisma/client/index.d';
import { Injectable, Logger } from '@nestjs/common';
import { DepartmentService } from '@server/models/department/department.service';
import { AppConfigService } from '@server/models/app-config/app-config.service';
@ -54,11 +55,58 @@ export class GenDevService {
//await this.generateCourses(8);
await this.generateTrainContent(2, 6);
await this.generateTrainSituations();
await this.initializeReaderType();
} catch (err) {
this.logger.error(err);
}
EventBus.emit('genDataEvent', { type: 'end' });
}
public async initializeReaderType() {
this.logger.log('初始化读者分类');
// 查找是否存在系统类型分类
let readerTypeTaxonomy = await db.taxonomy.findFirst({
where: { slug: 'reader_type' }, // 通过 slug 查找
});
// 如果不存在,则创建新的分类
if (!readerTypeTaxonomy) {
readerTypeTaxonomy = await db.taxonomy.create({
data: {
name: '读者类型', // 分类名称
slug: 'reader_type', // 唯一标识符
objectType: ['reader'], // 关联对象类型为 device
},
});
}
// 2. 创建具体的读者分类
const readerTypes = [
{ name: '长借读者', description: '可长期借阅图书的读者' },
{ name: '短借读者', description: '只能短期借阅图书的读者' },
];
// 检查并创建分类
for (const type of readerTypes) {
const existingTerm = await db.term.findFirst({
where: {
name: type.name,
taxonomyId: readerTypeTaxonomy.id,
},
});
if (!existingTerm) {
await db.term.create({
data: {
name: type.name,
description: type.description,
taxonomyId: readerTypeTaxonomy.id,
},
});
}
}
}
private async calculateCounts() {
this.counts = await getCounts();
Object.entries(this.counts).forEach(([key, value]) => {

View File

@ -13,6 +13,8 @@ export interface DevDataCounts {
termCount: number;
courseCount: number;
}
//查询数据库并返回各种实体的当前计数
export async function getCounts(): Promise<DevDataCounts> {
const counts = {
deptCount: await db.department.count(),
@ -27,9 +29,13 @@ export async function getCounts(): Promise<DevDataCounts> {
};
return counts;
}
//将字符串的首字母大写
export function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
//这个函数生成指定数量的随机图片链接
export function getRandomImageLinks(count: number = 5): string[] {
const baseUrl = 'https://picsum.photos/200/300?random=';
const imageLinks: string[] = [];

View File

@ -32,6 +32,8 @@ export class ReminderService {
* @param totalDays
* @returns
*/
//从开始日期距离截止日期还有一半的天数时提醒一次然后每1/2的1/2的天数提醒一次直到距离截止日期还有一天提醒一次
generateReminderTimes(totalDays: number): number[] {
// 如果总天数小于3天则不需要提醒
if (totalDays < 3) return [];

View File

@ -1,3 +1,4 @@
import { Library } from './../../../../node_modules/.prisma/client/index.d';
import { Logger, Module } from '@nestjs/common';
import { TrpcService } from './trpc.service';
import { TrpcRouter } from './trpc.router';
@ -19,6 +20,14 @@ import { ResourceModule } from '@server/models/resource/resource.module';
import { TrainSituationModule } from '@server/models/train-situation/trainSituation.module';
import { DailyTrainModule } from '@server/models/daily-train/dailyTrain.module';
import { SystemLogModule } from '@server/models/sys-logs/systemLog.module';
import { LibraryModule } from '@server/models/library/library.module';
import { DeviceModule } from '@server/models/device/device.module';
import { ReaderModule } from '@server/models/reader/reader.module';
import { BookModule } from '@server/models/book/book.module';
import { BorrowRecordModule } from '@server/models/borrowRecord/borrowRecord.module';
import { CommentModule } from '@server/models/comment/comment.module';
import { UserModule } from '@server/models/user/user.module';
@Module({
imports: [
AuthModule,
@ -39,6 +48,13 @@ import { SystemLogModule } from '@server/models/sys-logs/systemLog.module';
TrainSituationModule,
DailyTrainModule,
SystemLogModule,
LibraryModule,
DeviceModule,
ReaderModule,
BookModule,
BorrowRecordModule,
CommentModule,
UserModule,
],
controllers: [],
providers: [TrpcService, TrpcRouter, Logger],

View File

@ -18,7 +18,13 @@ import { TrainContentRouter } from '@server/models/train-content/trainContent.ro
import { TrainSituationRouter } from '@server/models/train-situation/trainSituation.router';
import { DailyTrainRouter } from '@server/models/daily-train/dailyTrain.router';
import { SystemLogRouter } from '@server/models/sys-logs/systemLog.router';
import { DeviceRouter } from '@server/models/device/device.router';
import { LibraryRouter } from '@server/models/library/library.router';
import { ReaderRouter } from '@server/models/reader/reader.router';
import { BookRouter } from '@server/models/book/book.router';
import { BorrowRecordRouter } from '@server/models/borrowRecord/borrowRecord.router';
import { CommentRouter } from '@server/models/comment/comment.router';
import { UserRouter } from '@server/models/user/user.router';
@Injectable()
export class TrpcRouter {
logger = new Logger(TrpcRouter.name);
@ -40,6 +46,13 @@ export class TrpcRouter {
private readonly trainSituation: TrainSituationRouter,
private readonly dailyTrain: DailyTrainRouter,
private readonly systemLogRouter: SystemLogRouter,
private readonly deviceRouter: DeviceRouter,
private readonly libraryRouter: LibraryRouter,
private readonly readerRouter: ReaderRouter,
private readonly bookRouter: BookRouter,
private readonly borrowRecordRouter: BorrowRecordRouter,
private readonly commentRouter: CommentRouter,
private readonly userRouter: UserRouter,
) {}
getRouter() {
return;
@ -61,6 +74,13 @@ export class TrpcRouter {
trainSituation: this.trainSituation.router,
dailyTrain: this.dailyTrain.router,
systemLog: this.systemLogRouter.router,
device: this.deviceRouter.router,
library: this.libraryRouter.router,
reader: this.readerRouter.router,
book: this.bookRouter.router,
borrowRecord: this.borrowRecordRouter.router,
comment: this.commentRouter.router,
user: this.userRouter.router,
});
wss: WebSocketServer = undefined;

View File

@ -18,9 +18,8 @@ export class TrpcService {
ip: string;
}> {
const token = opts.req.headers.authorization?.split(' ')[1];
const staff = await UserProfileService.instance.getUserProfileByToken(
token,
);
const staff =
await UserProfileService.instance.getUserProfileByToken(token);
const ip = getClientIp(opts.req);
return {
staff: staff.staff,

271
apps/web/src/app/logintest.tsx Executable file
View File

@ -0,0 +1,271 @@
import React, { useState, useRef, useEffect } from "react";
import { Form, Input, Button, message, Row, Col } from "antd";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../providers/auth-provider";
import DepartmentSelect from "../components/models/department/department-select";
import SineWave from "../components/animation/sine-wave";
const LoginPage: React.FC = () => {
const [showLogin, setShowLogin] = useState(true);
const [registerLoading, setRegisterLoading] = useState(false);
const { login, isAuthenticated, signup } = useAuth();
const loginFormRef = useRef<any>(null);
const registerFormRef = useRef<any>(null);
const location = useLocation();
const navigate = useNavigate();
const onFinishLogin = async (values: any) => {
try {
const { username, password } = values;
await login(username, password);
} catch (err: any) {
message.error(err?.response?.data?.message || "账号或密码错误!");
console.error(err);
}
};
const onFinishRegister = async (values: any) => {
setRegisterLoading(true);
const { username, password, deptId, officerId, showname } = values;
try {
await signup({ username, password, deptId, officerId, showname });
message.success("注册成功!");
setShowLogin(true);
// loginFormRef.current.submit();
} catch (err: any) {
message.error(err?.response?.data?.message);
} finally {
setRegisterLoading(false);
}
};
useEffect(() => {
if (isAuthenticated) {
const params = new URLSearchParams(location.search);
const redirectUrl = params.get("redirect_url") || "/";
navigate(redirectUrl, { replace: true });
}
}, [isAuthenticated, location]);
return (
<div
className="flex justify-center items-center h-screen w-full bg-gray-200"
style={
{
// backgroundImage: `url(${backgroundUrl})`,
// backgroundSize: "cover",
}
}>
<div
className="flex items-center transition-all hover:bg-white overflow-hidden border-2 border-white bg-gray-50 shadow-elegant rounded-xl "
style={{ width: 800, height: 600 }}>
<div
className={`transition-all h-full flex-1 text-white p-10 flex items-center justify-center bg-primary`}>
{showLogin ? (
<div className="flex flex-col">
<SineWave width={300} height={200} />
<div className="text-2xl my-4"></div>
<div className="my-4 font-thin text-sm">
</div>
<div
onClick={() => setShowLogin(false)}
className="hover:translate-y-1 my-8 p-2 text-center rounded-xl border-white border hover:bg-white hover:text-primary hover:shadow-xl hover:cursor-pointer transition-all">
</div>
</div>
) : (
<div className="flex flex-col">
<div className="text-2xl my-4"></div>
<div className="my-4 font-thin text-sm">
</div>
<div
onClick={() => setShowLogin(true)}
className="hover:translate-y-1 my-8 rounded-xl text-center border-white border p-2 hover:bg-white hover:text-primary hover:shadow-xl hover:cursor-pointer transition-all">
</div>
<SineWave width={300} height={200} />
</div>
)}
</div>
<div className="flex-1 py-10 px-10">
{showLogin ? (
<>
<div className="text-center text-2xl text-primary select-none">
<span className="px-2"></span>
</div>
<Form
ref={loginFormRef}
onFinish={onFinishLogin}
layout="vertical"
requiredMark="optional"
size="large">
<Form.Item
name="username"
label="账号"
rules={[
{
required: true,
message: "请输入账号",
},
]}>
<Input />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[
{
required: true,
message: "请输入密码",
},
]}>
<Input.Password />
</Form.Item>
<div className="flex items-center justify-center">
<Button type="primary" htmlType="submit">
</Button>
</div>
</Form>
</>
) : (
<div>
<div className="text-center text-2xl text-primary">
</div>
<Form
requiredMark="optional"
ref={registerFormRef}
onFinish={onFinishRegister}
layout="vertical"
size="large">
<Form.Item
name="deptId"
label="所属单位"
rules={[
{
required: true,
message: "请选择单位",
},
]}>
<DepartmentSelect
domain={true}></DepartmentSelect>
</Form.Item>
<Row gutter={8}>
<Col span={12}>
<Form.Item
name="username"
label="账号"
rules={[
{
required: true,
message: "请输入账号",
},
{
min: 2,
max: 15,
message:
"账号长度为 2 到 15 个字符",
},
]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="showname"
label="姓名"
rules={[
{
required: true,
message: "请输入姓名",
},
{
min: 2,
max: 15,
message:
"姓名长度为 2 到 15 个字符",
},
]}>
<Input />
</Form.Item>
</Col>
</Row>
<Form.Item
name="officerId"
label="证件号"
rules={[
{
required: true,
pattern: /^\d{5,12}$/,
message:
"请输入正确的证件号(数字格式)",
},
]}>
<Input />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[
{
required: true,
message: "请输入密码",
},
{
min: 6,
message: "密码长度不能小于 6 位",
},
]}>
<Input.Password />
</Form.Item>
<Form.Item
name="repeatPass"
label="确认密码"
dependencies={["password"]}
hasFeedback
rules={[
{
required: true,
message: "请再次输入密码",
},
({ getFieldValue }) => ({
validator(_, value) {
if (
!value ||
getFieldValue(
"password"
) === value
) {
return Promise.resolve();
}
return Promise.reject(
new Error(
"两次输入的密码不一致"
)
);
},
}),
]}>
<Input.Password />
</Form.Item>
<div className="flex items-center justify-center">
<Button
loading={registerLoading}
type="primary"
htmlType="submit">
</Button>
</div>
</Form>
</div>
)}
</div>
</div>
</div>
);
};
export default LoginPage;

View File

@ -20,17 +20,17 @@ const TestPage: React.FC = () => {
page: currentPage,
pageSize: pageSize,
where: {
deletedAt: null,
OR: searchText ? [
{ username: { contains: searchText } },
{ showname: { contains: searchText } }
] : undefined
},
select: {
id: true,
username: true,
showname: true,
absent: true,
id: true,
username: true,
showname: true,
absent: true,
}
});

View File

@ -0,0 +1,153 @@
import { api } from '@nice/client';
import { message, Form, Modal } from 'antd';
import React, { useState, useEffect } from 'react';
import { SearchBar } from './components/SearchBar';
import { BookTable } from './components/BookTable';
import { BookModal } from './components/BookModal';
import { Book, Library } from './components/types';
export const BookPage = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editingBook, setEditingBook] = useState<Book | undefined>();
const [searchText, setSearchText] = useState('');
const [form] = Form.useForm();
// 查询数据
const { data, isLoading, refetch, isError, error } = api.book.findMany.useQuery({
where: {
deletedAt: null,
OR: searchText ? [
{ bookName: { contains: searchText } },
{ isbn: { contains: searchText } },
{ author: { contains: searchText } },
{ library: { name: { contains: searchText } } },
{ terms: { some:{ name: { contains: searchText } } }},
] : undefined
},
include: {
library: true,
terms:true,
},
});
// 独立查询所有图书馆数据
const { data: libraryList, isLoading: librarysLoading } = api.library.findMany.useQuery({
where: {
deletedAt: null,
},
});
if (isError && error) {
message.error('获取数据失败:' + error.message);
}
// 创建数据
const createMutation = api.book.create.useMutation({
onSuccess: () => {
message.success('创建成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
message.error('创建失败:' + error.message);
}
});
// 更新数据
const updateMutation = api.book.update.useMutation({
onSuccess: () => {
message.success('更新成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
message.error('更新失败:' + error.message);
}
});
// 删除数据
const deleteMutation = api.book.softDeleteByIds.useMutation({
onSuccess: () => {
message.success('删除成功');
refetch();
},
onError: (error) => {
message.error('删除失败:' + error.message);
}
});
// 处理搜索
const handleSearch = () => {
refetch();
};
// 处理添加
const handleAdd = () => {
setEditingBook(undefined);
setModalOpen(true);
};
// 处理表单提交
const handleModalOk = (values: any) => {
if (editingBook) {
updateMutation.mutate({
where: { id: editingBook.id },
data: {
bookName: values.bookName,
isbn: values.isbn,
author: values.author,
libraryId: values.libraryId,
// 更新terms关联
terms: values.termIds ? {
set: values.termIds.map((id: string) => ({ id }))
} : undefined
}
});
} else {
createMutation.mutate({
bookName: values.bookName,
isbn: values.isbn,
author: values.author,
libraryId: values.libraryId,
// 更新terms关联
terms: values.termIds ? {
connect: values.termIds.map((id: string) => ({ id }))
} : undefined
});
} console.log(values);
};
return (
<div className="p-6">
<SearchBar
searchText={searchText}
onSearchTextChange={setSearchText}
onSearch={handleSearch}
onAdd={handleAdd}
onImportSuccess={refetch}
/>
<BookTable
data={(data || []) as Book[]}
loading={isLoading}
onEdit={(book) => {
setEditingBook(book);
setModalOpen(true);
}}
onDelete={(ids) => deleteMutation.mutate({ ids })}
/>
<BookModal
open={modalOpen}
editingBook={editingBook}
libraryList={(libraryList || []) as Library[]} // 传递俱乐部列表
loading={createMutation.isPending || updateMutation.isPending}
onOk={handleModalOk}
onCancel={() => setModalOpen(false)}
/>
</div>
);
};
export default BookPage;

View File

@ -0,0 +1,129 @@
import { Modal, Form, Input, Select } from 'antd';
import { Book, Library } from './types';
import React, { useEffect } from 'react';
import TermSelect from '@web/src/components/models/term/term-select';
import { api } from '@nice/client';
interface BookModalProps {
open: boolean;
loading?: boolean;
editingBook?: Book;
libraryList?: Library[];
onOk: (values: any) => void;
onCancel: () => void;
}
export const BookModal: React.FC<BookModalProps> = ({
open,
loading,
editingBook,
libraryList,
onOk,
onCancel,
}) => {
const [form] = Form.useForm();
// 查询图书分类的taxonomy
const { data: taxonomies } = api.taxonomy.getAll.useQuery({
// type: 'book'
});
// 当编辑图书时,设置初始值
//bug需要再新增时初始化表单
useEffect(() => {
if (editingBook && open) {
form.setFieldsValue({
...editingBook,
termIds: editingBook.terms?.map(term => term.id) || []
});
} else if (!editingBook && open) {
form.resetFields();
}
}, [editingBook, open, form]);
// 找到图书分类的taxonomy
const bookTaxonomy = taxonomies?.find(tax => tax.objectType.includes('book'));
return (
<Modal
title={editingBook ? '编辑图书' : '新增图书'}
open={open}
onOk={() => form.submit()}
onCancel={onCancel}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
initialValues={editingBook}
onFinish={(values) => {
// 确保数据格式正确
const formData = {
bookName: values.bookName,
isbn: values.isbn,
author: values.author,
termIds: values.termIds, // 添加图书分类ID
libraryId: values.libraryId,
};
onOk(formData);
}}
>
<Form.Item
name="bookName"
label="书名"
rules={[{ required: true, message: '请输入书名' }]}
>
<Input />
</Form.Item>
<Form.Item
name="isbn"
label="图书编码"
rules={[{ required: true, message: '请输入图书编码' }]}
>
<Input />
</Form.Item>
<Form.Item
name="author"
label="作者"
rules={[{ required: true, message: '请输入作者' }]}
>
<Input />
</Form.Item>
{bookTaxonomy && (
<Form.Item
name="termIds"
label="图书分类"
>
<TermSelect
multiple
placeholder="请选择图书分类"
taxonomyId={bookTaxonomy.id}
/>
</Form.Item>
)}
<Form.Item
name="libraryId"
label="所属图书馆"
rules={[{ required: true, message: '请选择所属图书馆' }]}
>
<Select
allowClear
placeholder="请选择图书馆"
>
{libraryList?.map(library => (
<Select.Option key={library.id} value={library.id}>
{library.name} {/* 使用数据库中的 name 字段 */}
</Select.Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
);
};

View File

@ -0,0 +1,88 @@
import { Table, Space, Button, Popconfirm, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Book } from './types';
import React from 'react';
interface BookTableProps {
data: Book[];
loading?: boolean;
onEdit: (record: Book) => void;
onDelete: (ids: string[]) => void;
}
export const BookTable: React.FC<BookTableProps> = ({
data,
loading,
onEdit,
onDelete,
}) => {
const columns: ColumnsType<Book> = [
{
title: '图书名称',
dataIndex: 'bookName',
key: 'bookName',
},
{
title: '图书编码',
dataIndex: 'isbn',
key: 'isbn',
},
{
title: '作者',
dataIndex: 'author',
key: 'author',
},
{
title: '图书分类',
key: 'terms',
render: (_, record) => (
<>
{record.terms?.map(term => (
<Tag color="blue" key={term.id}>
{term.name}
</Tag>
))}
</>
),
},
{
title: '所属图书馆',
dataIndex: ['library', 'name'],
key: 'libraryName',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space size="middle">
<Button type="link" onClick={() => onEdit(record)}>
</Button>
<Popconfirm
title="确定要删除吗?"
onConfirm={() => onDelete([record.id])}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Table
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
/>
);
};

View File

@ -0,0 +1,158 @@
import { Button, Upload, message } from 'antd';
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import React from 'react';
import { Club } from './types';
import * as XLSX from 'xlsx';
import { api } from '@nice/client';
interface ImportExportButtonsProps {
onImportSuccess: () => void;
data: Club[];
}
export const ImportExportButtons: React.FC<ImportExportButtonsProps> = ({
onImportSuccess,
data,
}) => {
const createMutation = api.club.create.useMutation({
onSuccess: () => {
// 成功时不显示单条消息,让最终统计来显示
},
onError: (error) => {
// 只有当不是名称重复错误时才显示错误信息
if (!error.message.includes('Unique constraint failed')) {
message.error('导入失败: ' + error.message);
}
}
});
// 添加恢复记录的 mutation
const restoreMutation = api.club.update.useMutation({
onSuccess: () => {
// 静默成功,不显示消息
},
onError: (error) => {
console.error('恢复记录失败:', error);
}
});
const handleImport = async (file: File) => {
try {
const reader = new FileReader();
reader.onload = async (e) => {
const workbook = XLSX.read(e.target?.result, { type: 'binary' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
let successCount = 0;
let restoredCount = 0;
let errorCount = 0;
// 转换数据格式,包含 id 字段
const clubData = jsonData.map((row: any) => ({
id: row['ID'] || undefined,
name: row['名称'],
description: row['描述'],
parentId: row['上级俱乐部ID'] || null,
}));
// 批量处理
for (const club of clubData) {
try {
await createMutation.mutateAsync(club);
successCount++;
} catch (error: any) {
if (error.message.includes('Unique constraint failed')) {
try {
// 这里需要 club.id假设 clubData 里有 id 字段,否则需要先查找 id
if (!club.id) {
errorCount++;
console.error('恢复记录失败: 缺少唯一标识 id');
continue;
}
await restoreMutation.mutateAsync({
where: {
id: club.id,
},
data: {
deletedAt: null,
description: club.description,
parentId: club.parentId,
}
});
restoredCount++;
} catch (restoreError) {
errorCount++;
console.error('恢复记录失败:', restoreError);
}
} else {
errorCount++;
console.error('创建记录失败:', error);
}
}
}
// 显示导入结果
if (successCount > 0 || restoredCount > 0) {
let successMessage = [];
if (successCount > 0) {
successMessage.push(`新增 ${successCount}`);
}
if (restoredCount > 0) {
successMessage.push(`恢复 ${restoredCount}`);
}
message.success(`导入完成:${successMessage.join('')}`);
onImportSuccess();
}
if (errorCount > 0) {
message.warning(`${errorCount} 条记录导入失败`);
}
};
reader.readAsBinaryString(file);
} catch (error) {
message.error('文件读取失败');
}
return false;
};
const handleExport = () => {
try {
const exportData = data.map(club => ({
'名称': club.name,
'描述': club.description,
'上级俱乐部': club.parent?.name || '',
'上级俱乐部ID': club.parentId || '',
'创建时间': new Date(club.createdAt).toLocaleString(),
'更新时间': new Date(club.updatedAt).toLocaleString(),
}));
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, '俱乐部列表');
XLSX.writeFile(workbook, `俱乐部列表_${new Date().toLocaleDateString()}.xlsx`);
message.success('导出成功');
} catch (error) {
message.error('导出失败: ' + (error as Error).message);
}
};
return (
<div className="flex space-x-2">
<Upload
accept=".xlsx,.xls"
showUploadList={false}
beforeUpload={handleImport}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<Button
icon={<DownloadOutlined />}
onClick={handleExport}
>
</Button>
</div>
);
};

View File

@ -0,0 +1,55 @@
import { Button, Input, Space } from 'antd';
import { SearchOutlined, PlusOutlined } from '@ant-design/icons';
import React from 'react';
// import { ImportExportButtons } from './ImportExportButtons';
// Define or import the Staff type
// interface Driver {
// id: string;
// name: string;
// gender: string;
// age: number;
// clubName: string;
// }
interface SearchBarProps {
searchText: string;
onSearchTextChange: (text: string) => void;
onSearch: () => void;
onAdd: () => void;
onImportSuccess: () => void;
// data: Driver[];
}
export const SearchBar: React.FC<SearchBarProps> = ({
searchText,
onSearchTextChange,
onSearch,
onAdd,
onImportSuccess,
// data,
}) => {
return (
<div className="mb-4 flex justify-between">
<Space>
<Input
placeholder="搜索图书名称及相关信息"
value={searchText}
onChange={(e) => onSearchTextChange(e.target.value)}
onPressEnter={onSearch}
prefix={<SearchOutlined />}
/>
<Button type="primary" onClick={onSearch}></Button>
</Space>
<Space>
{/* <ImportExportButtons
onImportSuccess={onImportSuccess}
data={data}
/> */}
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
</Button>
</Space>
</div>
);
};

View File

@ -0,0 +1,25 @@
export interface Book{
id : string ;
bookName : string;
isbn : string;
author : string;
libraryId : string;
library? : Library;
terms? : Term[];
createdAt: Date | string;
updatedAt: Date | string;
deletedAt?: Date | string | null;
}
export interface Library {
id: string;
name: string;
}
export interface Term {
id: string;
name: string;
taxonomyId?: string;
}

View File

@ -0,0 +1,145 @@
import { api } from '@nice/client';
import { message, Form } from 'antd';
import React, { useState } from 'react';
import { SearchBar } from './components/SearchBar';
import { BorrowRecordTable } from './components/BorrowRecordTable';
import { BorrowRecordModal } from './components/BorrowRecordModal';
import { BorrowRecord } from './components/types';
export const BorrowRecordPage = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<BorrowRecord | undefined>();
const [searchText, setSearchText] = useState('');
const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
const [form] = Form.useForm();
// 查询借阅记录数据
const { data, isLoading, refetch, isError, error } = api.borrowRecord.findMany.useQuery({
where: {
deletedAt: null,
libraryId: selectedLibraryId || undefined,
OR: searchText ? [
{ book: { bookName: { contains: searchText } } },
{ book: { isbn: { contains: searchText } } },
{ reader: { name: { contains: searchText } } },
{ reader: { username: { contains: searchText } } },
] : undefined
},
include: {
library: true,
reader: true,
book: true,
},
});
if (isError && error) {
message.error('获取数据失败:' + error.message);
}
// 创建借阅记录
const createMutation = api.borrowRecord.create.useMutation({
onSuccess: () => {
message.success('创建成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
message.error('创建失败:' + error.message);
}
});
// 更新借阅记录
const updateMutation = api.borrowRecord.update.useMutation({
onSuccess: () => {
message.success('更新成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
message.error('更新失败:' + error.message);
}
});
// 删除借阅记录
const deleteMutation = api.borrowRecord.softDeleteByIds.useMutation({
onSuccess: () => {
message.success('删除成功');
refetch();
},
onError: (error) => {
message.error('删除失败:' + error.message);
}
});
// 处理搜索
const handleSearch = () => {
refetch();
};
// 处理添加
const handleAdd = () => {
setEditingRecord(undefined);
setModalOpen(true);
};
// 处理表单提交
const handleModalOk = (values: any) => {
if (editingRecord) {
updateMutation.mutate({
where: { id: editingRecord.id },
data: {
giveTime: values.giveTime,
backTime: values.backTime,
isbackTime: values.isbackTime,
libraryId: values.libraryId,
readerId: values.readerId,
bookId: values.bookId,
}
});
} else {
createMutation.mutate({
giveTime: values.giveTime,
backTime: values.backTime,
isbackTime: values.isbackTime,
libraryId: values.libraryId,
readerId: values.readerId,
bookId: values.bookId,
});
}
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6 text-center"></h1>
<SearchBar
searchText={searchText}
onSearchTextChange={setSearchText}
onSearch={handleSearch}
onAdd={handleAdd}
libraryId={selectedLibraryId}
onLibraryChange={setSelectedLibraryId}
/>
<BorrowRecordTable
data={(data || []) as BorrowRecord[]}
loading={isLoading}
onEdit={(record) => {
setEditingRecord(record);
setModalOpen(true);
}}
onDelete={(ids) => deleteMutation.mutate({ ids })}
/>
<BorrowRecordModal
open={modalOpen}
editingRecord={editingRecord}
loading={createMutation.isPending || updateMutation.isPending}
onOk={handleModalOk}
onCancel={() => setModalOpen(false)}
/>
</div>
);
};
export default BorrowRecordPage;

View File

@ -0,0 +1,201 @@
import { Modal, Form, DatePicker, Select, Alert } from 'antd';
import { BorrowRecord, Library, Reader, Book } from './types';
import React, { useState, useEffect } from 'react';
import { api } from '@nice/client';
import dayjs from 'dayjs';
interface BorrowRecordModalProps {
open: boolean;
loading?: boolean;
editingRecord?: BorrowRecord;
onOk: (values: any) => void;
onCancel: () => void;
}
export const BorrowRecordModal: React.FC<BorrowRecordModalProps> = ({
open,
loading,
editingRecord,
onOk,
onCancel,
}) => {
const [form] = Form.useForm();
const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
const [selectedReaderId, setSelectedReaderId] = useState<string>('');
// 查询所有图书馆
const { data: libraryList } = api.library.findMany.useQuery({
where: { deletedAt: null },
});
// 根据选择的图书馆筛选读者
const { data: readerList } = api.reader.findMany.useQuery({
where: {
deletedAt: null,
libraryId: selectedLibraryId || undefined
},
include: { library: true }
}, {
enabled: !!selectedLibraryId
});
// 根据选择的图书馆筛选图书
const { data: bookList } = api.book.findMany.useQuery({
where: {
deletedAt: null,
libraryId: selectedLibraryId || undefined
},
include: { library: true }
}, {
enabled: !!selectedLibraryId
});
// 初始化表单数据
useEffect(() => {
if (editingRecord && open) {
setSelectedLibraryId(editingRecord.libraryId);
setSelectedReaderId(editingRecord.readerId);
form.setFieldsValue({
...editingRecord,
giveTime: dayjs(editingRecord.giveTime),
backTime: dayjs(editingRecord.backTime),
isbackTime: editingRecord.isbackTime ? dayjs(editingRecord.isbackTime) : null,
});
} else if (!editingRecord && open) {
form.resetFields();
setSelectedLibraryId('');
setSelectedReaderId('');
}
}, [editingRecord, open, form]);
// 处理图书馆选择变化
const handleLibraryChange = (value: string) => {
setSelectedLibraryId(value);
// 清空读者和图书的选择
form.setFieldsValue({ readerId: undefined, bookId: undefined });
};
// 处理读者选择变化
const handleReaderChange = (value: string) => {
setSelectedReaderId(value);
// 如果选择了读者,自动设置图书馆为读者所属图书馆
if (value && readerList) {
const reader = readerList.find(r => r.id === value);
if (reader && reader.libraryId) {
setSelectedLibraryId(reader.libraryId);
form.setFieldsValue({ libraryId: reader.libraryId });
}
}
};
return (
<Modal
title={editingRecord ? '编辑借阅记录' : '新增借阅记录'}
open={open}
onOk={() => form.submit()}
onCancel={onCancel}
confirmLoading={loading}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={(values) => {
const formData = {
...values,
giveTime: values.giveTime.toDate(),
backTime: values.backTime.toDate(),
isbackTime: values.isbackTime ? values.isbackTime.toDate() : new Date(0),
};
onOk(formData);
}}
>
<Form.Item
name="libraryId"
label="图书馆"
rules={[{ required: true, message: '请选择图书馆' }]}
>
<Select
placeholder="请选择图书馆"
onChange={handleLibraryChange}
>
{libraryList?.map(library => (
<Select.Option key={library.id} value={library.id}>
{library.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="readerId"
label="借阅人"
rules={[{ required: true, message: '请选择借阅人' }]}
>
<Select
placeholder="请选择借阅人"
onChange={handleReaderChange}
disabled={!selectedLibraryId}
>
{readerList?.map(reader => (
<Select.Option key={reader.id} value={reader.id}>
{reader.name} ({reader.username})
</Select.Option>
))}
</Select>
</Form.Item>
{!selectedLibraryId && (
<Alert
message="请先选择图书馆"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Form.Item
name="bookId"
label="借阅图书"
rules={[{ required: true, message: '请选择借阅图书' }]}
>
<Select
placeholder="请选择图书"
disabled={!selectedLibraryId}
>
{bookList?.map(book => (
<Select.Option key={book.id} value={book.id}>
{book.bookName} (ISBN: {book.isbn})
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="giveTime"
label="借出时间"
rules={[{ required: true, message: '请选择借出时间' }]}
>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="backTime"
label="应还时间"
rules={[{ required: true, message: '请选择应还时间' }]}
>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="isbackTime"
label="实际归还时间"
>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
);
};

View File

@ -0,0 +1,95 @@
import { Button, Space, Table, Tag, Popconfirm } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { BorrowRecord } from './types';
import React from 'react';
interface BorrowRecordTableProps {
data: BorrowRecord[];
loading?: boolean;
onEdit: (record: BorrowRecord) => void;
onDelete: (ids: string[]) => void;
}
export const BorrowRecordTable: React.FC<BorrowRecordTableProps> = ({
data,
loading,
onEdit,
onDelete,
}) => {
console.log(data);
const columns: ColumnsType<BorrowRecord> = [
{
title: '图书名称',
dataIndex: ['book', 'bookName'],
key: 'bookName',
},
{
title: '图书ISBN',
dataIndex: ['book', 'isbn'],
key: 'isbn',
},
{
title: '借阅人',
dataIndex: ['reader', 'name'],
key: 'readerName',
},
{
title: '所属图书馆',
dataIndex: ['library', 'name'],
key: 'libraryName',
},
{
title: '借出时间',
dataIndex: 'giveTime',
key: 'giveTime',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '应还时间',
dataIndex: 'backTime',
key: 'backTime',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '实际归还时间',
dataIndex: 'isbackTime',
key: 'isbackTime',
render: (date: string) => {
const isbackDate = new Date(date);
const backDate = new Date(0); // 1970年1月1日
return isbackDate > backDate ?
isbackDate.toLocaleString('zh-CN') :
<Tag color="red"></Tag>;
},
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space size="middle">
<Button type="link" onClick={() => onEdit(record)}>
</Button>
<Popconfirm
title="确定要删除吗?"
onConfirm={() => onDelete([record.id])}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Table
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
/>
);
};

View File

@ -0,0 +1,153 @@
import { Button, Upload, message } from 'antd';
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import React from 'react';
import { Staff } from './types';
import * as XLSX from 'xlsx';
import { api } from '@nice/client';
interface ImportExportButtonsProps {
onImportSuccess: () => void;
data: Staff[];
}
export const ImportExportButtons: React.FC<ImportExportButtonsProps> = ({
onImportSuccess,
data,
}) => {
const createMutation = api.staff.create.useMutation({
onSuccess: () => {
// 成功时不显示单条消息,让最终统计来显示
},
onError: (error) => {
// 只有当不是用户名重复错误时才显示错误信息
if (!error.message.includes('Unique constraint failed')) {
message.error('导入失败: ' + error.message);
}
}
});
// 添加恢复记录的 mutation
const restoreMutation = api.staff.update.useMutation({
onSuccess: () => {
// 静默成功,不显示消息
},
onError: (error) => {
console.error('恢复记录失败:', error);
}
});
const handleImport = async (file: File) => {
try {
const reader = new FileReader();
reader.onload = async (e) => {
const workbook = XLSX.read(e.target?.result, { type: 'binary' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
let successCount = 0;
let restoredCount = 0;
let errorCount = 0;
// 转换数据格式
const staffData = jsonData.map((row: any) => ({
username: row['用户名'],
showname: row['名称'],
absent: row['是否在位'] === '在位',
}));
// 批量处理
for (const staff of staffData) {
try {
// 先尝试创建新记录
await createMutation.mutateAsync({
data: staff
});
successCount++;
} catch (error: any) {
// 如果是用户名重复错误
if (error.message.includes('Unique constraint failed')) {
try {
// 尝试恢复已删除的记录
await restoreMutation.mutateAsync({
where: {
username: staff.username,
},
data: {
deletedAt: null,
showname: staff.showname,
absent: staff.absent,
}
});
restoredCount++;
} catch (restoreError) {
errorCount++;
console.error('恢复记录失败:', restoreError);
}
} else {
errorCount++;
console.error('创建记录失败:', error);
}
}
}
// 显示导入结果
if (successCount > 0 || restoredCount > 0) {
let successMessage = [];
if (successCount > 0) {
successMessage.push(`新增 ${successCount}`);
}
if (restoredCount > 0) {
successMessage.push(`恢复 ${restoredCount}`);
}
message.success(`导入完成:${successMessage.join('')}`);
onImportSuccess();
}
if (errorCount > 0) {
message.warning(`${errorCount} 条记录导入失败`);
}
};
reader.readAsBinaryString(file);
} catch (error) {
message.error('文件读取失败');
}
return false;
};
const handleExport = () => {
try {
const exportData = data.map(staff => ({
'用户名': staff.username,
'名称': staff.showname,
'是否在位': staff.absent ? '是' : '否'
}));
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, '员工列表');
XLSX.writeFile(workbook, `员工列表_${new Date().toLocaleDateString()}.xlsx`);
message.success('导出成功');
} catch (error) {
message.error('导出失败: ' + (error as Error).message);
}
};
return (
<div className="flex space-x-2">
<Upload
accept=".xlsx,.xls"
showUploadList={false}
beforeUpload={handleImport}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<Button
icon={<DownloadOutlined />}
onClick={handleExport}
>
</Button>
</div>
);
};

View File

@ -0,0 +1,59 @@
import { Button, Input, Space, Select } from 'antd';
import { SearchOutlined, PlusOutlined } from '@ant-design/icons';
import React, { useState } from 'react';
import { api } from '@nice/client';
interface SearchBarProps {
searchText: string;
onSearchTextChange: (text: string) => void;
onSearch: () => void;
onAdd: () => void;
libraryId: string;
onLibraryChange: (libraryId: string) => void;
}
export const SearchBar: React.FC<SearchBarProps> = ({
searchText,
onSearchTextChange,
onSearch,
onAdd,
libraryId,
onLibraryChange
}) => {
// 查询所有图书馆
const { data: libraryList } = api.library.findMany.useQuery({
where: { deletedAt: null },
});
return (
<div className="mb-4 flex justify-between">
<Space>
<Input
placeholder="搜索图书或读者信息"
value={searchText}
onChange={(e) => onSearchTextChange(e.target.value)}
onPressEnter={onSearch}
prefix={<SearchOutlined />}
style={{ width: 200 }}
/>
<Select
placeholder="选择图书馆"
value={libraryId || undefined}
onChange={onLibraryChange}
allowClear
style={{ width: 180 }}
>
{libraryList?.map(library => (
<Select.Option key={library.id} value={library.id}>
{library.name}
</Select.Option>
))}
</Select>
<Button type="primary" onClick={onSearch}></Button>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
</Button>
</div>
);
};

View File

@ -0,0 +1,40 @@
export interface BorrowRecord {
id: string;
giveTime: Date | string;
backTime: Date | string;
isbackTime: Date | string;
libraryId: string;
library?: Library;
readerId: string;
reader?: Reader;
bookId: string;
book?: Book;
createdAt: Date | string;
updatedAt: Date | string;
deletedAt?: Date | string | null;
}
export interface Library {
id: string;
name: string;
}
export interface Reader {
id: string;
name: string;
username: string;
libraryId: string;
}
export interface Book {
id: string;
bookName: string;
isbn: string;
author: string;
libraryId: string;
}
export interface PaginatedResponse {
items: BorrowRecord[];
total: number;
}

View File

@ -0,0 +1,229 @@
import { api } from '@nice/client';
import { message, Card, Empty, Pagination } from 'antd';
import React, { useState } from 'react';
import { SearchBar } from './components/SearchBar';
import { CommentTable } from './components/CommentTable';
import { CommentModal } from './components/CommentModal';
import { Comment } from './components/types';
export const CommentPage = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<Comment | undefined>();
const [searchText, setSearchText] = useState('');
const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
const [commentType, setCommentType] = useState<'all' | 'book' | 'library'>('all');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// 构建查询条件
const buildWhereClause = () => {
const where: any = {
deletedAt: null,
};
// 如果选择了图书馆
if (selectedLibraryId) {
if (commentType === 'book') {
where.book = { libraryId: selectedLibraryId };
} else if (commentType === 'library') {
where.libraryId = selectedLibraryId;
} else {
where.OR = [
{ libraryId: selectedLibraryId },
{ book: { libraryId: selectedLibraryId } }
];
}
}
// 根据评论类型筛选
if (commentType === 'book' && !selectedLibraryId) {
where.bookId = { not: null };
} else if (commentType === 'library' && !selectedLibraryId) {
where.libraryId = { not: null };
}
// 搜索文本
if (searchText) {
where.OR = [
{ content: { contains: searchText } },
{ reader: { name: { contains: searchText } } },
{ reader: { username: { contains: searchText } } },
{ book: { bookName: { contains: searchText } } },
{ library: { name: { contains: searchText } } }
];
}
return where;
};
// 查询评论数据
const { data, isLoading, refetch, isError, error } = api.comment.findMany.useQuery({
where: buildWhereClause(),
include: {
library: true,
reader: true,
book: {
include: {
library: true
}
}
},
orderBy: {
createdAt: 'desc'
},
skip: (currentPage - 1) * pageSize,
take: pageSize,
});
if (isError && error) {
message.error('获取数据失败:' + error.message);
}
// 创建评论
const createMutation = api.comment.create.useMutation({
onSuccess: () => {
message.success('评论发布成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
message.error('评论发布失败:' + error.message);
}
});
// 更新评论
const updateMutation = api.comment.update.useMutation({
onSuccess: () => {
message.success('评论更新成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
message.error('评论更新失败:' + error.message);
}
});
// 删除评论
const deleteMutation = api.comment.softDeleteByIds.useMutation({
onSuccess: () => {
message.success('评论删除成功');
refetch();
},
onError: (error) => {
message.error('评论删除失败:' + error.message);
}
});
// 处理搜索
const handleSearch = () => {
setCurrentPage(1);
refetch();
};
// 处理添加
const handleAdd = () => {
setEditingRecord(undefined);
setModalOpen(true);
};
// 处理表单提交
const handleModalOk = (values: any) => {
if (editingRecord) {
updateMutation.mutate({
where: { id: editingRecord.id },
data: {
content: values.content,
libraryId: values.libraryId,
readerId: values.readerId,
bookId: values.bookId,
}
});
} else {
createMutation.mutate({
content: values.content,
libraryId: values.libraryId,
readerId: values.readerId,
bookId: values.bookId,
});
}
};
// 处理分页变化
const handlePageChange = (page: number, size?: number) => {
setCurrentPage(page);
if (size) {
setPageSize(size);
}
};
// 获取数据总数的估计值,用于分页
// 由于我们不能直接获取总数,这里使用当前页数据长度来估计
// 如果当前页数据长度小于页大小,说明已经到达最后一页
const estimatedTotal = data && data.length < pageSize
? (currentPage - 1) * pageSize + data.length
: (currentPage * pageSize) + 1; // 如果当前页数据等于页大小,可能还有更多数据
return (
<div className="p-6">
<Card className="mb-6">
<h1 className="text-2xl font-bold mb-6 text-center"></h1>
<p className="text-center text-gray-500 mb-6">
使
</p>
<SearchBar
searchText={searchText}
onSearchTextChange={setSearchText}
onSearch={handleSearch}
onAdd={handleAdd}
libraryId={selectedLibraryId}
onLibraryChange={setSelectedLibraryId}
commentType={commentType}
onCommentTypeChange={setCommentType}
/>
</Card>
<Card>
{data && data.length > 0 ? (
<>
<CommentTable
data={(data || []) as Comment[]}
loading={isLoading}
onEdit={(record) => {
setEditingRecord(record);
setModalOpen(true);
}}
onDelete={(ids) => deleteMutation.mutate({ ids })}
/>
<div className="mt-4 flex justify-end">
<Pagination
current={currentPage}
pageSize={pageSize}
total={estimatedTotal}
onChange={handlePageChange}
showSizeChanger
showQuickJumper
showTotal={(total) => `${currentPage} 页 / 共 ${Math.ceil(total / pageSize)}`}
/>
</div>
</>
) : (
<Empty
description={isLoading ? "加载中..." : "暂无评论数据"}
className="py-12"
/>
)}
</Card>
<CommentModal
open={modalOpen}
editingRecord={editingRecord}
loading={createMutation.isPending || updateMutation.isPending}
onOk={handleModalOk}
onCancel={() => setModalOpen(false)}
/>
</div>
);
};
export default CommentPage;

View File

@ -0,0 +1,234 @@
import { Modal, Form, Input, Select, Radio, Alert, Divider } from 'antd';
import { Comment, Library, Reader, Book } from './types';
import React, { useState, useEffect } from 'react';
import { api } from '@nice/client';
const { TextArea } = Input;
interface CommentModalProps {
open: boolean;
loading?: boolean;
editingRecord?: Comment;
onOk: (values: any) => void;
onCancel: () => void;
}
export const CommentModal: React.FC<CommentModalProps> = ({
open,
loading,
editingRecord,
onOk,
onCancel,
}) => {
const [form] = Form.useForm();
const [commentType, setCommentType] = useState<'book' | 'library'>('book');
const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
const [selectedReaderId, setSelectedReaderId] = useState<string>('');
console.log(editingRecord);
// 查询所有图书馆
const { data: libraryList } = api.library.findMany.useQuery({
where: { deletedAt: null },
});
// 查询所有读者
const { data: readerList } = api.reader.findMany.useQuery({
where: {
deletedAt: null,
},
});
// 根据选择的图书馆筛选图书
const { data: bookList } = api.book.findMany.useQuery({
where: {
deletedAt: null,
libraryId: selectedLibraryId || undefined
},
include: { library: true }
}, {
enabled: !!selectedLibraryId && commentType === 'book'
});
// 初始化表单数据
useEffect(() => {
if (editingRecord && open) {
// 判断评论类型
const type = editingRecord.bookId ? 'book' : 'library';
setCommentType(type);
setSelectedLibraryId(type === 'book' ? editingRecord.book?.libraryId : editingRecord.libraryId);
setSelectedReaderId(editingRecord.readerId);
form.setFieldsValue({
content: editingRecord.content,
readerId: editingRecord.readerId,
commentType: type,
libraryId: type === 'library' ? editingRecord.libraryId : undefined,
bookId: type === 'book' ? editingRecord.bookId : undefined,
});
} else if (!editingRecord && open) {
form.resetFields();
setCommentType('book');
setSelectedLibraryId('');
setSelectedReaderId('');
}
}, [editingRecord, open, form]);
// 处理评论类型变化
const handleCommentTypeChange = (e: any) => {
const type = e.target.value;
setCommentType(type);
// 清空相关字段
if (type === 'book') {
form.setFieldsValue({ libraryId: undefined });
} else {
form.setFieldsValue({ bookId: undefined });
}
};
// 处理图书馆选择变化
const handleLibraryChange = (value: string) => {
setSelectedLibraryId(value);
// 如果是图书评论,清空图书选择
if (commentType === 'book') {
form.setFieldsValue({ bookId: undefined });
}
};
return (
<Modal
title={editingRecord ? '编辑评论' : '添加评论'}
open={open}
onOk={() => form.submit()}
onCancel={onCancel}
confirmLoading={loading}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={(values) => {
// 根据评论类型处理数据
const formData = {
...values,
libraryId: values.commentType === 'library' ? values.libraryId : null,
bookId: values.commentType === 'book' ? values.bookId : null,
};
onOk(formData);
}}
>
<Form.Item
name="readerId"
label="评论人"
rules={[{ required: true, message: '请选择评论人' }]}
>
<Select
placeholder="请选择评论人"
showSearch
optionFilterProp="children"
>
{readerList?.map(reader => (
<Select.Option key={reader.id} value={reader.id}>
{reader.name} ({reader.username})
</Select.Option>
))}
</Select>
</Form.Item>
<Divider></Divider>
<Form.Item
name="commentType"
label="评论类型"
rules={[{ required: true, message: '请选择评论类型' }]}
initialValue="book"
>
<Radio.Group onChange={handleCommentTypeChange}>
<Radio value="book"></Radio>
<Radio value="library"></Radio>
</Radio.Group>
</Form.Item>
{commentType === 'book' ? (
<>
<Form.Item
name="libraryId"
label="图书所属图书馆"
rules={[{ required: true, message: '请选择图书馆' }]}
>
<Select
placeholder="请选择图书馆"
onChange={handleLibraryChange}
>
{libraryList?.map(library => (
<Select.Option key={library.id} value={library.id}>
{library.name}
</Select.Option>
))}
</Select>
</Form.Item>
{!selectedLibraryId && (
<Alert
message="请先选择图书馆"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Form.Item
name="bookId"
label="评论图书"
rules={[{ required: true, message: '请选择图书' }]}
>
<Select
placeholder="请选择图书"
disabled={!selectedLibraryId}
showSearch
optionFilterProp="children"
>
{bookList?.map(book => (
<Select.Option key={book.id} value={book.id}>
{book.bookName} (ISBN: {book.isbn})
</Select.Option>
))}
</Select>
</Form.Item>
</>
) : (
<Form.Item
name="libraryId"
label="评论图书馆"
rules={[{ required: true, message: '请选择图书馆' }]}
>
<Select
placeholder="请选择图书馆"
onChange={handleLibraryChange}
>
{libraryList?.map(library => (
<Select.Option key={library.id} value={library.id}>
{library.name}
</Select.Option>
))}
</Select>
</Form.Item>
)}
<Divider></Divider>
<Form.Item
name="content"
label="评论内容"
rules={[{ required: true, message: '请输入评论内容' }]}
>
<TextArea
rows={4}
placeholder="请输入您的评论内容..."
showCount
maxLength={500}
/>
</Form.Item>
</Form>
</Modal>
);
};

View File

@ -0,0 +1,120 @@
import { Button, Space, Table, Popconfirm, Avatar, Rate, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Comment } from './types';
import React from 'react';
import { UserOutlined, BookOutlined, HomeOutlined } from '@ant-design/icons';
interface CommentTableProps {
data: Comment[];
loading?: boolean;
onEdit: (record: Comment) => void;
onDelete: (ids: string[]) => void;
}
export const CommentTable: React.FC<CommentTableProps> = ({
data,
loading,
onEdit,
onDelete,
}) => {
console.log(data);
const columns: ColumnsType<Comment> = [
{
title: '评论内容',
dataIndex: 'content',
key: 'content',
ellipsis:true,
render: (content: string) => (
<Space>
{content}
</Space>
),
width: '30%',
},
{
title: '评论人',
dataIndex: ['reader', 'name'],
key: 'readerName',
render: (name: string, record: Comment) => (
<Space>
<Avatar icon={<UserOutlined />} />
<span>{name} ({record.reader?.username})</span>
</Space>
),
},
{
title: '评论对象',
key: 'commentTarget',
render: (_, record: Comment) => {
if (record.bookId && record.book) {
return (
<Space>
<BookOutlined />
<span>
<Tag color="blue">{record.book.library?.name}</Tag>
{record.book.bookName} (ISBN: {record.book.isbn})
</span>
</Space>
);
} else if (record.libraryId && record.library) {
return (
<Space>
<HomeOutlined />
<span>{record.library.name}</span>
</Space>
);
}
return '-';
},
width: '25%',
},
{
title: '评论时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
sorter: (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space size="middle">
<Button type="link" onClick={() => onEdit(record)}>
</Button>
<Popconfirm
title="确定要删除这条评论吗?"
onConfirm={() => onDelete([record.id])}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Table
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
pagination={{ pageSize: 10 }}
expandable={{
expandedRowRender: (record) => (
<div className="p-4 bg-gray-50">
{record.content}
<div className="text-gray-500 text-sm mt-2">
: {new Date(record.createdAt).toLocaleString('zh-CN')}
</div>
</div>
),
}}
/>
);
};

View File

@ -0,0 +1,153 @@
import { Button, Upload, message } from 'antd';
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import React from 'react';
import { Staff } from './types';
import * as XLSX from 'xlsx';
import { api } from '@nice/client';
interface ImportExportButtonsProps {
onImportSuccess: () => void;
data: Staff[];
}
export const ImportExportButtons: React.FC<ImportExportButtonsProps> = ({
onImportSuccess,
data,
}) => {
const createMutation = api.staff.create.useMutation({
onSuccess: () => {
// 成功时不显示单条消息,让最终统计来显示
},
onError: (error) => {
// 只有当不是用户名重复错误时才显示错误信息
if (!error.message.includes('Unique constraint failed')) {
message.error('导入失败: ' + error.message);
}
}
});
// 添加恢复记录的 mutation
const restoreMutation = api.staff.update.useMutation({
onSuccess: () => {
// 静默成功,不显示消息
},
onError: (error) => {
console.error('恢复记录失败:', error);
}
});
const handleImport = async (file: File) => {
try {
const reader = new FileReader();
reader.onload = async (e) => {
const workbook = XLSX.read(e.target?.result, { type: 'binary' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
let successCount = 0;
let restoredCount = 0;
let errorCount = 0;
// 转换数据格式
const staffData = jsonData.map((row: any) => ({
username: row['用户名'],
showname: row['名称'],
absent: row['是否在位'] === '在位',
}));
// 批量处理
for (const staff of staffData) {
try {
// 先尝试创建新记录
await createMutation.mutateAsync({
data: staff
});
successCount++;
} catch (error: any) {
// 如果是用户名重复错误
if (error.message.includes('Unique constraint failed')) {
try {
// 尝试恢复已删除的记录
await restoreMutation.mutateAsync({
where: {
username: staff.username,
},
data: {
deletedAt: null,
showname: staff.showname,
absent: staff.absent,
}
});
restoredCount++;
} catch (restoreError) {
errorCount++;
console.error('恢复记录失败:', restoreError);
}
} else {
errorCount++;
console.error('创建记录失败:', error);
}
}
}
// 显示导入结果
if (successCount > 0 || restoredCount > 0) {
let successMessage = [];
if (successCount > 0) {
successMessage.push(`新增 ${successCount}`);
}
if (restoredCount > 0) {
successMessage.push(`恢复 ${restoredCount}`);
}
message.success(`导入完成:${successMessage.join('')}`);
onImportSuccess();
}
if (errorCount > 0) {
message.warning(`${errorCount} 条记录导入失败`);
}
};
reader.readAsBinaryString(file);
} catch (error) {
message.error('文件读取失败');
}
return false;
};
const handleExport = () => {
try {
const exportData = data.map(staff => ({
'用户名': staff.username,
'名称': staff.showname,
'是否在位': staff.absent ? '是' : '否'
}));
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, '员工列表');
XLSX.writeFile(workbook, `员工列表_${new Date().toLocaleDateString()}.xlsx`);
message.success('导出成功');
} catch (error) {
message.error('导出失败: ' + (error as Error).message);
}
};
return (
<div className="flex space-x-2">
<Upload
accept=".xlsx,.xls"
showUploadList={false}
beforeUpload={handleImport}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<Button
icon={<DownloadOutlined />}
onClick={handleExport}
>
</Button>
</div>
);
};

View File

@ -0,0 +1,73 @@
import { Button, Input, Space, Select, Radio } from 'antd';
import { SearchOutlined, PlusOutlined } from '@ant-design/icons';
import React, { useState } from 'react';
import { api } from '@nice/client';
interface SearchBarProps {
searchText: string;
onSearchTextChange: (text: string) => void;
onSearch: () => void;
onAdd: () => void;
libraryId: string;
onLibraryChange: (libraryId: string) => void;
commentType: 'all' | 'book' | 'library';
onCommentTypeChange: (type: 'all' | 'book' | 'library') => void;
}
export const SearchBar: React.FC<SearchBarProps> = ({
searchText,
onSearchTextChange,
onSearch,
onAdd,
libraryId,
onLibraryChange,
commentType,
onCommentTypeChange
}) => {
// 查询所有图书馆
const { data: libraryList } = api.library.findMany.useQuery({
where: { deletedAt: null },
});
return (
<div className="mb-4 flex flex-wrap justify-between items-center">
<div className="flex flex-wrap items-center gap-2 mb-2 sm:mb-0">
<Input
placeholder="搜索评论内容或评论人"
value={searchText}
onChange={(e) => onSearchTextChange(e.target.value)}
onPressEnter={onSearch}
prefix={<SearchOutlined />}
style={{ width: 200 }}
/>
<Select
placeholder="选择图书馆"
value={libraryId || undefined}
onChange={onLibraryChange}
allowClear
style={{ width: 180 }}
>
{libraryList?.map(library => (
<Select.Option key={library.id} value={library.id}>
{library.name}
</Select.Option>
))}
</Select>
<Radio.Group
value={commentType}
onChange={(e) => onCommentTypeChange(e.target.value)}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="all"></Radio.Button>
<Radio.Button value="book"></Radio.Button>
<Radio.Button value="library"></Radio.Button>
</Radio.Group>
<Button type="primary" onClick={onSearch}></Button>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
</Button>
</div>
);
};

View File

@ -0,0 +1,41 @@
export interface Comment {
id: string;
content: string;
libraryId?: string | null;
library?: Library;
readerId: string;
reader?: Reader;
bookId?: string | null;
book?: Book;
createdAt: Date | string;
updatedAt: Date | string;
deletedAt?: Date | string | null;
}
export interface Library {
id: string;
name: string;
address?: string;
description?: string;
}
export interface Reader {
id: string;
name: string;
username: string;
libraryId?: string;
}
export interface Book {
id: string;
bookName: string;
isbn: string;
author: string;
libraryId: string;
library?: Library;
}
export interface PaginatedResponse {
items: Comment[];
total: number;
}

View File

@ -0,0 +1,175 @@
import { api } from '@nice/client';
import { message, Form, Modal, Table, Space } from 'antd';
import React, { useState, useEffect } from 'react';
import { SearchBar } from './components/SearchBar';
import { GameTable } from './components/GameTable';
import { GameModal } from './components/GameModal';
import { Game } from './components/types';
export const GamePage = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editingGame, setEditingGame] = useState<Game | undefined>();
const [searchText, setSearchText] = useState('');
const [messageApi, contextHolder] = message.useMessage();
const [form] = Form.useForm();
// 查询数据
const { data, isLoading, refetch, isError, error } = api.game.findMany.useQuery({
where: {
deletedAt: null,
},
include: {
clubs: true,
sorties: {
include: {
driver: {
select: {
id: true,
name: true,
},
},
car: {
select: {
id: true,
name: true,
},
},
},
},
},
});
// 独立查询所有俱乐部数据
const { data: clubList, isLoading: clubsLoading } = api.club.findMany.useQuery({
where: {
deletedAt: null,
},
});
if (isError && error) {
messageApi.error('获取数据失败:' + error.message);
}
// 创建数据
const createMutation = api.game.create.useMutation({
onSuccess: () => {
messageApi.success('创建成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
messageApi.error('创建失败:' + error.message);
}
});
// 更新数据
const updateMutation = api.game.update.useMutation({
onSuccess: () => {
messageApi.success('更新成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
messageApi.error('更新失败:' + error.message);
}
});
// 删除数据
const deleteMutation = api.driver.softDeleteByIds.useMutation({
onSuccess: () => {
messageApi.success('删除成功');
refetch();
},
onError: (error) => {
messageApi.error('删除失败:' + error.message);
}
});
// 处理搜索
const handleSearch = () => {
refetch();
};
// 处理添加
const handleAdd = () => {
setEditingGame(undefined);
setModalOpen(true);
};
// 处理表单提交
const handleModalOk = (values: any) => {
if (editingGame) {
updateMutation.mutate({
where: { id: editingGame.id },
data: values,
});
} else {
createMutation.mutate({
data: values,
});
}
console.log(values);
};
// 添加统计信息
const expandedRowRender = (record: Game) => {
const stats = {
avgTime: record.sorties.reduce((acc, curr) => acc + curr.totalTime, 0) / record.sorties.length,
maxScore: Math.max(...record.sorties.map(s => s.score)),
totalSorties: record.sorties.length,
};
return (
<div>
<div style={{ marginBottom: 16 }}>
<Space>
<span>: {stats.avgTime.toFixed(2)}</span>
<span>: {stats.maxScore.toFixed(2)}</span>
<span>: {stats.totalSorties}</span>
</Space>
</div>
<Table
columns={sortieColumns}
dataSource={record.sorties}
pagination={false}
rowKey="id"
/>
</div>
);
};
return (
<>
{contextHolder}
<div className="p-6">
<SearchBar
searchText={searchText}
onSearchTextChange={setSearchText}
onSearch={handleSearch}
onAdd={handleAdd}
onImportSuccess={refetch}
data={data || []}
/>
<GameTable
data={data || []}
loading={isLoading}
onEdit={(driver) => {
setEditingGame(driver);
setModalOpen(true);
}}
onDelete={(ids) => deleteMutation.mutate({ ids })}
/>
<GameModal
open={modalOpen}
editingGame={editingGame}
clubList={clubList || []} // 传递俱乐部列表
loading={createMutation.isLoading || updateMutation.isLoading}
onOk={handleModalOk}
onCancel={() => setModalOpen(false)}
/>
</div>
</>
);
};
export default GamePage;

View File

@ -0,0 +1,93 @@
import { Modal, Form, Input, Select, DatePicker } from 'antd';
import { Game, Club } from './types';
import React, { useEffect } from 'react';
import dayjs from 'dayjs';
interface GameModalProps {
open: boolean;
loading?: boolean;
editingGame?: Game;
clubList?: Club[];
onOk: (values: any) => void;
onCancel: () => void;
}
export const GameModal: React.FC<GameModalProps> = ({
open,
loading,
editingGame,
clubList,
onOk,
onCancel,
}) => {
const [form] = Form.useForm();
useEffect(() => {
if (editingGame) {
form.setFieldsValue({
...editingGame,
startTime: editingGame.startTime ? dayjs(editingGame.startTime) : null,
clubIds: editingGame.clubs?.map(club => club.id) || [],
});
} else {
form.resetFields();
}
}, [editingGame, open, form]);
return (
<Modal
title={editingGame ? '编辑比赛' : '新增比赛'}
open={open}
onOk={() => form.submit()}
onCancel={onCancel}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
onFinish={(values) => {
const formData = {
name: values.name,
startTime: values.startTime ? values.startTime.toISOString() : null,
clubIds: values.clubIds || [],
};
onOk(formData);
}}
>
<Form.Item
name="name"
label="比赛名称"
rules={[{ required: true, message: '请输入比赛名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="startTime"
label="比赛开始时间"
rules={[{ required: true, message: '请选择比赛开始时间' }]}
>
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="clubIds"
label="参与俱乐部"
rules={[{ required: true, message: '请选择参与俱乐部', type: 'array' }]}
>
<Select
mode="multiple"
allowClear
placeholder="请选择俱乐部"
>
{clubList?.map(club => (
<Select.Option key={club.id} value={club.id}>
{club.name}
</Select.Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
);
};

View File

@ -0,0 +1,114 @@
import { Table, Space, Button, Popconfirm } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Game, Sortie } from './types';
import React from 'react';
interface GameTableProps {
data: Game[];
loading?: boolean;
onEdit: (record: Game) => void;
onDelete: (ids: string[]) => void;
}
export const GameTable: React.FC<GameTableProps> = ({
data,
loading,
onEdit,
onDelete,
}) => {
// 主表格列定义
const columns: ColumnsType<Game> = [
{
title: '比赛名称',
dataIndex: 'name',
key: 'name',
},
{
title: '比赛开始时间',
dataIndex: 'startTime',
key: 'startTime',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '参与俱乐部',
dataIndex: 'clubs',
key: 'clubs',
render: (clubs: any[]) => clubs.map(club => club.name).join(', '),
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space size="middle">
<Button type="link" onClick={() => onEdit(record)}>
</Button>
<Popconfirm
title="确定要删除吗?"
onConfirm={() => onDelete([record.id])}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
// 子表格列定义(车次信息)
const expandedRowRender = (record: Game) => {
const sortieColumns: ColumnsType<Sortie> = [
{
title: '驾驶员',
dataIndex: ['driver', 'name'],
key: 'driverName',
},
{
title: '车辆',
dataIndex: ['car', 'name'],
key: 'carName',
},
{
title: '总时间',
dataIndex: 'totalTime',
key: 'totalTime',
render: (time: number) => `${time.toFixed(2)}`,
},
{
title: '得分',
dataIndex: 'score',
key: 'score',
render: (score: number) => score.toFixed(2),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
];
return (
<Table
columns={sortieColumns}
dataSource={record.sorties}
pagination={false}
rowKey="id"
/>
);
};
return (
<Table
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
expandable={{
expandedRowRender,
defaultExpandAllRows: false,
}}
/>
);
};

View File

@ -0,0 +1,153 @@
import { Button, Upload, message } from 'antd';
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import React from 'react';
import { Driver } from './types';
import * as XLSX from 'xlsx';
import { api } from '@nice/client';
interface ImportExportButtonsProps {
onImportSuccess: () => void;
data: Driver[];
}
export const ImportExportButtons: React.FC<ImportExportButtonsProps> = ({
onImportSuccess,
data,
}) => {
const createMutation = api.driver.create.useMutation({
onSuccess: () => {
// 成功时不显示单条消息,让最终统计来显示
},
onError: (error) => {
// 只有当不是用户名重复错误时才显示错误信息
if (!error.message.includes('Unique constraint failed')) {
message.error('导入失败: ' + error.message);
}
}
});
// 添加恢复记录的 mutation
const restoreMutation = api.driver.update.useMutation({
onSuccess: () => {
// 静默成功,不显示消息
},
onError: (error) => {
console.error('恢复记录失败:', error);
}
});
const handleImport = async (file: File) => {
try {
const reader = new FileReader();
reader.onload = async (e) => {
const workbook = XLSX.read(e.target?.result, { type: 'binary' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
let successCount = 0;
let restoredCount = 0;
let errorCount = 0;
// 转换数据格式
const driverData = jsonData.map((row: any) => ({
username: row['用户名'],
showname: row['名称'],
absent: row['是否在位'] === '在位',
}));
// 批量处理
for (const driver of driverData) {
try {
// 先尝试创建新记录
await createMutation.mutateAsync({
data: driver
});
successCount++;
} catch (error: any) {
// 如果是用户名重复错误
if (error.message.includes('Unique constraint failed')) {
try {
// 尝试恢复已删除的记录
await restoreMutation.mutateAsync({
where: {
username: driver.username,
},
data: {
deletedAt: null,
showname: driver.showname,
absent: driver.absent,
}
});
restoredCount++;
} catch (restoreError) {
errorCount++;
console.error('恢复记录失败:', restoreError);
}
} else {
errorCount++;
console.error('创建记录失败:', error);
}
}
}
// 显示导入结果
if (successCount > 0 || restoredCount > 0) {
let successMessage = [];
if (successCount > 0) {
successMessage.push(`新增 ${successCount}`);
}
if (restoredCount > 0) {
successMessage.push(`恢复 ${restoredCount}`);
}
message.success(`导入完成:${successMessage.join('')}`);
onImportSuccess();
}
if (errorCount > 0) {
message.warning(`${errorCount} 条记录导入失败`);
}
};
reader.readAsBinaryString(file);
} catch (error) {
message.error('文件读取失败');
}
return false;
};
const handleExport = () => {
try {
const exportData = data.map(driver => ({
'用户名': driver.username,
'名称': driver.showname,
'是否在位': driver.absent ? '是' : '否'
}));
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, '员工列表');
XLSX.writeFile(workbook, `员工列表_${new Date().toLocaleDateString()}.xlsx`);
message.success('导出成功');
} catch (error) {
message.error('导出失败: ' + (error as Error).message);
}
};
return (
<div className="flex space-x-2">
<Upload
accept=".xlsx,.xls"
showUploadList={false}
beforeUpload={handleImport}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<Button
icon={<DownloadOutlined />}
onClick={handleExport}
>
</Button>
</div>
);
};

View File

@ -0,0 +1,55 @@
import { Button, Input, Space } from 'antd';
import { SearchOutlined, PlusOutlined } from '@ant-design/icons';
import React from 'react';
// import { ImportExportButtons } from './ImportExportButtons';
// Define or import the Staff type
// interface Driver {
// id: string;
// name: string;
// gender: string;
// age: number;
// clubName: string;
// }
interface SearchBarProps {
searchText: string;
onSearchTextChange: (text: string) => void;
onSearch: () => void;
onAdd: () => void;
onImportSuccess: () => void;
// data: Driver[];
}
export const SearchBar: React.FC<SearchBarProps> = ({
searchText,
onSearchTextChange,
onSearch,
onAdd,
onImportSuccess,
// data,
}) => {
return (
<div className="mb-4 flex justify-between">
<Space>
<Input
placeholder="搜索比赛名称"
value={searchText}
onChange={(e) => onSearchTextChange(e.target.value)}
onPressEnter={onSearch}
prefix={<SearchOutlined />}
/>
<Button type="primary" onClick={onSearch}></Button>
</Space>
<Space>
{/* <ImportExportButtons
onImportSuccess={onImportSuccess}
data={data}
/> */}
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
</Button>
</Space>
</div>
);
};

View File

@ -0,0 +1,30 @@
export interface Game {
id : string ;
name : string;
startTime : string;
clubs : Club[];
sorties : Sortie[];
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
export interface Club {
id: string;
name: string;
}
export interface Sortie {
id: string;
totalTime: number;
score: number;
driver: {
id: string;
name: string;
};
car: {
id: string;
name: string;
};
createdAt: string;
}

View File

@ -52,10 +52,17 @@ const items = [
null,
),
getItem(
"test",
"/test",
"图书管理系统",
"/library",
<i className="iconfont icon-icon-category" />,
null,
[ getItem("用户", "/li/user", null, null, null),
getItem("图书馆", "/li/library", null, null, null),
getItem("读者", "/li/reader", null, null, null),
getItem("图书", "/li/book", null, null, null),
getItem("借阅记录", "/li/borrowRecord", null, null, null),
getItem("评论", "/li/comment", null, null, null),
],
null,
),
getItem(

View File

@ -0,0 +1,154 @@
import { useState } from 'react';
import { LibraryTable } from './components/LibraryTable';
import { LibraryModal } from './components/LibraryModal';
import { SearchBar } from './components/SearchBar';
import { Library } from './components/types';
import { api } from '@nice/client';
import { message } from 'antd';
import { useAuth } from '@web/src/providers/auth-provider';
export const LibraryPage = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editingLibrary, setEditingLibrary] = useState<Library | undefined>();
const [searchText, setSearchText] = useState('');
const { user } = useAuth();
// 查询条件:管理员只能看到自己的图书馆
const whereCondition = {
deletedAt: null,
OR: searchText.trim() ? [
{ name: { contains: searchText } },
{ address: { contains: searchText } },
{ description: { contains: searchText } },
] : undefined,
...(user?.role === 'LIB_ADMIN' ? { adminId: user.id } : {})
};
// 查询数据
const { data, isLoading, refetch, isError, error } = api.library.findMany.useQuery({
where: whereCondition,
include: {
admin: true,
_count: {
select: {
readers: true,
books: true,
borrowRecords: true,
comments: true
}
}
},
});
if (isError && error) {
message.error('获取数据失败:' + error.message);
}
// 创建数据
const createMutation = api.library.create.useMutation({
onSuccess: () => {
message.success('创建成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
message.error('创建失败:' + error.message);
}
});
// 更新数据
const updateMutation = api.library.update.useMutation({
onSuccess: () => {
message.success('更新成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
message.error('更新失败:' + error.message);
}
});
// 删除数据
const deleteMutation = api.library.softDeleteByIds.useMutation({
onSuccess: () => {
message.success('删除成功');
refetch();
},
onError: (error) => {
message.error('删除失败:' + error.message);
}
});
// 处理搜索
const handleSearch = () => {
refetch();
};
// 处理添加
const handleAdd = () => {
setEditingLibrary(undefined);
setModalOpen(true);
};
// 处理模态框取消
const handleCancelModal = () => {
setModalOpen(false);
setEditingLibrary(undefined);
};
// 处理表单提交
const handleModalOk = (values: any) => {
if (editingLibrary) {
updateMutation.mutate({
where: { id: editingLibrary.id },
data: {
name: values.name,
address: values.address,
description: values.description,
adminId: values.adminId
}
});
} else {
createMutation.mutate({
name: values.name,
address: values.address,
description: values.description,
adminId: values.adminId
});
}
};
return (
<div className="p-6">
<SearchBar
searchText={searchText}
onSearchTextChange={setSearchText}
onSearch={handleSearch}
onAdd={handleAdd}
onImportSuccess={refetch}
data={data || []}
/>
<LibraryTable
data={data || []}
loading={isLoading}
onEdit={(library) => {
setEditingLibrary(library);
setModalOpen(true);
}}
onDelete={(ids) => deleteMutation.mutate({ ids })}
/>
<LibraryModal
open={modalOpen}
editingLibrary={editingLibrary}
loading={createMutation.isPending || updateMutation.isPending}
onOk={handleModalOk}
onCancel={handleCancelModal}
/>
</div>
);
};
export default LibraryPage;

View File

@ -0,0 +1,161 @@
import { Button, Upload, message } from 'antd';
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import React from 'react';
import { Library } from './types';
import * as XLSX from 'xlsx';
import { api } from '@nice/client';
interface ImportExportButtonsProps {
onImportSuccess: () => void;
data: Library[];
}
export const ImportExportButtons: React.FC<ImportExportButtonsProps> = ({
onImportSuccess,
data,
}) => {
const createMutation = api.library.create.useMutation({
onSuccess: () => {
// 成功时不显示单条消息,让最终统计来显示
},
onError: (error) => {
// 只有当不是名称重复错误时才显示错误信息
if (!error.message.includes('Unique constraint failed')) {
message.error('导入失败: ' + error.message);
}
}
});
// 添加恢复记录的 mutation
const restoreMutation = api.library.update.useMutation({
onSuccess: () => {
// 静默成功,不显示消息
},
onError: (error) => {
console.error('恢复记录失败:', error);
}
});
const handleImport = async (file: File) => {
try {
const reader = new FileReader();
reader.onload = async (e) => {
const workbook = XLSX.read(e.target?.result, { type: 'binary' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
let successCount = 0;
let restoredCount = 0;
let errorCount = 0;
// 转换数据格式
const libraryData = jsonData.map((row: any) => ({
id: row['ID'] || undefined,
name: row['名称'],
address: row['地址'],
description: row['描述'],
adminId: row['管理员ID'] || null,
}));
// 批量处理
for (const library of libraryData) {
try {
await createMutation.mutateAsync(library);
successCount++;
} catch (error: any) {
if (error.message.includes('Unique constraint failed')) {
try {
if (!library.id) {
errorCount++;
console.error('恢复记录失败: 缺少唯一标识 id');
continue;
}
await restoreMutation.mutateAsync({
where: {
id: library.id,
},
data: {
deletedAt: null,
name: library.name,
address: library.address,
description: library.description,
adminId: library.adminId,
}
});
restoredCount++;
} catch (restoreError) {
errorCount++;
console.error('恢复记录失败:', restoreError);
}
} else {
errorCount++;
console.error('创建记录失败:', error);
}
}
}
// 显示导入结果
if (successCount > 0 || restoredCount > 0) {
let successMessage = [];
if (successCount > 0) {
successMessage.push(`新增 ${successCount}`);
}
if (restoredCount > 0) {
successMessage.push(`恢复 ${restoredCount}`);
}
message.success(`导入完成:${successMessage.join('')}`);
onImportSuccess();
}
if (errorCount > 0) {
message.warning(`${errorCount} 条记录导入失败`);
}
};
reader.readAsBinaryString(file);
} catch (error) {
message.error('文件读取失败');
}
return false;
};
const handleExport = () => {
try {
const exportData = data.map(library => ({
'名称': library.name,
'地址': library.address,
'描述': library.description,
'管理员': library.admin?.name || '',
'管理员ID': library.adminId || '',
'创建时间': new Date(library.createdAt).toLocaleString(),
'更新时间': new Date(library.updatedAt).toLocaleString(),
}));
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, '图书馆列表');
XLSX.writeFile(workbook, `图书馆列表_${new Date().toLocaleDateString()}.xlsx`);
message.success('导出成功');
} catch (error) {
message.error('导出失败: ' + (error as Error).message);
}
};
return (
<div className="flex space-x-2">
<Upload
accept=".xlsx,.xls"
showUploadList={false}
beforeUpload={handleImport}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<Button
icon={<DownloadOutlined />}
onClick={handleExport}
>
</Button>
</div>
);
};

View File

@ -0,0 +1,123 @@
import { Modal, Form, Input, Select, Spin } from 'antd';
import { Library } from './types';
import React, { useEffect } from 'react';
import { api } from '@nice/client';
interface LibraryModalProps {
open: boolean;
loading?: boolean;
editingLibrary?: Library;
onOk: (values: any) => void;
onCancel: () => void;
}
export const LibraryModal: React.FC<LibraryModalProps> = ({
open,
loading,
editingLibrary,
onOk,
onCancel,
}) => {
const [form] = Form.useForm();
// 查询图书馆管理员用户
const { data: adminUsers, isLoading: loadingAdmins } = api.user.findMany.useQuery({
where: {
role: 'LIB_ADMIN',
deletedAt: null,
},
select: {
id: true,
name: true,
username: true,
},
}, {
enabled: open, // 只有当模态框打开时才查询
});
// 核心逻辑:当模态框的打开状态或编辑对象改变时,更新表单
useEffect(() => {
if (open) {
// 当模态框打开时
if (editingLibrary) {
// 如果有编辑中的图书馆(编辑模式),则填充表单
form.setFieldsValue(editingLibrary);
} else {
// 如果没有编辑中的图书馆(新增模式),则清空表单
form.resetFields();
}
} else {
// 当模态框关闭时,也清空表单,为下一次打开(特别是新增)做准备
form.resetFields();
}
}, [open, editingLibrary, form]); // 依赖项:模态框的打开状态、编辑对象和表单实例
return (
<Modal
title={editingLibrary ? '编辑图书馆' : '新增图书馆'}
open={open}
onOk={() => form.submit()}
onCancel={onCancel}
confirmLoading={loading}
width={600}
>
<Form
form={form}
layout="vertical"
initialValues={editingLibrary}
onFinish={(values) => {
// 确保数据格式正确
const formData = {
name: values.name,
address: values.address,
description: values.description,
adminId: values.adminId,
};
onOk(formData);
}}
>
<Form.Item
name="name"
label="图书馆名称"
rules={[{ required: true, message: '请输入图书馆名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="address"
label="地址"
rules={[{ required: true, message: '请输入图书馆地址' }]}
>
<Input />
</Form.Item>
<Form.Item
name="description"
label="描述"
rules={[{ required: true, message: '请输入图书馆描述' }]}
>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item
name="adminId"
label="图书馆管理员"
>
<Select
allowClear
placeholder="选择图书馆管理员"
loading={loadingAdmins}
notFoundContent={loadingAdmins ? <Spin size="small" /> : null}
>
{adminUsers?.map(admin => (
<Select.Option key={admin.id} value={admin.id}>
{admin.name} ({admin.username})
</Select.Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
);
};

View File

@ -0,0 +1,112 @@
import { Table, Space, Button, Popconfirm, Badge, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Library } from './types';
import { BookOutlined, TeamOutlined, SwapOutlined, CommentOutlined } from '@ant-design/icons';
import React from 'react';
interface LibraryTableProps {
data: any[]; // 使用 any 类型,因为包含了 _count 等额外数据
loading?: boolean;
onEdit: (record: Library) => void;
onDelete: (ids: string[]) => void;
}
export const LibraryTable: React.FC<LibraryTableProps> = ({
data,
loading,
onEdit,
onDelete,
}) => {
const columns: ColumnsType<any> = [
{
title: '图书馆名称',
dataIndex: 'name',
key: 'name',
},
{
title: '地址',
dataIndex: 'address',
key: 'address',
},
{
title: '图书馆描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '管理员',
dataIndex: ['admin', 'name'],
key: 'admin',
render: (text, record) => text || '未分配',
},
{
title: '统计信息',
key: 'stats',
render: (_, record) => (
<Space>
<Tooltip title="读者数量">
<Badge count={record._count?.readers || 0} showZero>
<TeamOutlined style={{ fontSize: '16px' }} />
</Badge>
</Tooltip>
<Tooltip title="图书数量">
<Badge count={record._count?.books || 0} showZero>
<BookOutlined style={{ fontSize: '16px' }} />
</Badge>
</Tooltip>
<Tooltip title="借阅记录">
<Badge count={record._count?.borrowRecords || 0} showZero>
<SwapOutlined style={{ fontSize: '16px' }} />
</Badge>
</Tooltip>
<Tooltip title="评论">
<Badge count={record._count?.comments || 0} showZero>
<CommentOutlined style={{ fontSize: '16px' }} />
</Badge>
</Tooltip>
</Space>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space size="middle">
<Button type="link" onClick={() => onEdit(record)}>
</Button>
<Popconfirm
title="确定要删除吗?"
description="删除后相关的读者、图书、借阅记录和评论将无法访问此图书馆"
onConfirm={() => onDelete([record.id])}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Table
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
pagination={{
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
}}
/>
);
};

View File

@ -0,0 +1,47 @@
import { Button, Input, Space } from 'antd';
import { SearchOutlined, PlusOutlined } from '@ant-design/icons';
import React from 'react';
import { ImportExportButtons } from './ImportExportButtons';
import { Library } from './types';
interface SearchBarProps {
searchText: string;
onSearchTextChange: (text: string) => void;
onSearch: () => void;
onAdd: () => void;
onImportSuccess: () => void;
data: Library[];
}
export const SearchBar: React.FC<SearchBarProps> = ({
searchText,
onSearchTextChange,
onSearch,
onAdd,
onImportSuccess,
data,
}) => {
return (
<div className="mb-4 flex justify-between">
<Space>
<Input
placeholder="搜索图书馆"
value={searchText}
onChange={(e) => onSearchTextChange(e.target.value)}
onPressEnter={onSearch}
prefix={<SearchOutlined />}
/>
<Button type="primary" onClick={onSearch}></Button>
</Space>
<Space>
<ImportExportButtons
onImportSuccess={onImportSuccess}
data={data}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
</Button>
</Space>
</div>
);
};

View File

@ -0,0 +1,21 @@
export interface Library {
id: string;
name: string;
address: string;
description: string;
adminId?: string | null;
admin?: {
id: string;
name: string;
username: string;
} | null;
createdAt: Date | string;
updatedAt: Date | string;
deletedAt?: Date | string | null;
_count?: {
readers: number;
books: number;
borrowRecords: number;
comments: number;
}
}

View File

@ -0,0 +1,205 @@
import { api } from '@nice/client';
import { message, Form, Modal } from 'antd';
import React, { useState, useEffect } from 'react';
import { SearchBar } from './components/SearchBar';
import { ReaderTable } from './components/ReaderTable';
import { ReaderModal } from './components/ReaderModal';
import { Reader } from './components/types';
export const ReaderPage = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editingReader, setEditingReader] = useState<Reader | undefined>();
const [searchText, setSearchText] = useState('');
const [form] = Form.useForm();
// 查询数据
const { data, isLoading, refetch, isError, error } = api.reader.findMany.useQuery({
where: {
deletedAt: null,
OR: searchText ? [
{ username: { contains: searchText } },
{ gender: { contains: searchText } },
{ age: !isNaN(Number(searchText)) ? Number(searchText) : undefined },
{ relax: { contains: searchText } },
{ library: { name: { contains: searchText } } }
] : undefined
},
include: {
library: true,
terms: { // 添加分类信息
where: { deletedAt: null },
include: {
taxonomy: true
}
}
},
});
// 查询所有读者分类
const { data: readerTypes } = api.reader.getReaderTypes.useQuery();
// 分配读者分类
const assignTypeMutation = api.reader.assignReaderType.useMutation({
onSuccess: () => {
message.success('分类分配成功');
refetch();
},
onError: (error) => {
message.error('分类分配失败:' + error.message);
}
});
// 移除读者分类
const removeTypeMutation = api.reader.removeReaderType.useMutation({
onSuccess: () => {
message.success('分类移除成功');
refetch();
},
onError: (error) => {
message.error('分类移除失败:' + error.message);
}
});
if (isError && error) {
message.error('获取数据失败:' + error.message);
}
// 处理分类变更
const handleTypeChange = async (readerId: string, termId: string, checked: boolean) => {
if (checked) {
await assignTypeMutation.mutateAsync({ readerId, termId });
} else {
await removeTypeMutation.mutateAsync({ readerId, termId });
}
};
// 独立查询所有俱乐部数据
const { data: libraryList, isLoading: librarysLoading } = api.library.findMany.useQuery({
where: {
deletedAt: null,
},
});
if (isError && error) {
message.error('获取数据失败:' + error.message);
}
// 创建数据
const createMutation = api.reader.create.useMutation({
onSuccess: () => {
message.success('创建成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
message.error('创建失败:' + error.message);
}
});
// 更新数据
const updateMutation = api.reader.update.useMutation({
onSuccess: () => {
message.success('更新成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
message.error('更新失败:' + error.message);
}
});
// 删除数据
const deleteMutation = api.reader.softDeleteByIds.useMutation({
onSuccess: () => {
message.success('删除成功');
refetch();
},
onError: (error) => {
message.error('删除失败:' + error.message);
}
});
// 处理搜索
const handleSearch = () => {
refetch();
};
// 处理添加
const handleAdd = () => {
setEditingReader(undefined);
setModalOpen(true);
};
// 处理表单提交
const handleModalOk = (values: any) => {
if (editingReader) {
updateMutation.mutate({
where: { id: editingReader.id },
data: {
name: values.name,
password: values.password,
username: values.username,
gender: values.gender,
age: Number(values.age), // 确保 age 是数字类型
relax: values.relax || null,
libraryId: values.libraryId,
terms: values.terms ? {
connect: values.terms.map((id: string) => ({ id }))
} : undefined
}
});
} else {
createMutation.mutate({
name: values.name,
password: values.password,
username: values.username,
gender: values.gender,
age: Number(values.age), // 确保 age 是数字类型
relax: values.relax || null,
libraryId: values.libraryId,
terms: values.terms ? {
connect: values.terms.map((id: string) => ({ id }))
} : undefined
});
} console.log(values);
};
return (
<div className="p-6">
<SearchBar
searchText={searchText}
onSearchTextChange={setSearchText}
onSearch={handleSearch}
onAdd={handleAdd}
onImportSuccess={refetch}
data={data || []}
/>
<ReaderTable
data={data || []}
loading={isLoading}
readerTypes={readerTypes || []}
onEdit={(reader) => {
setEditingReader(reader);
setModalOpen(true);
}}
onDelete={(ids) => deleteMutation.mutate({ ids })}
onTypeChange={handleTypeChange}
/>
<ReaderModal
open={modalOpen}
editingReader={editingReader}
libraryList={libraryList || []} // 传递俱乐部列表
readerTypes={readerTypes || []}
loading={createMutation.isLoading || updateMutation.isLoading}
onOk={handleModalOk}
onCancel={() => setModalOpen(false)}
/>
</div>
);
};
export default ReaderPage;

View File

@ -0,0 +1,153 @@
import { Button, Upload, message } from 'antd';
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import React from 'react';
import { Driver } from './types';
import * as XLSX from 'xlsx';
import { api } from '@nice/client';
interface ImportExportButtonsProps {
onImportSuccess: () => void;
data: Driver[];
}
export const ImportExportButtons: React.FC<ImportExportButtonsProps> = ({
onImportSuccess,
data,
}) => {
const createMutation = api.driver.create.useMutation({
onSuccess: () => {
// 成功时不显示单条消息,让最终统计来显示
},
onError: (error) => {
// 只有当不是用户名重复错误时才显示错误信息
if (!error.message.includes('Unique constraint failed')) {
message.error('导入失败: ' + error.message);
}
}
});
// 添加恢复记录的 mutation
const restoreMutation = api.driver.update.useMutation({
onSuccess: () => {
// 静默成功,不显示消息
},
onError: (error) => {
console.error('恢复记录失败:', error);
}
});
const handleImport = async (file: File) => {
try {
const reader = new FileReader();
reader.onload = async (e) => {
const workbook = XLSX.read(e.target?.result, { type: 'binary' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
let successCount = 0;
let restoredCount = 0;
let errorCount = 0;
// 转换数据格式
const driverData = jsonData.map((row: any) => ({
username: row['用户名'],
showname: row['名称'],
absent: row['是否在位'] === '在位',
}));
// 批量处理
for (const driver of driverData) {
try {
// 先尝试创建新记录
await createMutation.mutateAsync({
data: driver
});
successCount++;
} catch (error: any) {
// 如果是用户名重复错误
if (error.message.includes('Unique constraint failed')) {
try {
// 尝试恢复已删除的记录
await restoreMutation.mutateAsync({
where: {
username: driver.username,
},
data: {
deletedAt: null,
showname: driver.showname,
absent: driver.absent,
}
});
restoredCount++;
} catch (restoreError) {
errorCount++;
console.error('恢复记录失败:', restoreError);
}
} else {
errorCount++;
console.error('创建记录失败:', error);
}
}
}
// 显示导入结果
if (successCount > 0 || restoredCount > 0) {
let successMessage = [];
if (successCount > 0) {
successMessage.push(`新增 ${successCount}`);
}
if (restoredCount > 0) {
successMessage.push(`恢复 ${restoredCount}`);
}
message.success(`导入完成:${successMessage.join('')}`);
onImportSuccess();
}
if (errorCount > 0) {
message.warning(`${errorCount} 条记录导入失败`);
}
};
reader.readAsBinaryString(file);
} catch (error) {
message.error('文件读取失败');
}
return false;
};
const handleExport = () => {
try {
const exportData = data.map(driver => ({
'用户名': driver.username,
'名称': driver.showname,
'是否在位': driver.absent ? '是' : '否'
}));
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, '员工列表');
XLSX.writeFile(workbook, `员工列表_${new Date().toLocaleDateString()}.xlsx`);
message.success('导出成功');
} catch (error) {
message.error('导出失败: ' + (error as Error).message);
}
};
return (
<div className="flex space-x-2">
<Upload
accept=".xlsx,.xls"
showUploadList={false}
beforeUpload={handleImport}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<Button
icon={<DownloadOutlined />}
onClick={handleExport}
>
</Button>
</div>
);
};

View File

@ -0,0 +1,133 @@
import { Modal, Form, Input, Select } from 'antd';
import { Library, Reader } from './types';
import React from 'react';
interface ReaderModalProps {
open: boolean;
loading?: boolean;
editingReader?: Reader;
libraryList?: Library[];
readerTypes: any[];
onOk: (values: any) => void;
onCancel: () => void;
}
export const ReaderModal: React.FC<ReaderModalProps> = ({
open,
loading,
editingReader,
libraryList,
readerTypes,
onOk,
onCancel,
}) => {
const [form] = Form.useForm();
return (
<Modal
title={editingReader ? '编辑读者' : '新增读者'}
open={open}
onOk={() => form.submit()}
onCancel={onCancel}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
initialValues={editingReader}
onFinish={(values) => {
// 确保数据格式正确
const formData = {
name: values.name,
password: values.password,
username:values.username,
gender: values.gender,
age: values.age,
relax: values.relax|| null,
libraryId: values.libraryId,
};
onOk(formData);
}}
>
<Form.Item
name="name"
label="姓名"
rules={[{ required: true, message: '请输入姓名' }]}
>
<Input />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input />
</Form.Item>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input />
</Form.Item>
<Form.Item
name="gender"
label="性别"
rules={[{ required: true, message: '请输入性别' }]}
>
<Input />
</Form.Item>
<Form.Item
name="age"
label="年龄"
rules={[{ required: true, message: '请输入年龄' }]}
>
<Input />
</Form.Item>
<Form.Item
name="relax"
label="联系方式"
>
<Input.TextArea />
</Form.Item>
<Form.Item
name="libraryId"
label="所属俱乐部"
rules={[{ required: true, message: '请选择所属图书馆' }]}
>
<Select
allowClear
placeholder="请选择图书馆"
>
{libraryList?.map(library => (
<Select.Option key={library.id} value={library.id}>
{library.name} {/* 使用 club 关联的 name */}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="terms"
label="读者类型"
>
<Select
mode="multiple"
placeholder="请选择读者类型"
options={readerTypes?.map(type => ({
label: type.name,
value: type.id
}))}
/>
</Form.Item>
</Form>
</Modal>
);
};

View File

@ -0,0 +1,115 @@
import { Table, Space, Button, Popconfirm,Select } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Reader } from './types';
import React from 'react';
interface ReaderTableProps {
data: Reader[];
loading?: boolean;
readerTypes: any[];
onEdit: (record: Reader) => void;
onDelete: (ids: string[]) => void;
onTypeChange: (readerId: string, termId: string, checked: boolean) => void;
}
export const ReaderTable: React.FC<ReaderTableProps> = ({
data,
loading,
readerTypes,
onEdit,
onDelete,
onTypeChange,
}) => {
const columns: ColumnsType<Reader> = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
},
{
title: '年龄',
dataIndex: 'age',
key: 'age',
},
{
title: '联系方式',
dataIndex: 'relax',
key: 'relax',
},
{
title: '所属图书馆',
dataIndex: ['library', 'name'],
key: 'libraryName',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '读者类型',
dataIndex: 'terms',
render: (terms: any[], record: Reader) => (
<Select
mode="multiple"
value={terms?.map(t => t.id)}
onChange={(values) => {
// 处理分类变更
values.forEach(termId => {
if (!terms?.find(t => t.id === termId)) {
onTypeChange(record.id, termId, true);
}
});
terms?.forEach(term => {
if (!values.includes(term.id)) {
onTypeChange(record.id, term.id, false);
}
});
}}
options={readerTypes?.map(type => ({
label: type.name,
value: type.id
}))}
/>
)
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space size="middle">
<Button type="link" onClick={() => onEdit(record)}>
</Button>
<Popconfirm
title="确定要删除吗?"
onConfirm={() => onDelete([record.id])}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Table
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
/>
);
};

View File

@ -0,0 +1,55 @@
import { Button, Input, Space } from 'antd';
import { SearchOutlined, PlusOutlined } from '@ant-design/icons';
import React from 'react';
// import { ImportExportButtons } from './ImportExportButtons';
// Define or import the Staff type
// interface Driver {
// id: string;
// name: string;
// gender: string;
// age: number;
// clubName: string;
// }
interface SearchBarProps {
searchText: string;
onSearchTextChange: (text: string) => void;
onSearch: () => void;
onAdd: () => void;
onImportSuccess: () => void;
// data: Driver[];
}
export const SearchBar: React.FC<SearchBarProps> = ({
searchText,
onSearchTextChange,
onSearch,
onAdd,
onImportSuccess,
// data,
}) => {
return (
<div className="mb-4 flex justify-between">
<Space>
<Input
placeholder="搜索读者"
value={searchText}
onChange={(e) => onSearchTextChange(e.target.value)}
onPressEnter={onSearch}
prefix={<SearchOutlined />}
/>
<Button type="primary" onClick={onSearch}></Button>
</Space>
<Space>
{/* <ImportExportButtons
onImportSuccess={onImportSuccess}
data={data}
/> */}
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
</Button>
</Space>
</div>
);
};

View File

@ -0,0 +1,22 @@
export interface Reader {
id : string ;
name : string;
password: string;
username : string;
gender : string;
age : number;
relax : string;
libraryId : string;
library : Library;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date | null;
}
export interface Library {
id: string;
name: string;
}

View File

@ -0,0 +1,188 @@
import { useState } from 'react';
import { UserTable } from '@web/src/app/main/user/components/UserTable';
import { UserModal } from '@web/src/app/main/user/components/UserModal';
import { SearchBar } from '@web/src/app/main/user/components/SearchBar';
import { User, Library } from '@web/src/app/main/user/components/types';
import { api } from '@nice/client';
import { message } from 'antd';
import { useAuth } from '@web/src/providers/auth-provider';
import { Character } from '@nice/common'; // 或者从正确的路径导入
export const UserPage = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | undefined>();
const [searchText, setSearchText] = useState('');
const [roleFilter, setRoleFilter] = useState<string | null>(null);
const { user: currentUser } = useAuth();
// 查询条件
const whereCondition = {
deletedAt: null,
OR: searchText.trim() ? [
{ name: { contains: searchText } },
{ username: { contains: searchText } },
] : undefined,
...(roleFilter ? { role: { equals: roleFilter as Character } } : {}),
};
// 查询数据
const { data, isLoading, refetch, isError, error } = api.user.findMany.useQuery({
where: whereCondition,
include: {
reader: {
include: {
library: true
}
},
library: true
},
});
if (isError && error) {
message.error('获取数据失败:' + error.message);
}
// 查询图书馆列表
const { data: libraryList } = api.library.findMany.useQuery({
where: {
deletedAt: null,
...(currentUser?.role === 'LIB_ADMIN' ? { adminId: currentUser.id } : {})
}
});
// 创建数据
const createMutation = api.user.create.useMutation({
onSuccess: () => {
message.success('创建成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
message.error('创建失败:' + error.message);
}
});
// 更新数据
const updateMutation = api.user.update.useMutation({
onSuccess: () => {
message.success('更新成功');
setModalOpen(false);
refetch();
},
onError: (error) => {
message.error('更新失败:' + error.message);
}
});
// 删除数据
const deleteMutation = api.user.softDeleteByIds.useMutation({
onSuccess: () => {
message.success('删除成功');
refetch();
},
onError: (error) => {
message.error('删除失败:' + error.message);
}
});
// 处理搜索
const handleSearch = () => {
refetch();
};
// 处理添加
const handleAdd = () => {
setEditingUser(undefined);
setModalOpen(true);
};
// 处理模态框取消
const handleCancelModal = () => {
setModalOpen(false);
setEditingUser(undefined);
};
// 处理表单提交
const handleModalOk = (values: any) => {
if (editingUser) {
const updateData: any = {
name: values.name,
gender: values.gender,
age: values.age,
role: values.role,
relax: values.relax,
};
// 如果提供了密码,则更新密码
if (values.password) {
updateData.password = values.password;
}
// 如果是图书馆管理员,更新关联的图书馆
if (values.role === 'LIB_ADMIN') {
updateData.library = {
connect: { id: values.libraryId }
};
}
updateMutation.mutate({
where: { id: editingUser.id },
data: updateData
});
} else {
const createData: any = {
name: values.name,
username: values.username,
password: values.password,
gender: values.gender,
age: values.age,
role: values.role,
relax: values.relax
};
// 如果是图书馆管理员,关联到图书馆
if (values.role === 'LIB_ADMIN' && values.libraryId) {
createData.library = {
connect: { id: values.libraryId }
};
}
createMutation.mutate(createData);
}
};
return (
<div className="p-6">
<SearchBar
searchText={searchText}
roleFilter={roleFilter}
onSearchTextChange={setSearchText}
onRoleFilterChange={setRoleFilter}
onSearch={handleSearch}
onAdd={handleAdd}
onImportSuccess={refetch}
/>
<UserTable
data={(data || []) as User[]}
loading={isLoading}
onEdit={(user) => {
setEditingUser(user);
setModalOpen(true);
}}
onDelete={(ids) => deleteMutation.mutate({ ids })}
/>
<UserModal
open={modalOpen}
editingUser={editingUser}
libraryList={(libraryList || []) as Library[]}
loading={createMutation.isPending || updateMutation.isPending}
onOk={handleModalOk}
onCancel={handleCancelModal}
/>
</div>
);
};
export default UserPage;

View File

@ -0,0 +1,153 @@
import { Button, Upload, message } from 'antd';
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import React from 'react';
import { Driver } from './types';
import * as XLSX from 'xlsx';
import { api } from '@nice/client';
interface ImportExportButtonsProps {
onImportSuccess: () => void;
data: Driver[];
}
export const ImportExportButtons: React.FC<ImportExportButtonsProps> = ({
onImportSuccess,
data,
}) => {
const createMutation = api.driver.create.useMutation({
onSuccess: () => {
// 成功时不显示单条消息,让最终统计来显示
},
onError: (error) => {
// 只有当不是用户名重复错误时才显示错误信息
if (!error.message.includes('Unique constraint failed')) {
message.error('导入失败: ' + error.message);
}
}
});
// 添加恢复记录的 mutation
const restoreMutation = api.driver.update.useMutation({
onSuccess: () => {
// 静默成功,不显示消息
},
onError: (error) => {
console.error('恢复记录失败:', error);
}
});
const handleImport = async (file: File) => {
try {
const reader = new FileReader();
reader.onload = async (e) => {
const workbook = XLSX.read(e.target?.result, { type: 'binary' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
let successCount = 0;
let restoredCount = 0;
let errorCount = 0;
// 转换数据格式
const driverData = jsonData.map((row: any) => ({
username: row['用户名'],
showname: row['名称'],
absent: row['是否在位'] === '在位',
}));
// 批量处理
for (const driver of driverData) {
try {
// 先尝试创建新记录
await createMutation.mutateAsync({
data: driver
});
successCount++;
} catch (error: any) {
// 如果是用户名重复错误
if (error.message.includes('Unique constraint failed')) {
try {
// 尝试恢复已删除的记录
await restoreMutation.mutateAsync({
where: {
username: driver.username,
},
data: {
deletedAt: null,
showname: driver.showname,
absent: driver.absent,
}
});
restoredCount++;
} catch (restoreError) {
errorCount++;
console.error('恢复记录失败:', restoreError);
}
} else {
errorCount++;
console.error('创建记录失败:', error);
}
}
}
// 显示导入结果
if (successCount > 0 || restoredCount > 0) {
let successMessage = [];
if (successCount > 0) {
successMessage.push(`新增 ${successCount}`);
}
if (restoredCount > 0) {
successMessage.push(`恢复 ${restoredCount}`);
}
message.success(`导入完成:${successMessage.join('')}`);
onImportSuccess();
}
if (errorCount > 0) {
message.warning(`${errorCount} 条记录导入失败`);
}
};
reader.readAsBinaryString(file);
} catch (error) {
message.error('文件读取失败');
}
return false;
};
const handleExport = () => {
try {
const exportData = data.map(driver => ({
'用户名': driver.username,
'名称': driver.showname,
'是否在位': driver.absent ? '是' : '否'
}));
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, '员工列表');
XLSX.writeFile(workbook, `员工列表_${new Date().toLocaleDateString()}.xlsx`);
message.success('导出成功');
} catch (error) {
message.error('导出失败: ' + (error as Error).message);
}
};
return (
<div className="flex space-x-2">
<Upload
accept=".xlsx,.xls"
showUploadList={false}
beforeUpload={handleImport}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<Button
icon={<DownloadOutlined />}
onClick={handleExport}
>
</Button>
</div>
);
};

View File

@ -0,0 +1,75 @@
import { Button, Input, Space, Select } from 'antd';
import { SearchOutlined, PlusOutlined } from '@ant-design/icons';
import React from 'react';
// import { ImportExportButtons } from './ImportExportButtons';
// Define or import the Staff type
// interface Driver {
// id: string;
// name: string;
// gender: string;
// age: number;
// clubName: string;
// }
interface SearchBarProps {
searchText: string;
roleFilter: string | null;
onSearchTextChange: (text: string) => void;
onRoleFilterChange: (role: string | null) => void;
onSearch: () => void;
onAdd: () => void;
onImportSuccess: () => void;
// data: Driver[];
}
export const SearchBar: React.FC<SearchBarProps> = ({
searchText,
roleFilter,
onSearchTextChange,
onRoleFilterChange,
onSearch,
onAdd,
onImportSuccess,
// data,
}) => {
// 角色选项
const roleOptions = [
{ label: '全部', value: '' },
{ label: '系统管理员', value: 'ADMIN' },
{ label: '图书馆管理员', value: 'LIB_ADMIN' },
{ label: '读者', value: 'READER' }
];
return (
<div className="mb-4 flex justify-between">
<Space>
<Input
placeholder="搜索用户名或姓名"
value={searchText}
onChange={(e) => onSearchTextChange(e.target.value)}
onPressEnter={onSearch}
prefix={<SearchOutlined />}
style={{ width: 200 }}
/>
<Select
placeholder="选择角色"
value={roleFilter || ''}
onChange={(value) => onRoleFilterChange(value || null)}
options={roleOptions}
style={{ width: 150 }}
/>
<Button type="primary" onClick={onSearch}></Button>
</Space>
<Space>
{/* <ImportExportButtons
onImportSuccess={onImportSuccess}
data={data}
/> */}
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
</Button>
</Space>
</div>
);
};

View File

@ -0,0 +1,172 @@
import { Modal, Form, Input, Select, InputNumber } from 'antd';
import { User, Library } from './types';
import React, { useEffect } from 'react';
interface UserModalProps {
open: boolean;
loading?: boolean;
editingUser?: User;
libraryList?: Library[];
onOk: (values: any) => void;
onCancel: () => void;
}
export const UserModal: React.FC<UserModalProps> = ({
open,
loading,
editingUser,
libraryList,
onOk,
onCancel,
}) => {
const [form] = Form.useForm();
// 当编辑的用户变化时,重置表单
useEffect(() => {
if (open) {
form.resetFields();
// 如果是编辑模式,设置初始值
if (editingUser) {
const formValues = {
...editingUser,
// 不回显密码
password: undefined,
};
form.setFieldsValue(formValues);
}
}
}, [open, editingUser, form]);
// 角色选项
const roleOptions = [
{ label: '系统管理员', value: 'ADMIN' },
{ label: '图书馆管理员', value: 'LIB_ADMIN' },
{ label: '读者', value: 'READER' }
];
// 当角色变化时,重置相关字段
const handleRoleChange = (value: string) => {
if (value === 'LIB_ADMIN') {
// 图书馆管理员需要选择图书馆
form.setFieldsValue({ libraryId: undefined });
} else {
// 其他角色不需要选择图书馆
form.setFieldsValue({ libraryId: undefined });
}
};
return (
<Modal
title={editingUser ? '编辑用户' : '新增用户'}
open={open}
onOk={() => form.submit()}
onCancel={onCancel}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
onFinish={(values) => {
// 确保数据格式正确
const formData = {
...values,
age: parseInt(values.age, 10),
};
onOk(formData);
}}
>
<Form.Item
name="name"
label="姓名"
rules={[{ required: true, message: '请输入姓名' }]}
>
<Input />
</Form.Item>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[
{
required: !editingUser,
message: '请输入密码'
}
]}
>
<Input.Password placeholder={editingUser ? '不修改请留空' : '请输入密码'} />
</Form.Item>
<Form.Item
name="gender"
label="性别"
rules={[{ required: true, message: '请输入性别' }]}
>
<Select>
<Select.Option value="男"></Select.Option>
<Select.Option value="女"></Select.Option>
<Select.Option value="其他"></Select.Option>
</Select>
</Form.Item>
<Form.Item
name="age"
label="年龄"
rules={[{ required: true, message: '请输入年龄' }]}
>
<InputNumber min={1} max={120} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="relax"
label="联系方式"
>
<Input.TextArea />
</Form.Item>
<Form.Item
name="role"
label="角色"
rules={[{ required: true, message: '请选择角色' }]}
>
<Select options={roleOptions} onChange={handleRoleChange} />
</Form.Item>
{/* 仅当角色为图书馆管理员时显示图书馆选择 */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.role !== currentValues.role}
>
{({ getFieldValue }) =>
getFieldValue('role') === 'LIB_ADMIN' ? (
<Form.Item
name="libraryId"
label="管理的图书馆"
rules={[{ required: true, message: '请选择管理的图书馆' }]}
>
<Select
allowClear
placeholder="请选择图书馆"
>
{libraryList?.map(library => (
<Select.Option key={library.id} value={library.id}>
{library.name}
</Select.Option>
))}
</Select>
</Form.Item>
) : null
}
</Form.Item>
</Form>
</Modal>
);
};

View File

@ -0,0 +1,116 @@
import { Table, Space, Button, Popconfirm, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { User } from './types';
import React from 'react';
interface UserTableProps {
data: User[];
loading?: boolean;
onEdit: (record: User) => void;
onDelete: (ids: string[]) => void;
}
export const UserTable: React.FC<UserTableProps> = ({
data,
loading,
onEdit,
onDelete,
}) => {
// 角色显示标签颜色映射
const roleColors = {
ADMIN: 'red',
LIB_ADMIN: 'blue',
READER: 'green'
};
// 角色名称映射
const roleLabels = {
ADMIN: '系统管理员',
LIB_ADMIN: '图书馆管理员',
READER: '读者'
};
const columns: ColumnsType<User> = [
{
title: '姓名',
dataIndex: 'name',
key: 'name',
},
{
title: '用户名',
dataIndex: 'username',
key: 'username',
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
},
{
title: '年龄',
dataIndex: 'age',
key: 'age',
},
{
title: '联系方式',
dataIndex: 'relax',
key: 'relax',
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: (role: string) => (
<Tag color={roleColors[role as keyof typeof roleColors]}>
{roleLabels[role as keyof typeof roleLabels]}
</Tag>
),
},
{
title: '关联信息',
key: 'relation',
render: (_, record) => {
if (record.role === 'LIB_ADMIN' && record.library) {
return <span>: {record.library.name}</span>;
} else if (record.role === 'READER' && record.reader) {
return <span>ID: {record.reader.id}</span>;
}
return '-';
}
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space size="middle">
<Button type="link" onClick={() => onEdit(record)}>
</Button>
<Popconfirm
title="确定要删除吗?"
onConfirm={() => onDelete([record.id])}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Table
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
/>
);
};

View File

@ -0,0 +1,43 @@
// apps/web/src/app/main/user/components/types.ts
export interface User {
id: string;
name: string;
username: string;
password?: string; // 编辑时可能不需要修改密码
gender: string;
age: number;
relax?: string | null;
role: 'ADMIN' | 'LIB_ADMIN' | 'READER';
reader?: Reader | null;
library?: Library | null;
createdAt: Date | string;
updatedAt: Date | string;
deletedAt?: Date | string | null;
}
export interface Reader {
id: string;
name: string;
password: string;
username: string;
gender: string;
age: number;
relax: string;
libraryId: string;
library: Library;
createdAt: Date | string;
updatedAt: Date | string;
deletedAt?: Date | string | null;
}
export interface Library {
id: string;
name: string;
address: string;
description: string;
createdAt: Date | string;
updatedAt: Date | string;
deletedAt?: Date | string | null;
}

View File

@ -19,6 +19,14 @@ import { adminRoute } from "./admin-route";
import AdminLayout from "../components/layout/admin/AdminLayout";
import SystemLogPage from "../app/main/systemlog/SystemLogPage";
import TestPage from "../app/main/Test/Page";
import LibraryPage from "../app/main/library/Page";
import ReaderPage from "../app/main/reader/Page";
import BookPage from "../app/main/book/Page";
import BorrowRecordPage from "../app/main/borrowRecord/Page";
import CommentPage from "../app/main/comment/Page";
import UserPage from "../app/main/user/Page";
import WithAuth from "../components/utils/with-auth";
interface CustomIndexRouteObject extends IndexRouteObject {
@ -78,6 +86,35 @@ export const routes: CustomRouteObject[] = [
path: "/test",
element: <TestPage></TestPage>,
},
{
path: "/li/user",
element: <UserPage></UserPage>,
},
{
path: "/li/library",
element: <WithAuth><LibraryPage></LibraryPage></WithAuth>,
},
{
path: "/li/library",
element: <LibraryPage></LibraryPage>,
},
{
path: "/li/reader",
element: <ReaderPage></ReaderPage>,
},
{
path: "/li/book",
element: <BookPage></BookPage>,
},
{
path: "/li/borrowRecord",
element: <BorrowRecordPage></BorrowRecordPage>,
},
{
path: "/li/comment",
element: <CommentPage></CommentPage>,
},
],
},
],

View File

@ -2,7 +2,7 @@ server {
# 监听80端口
listen 80;
# 服务器域名/IP地址使用环境变量
server_name 192.168.239.194;
server_name 192.168.202.194;
# 基础性能优化配置
# 启用tcp_nopush以优化数据发送
@ -100,7 +100,7 @@ server {
# 仅供内部使用
internal;
# 代理到认证服务
proxy_pass http://192.168.239.194:3000/auth/file;
proxy_pass http://192.168.202.194:3000/auth/file;
# 请求优化:不传递请求体
proxy_pass_request_body off;

View File

@ -1,475 +0,0 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Taxonomy {
id String @id @default(cuid())
name String @unique
slug String @unique @map("slug")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
terms Term[]
objectType String[] @map("object_type")
order Float? @map("order")
@@index([order, deletedAt])
@@map("taxonomy")
}
model Term {
id String @id @default(cuid())
name String
taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id])
taxonomyId String? @map("taxonomy_id")
order Float? @map("order")
description String?
parentId String? @map("parent_id")
parent Term? @relation("ChildParent", fields: [parentId], references: [id], onDelete: Cascade)
children Term[] @relation("ChildParent")
ancestors TermAncestry[] @relation("DescendantToAncestor")
descendants TermAncestry[] @relation("AncestorToDescendant")
domainId String? @map("domain_id")
domain Department? @relation("TermDom", fields: [domainId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
createdBy String? @map("created_by")
depts Department[] @relation("department_term")
hasChildren Boolean? @default(false) @map("has_children")
courses Course[] @relation("course_term")
@@index([name]) // 对name字段建立索引以加快基于name的查找速度
@@index([parentId]) // 对parentId字段建立索引以加快基于parentId的查找速度
@@map("term")
}
model TermAncestry {
id String @id @default(cuid())
ancestorId String? @map("ancestor_id")
descendantId String @map("descendant_id")
relDepth Int @map("rel_depth")
ancestor Term? @relation("AncestorToDescendant", fields: [ancestorId], references: [id])
descendant Term @relation("DescendantToAncestor", fields: [descendantId], references: [id])
// 索引建议
@@index([ancestorId]) // 针对祖先的查询
@@index([descendantId]) // 针对后代的查询
@@index([ancestorId, descendantId]) // 组合索引,用于查询特定的祖先-后代关系
@@index([relDepth]) // 根据关系深度的查询
@@map("term_ancestry")
}
model Staff {
id String @id @default(cuid())
showname String? @map("showname")
username String @unique @map("username")
avatar String? @map("avatar")
password String? @map("password")
phoneNumber String? @unique @map("phone_number")
domainId String? @map("domain_id")
deptId String? @map("dept_id")
domain Department? @relation("DomainStaff", fields: [domainId], references: [id])
department Department? @relation("DeptStaff", fields: [deptId], references: [id])
order Float?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
enabled Boolean? @default(true)
deletedAt DateTime? @map("deleted_at")
officerId String? @map("officer_id")
watchedPost Post[] @relation("post_watch_staff")
visits Visit[]
posts Post[]
sentMsgs Message[] @relation("message_sender")
receivedMsgs Message[] @relation("message_receiver")
registerToken String?
enrollments Enrollment[]
teachedCourses CourseInstructor[]
ownedResources Resource[]
@@index([officerId])
@@index([deptId])
@@index([domainId])
@@index([username])
@@index([order])
@@map("staff")
}
model Department {
id String @id @default(cuid())
name String
order Float?
ancestors DeptAncestry[] @relation("DescendantToAncestor")
descendants DeptAncestry[] @relation("AncestorToDescendant")
parentId String? @map("parent_id")
parent Department? @relation("ChildParent", fields: [parentId], references: [id])
children Department[] @relation("ChildParent")
domainId String? @map("domain_id")
domainTerms Term[] @relation("TermDom")
deletedAt DateTime? @map("deleted_at")
isDomain Boolean? @default(false) @map("is_domain")
domainStaffs Staff[] @relation("DomainStaff")
deptStaffs Staff[] @relation("DeptStaff")
terms Term[] @relation("department_term")
watchedPost Post[] @relation("post_watch_dept")
hasChildren Boolean? @default(false) @map("has_children")
@@index([parentId])
@@index([isDomain])
@@index([name])
@@index([order])
@@map("department")
}
model DeptAncestry {
id String @id @default(cuid())
ancestorId String? @map("ancestor_id")
descendantId String @map("descendant_id")
relDepth Int @map("rel_depth")
ancestor Department? @relation("AncestorToDescendant", fields: [ancestorId], references: [id])
descendant Department @relation("DescendantToAncestor", fields: [descendantId], references: [id])
// 索引建议
@@index([ancestorId]) // 针对祖先的查询
@@index([descendantId]) // 针对后代的查询
@@index([ancestorId, descendantId]) // 组合索引,用于查询特定的祖先-后代关系
@@index([relDepth]) // 根据关系深度的查询
@@map("dept_ancestry")
}
model RoleMap {
id String @id @default(cuid())
objectId String @map("object_id")
roleId String @map("role_id")
domainId String? @map("domain_id")
objectType String @map("object_type")
role Role @relation(fields: [roleId], references: [id])
@@index([domainId])
@@index([objectId])
@@map("rolemap")
}
model Role {
id String @id @default(cuid())
name String @unique @map("name")
permissions String[] @default([]) @map("permissions")
roleMaps RoleMap[]
system Boolean? @default(false) @map("system")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@map("role")
}
model AppConfig {
id String @id @default(cuid())
slug String @unique
title String?
description String?
meta Json?
@@map("app_config")
}
model Post {
// 字符串类型字段
id String @id @default(cuid()) // 帖子唯一标识,使用 cuid() 生成默认值
type String? // 帖子类型,可为空
title String? // 帖子标题,可为空
content String? // 帖子内容,可为空
domainId String? @map("domain_id")
// 日期时间类型字段
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
// 整数类型字段
rating Int // 评分(1-5星)
// 关系类型字段
authorId String? @map("author_id")
author Staff? @relation(fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型
visits Visit[] // 访问记录,关联 Visit 模型
courseId String @map("course_id")
course Course @relation(fields: [courseId], references: [id]) // 关联课程,关联 Course 模型
parentId String? @map("parent_id")
parent Post? @relation("PostChildren", fields: [parentId], references: [id]) // 父级帖子,关联 Post 模型
children Post[] @relation("PostChildren") // 子级帖子列表,关联 Post 模型
lectureId String? @map("lecture_id")
lecture Lecture? @relation(fields: [lectureId], references: [id]) // 关联讲座,关联 Lecture 模型
resources Resource[] // 附件列表
watchableStaffs Staff[] @relation("post_watch_staff") // 可观看的员工列表,关联 Staff 模型
watchableDepts Department[] @relation("post_watch_dept") // 可观看的部门列表,关联 Department 模型
// 复合索引
@@index([type, domainId]) // 类型和域组合查询
@@index([authorId, type]) // 作者和类型组合查询
@@index([parentId, type]) // 父级帖子和创建时间索引
// 时间相关索引
@@index([createdAt]) // 按创建时间倒序索引
@@index([updatedAt]) // 按更新时间倒序索引
}
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")
lecture Lecture? @relation(fields: [lectureId], references: [id], onDelete: Cascade)
lectureId String? @map("lecture_id") // 课时ID
// 学习数据
// progress Float? @default(0) @map("progress") // 完成进度(0-100%)
// isCompleted Boolean? @default(false) @map("is_completed") // 是否完成
// lastPosition Int? @default(0) @map("last_position") // 视频播放位置(秒)
// totalWatchTime Int? @default(0) @map("total_watch_time") // 总观看时长(秒)
// // 时间记录
// lastWatchedAt DateTime? @map("last_watched_at") // 最后观看时间
createdAt DateTime @default(now()) @map("created_at") // 创建时间
updatedAt DateTime @updatedAt @map("updated_at") // 更新时间
meta Json?
@@index([postId, type, visitorId])
@@index([messageId, type, visitorId])
@@map("visit")
}
model Course {
id String @id @default(cuid()) @map("id") // 课程唯一标识符
title String? @map("title") // 课程标题
subTitle String? @map("sub_title") // 课程副标题(可选)
description String? @map("description") // 课程详细描述
thumbnail String? @map("thumbnail") // 课程封面图片URL(可选)
level String? @map("level") // 课程难度等级
// 课程内容组织结构
terms Term[] @relation("course_term") // 课程学期
instructors CourseInstructor[] // 课程讲师团队
sections Section[] // 课程章节结构
lectures Lecture[]
enrollments Enrollment[] // 学生报名记录
reviews Post[] // 学员课程评价
// 课程规划与目标设定
requirements String[] @map("requirements") // 课程学习前置要求
objectives String[] @map("objectives") // 具体的学习目标
// 课程状态管理
status String? @map("status") // 课程状态(如:草稿/已发布/已归档)
featured Boolean? @default(false) @map("featured") // 是否为精选推荐课程
// 生命周期时间戳
createdAt DateTime? @default(now()) @map("created_at") // 创建时间
updatedAt DateTime? @updatedAt @map("updated_at") // 最后更新时间
publishedAt DateTime? @map("published_at") // 发布时间
deletedAt DateTime? @map("deleted_at") // 软删除时间
meta Json?
// 课程统计指标
// totalDuration Int? @default(0) @map("total_duration") // 课程总时长(分钟)
// totalLectures Int? @default(0) @map("total_lectures") // 总课时数
// averageRating Float? @default(0) @map("average_rating") // 平均评分(1-5分)
// numberOfReviews Int? @default(0) @map("number_of_reviews") // 评价总数
// numberOfStudents Int? @default(0) @map("number_of_students") // 学习人数
// completionRate Float? @default(0) @map("completion_rate") // 完课率(0-100%)
// 数据库索引优化
@@index([status]) // 课程状态索引,用于快速筛选
@@index([level]) // 难度等级索引,用于分类查询
@@index([featured]) // 精选标记索引,用于首页推荐
@@map("course")
}
model Section {
id String @id @default(cuid()) @map("id")
title String @map("title")
description String? @map("description")
objectives String[] @map("objectives")
order Float? @default(0) @map("order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
// 关联关系
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
courseId String @map("course_id")
lectures Lecture[]
meta Json?
// totalDuration Int @default(0) @map("total_duration")
// totalLectures Int @default(0) @map("total_lectures")
@@index([courseId, order])
@@map("section")
}
model Lecture {
id String @id @default(cuid()) @map("id")
title String @map("title")
description String? @map("description")
content String? @map("content")
order Float? @default(0) @map("order")
duration Int @map("duration")
type String @map("type")
videoUrl String? @map("video_url")
videoThumbnail String? @map("video_thumbnail")
publishedAt DateTime? @map("published_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
// 关联关系
resources Resource[]
section Section? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
sectionId String? @map("section_id")
course Course? @relation(fields: [courseId], references: [id], onDelete: Cascade)
courseId String? @map("course_id")
comments Post[]
visits Visit[]
@@index([sectionId, order])
@@index([type, publishedAt])
@@map("lecture")
}
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")
course Course @relation(fields: [courseId], references: [id])
courseId String @map("course_id")
@@unique([studentId, courseId])
@@index([status])
@@index([completedAt])
@@map("enrollment")
}
model CourseInstructor {
courseId String @map("course_id")
instructorId String @map("instructor_id")
role String @map("role")
createdAt DateTime @default(now()) @map("created_at")
order Float? @default(0) @map("order")
course Course @relation(fields: [courseId], references: [id])
instructor Staff @relation(fields: [instructorId], references: [id])
@@id([courseId, instructorId])
@@map("course_instructor")
}
model Resource {
id String @id @default(cuid()) @map("id")
title String? @map("title")
description String? @map("description")
type String? @map("type")
fileId String? @unique
url String?
// 元数据
metadata Json? @map("metadata")
// 处理状态控制
status String?
createdAt DateTime? @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
createdBy String? @map("created_by")
updatedBy String? @map("updated_by")
deletedAt DateTime? @map("deleted_at")
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")
lecture Lecture? @relation(fields: [lectureId], references: [id])
lectureId String? @map("lecture_id")
// 索引
@@index([type])
@@index([createdAt])
@@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")
// 关联关系
sourceEdges NodeEdge[] @relation("source_node")
targetEdges NodeEdge[] @relation("target_node")
@@map("node")
}
model NodeEdge {
id String @id @default(cuid()) @map("id")
type String? @map("type")
label String? @map("label")
description String? @map("description")
style Json? @map("style")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
source Node @relation("source_node", fields: [sourceId], references: [id], onDelete: Cascade)
sourceId String @map("source_id")
target Node @relation("target_node", fields: [targetId], references: [id], onDelete: Cascade)
targetId String @map("target_id")
@@unique([sourceId, targetId, type])
@@index([sourceId])
@@index([targetId])
@@map("node_edge")
}

View File

@ -10,6 +10,169 @@ datasource db {
url = env("DATABASE_URL")
}
enum Character {
ADMIN // 系统管理员
LIB_ADMIN // 图书馆管理员
READER // 读者
}
model User {
id String @id @default(cuid())
name String @map("name") // 必填:用户姓名
username String @unique // 必填:登录用户名
password String @map("password") // 必填:登录密码
gender String @map("gender") // 必填:性别
age Int @map("age") // 必填:年龄
relax String? @map("relax") // 选填:备注信息
role Character @default(READER) // 必填:用户角色,默认为读者
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
// 关联关系 - 只在一端指定 onDelete/onUpdate
reader Reader? // 一对一关系
library Library? @relation("LibraryAdmin") // 图书馆管理员关系
@@index([deletedAt])
@@map("user")
}
model Library {
id String @id @default(cuid())
name String @map("name") // 必填:图书馆名称
address String @map("address") // 必填:图书馆地址
description String @map("description") // 必填:图书馆描述
// 关联关系 - 图书馆管理员
admin User? @relation("LibraryAdmin", fields: [adminId], references: [id], onDelete: SetNull, onUpdate: Cascade)
adminId String? @unique @map("admin_id") // 选填图书馆管理员ID用户删除时置空
// 图书馆的读者 - 不在这一端指定 onDelete/onUpdate
readers Reader[] @relation("LibraryReaders")
// 图书馆的图书 - 不在这一端指定 onDelete/onUpdate
books Book[] @relation("LibraryBooks")
// 图书馆的借阅记录和评论 - 不在这一端指定 onDelete/onUpdate
borrowRecords BorrowRecord[] @relation("LibraryBorrows")
comments Comment[] @relation("LibraryComments")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@index([deletedAt])
@@map("library")
}
model Reader {
id String @id @default(cuid())
// 关联到用户表 - 在这一端指定 onDelete/onUpdate
userId String @unique @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
// 关联到图书馆 - 在这一端指定 onDelete/onUpdate
libraryId String? @map("library_id")
library Library? @relation("LibraryReaders", fields: [libraryId], references: [id], onDelete: SetNull, onUpdate: Cascade)
// 读者的借阅记录和评论 - 不在这一端指定 onDelete/onUpdate
borrowRecords BorrowRecord[] @relation("ReaderBorrows")
comments Comment[] @relation("ReaderComments")
// 读者的标签关系
terms Term[] @relation("reader_terms")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@index([libraryId])
@@index([deletedAt])
@@map("reader")
}
model Book {
id String @id @default(cuid())
isbn String @map("isbn") // 必填ISBN编号
bookName String @map("book_name") // 必填:图书名称
author String @map("author") // 必填:作者
// 关联到图书馆 - 在这一端指定 onDelete/onUpdate
libraryId String @map("library_id")
library Library @relation("LibraryBooks", fields: [libraryId], references: [id], onDelete: Cascade, onUpdate: Cascade)
// 图书的借阅记录和评论 - 不在这一端指定 onDelete/onUpdate
borrowRecord BorrowRecord[] @relation("BookBorrows")
comments Comment[] @relation("BookComments")
// 图书的标签关系
terms Term[] @relation("book_terms")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@index([libraryId])
@@index([deletedAt])
@@map("book")
}
model BorrowRecord {
id String @id @default(cuid())
giveTime DateTime @map("give_time") // 必填:借出时间
backTime DateTime @map("back_time") // 必填:应还时间
isbackTime DateTime @map("isback_time") // 必填:实际归还时间
// 关联到图书馆 - 在这一端指定 onDelete/onUpdate
libraryId String? @map("library_id")
library Library? @relation("LibraryBorrows", fields: [libraryId], references: [id], onDelete: SetNull, onUpdate: Cascade)
// 关联到读者 - 在这一端指定 onDelete/onUpdate
readerId String @map("read_id")
reader Reader @relation("ReaderBorrows", fields: [readerId], references: [id], onDelete: Cascade, onUpdate: Cascade)
// 关联到图书 - 在这一端指定 onDelete/onUpdate
bookId String? @map("book_id")
book Book? @relation("BookBorrows", fields: [bookId], references: [id], onDelete: SetNull, onUpdate: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@index([deletedAt])
@@index([readerId])
@@index([bookId])
@@map("borrow_record")
}
model Comment {
id String @id @default(cuid())
content String @map("content") // 必填:评论内容
// 关联到图书馆 - 在这一端指定 onDelete/onUpdate
libraryId String? @map("library_id")
library Library? @relation("LibraryComments", fields: [libraryId], references: [id], onDelete: SetNull, onUpdate: Cascade)
// 关联到读者 - 在这一端指定 onDelete/onUpdate
readerId String @map("read_id")
reader Reader @relation("ReaderComments", fields: [readerId], references: [id], onDelete: Cascade, onUpdate: Cascade)
// 关联到图书 - 在这一端指定 onDelete/onUpdate
bookId String? @map("book_id")
book Book? @relation("BookComments", fields: [bookId], references: [id], onDelete: SetNull, onUpdate: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@index([deletedAt])
@@index([libraryId])
@@index([readerId])
@@index([bookId])
@@map("comment")
}
model Taxonomy {
id String @id @default(cuid())
name String @unique
@ -45,6 +208,8 @@ model Term {
depts Department[] @relation("department_term")
hasChildren Boolean? @default(false) @map("has_children")
posts Post[] @relation("post_term")
reader Reader[] @relation("reader_terms")
book Book[] @relation("book_terms")
@@index([name]) // 对name字段建立索引以加快基于name的查找速度
@@index([parentId]) // 对parentId字段建立索引以加快基于parentId的查找速度
@ -407,12 +572,12 @@ model Department {
domainStaffs Staff[] @relation("DomainStaff")
deptStaffs Staff[] @relation("DeptStaff")
terms Term[] @relation("department_term")
trainPlans TrainPlan[] @relation("TrainPlanDept")
deptDevices Device[] @relation("DeptDevice")
trainPlans TrainPlan[] @relation("TrainPlanDept")
// watchedPost Post[] @relation("post_watch_dept")
hasChildren Boolean? @default(false) @map("has_children")
logs SystemLog[]
logs SystemLog[]
@@index([parentId])
@@index([isDomain])
@ -446,7 +611,7 @@ model StaffFieldValue {
fieldId String @map("field_id")
value String? // 字段值
staff Staff @relation(fields: [staffId], references: [id])
field StaffField @relation("StaffFieldToValue",fields: [fieldId], references: [id], onDelete: Cascade) // 添加级联删除
field StaffField @relation("StaffFieldToValue", fields: [fieldId], references: [id], onDelete: Cascade) // 添加级联删除
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -458,22 +623,22 @@ model StaffFieldValue {
model Staff {
// 基础信息
id String @id @default(cuid())
username String? @unique @map("username")
password String? @map("password")
showname String? @map("showname")
avatar String? @map("avatar")
enabled Boolean? @default(true)
officerId String? @map("officer_id")
phoneNumber String? @map("phone_number")
age Int?@map("age")
sex String?@map("sex")
id String @id @default(cuid())
username String? @unique @map("username")
password String? @map("password")
showname String? @map("showname")
avatar String? @map("avatar")
enabled Boolean? @default(true)
officerId String? @map("officer_id")
phoneNumber String? @map("phone_number")
age Int? @map("age")
sex String? @map("sex")
// 部门关系
domainId String? @map("domain_id")
deptId String? @map("dept_id")
domain Department? @relation("DomainStaff", fields: [domainId], references: [id])
department Department? @relation("DeptStaff", fields: [deptId], references: [id])
order Float?
domainId String? @map("domain_id")
deptId String? @map("dept_id")
domain Department? @relation("DomainStaff", fields: [domainId], references: [id])
department Department? @relation("DeptStaff", fields: [deptId], references: [id])
order Float?
// 关联关系
trainSituations TrainSituation[]
@ -497,7 +662,9 @@ model Staff {
logs SystemLog[] @relation("log_operator")
// 添加自定义字段值关联
fieldValues StaffFieldValue[]
deviceStatus String? @map("device_status")
deviceType String? @map("device_type")
@@index([officerId])
@@index([deptId])
@@index([domainId])
@ -542,9 +709,9 @@ model ShareCode {
model SystemLog {
id String @id @default(cuid())
timestamp DateTime @default(now()) @map("timestamp")
level String? @map("level") // info, warning, error, debug
module String? @map("module") // 操作模块,如"人员管理"
action String? @map("action") // 具体操作,如"新增人员"、"修改人员"
level String? @map("level") // info, warning, error, debug
module String? @map("module") // 操作模块,如"人员管理"
action String? @map("action") // 具体操作,如"新增人员"、"修改人员"
// 操作人信息
operatorId String? @map("operator_id")
@ -562,13 +729,14 @@ model SystemLog {
afterData Json? @map("after_data") // 操作后数据
// 操作结果
status String? @map("status") // success, failure
status String? @map("status") // success, failure
errorMessage String? @map("error_message") // 如果操作失败,记录错误信息
// 关联部门
departmentId String? @map("department_id")
department Department? @relation(fields: [departmentId], references: [id])
message String? @map("message") // 完整的日志文本内容
message String? @map("message") // 完整的日志文本内容
// 优化索引
@@index([timestamp])
@@index([level])
@ -579,3 +747,33 @@ model SystemLog {
@@index([departmentId])
@@map("system_log")
}
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")
@@index([deptId])
@@index([systemType])
@@index([deviceType])
@@index([responsiblePerson])
@@map("device")
}

View File

@ -0,0 +1,179 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
CLUB_AADMIN
DRIVER
GUEST
}
enum Gender {
MALE
FEMALE
}
model User {
id String @id @default(cuid())
username String @unique
password String
role Role @default(GUEST)
driver Driver?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([deletedAt])
}
model Club {
id String @id @default(cuid())
name String
description String
parentId String?
parent Club? @relation("ClubHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children Club[] @relation("ClubHierarchy")
drivers Driver[]
cars Car[]
games Game[]
comments Comment[]
terms Term[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([parentId])
@@index([deletedAt])
}
model Driver {
id String @id @default(cuid())
name String?
gender Gender
age Int
bio String?
clubId String
club Club @relation(fields: [clubId], references: [id])
sorties Sortie[]
comment Comment[]
terms Term[]
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
userId String? @unique
@@index([clubId])
@@index([deletedAt])
}
model Car {
id String @id @default(cuid())
model String
number String
name String
clubId String
club Club @relation(fields: [clubId], references: [id])
sorties Sortie[]
comments Comment[]
terms Term[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([clubId])
@@index([deletedAt])
}
model Game {
id String @id @default(cuid())
name String
startTime DateTime
clubs Club[]
sorties Sortie[]
comments Comment[]
terms Term[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
}
model Sortie {
id String @id @default(cuid())
totalTime Float
score Float
driverId String
driver Driver @relation(fields: [driverId], references: [id])
carId String
car Car @relation(fields: [carId], references: [id])
gameID String
game Game @relation(fields: [gameID], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([deletedAt])
}
model Comment {
id String @id @default(cuid())
content String
clubId String
club Club @relation(fields: [clubId], references: [id])
driverId String
driver Driver @relation(fields: [driverId], references: [id])
carId String
car Car @relation(fields: [carId], references: [id])
gameId String
game Game @relation(fields: [gameId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([deletedAt])
@@index([clubId])
@@index([gameId])
@@index([driverId])
}
model Taxonomy {
id String @id @default(cuid())
name String @unique
deletedAt DateTime?
createdAt DateTime @default(now())
terms Term[]
@@index([deletedAt])
@@map("taxonomy")
}
model Term {
id String @id @default(cuid())
name String
taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id])
taxonomyId String?
parentId String?
parent Term? @relation("ChildParent", fields: [parentId], references: [id], onDelete: Cascade)
children Term[] @relation("ChildParent")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
createdBy String?
club Club[]
driver Driver[]
car Car[]
game Game[]
@@index([name]) // 对name字段建立索引以加快基于name的查找速度
@@index([parentId]) // 对parentId字段建立索引以加快基于parentId的查找速度
@@map("term")
}

View File

@ -1,6 +1,8 @@
export enum SocketMsgType {
NOTIFY,
}
export enum PostType {
POST = "post",
POST_COMMENT = "post_comment",
@ -62,7 +64,13 @@ export enum ObjectType {
TRAIN_CONTENT = "trainContent",
TRAIN_SITUATION = "trainSituation",
DAILY_TRAIN = "dailyTrainTime",
SYSTEM_LOG = 'system_log'
SYSTEM_LOG = 'system_log',
DEVICE = 'device',
LIBRARY = 'library',
READER = 'reader',
BOOK = 'book',
BORROWRECORD= 'borrowRecord',
USER = "user",
}
export enum RolePerms {
// Create Permissions 创建权限